obol-ai 0.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.
@@ -0,0 +1,410 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { execSync } = require('child_process');
4
+ const { OBOL_DIR, loadConfig, saveConfig } = require('./config');
5
+
6
+ const POST_SETUP_FLAG = path.join(OBOL_DIR, '.post-setup-complete');
7
+
8
+ function isPostSetupDone() {
9
+ return fs.existsSync(POST_SETUP_FLAG);
10
+ }
11
+
12
+ function markPostSetupDone() {
13
+ fs.writeFileSync(POST_SETUP_FLAG, JSON.stringify({
14
+ completedAt: new Date().toISOString(),
15
+ tasks: SETUP_TASKS.map(t => t.name),
16
+ }));
17
+ }
18
+
19
+ // ─── SETUP TASKS ───
20
+ // These run in order after first-run conversation completes.
21
+ // Each task is a self-contained function that returns { success, message }.
22
+ // Add new tasks here — they'll run automatically on next boot.
23
+
24
+ const SETUP_TASKS = [
25
+ {
26
+ name: 'install-pass',
27
+ description: 'Install GPG and pass for encrypted secret storage',
28
+ run: async (config) => {
29
+ try {
30
+ // Check if already installed
31
+ try {
32
+ execSync('which pass', { stdio: 'pipe' });
33
+ execSync('pass ls', { stdio: 'pipe' });
34
+ return { success: true, message: 'pass already configured' };
35
+ } catch {}
36
+
37
+ // Install gpg + pass
38
+ const os = execSync('cat /etc/os-release 2>/dev/null || echo "unknown"', { encoding: 'utf-8' });
39
+ if (os.includes('Ubuntu') || os.includes('Debian')) {
40
+ execSync('apt-get update -qq && apt-get install -y -qq gnupg pass', { stdio: 'pipe' });
41
+ } else if (os.includes('Alpine')) {
42
+ execSync('apk add --quiet gnupg pass', { stdio: 'pipe' });
43
+ } else {
44
+ return { success: false, message: 'Unknown OS — install gpg and pass manually' };
45
+ }
46
+
47
+ // Generate GPG key (non-interactive)
48
+ const botName = config.bot?.name || 'OBOL';
49
+ const gpgBatch = `
50
+ %no-protection
51
+ Key-Type: RSA
52
+ Key-Length: 2048
53
+ Subkey-Type: RSA
54
+ Subkey-Length: 2048
55
+ Name-Real: ${botName}
56
+ Name-Email: obol@local
57
+ Expire-Date: 0
58
+ %commit
59
+ `;
60
+ const batchFile = path.join(OBOL_DIR, '.gpg-batch');
61
+ fs.writeFileSync(batchFile, gpgBatch);
62
+ execSync(`gpg --batch --gen-key ${batchFile}`, { stdio: 'pipe' });
63
+ fs.unlinkSync(batchFile);
64
+
65
+ // Get the key fingerprint
66
+ const keys = execSync('gpg --list-keys --with-colons obol@local', { encoding: 'utf-8' });
67
+ const fprLine = keys.split('\n').find(l => l.startsWith('fpr:'));
68
+ const fingerprint = fprLine?.split(':')[9];
69
+
70
+ if (!fingerprint) {
71
+ return { success: false, message: 'GPG key generated but could not extract fingerprint' };
72
+ }
73
+
74
+ // Init pass store
75
+ execSync(`pass init ${fingerprint}`, { stdio: 'pipe' });
76
+
77
+ return { success: true, message: `GPG key + pass store initialized (${fingerprint.slice(-8)})` };
78
+ } catch (e) {
79
+ return { success: false, message: `Failed: ${e.message}` };
80
+ }
81
+ },
82
+ },
83
+
84
+ {
85
+ name: 'migrate-secrets',
86
+ description: 'Move plaintext secrets from config.json to pass',
87
+ run: async (config) => {
88
+ try {
89
+ // Verify pass is working
90
+ execSync('pass ls', { stdio: 'pipe' });
91
+
92
+ const secrets = {
93
+ 'obol/anthropic-key': config.anthropic?.apiKey,
94
+ 'obol/telegram-token': config.telegram?.token,
95
+ 'obol/supabase-url': config.supabase?.url,
96
+ 'obol/supabase-key': config.supabase?.serviceKey,
97
+ 'obol/supabase-access-token': config.supabase?.accessToken,
98
+ 'obol/github-token': config.github?.token,
99
+ 'obol/vercel-token': config.vercel?.token,
100
+ };
101
+
102
+ let migrated = 0;
103
+ for (const [passPath, value] of Object.entries(secrets)) {
104
+ if (!value) continue;
105
+ execSync(`echo "${value}" | pass insert -m ${passPath}`, { stdio: 'pipe' });
106
+ migrated++;
107
+ }
108
+
109
+ // Rewrite config.json without plaintext secrets
110
+ const cleanConfig = {
111
+ ...config,
112
+ anthropic: { apiKey: 'pass:obol/anthropic-key' },
113
+ telegram: { ...config.telegram, token: 'pass:obol/telegram-token' },
114
+ supabase: config.supabase ? {
115
+ url: 'pass:obol/supabase-url',
116
+ serviceKey: 'pass:obol/supabase-key',
117
+ ...(config.supabase.accessToken ? { accessToken: 'pass:obol/supabase-access-token' } : {}),
118
+ ...(config.supabase.anonKey ? { anonKey: config.supabase.anonKey } : {}),
119
+ } : null,
120
+ github: config.github ? {
121
+ ...config.github,
122
+ token: 'pass:obol/github-token',
123
+ } : null,
124
+ vercel: config.vercel ? { token: 'pass:obol/vercel-token' } : null,
125
+ };
126
+
127
+ saveConfig(cleanConfig);
128
+
129
+ return { success: true, message: `Migrated ${migrated} secrets to pass. Config cleaned.` };
130
+ } catch (e) {
131
+ return { success: false, message: `Failed: ${e.message}` };
132
+ }
133
+ },
134
+ },
135
+
136
+ {
137
+ name: 'install-pm2',
138
+ description: 'Install pm2 process manager and configure auto-start on boot',
139
+ run: async () => {
140
+ try {
141
+ try {
142
+ execSync('which pm2', { stdio: 'pipe' });
143
+ return { success: true, message: 'pm2 already installed' };
144
+ } catch {}
145
+
146
+ execSync('npm install -g pm2', { stdio: 'pipe' });
147
+ execSync('pm2 startup -u root --hp /root 2>/dev/null || pm2 startup', { stdio: 'pipe' });
148
+
149
+ return { success: true, message: 'pm2 installed + startup configured' };
150
+ } catch (e) {
151
+ return { success: false, message: `pm2 setup failed: ${e.message}` };
152
+ }
153
+ },
154
+ },
155
+
156
+ {
157
+ name: 'setup-swap',
158
+ description: 'Add swap if RAM is low (embedding model needs ~200MB)',
159
+ run: async () => {
160
+ try {
161
+ const memInfo = execSync('free -m', { encoding: 'utf-8' });
162
+ const totalMatch = memInfo.match(/Mem:\s+(\d+)/);
163
+ const totalMB = totalMatch ? parseInt(totalMatch[1]) : 0;
164
+
165
+ if (totalMB >= 2048) {
166
+ return { success: true, message: `${totalMB}MB RAM — swap not needed` };
167
+ }
168
+
169
+ // Check if swap already exists
170
+ const swapInfo = execSync('swapon --show', { encoding: 'utf-8' });
171
+ if (swapInfo.trim()) {
172
+ return { success: true, message: 'Swap already configured' };
173
+ }
174
+
175
+ // Create 2GB swap
176
+ execSync('fallocate -l 2G /swapfile && chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile', { stdio: 'pipe' });
177
+
178
+ // Make persistent
179
+ const fstab = fs.readFileSync('/etc/fstab', 'utf-8');
180
+ if (!fstab.includes('/swapfile')) {
181
+ fs.appendFileSync('/etc/fstab', '\n/swapfile none swap sw 0 0\n');
182
+ }
183
+
184
+ return { success: true, message: `2GB swap created (${totalMB}MB RAM detected)` };
185
+ } catch (e) {
186
+ return { success: false, message: `Swap setup failed: ${e.message}` };
187
+ }
188
+ },
189
+ },
190
+
191
+ {
192
+ name: 'harden-ssh',
193
+ description: 'Harden SSH — key-only auth, no root password, rate limiting',
194
+ run: async () => {
195
+ try {
196
+ const sshdConfig = '/etc/ssh/sshd_config';
197
+ if (!fs.existsSync(sshdConfig)) {
198
+ return { success: true, message: 'No sshd_config found — not an SSH server' };
199
+ }
200
+
201
+ let config = fs.readFileSync(sshdConfig, 'utf-8');
202
+ let changed = false;
203
+ const changes = [];
204
+
205
+ const settings = {
206
+ 'Port': '2222',
207
+ 'PasswordAuthentication': 'no',
208
+ 'PermitRootLogin': 'prohibit-password',
209
+ 'PubkeyAuthentication': 'yes',
210
+ 'MaxAuthTries': '3',
211
+ 'LoginGraceTime': '20',
212
+ 'X11Forwarding': 'no',
213
+ 'PermitEmptyPasswords': 'no',
214
+ };
215
+
216
+ for (const [key, value] of Object.entries(settings)) {
217
+ const regex = new RegExp(`^#?\\s*${key}\\s+.*$`, 'm');
218
+ const target = `${key} ${value}`;
219
+ if (config.match(new RegExp(`^${key}\\s+${value}$`, 'm'))) continue;
220
+
221
+ if (regex.test(config)) {
222
+ config = config.replace(regex, target);
223
+ } else {
224
+ config += `\n${target}`;
225
+ }
226
+ changes.push(`${key}=${value}`);
227
+ changed = true;
228
+ }
229
+
230
+ if (changed) {
231
+ // Backup original
232
+ execSync(`cp ${sshdConfig} ${sshdConfig}.bak.obol`, { stdio: 'pipe' });
233
+ fs.writeFileSync(sshdConfig, config);
234
+ // Test config before restarting
235
+ try {
236
+ execSync('sshd -t', { stdio: 'pipe' });
237
+ execSync('systemctl reload sshd 2>/dev/null || systemctl reload ssh 2>/dev/null', { stdio: 'pipe' });
238
+ return { success: true, message: `Hardened: ${changes.join(', ')}` };
239
+ } catch (e) {
240
+ // Rollback on bad config
241
+ execSync(`cp ${sshdConfig}.bak.obol ${sshdConfig}`, { stdio: 'pipe' });
242
+ return { success: false, message: `Config test failed, rolled back: ${e.message}` };
243
+ }
244
+ }
245
+
246
+ return { success: true, message: 'SSH already hardened' };
247
+ } catch (e) {
248
+ return { success: false, message: `SSH hardening failed: ${e.message}` };
249
+ }
250
+ },
251
+ },
252
+
253
+ {
254
+ name: 'install-fail2ban',
255
+ description: 'Install and configure fail2ban to block brute-force attacks',
256
+ run: async () => {
257
+ try {
258
+ // Check if already running
259
+ try {
260
+ const status = execSync('systemctl is-active fail2ban', { encoding: 'utf-8' }).trim();
261
+ if (status === 'active') {
262
+ return { success: true, message: 'fail2ban already active' };
263
+ }
264
+ } catch {}
265
+
266
+ // Install
267
+ execSync('apt-get update -qq && apt-get install -y -qq fail2ban', { stdio: 'pipe' });
268
+
269
+ // Write jail config — port 2222 (hardened by obol)
270
+ const jailLocal = `[sshd]
271
+ enabled = true
272
+ port = 2222
273
+ filter = sshd
274
+ logpath = /var/log/auth.log
275
+ maxretry = 3
276
+ bantime = 3600
277
+ findtime = 600
278
+ `;
279
+ fs.writeFileSync('/etc/fail2ban/jail.local', jailLocal);
280
+
281
+ execSync('systemctl enable fail2ban && systemctl restart fail2ban', { stdio: 'pipe' });
282
+
283
+ return { success: true, message: 'fail2ban active (SSH port 2222, max 3 retries, 1h ban)' };
284
+ } catch (e) {
285
+ return { success: false, message: `fail2ban setup failed: ${e.message}` };
286
+ }
287
+ },
288
+ },
289
+
290
+ {
291
+ name: 'setup-firewall',
292
+ description: 'Enable UFW firewall — allow SSH (port 2222) only',
293
+ run: async () => {
294
+ try {
295
+ // Install ufw if not present
296
+ try { execSync('which ufw', { stdio: 'pipe' }); } catch {
297
+ execSync('apt-get update -qq && apt-get install -y -qq ufw', { stdio: 'pipe' });
298
+ }
299
+
300
+ const status = execSync('ufw status', { encoding: 'utf-8' });
301
+ if (status.includes('Status: active')) {
302
+ return { success: true, message: 'Firewall already active' };
303
+ }
304
+
305
+ // Default deny inbound, allow outbound
306
+ execSync('ufw default deny incoming', { stdio: 'pipe' });
307
+ execSync('ufw default allow outgoing', { stdio: 'pipe' });
308
+ execSync('ufw allow 2222/tcp', { stdio: 'pipe' });
309
+ execSync('echo "y" | ufw enable', { stdio: 'pipe' });
310
+
311
+ return { success: true, message: 'Firewall enabled (SSH port 2222 only, deny all inbound)' };
312
+ } catch (e) {
313
+ return { success: false, message: `Firewall setup failed: ${e.message}` };
314
+ }
315
+ },
316
+ },
317
+
318
+ {
319
+ name: 'setup-auto-updates',
320
+ description: 'Enable automatic security updates',
321
+ run: async () => {
322
+ try {
323
+ // Check if already configured
324
+ try {
325
+ const conf = fs.readFileSync('/etc/apt/apt.conf.d/20auto-upgrades', 'utf-8');
326
+ if (conf.includes('Unattended-Upgrade "1"')) {
327
+ return { success: true, message: 'Unattended upgrades already enabled' };
328
+ }
329
+ } catch {}
330
+
331
+ execSync('apt-get update -qq && apt-get install -y -qq unattended-upgrades', { stdio: 'pipe' });
332
+
333
+ const autoConf = `APT::Periodic::Update-Package-Lists "1";
334
+ APT::Periodic::Unattended-Upgrade "1";
335
+ APT::Periodic::AutocleanInterval "7";
336
+ `;
337
+ fs.writeFileSync('/etc/apt/apt.conf.d/20auto-upgrades', autoConf);
338
+
339
+ execSync('systemctl enable unattended-upgrades', { stdio: 'pipe' });
340
+
341
+ return { success: true, message: 'Automatic security updates enabled (daily check, weekly cleanup)' };
342
+ } catch (e) {
343
+ return { success: false, message: `Auto-updates setup failed: ${e.message}` };
344
+ }
345
+ },
346
+ },
347
+
348
+ {
349
+ name: 'kernel-hardening',
350
+ description: 'Apply kernel network security settings',
351
+ run: async () => {
352
+ try {
353
+ const sysctlConf = `/etc/sysctl.d/99-obol-hardening.conf`;
354
+
355
+ const settings = {
356
+ // SYN flood protection
357
+ 'net.ipv4.tcp_syncookies': '1',
358
+ // Reverse path filtering
359
+ 'net.ipv4.conf.all.rp_filter': '1',
360
+ 'net.ipv4.conf.default.rp_filter': '1',
361
+ // Ignore ICMP redirects
362
+ 'net.ipv4.conf.all.accept_redirects': '0',
363
+ 'net.ipv4.conf.default.accept_redirects': '0',
364
+ 'net.ipv6.conf.all.accept_redirects': '0',
365
+ // Don't send ICMP redirects
366
+ 'net.ipv4.conf.all.send_redirects': '0',
367
+ // Ignore broadcast pings
368
+ 'net.ipv4.icmp_echo_ignore_broadcasts': '1',
369
+ // Log martian packets
370
+ 'net.ipv4.conf.all.log_martians': '1',
371
+ };
372
+
373
+ const content = '# OBOL security hardening\n' +
374
+ Object.entries(settings).map(([k, v]) => `${k} = ${v}`).join('\n') + '\n';
375
+
376
+ fs.writeFileSync(sysctlConf, content);
377
+ execSync('sysctl --system 2>/dev/null', { stdio: 'pipe' });
378
+
379
+ return { success: true, message: 'Kernel hardening applied (syncookies, rp_filter, no redirects)' };
380
+ } catch (e) {
381
+ return { success: false, message: `Kernel hardening failed: ${e.message}` };
382
+ }
383
+ },
384
+ },
385
+ ];
386
+
387
+ // ─── RUNNER ───
388
+
389
+ async function runPostSetup(config, reportFn) {
390
+ if (isPostSetupDone()) return;
391
+
392
+ reportFn?.('🪙 Running post-setup tasks...\n');
393
+
394
+ const results = [];
395
+ for (const task of SETUP_TASKS) {
396
+ reportFn?.(`⚙️ ${task.description}...`);
397
+ const result = await task.run(config);
398
+ results.push({ name: task.name, ...result });
399
+ reportFn?.(` ${result.success ? '✅' : '⚠️'} ${result.message}`);
400
+ }
401
+
402
+ markPostSetupDone();
403
+
404
+ const summary = results.map(r => `${r.success ? '✅' : '⚠️'} ${r.name}: ${r.message}`).join('\n');
405
+ reportFn?.(`\n🪙 Post-setup complete!\n${summary}`);
406
+
407
+ return results;
408
+ }
409
+
410
+ module.exports = { isPostSetupDone, runPostSetup, SETUP_TASKS };