dsh-composer-plus 1.12.0 → 1.14.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 +96 -19
- package/README.es.md +96 -19
- package/README.fr.md +96 -19
- package/README.hi.md +96 -19
- package/README.id.md +96 -19
- package/README.ja.md +96 -19
- package/README.ko.md +96 -19
- package/README.md +96 -19
- package/README.pt-BR.md +96 -19
- package/README.ru.md +96 -19
- package/README.tr.md +96 -19
- package/README.vi.md +96 -19
- package/README.zh.md +96 -19
- package/assets/features/changesPanel.webp +0 -0
- package/assets/features/copyMessageLink.webp +0 -0
- package/assets/features/defaultWorkspace.webp +0 -0
- package/assets/features/findInChat.webp +0 -0
- package/assets/features/goalCheck.webp +0 -0
- package/assets/features/markProjectRead.webp +0 -0
- package/assets/features/queueEdit.webp +0 -0
- package/assets/features/sessionBranch.webp +0 -0
- package/assets/features/worktreeChats.webp +0 -0
- package/client.js +1983 -199
- package/index.js +1824 -106
- package/package.json +7 -3
- package/tether/gateway/admin.html +47 -3
- package/tether/gateway/server.mjs +100 -29
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-composer-plus",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.14.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/file-chats.test.mjs && node test/file-chats-client.test.mjs && node test/feature-ideas.test.mjs && node test/feature-ideas-client.test.mjs && node test/github-issue.test.mjs && node test/project-status.test.mjs && node test/session-branch.test.mjs && node test/recent-projects.test.mjs && node test/project-tools.test.mjs && node test/project-picker.test.mjs && node test/worktree-chats.test.mjs && node test/worktree-chats-client.test.mjs && node test/default-workspace.test.mjs && node test/default-workspace-client.test.mjs && node test/find-in-chat.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/goal-check.test.mjs && node test/goal-check-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,17 +44,19 @@ 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']], ['file-chats', ['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']], ['improve', ['POST']], ['unread', ['GET', 'POST']],
|
|
49
|
+
['queue-move', ['POST']], ['branches', ['GET']], ['predict', ['POST']], ['complete', ['POST']], ['improve', ['POST']], ['unread', ['GET', 'POST']],
|
|
50
|
+
['worktrees', ['GET', 'POST']], ['new-session-workspace', ['POST']],
|
|
44
51
|
])
|
|
45
52
|
/** Allow for this chat: an Auto Review ask (`approval/request` with this reason) for a tool that the chat granted. */
|
|
46
53
|
export const AUTO_REVIEW_ASK = 'Auto review denied tool '
|
|
47
54
|
export const isGrantedAsk = (v, grants) => v.event === 'approval/request' && typeof v.request?.reason === 'string'
|
|
48
55
|
&& v.request.reason.startsWith(AUTO_REVIEW_ASK) && typeof v.request.toolName === 'string' && typeof v.agentId === 'string'
|
|
49
56
|
&& 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
|
-
|
|
57
|
+
/** A switch body (`{on: true|false}`) or a setting (`{model}`, the grant time `{ttlMs}`) changes the Mac for every device:
|
|
58
|
+
* the phone never sends one. */
|
|
59
|
+
const isSwitch = b => Object.hasOwn(b, 'on') || Object.hasOwn(b, 'model') || Object.hasOwn(b, 'ttlMs')
|
|
52
60
|
export const EVENTS = new Set([
|
|
53
61
|
'agent-preset/selected', 'approval/request', 'api-session/activity', 'api-session/added', 'api-session/error',
|
|
54
62
|
'api-session/removed', 'api-session/status', 'commands/change', 'goal/activation-changed',
|
|
@@ -81,8 +89,9 @@ export const isTailnet = ip => {
|
|
|
81
89
|
return /^fd7a:115c:a1e0:/.test(ip)
|
|
82
90
|
}
|
|
83
91
|
// Accept LAN peers that reached us on the LAN address, or tailnet peers that reached us on our tailnet address.
|
|
84
|
-
|
|
85
|
-
|
|
92
|
+
// `tailnetOnly` (the Mac page's "Tailscale only"): tailnet peers only, so nobody on a shared Wi-Fi reaches the gateway.
|
|
93
|
+
export const allowed = (remote, local, net, tailnetOnly = false) =>
|
|
94
|
+
(!tailnetOnly && inSubnet(remote, net.address, net.netmask) && String(local).replace(/^::ffff:/i, '') === net.address) ||
|
|
86
95
|
(isTailnet(remote) && isTailnet(local))
|
|
87
96
|
const TS_BINS = ['tailscale', '/Applications/Tailscale.app/Contents/MacOS/Tailscale']
|
|
88
97
|
export function tailscaleInfo(bins = TS_BINS) {
|
|
@@ -259,11 +268,36 @@ export function createGateway(opts = {}) {
|
|
|
259
268
|
const codeTtl = opts.codeTtlMs ?? 5 * 60e3
|
|
260
269
|
const approveTtl = opts.approveTtlMs ?? 120e3
|
|
261
270
|
const waterfallTtl = opts.waterfallTimeoutMs ?? 10 * 60e3
|
|
271
|
+
const seenSaveMs = opts.seenSaveMs ?? 60e3 // the last connection times reach devices.json at most this often
|
|
262
272
|
mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
263
273
|
const upstream = makeUpstream(opts.upstreamUrl)
|
|
264
274
|
const devFile = join(dir, 'devices.json')
|
|
265
275
|
let devices = existsSync(devFile) ? JSON.parse(readFileSync(devFile, 'utf8')) : []
|
|
266
|
-
|
|
276
|
+
let seenDirty = false, seenSavedAt = 0 // a `lastSeenAt` that is only in memory yet; when devices.json was written
|
|
277
|
+
// The flags change only after a good write: a failed one is tried again at the next watch tick (and at close).
|
|
278
|
+
const saveDevices = () => { saveJson(devFile, devices); seenDirty = false; seenSavedAt = Date.now() }
|
|
279
|
+
// "Tailscale only" (the Mac page): kept in its own file, so it survives a restart of the gateway and of dsh.
|
|
280
|
+
const settingsFile = join(dir, 'settings.json')
|
|
281
|
+
let tailnetOnly = readJson(settingsFile, {})?.tailnetOnly === true
|
|
282
|
+
// ---- Access log: each connection attempt with its outcome, oldest first (memory and access.json). ----
|
|
283
|
+
const logFile = join(dir, 'access.json')
|
|
284
|
+
let log = [readJson(logFile, [])].flat().filter(e => typeof e?.at === 'string' && typeof e.ip === 'string' && Number.isSafeInteger(e.n)).slice(-LOG_MAX), logTimer
|
|
285
|
+
// ponytail: a failed write is dropped; the list in memory stays right and the next attempt writes it again.
|
|
286
|
+
const flushLog = () => { clearTimeout(logTimer); logTimer = undefined; try { saveJson(logFile, log) } catch {} }
|
|
287
|
+
/** `result`: allowed | refused. `reason`: lan, tailnet, paired (allowed); network, tailnet-only, tls, rate, token, code,
|
|
288
|
+
* denied, expired, cancelled (refused). The same attempt again within a minute counts up one entry (`n`), so a phone
|
|
289
|
+
* that retries or a scanner cannot push the other entries out at once. */
|
|
290
|
+
function logAttempt(ip, device, result, reason) {
|
|
291
|
+
ip = String(ip ?? '').replace(/^::ffff:/i, '')
|
|
292
|
+
const now = Date.now()
|
|
293
|
+
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)
|
|
294
|
+
const e = i < 0 ? { ip, device, result, reason, n: 0 } : log.splice(i, 1)[0]
|
|
295
|
+
e.at = new Date(now).toISOString(); e.n++
|
|
296
|
+
log.push(e)
|
|
297
|
+
if (log.length > LOG_MAX) log.splice(0, log.length - LOG_MAX)
|
|
298
|
+
logTimer ??= setTimeout(flushLog, 2000)
|
|
299
|
+
logTimer.unref?.()
|
|
300
|
+
}
|
|
267
301
|
// Tailscale status: cached 30 s, refreshed on start and on new pairing codes. Tests inject opts.tailscale.
|
|
268
302
|
let ts = { installed: false, online: false, ip: null, name: null }, tsAt = 0
|
|
269
303
|
const refreshTs = async () => {
|
|
@@ -303,11 +337,15 @@ export function createGateway(opts = {}) {
|
|
|
303
337
|
if (Date.parse(d.expiresAt) - Date.now() < TOKEN_TTL / 2) { d.expiresAt = new Date(Date.now() + TOKEN_TTL).toISOString(); saveDevices() }
|
|
304
338
|
return d
|
|
305
339
|
}
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
340
|
+
/** A paired phone's request on a socket: a new socket is one connection (logged, and the device's last connection). */
|
|
341
|
+
function track(d, socket) {
|
|
342
|
+
let s = devSockets.get(d.id)
|
|
343
|
+
if (!s) devSockets.set(d.id, s = new Set())
|
|
309
344
|
if (s.has(socket)) return
|
|
310
345
|
s.add(socket); socket.once('close', () => s.delete(socket))
|
|
346
|
+
logAttempt(socket.remoteAddress, d.name, 'allowed', isTailnet(socket.remoteAddress) ? 'tailnet' : 'lan')
|
|
347
|
+
// In memory at once; the network watch writes it at most once a minute (and close() at the end).
|
|
348
|
+
d.lastSeenAt = new Date().toISOString(); seenDirty = true
|
|
311
349
|
}
|
|
312
350
|
function revoke(id) {
|
|
313
351
|
const before = devices.length
|
|
@@ -326,7 +364,10 @@ export function createGateway(opts = {}) {
|
|
|
326
364
|
// ---- Pairing: one-time code (5 min) shown as QR, then explicit approval on the Mac. ----
|
|
327
365
|
// The latest outcome, kept 60 s so the Mac page can say what happened after the request card goes away.
|
|
328
366
|
let last = null
|
|
329
|
-
const record = (p, result) => {
|
|
367
|
+
const record = (p, result) => {
|
|
368
|
+
last = { name: p.name, result, at: Date.now() }
|
|
369
|
+
logAttempt(p.ip, p.name, result === 'approved' ? 'allowed' : 'refused', result === 'approved' ? 'paired' : result)
|
|
370
|
+
}
|
|
330
371
|
function newCode() {
|
|
331
372
|
const net = bound ?? netInfo()
|
|
332
373
|
code = { value: randomBytes(16).toString('base64url'), expires: Date.now() + codeTtl }
|
|
@@ -344,7 +385,7 @@ export function createGateway(opts = {}) {
|
|
|
344
385
|
let b
|
|
345
386
|
try { b = JSON.parse(await readBody(req, 4096)) } catch { return reply(res, 400, { error: 'bad request' }) }
|
|
346
387
|
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' }) }
|
|
388
|
+
if (!ok) { fail(ip); logAttempt(ip, null, 'refused', 'code'); return reply(res, 403, { error: 'invalid or expired pairing code' }) }
|
|
348
389
|
code = null // one-time: a replayed code is rejected
|
|
349
390
|
const p = { id: randomUUID(), name: String(b.name ?? 'iPhone').replace(/[\u0000-\u001f]/g, '').slice(0, 64) || 'iPhone', ip, at: Date.now(), res }
|
|
350
391
|
pending.set(p.id, p)
|
|
@@ -449,12 +490,12 @@ export function createGateway(opts = {}) {
|
|
|
449
490
|
// ---- Phone-facing HTTPS ----
|
|
450
491
|
async function onRequest(req, res) {
|
|
451
492
|
const ip = req.socket.remoteAddress
|
|
452
|
-
if (!allowReq(ip)) return reply(res, 429, { error: 'rate limited' })
|
|
493
|
+
if (!allowReq(ip)) { logAttempt(ip, null, 'refused', 'rate'); return reply(res, 429, { error: 'rate limited' }) }
|
|
453
494
|
const path = new URL(req.url, 'https://x').pathname
|
|
454
495
|
if (req.method === 'POST' && path === '/pair') return pair(req, res, ip)
|
|
455
496
|
const dev = auth(req)
|
|
456
|
-
if (!dev) { fail(ip); return reply(res, 401, { error: 'unauthorized' }) }
|
|
457
|
-
track(dev
|
|
497
|
+
if (!dev) { fail(ip); logAttempt(ip, null, 'refused', 'token'); return reply(res, 401, { error: 'unauthorized' }) }
|
|
498
|
+
track(dev, req.socket)
|
|
458
499
|
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
500
|
if (path.startsWith('/plus/')) return plus(req, res, path.slice(6))
|
|
460
501
|
if (req.method !== 'POST' || !path.startsWith('/api/')) return reply(res, 404, { error: 'not found' })
|
|
@@ -492,13 +533,13 @@ export function createGateway(opts = {}) {
|
|
|
492
533
|
}
|
|
493
534
|
function onUpgrade(req, socket, head) {
|
|
494
535
|
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')
|
|
536
|
+
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
537
|
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
538
|
const dev = auth(req)
|
|
498
|
-
if (!dev) { fail(ip); return socket.end('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n') }
|
|
539
|
+
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
540
|
const ws = acceptWs(req, socket, head)
|
|
500
541
|
if (!ws) return
|
|
501
|
-
track(dev
|
|
542
|
+
track(dev, socket)
|
|
502
543
|
phoneMux(ws)
|
|
503
544
|
}
|
|
504
545
|
const isId = v => typeof v === 'string' && v.length > 0 && v.length <= 128
|
|
@@ -548,27 +589,42 @@ export function createGateway(opts = {}) {
|
|
|
548
589
|
await refreshTs()
|
|
549
590
|
const s = createHttps({ key, cert, minVersion: 'TLSv1.2' }, onRequest)
|
|
550
591
|
s.on('connection', sock => {
|
|
551
|
-
|
|
592
|
+
// LAN or tailnet only (or tailnet only), decided by the socket's own addresses
|
|
593
|
+
if (!allowed(sock.remoteAddress, sock.localAddress, net, tailnetOnly)) {
|
|
594
|
+
logAttempt(sock.remoteAddress, null, 'refused', tailnetOnly && allowed(sock.remoteAddress, sock.localAddress, net) ? 'tailnet-only' : 'network')
|
|
595
|
+
return sock.destroy()
|
|
596
|
+
}
|
|
552
597
|
conns.add(sock); sock.once('close', () => conns.delete(sock))
|
|
553
598
|
})
|
|
554
599
|
s.on('upgrade', onUpgrade)
|
|
555
600
|
// Kept for diagnosis only (no secrets): why a phone's TLS handshake died, shown in admin /state.
|
|
556
|
-
s.on('tlsClientError', (e, sock) => {
|
|
601
|
+
s.on('tlsClientError', (e, sock) => {
|
|
602
|
+
lastTlsError = { ip: sock.remoteAddress ?? null, error: String(e?.code ?? e?.message ?? e), at: new Date().toISOString() }
|
|
603
|
+
// A socket refused above also ends here: it is in the log once, with that reason.
|
|
604
|
+
if (allowed(sock.remoteAddress, sock.localAddress, net, tailnetOnly)) logAttempt(sock.remoteAddress, null, 'refused', 'tls')
|
|
605
|
+
sock.destroy()
|
|
606
|
+
})
|
|
557
607
|
await new Promise((ok, bad) => { s.once('error', bad); s.listen(port, net.bind ?? '::', ok) })
|
|
558
608
|
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
|
-
}
|
|
609
|
+
advertise()
|
|
564
610
|
connectEvents()
|
|
565
611
|
}
|
|
612
|
+
// Bonjour gives each device on the Wi-Fi the Mac name, the port and the certificate fingerprint: not while "Tailscale only"
|
|
613
|
+
// is on (the phone reaches the Mac over the tailnet with the address from pairing; mDNS does not cross Tailscale).
|
|
614
|
+
function advertise() {
|
|
615
|
+
const want = running && !tailnetOnly && opts.bonjour !== false
|
|
616
|
+
if (want === !!bonjour) return
|
|
617
|
+
if (!want) { bonjour.kill(); bonjour = null; return }
|
|
618
|
+
// The service name is the Mac's name, so the phone's "Macs on this network" list tells two Macs apart.
|
|
619
|
+
bonjour = (opts.spawn ?? spawn)('dns-sd', ['-R', mac, '_dshmobile._tcp', 'local', String(port), 'fp=' + fp], { stdio: 'ignore' })
|
|
620
|
+
bonjour.on('error', () => {})
|
|
621
|
+
}
|
|
566
622
|
async function stop(keepWanted = false) {
|
|
567
623
|
wanted = keepWanted
|
|
568
624
|
if (!running) return
|
|
569
625
|
running = false
|
|
570
626
|
clearTimeout(evTimer)
|
|
571
|
-
|
|
627
|
+
advertise()
|
|
572
628
|
ev.ws?.close()
|
|
573
629
|
for (const sock of conns) sock.destroy()
|
|
574
630
|
await new Promise(ok => server.close(ok))
|
|
@@ -580,6 +636,8 @@ export function createGateway(opts = {}) {
|
|
|
580
636
|
const now = Date.now()
|
|
581
637
|
for (const m of [reqHits, fails]) for (const [k, b] of m) if (now - b.t > 60e3) m.delete(k)
|
|
582
638
|
for (const set of devSockets.values()) for (const s of set) if (s.destroyed) set.delete(s)
|
|
639
|
+
// ponytail: a failed write keeps the times in memory; the next tick tries again.
|
|
640
|
+
if (seenDirty && now - seenSavedAt >= seenSaveMs) try { saveDevices() } catch {}
|
|
583
641
|
if (now - tsAt > 30e3) refreshTs()
|
|
584
642
|
const n = netInfo()
|
|
585
643
|
if (running && (!n || n.address !== bound.address || n.netmask !== bound.netmask)) await stop(true).catch(() => {})
|
|
@@ -589,7 +647,8 @@ export function createGateway(opts = {}) {
|
|
|
589
647
|
|
|
590
648
|
const state = () => ({
|
|
591
649
|
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,
|
|
650
|
+
fingerprint: fp ?? null, upstream: upState, tailscale: ts, lastTlsError, tailnetOnly,
|
|
651
|
+
log: log.slice(-50).reverse(), // newest first
|
|
593
652
|
code: code && Date.now() < code.expires ? { payload: code.payload, expiresAt: new Date(code.expires).toISOString(), qr: qrReady } : null,
|
|
594
653
|
pending: [...pending.values()].map(({ id, name, ip, at }) => ({ id, name, ip, at, expiresAt: new Date(at + approveTtl).toISOString() })),
|
|
595
654
|
last: last && Date.now() - last.at < 60e3 ? { ...last, at: new Date(last.at).toISOString() } : null,
|
|
@@ -615,6 +674,14 @@ export function createGateway(opts = {}) {
|
|
|
615
674
|
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
675
|
}
|
|
617
676
|
else if (path === '/revoke') revoke(b.id)
|
|
677
|
+
else if (path === '/settings') {
|
|
678
|
+
if (typeof b.tailnetOnly !== 'boolean') return reply(res, 400, { error: 'bad request' })
|
|
679
|
+
saveJson(settingsFile, { tailnetOnly: b.tailnetOnly }) // a failed save changes nothing (500 below)
|
|
680
|
+
tailnetOnly = b.tailnetOnly
|
|
681
|
+
// An open connection that the new rule refuses closes now, not at its next reconnect.
|
|
682
|
+
if (bound) for (const sock of conns) if (!allowed(sock.remoteAddress, sock.localAddress, bound, tailnetOnly)) sock.destroy()
|
|
683
|
+
advertise()
|
|
684
|
+
}
|
|
618
685
|
else if (path === '/start') await start()
|
|
619
686
|
else if (path === '/stop') await stop()
|
|
620
687
|
else return reply(res, 404, { error: 'not found' })
|
|
@@ -625,7 +692,11 @@ export function createGateway(opts = {}) {
|
|
|
625
692
|
adminServer = createHttp(onAdmin)
|
|
626
693
|
return new Promise((ok, bad) => { adminServer.once('error', bad); adminServer.listen(p, '127.0.0.1', () => ok(adminServer.address().port)) })
|
|
627
694
|
}
|
|
628
|
-
async function close() {
|
|
695
|
+
async function close() {
|
|
696
|
+
clearInterval(watch); await stop(); for (const p of pending.values()) p.res.destroy(); adminServer?.close(); adminServer?.closeAllConnections()
|
|
697
|
+
if (logTimer) flushLog()
|
|
698
|
+
if (seenDirty) try { saveDevices() } catch {}
|
|
699
|
+
}
|
|
629
700
|
|
|
630
701
|
/** An event from dsh-composer-plus itself (not from dsh's `$events`), for every connected phone. */
|
|
631
702
|
const emit = (event, args) => toPhones({ type: 'emit', event, args })
|