carom-link 1.0.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 +221 -0
- package/bin/carom.js +2 -0
- package/package.json +46 -0
- package/public/app.js +519 -0
- package/public/index.html +233 -0
- package/public/style.css +756 -0
- package/src/cli/commands/add.js +106 -0
- package/src/cli/commands/config.js +95 -0
- package/src/cli/commands/install.js +50 -0
- package/src/cli/commands/list.js +62 -0
- package/src/cli/commands/logs.js +70 -0
- package/src/cli/commands/remove.js +36 -0
- package/src/cli/commands/rules.js +168 -0
- package/src/cli/commands/start.js +43 -0
- package/src/cli/commands/stats.js +86 -0
- package/src/cli/commands/status.js +89 -0
- package/src/cli/commands/uninstall.js +28 -0
- package/src/cli/formatters.js +132 -0
- package/src/cli/index.js +45 -0
- package/src/cloak/detector.js +243 -0
- package/src/cloak/ipLookup.js +146 -0
- package/src/cloak/patterns.js +160 -0
- package/src/cloak/safePage.js +146 -0
- package/src/cloak/tokens.js +67 -0
- package/src/config.js +152 -0
- package/src/constants.js +78 -0
- package/src/db.js +256 -0
- package/src/server/app.js +110 -0
- package/src/server/routes/api.js +268 -0
- package/src/server/routes/redirect.js +141 -0
- package/src/server/server.js +117 -0
- package/src/service/launchd.js +166 -0
- package/src/service/manager.js +79 -0
- package/src/service/systemd.js +147 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import { createHash } from 'crypto';
|
|
3
|
+
import { findBySlug, logClick } from '../../db.js';
|
|
4
|
+
import { scoreRequest } from '../../cloak/detector.js';
|
|
5
|
+
import { generateToken } from '../../cloak/tokens.js';
|
|
6
|
+
import { generateSafePage, generateInterstitialPage } from '../../cloak/safePage.js';
|
|
7
|
+
import { getClientIp } from '../../cloak/ipLookup.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Create the redirect router.
|
|
11
|
+
*/
|
|
12
|
+
export function createRedirectRouter(config, db) {
|
|
13
|
+
const router = Router();
|
|
14
|
+
|
|
15
|
+
router.get('/:slug', async (req, res) => {
|
|
16
|
+
try {
|
|
17
|
+
const { slug } = req.params;
|
|
18
|
+
|
|
19
|
+
// Skip favicon and other common paths
|
|
20
|
+
if (slug === 'favicon.ico' || slug === 'robots.txt' || slug === 'sitemap.xml') {
|
|
21
|
+
return res.status(404).end();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Look up the link
|
|
25
|
+
const link = findBySlug(db, slug);
|
|
26
|
+
if (!link) {
|
|
27
|
+
return res.status(404).json({ error: 'Link not found' });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Check expiration
|
|
31
|
+
if (link.expires_at && new Date(link.expires_at) < new Date()) {
|
|
32
|
+
return res.status(410).json({ error: 'Link has expired' });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const ip = getClientIp(req);
|
|
36
|
+
const ipHash = createHash('sha256').update(ip + 'carom-salt').digest('hex').substring(0, 16);
|
|
37
|
+
const userAgent = req.headers['user-agent'] || '';
|
|
38
|
+
const referer = req.headers['referer'] || '';
|
|
39
|
+
|
|
40
|
+
// ── Safe page request (from interstitial fallback) ──
|
|
41
|
+
if (req.query._safe === '1') {
|
|
42
|
+
logClick(db, {
|
|
43
|
+
linkId: link.id,
|
|
44
|
+
userAgent,
|
|
45
|
+
ipHash,
|
|
46
|
+
referer,
|
|
47
|
+
isBot: true,
|
|
48
|
+
botScore: 100,
|
|
49
|
+
botSignals: ['interstitial_fallback'],
|
|
50
|
+
classification: 'bot',
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const html = generateSafePage({
|
|
54
|
+
title: config.shield?.safePage?.title,
|
|
55
|
+
description: config.shield?.safePage?.description,
|
|
56
|
+
brand: config.shield?.safePage?.brand,
|
|
57
|
+
url: `/${slug}`,
|
|
58
|
+
});
|
|
59
|
+
return res.status(200).type('html').send(html);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ── Bot protection disabled — straight redirect ──
|
|
63
|
+
if (!config.shield?.enabled) {
|
|
64
|
+
logClick(db, {
|
|
65
|
+
linkId: link.id,
|
|
66
|
+
userAgent,
|
|
67
|
+
ipHash,
|
|
68
|
+
referer,
|
|
69
|
+
isBot: false,
|
|
70
|
+
botScore: 0,
|
|
71
|
+
botSignals: [],
|
|
72
|
+
classification: 'human',
|
|
73
|
+
});
|
|
74
|
+
return res.redirect(301, link.destination_url);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ── Run the bot detection engine ──
|
|
78
|
+
const result = await scoreRequest(req, {
|
|
79
|
+
linkCreatedAt: link.created_at,
|
|
80
|
+
slug,
|
|
81
|
+
config,
|
|
82
|
+
db,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// Log the click
|
|
86
|
+
logClick(db, {
|
|
87
|
+
linkId: link.id,
|
|
88
|
+
userAgent,
|
|
89
|
+
ipHash,
|
|
90
|
+
referer,
|
|
91
|
+
isBot: result.isBot,
|
|
92
|
+
botScore: result.score,
|
|
93
|
+
botSignals: result.signals.map(s => s.name),
|
|
94
|
+
classification: result.classification,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// ── Bot detected — serve safe page ──
|
|
98
|
+
if (result.isBot) {
|
|
99
|
+
if (config.verbose) {
|
|
100
|
+
console.log(`[carom] 🛡 BOT GET /${slug} score=${result.score} ua="${userAgent.substring(0, 60)}" → safe page`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const html = generateSafePage({
|
|
104
|
+
title: config.shield?.safePage?.title,
|
|
105
|
+
description: config.shield?.safePage?.description,
|
|
106
|
+
brand: config.shield?.safePage?.brand,
|
|
107
|
+
url: `/${slug}`,
|
|
108
|
+
});
|
|
109
|
+
return res.status(200).type('html').send(html);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ── Interstitial mode — serve JS challenge ──
|
|
113
|
+
if (config.shield?.mode === 'interstitial' && !req.query._t) {
|
|
114
|
+
if (config.verbose) {
|
|
115
|
+
console.log(`[carom] 🔒 CHALLENGE GET /${slug} score=${result.score} → interstitial`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const token = generateToken(slug);
|
|
119
|
+
const html = generateInterstitialPage({
|
|
120
|
+
slug,
|
|
121
|
+
token,
|
|
122
|
+
destinationUrl: link.destination_url,
|
|
123
|
+
brand: config.shield?.safePage?.brand,
|
|
124
|
+
});
|
|
125
|
+
return res.status(200).type('html').send(html);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ── Human — redirect ──
|
|
129
|
+
if (config.verbose) {
|
|
130
|
+
console.log(`[carom] 👤 HUMAN GET /${slug} score=${result.score} → 301 redirect`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return res.redirect(301, link.destination_url);
|
|
134
|
+
} catch (err) {
|
|
135
|
+
console.error('[carom] Redirect error:', err);
|
|
136
|
+
res.status(500).json({ error: 'Internal server error' });
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
return router;
|
|
141
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { createRedirectApp, createAdminApp } from './app.js';
|
|
2
|
+
import { getDb, closeDb } from '../db.js';
|
|
3
|
+
import { writeFileSync, unlinkSync, mkdirSync } from 'fs';
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
import { DEFAULT_DATA_DIR, PID_FILENAME, LOG_DIR } from '../constants.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Start the carom servers (redirect + admin).
|
|
9
|
+
*
|
|
10
|
+
* @param {object} config - Full config object
|
|
11
|
+
* @param {object} options - Additional options
|
|
12
|
+
* @param {string} options.dataDir - Data directory
|
|
13
|
+
* @param {boolean} options.verbose - Verbose logging
|
|
14
|
+
* @returns {Promise<{ redirectApp, adminApp, redirectServer, adminServer, db }>}
|
|
15
|
+
*/
|
|
16
|
+
export async function startServer(config, options = {}) {
|
|
17
|
+
const dataDir = options.dataDir || process.env.CAROM_DATA_DIR || DEFAULT_DATA_DIR;
|
|
18
|
+
const port = config.port || 3000;
|
|
19
|
+
const adminPort = config.adminPort || 3001;
|
|
20
|
+
const host = config.host || 'localhost';
|
|
21
|
+
|
|
22
|
+
// Ensure data & log directories exist
|
|
23
|
+
mkdirSync(dataDir, { recursive: true });
|
|
24
|
+
mkdirSync(join(dataDir, LOG_DIR), { recursive: true });
|
|
25
|
+
|
|
26
|
+
// Initialize database
|
|
27
|
+
const db = getDb(dataDir);
|
|
28
|
+
|
|
29
|
+
// Build full config with runtime fields
|
|
30
|
+
const fullConfig = { ...config, verbose: options.verbose || false, _dataDir: dataDir };
|
|
31
|
+
|
|
32
|
+
// Create both Express apps
|
|
33
|
+
const redirectApp = createRedirectApp(fullConfig, db);
|
|
34
|
+
const adminApp = createAdminApp(fullConfig, db);
|
|
35
|
+
|
|
36
|
+
// Start both servers
|
|
37
|
+
return new Promise((resolve, reject) => {
|
|
38
|
+
let redirectReady = false;
|
|
39
|
+
let adminReady = false;
|
|
40
|
+
|
|
41
|
+
const checkReady = () => {
|
|
42
|
+
if (redirectReady && adminReady) {
|
|
43
|
+
// Write PID file
|
|
44
|
+
const pidPath = join(dataDir, PID_FILENAME);
|
|
45
|
+
try {
|
|
46
|
+
writeFileSync(pidPath, String(process.pid));
|
|
47
|
+
} catch {
|
|
48
|
+
// Non-fatal
|
|
49
|
+
}
|
|
50
|
+
resolve({ redirectApp, adminApp, redirectServer, adminServer, db });
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// ── Redirect server (public) ──
|
|
55
|
+
const redirectServer = redirectApp.listen(port, host, () => {
|
|
56
|
+
redirectReady = true;
|
|
57
|
+
checkReady();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
redirectServer.on('error', (err) => {
|
|
61
|
+
if (err.code === 'EADDRINUSE') {
|
|
62
|
+
reject(new Error(`Redirect port ${port} is already in use. Use --port to specify a different port.`));
|
|
63
|
+
} else {
|
|
64
|
+
reject(err);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// ── Admin server (dashboard + API) ──
|
|
69
|
+
const adminServer = adminApp.listen(adminPort, host, () => {
|
|
70
|
+
adminReady = true;
|
|
71
|
+
checkReady();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
adminServer.on('error', (err) => {
|
|
75
|
+
if (err.code === 'EADDRINUSE') {
|
|
76
|
+
reject(new Error(`Admin port ${adminPort} is already in use. Use --admin-port to specify a different port.`));
|
|
77
|
+
} else {
|
|
78
|
+
reject(err);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// Graceful shutdown — close both servers
|
|
83
|
+
const shutdown = (signal) => {
|
|
84
|
+
console.log(`\n[carom] Received ${signal}, shutting down...`);
|
|
85
|
+
|
|
86
|
+
let closed = 0;
|
|
87
|
+
const onClose = () => {
|
|
88
|
+
closed++;
|
|
89
|
+
if (closed >= 2) {
|
|
90
|
+
closeDb();
|
|
91
|
+
|
|
92
|
+
// Remove PID file
|
|
93
|
+
try {
|
|
94
|
+
unlinkSync(join(dataDir, PID_FILENAME));
|
|
95
|
+
} catch {
|
|
96
|
+
// Non-fatal
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
console.log('[carom] Servers stopped.');
|
|
100
|
+
process.exit(0);
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
redirectServer.close(onClose);
|
|
105
|
+
adminServer.close(onClose);
|
|
106
|
+
|
|
107
|
+
// Force exit after 5 seconds
|
|
108
|
+
setTimeout(() => {
|
|
109
|
+
console.error('[carom] Forced shutdown after timeout.');
|
|
110
|
+
process.exit(1);
|
|
111
|
+
}, 5000);
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
115
|
+
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
116
|
+
});
|
|
117
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { writeFileSync, unlinkSync, existsSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { homedir } from 'os';
|
|
4
|
+
import { execSync } from 'child_process';
|
|
5
|
+
import { DEFAULT_DATA_DIR } from '../constants.js';
|
|
6
|
+
|
|
7
|
+
const LABEL = 'com.carom';
|
|
8
|
+
const PLIST_DIR = join(homedir(), 'Library', 'LaunchAgents');
|
|
9
|
+
const PLIST_PATH = join(PLIST_DIR, `${LABEL}.plist`);
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Generate the launchd plist XML.
|
|
13
|
+
*/
|
|
14
|
+
function generatePlist(config) {
|
|
15
|
+
const dataDir = config.dataDir || DEFAULT_DATA_DIR;
|
|
16
|
+
const nodePath = process.execPath;
|
|
17
|
+
const binPath = join(process.argv[1] || '', '..', '..', 'bin', 'carom.js');
|
|
18
|
+
|
|
19
|
+
// Try to find the actual bin path
|
|
20
|
+
let actualBinPath;
|
|
21
|
+
try {
|
|
22
|
+
actualBinPath = execSync('which carom', { encoding: 'utf8' }).trim();
|
|
23
|
+
} catch {
|
|
24
|
+
// Fallback: resolve from the current execution
|
|
25
|
+
actualBinPath = binPath;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const args = [nodePath, actualBinPath, 'start'];
|
|
29
|
+
if (config.port) args.push('--port', String(config.port));
|
|
30
|
+
if (config.adminPort) args.push('--admin-port', String(config.adminPort));
|
|
31
|
+
if (config.host && config.host !== 'localhost') args.push('--host', config.host);
|
|
32
|
+
if (config.apiKey) args.push('--api-key', config.apiKey);
|
|
33
|
+
if (config.dataDir) args.push('--data-dir', config.dataDir);
|
|
34
|
+
|
|
35
|
+
const logDir = join(dataDir, 'logs');
|
|
36
|
+
const stdoutLog = join(logDir, 'stdout.log');
|
|
37
|
+
const stderrLog = join(logDir, 'stderr.log');
|
|
38
|
+
|
|
39
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
40
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
41
|
+
<plist version="1.0">
|
|
42
|
+
<dict>
|
|
43
|
+
<key>Label</key>
|
|
44
|
+
<string>${LABEL}</string>
|
|
45
|
+
|
|
46
|
+
<key>ProgramArguments</key>
|
|
47
|
+
<array>
|
|
48
|
+
${args.map(a => ` <string>${escapeXml(a)}</string>`).join('\n')}
|
|
49
|
+
</array>
|
|
50
|
+
|
|
51
|
+
<key>RunAtLoad</key>
|
|
52
|
+
<true/>
|
|
53
|
+
|
|
54
|
+
<key>KeepAlive</key>
|
|
55
|
+
<true/>
|
|
56
|
+
|
|
57
|
+
<key>StandardOutPath</key>
|
|
58
|
+
<string>${escapeXml(stdoutLog)}</string>
|
|
59
|
+
|
|
60
|
+
<key>StandardErrorPath</key>
|
|
61
|
+
<string>${escapeXml(stderrLog)}</string>
|
|
62
|
+
|
|
63
|
+
<key>EnvironmentVariables</key>
|
|
64
|
+
<dict>
|
|
65
|
+
<key>CAROM_DATA_DIR</key>
|
|
66
|
+
<string>${escapeXml(dataDir)}</string>
|
|
67
|
+
<key>PATH</key>
|
|
68
|
+
<string>/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin</string>
|
|
69
|
+
</dict>
|
|
70
|
+
|
|
71
|
+
<key>ThrottleInterval</key>
|
|
72
|
+
<integer>5</integer>
|
|
73
|
+
</dict>
|
|
74
|
+
</plist>`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function escapeXml(str) {
|
|
78
|
+
return String(str)
|
|
79
|
+
.replace(/&/g, '&')
|
|
80
|
+
.replace(/</g, '<')
|
|
81
|
+
.replace(/>/g, '>')
|
|
82
|
+
.replace(/"/g, '"')
|
|
83
|
+
.replace(/'/g, ''');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Install the launchd service.
|
|
88
|
+
*/
|
|
89
|
+
export async function installLaunchd(config) {
|
|
90
|
+
// Unload existing if present
|
|
91
|
+
if (existsSync(PLIST_PATH)) {
|
|
92
|
+
try {
|
|
93
|
+
execSync(`launchctl unload "${PLIST_PATH}" 2>/dev/null`, { stdio: 'ignore' });
|
|
94
|
+
} catch {
|
|
95
|
+
// Ignore
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const plist = generatePlist(config);
|
|
100
|
+
writeFileSync(PLIST_PATH, plist, 'utf8');
|
|
101
|
+
|
|
102
|
+
// Load the service
|
|
103
|
+
execSync(`launchctl load "${PLIST_PATH}"`);
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
path: PLIST_PATH,
|
|
107
|
+
label: LABEL,
|
|
108
|
+
message: `Service installed at ${PLIST_PATH}`,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Uninstall the launchd service.
|
|
114
|
+
*/
|
|
115
|
+
export async function uninstallLaunchd() {
|
|
116
|
+
if (!existsSync(PLIST_PATH)) {
|
|
117
|
+
throw new Error('Service is not installed.');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
execSync(`launchctl unload "${PLIST_PATH}"`, { stdio: 'ignore' });
|
|
122
|
+
} catch {
|
|
123
|
+
// May already be unloaded
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
unlinkSync(PLIST_PATH);
|
|
127
|
+
|
|
128
|
+
return { message: 'Service uninstalled successfully.' };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Check if the launchd service is installed.
|
|
133
|
+
*/
|
|
134
|
+
export function isLaunchdInstalled() {
|
|
135
|
+
return existsSync(PLIST_PATH);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Get the launchd service status.
|
|
140
|
+
*/
|
|
141
|
+
export async function getLaunchdStatus() {
|
|
142
|
+
const installed = isLaunchdInstalled();
|
|
143
|
+
let running = false;
|
|
144
|
+
let pid = null;
|
|
145
|
+
|
|
146
|
+
if (installed) {
|
|
147
|
+
try {
|
|
148
|
+
const output = execSync(`launchctl list | grep ${LABEL}`, { encoding: 'utf8' });
|
|
149
|
+
const parts = output.trim().split(/\s+/);
|
|
150
|
+
if (parts[0] && parts[0] !== '-') {
|
|
151
|
+
pid = parseInt(parts[0], 10);
|
|
152
|
+
running = !isNaN(pid);
|
|
153
|
+
}
|
|
154
|
+
} catch {
|
|
155
|
+
// Not running
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
installed,
|
|
161
|
+
running,
|
|
162
|
+
pid,
|
|
163
|
+
type: 'launchd',
|
|
164
|
+
path: PLIST_PATH,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { platform } from 'os';
|
|
2
|
+
import { installLaunchd, uninstallLaunchd, isLaunchdInstalled, getLaunchdStatus } from './launchd.js';
|
|
3
|
+
import { installSystemd, uninstallSystemd, isSystemdInstalled, getSystemdStatus } from './systemd.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Detect the current platform and return the appropriate service manager.
|
|
7
|
+
*/
|
|
8
|
+
function getServiceImpl() {
|
|
9
|
+
const os = platform();
|
|
10
|
+
if (os === 'darwin') {
|
|
11
|
+
return {
|
|
12
|
+
install: installLaunchd,
|
|
13
|
+
uninstall: uninstallLaunchd,
|
|
14
|
+
isInstalled: isLaunchdInstalled,
|
|
15
|
+
getStatus: getLaunchdStatus,
|
|
16
|
+
type: 'launchd',
|
|
17
|
+
};
|
|
18
|
+
} else if (os === 'linux') {
|
|
19
|
+
return {
|
|
20
|
+
install: installSystemd,
|
|
21
|
+
uninstall: uninstallSystemd,
|
|
22
|
+
isInstalled: isSystemdInstalled,
|
|
23
|
+
getStatus: getSystemdStatus,
|
|
24
|
+
type: 'systemd',
|
|
25
|
+
};
|
|
26
|
+
} else {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Install the carom service for auto-start on boot.
|
|
33
|
+
*/
|
|
34
|
+
export async function installService(config) {
|
|
35
|
+
const impl = getServiceImpl();
|
|
36
|
+
if (!impl) {
|
|
37
|
+
throw new Error(`Service installation is not supported on ${platform()}. Use "carom start" to run manually.`);
|
|
38
|
+
}
|
|
39
|
+
return impl.install(config);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Uninstall the carom service.
|
|
44
|
+
*/
|
|
45
|
+
export async function uninstallService() {
|
|
46
|
+
const impl = getServiceImpl();
|
|
47
|
+
if (!impl) {
|
|
48
|
+
throw new Error(`Service management is not supported on ${platform()}.`);
|
|
49
|
+
}
|
|
50
|
+
return impl.uninstall();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Check if the service is installed.
|
|
55
|
+
*/
|
|
56
|
+
export function isServiceInstalled() {
|
|
57
|
+
const impl = getServiceImpl();
|
|
58
|
+
if (!impl) return false;
|
|
59
|
+
return impl.isInstalled();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Get the service status.
|
|
64
|
+
*/
|
|
65
|
+
export async function getServiceStatus() {
|
|
66
|
+
const impl = getServiceImpl();
|
|
67
|
+
if (!impl) {
|
|
68
|
+
return { installed: false, running: false, type: 'unsupported' };
|
|
69
|
+
}
|
|
70
|
+
return impl.getStatus();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Get the service type for the current platform.
|
|
75
|
+
*/
|
|
76
|
+
export function getServiceType() {
|
|
77
|
+
const impl = getServiceImpl();
|
|
78
|
+
return impl?.type || 'unsupported';
|
|
79
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { writeFileSync, unlinkSync, existsSync } from 'fs';
|
|
2
|
+
import { execSync } from 'child_process';
|
|
3
|
+
import { DEFAULT_DATA_DIR } from '../constants.js';
|
|
4
|
+
|
|
5
|
+
const SERVICE_NAME = 'carom';
|
|
6
|
+
const UNIT_PATH = `/etc/systemd/system/${SERVICE_NAME}.service`;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Generate the systemd unit file.
|
|
10
|
+
*/
|
|
11
|
+
function generateUnit(config) {
|
|
12
|
+
const dataDir = config.dataDir || DEFAULT_DATA_DIR;
|
|
13
|
+
const nodePath = process.execPath;
|
|
14
|
+
|
|
15
|
+
let execStart;
|
|
16
|
+
try {
|
|
17
|
+
const binPath = execSync('which carom', { encoding: 'utf8' }).trim();
|
|
18
|
+
execStart = `${nodePath} ${binPath} start`;
|
|
19
|
+
} catch {
|
|
20
|
+
execStart = `${nodePath} ${process.argv[1]} start`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (config.port) execStart += ` --port ${config.port}`;
|
|
24
|
+
if (config.adminPort) execStart += ` --admin-port ${config.adminPort}`;
|
|
25
|
+
if (config.host && config.host !== 'localhost') execStart += ` --host ${config.host}`;
|
|
26
|
+
if (config.apiKey) execStart += ` --api-key ${config.apiKey}`;
|
|
27
|
+
if (config.dataDir) execStart += ` --data-dir ${config.dataDir}`;
|
|
28
|
+
|
|
29
|
+
const user = process.env.USER || process.env.LOGNAME || 'root';
|
|
30
|
+
|
|
31
|
+
return `[Unit]
|
|
32
|
+
Description=carom - Link redirect server with bot protection
|
|
33
|
+
After=network.target
|
|
34
|
+
|
|
35
|
+
[Service]
|
|
36
|
+
Type=simple
|
|
37
|
+
User=${user}
|
|
38
|
+
ExecStart=${execStart}
|
|
39
|
+
Restart=always
|
|
40
|
+
RestartSec=5
|
|
41
|
+
Environment=CAROM_DATA_DIR=${dataDir}
|
|
42
|
+
Environment=NODE_ENV=production
|
|
43
|
+
StandardOutput=append:${dataDir}/logs/stdout.log
|
|
44
|
+
StandardError=append:${dataDir}/logs/stderr.log
|
|
45
|
+
|
|
46
|
+
[Install]
|
|
47
|
+
WantedBy=multi-user.target
|
|
48
|
+
`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Install the systemd service.
|
|
53
|
+
*/
|
|
54
|
+
export async function installSystemd(config) {
|
|
55
|
+
const unit = generateUnit(config);
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
writeFileSync(UNIT_PATH, unit, 'utf8');
|
|
59
|
+
} catch (err) {
|
|
60
|
+
if (err.code === 'EACCES') {
|
|
61
|
+
throw new Error('Permission denied. Run with sudo: sudo carom install');
|
|
62
|
+
}
|
|
63
|
+
throw err;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
execSync('systemctl daemon-reload');
|
|
67
|
+
execSync(`systemctl enable ${SERVICE_NAME}`);
|
|
68
|
+
execSync(`systemctl start ${SERVICE_NAME}`);
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
path: UNIT_PATH,
|
|
72
|
+
name: SERVICE_NAME,
|
|
73
|
+
message: `Service installed at ${UNIT_PATH}`,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Uninstall the systemd service.
|
|
79
|
+
*/
|
|
80
|
+
export async function uninstallSystemd() {
|
|
81
|
+
if (!existsSync(UNIT_PATH)) {
|
|
82
|
+
throw new Error('Service is not installed.');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
execSync(`systemctl stop ${SERVICE_NAME}`, { stdio: 'ignore' });
|
|
87
|
+
execSync(`systemctl disable ${SERVICE_NAME}`, { stdio: 'ignore' });
|
|
88
|
+
} catch {
|
|
89
|
+
// May already be stopped
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
unlinkSync(UNIT_PATH);
|
|
94
|
+
} catch (err) {
|
|
95
|
+
if (err.code === 'EACCES') {
|
|
96
|
+
throw new Error('Permission denied. Run with sudo: sudo carom uninstall');
|
|
97
|
+
}
|
|
98
|
+
throw err;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
execSync('systemctl daemon-reload');
|
|
102
|
+
|
|
103
|
+
return { message: 'Service uninstalled successfully.' };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Check if the systemd service is installed.
|
|
108
|
+
*/
|
|
109
|
+
export function isSystemdInstalled() {
|
|
110
|
+
return existsSync(UNIT_PATH);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Get the systemd service status.
|
|
115
|
+
*/
|
|
116
|
+
export async function getSystemdStatus() {
|
|
117
|
+
const installed = isSystemdInstalled();
|
|
118
|
+
let running = false;
|
|
119
|
+
let pid = null;
|
|
120
|
+
|
|
121
|
+
if (installed) {
|
|
122
|
+
try {
|
|
123
|
+
const output = execSync(`systemctl is-active ${SERVICE_NAME}`, { encoding: 'utf8' }).trim();
|
|
124
|
+
running = output === 'active';
|
|
125
|
+
} catch {
|
|
126
|
+
running = false;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (running) {
|
|
130
|
+
try {
|
|
131
|
+
const output = execSync(`systemctl show ${SERVICE_NAME} --property=MainPID`, { encoding: 'utf8' }).trim();
|
|
132
|
+
const match = output.match(/MainPID=(\d+)/);
|
|
133
|
+
if (match) pid = parseInt(match[1], 10);
|
|
134
|
+
} catch {
|
|
135
|
+
// Ignore
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
installed,
|
|
142
|
+
running,
|
|
143
|
+
pid,
|
|
144
|
+
type: 'systemd',
|
|
145
|
+
path: UNIT_PATH,
|
|
146
|
+
};
|
|
147
|
+
}
|