hop2 0.8.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/.hop-tmux.conf +18 -0
- package/INTEGRATION.md +92 -0
- package/README.md +260 -0
- package/hay/CHANGELOG.md +46 -0
- package/hay/DEV_NOTES.md +39 -0
- package/hay/README.md +290 -0
- package/hay/apps/cli/package.json +25 -0
- package/hay/apps/cli/src/index.ts +1250 -0
- package/hay/apps/cli/tsconfig.json +8 -0
- package/hay/apps/server/package.json +37 -0
- package/hay/apps/server/src/index.ts +103 -0
- package/hay/apps/server/src/lib.ts +161 -0
- package/hay/apps/server/src/pty.ts +68 -0
- package/hay/apps/server/src/rooms.ts +421 -0
- package/hay/apps/server/src/utils.ts +20 -0
- package/hay/apps/server/test/rooms.test.ts +146 -0
- package/hay/apps/server/tsconfig.json +9 -0
- package/hay/apps/web/index.html +12 -0
- package/hay/apps/web/package.json +32 -0
- package/hay/apps/web/scripts/run-e2e.mjs +130 -0
- package/hay/apps/web/src/App.tsx +1302 -0
- package/hay/apps/web/src/components/MobileKeyboard.tsx +701 -0
- package/hay/apps/web/src/main.tsx +13 -0
- package/hay/apps/web/src/setupTests.ts +1 -0
- package/hay/apps/web/src/styles.css +1198 -0
- package/hay/apps/web/src/utils/optimisticEcho.ts +115 -0
- package/hay/apps/web/src/utils/presence.ts +24 -0
- package/hay/apps/web/src/vite-env.d.ts +10 -0
- package/hay/apps/web/test/optimisticEcho.test.ts +39 -0
- package/hay/apps/web/test/presence.test.ts +18 -0
- package/hay/apps/web/tsconfig.json +8 -0
- package/hay/apps/web/vite.config.ts +13 -0
- package/hay/apps/web/vitest.config.ts +12 -0
- package/hay/package-lock.json +5988 -0
- package/hay/package.json +18 -0
- package/hay/packages/shared/package.json +27 -0
- package/hay/packages/shared/src/colors.ts +16 -0
- package/hay/packages/shared/src/index.ts +2 -0
- package/hay/packages/shared/src/protocol.ts +90 -0
- package/hay/packages/shared/test/protocol.test.ts +33 -0
- package/hay/packages/shared/tsconfig.json +7 -0
- package/hay/tsconfig.base.json +15 -0
- package/hay-web/assets/index-CTTzApRp.css +32 -0
- package/hay-web/assets/index-FDfdolCs.js +60 -0
- package/hay-web/assets/index-FDfdolCs.js.map +1 -0
- package/hay-web/index.html +13 -0
- package/hop +3717 -0
- package/package.json +52 -0
- package/sessions.html +243 -0
- package/tunnel-shell.js +254 -0
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "hop2",
|
|
3
|
+
"version": "0.8.0",
|
|
4
|
+
"description": "🐰 Hop into your terminal from anywhere. Secure remote shell access with 2FA via Cloudflare Tunnel. Next-gen rewrite with native terminal sharing.",
|
|
5
|
+
"main": "hop",
|
|
6
|
+
"bin": {
|
|
7
|
+
"hop": "./hop"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"start": "node hop",
|
|
11
|
+
"build": "npm --prefix hay install && npm --prefix hay run build && rsync -a --delete hay/apps/web/dist/ hay-web/",
|
|
12
|
+
"test": "node --check hop"
|
|
13
|
+
},
|
|
14
|
+
"keywords": [
|
|
15
|
+
"terminal",
|
|
16
|
+
"remote",
|
|
17
|
+
"shell",
|
|
18
|
+
"ssh",
|
|
19
|
+
"tunnel",
|
|
20
|
+
"cloudflare",
|
|
21
|
+
"termshare",
|
|
22
|
+
"2fa",
|
|
23
|
+
"totp",
|
|
24
|
+
"web-terminal",
|
|
25
|
+
"mobile"
|
|
26
|
+
],
|
|
27
|
+
"author": "",
|
|
28
|
+
"license": "MIT",
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "https://github.com/jzthree/hop"
|
|
32
|
+
},
|
|
33
|
+
"homepage": "https://github.com/jzthree/hop#readme",
|
|
34
|
+
"bugs": {
|
|
35
|
+
"url": "https://github.com/jzthree/hop/issues"
|
|
36
|
+
},
|
|
37
|
+
"engines": {
|
|
38
|
+
"node": ">=16.0.0"
|
|
39
|
+
},
|
|
40
|
+
"os": [
|
|
41
|
+
"darwin",
|
|
42
|
+
"linux"
|
|
43
|
+
],
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"cookie": "^1.1.1",
|
|
46
|
+
"http-proxy": "^1.18.1",
|
|
47
|
+
"node-pty-prebuilt-multiarch": "^0.10.1-pre.5",
|
|
48
|
+
"otplib": "^12.0.1",
|
|
49
|
+
"qrcode-terminal": "^0.12.0",
|
|
50
|
+
"ws": "^8.18.0"
|
|
51
|
+
}
|
|
52
|
+
}
|
package/sessions.html
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
<html>
|
|
2
|
+
<head>
|
|
3
|
+
<meta charset="UTF-8">
|
|
4
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
|
|
5
|
+
<title>Session Picker</title>
|
|
6
|
+
<style>
|
|
7
|
+
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
|
|
8
|
+
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #fafafa; color: #18181b; margin: 0; padding: 1.5rem; min-height: 100vh; }
|
|
9
|
+
.container { max-width: 600px; margin: 0 auto; }
|
|
10
|
+
h1 { font-size: 1.25rem; font-weight: 600; margin-bottom: 0.5rem; }
|
|
11
|
+
.subtitle { color: #71717a; font-size: 0.875rem; margin-bottom: 1rem; }
|
|
12
|
+
.session-list { background: #fff; border: 1px solid #e4e4e7; border-radius: 12px; overflow: hidden; margin-bottom: 1.5rem; }
|
|
13
|
+
.session-item-wrapper { position: relative; overflow: hidden; border-bottom: 1px solid #e4e4e7; }
|
|
14
|
+
.session-item-wrapper:last-child { border-bottom: none; }
|
|
15
|
+
.session-item { display: flex; justify-content: space-between; align-items: center; padding: 1rem; gap: 1rem; background: #fff; position: relative; transition: transform 0.2s ease-out; }
|
|
16
|
+
.session-item:active { background: #f4f4f5; }
|
|
17
|
+
.session-action { position: absolute; top: 0; bottom: 0; width: 80px; display: flex; align-items: center; justify-content: center; color: #fff; font-weight: 500; cursor: pointer; font-size: 0.85rem; }
|
|
18
|
+
.session-action.rename { left: 0; background: #3b82f6; }
|
|
19
|
+
.session-action.rename:active { background: #2563eb; }
|
|
20
|
+
.session-action.delete { right: 0; background: #dc2626; }
|
|
21
|
+
.session-action.delete:active { background: #b91c1c; }
|
|
22
|
+
.session-info { flex: 1; min-width: 0; }
|
|
23
|
+
.session-header { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
|
|
24
|
+
.session-name { font-weight: 500; font-size: 1rem; }
|
|
25
|
+
.active-badge { background: #22c55e; color: #fff; padding: 0.15rem 0.4rem; border-radius: 4px; font-size: 0.65rem; font-weight: 600; text-transform: uppercase; white-space: nowrap; }
|
|
26
|
+
.starting-badge { background: #f59e0b; color: #111827; padding: 0.15rem 0.4rem; border-radius: 4px; font-size: 0.65rem; font-weight: 600; text-transform: uppercase; white-space: nowrap; }
|
|
27
|
+
.session-meta { color: #71717a; font-size: 0.8rem; margin-top: 0.25rem; }
|
|
28
|
+
.btn { padding: 0.75rem 1.25rem; border: none; border-radius: 8px; cursor: pointer; font-size: 1rem; font-weight: 500; text-decoration: none; display: inline-block; min-width: 80px; text-align: center; transition: opacity 0.2s; }
|
|
29
|
+
.btn-primary { background: #18181b; color: #fff; }
|
|
30
|
+
.btn-primary:hover, .btn-primary:active { opacity: 0.85; }
|
|
31
|
+
.btn-secondary { background: #e4e4e7; color: #18181b; }
|
|
32
|
+
.btn-secondary:hover { background: #d4d4d8; }
|
|
33
|
+
.btn:disabled { opacity: 0.5; cursor: wait; }
|
|
34
|
+
.new-session { display: flex; gap: 0.75rem; flex-wrap: wrap; margin-bottom: 0.75rem; }
|
|
35
|
+
.new-session input,
|
|
36
|
+
.new-session select { flex: 1; min-width: 180px; padding: 0.875rem 1rem; border: 1px solid #e4e4e7; border-radius: 8px; font-size: 1rem; background: #fff; }
|
|
37
|
+
.new-session input:focus { outline: none; border-color: #7c3aed; }
|
|
38
|
+
.new-session select:focus { outline: none; border-color: #7c3aed; }
|
|
39
|
+
.quick-names { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 1rem; }
|
|
40
|
+
.quick-names button { padding: 0.5rem 1rem; font-size: 0.875rem; border-radius: 6px; }
|
|
41
|
+
.empty { padding: 2rem; text-align: center; color: #71717a; }
|
|
42
|
+
.active-badge { background: #22c55e; color: #fff; padding: 0.25rem 0.5rem; border-radius: 4px; font-size: 0.7rem; margin-left: 0.5rem; vertical-align: middle; }
|
|
43
|
+
.port-badge { background: #f59e0b; color: #111827; padding: 0.25rem 0.5rem; border-radius: 4px; font-size: 0.7rem; margin-left: 0.5rem; vertical-align: middle; font-weight: 600; text-transform: uppercase; }
|
|
44
|
+
.loading { display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(255,255,255,0.9); justify-content: center; align-items: center; flex-direction: column; z-index: 100; }
|
|
45
|
+
.loading.show { display: flex; }
|
|
46
|
+
.spinner { width: 40px; height: 40px; border: 3px solid #e4e4e7; border-top-color: #7c3aed; border-radius: 50%; animation: spin 1s linear infinite; }
|
|
47
|
+
@keyframes spin { to { transform: rotate(360deg); } }
|
|
48
|
+
@media (max-width: 480px) {
|
|
49
|
+
body { padding: 1rem; }
|
|
50
|
+
.new-session { flex-direction: column; }
|
|
51
|
+
.new-session input,
|
|
52
|
+
.new-session select { min-width: 100%; }
|
|
53
|
+
.new-session .btn { width: 100%; }
|
|
54
|
+
}
|
|
55
|
+
</style>
|
|
56
|
+
</head>
|
|
57
|
+
<body>
|
|
58
|
+
<div class="loading" id="loading">
|
|
59
|
+
<div class="spinner"></div>
|
|
60
|
+
<p style="margin-top: 1rem; color: #71717a;">Starting session...</p>
|
|
61
|
+
</div>
|
|
62
|
+
<div class="container">
|
|
63
|
+
<h1>Session Picker</h1>
|
|
64
|
+
<p class="subtitle">Create a terminal or port session, rename existing ones, or join one. Swipe left to delete.</p>
|
|
65
|
+
<div class="new-session">
|
|
66
|
+
<input type="text" id="newName" placeholder="Session name..." pattern="[a-zA-Z0-9_-]+">
|
|
67
|
+
<select id="sessionType">
|
|
68
|
+
<option value="terminal">Terminal</option>
|
|
69
|
+
<option value="port">Port</option>
|
|
70
|
+
</select>
|
|
71
|
+
<input type="number" id="portInput" placeholder="Port (e.g. 3000)" min="1" max="65535" style="display:none;">
|
|
72
|
+
<button class="btn btn-primary" onclick="createSession()">Create</button>
|
|
73
|
+
</div>
|
|
74
|
+
<div class="session-list" id="sessionList">
|
|
75
|
+
<div class="empty">Loading sessions...</div>
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
<script>
|
|
79
|
+
function showLoading() {
|
|
80
|
+
document.getElementById('loading').classList.add('show');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function loadSessions() {
|
|
84
|
+
const res = await fetch('/api/sessions');
|
|
85
|
+
const data = await res.json();
|
|
86
|
+
const list = document.getElementById('sessionList');
|
|
87
|
+
|
|
88
|
+
if (data.sessions.length === 0 && data.active.length === 0) {
|
|
89
|
+
list.innerHTML = '<div class="empty">Waiting for first session to start...</div>';
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Build session list using displayName for UI, name for API
|
|
94
|
+
const sessionMap = new Map();
|
|
95
|
+
data.sessions.forEach(s => sessionMap.set(s.name, s));
|
|
96
|
+
data.active.forEach(n => { if (!sessionMap.has(n)) sessionMap.set(n, { name: n }); });
|
|
97
|
+
|
|
98
|
+
list.innerHTML = Array.from(sessionMap.values()).map(s => {
|
|
99
|
+
const name = s.name;
|
|
100
|
+
const displayName = s.displayName || name;
|
|
101
|
+
const isActive = data.active.includes(name);
|
|
102
|
+
const isStarting = (data.starting || []).includes(name);
|
|
103
|
+
const isPort = s.type === 'port';
|
|
104
|
+
const portLabel = isPort ? `PORT ${s.port || ''}`.trim() : '';
|
|
105
|
+
return `
|
|
106
|
+
<div class="session-item-wrapper" data-session="${name}">
|
|
107
|
+
<div class="session-action rename" onclick="renameSession('${name}', '${displayName}')">Rename</div>
|
|
108
|
+
<div class="session-action delete" onclick="deleteSession('${name}')">Delete</div>
|
|
109
|
+
<div class="session-item">
|
|
110
|
+
<div class="session-info">
|
|
111
|
+
<div class="session-header">
|
|
112
|
+
<span class="session-name">${displayName}</span>
|
|
113
|
+
${isActive ? '<span class="active-badge">LIVE</span>' : (isStarting ? '<span class="starting-badge">STARTING</span>' : (isPort ? `<span class="port-badge">${portLabel}</span>` : ''))}
|
|
114
|
+
</div>
|
|
115
|
+
<div class="session-meta">${isPort ? `Proxy to localhost:${s.port || ''}` : (s.windows ? s.windows + ' window(s)' : (isStarting ? 'Starting...' : 'Not started'))}</div>
|
|
116
|
+
</div>
|
|
117
|
+
<a href="/s/${encodeURIComponent(name)}/" class="btn btn-primary" onclick="showLoading()">Join</a>
|
|
118
|
+
</div>
|
|
119
|
+
</div>
|
|
120
|
+
`;
|
|
121
|
+
}).join('');
|
|
122
|
+
initSwipe();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function createSession() {
|
|
126
|
+
const input = document.getElementById('newName');
|
|
127
|
+
const name = input.value.trim();
|
|
128
|
+
if (!name) { input.focus(); return; }
|
|
129
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(name)) return alert('Only letters, numbers, - and _ allowed');
|
|
130
|
+
|
|
131
|
+
const type = document.getElementById('sessionType').value;
|
|
132
|
+
const portInput = document.getElementById('portInput');
|
|
133
|
+
let port = null;
|
|
134
|
+
if (type === 'port') {
|
|
135
|
+
port = parseInt(portInput.value, 10);
|
|
136
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
137
|
+
portInput.focus();
|
|
138
|
+
return alert('Enter a valid port (1-65535)');
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
showLoading();
|
|
143
|
+
const res = await fetch('/api/sessions', {
|
|
144
|
+
method: 'POST',
|
|
145
|
+
headers: { 'Content-Type': 'application/json' },
|
|
146
|
+
body: JSON.stringify({ name, type, port })
|
|
147
|
+
});
|
|
148
|
+
const data = await res.json();
|
|
149
|
+
window.location.href = '/s/' + encodeURIComponent(data.name) + '/';
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function renameSession(oldName, oldDisplayName) {
|
|
153
|
+
const newDisplayName = prompt('Enter new session name:', oldDisplayName);
|
|
154
|
+
if (!newDisplayName || newDisplayName === oldDisplayName) return;
|
|
155
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(newDisplayName)) return alert('Only letters, numbers, - and _ allowed');
|
|
156
|
+
|
|
157
|
+
await fetch('/api/sessions/rename', {
|
|
158
|
+
method: 'POST',
|
|
159
|
+
headers: { 'Content-Type': 'application/json' },
|
|
160
|
+
body: JSON.stringify({ oldName, newName: newDisplayName })
|
|
161
|
+
});
|
|
162
|
+
loadSessions();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function deleteSession(name) {
|
|
166
|
+
if (!confirm('Delete session "' + name + '"? This will remove the session.')) return;
|
|
167
|
+
|
|
168
|
+
await fetch('/api/sessions/delete', {
|
|
169
|
+
method: 'POST',
|
|
170
|
+
headers: { 'Content-Type': 'application/json' },
|
|
171
|
+
body: JSON.stringify({ name })
|
|
172
|
+
});
|
|
173
|
+
loadSessions();
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function initSwipe() {
|
|
177
|
+
document.querySelectorAll('.session-item-wrapper').forEach(wrapper => {
|
|
178
|
+
const item = wrapper.querySelector('.session-item');
|
|
179
|
+
let startX, currentX, isDragging = false;
|
|
180
|
+
|
|
181
|
+
wrapper.addEventListener('touchstart', e => {
|
|
182
|
+
startX = e.touches[0].clientX;
|
|
183
|
+
currentX = startX;
|
|
184
|
+
isDragging = true;
|
|
185
|
+
item.style.transition = 'none';
|
|
186
|
+
}, { passive: true });
|
|
187
|
+
|
|
188
|
+
wrapper.addEventListener('touchmove', e => {
|
|
189
|
+
if (!isDragging) return;
|
|
190
|
+
currentX = e.touches[0].clientX;
|
|
191
|
+
let dx = currentX - startX;
|
|
192
|
+
// Clamp: -80 (left/delete) to +80 (right/rename)
|
|
193
|
+
dx = Math.max(-80, Math.min(80, dx));
|
|
194
|
+
item.style.transform = 'translateX(' + dx + 'px)';
|
|
195
|
+
}, { passive: true });
|
|
196
|
+
|
|
197
|
+
wrapper.addEventListener('touchend', () => {
|
|
198
|
+
if (!isDragging) return;
|
|
199
|
+
isDragging = false;
|
|
200
|
+
item.style.transition = 'transform 0.2s ease-out';
|
|
201
|
+
const dx = currentX - startX;
|
|
202
|
+
if (dx < -40) {
|
|
203
|
+
item.style.transform = 'translateX(-80px)'; // Show delete
|
|
204
|
+
} else if (dx > 40) {
|
|
205
|
+
item.style.transform = 'translateX(80px)'; // Show rename
|
|
206
|
+
} else {
|
|
207
|
+
item.style.transform = 'translateX(0)';
|
|
208
|
+
}
|
|
209
|
+
}, { passive: true });
|
|
210
|
+
|
|
211
|
+
// Close on tap elsewhere
|
|
212
|
+
item.addEventListener('click', e => {
|
|
213
|
+
const transform = item.style.transform;
|
|
214
|
+
if (transform && transform !== 'translateX(0px)' && transform !== 'translateX(0)') {
|
|
215
|
+
e.preventDefault();
|
|
216
|
+
e.stopPropagation();
|
|
217
|
+
item.style.transform = 'translateX(0)';
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
document.getElementById('newName').addEventListener('keypress', e => {
|
|
224
|
+
if (e.key === 'Enter') createSession();
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
const typeSelect = document.getElementById('sessionType');
|
|
228
|
+
const portInput = document.getElementById('portInput');
|
|
229
|
+
typeSelect.addEventListener('change', () => {
|
|
230
|
+
if (typeSelect.value === 'port') {
|
|
231
|
+
portInput.style.display = 'block';
|
|
232
|
+
portInput.focus();
|
|
233
|
+
} else {
|
|
234
|
+
portInput.style.display = 'none';
|
|
235
|
+
portInput.value = '';
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
loadSessions();
|
|
240
|
+
setInterval(loadSessions, 3000);
|
|
241
|
+
</script>
|
|
242
|
+
</body>
|
|
243
|
+
</html>
|
package/tunnel-shell.js
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const { spawn } = require('child_process');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const crypto = require('crypto');
|
|
7
|
+
const http = require('http');
|
|
8
|
+
const httpProxy = require('http-proxy');
|
|
9
|
+
const { authenticator } = require('otplib');
|
|
10
|
+
const qrcode = require('qrcode-terminal');
|
|
11
|
+
const cookie = require('cookie');
|
|
12
|
+
|
|
13
|
+
// --- Configuration ---
|
|
14
|
+
const TTYD_PORT = Math.floor(Math.random() * (60000 - 10000 + 1) + 10000);
|
|
15
|
+
const PROXY_PORT = TTYD_PORT + 1;
|
|
16
|
+
const SESSION_NAME = `remote-${crypto.randomBytes(3).toString('hex')}`;
|
|
17
|
+
const AUTH_SECRET = authenticator.generateSecret();
|
|
18
|
+
const SESSION_COOKIE_NAME = 'tunnel_session';
|
|
19
|
+
const SESSION_SECRET = crypto.randomBytes(32).toString('hex');
|
|
20
|
+
|
|
21
|
+
// Colors
|
|
22
|
+
const RESET = "\x1b[0m";
|
|
23
|
+
const GREEN = "\x1b[32m";
|
|
24
|
+
const YELLOW = "\x1b[33m";
|
|
25
|
+
const CYAN = "\x1b[36m";
|
|
26
|
+
const RED = "\x1b[31m";
|
|
27
|
+
const BOLD = "\x1b[1m";
|
|
28
|
+
|
|
29
|
+
function log(msg, color = RESET) {
|
|
30
|
+
console.log(`${color}${msg}${RESET}`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function runCommand(command, args, opts = {}) {
|
|
34
|
+
return new Promise((resolve, reject) => {
|
|
35
|
+
const proc = spawn(command, args, { stdio: 'inherit', ...opts });
|
|
36
|
+
proc.on('close', (code) => {
|
|
37
|
+
if (code === 0) resolve();
|
|
38
|
+
else reject(new Error(`Command ${command} exited with code ${code}`));
|
|
39
|
+
});
|
|
40
|
+
proc.on('error', (err) => reject(err));
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ... Dependency check unchanged ...
|
|
45
|
+
async function checkAndInstallDependencies() {
|
|
46
|
+
log(`[1/5] Checking dependencies...`, BOLD);
|
|
47
|
+
try { await runCommand('brew', ['--version'], { stdio: 'ignore' }); }
|
|
48
|
+
catch (e) { log(`Error: Homebrew missing.`, RED); process.exit(1); }
|
|
49
|
+
|
|
50
|
+
const deps = ['tmux', 'ttyd', 'cloudflared'];
|
|
51
|
+
for (const dep of deps) {
|
|
52
|
+
try {
|
|
53
|
+
await runCommand('which', [dep], { stdio: 'ignore' });
|
|
54
|
+
log(` - ${dep} found.`, GREEN);
|
|
55
|
+
} catch (e) {
|
|
56
|
+
log(` - ${dep} NOT found. Installing...`, YELLOW);
|
|
57
|
+
try {
|
|
58
|
+
await runCommand('brew', ['install', dep]);
|
|
59
|
+
log(` - ${dep} installed.`, GREEN);
|
|
60
|
+
} catch (err) {
|
|
61
|
+
log(` - Failed to install ${dep}. Check Xcode license?`, RED);
|
|
62
|
+
process.exit(1);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function startTtyd() {
|
|
69
|
+
log(`[2/5] Starting Secure Shell (ttyd) on port ${TTYD_PORT}...`, BOLD);
|
|
70
|
+
const logFile = fs.openSync(path.join(__dirname, 'ttyd.log'), 'w');
|
|
71
|
+
|
|
72
|
+
// ttyd listens on localhost only. No auth here (proxy handles it).
|
|
73
|
+
// We bind to 127.0.0.1 to allow only the proxy to talk to it.
|
|
74
|
+
const ttyd = spawn('ttyd', [
|
|
75
|
+
'-p', TTYD_PORT.toString(),
|
|
76
|
+
'-i', '127.0.0.1',
|
|
77
|
+
'-W',
|
|
78
|
+
'tmux', 'new-session', '-A', '-s', SESSION_NAME, 'zsh'
|
|
79
|
+
], {
|
|
80
|
+
stdio: ['ignore', logFile, logFile],
|
|
81
|
+
detached: false
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
ttyd.on('error', (err) => { log(`Failed TTYD: ${err.message}`, RED); process.exit(1); });
|
|
85
|
+
return ttyd;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function startAuthProxy() {
|
|
89
|
+
log(`[3/5] Starting Local 2FA Proxy on port ${PROXY_PORT}...`, BOLD);
|
|
90
|
+
|
|
91
|
+
const proxy = httpProxy.createProxyServer({ target: `http://127.0.0.1:${TTYD_PORT}`, ws: true });
|
|
92
|
+
|
|
93
|
+
const server = http.createServer((req, res) => {
|
|
94
|
+
// Parse cookie
|
|
95
|
+
const cookies = cookie.parse(req.headers.cookie || '');
|
|
96
|
+
const isAuthenticated = cookies[SESSION_COOKIE_NAME] === SESSION_SECRET;
|
|
97
|
+
|
|
98
|
+
// Login Page
|
|
99
|
+
if (!isAuthenticated) {
|
|
100
|
+
if (req.method === 'POST') {
|
|
101
|
+
let body = '';
|
|
102
|
+
req.on('data', chunk => body += chunk);
|
|
103
|
+
req.on('end', () => {
|
|
104
|
+
const params = new URLSearchParams(body);
|
|
105
|
+
const token = params.get('token');
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
if (authenticator.check(token, AUTH_SECRET)) {
|
|
109
|
+
res.setHeader('Set-Cookie', cookie.serialize(SESSION_COOKIE_NAME, SESSION_SECRET, {
|
|
110
|
+
httpOnly: true,
|
|
111
|
+
maxAge: 60 * 60 * 24 * 7 // 1 week
|
|
112
|
+
}));
|
|
113
|
+
res.writeHead(302, { 'Location': '/' });
|
|
114
|
+
res.end();
|
|
115
|
+
} else {
|
|
116
|
+
res.writeHead(401, { 'Content-Type': 'text/html' });
|
|
117
|
+
res.end('Invalid Code. <a href="/">Try Again</a>');
|
|
118
|
+
}
|
|
119
|
+
} catch (e) {
|
|
120
|
+
res.writeHead(401);
|
|
121
|
+
res.end('Error verifying code');
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
} else {
|
|
125
|
+
// Return Login HTML
|
|
126
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
127
|
+
res.end(`
|
|
128
|
+
<html>
|
|
129
|
+
<head>
|
|
130
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
131
|
+
<style>
|
|
132
|
+
body { font-family: -apple-system, sans-serif; background: #111; color: #eee; display: flex; justify-content: center; align-items: center; height: 100vh; margin:0; }
|
|
133
|
+
.box { background: #222; padding: 2rem; border-radius: 8px; text-align: center; }
|
|
134
|
+
input { font-size: 2rem; width: 150px; text-align: center; margin-bottom: 1rem; border-radius: 4px; border: none; padding: 0.5rem; }
|
|
135
|
+
button { font-size: 1.2rem; padding: 0.5rem 2rem; background: #0070f3; color: white; border: none; border-radius: 4px; cursor: pointer; }
|
|
136
|
+
</style>
|
|
137
|
+
</head>
|
|
138
|
+
<body>
|
|
139
|
+
<div class="box">
|
|
140
|
+
<h2>Secure Shell Login</h2>
|
|
141
|
+
<form method="POST">
|
|
142
|
+
<input type="text" name="token" pattern="[0-9]*" inputmode="numeric" placeholder="123456" autofocus autocomplete="one-time-code">
|
|
143
|
+
<br>
|
|
144
|
+
<button type="submit">Verify</button>
|
|
145
|
+
</form>
|
|
146
|
+
</div>
|
|
147
|
+
</body>
|
|
148
|
+
</html>
|
|
149
|
+
`);
|
|
150
|
+
}
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Authenticated -> Proxy to ttyd
|
|
155
|
+
proxy.web(req, res);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
server.on('upgrade', (req, socket, head) => {
|
|
159
|
+
const cookies = cookie.parse(req.headers.cookie || '');
|
|
160
|
+
if (cookies[SESSION_COOKIE_NAME] === SESSION_SECRET) {
|
|
161
|
+
proxy.ws(req, socket, head);
|
|
162
|
+
} else {
|
|
163
|
+
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
|
164
|
+
socket.destroy();
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
server.listen(PROXY_PORT, '127.0.0.1');
|
|
169
|
+
return server;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function startTunnel() {
|
|
173
|
+
log(`[4/5] Opening Cloudflare Tunnel...`, BOLD);
|
|
174
|
+
// Tunnel points to PROXY_PORT now, not TTYD_PORT
|
|
175
|
+
const tunnel = spawn('cloudflared', ['tunnel', '--url', `http://localhost:${PROXY_PORT}`]);
|
|
176
|
+
let urlFound = false;
|
|
177
|
+
|
|
178
|
+
return new Promise((resolve) => {
|
|
179
|
+
tunnel.stderr.on('data', (data) => {
|
|
180
|
+
if (urlFound) return;
|
|
181
|
+
const output = data.toString();
|
|
182
|
+
const match = output.match(/https:\/\/[a-zA-Z0-9-]+\.trycloudflare\.com/);
|
|
183
|
+
|
|
184
|
+
if (match) {
|
|
185
|
+
const publicUrl = match[0];
|
|
186
|
+
if (publicUrl.includes('api.trycloudflare.com')) return;
|
|
187
|
+
|
|
188
|
+
urlFound = true;
|
|
189
|
+
|
|
190
|
+
console.clear();
|
|
191
|
+
log(`\n==================================================`, GREEN);
|
|
192
|
+
log(` >>> TUNNEL LIVE <<<`, BOLD + GREEN);
|
|
193
|
+
log(`==================================================\n`, GREEN);
|
|
194
|
+
log(`1. Scan this QR Code with your Authenticator App:`, YELLOW);
|
|
195
|
+
|
|
196
|
+
qrcode.generate(`otpauth://totp/TunnelShell?secret=${AUTH_SECRET}&issuer=TunnelShell`, { small: true });
|
|
197
|
+
|
|
198
|
+
log(`\n2. Access your shell at:`, RESET);
|
|
199
|
+
log(` ${publicUrl}`, BOLD + CYAN);
|
|
200
|
+
log(`\n--------------------------------------------------`, RESET);
|
|
201
|
+
|
|
202
|
+
// Wait for user to scan
|
|
203
|
+
const readline = require('readline');
|
|
204
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
205
|
+
|
|
206
|
+
log(`\n[ACTION REQUIRED]`, YELLOW + BOLD);
|
|
207
|
+
rl.question('Scan the QR code above, then press ENTER to launch your session...', () => {
|
|
208
|
+
rl.close();
|
|
209
|
+
resolve({ tunnel, publicUrl });
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function main() {
|
|
217
|
+
await checkAndInstallDependencies();
|
|
218
|
+
const ttydProc = startTtyd();
|
|
219
|
+
const proxyServer = startAuthProxy();
|
|
220
|
+
const { tunnel: tunnelProc, publicUrl } = await startTunnel();
|
|
221
|
+
|
|
222
|
+
log(`[5/5] Attaching to local session...`, BOLD);
|
|
223
|
+
|
|
224
|
+
const tmux = spawn('tmux', ['new-session', '-A', '-s', SESSION_NAME, 'zsh'], { stdio: 'inherit' });
|
|
225
|
+
|
|
226
|
+
setTimeout(() => {
|
|
227
|
+
spawn('tmux', ['set-option', '-t', SESSION_NAME, 'status', 'on']);
|
|
228
|
+
spawn('tmux', ['set-option', '-t', SESSION_NAME, 'status-right-length', '200']);
|
|
229
|
+
spawn('tmux', ['set-option', '-t', SESSION_NAME, 'status-right', ` #[fg=black,bg=green] REMOTE: ${publicUrl} #[default] `]);
|
|
230
|
+
spawn('tmux', ['set-option', '-t', SESSION_NAME, 'mouse', 'on']);
|
|
231
|
+
}, 1000);
|
|
232
|
+
|
|
233
|
+
tmux.on('close', () => {
|
|
234
|
+
log(`\nLocal session ended. Shutting down tunnel...`, YELLOW);
|
|
235
|
+
ttydProc.kill();
|
|
236
|
+
tunnelProc.kill();
|
|
237
|
+
proxyServer.close();
|
|
238
|
+
process.exit(0);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
const cleanup = () => {
|
|
242
|
+
ttydProc.kill();
|
|
243
|
+
tunnelProc.kill();
|
|
244
|
+
proxyServer.close();
|
|
245
|
+
process.exit();
|
|
246
|
+
}
|
|
247
|
+
process.on('SIGINT', cleanup);
|
|
248
|
+
process.on('SIGTERM', cleanup);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
main().catch(err => {
|
|
252
|
+
log(`Fatal: ${err.message}`, RED);
|
|
253
|
+
process.exit(1);
|
|
254
|
+
});
|