dsh-composer-plus 1.11.3 → 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 +81 -42
- package/README.es.md +81 -42
- package/README.fr.md +81 -42
- package/README.hi.md +81 -42
- package/README.id.md +81 -42
- package/README.ja.md +81 -42
- package/README.ko.md +81 -42
- package/README.md +81 -42
- package/README.pt-BR.md +81 -42
- package/README.ru.md +81 -42
- package/README.tr.md +81 -42
- package/README.vi.md +81 -42
- package/README.zh.md +81 -42
- package/assets/features/changesPanel.webp +0 -0
- package/assets/features/copyMessageLink.webp +0 -0
- package/assets/features/markProjectRead.webp +0 -0
- package/assets/features/promptImprove.webp +0 -0
- package/assets/features/queueEdit.webp +0 -0
- package/client.js +1215 -144
- package/index.js +696 -116
- package/package.json +8 -3
- package/tether/gateway/admin.html +47 -3
- package/tether/gateway/server.mjs +114 -35
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,8 +77,9 @@
|
|
|
73
77
|
"assets/features/*.webp"
|
|
74
78
|
],
|
|
75
79
|
"scripts": {
|
|
76
|
-
"test": "node scripts/build-locales.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/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/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",
|
|
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",
|
|
82
|
+
"build:readme": "node scripts/build-docs.mjs",
|
|
78
83
|
"prepublishOnly": "npm test"
|
|
79
84
|
},
|
|
80
85
|
"dsh": {
|
|
@@ -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,17 +44,18 @@ 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
|
-
['queue-move', ['POST']], ['predict', ['POST']], ['complete', ['POST']],
|
|
49
|
+
['queue-move', ['POST']], ['predict', ['POST']], ['complete', ['POST']], ['improve', ['POST']], ['unread', ['GET', 'POST']],
|
|
44
50
|
])
|
|
45
51
|
/** Allow for this chat: an Auto Review ask (`approval/request` with this reason) for a tool that the chat granted. */
|
|
46
52
|
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) {
|
|
@@ -206,9 +214,10 @@ export function wsConnect(url, headers = {}, tls = {}) {
|
|
|
206
214
|
}
|
|
207
215
|
|
|
208
216
|
// ---- Upstream: the DSH host on loopback, authenticated with the URL that dsh-composer-plus (Mobile connect) gives. ----
|
|
209
|
-
function send(method, url, body, headers = {}) {
|
|
217
|
+
function send(method, url, body, headers = {}, signal) {
|
|
210
218
|
return new Promise((ok, bad) => {
|
|
211
|
-
|
|
219
|
+
// `signal`: the phone's request closed, so dsh's request goes too (a model call on the Mac stops).
|
|
220
|
+
const r = httpRequest(url, { method, headers, agent: false, signal }, res => {
|
|
212
221
|
const parts = []
|
|
213
222
|
res.on('data', d => parts.push(d))
|
|
214
223
|
res.on('end', () => ok({ status: res.statusCode, headers: res.headers, body: Buffer.concat(parts) }))
|
|
@@ -237,8 +246,8 @@ function makeUpstream(upstreamUrl) {
|
|
|
237
246
|
}
|
|
238
247
|
}
|
|
239
248
|
return {
|
|
240
|
-
rpc: (path, body, method = 'POST') => retry(async () => {
|
|
241
|
-
const r = await send(method, base + path, body, method === 'GET' ? { cookie } : { 'content-type': 'application/json', cookie })
|
|
249
|
+
rpc: (path, body, method = 'POST', signal) => retry(async () => {
|
|
250
|
+
const r = await send(method, base + path, body, method === 'GET' ? { cookie } : { 'content-type': 'application/json', cookie }, signal)
|
|
242
251
|
if (r.status === 401 || r.status === 403) throw Object.assign(new Error('upstream auth'), { status: r.status })
|
|
243
252
|
return r
|
|
244
253
|
}),
|
|
@@ -258,11 +267,36 @@ export function createGateway(opts = {}) {
|
|
|
258
267
|
const codeTtl = opts.codeTtlMs ?? 5 * 60e3
|
|
259
268
|
const approveTtl = opts.approveTtlMs ?? 120e3
|
|
260
269
|
const waterfallTtl = opts.waterfallTimeoutMs ?? 10 * 60e3
|
|
270
|
+
const seenSaveMs = opts.seenSaveMs ?? 60e3 // the last connection times reach devices.json at most this often
|
|
261
271
|
mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
262
272
|
const upstream = makeUpstream(opts.upstreamUrl)
|
|
263
273
|
const devFile = join(dir, 'devices.json')
|
|
264
274
|
let devices = existsSync(devFile) ? JSON.parse(readFileSync(devFile, 'utf8')) : []
|
|
265
|
-
|
|
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
|
+
}
|
|
266
300
|
// Tailscale status: cached 30 s, refreshed on start and on new pairing codes. Tests inject opts.tailscale.
|
|
267
301
|
let ts = { installed: false, online: false, ip: null, name: null }, tsAt = 0
|
|
268
302
|
const refreshTs = async () => {
|
|
@@ -302,11 +336,15 @@ export function createGateway(opts = {}) {
|
|
|
302
336
|
if (Date.parse(d.expiresAt) - Date.now() < TOKEN_TTL / 2) { d.expiresAt = new Date(Date.now() + TOKEN_TTL).toISOString(); saveDevices() }
|
|
303
337
|
return d
|
|
304
338
|
}
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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())
|
|
308
343
|
if (s.has(socket)) return
|
|
309
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
|
|
310
348
|
}
|
|
311
349
|
function revoke(id) {
|
|
312
350
|
const before = devices.length
|
|
@@ -325,7 +363,10 @@ export function createGateway(opts = {}) {
|
|
|
325
363
|
// ---- Pairing: one-time code (5 min) shown as QR, then explicit approval on the Mac. ----
|
|
326
364
|
// The latest outcome, kept 60 s so the Mac page can say what happened after the request card goes away.
|
|
327
365
|
let last = null
|
|
328
|
-
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
|
+
}
|
|
329
370
|
function newCode() {
|
|
330
371
|
const net = bound ?? netInfo()
|
|
331
372
|
code = { value: randomBytes(16).toString('base64url'), expires: Date.now() + codeTtl }
|
|
@@ -343,7 +384,7 @@ export function createGateway(opts = {}) {
|
|
|
343
384
|
let b
|
|
344
385
|
try { b = JSON.parse(await readBody(req, 4096)) } catch { return reply(res, 400, { error: 'bad request' }) }
|
|
345
386
|
const ok = code && typeof b?.code === 'string' && Date.now() < code.expires && safeEq(b.code, code.value)
|
|
346
|
-
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' }) }
|
|
347
388
|
code = null // one-time: a replayed code is rejected
|
|
348
389
|
const p = { id: randomUUID(), name: String(b.name ?? 'iPhone').replace(/[\u0000-\u001f]/g, '').slice(0, 64) || 'iPhone', ip, at: Date.now(), res }
|
|
349
390
|
pending.set(p.id, p)
|
|
@@ -448,12 +489,12 @@ export function createGateway(opts = {}) {
|
|
|
448
489
|
// ---- Phone-facing HTTPS ----
|
|
449
490
|
async function onRequest(req, res) {
|
|
450
491
|
const ip = req.socket.remoteAddress
|
|
451
|
-
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' }) }
|
|
452
493
|
const path = new URL(req.url, 'https://x').pathname
|
|
453
494
|
if (req.method === 'POST' && path === '/pair') return pair(req, res, ip)
|
|
454
495
|
const dev = auth(req)
|
|
455
|
-
if (!dev) { fail(ip); return reply(res, 401, { error: 'unauthorized' }) }
|
|
456
|
-
track(dev
|
|
496
|
+
if (!dev) { fail(ip); logAttempt(ip, null, 'refused', 'token'); return reply(res, 401, { error: 'unauthorized' }) }
|
|
497
|
+
track(dev, req.socket)
|
|
457
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()] })
|
|
458
499
|
if (path.startsWith('/plus/')) return plus(req, res, path.slice(6))
|
|
459
500
|
if (req.method !== 'POST' || !path.startsWith('/api/')) return reply(res, 404, { error: 'not found' })
|
|
@@ -478,21 +519,26 @@ export function createGateway(opts = {}) {
|
|
|
478
519
|
if (isSwitch(body)) return reply(res, 403, { error: 'endpoint not allowed' })
|
|
479
520
|
body = JSON.stringify(body)
|
|
480
521
|
}
|
|
522
|
+
// Improve prompt only (a model call that changes nothing): when the phone gives up (time-out, Cancel, a new tap), stop
|
|
523
|
+
// dsh's request, so the call ends and frees its chat. Every other route runs to its end on the Mac: the phone's first
|
|
524
|
+
// address gives up after 5 s, and a stopped rollback or delete would leave half a change.
|
|
525
|
+
const gone = name === 'improve' ? new AbortController() : undefined
|
|
526
|
+
if (gone) res.on('close', () => { if (!res.writableFinished) gone.abort() })
|
|
481
527
|
try {
|
|
482
|
-
const up = await upstream.rpc('/api/composer-plus.' + name + new URL(req.url, 'https://x').search, body, req.method)
|
|
528
|
+
const up = await upstream.rpc('/api/composer-plus.' + name + new URL(req.url, 'https://x').search, body, req.method, gone?.signal)
|
|
483
529
|
// An older dsh-composer-plus without this route answers 404: the phone hides the feature.
|
|
484
530
|
res.writeHead(up.status, { 'content-type': up.headers['content-type'] ?? 'application/json' }); res.end(up.body)
|
|
485
531
|
} catch { reply(res, 502, { error: 'DSH host unavailable' }) }
|
|
486
532
|
}
|
|
487
533
|
function onUpgrade(req, socket, head) {
|
|
488
534
|
const ip = socket.remoteAddress
|
|
489
|
-
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') }
|
|
490
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')
|
|
491
537
|
const dev = auth(req)
|
|
492
|
-
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') }
|
|
493
539
|
const ws = acceptWs(req, socket, head)
|
|
494
540
|
if (!ws) return
|
|
495
|
-
track(dev
|
|
541
|
+
track(dev, socket)
|
|
496
542
|
phoneMux(ws)
|
|
497
543
|
}
|
|
498
544
|
const isId = v => typeof v === 'string' && v.length > 0 && v.length <= 128
|
|
@@ -542,27 +588,42 @@ export function createGateway(opts = {}) {
|
|
|
542
588
|
await refreshTs()
|
|
543
589
|
const s = createHttps({ key, cert, minVersion: 'TLSv1.2' }, onRequest)
|
|
544
590
|
s.on('connection', sock => {
|
|
545
|
-
|
|
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
|
+
}
|
|
546
596
|
conns.add(sock); sock.once('close', () => conns.delete(sock))
|
|
547
597
|
})
|
|
548
598
|
s.on('upgrade', onUpgrade)
|
|
549
599
|
// Kept for diagnosis only (no secrets): why a phone's TLS handshake died, shown in admin /state.
|
|
550
|
-
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
|
+
})
|
|
551
606
|
await new Promise((ok, bad) => { s.once('error', bad); s.listen(port, net.bind ?? '::', ok) })
|
|
552
607
|
server = s; bound = net; port = s.address().port; running = true
|
|
553
|
-
|
|
554
|
-
// The service name is the Mac's name, so the phone's "Macs on this network" list tells two Macs apart.
|
|
555
|
-
bonjour = spawn('dns-sd', ['-R', mac, '_dshmobile._tcp', 'local', String(port), 'fp=' + fp], { stdio: 'ignore' })
|
|
556
|
-
bonjour.on('error', () => {})
|
|
557
|
-
}
|
|
608
|
+
advertise()
|
|
558
609
|
connectEvents()
|
|
559
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
|
+
}
|
|
560
621
|
async function stop(keepWanted = false) {
|
|
561
622
|
wanted = keepWanted
|
|
562
623
|
if (!running) return
|
|
563
624
|
running = false
|
|
564
625
|
clearTimeout(evTimer)
|
|
565
|
-
|
|
626
|
+
advertise()
|
|
566
627
|
ev.ws?.close()
|
|
567
628
|
for (const sock of conns) sock.destroy()
|
|
568
629
|
await new Promise(ok => server.close(ok))
|
|
@@ -574,6 +635,8 @@ export function createGateway(opts = {}) {
|
|
|
574
635
|
const now = Date.now()
|
|
575
636
|
for (const m of [reqHits, fails]) for (const [k, b] of m) if (now - b.t > 60e3) m.delete(k)
|
|
576
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 {}
|
|
577
640
|
if (now - tsAt > 30e3) refreshTs()
|
|
578
641
|
const n = netInfo()
|
|
579
642
|
if (running && (!n || n.address !== bound.address || n.netmask !== bound.netmask)) await stop(true).catch(() => {})
|
|
@@ -583,7 +646,8 @@ export function createGateway(opts = {}) {
|
|
|
583
646
|
|
|
584
647
|
const state = () => ({
|
|
585
648
|
mac, running, address: bound?.address ?? netInfo()?.address ?? null, port, tls: running ? 'TLS 1.2+, self-signed' : 'off',
|
|
586
|
-
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
|
|
587
651
|
code: code && Date.now() < code.expires ? { payload: code.payload, expiresAt: new Date(code.expires).toISOString(), qr: qrReady } : null,
|
|
588
652
|
pending: [...pending.values()].map(({ id, name, ip, at }) => ({ id, name, ip, at, expiresAt: new Date(at + approveTtl).toISOString() })),
|
|
589
653
|
last: last && Date.now() - last.at < 60e3 ? { ...last, at: new Date(last.at).toISOString() } : null,
|
|
@@ -609,6 +673,14 @@ export function createGateway(opts = {}) {
|
|
|
609
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.' })
|
|
610
674
|
}
|
|
611
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
|
+
}
|
|
612
684
|
else if (path === '/start') await start()
|
|
613
685
|
else if (path === '/stop') await stop()
|
|
614
686
|
else return reply(res, 404, { error: 'not found' })
|
|
@@ -619,8 +691,15 @@ export function createGateway(opts = {}) {
|
|
|
619
691
|
adminServer = createHttp(onAdmin)
|
|
620
692
|
return new Promise((ok, bad) => { adminServer.once('error', bad); adminServer.listen(p, '127.0.0.1', () => ok(adminServer.address().port)) })
|
|
621
693
|
}
|
|
622
|
-
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
|
+
}
|
|
699
|
+
|
|
700
|
+
/** An event from dsh-composer-plus itself (not from dsh's `$events`), for every connected phone. */
|
|
701
|
+
const emit = (event, args) => toPhones({ type: 'emit', event, args })
|
|
623
702
|
|
|
624
|
-
return { start, stop, close, state, newCode, settle, revoke, listenAdmin, get port() { return port }, get fingerprint() { return fp } }
|
|
703
|
+
return { start, stop, close, state, newCode, settle, revoke, listenAdmin, emit, get port() { return port }, get fingerprint() { return fp } }
|
|
625
704
|
}
|
|
626
705
|
|