dsh-composer-plus 1.12.0 → 1.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.de.md +36 -13
- package/README.es.md +36 -13
- package/README.fr.md +36 -13
- package/README.hi.md +36 -13
- package/README.id.md +36 -13
- package/README.ja.md +36 -13
- package/README.ko.md +36 -13
- package/README.md +36 -13
- package/README.pt-BR.md +36 -13
- package/README.ru.md +36 -13
- package/README.tr.md +36 -13
- package/README.vi.md +36 -13
- package/README.zh.md +36 -13
- package/assets/features/changesPanel.webp +0 -0
- package/assets/features/copyMessageLink.webp +0 -0
- package/assets/features/markProjectRead.webp +0 -0
- package/assets/features/queueEdit.webp +0 -0
- package/client.js +917 -133
- package/index.js +439 -83
- package/package.json +7 -3
- package/tether/gateway/admin.html +47 -3
- package/tether/gateway/server.mjs +98 -28
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-composer-plus",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.13.0",
|
|
4
4
|
"description": "DeepSeek Harness helpers: 11 extra UI languages (Türkçe, Español, Français, Deutsch, 日本語…), Changes panel, swipe-card feature ideas, sidebar search in message text, turn rollback with git file restore, delete chats to the Trash, input history, Shift+Tab /plan, one-click Continue, resume running chats after a restart, queue drag-and-drop, deeplinks, Auto Review allow-for-chat, notifications, iPhone access with the Tether app, automatic updates",
|
|
5
5
|
"icon": "./assets/icon.svg",
|
|
6
6
|
"license": "MIT",
|
|
@@ -34,12 +34,15 @@
|
|
|
34
34
|
"ios",
|
|
35
35
|
"iphone",
|
|
36
36
|
"localization",
|
|
37
|
+
"mark-as-read",
|
|
38
|
+
"message-link",
|
|
37
39
|
"mobile",
|
|
38
40
|
"notifications",
|
|
39
41
|
"plan-mode",
|
|
40
42
|
"plugin",
|
|
41
43
|
"queue",
|
|
42
44
|
"recent-projects",
|
|
45
|
+
"redo",
|
|
43
46
|
"remote-control",
|
|
44
47
|
"restart",
|
|
45
48
|
"resume",
|
|
@@ -50,7 +53,8 @@
|
|
|
50
53
|
"tailscale",
|
|
51
54
|
"tether",
|
|
52
55
|
"translation",
|
|
53
|
-
"undo"
|
|
56
|
+
"undo",
|
|
57
|
+
"whitespace"
|
|
54
58
|
],
|
|
55
59
|
"exports": {
|
|
56
60
|
".": "./index.js",
|
|
@@ -73,7 +77,7 @@
|
|
|
73
77
|
"assets/features/*.webp"
|
|
74
78
|
],
|
|
75
79
|
"scripts": {
|
|
76
|
-
"test": "node scripts/build-locales.mjs --check && node scripts/build-docs.mjs --check && node test/locale.test.mjs && node test/continue.test.mjs && node test/continue-host.test.mjs && node test/resume-restart.test.mjs && node test/content-search.test.mjs && node test/turn-rollback.test.mjs && node test/turn-rollback-client.test.mjs && node test/delete-chat.test.mjs && node test/export-chat.test.mjs && node test/project-name.test.mjs && node test/queue-drag.test.mjs && node test/queue-move.test.mjs && node test/plan-toggle.test.mjs && node test/keep-focus.test.mjs && node test/mobile-connect.test.mjs && node test/unread.test.mjs && node test/unread-client.test.mjs && node test/tether-gateway.test.mjs && node test/tether-parity.test.mjs && node test/deeplink.test.mjs && node test/chat-grant.test.mjs && node test/notify.test.mjs && node test/changes.test.mjs && node test/feature-ideas.test.mjs && node test/feature-ideas-client.test.mjs && node test/project-status.test.mjs && node test/recent-projects.test.mjs && node test/project-tools.test.mjs && node test/project-picker.test.mjs && node test/predict.test.mjs && node test/predict-client.test.mjs && node test/complete.test.mjs && node test/complete-client.test.mjs && node test/prompt-improve.test.mjs && node test/prompt-improve-client.test.mjs && node test/status-order.test.mjs && node test/toggles.test.mjs && node test/update.test.mjs && node test/meta.test.mjs && node test/shots.test.mjs && node test/readme.test.mjs && node test/readme-labels.test.mjs && node test/feature-docs.test.mjs && node test/routes.test.mjs",
|
|
80
|
+
"test": "node scripts/build-locales.mjs --check && node scripts/build-docs.mjs --check && node test/locale.test.mjs && node test/continue.test.mjs && node test/continue-host.test.mjs && node test/resume-restart.test.mjs && node test/content-search.test.mjs && node test/turn-rollback.test.mjs && node test/turn-rollback-client.test.mjs && node test/delete-chat.test.mjs && node test/export-chat.test.mjs && node test/project-name.test.mjs && node test/queue-drag.test.mjs && node test/queue-move.test.mjs && node test/queue-edit.test.mjs && node test/message-link.test.mjs && node test/mark-read.test.mjs && node test/plan-toggle.test.mjs && node test/keep-focus.test.mjs && node test/mobile-connect.test.mjs && node test/unread.test.mjs && node test/unread-client.test.mjs && node test/tether-gateway.test.mjs && node test/tether-parity.test.mjs && node test/deeplink.test.mjs && node test/chat-grant.test.mjs && node test/grant-ttl.test.mjs && node test/notify.test.mjs && node test/changes.test.mjs && node test/chat-changes.test.mjs && node test/changes-tree.test.mjs && node test/feature-ideas.test.mjs && node test/feature-ideas-client.test.mjs && node test/project-status.test.mjs && node test/recent-projects.test.mjs && node test/project-tools.test.mjs && node test/project-picker.test.mjs && node test/predict.test.mjs && node test/predict-client.test.mjs && node test/complete.test.mjs && node test/complete-client.test.mjs && node test/prompt-improve.test.mjs && node test/prompt-improve-client.test.mjs && node test/status-order.test.mjs && node test/toggles.test.mjs && node test/update.test.mjs && node test/meta.test.mjs && node test/shots.test.mjs && node test/readme.test.mjs && node test/readme-labels.test.mjs && node test/feature-docs.test.mjs && node test/routes.test.mjs",
|
|
77
81
|
"build:locales": "node scripts/build-locales.mjs",
|
|
78
82
|
"build:readme": "node scripts/build-docs.mjs",
|
|
79
83
|
"prepublishOnly": "npm test"
|
|
@@ -49,6 +49,10 @@
|
|
|
49
49
|
#status-table td { text-align: left; overflow-wrap: anywhere; } #status-table td:first-child { color: var(--mute); width: 30%; }
|
|
50
50
|
#status-table .link { margin-left: 8px; font-size: 12px; }
|
|
51
51
|
.addrow { margin-top: 8px; }
|
|
52
|
+
.sub { font-size: 12px; color: var(--mute); }
|
|
53
|
+
label.opt { display: flex; gap: 8px; align-items: flex-start; cursor: pointer; } label.opt input { margin: 3px 0 0; }
|
|
54
|
+
#log-table td { font-size: 12px; padding: 5px 0; text-align: left; } #log-table td:first-child { white-space: nowrap; padding-right: 8px; color: var(--mute); }
|
|
55
|
+
#log-table .bad { color: var(--red); } #log-table .ok { color: var(--green); }
|
|
52
56
|
|
|
53
57
|
/* ?embed=1: inside DSH's "Mobile connect" popover */
|
|
54
58
|
.embed body { max-width: none; margin: 0; padding: 4px 12px 12px; background: transparent; overflow: hidden; }
|
|
@@ -87,6 +91,16 @@
|
|
|
87
91
|
<table id="devices"></table>
|
|
88
92
|
<p class="addrow" id="addrow" hidden><button id="add">Add an iPhone</button></p>
|
|
89
93
|
|
|
94
|
+
<section id="sec">
|
|
95
|
+
<h2>Security</h2>
|
|
96
|
+
<label class="opt"><input type="checkbox" id="tailnet-only"><span><strong>Tailscale only</strong><br><span class="sub">Refuse connections from the local network (Wi‑Fi, cable). Only iPhones that reach this Mac over Tailscale can connect. Use it on shared Wi‑Fi, for example in a café or an office.</span></span></label>
|
|
97
|
+
<p class="note warn" id="tailnet-warn" hidden>Tailscale is off on this Mac: no iPhone can connect now.</p>
|
|
98
|
+
</section>
|
|
99
|
+
|
|
100
|
+
<details id="log"><summary id="log-sum">Access log</summary>
|
|
101
|
+
<table id="log-table"></table>
|
|
102
|
+
</details>
|
|
103
|
+
|
|
90
104
|
<details id="gw"><summary>Connection details</summary>
|
|
91
105
|
<table id="status-table"></table>
|
|
92
106
|
<p style="margin-top:8px"><button id="start">Start</button> <button id="stop">Stop</button></p>
|
|
@@ -109,10 +123,19 @@ async function post(path, body) {
|
|
|
109
123
|
try {
|
|
110
124
|
const r = await fetch(path, { method: 'POST', headers: { 'x-dsh-admin': '1', 'content-type': 'application/json' }, body: JSON.stringify(body ?? {}) })
|
|
111
125
|
const s = await r.json()
|
|
112
|
-
if (s.error) say(s.error, 'warn'); else render(s)
|
|
126
|
+
if (s.error) say(s.error, 'warn'); else { busy = false; render(s); return true }
|
|
113
127
|
} catch { say('The Tether gateway did not answer. Is DSH still running?', 'warn') }
|
|
114
128
|
finally { busy = false }
|
|
129
|
+
return false
|
|
115
130
|
}
|
|
131
|
+
document.addEventListener('change', e => {
|
|
132
|
+
if (e.target.id !== 'tailnet-only') return
|
|
133
|
+
const on = e.target.checked
|
|
134
|
+
post('/settings', { tailnetOnly: on }).then(ok => {
|
|
135
|
+
if (ok) say(on ? 'Tailscale only is on. iPhones on the local network cannot connect.' : 'Tailscale only is off. iPhones on the same Wi‑Fi can connect again.', 'ok')
|
|
136
|
+
else if (state) e.target.checked = state.tailnetOnly // refused: show the saved value again
|
|
137
|
+
})
|
|
138
|
+
})
|
|
116
139
|
const left = iso => Math.max(0, Math.round((Date.parse(iso) - Date.now()) / 1000))
|
|
117
140
|
const mmss = s => Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0')
|
|
118
141
|
const RESULT = {
|
|
@@ -121,6 +144,14 @@ const RESULT = {
|
|
|
121
144
|
cancelled: n => [n + ' stopped waiting before you clicked Approve. Scan the code again on the iPhone.', 'warn'],
|
|
122
145
|
expired: n => ['The request from ' + n + ' timed out. Scan the code again on the iPhone.', 'warn'],
|
|
123
146
|
}
|
|
147
|
+
// Access log reasons, as the user reads them.
|
|
148
|
+
const REASON = {
|
|
149
|
+
lan: 'Wi‑Fi', tailnet: 'Tailscale', paired: 'paired', network: 'not this network', 'tailnet-only': 'Tailscale only',
|
|
150
|
+
tls: 'TLS handshake failed', rate: 'too many requests', token: 'unknown or removed key', code: 'wrong pairing code',
|
|
151
|
+
denied: 'pairing denied', expired: 'pairing timed out', cancelled: 'iPhone stopped waiting',
|
|
152
|
+
}
|
|
153
|
+
// Today: the time only; earlier: the date and the time.
|
|
154
|
+
const when = iso => { const d = new Date(iso); return d.toDateString() === new Date().toDateString() ? d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : d.toLocaleString([], { dateStyle: 'short', timeStyle: 'short' }) }
|
|
124
155
|
function paintNote() {
|
|
125
156
|
const l = state?.last, f = flash && flash.until > Date.now() ? flash : null
|
|
126
157
|
const [text, kind] = f ? [f.text, f.kind] : l && RESULT[l.result] ? RESULT[l.result](l.name) : ['', '']
|
|
@@ -160,8 +191,20 @@ function render(s) {
|
|
|
160
191
|
paintNote()
|
|
161
192
|
|
|
162
193
|
put('devices', s.devices.map(d => '<tr><td><span class="dot ' + (d.connections ? 'ok' : '') + '"></span>' + esc(d.name) +
|
|
163
|
-
' <span class="mute">' + (d.connections ? 'online' :
|
|
194
|
+
' <span class="mute">' + (d.connections ? 'online' : d.lastSeenAt ? 'last seen ' + esc(when(d.lastSeenAt)) : 'never connected') + '</span>' +
|
|
195
|
+
(d.createdAt ? '<br><span class="sub">Paired ' + esc(new Date(d.createdAt).toLocaleDateString()) + '</span>' : '') +
|
|
196
|
+
'</td><td><button class="link" data-revoke="' + esc(d.id) + '" data-name="' + esc(d.name) + '" aria-label="Remove ' + esc(d.name) + '">Remove</button></td></tr>').join('')
|
|
164
197
|
|| '<tr><td class="mute">No iPhone yet.</td></tr>')
|
|
198
|
+
// An older gateway (before a dsh restart) has no Tailscale only setting and no log: hide them.
|
|
199
|
+
const has = typeof s.tailnetOnly === 'boolean'
|
|
200
|
+
$('sec').hidden = $('log').hidden = !has
|
|
201
|
+
if (has && !busy) $('tailnet-only').checked = s.tailnetOnly
|
|
202
|
+
$('tailnet-warn').hidden = !(has && s.tailnetOnly && s.running && !t.online)
|
|
203
|
+
const log = s.log ?? [], day = log.filter(e => e.result === 'refused' && Date.now() - Date.parse(e.at) < 864e5).reduce((n, e) => n + e.n, 0)
|
|
204
|
+
$('log-sum').textContent = 'Access log' + (day ? ' · ' + day + ' refused in the last 24 hours' : '')
|
|
205
|
+
put('log-table', log.map(e => '<tr><td>' + esc(when(e.at)) + '</td><td><code>' + esc(e.ip) + '</code>' + (e.device ? ' · ' + esc(e.device) : '') +
|
|
206
|
+
'<br><span class="' + (e.result === 'allowed' ? 'ok' : 'bad') + '">' + (e.result === 'allowed' ? 'Allowed' : 'Refused') + '</span> · ' + esc(REASON[e.reason] ?? e.reason) +
|
|
207
|
+
(e.n > 1 ? ' · ×' + esc(e.n) : '') + '</td></tr>').join('') || '<tr><td class="mute">No connection attempts yet.</td></tr>')
|
|
165
208
|
// The certificate hash is long: a short form fits the popover, Copy gives the whole value.
|
|
166
209
|
const cert = s.fingerprint ? '<code title="SHA-256: ' + esc(s.fingerprint) + '">' + esc(short(s.fingerprint)) + '</code><button class="link" data-copy="' + esc(s.fingerprint) + '">Copy</button>' : '<code>-</code>'
|
|
167
210
|
put('status-table', [['Gateway', s.running ? 'running' : 'stopped'], ['Address', (s.address ?? 'no Wi‑Fi address') + ':' + s.port], ['DSH', s.upstream]]
|
|
@@ -171,7 +214,8 @@ setInterval(() => { if (state) render(state) }, 1000)
|
|
|
171
214
|
document.addEventListener('click', e => {
|
|
172
215
|
const b = e.target.closest('button'); if (!b) return
|
|
173
216
|
if (b.id === 'approve' || b.id === 'deny') post('/' + b.id, { id: b.dataset.id })
|
|
174
|
-
|
|
217
|
+
// One click: a lost phone is cut off at once. The iPhone pairs again to come back.
|
|
218
|
+
else if (b.dataset.revoke) post('/revoke', { id: b.dataset.revoke }).then(ok => { if (ok) say(b.dataset.name + ' was removed. It must pair again to connect.', 'ok') })
|
|
175
219
|
else if (b.id === 'copy') { if (state?.code) navigator.clipboard.writeText(state.code.payload).then(() => say('Pairing link copied. Paste it in Tether on the iPhone.', 'ok'), () => say('Could not copy.', 'warn')) }
|
|
176
220
|
else if (b.dataset.copy) navigator.clipboard.writeText(b.dataset.copy).then(() => say('Certificate hash copied.', 'ok'), () => say('Could not copy.', 'warn'))
|
|
177
221
|
else if (b.id === 'add' || b.id === 'cancel-add') { adding = b.id === 'add'; if (state) render(state) }
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { createServer as createHttps, request as httpsRequest } from 'node:https'
|
|
5
5
|
import { createServer as createHttp, request as httpRequest } from 'node:http'
|
|
6
6
|
import { createHash, randomBytes, randomUUID, timingSafeEqual, X509Certificate } from 'node:crypto'
|
|
7
|
-
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
7
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
8
8
|
import { execFile, execFileSync, spawn } from 'node:child_process'
|
|
9
9
|
import { homedir, hostname, networkInterfaces } from 'node:os'
|
|
10
10
|
import { dirname, join } from 'node:path'
|
|
@@ -13,6 +13,12 @@ import { fileURLToPath } from 'node:url'
|
|
|
13
13
|
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
14
14
|
const TOKEN_TTL = 30 * 864e5
|
|
15
15
|
const MAX_BODY = 16 << 20
|
|
16
|
+
/** Access log: at most this many entries on disk; the same attempt again within LOG_MERGE_MS counts up one entry. */
|
|
17
|
+
export const LOG_MAX = 200
|
|
18
|
+
const LOG_MERGE_MS = 60e3
|
|
19
|
+
/** A JSON file written owner-only and replaced atomically (a crash never leaves half a file). */
|
|
20
|
+
const saveJson = (file, data) => { const tmp = file + '.' + process.pid + '.tmp'; writeFileSync(tmp, JSON.stringify(data), { mode: 0o600 }); renameSync(tmp, file) }
|
|
21
|
+
const readJson = (file, fallback) => { try { return JSON.parse(readFileSync(file, 'utf8')) ?? fallback } catch { return fallback } }
|
|
16
22
|
|
|
17
23
|
/** The name the phone shows for this Mac in its Mac picker: the Mac's own "Computer Name", else the host name. */
|
|
18
24
|
export function macName(read = () => execFileSync('scutil', ['--get', 'ComputerName'], { encoding: 'utf8', timeout: 2000 })) {
|
|
@@ -38,7 +44,7 @@ export const STREAMS = new Set(['session/follow', 'session/control', 'workspace/
|
|
|
38
44
|
* Only the chat-level features; never the switches (`{on}`), the updater, the screenshots or the Mobile connect route,
|
|
39
45
|
* so a phone cannot turn features off, install code or open ports. */
|
|
40
46
|
export const PLUS = new Map([
|
|
41
|
-
['changes', ['GET']], ['diff', ['GET']], ['continue', ['POST']], ['grants', ['GET', 'POST']],
|
|
47
|
+
['changes', ['GET']], ['diff', ['GET']], ['graph', ['GET']], ['continue', ['POST']], ['grants', ['GET', 'POST']],
|
|
42
48
|
['rollback', ['GET', 'POST']], ['delete-chat', ['POST']], ['ideas', ['POST']], ['later', ['GET', 'POST']], ['idea-history', ['GET', 'POST']],
|
|
43
49
|
['queue-move', ['POST']], ['predict', ['POST']], ['complete', ['POST']], ['improve', ['POST']], ['unread', ['GET', 'POST']],
|
|
44
50
|
])
|
|
@@ -47,8 +53,9 @@ export const AUTO_REVIEW_ASK = 'Auto review denied tool '
|
|
|
47
53
|
export const isGrantedAsk = (v, grants) => v.event === 'approval/request' && typeof v.request?.reason === 'string'
|
|
48
54
|
&& v.request.reason.startsWith(AUTO_REVIEW_ASK) && typeof v.request.toolName === 'string' && typeof v.agentId === 'string'
|
|
49
55
|
&& Array.isArray(grants?.[v.agentId]) && grants[v.agentId].includes(v.request.toolName)
|
|
50
|
-
/** A switch body (`{on: true|false}`) or a setting (`{model}`) changes the Mac for every device:
|
|
51
|
-
|
|
56
|
+
/** A switch body (`{on: true|false}`) or a setting (`{model}`, the grant time `{ttlMs}`) changes the Mac for every device:
|
|
57
|
+
* the phone never sends one. */
|
|
58
|
+
const isSwitch = b => Object.hasOwn(b, 'on') || Object.hasOwn(b, 'model') || Object.hasOwn(b, 'ttlMs')
|
|
52
59
|
export const EVENTS = new Set([
|
|
53
60
|
'agent-preset/selected', 'approval/request', 'api-session/activity', 'api-session/added', 'api-session/error',
|
|
54
61
|
'api-session/removed', 'api-session/status', 'commands/change', 'goal/activation-changed',
|
|
@@ -81,8 +88,9 @@ export const isTailnet = ip => {
|
|
|
81
88
|
return /^fd7a:115c:a1e0:/.test(ip)
|
|
82
89
|
}
|
|
83
90
|
// Accept LAN peers that reached us on the LAN address, or tailnet peers that reached us on our tailnet address.
|
|
84
|
-
|
|
85
|
-
|
|
91
|
+
// `tailnetOnly` (the Mac page's "Tailscale only"): tailnet peers only, so nobody on a shared Wi-Fi reaches the gateway.
|
|
92
|
+
export const allowed = (remote, local, net, tailnetOnly = false) =>
|
|
93
|
+
(!tailnetOnly && inSubnet(remote, net.address, net.netmask) && String(local).replace(/^::ffff:/i, '') === net.address) ||
|
|
86
94
|
(isTailnet(remote) && isTailnet(local))
|
|
87
95
|
const TS_BINS = ['tailscale', '/Applications/Tailscale.app/Contents/MacOS/Tailscale']
|
|
88
96
|
export function tailscaleInfo(bins = TS_BINS) {
|
|
@@ -259,11 +267,36 @@ export function createGateway(opts = {}) {
|
|
|
259
267
|
const codeTtl = opts.codeTtlMs ?? 5 * 60e3
|
|
260
268
|
const approveTtl = opts.approveTtlMs ?? 120e3
|
|
261
269
|
const waterfallTtl = opts.waterfallTimeoutMs ?? 10 * 60e3
|
|
270
|
+
const seenSaveMs = opts.seenSaveMs ?? 60e3 // the last connection times reach devices.json at most this often
|
|
262
271
|
mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
263
272
|
const upstream = makeUpstream(opts.upstreamUrl)
|
|
264
273
|
const devFile = join(dir, 'devices.json')
|
|
265
274
|
let devices = existsSync(devFile) ? JSON.parse(readFileSync(devFile, 'utf8')) : []
|
|
266
|
-
|
|
275
|
+
let seenDirty = false, seenSavedAt = 0 // a `lastSeenAt` that is only in memory yet; when devices.json was written
|
|
276
|
+
// The flags change only after a good write: a failed one is tried again at the next watch tick (and at close).
|
|
277
|
+
const saveDevices = () => { saveJson(devFile, devices); seenDirty = false; seenSavedAt = Date.now() }
|
|
278
|
+
// "Tailscale only" (the Mac page): kept in its own file, so it survives a restart of the gateway and of dsh.
|
|
279
|
+
const settingsFile = join(dir, 'settings.json')
|
|
280
|
+
let tailnetOnly = readJson(settingsFile, {})?.tailnetOnly === true
|
|
281
|
+
// ---- Access log: each connection attempt with its outcome, oldest first (memory and access.json). ----
|
|
282
|
+
const logFile = join(dir, 'access.json')
|
|
283
|
+
let log = [readJson(logFile, [])].flat().filter(e => typeof e?.at === 'string' && typeof e.ip === 'string' && Number.isSafeInteger(e.n)).slice(-LOG_MAX), logTimer
|
|
284
|
+
// ponytail: a failed write is dropped; the list in memory stays right and the next attempt writes it again.
|
|
285
|
+
const flushLog = () => { clearTimeout(logTimer); logTimer = undefined; try { saveJson(logFile, log) } catch {} }
|
|
286
|
+
/** `result`: allowed | refused. `reason`: lan, tailnet, paired (allowed); network, tailnet-only, tls, rate, token, code,
|
|
287
|
+
* denied, expired, cancelled (refused). The same attempt again within a minute counts up one entry (`n`), so a phone
|
|
288
|
+
* that retries or a scanner cannot push the other entries out at once. */
|
|
289
|
+
function logAttempt(ip, device, result, reason) {
|
|
290
|
+
ip = String(ip ?? '').replace(/^::ffff:/i, '')
|
|
291
|
+
const now = Date.now()
|
|
292
|
+
const i = log.findLastIndex(e => now - Date.parse(e.at) < LOG_MERGE_MS && e.ip === ip && e.device === device && e.result === result && e.reason === reason)
|
|
293
|
+
const e = i < 0 ? { ip, device, result, reason, n: 0 } : log.splice(i, 1)[0]
|
|
294
|
+
e.at = new Date(now).toISOString(); e.n++
|
|
295
|
+
log.push(e)
|
|
296
|
+
if (log.length > LOG_MAX) log.splice(0, log.length - LOG_MAX)
|
|
297
|
+
logTimer ??= setTimeout(flushLog, 2000)
|
|
298
|
+
logTimer.unref?.()
|
|
299
|
+
}
|
|
267
300
|
// Tailscale status: cached 30 s, refreshed on start and on new pairing codes. Tests inject opts.tailscale.
|
|
268
301
|
let ts = { installed: false, online: false, ip: null, name: null }, tsAt = 0
|
|
269
302
|
const refreshTs = async () => {
|
|
@@ -303,11 +336,15 @@ export function createGateway(opts = {}) {
|
|
|
303
336
|
if (Date.parse(d.expiresAt) - Date.now() < TOKEN_TTL / 2) { d.expiresAt = new Date(Date.now() + TOKEN_TTL).toISOString(); saveDevices() }
|
|
304
337
|
return d
|
|
305
338
|
}
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
339
|
+
/** A paired phone's request on a socket: a new socket is one connection (logged, and the device's last connection). */
|
|
340
|
+
function track(d, socket) {
|
|
341
|
+
let s = devSockets.get(d.id)
|
|
342
|
+
if (!s) devSockets.set(d.id, s = new Set())
|
|
309
343
|
if (s.has(socket)) return
|
|
310
344
|
s.add(socket); socket.once('close', () => s.delete(socket))
|
|
345
|
+
logAttempt(socket.remoteAddress, d.name, 'allowed', isTailnet(socket.remoteAddress) ? 'tailnet' : 'lan')
|
|
346
|
+
// In memory at once; the network watch writes it at most once a minute (and close() at the end).
|
|
347
|
+
d.lastSeenAt = new Date().toISOString(); seenDirty = true
|
|
311
348
|
}
|
|
312
349
|
function revoke(id) {
|
|
313
350
|
const before = devices.length
|
|
@@ -326,7 +363,10 @@ export function createGateway(opts = {}) {
|
|
|
326
363
|
// ---- Pairing: one-time code (5 min) shown as QR, then explicit approval on the Mac. ----
|
|
327
364
|
// The latest outcome, kept 60 s so the Mac page can say what happened after the request card goes away.
|
|
328
365
|
let last = null
|
|
329
|
-
const record = (p, result) => {
|
|
366
|
+
const record = (p, result) => {
|
|
367
|
+
last = { name: p.name, result, at: Date.now() }
|
|
368
|
+
logAttempt(p.ip, p.name, result === 'approved' ? 'allowed' : 'refused', result === 'approved' ? 'paired' : result)
|
|
369
|
+
}
|
|
330
370
|
function newCode() {
|
|
331
371
|
const net = bound ?? netInfo()
|
|
332
372
|
code = { value: randomBytes(16).toString('base64url'), expires: Date.now() + codeTtl }
|
|
@@ -344,7 +384,7 @@ export function createGateway(opts = {}) {
|
|
|
344
384
|
let b
|
|
345
385
|
try { b = JSON.parse(await readBody(req, 4096)) } catch { return reply(res, 400, { error: 'bad request' }) }
|
|
346
386
|
const ok = code && typeof b?.code === 'string' && Date.now() < code.expires && safeEq(b.code, code.value)
|
|
347
|
-
if (!ok) { fail(ip); return reply(res, 403, { error: 'invalid or expired pairing code' }) }
|
|
387
|
+
if (!ok) { fail(ip); logAttempt(ip, null, 'refused', 'code'); return reply(res, 403, { error: 'invalid or expired pairing code' }) }
|
|
348
388
|
code = null // one-time: a replayed code is rejected
|
|
349
389
|
const p = { id: randomUUID(), name: String(b.name ?? 'iPhone').replace(/[\u0000-\u001f]/g, '').slice(0, 64) || 'iPhone', ip, at: Date.now(), res }
|
|
350
390
|
pending.set(p.id, p)
|
|
@@ -449,12 +489,12 @@ export function createGateway(opts = {}) {
|
|
|
449
489
|
// ---- Phone-facing HTTPS ----
|
|
450
490
|
async function onRequest(req, res) {
|
|
451
491
|
const ip = req.socket.remoteAddress
|
|
452
|
-
if (!allowReq(ip)) return reply(res, 429, { error: 'rate limited' })
|
|
492
|
+
if (!allowReq(ip)) { logAttempt(ip, null, 'refused', 'rate'); return reply(res, 429, { error: 'rate limited' }) }
|
|
453
493
|
const path = new URL(req.url, 'https://x').pathname
|
|
454
494
|
if (req.method === 'POST' && path === '/pair') return pair(req, res, ip)
|
|
455
495
|
const dev = auth(req)
|
|
456
|
-
if (!dev) { fail(ip); return reply(res, 401, { error: 'unauthorized' }) }
|
|
457
|
-
track(dev
|
|
496
|
+
if (!dev) { fail(ip); logAttempt(ip, null, 'refused', 'token'); return reply(res, 401, { error: 'unauthorized' }) }
|
|
497
|
+
track(dev, req.socket)
|
|
458
498
|
if (req.method === 'GET' && path === '/me') return reply(res, 200, { deviceId: dev.id, name: dev.name, expiresAt: dev.expiresAt, upstream: upState, tailscale: ts.online ? ts.ip : null, mac, plus: [...PLUS.keys()] })
|
|
459
499
|
if (path.startsWith('/plus/')) return plus(req, res, path.slice(6))
|
|
460
500
|
if (req.method !== 'POST' || !path.startsWith('/api/')) return reply(res, 404, { error: 'not found' })
|
|
@@ -492,13 +532,13 @@ export function createGateway(opts = {}) {
|
|
|
492
532
|
}
|
|
493
533
|
function onUpgrade(req, socket, head) {
|
|
494
534
|
const ip = socket.remoteAddress
|
|
495
|
-
if (!allowReq(ip)) return socket.end('HTTP/1.1 429 Too Many Requests\r\nConnection: close\r\n\r\n')
|
|
535
|
+
if (!allowReq(ip)) { logAttempt(ip, null, 'refused', 'rate'); return socket.end('HTTP/1.1 429 Too Many Requests\r\nConnection: close\r\n\r\n') }
|
|
496
536
|
if (new URL(req.url, 'https://x').pathname !== '/api/remote.mux') return socket.end('HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n')
|
|
497
537
|
const dev = auth(req)
|
|
498
|
-
if (!dev) { fail(ip); return socket.end('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n') }
|
|
538
|
+
if (!dev) { fail(ip); logAttempt(ip, null, 'refused', 'token'); return socket.end('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n') }
|
|
499
539
|
const ws = acceptWs(req, socket, head)
|
|
500
540
|
if (!ws) return
|
|
501
|
-
track(dev
|
|
541
|
+
track(dev, socket)
|
|
502
542
|
phoneMux(ws)
|
|
503
543
|
}
|
|
504
544
|
const isId = v => typeof v === 'string' && v.length > 0 && v.length <= 128
|
|
@@ -548,27 +588,42 @@ export function createGateway(opts = {}) {
|
|
|
548
588
|
await refreshTs()
|
|
549
589
|
const s = createHttps({ key, cert, minVersion: 'TLSv1.2' }, onRequest)
|
|
550
590
|
s.on('connection', sock => {
|
|
551
|
-
|
|
591
|
+
// LAN or tailnet only (or tailnet only), decided by the socket's own addresses
|
|
592
|
+
if (!allowed(sock.remoteAddress, sock.localAddress, net, tailnetOnly)) {
|
|
593
|
+
logAttempt(sock.remoteAddress, null, 'refused', tailnetOnly && allowed(sock.remoteAddress, sock.localAddress, net) ? 'tailnet-only' : 'network')
|
|
594
|
+
return sock.destroy()
|
|
595
|
+
}
|
|
552
596
|
conns.add(sock); sock.once('close', () => conns.delete(sock))
|
|
553
597
|
})
|
|
554
598
|
s.on('upgrade', onUpgrade)
|
|
555
599
|
// Kept for diagnosis only (no secrets): why a phone's TLS handshake died, shown in admin /state.
|
|
556
|
-
s.on('tlsClientError', (e, sock) => {
|
|
600
|
+
s.on('tlsClientError', (e, sock) => {
|
|
601
|
+
lastTlsError = { ip: sock.remoteAddress ?? null, error: String(e?.code ?? e?.message ?? e), at: new Date().toISOString() }
|
|
602
|
+
// A socket refused above also ends here: it is in the log once, with that reason.
|
|
603
|
+
if (allowed(sock.remoteAddress, sock.localAddress, net, tailnetOnly)) logAttempt(sock.remoteAddress, null, 'refused', 'tls')
|
|
604
|
+
sock.destroy()
|
|
605
|
+
})
|
|
557
606
|
await new Promise((ok, bad) => { s.once('error', bad); s.listen(port, net.bind ?? '::', ok) })
|
|
558
607
|
server = s; bound = net; port = s.address().port; running = true
|
|
559
|
-
|
|
560
|
-
// The service name is the Mac's name, so the phone's "Macs on this network" list tells two Macs apart.
|
|
561
|
-
bonjour = spawn('dns-sd', ['-R', mac, '_dshmobile._tcp', 'local', String(port), 'fp=' + fp], { stdio: 'ignore' })
|
|
562
|
-
bonjour.on('error', () => {})
|
|
563
|
-
}
|
|
608
|
+
advertise()
|
|
564
609
|
connectEvents()
|
|
565
610
|
}
|
|
611
|
+
// Bonjour gives each device on the Wi-Fi the Mac name, the port and the certificate fingerprint: not while "Tailscale only"
|
|
612
|
+
// is on (the phone reaches the Mac over the tailnet with the address from pairing; mDNS does not cross Tailscale).
|
|
613
|
+
function advertise() {
|
|
614
|
+
const want = running && !tailnetOnly && opts.bonjour !== false
|
|
615
|
+
if (want === !!bonjour) return
|
|
616
|
+
if (!want) { bonjour.kill(); bonjour = null; return }
|
|
617
|
+
// The service name is the Mac's name, so the phone's "Macs on this network" list tells two Macs apart.
|
|
618
|
+
bonjour = (opts.spawn ?? spawn)('dns-sd', ['-R', mac, '_dshmobile._tcp', 'local', String(port), 'fp=' + fp], { stdio: 'ignore' })
|
|
619
|
+
bonjour.on('error', () => {})
|
|
620
|
+
}
|
|
566
621
|
async function stop(keepWanted = false) {
|
|
567
622
|
wanted = keepWanted
|
|
568
623
|
if (!running) return
|
|
569
624
|
running = false
|
|
570
625
|
clearTimeout(evTimer)
|
|
571
|
-
|
|
626
|
+
advertise()
|
|
572
627
|
ev.ws?.close()
|
|
573
628
|
for (const sock of conns) sock.destroy()
|
|
574
629
|
await new Promise(ok => server.close(ok))
|
|
@@ -580,6 +635,8 @@ export function createGateway(opts = {}) {
|
|
|
580
635
|
const now = Date.now()
|
|
581
636
|
for (const m of [reqHits, fails]) for (const [k, b] of m) if (now - b.t > 60e3) m.delete(k)
|
|
582
637
|
for (const set of devSockets.values()) for (const s of set) if (s.destroyed) set.delete(s)
|
|
638
|
+
// ponytail: a failed write keeps the times in memory; the next tick tries again.
|
|
639
|
+
if (seenDirty && now - seenSavedAt >= seenSaveMs) try { saveDevices() } catch {}
|
|
583
640
|
if (now - tsAt > 30e3) refreshTs()
|
|
584
641
|
const n = netInfo()
|
|
585
642
|
if (running && (!n || n.address !== bound.address || n.netmask !== bound.netmask)) await stop(true).catch(() => {})
|
|
@@ -589,7 +646,8 @@ export function createGateway(opts = {}) {
|
|
|
589
646
|
|
|
590
647
|
const state = () => ({
|
|
591
648
|
mac, running, address: bound?.address ?? netInfo()?.address ?? null, port, tls: running ? 'TLS 1.2+, self-signed' : 'off',
|
|
592
|
-
fingerprint: fp ?? null, upstream: upState, tailscale: ts, lastTlsError,
|
|
649
|
+
fingerprint: fp ?? null, upstream: upState, tailscale: ts, lastTlsError, tailnetOnly,
|
|
650
|
+
log: log.slice(-50).reverse(), // newest first
|
|
593
651
|
code: code && Date.now() < code.expires ? { payload: code.payload, expiresAt: new Date(code.expires).toISOString(), qr: qrReady } : null,
|
|
594
652
|
pending: [...pending.values()].map(({ id, name, ip, at }) => ({ id, name, ip, at, expiresAt: new Date(at + approveTtl).toISOString() })),
|
|
595
653
|
last: last && Date.now() - last.at < 60e3 ? { ...last, at: new Date(last.at).toISOString() } : null,
|
|
@@ -615,6 +673,14 @@ export function createGateway(opts = {}) {
|
|
|
615
673
|
if (!settle(b.id, path === '/approve')) return reply(res, 409, { error: 'This request is no longer waiting. The iPhone stopped waiting or the request timed out. Scan the code again.' })
|
|
616
674
|
}
|
|
617
675
|
else if (path === '/revoke') revoke(b.id)
|
|
676
|
+
else if (path === '/settings') {
|
|
677
|
+
if (typeof b.tailnetOnly !== 'boolean') return reply(res, 400, { error: 'bad request' })
|
|
678
|
+
saveJson(settingsFile, { tailnetOnly: b.tailnetOnly }) // a failed save changes nothing (500 below)
|
|
679
|
+
tailnetOnly = b.tailnetOnly
|
|
680
|
+
// An open connection that the new rule refuses closes now, not at its next reconnect.
|
|
681
|
+
if (bound) for (const sock of conns) if (!allowed(sock.remoteAddress, sock.localAddress, bound, tailnetOnly)) sock.destroy()
|
|
682
|
+
advertise()
|
|
683
|
+
}
|
|
618
684
|
else if (path === '/start') await start()
|
|
619
685
|
else if (path === '/stop') await stop()
|
|
620
686
|
else return reply(res, 404, { error: 'not found' })
|
|
@@ -625,7 +691,11 @@ export function createGateway(opts = {}) {
|
|
|
625
691
|
adminServer = createHttp(onAdmin)
|
|
626
692
|
return new Promise((ok, bad) => { adminServer.once('error', bad); adminServer.listen(p, '127.0.0.1', () => ok(adminServer.address().port)) })
|
|
627
693
|
}
|
|
628
|
-
async function close() {
|
|
694
|
+
async function close() {
|
|
695
|
+
clearInterval(watch); await stop(); for (const p of pending.values()) p.res.destroy(); adminServer?.close(); adminServer?.closeAllConnections()
|
|
696
|
+
if (logTimer) flushLog()
|
|
697
|
+
if (seenDirty) try { saveDevices() } catch {}
|
|
698
|
+
}
|
|
629
699
|
|
|
630
700
|
/** An event from dsh-composer-plus itself (not from dsh's `$events`), for every connected phone. */
|
|
631
701
|
const emit = (event, args) => toPhones({ type: 'emit', event, args })
|