pinokiod 8.2.29 → 8.2.31
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/kernel/anywhere/forward.js +162 -0
- package/kernel/anywhere/index.js +60 -11
- package/kernel/anywhere/ports.js +1 -1
- package/kernel/anywhere/serve.js +21 -5
- package/kernel/bin/conda.js +1 -1
- package/kernel/bin/setup.js +1 -1
- package/kernel/bin/tailscaled.js +2 -2
- package/package.json +1 -1
- package/server/index.js +138 -2
- package/server/views/anywhere.ejs +149 -47
- package/test/anywhere-clipboard.test.js +18 -17
- package/test/anywhere-forward.test.js +135 -0
- package/test/anywhere-onboarding.test.js +95 -0
- package/test/anywhere-others.test.js +96 -0
- package/test/anywhere-peers.test.js +199 -0
|
@@ -40,8 +40,29 @@ body.dark .anywhere-card { border-color: rgba(255,255,255,0.1); background: rgba
|
|
|
40
40
|
.anywhere-muted { opacity: 0.7; font-size: 13px; }
|
|
41
41
|
.anywhere-grid { display: grid; grid-template-columns: minmax(280px, 380px) 1fr; gap: 16px; align-items: start; }
|
|
42
42
|
@media (max-width: 900px) { .anywhere-grid { grid-template-columns: 1fr; } }
|
|
43
|
-
.anywhere-
|
|
44
|
-
|
|
43
|
+
.anywhere-signout, .anywhere-linkbtn { background: none; border: none; padding: 0; font: inherit; color: inherit; text-decoration: underline; cursor: pointer; }
|
|
44
|
+
/* which device is connecting: a segmented control, the phone panel by default */
|
|
45
|
+
.anywhere-segments { display: grid; grid-template-columns: 1fr 1fr; width: 100%; padding: 4px; border-radius: 12px; background: rgba(0,0,0,0.05); }
|
|
46
|
+
body.dark .anywhere-segments { background: rgba(255,255,255,0.08); }
|
|
47
|
+
.anywhere-segments label { position: relative; display: block; }
|
|
48
|
+
.anywhere-segments input { position: absolute; opacity: 0; width: 0; height: 0; }
|
|
49
|
+
.anywhere-segments span { display: block; text-align: center; padding: 9px 12px; border-radius: 9px; font-size: 14px; cursor: pointer; opacity: 0.7; transition: opacity 0.15s; }
|
|
50
|
+
.anywhere-segments span:hover { opacity: 1; }
|
|
51
|
+
.anywhere-segments input:checked + span { background: white; font-weight: bold; opacity: 1; box-shadow: 0 1px 3px rgba(0,0,0,0.12); }
|
|
52
|
+
body.dark .anywhere-segments input:checked + span { background: rgba(255,255,255,0.16); box-shadow: none; }
|
|
53
|
+
.anywhere-segments input:focus-visible + span { outline: 2px solid currentColor; outline-offset: 1px; }
|
|
54
|
+
#anywhere-connect[data-device=phone] [data-panel=computer], #anywhere-connect[data-device=computer] [data-panel=phone] { display: none; }
|
|
55
|
+
#anywhere-connect [data-panel] { display: flex; flex-direction: column; align-items: center; gap: 14px; width: 100%; }
|
|
56
|
+
#anywhere-connect [data-panel] p { margin: 0; max-width: 360px; font-size: 15px; line-height: 1.5; }
|
|
57
|
+
#anywhere-connect [data-panel=computer] { padding-top: 10px; }
|
|
58
|
+
.anywhere-warning { padding: 10px 14px; border-radius: 8px; background: rgba(255,140,0,0.14); font-size: 13px; line-height: 1.5; margin: 16px 0 0; }
|
|
59
|
+
.anywhere-warning a { color: inherit; }
|
|
60
|
+
.anywhere-connect-card { max-width: 440px; padding: 22px 24px 24px; }
|
|
61
|
+
.anywhere-connect-title { text-align: center; font-size: 17px; margin: 0 0 14px; }
|
|
62
|
+
.anywhere-qr { display: flex; flex-direction: column; align-items: center; gap: 14px; text-align: center; }
|
|
63
|
+
.anywhere-qr img { width: 212px; height: 212px; border-radius: 14px; background: white; padding: 12px; border: 1px solid rgba(0,0,0,0.08); margin-top: 4px; }
|
|
64
|
+
body.dark .anywhere-qr img { border-color: transparent; }
|
|
65
|
+
.anywhere-qr > .anywhere-muted { font-size: 12px; margin-top: 2px; }
|
|
45
66
|
.anywhere-address {
|
|
46
67
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
47
68
|
font-size: 15px; word-break: break-all;
|
|
@@ -76,9 +97,7 @@ body.dark .anywhere-row { border-top-color: rgba(255,255,255,0.06); }
|
|
|
76
97
|
.anywhere-row a.remote { font-family: ui-monospace, Menlo, monospace; font-size: 13px; color: royalblue; text-decoration: none; word-break: break-all; }
|
|
77
98
|
.anywhere-dot { width: 9px; height: 9px; border-radius: 50%; background: silver; flex-shrink: 0; }
|
|
78
99
|
.anywhere-dot.online { background: yellowgreen; }
|
|
79
|
-
.anywhere-
|
|
80
|
-
.anywhere-steps li { margin: 6px 0; line-height: 1.5; }
|
|
81
|
-
.anywhere-waiting { display: inline-flex; align-items: center; gap: 8px; padding: 8px 12px; border-radius: 8px; background: rgba(255,200,0,0.15); font-size: 13px; }
|
|
100
|
+
.anywhere-waiting { display: inline-flex; align-items: center; gap: 8px; padding: 6px 12px; border-radius: 999px; background: rgba(255,200,0,0.15); font-size: 12px; }
|
|
82
101
|
.anywhere-waiting.joined { background: rgba(154,205,50,0.2); }
|
|
83
102
|
.anywhere-switch { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; padding: 8px 12px; border-radius: 8px; background: rgba(0,0,0,0.05); }
|
|
84
103
|
body.dark .anywhere-switch { background: rgba(255,255,255,0.08); }
|
|
@@ -170,7 +189,8 @@ document.addEventListener('DOMContentLoaded', function() {
|
|
|
170
189
|
<div class='anywhere-state' data-state='login'>
|
|
171
190
|
<div class='anywhere-card'>
|
|
172
191
|
<h2>Sign in in your browser</h2>
|
|
173
|
-
<p>Remote
|
|
192
|
+
<p>Remote uses <b>Tailscale</b>, a free private network. Sign in with a <b>personal</b> Google, Apple, GitHub or Microsoft account. Use the same account on every device you want to connect.</p>
|
|
193
|
+
<p>After signing in you may be asked to approve one thing. Both happen in your browser.</p>
|
|
174
194
|
<p><a class='anywhere-btn' id='anywhere-login-link' href='#' target='_blank' rel='noopener'><i class="fa-solid fa-arrow-up-right-from-square"></i> Sign in with Tailscale</a></p>
|
|
175
195
|
<p class='anywhere-muted'>Waiting for you to finish signing in… <span id='anywhere-login-url' class='anywhere-muted'></span></p>
|
|
176
196
|
</div>
|
|
@@ -186,11 +206,12 @@ document.addEventListener('DOMContentLoaded', function() {
|
|
|
186
206
|
|
|
187
207
|
<div class='anywhere-state' data-state='https'>
|
|
188
208
|
<div class='anywhere-card'>
|
|
189
|
-
<h2>One more
|
|
190
|
-
<p
|
|
191
|
-
<p
|
|
209
|
+
<h2>One more approval</h2>
|
|
210
|
+
<p>A one-time approval so your devices can connect securely. It opens in your browser; if it didn't, use the button.</p>
|
|
211
|
+
<p id='anywhere-approval-text' class='anywhere-muted'></p>
|
|
212
|
+
<p><a class='anywhere-btn' id='anywhere-approval-link' href='#' target='_blank' rel='noopener'><i class="fa-solid fa-lock"></i> Approve in Tailscale</a>
|
|
192
213
|
<a class='anywhere-btn secondary' href='https://login.tailscale.com/admin/dns' target='_blank' rel='noopener'>Open DNS settings</a></p>
|
|
193
|
-
<p class='anywhere-muted'>Waiting for
|
|
214
|
+
<p class='anywhere-muted'>Waiting for the approval… (in the admin console: DNS → HTTPS Certificates → Enable)</p>
|
|
194
215
|
</div>
|
|
195
216
|
</div>
|
|
196
217
|
|
|
@@ -203,29 +224,33 @@ document.addEventListener('DOMContentLoaded', function() {
|
|
|
203
224
|
</div>
|
|
204
225
|
|
|
205
226
|
<div class='anywhere-state' data-state='ready'>
|
|
227
|
+
<div class='anywhere-warning' id='anywhere-expiry-warning' hidden></div>
|
|
206
228
|
<div class='anywhere-grid'>
|
|
207
|
-
<div class='anywhere-card'>
|
|
208
|
-
<h2>Connect
|
|
209
|
-
<div class='anywhere-qr'>
|
|
210
|
-
<
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
<div id='anywhere-copy-status' class='anywhere-muted' role='status' aria-live='polite'></div>
|
|
214
|
-
<div id='anywhere-copy-manual' class='anywhere-copy-manual' hidden>
|
|
215
|
-
<input id='anywhere-copy-value' type='text' readonly aria-label='Remote URL' aria-describedby='anywhere-copy-status'>
|
|
229
|
+
<div class='anywhere-card anywhere-connect-card'>
|
|
230
|
+
<h2 class='anywhere-connect-title'>Connect a device</h2>
|
|
231
|
+
<div class='anywhere-qr' id='anywhere-connect' data-device='phone'>
|
|
232
|
+
<div class='anywhere-segments'>
|
|
233
|
+
<label><input type='radio' name='anywhere-device' value='phone' checked><span>Phone or tablet</span></label>
|
|
234
|
+
<label><input type='radio' name='anywhere-device' value='computer'><span>Another computer</span></label>
|
|
216
235
|
</div>
|
|
217
|
-
<div
|
|
236
|
+
<div data-panel='phone'>
|
|
237
|
+
<img id='anywhere-qr' alt='QR code to connect' src=''>
|
|
238
|
+
<p>Scan with your phone. It walks you through the rest.</p>
|
|
239
|
+
</div>
|
|
240
|
+
<div data-panel='computer'>
|
|
241
|
+
<p>Open this link on the other computer. It walks you through installing Tailscale and signing in as <b class='anywhere-account-inline'></b>, then opens this Pinokio.</p>
|
|
242
|
+
<div class='anywhere-address'><a id='anywhere-address' href='#' target='_blank' rel='noopener'></a></div>
|
|
243
|
+
<p><button type='button' class='anywhere-btn secondary anywhere-copy' id='anywhere-copy' disabled><i class='fa-regular fa-copy' aria-hidden='true'></i> Copy link</button></p>
|
|
244
|
+
<div id='anywhere-copy-status' class='anywhere-muted' role='status' aria-live='polite'></div>
|
|
245
|
+
<div id='anywhere-copy-manual' class='anywhere-copy-manual' hidden>
|
|
246
|
+
<input id='anywhere-copy-value' type='text' readonly aria-label='Remote link' aria-describedby='anywhere-copy-status'>
|
|
247
|
+
</div>
|
|
248
|
+
</div>
|
|
249
|
+
<div class='anywhere-muted'>signed in as <b id='anywhere-account'></b> · <button type='button' class='anywhere-signout' data-action='logout'>Sign out</button></div>
|
|
218
250
|
<div class='anywhere-waiting' id='anywhere-waiting'><i class="fa-solid fa-circle-notch fa-spin"></i> Waiting for another device to join…</div>
|
|
219
251
|
</div>
|
|
220
|
-
<h3>Steps</h3>
|
|
221
|
-
<ol class='anywhere-steps'>
|
|
222
|
-
<li>Install <b>Tailscale</b> on the computer, phone, or tablet you want to connect from.</li>
|
|
223
|
-
<li>Sign in with <b class='anywhere-account-inline'></b> and turn the connection on.</li>
|
|
224
|
-
<li>On that device, scan the QR code or open the address above in a browser.</li>
|
|
225
|
-
</ol>
|
|
226
|
-
<p class='anywhere-muted'>Bookmark the address for quick access. On a phone or tablet, you can also use <i>Add to Home Screen</i>.</p>
|
|
227
252
|
</div>
|
|
228
|
-
<div>
|
|
253
|
+
<div id='anywhere-details' hidden>
|
|
229
254
|
<div class='anywhere-card'>
|
|
230
255
|
<h2>Running apps</h2>
|
|
231
256
|
<p class='anywhere-muted'>Every app Pinokio launches gets its own address automatically. Entries disappear when an app stops.</p>
|
|
@@ -233,6 +258,13 @@ document.addEventListener('DOMContentLoaded', function() {
|
|
|
233
258
|
<h3>Published addresses</h3>
|
|
234
259
|
<div id='anywhere-ports' class='anywhere-muted'></div>
|
|
235
260
|
</div>
|
|
261
|
+
<% if (locals.loopback) { %>
|
|
262
|
+
<div class='anywhere-card'>
|
|
263
|
+
<h2>Your other Pinokios</h2>
|
|
264
|
+
<p class='anywhere-muted'>Computers on your network with Remote on. They open through this Pinokio; nothing else to install.</p>
|
|
265
|
+
<div id='anywhere-others'><p class='anywhere-muted'>Looking…</p></div>
|
|
266
|
+
</div>
|
|
267
|
+
<% } %>
|
|
236
268
|
<div class='anywhere-card'>
|
|
237
269
|
<h2>Devices on your network</h2>
|
|
238
270
|
<div id='anywhere-peers'></div>
|
|
@@ -246,7 +278,6 @@ document.addEventListener('DOMContentLoaded', function() {
|
|
|
246
278
|
<dt>Daemon</dt><dd id='anywhere-version'></dd>
|
|
247
279
|
</dl>
|
|
248
280
|
<p style='margin-top:14px'>
|
|
249
|
-
<button class='anywhere-btn secondary' data-action='logout'><i class="fa-solid fa-right-from-bracket"></i> Sign out / switch account</button>
|
|
250
281
|
<button class='anywhere-btn secondary' data-action='stop'><i class="fa-solid fa-power-off"></i> Turn off</button>
|
|
251
282
|
</p>
|
|
252
283
|
</div>
|
|
@@ -274,15 +305,18 @@ document.addEventListener('DOMContentLoaded', function() {
|
|
|
274
305
|
let current = <%- JSON.stringify(status || {}).replace(/</g, '\\u003c') %>
|
|
275
306
|
let busy = false
|
|
276
307
|
let copying = false
|
|
308
|
+
// the approval URL opened without a click, kept for the window so a reload does not open it again
|
|
309
|
+
let approvalOpened = null
|
|
310
|
+
try { approvalOpened = sessionStorage.getItem('anywhere-approval-opened') } catch (_) { /* storage unavailable: once per page load */ }
|
|
277
311
|
|
|
278
312
|
copyButton.addEventListener('click', async () => {
|
|
279
|
-
const url = current.
|
|
313
|
+
const url = current.connectUrl
|
|
280
314
|
if (copying || current.phase !== 'ready' || !url) return
|
|
281
315
|
const previousFocus = document.activeElement
|
|
282
316
|
copying = true
|
|
283
317
|
copyButton.disabled = true
|
|
284
318
|
copyStatus.textContent = 'Copying…'
|
|
285
|
-
const stillCurrent = () => current.phase === 'ready' && current.
|
|
319
|
+
const stillCurrent = () => current.phase === 'ready' && current.connectUrl === url
|
|
286
320
|
let copied = false
|
|
287
321
|
try {
|
|
288
322
|
// Call directly from the click: Safari requires a user gesture.
|
|
@@ -295,7 +329,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|
|
295
329
|
if (!copied) {
|
|
296
330
|
copyValue.value = url
|
|
297
331
|
copyManual.hidden = false
|
|
298
|
-
copyStatus.textContent = 'Automatic copy is unavailable. Touch and hold the
|
|
332
|
+
copyStatus.textContent = 'Automatic copy is unavailable. Touch and hold the link to copy it, or press Ctrl+C / ⌘C.'
|
|
299
333
|
try {
|
|
300
334
|
copyValue.focus({ preventScroll: true })
|
|
301
335
|
copyValue.select()
|
|
@@ -305,11 +339,11 @@ document.addEventListener('DOMContentLoaded', function() {
|
|
|
305
339
|
}
|
|
306
340
|
if (copied) {
|
|
307
341
|
copyManual.hidden = true
|
|
308
|
-
copyStatus.textContent = '
|
|
342
|
+
copyStatus.textContent = 'Link copied.'
|
|
309
343
|
}
|
|
310
344
|
}
|
|
311
345
|
copying = false
|
|
312
|
-
copyButton.disabled = current.phase !== 'ready' || !current.
|
|
346
|
+
copyButton.disabled = current.phase !== 'ready' || !current.connectUrl
|
|
313
347
|
if (copied && stillCurrent() && previousFocus &&
|
|
314
348
|
(document.activeElement === copyValue || document.activeElement === document.body)) {
|
|
315
349
|
previousFocus.focus({ preventScroll: true })
|
|
@@ -325,12 +359,12 @@ document.addEventListener('DOMContentLoaded', function() {
|
|
|
325
359
|
window.open(href, '_blank')
|
|
326
360
|
}
|
|
327
361
|
}
|
|
362
|
+
const daysUntil = (value) => value ? Math.round((new Date(value).getTime() - Date.now()) / 86400000) : NaN
|
|
328
363
|
const formatDate = (value) => {
|
|
329
364
|
if (!value) return '—'
|
|
330
365
|
try {
|
|
331
|
-
const
|
|
332
|
-
|
|
333
|
-
return d.toLocaleDateString() + (days >= 0 ? ` (in ${days} day${days === 1 ? '' : 's'})` : ' (expired)')
|
|
366
|
+
const days = daysUntil(value)
|
|
367
|
+
return new Date(value).toLocaleDateString() + (days >= 0 ? ` (in ${days} day${days === 1 ? '' : 's'})` : ' (expired)')
|
|
334
368
|
} catch (e) { return String(value) }
|
|
335
369
|
}
|
|
336
370
|
const osIcon = (os) => {
|
|
@@ -351,12 +385,12 @@ document.addEventListener('DOMContentLoaded', function() {
|
|
|
351
385
|
}
|
|
352
386
|
|
|
353
387
|
const render = (status) => {
|
|
354
|
-
if (current.
|
|
388
|
+
if (current.connectUrl !== (status || {}).connectUrl || current.phase !== (status || {}).phase) {
|
|
355
389
|
copyManual.hidden = true
|
|
356
390
|
copyStatus.textContent = ''
|
|
357
391
|
}
|
|
358
392
|
current = status || {}
|
|
359
|
-
copyButton.disabled = copying || current.phase !== 'ready' || !current.
|
|
393
|
+
copyButton.disabled = copying || current.phase !== 'ready' || !current.connectUrl
|
|
360
394
|
const phase = current.phase && current.phase !== 'setup' ? current.phase : 'starting'
|
|
361
395
|
const on = !!current.enabled && phase !== 'off' && phase !== 'setup'
|
|
362
396
|
switchEl.dataset.on = on ? 'true' : 'false'
|
|
@@ -373,8 +407,12 @@ document.addEventListener('DOMContentLoaded', function() {
|
|
|
373
407
|
const link = document.getElementById('anywhere-approval-link')
|
|
374
408
|
const url = current.approval && current.approval.url
|
|
375
409
|
link.href = url || 'https://login.tailscale.com/admin/dns'
|
|
376
|
-
|
|
377
|
-
|
|
410
|
+
document.getElementById('anywhere-approval-text').textContent = (current.approval && current.approval.text) || ''
|
|
411
|
+
// one browser trip: the approval follows sign-in without another click; the button stays for a blocked popup
|
|
412
|
+
if (url && approvalOpened !== url) {
|
|
413
|
+
approvalOpened = url
|
|
414
|
+
try { sessionStorage.setItem('anywhere-approval-opened', url) } catch (_) { /* same */ }
|
|
415
|
+
openExternal(url)
|
|
378
416
|
}
|
|
379
417
|
}
|
|
380
418
|
if (phase === 'error') {
|
|
@@ -384,9 +422,10 @@ document.addEventListener('DOMContentLoaded', function() {
|
|
|
384
422
|
if (phase === 'ready') {
|
|
385
423
|
const qr = document.getElementById('anywhere-qr')
|
|
386
424
|
if (current.qr && qr.getAttribute('src') !== current.qr) qr.src = current.qr
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
425
|
+
// the same link the QR carries; its fragment holds the address and account, so the text stops at the hash
|
|
426
|
+
const link = document.getElementById('anywhere-address')
|
|
427
|
+
link.textContent = (current.connectUrl || '').replace(/^https?:\/\//, '').replace(/#.*$/, '#…')
|
|
428
|
+
link.href = current.connectUrl || '#'
|
|
390
429
|
const email = current.account && current.account.email ? current.account.email : ''
|
|
391
430
|
document.getElementById('anywhere-account').textContent = email
|
|
392
431
|
for (const el of document.querySelectorAll('.anywhere-account-inline')) el.textContent = email
|
|
@@ -399,6 +438,16 @@ document.addEventListener('DOMContentLoaded', function() {
|
|
|
399
438
|
waiting.classList.remove('joined')
|
|
400
439
|
waiting.innerHTML = `<i class="fa-solid fa-circle-notch fa-spin"></i> Waiting for another device to join…`
|
|
401
440
|
}
|
|
441
|
+
// apps, devices and details only once a device has ever joined; until then the page is the onboarding card
|
|
442
|
+
document.getElementById('anywhere-details').hidden = peers.length === 0
|
|
443
|
+
const warning = document.getElementById('anywhere-expiry-warning')
|
|
444
|
+
const daysLeft = daysUntil(current.keyExpiry)
|
|
445
|
+
if (Number.isFinite(daysLeft) && daysLeft <= 14) {
|
|
446
|
+
warning.hidden = false
|
|
447
|
+
warning.innerHTML = `<i class="fa-solid fa-triangle-exclamation"></i> Your Tailscale key expires ${esc(formatDate(current.keyExpiry))}. After that, devices can't connect until you sign in again here. <a href='https://login.tailscale.com/admin/machines' target='_blank' rel='noopener'>Disable key expiry</a> for <b>${esc(current.dnsName ? current.dnsName.split('.')[0] : 'pinokio')}</b> in the Tailscale admin console.`
|
|
448
|
+
} else {
|
|
449
|
+
warning.hidden = true
|
|
450
|
+
}
|
|
402
451
|
const apps = Array.isArray(current.apps) ? current.apps : []
|
|
403
452
|
document.getElementById('anywhere-apps').innerHTML = apps.length === 0
|
|
404
453
|
? `<p class='anywhere-muted'>No app is running right now. Launch one and it will show up here.</p>`
|
|
@@ -412,9 +461,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|
|
412
461
|
</div>`).join('')
|
|
413
462
|
const serve = Array.isArray(current.serve) ? current.serve : []
|
|
414
463
|
document.getElementById('anywhere-ports').innerHTML = serve.length === 0 ? '—' : serve.map((entry) => `${esc(entry.name || (entry.port === 443 ? 'Pinokio' : ''))} <a class='remote' href='${esc(entry.url)}' target='_blank' rel='noopener'>${esc(entry.url)}</a> → ${esc(entry.target)}`).join('<br>')
|
|
415
|
-
document.getElementById('anywhere-peers').innerHTML = peers.
|
|
416
|
-
? `<p class='anywhere-muted'>No other devices yet. Sign in to Tailscale on another device with the same account.</p>`
|
|
417
|
-
: peers.map((peer) => `
|
|
464
|
+
document.getElementById('anywhere-peers').innerHTML = peers.map((peer) => `
|
|
418
465
|
<div class='anywhere-row'>
|
|
419
466
|
<span class='anywhere-dot ${peer.online ? 'online' : ''}'></span>
|
|
420
467
|
<i class='${osIcon(peer.os)}'></i>
|
|
@@ -430,6 +477,56 @@ document.addEventListener('DOMContentLoaded', function() {
|
|
|
430
477
|
}
|
|
431
478
|
}
|
|
432
479
|
|
|
480
|
+
// Your other Pinokios: only on a loopback host, refreshed with each status poll while ready
|
|
481
|
+
const others = document.getElementById('anywhere-others')
|
|
482
|
+
let othersBusy = false
|
|
483
|
+
let othersNote = '' // the last failed action, shown until the next one
|
|
484
|
+
const renderOthers = (peers) => {
|
|
485
|
+
if (!others) return
|
|
486
|
+
others.innerHTML = (othersNote ? `<p class='anywhere-error'>${esc(othersNote)}</p>` : '') + (peers.length === 0
|
|
487
|
+
? `<p class='anywhere-muted'>No other Pinokio with Remote on is online.</p>`
|
|
488
|
+
: peers.map((peer) => `
|
|
489
|
+
<div class='anywhere-row anywhere-other' data-peer='${esc(peer.name)}'>
|
|
490
|
+
<i class='fa-solid fa-desktop'></i>
|
|
491
|
+
<div class='grow'>
|
|
492
|
+
<div class='title'>${esc(peer.name)} <a class='remote' href='/anywhere/peers/${encodeURIComponent(peer.name)}/open?port=443&to=%2F' target='_blank' rel='noopener'>Open Pinokio</a></div>
|
|
493
|
+
${(peer.apps || []).length === 0 ? `<div class='sub'>no app running</div>` : peer.apps.map((app) => `
|
|
494
|
+
<div class='sub'>${esc(app.name)} ${app.open ? `<a class='remote' href='${esc(app.open)}' target='_blank' rel='noopener'>Open</a>` : `<span class='anywhere-badge'>${esc(app.state || 'starting')}</span>`} · <button type='button' class='anywhere-linkbtn' data-stop='${esc(app.id)}'>Stop</button></div>`).join('')}
|
|
495
|
+
</div>
|
|
496
|
+
</div>`).join(''))
|
|
497
|
+
}
|
|
498
|
+
const refreshOthers = async () => {
|
|
499
|
+
if (!others || othersBusy || current.phase !== 'ready') return
|
|
500
|
+
othersBusy = true
|
|
501
|
+
try {
|
|
502
|
+
const response = await fetch('/anywhere/peers', { cache: 'no-store' })
|
|
503
|
+
const answer = await response.json().catch(() => ({}))
|
|
504
|
+
if (!response.ok) throw new Error(answer.error || `HTTP ${response.status}`)
|
|
505
|
+
renderOthers(answer.peers || [])
|
|
506
|
+
} catch (e) {
|
|
507
|
+
others.innerHTML = `<p class='anywhere-muted'>Could not look for other Pinokios: ${esc(e.message)}</p>`
|
|
508
|
+
} finally {
|
|
509
|
+
othersBusy = false
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
if (others) {
|
|
513
|
+
others.addEventListener('click', async (event) => {
|
|
514
|
+
const button = event.target.closest('button[data-stop]')
|
|
515
|
+
if (!button) return
|
|
516
|
+
const peer = button.closest('[data-peer]').dataset.peer
|
|
517
|
+
button.disabled = true
|
|
518
|
+
othersNote = ''
|
|
519
|
+
try {
|
|
520
|
+
const response = await fetch(`/anywhere/peers/${encodeURIComponent(peer)}/apps/stop`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: button.dataset.stop }) })
|
|
521
|
+
const answer = await response.json().catch(() => ({}))
|
|
522
|
+
if (!response.ok) othersNote = `Could not stop it on ${peer}: ${answer.error || `HTTP ${response.status}`}`
|
|
523
|
+
} catch (e) {
|
|
524
|
+
othersNote = `Could not stop it on ${peer}: ${e.message}`
|
|
525
|
+
}
|
|
526
|
+
await refreshOthers()
|
|
527
|
+
})
|
|
528
|
+
}
|
|
529
|
+
|
|
433
530
|
const poll = async () => {
|
|
434
531
|
try {
|
|
435
532
|
const response = await fetch('/anywhere/status', { cache: 'no-store' })
|
|
@@ -438,6 +535,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|
|
438
535
|
} catch (e) {
|
|
439
536
|
render(Object.assign({}, current, { phase: 'error', error: e.message }))
|
|
440
537
|
}
|
|
538
|
+
refreshOthers()
|
|
441
539
|
const phase = current.phase || ''
|
|
442
540
|
const delay = (phase === 'ready' || phase === 'off') ? 5000 : 2000
|
|
443
541
|
setTimeout(poll, delay)
|
|
@@ -485,6 +583,9 @@ document.addEventListener('DOMContentLoaded', function() {
|
|
|
485
583
|
openExternal(link.href)
|
|
486
584
|
}
|
|
487
585
|
})
|
|
586
|
+
for (const input of document.querySelectorAll('input[name=anywhere-device]')) {
|
|
587
|
+
input.addEventListener('change', () => { document.getElementById('anywhere-connect').dataset.device = input.value })
|
|
588
|
+
}
|
|
488
589
|
switchEl.addEventListener('click', () => {
|
|
489
590
|
if (switchEl.dataset.on === 'true') {
|
|
490
591
|
act('stop')
|
|
@@ -494,6 +595,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|
|
494
595
|
})
|
|
495
596
|
|
|
496
597
|
render(current)
|
|
598
|
+
refreshOthers()
|
|
497
599
|
setTimeout(poll, 1500)
|
|
498
600
|
})()
|
|
499
601
|
</script>
|
|
@@ -8,10 +8,11 @@ const { JSDOM, VirtualConsole } = require('jsdom')
|
|
|
8
8
|
const filename = path.resolve(__dirname, '../server/views/anywhere.ejs')
|
|
9
9
|
const template = fs.readFileSync(filename, 'utf8')
|
|
10
10
|
const address = 'https://pinokio-1.tail123.ts.net'
|
|
11
|
+
const link = 'https://pinokio.co/anywhere/connect#u=' + encodeURIComponent(address) + '&a=me%40example.com'
|
|
11
12
|
const tick = () => new Promise((resolve) => setImmediate(resolve))
|
|
12
13
|
|
|
13
14
|
function createHarness(t, options = {}) {
|
|
14
|
-
const state = { status: { phase: 'ready', enabled: true, address }, timers: [], opened: [] }
|
|
15
|
+
const state = { status: { phase: 'ready', enabled: true, address, connectUrl: link }, timers: [], opened: [] }
|
|
15
16
|
Object.assign(state.status, options.status)
|
|
16
17
|
const html = ejs.render(template, {
|
|
17
18
|
theme: 'light', agent: options.agent || 'browser', status: state.status
|
|
@@ -56,21 +57,21 @@ for (const agent of ['browser', 'electron']) {
|
|
|
56
57
|
})
|
|
57
58
|
h.button.focus()
|
|
58
59
|
h.button.click()
|
|
59
|
-
assert.deepEqual(copied, [
|
|
60
|
+
assert.deepEqual(copied, [link], 'clipboard is invoked synchronously in the click')
|
|
60
61
|
assert.equal(h.button.disabled, true)
|
|
61
62
|
h.button.click()
|
|
62
63
|
await tick()
|
|
63
|
-
assert.deepEqual(copied, [
|
|
64
|
-
assert.equal(h.feedback.textContent, '
|
|
64
|
+
assert.deepEqual(copied, [link], 'pending copies are not duplicated')
|
|
65
|
+
assert.equal(h.feedback.textContent, 'Link copied.')
|
|
65
66
|
assert.equal(h.manual.hidden, true)
|
|
66
67
|
assert.equal(h.button.disabled, false)
|
|
67
68
|
assert.equal(h.document.activeElement, h.button)
|
|
68
69
|
assert.deepEqual(h.state.opened, [])
|
|
69
|
-
assert.equal(h.document.getElementById('anywhere-address').getAttribute('href'),
|
|
70
|
+
assert.equal(h.document.getElementById('anywhere-address').getAttribute('href'), link)
|
|
70
71
|
assert.equal(h.feedback.getAttribute('role'), 'status')
|
|
71
72
|
if (agent === 'electron') {
|
|
72
73
|
h.document.getElementById('anywhere-address').click()
|
|
73
|
-
assert.deepEqual(h.state.opened, [[
|
|
74
|
+
assert.deepEqual(h.state.opened, [[link, '_blank', 'browser']])
|
|
74
75
|
}
|
|
75
76
|
})
|
|
76
77
|
}
|
|
@@ -99,8 +100,8 @@ for (const scenario of ['insecure LAN', 'denied', 'synchronous failure', 'electr
|
|
|
99
100
|
h.button.focus()
|
|
100
101
|
h.button.click()
|
|
101
102
|
await tick()
|
|
102
|
-
assert.deepEqual(copied, [
|
|
103
|
-
assert.equal(h.feedback.textContent, '
|
|
103
|
+
assert.deepEqual(copied, [link])
|
|
104
|
+
assert.equal(h.feedback.textContent, 'Link copied.')
|
|
104
105
|
assert.equal(h.manual.hidden, true)
|
|
105
106
|
assert.equal(h.document.activeElement, h.button)
|
|
106
107
|
assert.deepEqual(h.state.opened, [])
|
|
@@ -119,17 +120,17 @@ for (const failure of ['false', 'undefined', 'throws', 'unavailable']) {
|
|
|
119
120
|
h.button.click()
|
|
120
121
|
await tick()
|
|
121
122
|
assert.equal(h.manual.hidden, false)
|
|
122
|
-
assert.equal(h.input.value,
|
|
123
|
+
assert.equal(h.input.value, link)
|
|
123
124
|
assert.equal(h.input.selectionStart, 0)
|
|
124
|
-
assert.equal(h.input.selectionEnd,
|
|
125
|
+
assert.equal(h.input.selectionEnd, link.length)
|
|
125
126
|
assert.equal(h.document.activeElement, h.input)
|
|
126
127
|
assert.match(h.feedback.textContent, /Touch and hold.*Ctrl\+C \/ ⌘C/)
|
|
127
|
-
assert.doesNotMatch(h.feedback.textContent, /
|
|
128
|
+
assert.doesNotMatch(h.feedback.textContent, /Link copied/)
|
|
128
129
|
assert.equal(h.button.disabled, false)
|
|
129
130
|
await h.poll()
|
|
130
131
|
assert.equal(h.manual.hidden, false, 'routine polling preserves the manual copy field')
|
|
131
|
-
assert.equal(h.input.selectionEnd,
|
|
132
|
-
h.state.status.
|
|
132
|
+
assert.equal(h.input.selectionEnd, link.length)
|
|
133
|
+
h.state.status.connectUrl = 'https://pinokio.co/anywhere/connect#u=https%3A%2F%2Frenamed.tail123.ts.net'
|
|
133
134
|
await h.poll()
|
|
134
135
|
assert.equal(h.manual.hidden, true, 'a changed URL clears the stale manual field')
|
|
135
136
|
assert.equal(h.feedback.textContent, '')
|
|
@@ -138,7 +139,7 @@ for (const failure of ['false', 'undefined', 'throws', 'unavailable']) {
|
|
|
138
139
|
|
|
139
140
|
test('Remote does not copy a placeholder or an unavailable address', async (t) => {
|
|
140
141
|
const h = createHarness(t, {
|
|
141
|
-
status: { address: '' },
|
|
142
|
+
status: { address: '', connectUrl: '' },
|
|
142
143
|
clipboard: { writeText: () => assert.fail('no URL to copy') },
|
|
143
144
|
execCommand: () => assert.fail('no URL to copy')
|
|
144
145
|
})
|
|
@@ -146,7 +147,7 @@ test('Remote does not copy a placeholder or an unavailable address', async (t) =
|
|
|
146
147
|
h.button.click()
|
|
147
148
|
await tick()
|
|
148
149
|
assert.equal(h.feedback.textContent, '')
|
|
149
|
-
h.state.status.
|
|
150
|
+
h.state.status.connectUrl = link
|
|
150
151
|
await h.poll()
|
|
151
152
|
assert.equal(h.button.disabled, false)
|
|
152
153
|
h.state.status.phase = 'off'
|
|
@@ -165,8 +166,8 @@ test('Remote restores keyboard focus after disabling the button during modern co
|
|
|
165
166
|
assert.equal(h.document.activeElement, h.button)
|
|
166
167
|
})
|
|
167
168
|
|
|
168
|
-
test('Remote ignores a pending copy result if the
|
|
169
|
-
for (const change of [{
|
|
169
|
+
test('Remote ignores a pending copy result if the link changes or Remote stops', async (t) => {
|
|
170
|
+
for (const change of [{ connectUrl: 'https://pinokio.co/anywhere/connect#u=https%3A%2F%2Fnew.tail123.ts.net' }, { phase: 'off' }]) {
|
|
170
171
|
let rejectCopy
|
|
171
172
|
const h = createHarness(t, {
|
|
172
173
|
clipboard: { writeText: () => new Promise((resolve, reject) => { rejectCopy = reject }) },
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
const assert = require('node:assert/strict')
|
|
2
|
+
const http = require('node:http')
|
|
3
|
+
const test = require('node:test')
|
|
4
|
+
const { once } = require('node:events')
|
|
5
|
+
const WebSocket = require('ws')
|
|
6
|
+
const { Forwarders } = require('../kernel/anywhere/forward')
|
|
7
|
+
|
|
8
|
+
// Forwarders (spec/requirements/pinokio-anywhere.md, Other Pinokios; the
|
|
9
|
+
// API spec, Peers): one loopback port relaying to one peer and port. The
|
|
10
|
+
// peer here is a plain local server, so the TLS-through-proxy layer is
|
|
11
|
+
// replaced by the http scheme and no agent; what is checked is the relay.
|
|
12
|
+
|
|
13
|
+
async function peer(t) {
|
|
14
|
+
const seen = []
|
|
15
|
+
const server = http.createServer((req, res) => {
|
|
16
|
+
seen.push({ url: req.url, headers: req.headers, method: req.method })
|
|
17
|
+
let body = ''
|
|
18
|
+
req.on('data', (chunk) => { body += chunk })
|
|
19
|
+
req.on('end', () => {
|
|
20
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
21
|
+
res.end(JSON.stringify({ url: req.url, host: req.headers.host, forwarded: req.headers['x-pinokio-forwarded'] || null, peer: req.headers['x-pinokio-peer'] || null, body }))
|
|
22
|
+
})
|
|
23
|
+
})
|
|
24
|
+
const wss = new WebSocket.Server({ server })
|
|
25
|
+
wss.on('connection', (socket, req) => {
|
|
26
|
+
socket.on('message', (message) => socket.send(`echo:${message} forwarded=${req.headers['x-pinokio-forwarded'] || 'none'} peer=${req.headers['x-pinokio-peer'] || 'none'}`))
|
|
27
|
+
})
|
|
28
|
+
server.listen(0, '127.0.0.1')
|
|
29
|
+
await once(server, 'listening')
|
|
30
|
+
t.after(() => new Promise((resolve) => { wss.close(); server.close(resolve); server.closeAllConnections() }))
|
|
31
|
+
return { port: server.address().port, seen }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const get = (port, path, headers = {}) => new Promise((resolve, reject) => {
|
|
35
|
+
http.get({ host: '127.0.0.1', port, path, headers }, (res) => {
|
|
36
|
+
let body = ''
|
|
37
|
+
res.on('data', (chunk) => { body += chunk })
|
|
38
|
+
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body }))
|
|
39
|
+
}).on('error', reject)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test('a forwarder relays requests with the forwarded header, the peer as Host, and no trusted peer headers', async (t) => {
|
|
43
|
+
const target = await peer(t)
|
|
44
|
+
const forwarders = new Forwarders({ proxyUrl: () => null, serverPort: () => 42000, scheme: 'http' })
|
|
45
|
+
t.after(() => forwarders.stopAll())
|
|
46
|
+
const local = await forwarders.open('127.0.0.1', target.port)
|
|
47
|
+
assert.equal(await forwarders.open('127.0.0.1', target.port), local, 'the same peer and port reuse the forwarder')
|
|
48
|
+
const answer = await get(local, '/ui/?a=1', { 'X-Pinokio-Peer': '1', 'X-Pinokio-Source-Host': 'evil', 'X-Pinokio-Source-Proto': 'https' })
|
|
49
|
+
assert.equal(answer.status, 200)
|
|
50
|
+
const body = JSON.parse(answer.body)
|
|
51
|
+
assert.equal(body.url, '/ui/?a=1')
|
|
52
|
+
assert.equal(body.host, `127.0.0.1:${target.port}`, 'Host names the peer, not the loopback port')
|
|
53
|
+
assert.equal(body.forwarded, '1')
|
|
54
|
+
assert.equal(body.peer, null, 'the existing peer-trust headers never pass through')
|
|
55
|
+
assert.equal(target.seen[0].headers['x-pinokio-source-host'], undefined)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('two opens of the same peer and port at once share one forwarder', async (t) => {
|
|
59
|
+
const target = await peer(t)
|
|
60
|
+
const forwarders = new Forwarders({ proxyUrl: () => null, serverPort: () => 42000, scheme: 'http' })
|
|
61
|
+
t.after(() => forwarders.stopAll())
|
|
62
|
+
const [a, b] = await Promise.all([forwarders.open('127.0.0.1', target.port), forwarders.open('127.0.0.1', target.port)])
|
|
63
|
+
assert.ok(Number.isInteger(a) && a > 0)
|
|
64
|
+
assert.equal(a, b)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test('a forwarder answers /.pinokio/open itself with a redirect to this node\'s open route', async (t) => {
|
|
68
|
+
const target = await peer(t)
|
|
69
|
+
const forwarders = new Forwarders({ proxyUrl: () => null, serverPort: () => 42000, scheme: 'http' })
|
|
70
|
+
t.after(() => forwarders.stopAll())
|
|
71
|
+
const local = await forwarders.open('pinokio-1.tail1.ts.net', target.port)
|
|
72
|
+
const answer = await get(local, '/.pinokio/open?port=43001&to=%2Fui%2F%3Fa%3D1%23view')
|
|
73
|
+
assert.equal(answer.status, 302)
|
|
74
|
+
assert.equal(answer.headers.location, 'http://127.0.0.1:42000/anywhere/peers/pinokio-1/open?port=43001&to=%2Fui%2F%3Fa%3D1%23view')
|
|
75
|
+
assert.equal((await get(local, '/.pinokio/open?to=%2F')).status, 400, 'a link without a port does not bounce through pinokiod')
|
|
76
|
+
assert.equal(target.seen.length, 0, 'never relayed')
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
test('a forwarder relays a WebSocket upgrade', async (t) => {
|
|
80
|
+
const target = await peer(t)
|
|
81
|
+
const forwarders = new Forwarders({ proxyUrl: () => null, serverPort: () => 42000, scheme: 'http' })
|
|
82
|
+
t.after(() => forwarders.stopAll())
|
|
83
|
+
const local = await forwarders.open('127.0.0.1', target.port)
|
|
84
|
+
const socket = new WebSocket(`ws://127.0.0.1:${local}/socket`, { headers: { 'X-Pinokio-Peer': '1' } })
|
|
85
|
+
await once(socket, 'open')
|
|
86
|
+
socket.send('hi')
|
|
87
|
+
const [message] = await once(socket, 'message')
|
|
88
|
+
assert.equal(String(message), 'echo:hi forwarded=1 peer=none', 'the forwarded header is added and the trust header dropped on the upgrade path too')
|
|
89
|
+
socket.close()
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test('a peer that cannot be reached answers 502, and stopAll closes every forwarder, an open WebSocket included', async (t) => {
|
|
93
|
+
const target = await peer(t)
|
|
94
|
+
const forwarders = new Forwarders({ proxyUrl: () => null, serverPort: () => 42000, scheme: 'http' })
|
|
95
|
+
const dead = await forwarders.open('127.0.0.1', 1)
|
|
96
|
+
const answer = await get(dead, '/')
|
|
97
|
+
assert.equal(answer.status, 502)
|
|
98
|
+
assert.match(answer.body, /Could not reach 127\.0\.0\.1/)
|
|
99
|
+
const live = await forwarders.open('127.0.0.1', target.port)
|
|
100
|
+
const socket = new WebSocket(`ws://127.0.0.1:${live}/socket`)
|
|
101
|
+
await once(socket, 'open')
|
|
102
|
+
const closed = once(socket, 'close')
|
|
103
|
+
const stopped = forwarders.stopAll()
|
|
104
|
+
await Promise.race([stopped, new Promise((_, reject) => setTimeout(() => reject(new Error('stopAll hung on the open WebSocket')), 2000))])
|
|
105
|
+
await closed
|
|
106
|
+
await assert.rejects(get(live, '/'), /ECONNREFUSED/)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test('the https scheme refuses to forward while the daemon is down', () => {
|
|
110
|
+
const forwarders = new Forwarders({ proxyUrl: () => null, serverPort: () => 42000 })
|
|
111
|
+
assert.throws(() => forwarders.agent(), /Remote is not running/)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
test('a request to a peer returns status and body, and the deadline covers the connection itself', async (t) => {
|
|
115
|
+
const target = await peer(t)
|
|
116
|
+
const plain = new Forwarders({ proxyUrl: () => null, serverPort: () => 42000, scheme: 'http' })
|
|
117
|
+
const answer = await plain.request(`http://127.0.0.1:${target.port}/anywhere/status`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{"id":"x"}' })
|
|
118
|
+
assert.equal(answer.status, 200)
|
|
119
|
+
assert.equal(JSON.parse(answer.body).body, '{"id":"x"}')
|
|
120
|
+
const silent = http.createServer(() => { /* never answers */ })
|
|
121
|
+
silent.listen(0, '127.0.0.1')
|
|
122
|
+
await once(silent, 'listening')
|
|
123
|
+
t.after(() => { silent.closeAllConnections(); silent.close() })
|
|
124
|
+
await assert.rejects(plain.request(`http://127.0.0.1:${silent.address().port}/`, { timeoutMs: 200 }), /no answer in 0 s/)
|
|
125
|
+
// a proxy that accepts and never answers the CONNECT, through the real agent: the deadline
|
|
126
|
+
// still fires there, where a socket timeout would not have started yet
|
|
127
|
+
const held = []
|
|
128
|
+
const stuckProxy = require('node:net').createServer((socket) => { held.push(socket) /* never answers */ })
|
|
129
|
+
stuckProxy.listen(0, '127.0.0.1')
|
|
130
|
+
await once(stuckProxy, 'listening')
|
|
131
|
+
t.after(() => { for (const socket of held) socket.destroy(); stuckProxy.close() })
|
|
132
|
+
const forwarders = new Forwarders({ proxyUrl: () => `http://127.0.0.1:${stuckProxy.address().port}`, serverPort: () => 42000 })
|
|
133
|
+
t.after(() => forwarders.stopAll())
|
|
134
|
+
await assert.rejects(forwarders.request('https://peer.tail1.ts.net/anywhere/status', { timeoutMs: 300 }), /no answer in 0 s/)
|
|
135
|
+
})
|