@aion0/forge 0.2.10 → 0.2.12
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/CLAUDE.md +42 -38
- package/README.md +175 -97
- package/app/api/settings/route.ts +52 -5
- package/app/api/tunnel/route.ts +12 -4
- package/app/login/page.tsx +35 -6
- package/bin/forge-server.mjs +1 -1
- package/cli/mw.ts +166 -38
- package/components/SettingsModal.tsx +297 -35
- package/components/TunnelToggle.tsx +48 -5
- package/install.sh +2 -2
- package/instrumentation.ts +9 -4
- package/lib/auth.ts +13 -9
- package/lib/cloudflared.ts +6 -0
- package/lib/crypto.ts +68 -0
- package/lib/init.ts +47 -9
- package/lib/password.ts +67 -47
- package/lib/settings.ts +38 -3
- package/lib/telegram-bot.ts +56 -23
- package/lib/telegram-standalone.ts +0 -1
- package/package.json +1 -1
|
@@ -29,6 +29,165 @@ function SecretInput({ value, onChange, placeholder, className }: {
|
|
|
29
29
|
);
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
// ─── Secret Change Dialog ──────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
function SecretChangeDialog({ field, label, isSet, onSave, onClose }: {
|
|
35
|
+
field: string;
|
|
36
|
+
label: string;
|
|
37
|
+
isSet: boolean;
|
|
38
|
+
onSave: (field: string, adminPassword: string, newValue: string) => Promise<string | null>;
|
|
39
|
+
onClose: () => void;
|
|
40
|
+
}) {
|
|
41
|
+
const [mode, setMode] = useState<'change' | 'clear'>('change');
|
|
42
|
+
const [adminPassword, setAdminPassword] = useState('');
|
|
43
|
+
const [newValue, setNewValue] = useState('');
|
|
44
|
+
const [confirmValue, setConfirmValue] = useState('');
|
|
45
|
+
const [error, setError] = useState('');
|
|
46
|
+
const [saving, setSaving] = useState(false);
|
|
47
|
+
|
|
48
|
+
const canSave = mode === 'clear'
|
|
49
|
+
? adminPassword.length > 0
|
|
50
|
+
: (adminPassword.length > 0 && newValue.length > 0 && newValue === confirmValue);
|
|
51
|
+
|
|
52
|
+
const handleSave = async () => {
|
|
53
|
+
if (mode === 'change' && newValue !== confirmValue) {
|
|
54
|
+
setError('New values do not match');
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
setSaving(true);
|
|
58
|
+
setError('');
|
|
59
|
+
const err = await onSave(field, adminPassword, mode === 'clear' ? '' : newValue);
|
|
60
|
+
setSaving(false);
|
|
61
|
+
if (err) {
|
|
62
|
+
setError(err);
|
|
63
|
+
} else {
|
|
64
|
+
onClose();
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const inputClass = "w-full px-2 py-1.5 pr-8 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-xs text-[var(--text-primary)] font-mono focus:outline-none focus:border-[var(--accent)]";
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-[60]" onClick={onClose}>
|
|
72
|
+
<div
|
|
73
|
+
className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg w-[380px] p-4 space-y-3"
|
|
74
|
+
onClick={e => e.stopPropagation()}
|
|
75
|
+
>
|
|
76
|
+
<div className="flex items-center justify-between">
|
|
77
|
+
<h3 className="text-xs font-bold">{isSet ? `Change ${label}` : `Set ${label}`}</h3>
|
|
78
|
+
{isSet && (
|
|
79
|
+
<div className="flex bg-[var(--bg-tertiary)] rounded p-0.5">
|
|
80
|
+
<button
|
|
81
|
+
onClick={() => { setMode('change'); setError(''); }}
|
|
82
|
+
className={`text-[10px] px-2 py-0.5 rounded ${mode === 'change' ? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-sm' : 'text-[var(--text-secondary)]'}`}
|
|
83
|
+
>
|
|
84
|
+
Change
|
|
85
|
+
</button>
|
|
86
|
+
<button
|
|
87
|
+
onClick={() => { setMode('clear'); setError(''); }}
|
|
88
|
+
className={`text-[10px] px-2 py-0.5 rounded ${mode === 'clear' ? 'bg-[var(--red)] text-white shadow-sm' : 'text-[var(--text-secondary)]'}`}
|
|
89
|
+
>
|
|
90
|
+
Clear
|
|
91
|
+
</button>
|
|
92
|
+
</div>
|
|
93
|
+
)}
|
|
94
|
+
</div>
|
|
95
|
+
|
|
96
|
+
<div className="space-y-1">
|
|
97
|
+
<label className="text-[10px] text-[var(--text-secondary)]">Admin password (login password)</label>
|
|
98
|
+
<SecretInput
|
|
99
|
+
value={adminPassword}
|
|
100
|
+
onChange={v => { setAdminPassword(v); setError(''); }}
|
|
101
|
+
placeholder="Enter login password to verify"
|
|
102
|
+
className={inputClass}
|
|
103
|
+
/>
|
|
104
|
+
</div>
|
|
105
|
+
|
|
106
|
+
{mode === 'change' && (
|
|
107
|
+
<>
|
|
108
|
+
<div className="space-y-1">
|
|
109
|
+
<label className="text-[10px] text-[var(--text-secondary)]">New value</label>
|
|
110
|
+
<SecretInput
|
|
111
|
+
value={newValue}
|
|
112
|
+
onChange={v => { setNewValue(v); setError(''); }}
|
|
113
|
+
placeholder="Enter new value"
|
|
114
|
+
className={inputClass}
|
|
115
|
+
/>
|
|
116
|
+
</div>
|
|
117
|
+
|
|
118
|
+
<div className="space-y-1">
|
|
119
|
+
<label className="text-[10px] text-[var(--text-secondary)]">Confirm new value</label>
|
|
120
|
+
<SecretInput
|
|
121
|
+
value={confirmValue}
|
|
122
|
+
onChange={v => { setConfirmValue(v); setError(''); }}
|
|
123
|
+
placeholder="Re-enter new value"
|
|
124
|
+
className={inputClass}
|
|
125
|
+
/>
|
|
126
|
+
{confirmValue && newValue !== confirmValue && (
|
|
127
|
+
<p className="text-[9px] text-[var(--red)]">Values do not match</p>
|
|
128
|
+
)}
|
|
129
|
+
</div>
|
|
130
|
+
</>
|
|
131
|
+
)}
|
|
132
|
+
|
|
133
|
+
{mode === 'clear' && (
|
|
134
|
+
<p className="text-[10px] text-[var(--text-secondary)]">
|
|
135
|
+
Enter admin password to verify, then click Clear to remove this value.
|
|
136
|
+
</p>
|
|
137
|
+
)}
|
|
138
|
+
|
|
139
|
+
{error && <p className="text-[10px] text-[var(--red)]">{error}</p>}
|
|
140
|
+
|
|
141
|
+
<div className="flex justify-end gap-2 pt-1">
|
|
142
|
+
<button
|
|
143
|
+
onClick={onClose}
|
|
144
|
+
className="px-3 py-1.5 text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
|
145
|
+
>
|
|
146
|
+
Cancel
|
|
147
|
+
</button>
|
|
148
|
+
<button
|
|
149
|
+
onClick={handleSave}
|
|
150
|
+
disabled={!canSave || saving}
|
|
151
|
+
className={`px-3 py-1.5 text-xs text-white rounded hover:opacity-90 disabled:opacity-50 ${mode === 'clear' ? 'bg-[var(--red)]' : 'bg-[var(--accent)]'}`}
|
|
152
|
+
>
|
|
153
|
+
{saving ? 'Saving...' : mode === 'clear' ? 'Clear' : 'Save'}
|
|
154
|
+
</button>
|
|
155
|
+
</div>
|
|
156
|
+
</div>
|
|
157
|
+
</div>
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ─── Secret Field Display ──────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
function SecretField({ label, description, isSet, onEdit }: {
|
|
164
|
+
label: string;
|
|
165
|
+
description?: string;
|
|
166
|
+
isSet: boolean;
|
|
167
|
+
onEdit: () => void;
|
|
168
|
+
}) {
|
|
169
|
+
return (
|
|
170
|
+
<div className="space-y-1">
|
|
171
|
+
{description && (
|
|
172
|
+
<label className="text-[10px] text-[var(--text-secondary)]">{description}</label>
|
|
173
|
+
)}
|
|
174
|
+
<div className="flex items-center gap-2">
|
|
175
|
+
<div className="flex-1 px-2 py-1.5 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-xs font-mono text-[var(--text-secondary)]">
|
|
176
|
+
{isSet ? '••••••••' : <span className="italic">Not set</span>}
|
|
177
|
+
</div>
|
|
178
|
+
<button
|
|
179
|
+
onClick={onEdit}
|
|
180
|
+
className="text-[10px] px-2 py-1 border border-[var(--accent)] text-[var(--accent)] rounded hover:bg-[var(--accent)] hover:text-white transition-colors"
|
|
181
|
+
>
|
|
182
|
+
{isSet ? 'Change' : 'Set'}
|
|
183
|
+
</button>
|
|
184
|
+
</div>
|
|
185
|
+
</div>
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ─── Settings Modal ────────────────────────────────────────────
|
|
190
|
+
|
|
32
191
|
interface Settings {
|
|
33
192
|
projectRoots: string[];
|
|
34
193
|
docRoots: string[];
|
|
@@ -43,6 +202,7 @@ interface Settings {
|
|
|
43
202
|
pipelineModel: string;
|
|
44
203
|
telegramModel: string;
|
|
45
204
|
skipPermissions: boolean;
|
|
205
|
+
_secretStatus?: Record<string, boolean>;
|
|
46
206
|
}
|
|
47
207
|
|
|
48
208
|
interface TunnelStatus {
|
|
@@ -69,6 +229,7 @@ export default function SettingsModal({ onClose }: { onClose: () => void }) {
|
|
|
69
229
|
telegramModel: 'sonnet',
|
|
70
230
|
skipPermissions: false,
|
|
71
231
|
});
|
|
232
|
+
const [secretStatus, setSecretStatus] = useState<Record<string, boolean>>({});
|
|
72
233
|
const [newRoot, setNewRoot] = useState('');
|
|
73
234
|
const [newDocRoot, setNewDocRoot] = useState('');
|
|
74
235
|
const [saved, setSaved] = useState(false);
|
|
@@ -77,15 +238,28 @@ export default function SettingsModal({ onClose }: { onClose: () => void }) {
|
|
|
77
238
|
});
|
|
78
239
|
const [tunnelLoading, setTunnelLoading] = useState(false);
|
|
79
240
|
const [confirmStopTunnel, setConfirmStopTunnel] = useState(false);
|
|
241
|
+
const [tunnelPasswordPrompt, setTunnelPasswordPrompt] = useState(false);
|
|
242
|
+
const [tunnelPassword, setTunnelPassword] = useState('');
|
|
243
|
+
const [tunnelPasswordError, setTunnelPasswordError] = useState('');
|
|
244
|
+
const [editingSecret, setEditingSecret] = useState<{ field: string; label: string } | null>(null);
|
|
80
245
|
|
|
81
246
|
const refreshTunnel = useCallback(() => {
|
|
82
247
|
fetch('/api/tunnel').then(r => r.json()).then(setTunnel).catch(() => {});
|
|
83
248
|
}, []);
|
|
84
249
|
|
|
250
|
+
const fetchSettings = useCallback(() => {
|
|
251
|
+
fetch('/api/settings').then(r => r.json()).then((data: Settings) => {
|
|
252
|
+
const status = data._secretStatus || {};
|
|
253
|
+
delete data._secretStatus;
|
|
254
|
+
setSettings(data);
|
|
255
|
+
setSecretStatus(status);
|
|
256
|
+
});
|
|
257
|
+
}, []);
|
|
258
|
+
|
|
85
259
|
useEffect(() => {
|
|
86
|
-
|
|
260
|
+
fetchSettings();
|
|
87
261
|
refreshTunnel();
|
|
88
|
-
}, [refreshTunnel]);
|
|
262
|
+
}, [fetchSettings, refreshTunnel]);
|
|
89
263
|
|
|
90
264
|
// Poll tunnel status while starting
|
|
91
265
|
useEffect(() => {
|
|
@@ -104,6 +278,19 @@ export default function SettingsModal({ onClose }: { onClose: () => void }) {
|
|
|
104
278
|
setTimeout(() => setSaved(false), 2000);
|
|
105
279
|
};
|
|
106
280
|
|
|
281
|
+
const saveSecret = async (field: string, adminPassword: string, newValue: string): Promise<string | null> => {
|
|
282
|
+
const res = await fetch('/api/settings', {
|
|
283
|
+
method: 'PUT',
|
|
284
|
+
headers: { 'Content-Type': 'application/json' },
|
|
285
|
+
body: JSON.stringify({ _secretUpdate: { field, adminPassword, newValue } }),
|
|
286
|
+
});
|
|
287
|
+
const data = await res.json();
|
|
288
|
+
if (!data.ok) return data.error || 'Failed to save';
|
|
289
|
+
// Refresh status
|
|
290
|
+
setSecretStatus(prev => ({ ...prev, [field]: !!newValue }));
|
|
291
|
+
return null;
|
|
292
|
+
};
|
|
293
|
+
|
|
107
294
|
const addRoot = () => {
|
|
108
295
|
const path = newRoot.trim();
|
|
109
296
|
if (!path || settings.projectRoots.includes(path)) return;
|
|
@@ -242,12 +429,15 @@ export default function SettingsModal({ onClose }: { onClose: () => void }) {
|
|
|
242
429
|
<p className="text-[10px] text-[var(--text-secondary)]">
|
|
243
430
|
Get notified when tasks complete or fail. Create a bot via @BotFather, then send /start to it and use the test button below to get your chat ID.
|
|
244
431
|
</p>
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
432
|
+
|
|
433
|
+
<SecretField
|
|
434
|
+
label="Bot Token"
|
|
435
|
+
description="Telegram Bot API token (from @BotFather)"
|
|
436
|
+
isSet={!!secretStatus.telegramBotToken}
|
|
437
|
+
onEdit={() => setEditingSecret({ field: 'telegramBotToken', label: 'Bot Token' })}
|
|
438
|
+
|
|
250
439
|
/>
|
|
440
|
+
|
|
251
441
|
<input
|
|
252
442
|
value={settings.telegramChatId}
|
|
253
443
|
onChange={e => setSettings({ ...settings, telegramChatId: e.target.value })}
|
|
@@ -276,7 +466,7 @@ export default function SettingsModal({ onClose }: { onClose: () => void }) {
|
|
|
276
466
|
/>
|
|
277
467
|
Notify on failure
|
|
278
468
|
</label>
|
|
279
|
-
{
|
|
469
|
+
{secretStatus.telegramBotToken && settings.telegramChatId && (
|
|
280
470
|
<button
|
|
281
471
|
type="button"
|
|
282
472
|
onClick={async () => {
|
|
@@ -377,25 +567,82 @@ export default function SettingsModal({ onClose }: { onClose: () => void }) {
|
|
|
377
567
|
|
|
378
568
|
<div className="flex items-center gap-2">
|
|
379
569
|
{tunnel.status === 'stopped' || tunnel.status === 'error' ? (
|
|
570
|
+
tunnelPasswordPrompt ? (
|
|
571
|
+
<div className="flex items-center gap-2">
|
|
572
|
+
<input
|
|
573
|
+
type="password"
|
|
574
|
+
value={tunnelPassword}
|
|
575
|
+
onChange={e => { setTunnelPassword(e.target.value); setTunnelPasswordError(''); }}
|
|
576
|
+
onKeyDown={async e => {
|
|
577
|
+
if (e.key === 'Enter' && tunnelPassword) {
|
|
578
|
+
setTunnelLoading(true);
|
|
579
|
+
setTunnelPasswordError('');
|
|
580
|
+
try {
|
|
581
|
+
const res = await fetch('/api/tunnel', {
|
|
582
|
+
method: 'POST',
|
|
583
|
+
headers: { 'Content-Type': 'application/json' },
|
|
584
|
+
body: JSON.stringify({ action: 'start', password: tunnelPassword }),
|
|
585
|
+
});
|
|
586
|
+
const data = await res.json();
|
|
587
|
+
if (res.status === 403) {
|
|
588
|
+
setTunnelPasswordError('Wrong password');
|
|
589
|
+
} else {
|
|
590
|
+
setTunnel(data);
|
|
591
|
+
setTunnelPasswordPrompt(false);
|
|
592
|
+
setTunnelPassword('');
|
|
593
|
+
}
|
|
594
|
+
} catch {}
|
|
595
|
+
setTunnelLoading(false);
|
|
596
|
+
}
|
|
597
|
+
}}
|
|
598
|
+
placeholder="Login password"
|
|
599
|
+
autoFocus
|
|
600
|
+
className={`w-[140px] text-[10px] px-2 py-1 bg-[var(--bg-tertiary)] border rounded font-mono focus:outline-none ${
|
|
601
|
+
tunnelPasswordError ? 'border-[var(--red)]' : 'border-[var(--border)] focus:border-[var(--accent)]'
|
|
602
|
+
} text-[var(--text-primary)]`}
|
|
603
|
+
/>
|
|
604
|
+
<button
|
|
605
|
+
disabled={!tunnelPassword || tunnelLoading}
|
|
606
|
+
onClick={async () => {
|
|
607
|
+
setTunnelLoading(true);
|
|
608
|
+
setTunnelPasswordError('');
|
|
609
|
+
try {
|
|
610
|
+
const res = await fetch('/api/tunnel', {
|
|
611
|
+
method: 'POST',
|
|
612
|
+
headers: { 'Content-Type': 'application/json' },
|
|
613
|
+
body: JSON.stringify({ action: 'start', password: tunnelPassword }),
|
|
614
|
+
});
|
|
615
|
+
const data = await res.json();
|
|
616
|
+
if (res.status === 403) {
|
|
617
|
+
setTunnelPasswordError('Wrong password');
|
|
618
|
+
} else {
|
|
619
|
+
setTunnel(data);
|
|
620
|
+
setTunnelPasswordPrompt(false);
|
|
621
|
+
setTunnelPassword('');
|
|
622
|
+
}
|
|
623
|
+
} catch {}
|
|
624
|
+
setTunnelLoading(false);
|
|
625
|
+
}}
|
|
626
|
+
className="text-[10px] px-2 py-1 bg-[var(--green)] text-black rounded hover:opacity-90 disabled:opacity-50"
|
|
627
|
+
>
|
|
628
|
+
{tunnelLoading ? 'Starting...' : 'Start'}
|
|
629
|
+
</button>
|
|
630
|
+
<button
|
|
631
|
+
onClick={() => { setTunnelPasswordPrompt(false); setTunnelPassword(''); setTunnelPasswordError(''); }}
|
|
632
|
+
className="text-[10px] px-2 py-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
|
633
|
+
>
|
|
634
|
+
Cancel
|
|
635
|
+
</button>
|
|
636
|
+
{tunnelPasswordError && <span className="text-[9px] text-[var(--red)]">{tunnelPasswordError}</span>}
|
|
637
|
+
</div>
|
|
638
|
+
) : (
|
|
380
639
|
<button
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
setTunnelLoading(true);
|
|
384
|
-
try {
|
|
385
|
-
const res = await fetch('/api/tunnel', {
|
|
386
|
-
method: 'POST',
|
|
387
|
-
headers: { 'Content-Type': 'application/json' },
|
|
388
|
-
body: JSON.stringify({ action: 'start' }),
|
|
389
|
-
});
|
|
390
|
-
const data = await res.json();
|
|
391
|
-
setTunnel(data);
|
|
392
|
-
} catch {}
|
|
393
|
-
setTunnelLoading(false);
|
|
394
|
-
}}
|
|
395
|
-
className="text-[10px] px-3 py-1.5 bg-[var(--green)] text-black rounded hover:opacity-90 disabled:opacity-50"
|
|
640
|
+
onClick={() => setTunnelPasswordPrompt(true)}
|
|
641
|
+
className="text-[10px] px-3 py-1.5 bg-[var(--green)] text-black rounded hover:opacity-90"
|
|
396
642
|
>
|
|
397
|
-
|
|
643
|
+
Start Tunnel
|
|
398
644
|
</button>
|
|
645
|
+
)
|
|
399
646
|
) : confirmStopTunnel ? (
|
|
400
647
|
<div className="flex items-center gap-2">
|
|
401
648
|
<span className="text-[10px] text-[var(--text-secondary)]">Stop tunnel?</span>
|
|
@@ -487,17 +734,21 @@ export default function SettingsModal({ onClose }: { onClose: () => void }) {
|
|
|
487
734
|
Auto-start tunnel on server startup
|
|
488
735
|
</label>
|
|
489
736
|
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
737
|
+
</div>
|
|
738
|
+
|
|
739
|
+
{/* Admin Password */}
|
|
740
|
+
<div className="space-y-2">
|
|
741
|
+
<label className="text-xs text-[var(--text-secondary)] font-semibold uppercase">
|
|
742
|
+
Admin Password
|
|
743
|
+
</label>
|
|
744
|
+
<p className="text-[10px] text-[var(--text-secondary)]">
|
|
745
|
+
Used for local login, tunnel start, secret changes, and Telegram commands. Remote login requires admin password + session code (generated on tunnel start).
|
|
746
|
+
</p>
|
|
747
|
+
<SecretField
|
|
748
|
+
label="Admin Password"
|
|
749
|
+
isSet={!!secretStatus.telegramTunnelPassword}
|
|
750
|
+
onEdit={() => setEditingSecret({ field: 'telegramTunnelPassword', label: 'Admin Password' })}
|
|
751
|
+
/>
|
|
501
752
|
</div>
|
|
502
753
|
|
|
503
754
|
{/* Actions */}
|
|
@@ -521,6 +772,17 @@ export default function SettingsModal({ onClose }: { onClose: () => void }) {
|
|
|
521
772
|
</div>
|
|
522
773
|
</div>
|
|
523
774
|
</div>
|
|
775
|
+
|
|
776
|
+
{/* Secret Change Dialog */}
|
|
777
|
+
{editingSecret && (
|
|
778
|
+
<SecretChangeDialog
|
|
779
|
+
field={editingSecret.field}
|
|
780
|
+
label={editingSecret.label}
|
|
781
|
+
isSet={!!secretStatus[editingSecret.field]}
|
|
782
|
+
onSave={saveSecret}
|
|
783
|
+
onClose={() => setEditingSecret(null)}
|
|
784
|
+
/>
|
|
785
|
+
)}
|
|
524
786
|
</div>
|
|
525
787
|
);
|
|
526
788
|
}
|
|
@@ -14,6 +14,9 @@ export default function TunnelToggle() {
|
|
|
14
14
|
const [copied, setCopied] = useState(false);
|
|
15
15
|
const [isRemote, setIsRemote] = useState(false);
|
|
16
16
|
const [confirmStop, setConfirmStop] = useState(false);
|
|
17
|
+
const [showPasswordPrompt, setShowPasswordPrompt] = useState(false);
|
|
18
|
+
const [password, setPassword] = useState('');
|
|
19
|
+
const [passwordError, setPasswordError] = useState('');
|
|
17
20
|
|
|
18
21
|
useEffect(() => {
|
|
19
22
|
setIsRemote(!['localhost', '127.0.0.1'].includes(window.location.hostname));
|
|
@@ -50,16 +53,24 @@ export default function TunnelToggle() {
|
|
|
50
53
|
setConfirmStop(false);
|
|
51
54
|
};
|
|
52
55
|
|
|
53
|
-
const doStart = async () => {
|
|
56
|
+
const doStart = async (pw: string) => {
|
|
54
57
|
setLoading(true);
|
|
58
|
+
setPasswordError('');
|
|
55
59
|
try {
|
|
56
60
|
const res = await fetch('/api/tunnel', {
|
|
57
61
|
method: 'POST',
|
|
58
62
|
headers: { 'Content-Type': 'application/json' },
|
|
59
|
-
body: JSON.stringify({ action: 'start' }),
|
|
63
|
+
body: JSON.stringify({ action: 'start', password: pw }),
|
|
60
64
|
});
|
|
61
65
|
const data = await res.json();
|
|
66
|
+
if (res.status === 403) {
|
|
67
|
+
setPasswordError('Wrong password');
|
|
68
|
+
setLoading(false);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
62
71
|
setTunnel(data);
|
|
72
|
+
setShowPasswordPrompt(false);
|
|
73
|
+
setPassword('');
|
|
63
74
|
} catch {}
|
|
64
75
|
setLoading(false);
|
|
65
76
|
};
|
|
@@ -77,6 +88,38 @@ export default function TunnelToggle() {
|
|
|
77
88
|
return null;
|
|
78
89
|
}
|
|
79
90
|
|
|
91
|
+
// Password prompt
|
|
92
|
+
if (showPasswordPrompt) {
|
|
93
|
+
return (
|
|
94
|
+
<div className="flex items-center gap-1.5">
|
|
95
|
+
<input
|
|
96
|
+
type="password"
|
|
97
|
+
value={password}
|
|
98
|
+
onChange={e => { setPassword(e.target.value); setPasswordError(''); }}
|
|
99
|
+
onKeyDown={e => e.key === 'Enter' && password && doStart(password)}
|
|
100
|
+
placeholder="Login password"
|
|
101
|
+
autoFocus
|
|
102
|
+
className={`w-[120px] text-[10px] px-2 py-0.5 bg-[var(--bg-tertiary)] border rounded font-mono focus:outline-none ${
|
|
103
|
+
passwordError ? 'border-[var(--red)]' : 'border-[var(--border)] focus:border-[var(--accent)]'
|
|
104
|
+
} text-[var(--text-primary)]`}
|
|
105
|
+
/>
|
|
106
|
+
<button
|
|
107
|
+
onClick={() => password && doStart(password)}
|
|
108
|
+
disabled={!password || loading}
|
|
109
|
+
className="text-[10px] px-2 py-0.5 bg-[var(--green)] text-black rounded hover:opacity-90 disabled:opacity-50"
|
|
110
|
+
>
|
|
111
|
+
{loading ? 'Starting...' : 'Start'}
|
|
112
|
+
</button>
|
|
113
|
+
<button
|
|
114
|
+
onClick={() => { setShowPasswordPrompt(false); setPassword(''); setPasswordError(''); }}
|
|
115
|
+
className="text-[10px] px-1.5 py-0.5 text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
|
116
|
+
>
|
|
117
|
+
Cancel
|
|
118
|
+
</button>
|
|
119
|
+
</div>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
80
123
|
// Stop confirmation dialog
|
|
81
124
|
if (confirmStop) {
|
|
82
125
|
return (
|
|
@@ -102,12 +145,12 @@ export default function TunnelToggle() {
|
|
|
102
145
|
if (tunnel.status === 'stopped' && !tunnel.error) {
|
|
103
146
|
return (
|
|
104
147
|
<button
|
|
105
|
-
onClick={
|
|
148
|
+
onClick={() => setShowPasswordPrompt(true)}
|
|
106
149
|
disabled={loading}
|
|
107
150
|
className="text-[10px] px-2 py-0.5 border border-[var(--border)] rounded text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--text-secondary)] transition-colors disabled:opacity-50"
|
|
108
151
|
title="Start Cloudflare Tunnel for remote access"
|
|
109
152
|
>
|
|
110
|
-
|
|
153
|
+
Tunnel
|
|
111
154
|
</button>
|
|
112
155
|
);
|
|
113
156
|
}
|
|
@@ -149,7 +192,7 @@ export default function TunnelToggle() {
|
|
|
149
192
|
Tunnel error
|
|
150
193
|
</span>
|
|
151
194
|
<button
|
|
152
|
-
onClick={
|
|
195
|
+
onClick={() => setShowPasswordPrompt(true)}
|
|
153
196
|
disabled={loading}
|
|
154
197
|
className="text-[10px] px-2 py-0.5 border border-[var(--border)] rounded text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
|
155
198
|
>
|
package/install.sh
CHANGED
package/instrumentation.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Next.js instrumentation — runs once when the server starts.
|
|
3
|
-
*
|
|
3
|
+
* Loads .env.local and prints login password.
|
|
4
4
|
*/
|
|
5
5
|
export async function register() {
|
|
6
6
|
// Only run on server, not Edge
|
|
@@ -23,8 +23,13 @@ export async function register() {
|
|
|
23
23
|
}
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
const
|
|
28
|
-
|
|
26
|
+
// Print password info
|
|
27
|
+
const { getAdminPassword } = await import('./lib/password');
|
|
28
|
+
const admin = getAdminPassword();
|
|
29
|
+
if (admin) {
|
|
30
|
+
console.log(`[init] Admin password: configured`);
|
|
31
|
+
} else {
|
|
32
|
+
console.log('[init] No admin password set — configure in Settings');
|
|
33
|
+
}
|
|
29
34
|
}
|
|
30
35
|
}
|
package/lib/auth.ts
CHANGED
|
@@ -10,23 +10,31 @@ if (!process.env.AUTH_SECRET) {
|
|
|
10
10
|
|
|
11
11
|
export const { handlers, signIn, signOut, auth } = NextAuth({
|
|
12
12
|
trustHost: true,
|
|
13
|
+
logger: {
|
|
14
|
+
error: () => {}, // Suppress noisy CredentialsSignin stack traces
|
|
15
|
+
},
|
|
13
16
|
providers: [
|
|
14
17
|
// Google OAuth — for production use
|
|
15
18
|
Google({
|
|
16
19
|
clientId: process.env.GOOGLE_CLIENT_ID,
|
|
17
20
|
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
|
|
18
21
|
}),
|
|
19
|
-
// Local password
|
|
22
|
+
// Local: admin password only
|
|
23
|
+
// Remote (tunnel): admin password + session code (2FA)
|
|
20
24
|
Credentials({
|
|
21
25
|
name: 'Local',
|
|
22
26
|
credentials: {
|
|
23
27
|
password: { label: 'Password', type: 'password' },
|
|
28
|
+
sessionCode: { label: 'Session Code', type: 'text' },
|
|
29
|
+
isRemote: { label: 'Remote', type: 'text' },
|
|
24
30
|
},
|
|
25
31
|
async authorize(credentials) {
|
|
26
|
-
|
|
27
|
-
const
|
|
28
|
-
const
|
|
29
|
-
|
|
32
|
+
const { verifyLogin } = await import('./password');
|
|
33
|
+
const password = (credentials?.password ?? '') as string;
|
|
34
|
+
const sessionCode = (credentials?.sessionCode ?? '') as string;
|
|
35
|
+
const isRemote = String(credentials?.isRemote) === 'true';
|
|
36
|
+
|
|
37
|
+
if (verifyLogin(password, sessionCode, isRemote)) {
|
|
30
38
|
return { id: 'local', name: 'zliu', email: 'local@forge' };
|
|
31
39
|
}
|
|
32
40
|
return null;
|
|
@@ -40,13 +48,9 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
|
|
|
40
48
|
authorized({ auth }) {
|
|
41
49
|
return !!auth;
|
|
42
50
|
},
|
|
43
|
-
// Allow redirects to tunnel URLs (*.trycloudflare.com) after login
|
|
44
51
|
redirect({ url, baseUrl }) {
|
|
45
|
-
// Same origin — always allow
|
|
46
52
|
if (url.startsWith(baseUrl)) return url;
|
|
47
|
-
// Relative path — prepend base
|
|
48
53
|
if (url.startsWith('/')) return `${baseUrl}${url}`;
|
|
49
|
-
// Cloudflare tunnel URLs — allow
|
|
50
54
|
if (url.includes('.trycloudflare.com')) return url;
|
|
51
55
|
return baseUrl;
|
|
52
56
|
},
|
package/lib/cloudflared.ts
CHANGED
|
@@ -154,6 +154,12 @@ export async function startTunnel(localPort: number = 3000): Promise<{ url?: str
|
|
|
154
154
|
state.error = null;
|
|
155
155
|
state.log = [];
|
|
156
156
|
|
|
157
|
+
// Generate new session code for remote login 2FA
|
|
158
|
+
try {
|
|
159
|
+
const { rotateSessionCode } = require('./password');
|
|
160
|
+
rotateSessionCode();
|
|
161
|
+
} catch {}
|
|
162
|
+
|
|
157
163
|
let binPath: string;
|
|
158
164
|
try {
|
|
159
165
|
binPath = await downloadCloudflared();
|
package/lib/crypto.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Encryption utilities for storing secrets in settings.yaml
|
|
3
|
+
* Uses AES-256-GCM with a persistent key stored in ~/.forge/.encrypt-key
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
7
|
+
import { homedir } from 'node:os';
|
|
8
|
+
import { join, dirname } from 'node:path';
|
|
9
|
+
import { createCipheriv, createDecipheriv, randomBytes, createHash } from 'node:crypto';
|
|
10
|
+
|
|
11
|
+
const DATA_DIR = process.env.FORGE_DATA_DIR || join(homedir(), '.forge');
|
|
12
|
+
const KEY_FILE = join(DATA_DIR, '.encrypt-key');
|
|
13
|
+
const PREFIX = 'enc:';
|
|
14
|
+
|
|
15
|
+
function getEncryptionKey(): Buffer {
|
|
16
|
+
if (existsSync(KEY_FILE)) {
|
|
17
|
+
return Buffer.from(readFileSync(KEY_FILE, 'utf-8').trim(), 'hex');
|
|
18
|
+
}
|
|
19
|
+
// Generate a new 32-byte key
|
|
20
|
+
const key = randomBytes(32);
|
|
21
|
+
const dir = dirname(KEY_FILE);
|
|
22
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
23
|
+
writeFileSync(KEY_FILE, key.toString('hex'), { mode: 0o600 });
|
|
24
|
+
return key;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function encryptSecret(plaintext: string): string {
|
|
28
|
+
if (!plaintext) return '';
|
|
29
|
+
const key = getEncryptionKey();
|
|
30
|
+
const iv = randomBytes(12);
|
|
31
|
+
const cipher = createCipheriv('aes-256-gcm', key, iv);
|
|
32
|
+
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf-8'), cipher.final()]);
|
|
33
|
+
const tag = cipher.getAuthTag();
|
|
34
|
+
// Format: enc:<iv>:<tag>:<ciphertext> (all base64)
|
|
35
|
+
return `${PREFIX}${iv.toString('base64')}.${tag.toString('base64')}.${encrypted.toString('base64')}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function decryptSecret(value: string): string {
|
|
39
|
+
if (!value || !isEncrypted(value)) return value;
|
|
40
|
+
try {
|
|
41
|
+
const payload = value.slice(PREFIX.length);
|
|
42
|
+
const [ivB64, tagB64, dataB64] = payload.split('.');
|
|
43
|
+
const key = getEncryptionKey();
|
|
44
|
+
const iv = Buffer.from(ivB64, 'base64');
|
|
45
|
+
const tag = Buffer.from(tagB64, 'base64');
|
|
46
|
+
const data = Buffer.from(dataB64, 'base64');
|
|
47
|
+
const decipher = createDecipheriv('aes-256-gcm', key, iv);
|
|
48
|
+
decipher.setAuthTag(tag);
|
|
49
|
+
return Buffer.concat([decipher.update(data), decipher.final()]).toString('utf-8');
|
|
50
|
+
} catch {
|
|
51
|
+
// If decryption fails (key changed, corrupted), return empty
|
|
52
|
+
return '';
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function isEncrypted(value: string): boolean {
|
|
57
|
+
return typeof value === 'string' && value.startsWith(PREFIX);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Hash a secret for comparison without exposing plaintext */
|
|
61
|
+
export function hashSecret(value: string): string {
|
|
62
|
+
if (!value) return '';
|
|
63
|
+
return createHash('sha256').update(value).digest('hex').slice(0, 16);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Secret field names in settings */
|
|
67
|
+
export const SECRET_FIELDS = ['telegramBotToken', 'telegramTunnelPassword'] as const;
|
|
68
|
+
export type SecretField = typeof SECRET_FIELDS[number];
|