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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-composer-plus",
3
- "version": "1.12.0",
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' : 'offline') + '</span></td><td><button class="link" data-revoke="' + esc(d.id) + '">Remove</button></td></tr>').join('')
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
- else if (b.dataset.revoke) { if (confirm('Remove this iPhone? It disconnects now and must pair again.')) post('/revoke', { id: b.dataset.revoke }) }
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: the phone never sends one. */
51
- const isSwitch = b => Object.hasOwn(b, 'on') || Object.hasOwn(b, 'model')
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
- export const allowed = (remote, local, net) =>
85
- (inSubnet(remote, net.address, net.netmask) && String(local).replace(/^::ffff:/i, '') === net.address) ||
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
- const saveDevices = () => writeFileSync(devFile, JSON.stringify(devices, null, 2), { mode: 0o600 })
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
- function track(id, socket) {
307
- let s = devSockets.get(id)
308
- if (!s) devSockets.set(id, s = new Set())
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) => { last = { name: p.name, result, at: Date.now() } }
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.id, req.socket)
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.id, socket)
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
- if (!allowed(sock.remoteAddress, sock.localAddress, net)) return sock.destroy() // LAN or tailnet only, decided by the socket's own addresses
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) => { lastTlsError = { ip: sock.remoteAddress ?? null, error: String(e?.code ?? e?.message ?? e), at: new Date().toISOString() }; sock.destroy() })
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
- if (opts.bonjour !== false) {
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
- bonjour?.kill(); bonjour = null
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() { clearInterval(watch); await stop(); for (const p of pending.values()) p.res.destroy(); adminServer?.close(); adminServer?.closeAllConnections() }
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 })