bs9 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +97 -9
- package/bin/bs9 +58 -7
- package/dist/bs9-064xs9r9. +148 -0
- package/dist/bs9-0gqcrp5t. +144 -0
- package/dist/bs9-nv7nseny. +197 -0
- package/dist/bs9-r6b9zpw0. +156 -0
- package/dist/bs9.js +2 -0
- package/package.json +3 -4
- package/src/commands/deps.ts +295 -0
- package/src/commands/profile.ts +338 -0
- package/src/commands/restart.ts +28 -3
- package/src/commands/start.ts +201 -21
- package/src/commands/status.ts +154 -109
- package/src/commands/stop.ts +31 -3
- package/src/commands/web.ts +29 -3
- package/src/database/pool.ts +335 -0
- package/src/loadbalancer/manager.ts +481 -0
- package/src/macos/launchd.ts +402 -0
- package/src/platform/detect.ts +137 -0
- package/src/windows/service.ts +391 -0
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
import { execSync } from "node:child_process";
|
|
4
|
+
import { setTimeout } from "node:timers/promises";
|
|
5
|
+
|
|
6
|
+
interface ProfileOptions {
|
|
7
|
+
duration?: string;
|
|
8
|
+
output?: string;
|
|
9
|
+
service?: string;
|
|
10
|
+
interval?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface ProfileData {
|
|
14
|
+
timestamp: number;
|
|
15
|
+
cpu: number;
|
|
16
|
+
memory: number;
|
|
17
|
+
heapUsed: number;
|
|
18
|
+
heapTotal: number;
|
|
19
|
+
external: number;
|
|
20
|
+
eventLoopLag: number;
|
|
21
|
+
activeHandles: number;
|
|
22
|
+
activeRequests: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface PerformanceProfile {
|
|
26
|
+
serviceName: string;
|
|
27
|
+
duration: number;
|
|
28
|
+
interval: number;
|
|
29
|
+
samples: ProfileData[];
|
|
30
|
+
summary: {
|
|
31
|
+
avgCpu: number;
|
|
32
|
+
maxCpu: number;
|
|
33
|
+
avgMemory: number;
|
|
34
|
+
maxMemory: number;
|
|
35
|
+
avgHeapUsed: number;
|
|
36
|
+
maxHeapUsed: number;
|
|
37
|
+
avgEventLoopLag: number;
|
|
38
|
+
maxEventLoopLag: number;
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function profileCommand(options: ProfileOptions): Promise<void> {
|
|
43
|
+
const duration = Number(options.duration) || 60; // Default 60 seconds
|
|
44
|
+
const interval = Number(options.interval) || 1000; // Default 1 second
|
|
45
|
+
const serviceName = options.service;
|
|
46
|
+
|
|
47
|
+
if (!serviceName) {
|
|
48
|
+
console.error('❌ Service name is required. Use --service <name>');
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
console.log(`📊 Performance Profiling for Service: ${serviceName}`);
|
|
53
|
+
console.log(`⏱️ Duration: ${duration}s | Interval: ${interval}ms`);
|
|
54
|
+
console.log('='.repeat(80));
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
// Check if service is running
|
|
58
|
+
const serviceStatus = checkServiceStatus(serviceName);
|
|
59
|
+
if (serviceStatus !== 'active') {
|
|
60
|
+
console.error(`❌ Service '${serviceName}' is not running (status: ${serviceStatus})`);
|
|
61
|
+
process.exit(1);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Get service port for metrics
|
|
65
|
+
const port = getServicePort(serviceName);
|
|
66
|
+
if (!port) {
|
|
67
|
+
console.error(`❌ Could not determine port for service '${serviceName}'`);
|
|
68
|
+
process.exit(1);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
console.log(`🔍 Collecting metrics from http://localhost:${port}/metrics`);
|
|
72
|
+
console.log('Press Ctrl+C to stop profiling early\n');
|
|
73
|
+
|
|
74
|
+
const profile = await collectPerformanceData(serviceName, port, duration, interval);
|
|
75
|
+
|
|
76
|
+
// Display results
|
|
77
|
+
displayProfileResults(profile);
|
|
78
|
+
|
|
79
|
+
// Save to file if requested
|
|
80
|
+
if (options.output) {
|
|
81
|
+
await saveProfileData(profile, options.output);
|
|
82
|
+
console.log(`💾 Profile data saved to: ${options.output}`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
} catch (error) {
|
|
86
|
+
console.error(`❌ Failed to profile service: ${error}`);
|
|
87
|
+
process.exit(1);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function checkServiceStatus(serviceName: string): string {
|
|
92
|
+
try {
|
|
93
|
+
const output = execSync(`systemctl --user is-active ${serviceName}`, { encoding: "utf-8" }).trim();
|
|
94
|
+
return output;
|
|
95
|
+
} catch {
|
|
96
|
+
return 'unknown';
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function getServicePort(serviceName: string): number | null {
|
|
101
|
+
try {
|
|
102
|
+
const servicePath = `/home/xarhang/.config/systemd/user/${serviceName}.service`;
|
|
103
|
+
const serviceContent = execSync(`cat ${servicePath}`, { encoding: "utf-8" });
|
|
104
|
+
|
|
105
|
+
// Extract port from environment variables
|
|
106
|
+
const portMatch = serviceContent.match(/Environment=.*PORT=(\d+)/);
|
|
107
|
+
if (portMatch) {
|
|
108
|
+
return Number(portMatch[1]);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Extract port from description
|
|
112
|
+
const descMatch = serviceContent.match(/port[=:]?\s*(\d+)/i);
|
|
113
|
+
if (descMatch) {
|
|
114
|
+
return Number(descMatch[1]);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return null;
|
|
118
|
+
} catch {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function collectPerformanceData(
|
|
124
|
+
serviceName: string,
|
|
125
|
+
port: number,
|
|
126
|
+
duration: number,
|
|
127
|
+
interval: number
|
|
128
|
+
): Promise<PerformanceProfile> {
|
|
129
|
+
const samples: ProfileData[] = [];
|
|
130
|
+
const startTime = Date.now();
|
|
131
|
+
const endTime = startTime + (duration * 1000);
|
|
132
|
+
|
|
133
|
+
while (Date.now() < endTime) {
|
|
134
|
+
try {
|
|
135
|
+
const sample = await collectSample(port);
|
|
136
|
+
samples.push(sample);
|
|
137
|
+
|
|
138
|
+
// Show progress
|
|
139
|
+
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
|
140
|
+
const progress = Math.round((elapsed / duration) * 100);
|
|
141
|
+
process.stdout.write(`\r⏳ Progress: ${elapsed}s/${duration}s (${progress}%) | Samples: ${samples.length}`);
|
|
142
|
+
|
|
143
|
+
await setTimeout(interval);
|
|
144
|
+
} catch (error) {
|
|
145
|
+
console.error(`\n⚠️ Failed to collect sample: ${error}`);
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
console.log('\n'); // New line after progress
|
|
151
|
+
|
|
152
|
+
// Calculate summary
|
|
153
|
+
const summary = calculateSummary(samples);
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
serviceName,
|
|
157
|
+
duration,
|
|
158
|
+
interval,
|
|
159
|
+
samples,
|
|
160
|
+
summary,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function collectSample(port: number): Promise<ProfileData> {
|
|
165
|
+
const response = await fetch(`http://localhost:${port}/metrics`);
|
|
166
|
+
if (!response.ok) {
|
|
167
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const metricsText = await response.text();
|
|
171
|
+
const metrics = parsePrometheusMetrics(metricsText);
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
timestamp: Date.now(),
|
|
175
|
+
cpu: metrics.cpu || 0,
|
|
176
|
+
memory: metrics.memory || 0,
|
|
177
|
+
heapUsed: metrics.heapUsed || 0,
|
|
178
|
+
heapTotal: metrics.heapTotal || 0,
|
|
179
|
+
external: metrics.external || 0,
|
|
180
|
+
eventLoopLag: metrics.eventLoopLag || 0,
|
|
181
|
+
activeHandles: metrics.activeHandles || 0,
|
|
182
|
+
activeRequests: metrics.activeRequests || 0,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function parsePrometheusMetrics(metricsText: string): Record<string, number> {
|
|
187
|
+
const metrics: Record<string, number> = {};
|
|
188
|
+
const lines = metricsText.split('\n');
|
|
189
|
+
|
|
190
|
+
for (const line of lines) {
|
|
191
|
+
if (line.startsWith('#') || !line.trim()) continue;
|
|
192
|
+
|
|
193
|
+
const match = line.match(/^(.+)\s+([\d.]+)$/);
|
|
194
|
+
if (!match) continue;
|
|
195
|
+
|
|
196
|
+
const [, name, value] = match;
|
|
197
|
+
|
|
198
|
+
// Map Prometheus metrics to our internal names
|
|
199
|
+
switch (name) {
|
|
200
|
+
case 'process_cpu_seconds_total':
|
|
201
|
+
metrics.cpu = parseFloat(value);
|
|
202
|
+
break;
|
|
203
|
+
case 'process_resident_memory_bytes':
|
|
204
|
+
metrics.memory = parseFloat(value);
|
|
205
|
+
break;
|
|
206
|
+
case 'nodejs_heap_size_used_bytes':
|
|
207
|
+
metrics.heapUsed = parseFloat(value);
|
|
208
|
+
break;
|
|
209
|
+
case 'nodejs_heap_size_total_bytes':
|
|
210
|
+
metrics.heapTotal = parseFloat(value);
|
|
211
|
+
break;
|
|
212
|
+
case 'nodejs_external_memory_bytes':
|
|
213
|
+
metrics.external = parseFloat(value);
|
|
214
|
+
break;
|
|
215
|
+
case 'nodejs_eventloop_lag_seconds':
|
|
216
|
+
metrics.eventLoopLag = parseFloat(value) * 1000; // Convert to ms
|
|
217
|
+
break;
|
|
218
|
+
case 'nodejs_active_handles_total':
|
|
219
|
+
metrics.activeHandles = parseFloat(value);
|
|
220
|
+
break;
|
|
221
|
+
case 'nodejs_active_requests_total':
|
|
222
|
+
metrics.activeRequests = parseFloat(value);
|
|
223
|
+
break;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return metrics;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function calculateSummary(samples: ProfileData[]): PerformanceProfile['summary'] {
|
|
231
|
+
if (samples.length === 0) {
|
|
232
|
+
return {
|
|
233
|
+
avgCpu: 0, maxCpu: 0,
|
|
234
|
+
avgMemory: 0, maxMemory: 0,
|
|
235
|
+
avgHeapUsed: 0, maxHeapUsed: 0,
|
|
236
|
+
avgEventLoopLag: 0, maxEventLoopLag: 0,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const sum = samples.reduce((acc, sample) => ({
|
|
241
|
+
cpu: acc.cpu + sample.cpu,
|
|
242
|
+
memory: acc.memory + sample.memory,
|
|
243
|
+
heapUsed: acc.heapUsed + sample.heapUsed,
|
|
244
|
+
eventLoopLag: acc.eventLoopLag + sample.eventLoopLag,
|
|
245
|
+
}), { cpu: 0, memory: 0, heapUsed: 0, eventLoopLag: 0 });
|
|
246
|
+
|
|
247
|
+
const count = samples.length;
|
|
248
|
+
|
|
249
|
+
return {
|
|
250
|
+
avgCpu: sum.cpu / count,
|
|
251
|
+
maxCpu: Math.max(...samples.map(s => s.cpu)),
|
|
252
|
+
avgMemory: sum.memory / count,
|
|
253
|
+
maxMemory: Math.max(...samples.map(s => s.memory)),
|
|
254
|
+
avgHeapUsed: sum.heapUsed / count,
|
|
255
|
+
maxHeapUsed: Math.max(...samples.map(s => s.heapUsed)),
|
|
256
|
+
avgEventLoopLag: sum.eventLoopLag / count,
|
|
257
|
+
maxEventLoopLag: Math.max(...samples.map(s => s.eventLoopLag)),
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function displayProfileResults(profile: PerformanceProfile): void {
|
|
262
|
+
console.log('\n📊 Performance Profile Results');
|
|
263
|
+
console.log('='.repeat(80));
|
|
264
|
+
|
|
265
|
+
console.log(`\n🔧 Service: ${profile.serviceName}`);
|
|
266
|
+
console.log(`⏱️ Duration: ${profile.duration}s | Samples: ${profile.samples.length}`);
|
|
267
|
+
console.log(`📏 Interval: ${profile.interval}ms`);
|
|
268
|
+
|
|
269
|
+
console.log('\n💾 Memory Usage:');
|
|
270
|
+
console.log(` Average: ${formatBytes(profile.summary.avgMemory)} | Peak: ${formatBytes(profile.summary.maxMemory)}`);
|
|
271
|
+
console.log(` Heap Avg: ${formatBytes(profile.summary.avgHeapUsed)} | Heap Peak: ${formatBytes(profile.summary.maxHeapUsed)}`);
|
|
272
|
+
console.log(` External: ${formatBytes(profile.samples[0]?.external || 0)}`);
|
|
273
|
+
|
|
274
|
+
console.log('\n⚡ Performance:');
|
|
275
|
+
console.log(` CPU Avg: ${profile.summary.avgCpu.toFixed(2)}s | Peak: ${profile.summary.maxCpu.toFixed(2)}s`);
|
|
276
|
+
console.log(` Event Loop Avg: ${profile.summary.avgEventLoopLag.toFixed(2)}ms | Peak: ${profile.summary.maxEventLoopLag.toFixed(2)}ms`);
|
|
277
|
+
|
|
278
|
+
console.log('\n🔗 Resources:');
|
|
279
|
+
const lastSample = profile.samples[profile.samples.length - 1];
|
|
280
|
+
if (lastSample) {
|
|
281
|
+
console.log(` Active Handles: ${lastSample.activeHandles} | Active Requests: ${lastSample.activeRequests}`);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Performance recommendations
|
|
285
|
+
console.log('\n💡 Performance Insights:');
|
|
286
|
+
|
|
287
|
+
if (profile.summary.maxMemory > 500 * 1024 * 1024) { // > 500MB
|
|
288
|
+
console.log(' ⚠️ High memory usage detected - consider memory optimization');
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (profile.summary.maxEventLoopLag > 100) { // > 100ms
|
|
292
|
+
console.log(' ⚠️ High event loop lag detected - consider optimizing async operations');
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (profile.samples.length > 0) {
|
|
296
|
+
const firstSample = profile.samples[0];
|
|
297
|
+
if (firstSample.heapTotal > 0 && profile.summary.avgHeapUsed / firstSample.heapTotal > 0.8) { // > 80%
|
|
298
|
+
console.log(' ⚠️ High heap usage ratio - potential memory leak');
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (profile.samples.length < profile.duration * 1000 / profile.interval * 0.9) {
|
|
303
|
+
console.log(' ⚠️ Some samples failed to collect - check service health');
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
console.log('\n📈 Sample Timeline (last 10 samples):');
|
|
307
|
+
const recentSamples = profile.samples.slice(-10);
|
|
308
|
+
for (let i = 0; i < recentSamples.length; i++) {
|
|
309
|
+
const sample = recentSamples[i];
|
|
310
|
+
const time = new Date(sample.timestamp).toLocaleTimeString();
|
|
311
|
+
console.log(` ${time} | CPU: ${sample.cpu.toFixed(2)}s | Memory: ${formatBytes(sample.memory)} | Heap: ${formatBytes(sample.heapUsed)}`);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function formatBytes(bytes: number): string {
|
|
316
|
+
if (bytes === 0) return '0B';
|
|
317
|
+
const k = 1024;
|
|
318
|
+
const sizes = ['B', 'KB', 'MB', 'GB'];
|
|
319
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
320
|
+
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))}${sizes[i]}`;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async function saveProfileData(profile: PerformanceProfile, filename: string): Promise<void> {
|
|
324
|
+
const data = {
|
|
325
|
+
metadata: {
|
|
326
|
+
serviceName: profile.serviceName,
|
|
327
|
+
duration: profile.duration,
|
|
328
|
+
interval: profile.interval,
|
|
329
|
+
sampleCount: profile.samples.length,
|
|
330
|
+
timestamp: new Date().toISOString(),
|
|
331
|
+
},
|
|
332
|
+
summary: profile.summary,
|
|
333
|
+
samples: profile.samples,
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
const json = JSON.stringify(data, null, 2);
|
|
337
|
+
await Bun.write(filename, json);
|
|
338
|
+
}
|
package/src/commands/restart.ts
CHANGED
|
@@ -1,13 +1,38 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
|
|
3
3
|
import { execSync } from "node:child_process";
|
|
4
|
+
import { getPlatformInfo } from "../platform/detect.js";
|
|
5
|
+
|
|
6
|
+
// Security: Service name validation
|
|
7
|
+
function isValidServiceName(name: string): boolean {
|
|
8
|
+
const validPattern = /^[a-zA-Z0-9._-]+$/;
|
|
9
|
+
return validPattern.test(name) && name.length <= 64 && !name.includes('..') && !name.includes('/');
|
|
10
|
+
}
|
|
4
11
|
|
|
5
12
|
export async function restartCommand(name: string): Promise<void> {
|
|
13
|
+
// Security: Validate service name
|
|
14
|
+
if (!isValidServiceName(name)) {
|
|
15
|
+
console.error(`❌ Security: Invalid service name: ${name}`);
|
|
16
|
+
process.exit(1);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const platformInfo = getPlatformInfo();
|
|
20
|
+
|
|
6
21
|
try {
|
|
7
|
-
|
|
8
|
-
|
|
22
|
+
if (platformInfo.isLinux) {
|
|
23
|
+
// Security: Use shell escaping to prevent injection
|
|
24
|
+
const escapedName = name.replace(/[^a-zA-Z0-9._-]/g, '');
|
|
25
|
+
execSync(`systemctl --user restart "${escapedName}"`, { stdio: "inherit" });
|
|
26
|
+
console.log(`🔄 User service '${name}' restarted`);
|
|
27
|
+
} else if (platformInfo.isMacOS) {
|
|
28
|
+
const { launchdCommand } = await import("../macos/launchd.js");
|
|
29
|
+
await launchdCommand('restart', { name: `bs9.${name}` });
|
|
30
|
+
} else if (platformInfo.isWindows) {
|
|
31
|
+
const { windowsCommand } = await import("../windows/service.js");
|
|
32
|
+
await windowsCommand('restart', { name: `BS9_${name}` });
|
|
33
|
+
}
|
|
9
34
|
} catch (err) {
|
|
10
|
-
console.error(`❌ Failed to restart
|
|
35
|
+
console.error(`❌ Failed to restart service '${name}': ${err}`);
|
|
11
36
|
process.exit(1);
|
|
12
37
|
}
|
|
13
38
|
}
|
package/src/commands/start.ts
CHANGED
|
@@ -6,34 +6,101 @@ import { execSync } from "node:child_process";
|
|
|
6
6
|
import { randomUUID } from "node:crypto";
|
|
7
7
|
import { writeFileSync, mkdirSync } from "node:fs";
|
|
8
8
|
import { homedir } from "node:os";
|
|
9
|
+
import { getPlatformInfo } from "../platform/detect.js";
|
|
10
|
+
|
|
11
|
+
// Security: Host validation function
|
|
12
|
+
function isValidHost(host: string): boolean {
|
|
13
|
+
// Allow localhost, 0.0.0.0, and valid IP addresses
|
|
14
|
+
const localhostRegex = /^(localhost|127\.0\.0\.1|::1)$/;
|
|
15
|
+
const anyIPRegex = /^(0\.0\.0\.0|::)$/;
|
|
16
|
+
const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/;
|
|
17
|
+
const ipv6Regex = /^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$/;
|
|
18
|
+
const hostnameRegex = /^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*$/;
|
|
19
|
+
|
|
20
|
+
if (localhostRegex.test(host) || anyIPRegex.test(host)) {
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (ipv4Regex.test(host)) {
|
|
25
|
+
const parts = host.split('.');
|
|
26
|
+
return parts.every(part => {
|
|
27
|
+
const num = parseInt(part, 10);
|
|
28
|
+
return num >= 0 && num <= 255;
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (ipv6Regex.test(host)) {
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (hostnameRegex.test(host) && host.length <= 253) {
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
9
42
|
|
|
10
43
|
interface StartOptions {
|
|
11
44
|
name?: string;
|
|
12
45
|
port?: string;
|
|
46
|
+
host?: string;
|
|
13
47
|
env?: string[];
|
|
14
48
|
otel?: boolean;
|
|
15
49
|
prometheus?: boolean;
|
|
16
50
|
build?: boolean;
|
|
51
|
+
https?: boolean;
|
|
17
52
|
}
|
|
18
53
|
|
|
19
54
|
export async function startCommand(file: string, options: StartOptions): Promise<void> {
|
|
55
|
+
const platformInfo = getPlatformInfo();
|
|
56
|
+
|
|
57
|
+
// Security: Validate and sanitize file path
|
|
20
58
|
const fullPath = resolve(file);
|
|
21
59
|
if (!existsSync(fullPath)) {
|
|
22
60
|
console.error(`❌ File not found: ${fullPath}`);
|
|
23
61
|
process.exit(1);
|
|
24
62
|
}
|
|
25
|
-
|
|
26
|
-
|
|
63
|
+
|
|
64
|
+
// Security: Prevent directory traversal and ensure file is within allowed paths
|
|
65
|
+
const allowedPaths = [process.cwd(), homedir()];
|
|
66
|
+
const isAllowedPath = allowedPaths.some(allowed => fullPath.startsWith(allowed));
|
|
67
|
+
if (!isAllowedPath) {
|
|
68
|
+
console.error(`❌ Security: File path outside allowed directories: ${fullPath}`);
|
|
69
|
+
process.exit(1);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Security: Validate and sanitize service name
|
|
73
|
+
const rawServiceName = options.name || basename(fullPath, fullPath.endsWith('.ts') ? '.ts' : '.js');
|
|
74
|
+
const serviceName = rawServiceName.replace(/[^a-zA-Z0-9-_]/g, "_").replace(/^[^a-zA-Z]/, "_").substring(0, 64);
|
|
75
|
+
|
|
76
|
+
// Security: Validate port number
|
|
27
77
|
const port = options.port || "3000";
|
|
78
|
+
const portNum = Number(port);
|
|
79
|
+
if (isNaN(portNum) || portNum < 1 || portNum > 65535) {
|
|
80
|
+
console.error(`❌ Security: Invalid port number: ${port}. Must be 1-65535`);
|
|
81
|
+
process.exit(1);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Security: Validate host
|
|
85
|
+
const host = options.host || "localhost";
|
|
86
|
+
if (!isValidHost(host)) {
|
|
87
|
+
console.error(`❌ Security: Invalid host: ${host}`);
|
|
88
|
+
process.exit(1);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const protocol = options.https ? "https" : "http";
|
|
28
92
|
|
|
29
93
|
// Port warning for privileged ports
|
|
30
|
-
const portNum = Number(port);
|
|
31
94
|
if (portNum < 1024) {
|
|
32
95
|
console.warn(`⚠️ Port ${port} is privileged (< 1024).`);
|
|
33
96
|
console.warn(" Options:");
|
|
34
97
|
console.warn(" - Use port >= 1024 (recommended)");
|
|
35
|
-
|
|
36
|
-
|
|
98
|
+
if (platformInfo.isWindows) {
|
|
99
|
+
console.warn(" - Run as Administrator (not recommended)");
|
|
100
|
+
} else {
|
|
101
|
+
console.warn(" - Run with sudo (not recommended for user services)");
|
|
102
|
+
console.warn(" - Use port forwarding: `sudo iptables -t nat -A PREROUTING -p tcp --dport 80 -j REDIRECT --to-port 3000`");
|
|
103
|
+
}
|
|
37
104
|
}
|
|
38
105
|
|
|
39
106
|
// Handle TypeScript files and build option
|
|
@@ -77,43 +144,137 @@ export async function startCommand(file: string, options: StartOptions): Promise
|
|
|
77
144
|
auditResult.warning.forEach(issue => console.warn(` - ${issue}`));
|
|
78
145
|
}
|
|
79
146
|
|
|
147
|
+
// Platform-specific service creation
|
|
148
|
+
if (platformInfo.isLinux) {
|
|
149
|
+
await createLinuxService(serviceName, execPath, host, port, protocol, options);
|
|
150
|
+
} else if (platformInfo.isMacOS) {
|
|
151
|
+
await createMacOSService(serviceName, execPath, host, port, protocol, options);
|
|
152
|
+
} else if (platformInfo.isWindows) {
|
|
153
|
+
await createWindowsService(serviceName, execPath, host, port, protocol, options);
|
|
154
|
+
} else {
|
|
155
|
+
console.error(`❌ Platform ${platformInfo.platform} is not supported`);
|
|
156
|
+
process.exit(1);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function createLinuxService(serviceName: string, execPath: string, host: string, port: string, protocol: string, options: StartOptions): Promise<void> {
|
|
80
161
|
// Phase 1: Generate hardened systemd unit
|
|
81
162
|
const unitContent = generateSystemdUnit({
|
|
82
163
|
serviceName,
|
|
83
164
|
fullPath: execPath,
|
|
165
|
+
host,
|
|
84
166
|
port,
|
|
167
|
+
protocol,
|
|
85
168
|
env: options.env || [],
|
|
86
169
|
otel: options.otel ?? true,
|
|
87
170
|
prometheus: options.prometheus ?? true,
|
|
88
171
|
});
|
|
89
172
|
|
|
90
|
-
const
|
|
173
|
+
const platformInfo = getPlatformInfo();
|
|
174
|
+
const unitPath = join(platformInfo.serviceDir, `${serviceName}.service`);
|
|
91
175
|
|
|
92
176
|
// Create user systemd directory if it doesn't exist
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
console.log(`📁 Created user systemd directory: ${userSystemdDir}`);
|
|
177
|
+
if (!existsSync(platformInfo.serviceDir)) {
|
|
178
|
+
mkdirSync(platformInfo.serviceDir, { recursive: true });
|
|
179
|
+
console.log(`📁 Created user systemd directory: ${platformInfo.serviceDir}`);
|
|
97
180
|
}
|
|
98
181
|
|
|
99
182
|
try {
|
|
100
|
-
writeFileSync(unitPath, unitContent
|
|
183
|
+
writeFileSync(unitPath, unitContent);
|
|
101
184
|
console.log(`✅ Systemd user unit written to: ${unitPath}`);
|
|
102
|
-
|
|
103
|
-
|
|
185
|
+
|
|
186
|
+
execSync("systemctl --user daemon-reload");
|
|
187
|
+
execSync(`systemctl --user enable ${serviceName}`);
|
|
188
|
+
execSync(`systemctl --user start ${serviceName}`);
|
|
189
|
+
|
|
190
|
+
console.log(`🚀 Service '${serviceName}' started successfully`);
|
|
191
|
+
console.log(` Health: ${protocol}://${host}:${port}/healthz`);
|
|
192
|
+
console.log(` Metrics: ${protocol}://${host}:${port}/metrics`);
|
|
193
|
+
} catch (error) {
|
|
194
|
+
console.error(`❌ Failed to start service: ${error}`);
|
|
104
195
|
process.exit(1);
|
|
105
196
|
}
|
|
197
|
+
}
|
|
106
198
|
|
|
107
|
-
|
|
199
|
+
async function createMacOSService(serviceName: string, execPath: string, host: string, port: string, protocol: string, options: StartOptions): Promise<void> {
|
|
200
|
+
const { launchdCommand } = await import("../macos/launchd.js");
|
|
201
|
+
|
|
202
|
+
const envVars: Record<string, string> = {
|
|
203
|
+
PORT: port,
|
|
204
|
+
HOST: host,
|
|
205
|
+
PROTOCOL: protocol,
|
|
206
|
+
NODE_ENV: "production",
|
|
207
|
+
SERVICE_NAME: serviceName,
|
|
208
|
+
...(options.env || []).reduce((acc, env) => {
|
|
209
|
+
const [key, value] = env.split('=');
|
|
210
|
+
if (key && value) acc[key] = value;
|
|
211
|
+
return acc;
|
|
212
|
+
}, {} as Record<string, string>)
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
if (options.otel) {
|
|
216
|
+
envVars.OTEL_SERVICE_NAME = serviceName;
|
|
217
|
+
envVars.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT = "http://localhost:4318/v1/traces";
|
|
218
|
+
}
|
|
219
|
+
|
|
108
220
|
try {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
221
|
+
await launchdCommand('create', {
|
|
222
|
+
name: `bs9.${serviceName}`,
|
|
223
|
+
file: execPath,
|
|
224
|
+
workingDir: dirname(execPath),
|
|
225
|
+
env: JSON.stringify(envVars),
|
|
226
|
+
autoStart: true,
|
|
227
|
+
keepAlive: true,
|
|
228
|
+
logOut: `${getPlatformInfo().logDir}/${serviceName}.out.log`,
|
|
229
|
+
logErr: `${getPlatformInfo().logDir}/${serviceName}.err.log`
|
|
230
|
+
});
|
|
231
|
+
|
|
112
232
|
console.log(`🚀 Service '${serviceName}' started successfully`);
|
|
113
|
-
console.log(` Health:
|
|
114
|
-
console.log(` Metrics:
|
|
115
|
-
} catch (
|
|
116
|
-
console.error(`❌ Failed to start
|
|
233
|
+
console.log(` Health: ${protocol}://${host}:${port}/healthz`);
|
|
234
|
+
console.log(` Metrics: ${protocol}://${host}:${port}/metrics`);
|
|
235
|
+
} catch (error) {
|
|
236
|
+
console.error(`❌ Failed to start macOS service: ${error}`);
|
|
237
|
+
process.exit(1);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function createWindowsService(serviceName: string, execPath: string, host: string, port: string, protocol: string, options: StartOptions): Promise<void> {
|
|
242
|
+
const { windowsCommand } = await import("../windows/service.js");
|
|
243
|
+
|
|
244
|
+
const envVars: Record<string, string> = {
|
|
245
|
+
PORT: port,
|
|
246
|
+
HOST: host,
|
|
247
|
+
PROTOCOL: protocol,
|
|
248
|
+
NODE_ENV: "production",
|
|
249
|
+
SERVICE_NAME: serviceName,
|
|
250
|
+
...(options.env || []).reduce((acc, env) => {
|
|
251
|
+
const [key, value] = env.split('=');
|
|
252
|
+
if (key && value) acc[key] = value;
|
|
253
|
+
return acc;
|
|
254
|
+
}, {} as Record<string, string>)
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
if (options.otel) {
|
|
258
|
+
envVars.OTEL_SERVICE_NAME = serviceName;
|
|
259
|
+
envVars.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT = "http://localhost:4318/v1/traces";
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
try {
|
|
263
|
+
await windowsCommand('create', {
|
|
264
|
+
name: `BS9_${serviceName}`,
|
|
265
|
+
file: execPath,
|
|
266
|
+
displayName: `BS9 Service: ${serviceName}`,
|
|
267
|
+
description: `BS9 managed service: ${serviceName}`,
|
|
268
|
+
workingDir: dirname(execPath),
|
|
269
|
+
args: ['run', execPath],
|
|
270
|
+
env: JSON.stringify(envVars)
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
console.log(`🚀 Service '${serviceName}' started successfully`);
|
|
274
|
+
console.log(` Health: ${protocol}://${host}:${port}/healthz`);
|
|
275
|
+
console.log(` Metrics: ${protocol}://${host}:${port}/metrics`);
|
|
276
|
+
} catch (error) {
|
|
277
|
+
console.error(`❌ Failed to start Windows service: ${error}`);
|
|
117
278
|
process.exit(1);
|
|
118
279
|
}
|
|
119
280
|
}
|
|
@@ -140,6 +301,9 @@ async function securityAudit(filePath: string): Promise<SecurityAuditResult> {
|
|
|
140
301
|
{ pattern: /child_process\.exec\s*\(/, msg: "Unsafe child_process.exec() detected" },
|
|
141
302
|
{ pattern: /require\s*\(\s*["']fs["']\s*\)/, msg: "Direct fs module usage (potential file system access)" },
|
|
142
303
|
{ pattern: /process\.env\.\w+\s*\+\s*["']/, msg: "Potential command injection via env concatenation" },
|
|
304
|
+
{ pattern: /require\s*\(\s*["']child_process["']\s*\)/, msg: "Child process module usage detected" },
|
|
305
|
+
{ pattern: /spawn\s*\(/, msg: "Process spawning detected" },
|
|
306
|
+
{ pattern: /execSync\s*\(/, msg: "Synchronous execution detected" },
|
|
143
307
|
];
|
|
144
308
|
|
|
145
309
|
for (const { pattern, msg } of dangerousPatterns) {
|
|
@@ -164,7 +328,9 @@ async function securityAudit(filePath: string): Promise<SecurityAuditResult> {
|
|
|
164
328
|
interface SystemdUnitOptions {
|
|
165
329
|
serviceName: string;
|
|
166
330
|
fullPath: string;
|
|
331
|
+
host: string;
|
|
167
332
|
port: string;
|
|
333
|
+
protocol: string;
|
|
168
334
|
env: string[];
|
|
169
335
|
otel: boolean;
|
|
170
336
|
prometheus: boolean;
|
|
@@ -173,6 +339,8 @@ interface SystemdUnitOptions {
|
|
|
173
339
|
function generateSystemdUnit(opts: SystemdUnitOptions): string {
|
|
174
340
|
const envVars = [
|
|
175
341
|
`PORT=${opts.port}`,
|
|
342
|
+
`HOST=${opts.host}`,
|
|
343
|
+
`PROTOCOL=${opts.protocol}`,
|
|
176
344
|
`NODE_ENV=production`,
|
|
177
345
|
`SERVICE_NAME=${opts.serviceName}`,
|
|
178
346
|
...opts.env,
|
|
@@ -190,6 +358,7 @@ function generateSystemdUnit(opts: SystemdUnitOptions): string {
|
|
|
190
358
|
return `[Unit]
|
|
191
359
|
Description=BS9 Service: ${opts.serviceName}
|
|
192
360
|
After=network.target
|
|
361
|
+
Documentation=https://github.com/bs9/bs9
|
|
193
362
|
|
|
194
363
|
[Service]
|
|
195
364
|
Type=simple
|
|
@@ -201,6 +370,17 @@ WorkingDirectory=${workingDir}
|
|
|
201
370
|
ExecStart=${bunPath} run ${opts.fullPath}
|
|
202
371
|
${envSection}
|
|
203
372
|
|
|
373
|
+
# Security hardening (user systemd compatible)
|
|
374
|
+
PrivateTmp=true
|
|
375
|
+
ProtectSystem=strict
|
|
376
|
+
ProtectHome=true
|
|
377
|
+
ReadWritePaths=${workingDir}
|
|
378
|
+
UMask=0022
|
|
379
|
+
|
|
380
|
+
# Resource limits
|
|
381
|
+
LimitNOFILE=65536
|
|
382
|
+
LimitNPROC=4096
|
|
383
|
+
|
|
204
384
|
[Install]
|
|
205
385
|
WantedBy=default.target
|
|
206
386
|
`;
|