dsh-mini-remote 0.2.0 → 0.2.1

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/lib/page.js CHANGED
@@ -16,13 +16,16 @@ const PROJECT_ROOT = join(HERE, '..')
16
16
 
17
17
  let template = null
18
18
 
19
- export function renderPage({ defaultMode = 'minimal', needsToken = false, build = '', maxUpload = 0 } = {}) {
19
+ export function renderPage({ defaultMode = 'minimal', needsToken = false, deviceRevoked = false, build = '', maxUpload = 0 } = {}) {
20
20
  if (template === null) template = readFileSync(PAGE_PATH, 'utf8')
21
21
  return template
22
22
  // 三种显示模式都认(单帧/聊天/完整);不认识的值一律回落单帧——回落要卡在这一处,
23
23
  // 各处自己挑认哪几个,迟早出现「存得进去、刷新就变回去」的对不上。
24
24
  .replace('__DEFAULT_MODE__', ['chat', 'full'].includes(defaultMode) ? defaultMode : 'minimal')
25
25
  .replace('__NEEDS_TOKEN__', needsToken ? 'true' : 'false')
26
+ // 门禁页上那句人话「这台设备的授权已被移除」要不要露出来(见 lib/server.js 那一条
27
+ // 判据):**被撤销**和**没进过门**是两回事,前者得告诉用户去哪儿重新扫码。
28
+ .replace('__DEVICE_REVOKED__', deviceRevoked ? 'true' : 'false')
26
29
  // 立绘地址带上构建指纹(页面自己拼 ?v=__BUILD__),换了图浏览器就不会一直
27
30
  // 拿缓存里那张。只留字母数字,别让它有机会往 HTML 里塞东西。
28
31
  .replace('__BUILD__', String(build).replace(/[^a-zA-Z0-9]/g, ''))
package/lib/remotes.js ADDED
@@ -0,0 +1,279 @@
1
+ /**
2
+ * 「我能连的电脑」名册(**B 侧**):一台远端电脑一条记录,落盘在
3
+ * `$DSH_HOME/dsh-mini-remote/remotes.json`(和 `devices.json` 并排,同一个数据目录)。
4
+ *
5
+ * ## 它和 devices.json 是两个人,别看错方向
6
+ *
7
+ * `devices.json` 记的是**谁连得上我**(那台电脑来配对时留下的名册:密钥只存哈希,
8
+ * 读了文件也冒充不了);这一份记的是**我连得上谁**:里面那枚 `credential` 是**别人
9
+ * 发给我的**门卡,每次去换票都要**原样**发回去,所以它必须留着、而且只能是明文
10
+ * (见下面"明文存"那一段)。
11
+ *
12
+ * ## 为什么不能放进 settings.json(和 devices.json 当初同一条口径)
13
+ *
14
+ * `settings.json` 是给用户手改的,字段会进设置页那张表单。**长期凭据混进去,等于
15
+ * 「改个设置」都有机会碰坏登录凭据**——而且那份文件还会进备份、进界面的表单回显。
16
+ * 这份名册由插件自己管;界面要看,就照这一份渲染一份名单。
17
+ *
18
+ * ## 凭据只活在宿主进程里,**绝不进浏览器**
19
+ *
20
+ * `list()` 和 `add()` 回来的都是 `publicRecord()`——**没有 credential**(照
21
+ * lib/devices.js 的 `publicRecord` 那个先例)。整份文件里**唯一**把凭据交出去的地方是
22
+ * `secret()`:它只给「取票」那条宿主路由用(见 lib/index.js 的
23
+ * `/mini-remote/remotes/window`),而那一条只把它放进发给 A 的**请求头**。
24
+ * 界面拿到的永远只是 id、名字、地址、两个时间。
25
+ *
26
+ * ## 明文存,不加密(有意的)
27
+ *
28
+ * devices.json 那边只需要**认**(存哈希就够);这一份反过来:B 必须把凭据**发回给 A**,
29
+ * 所以只能明文。同一目录下的 `token` 本身就是明文,多一道锁只是自欺。该做的是两件事:
30
+ * **文件权限 0600、永不外发**——能读这个文件的人,本来也读得到同一目录里的 `token`。
31
+ *
32
+ * ## 一条记录长什么样
33
+ *
34
+ * ```
35
+ * { id, name, baseUrl, credential, addedAt, lastOk }
36
+ * ```
37
+ * · `id`:**B 自己起的**名字(随机 128 位),只在 B 内部用。A 那边名册里那份 id 是
38
+ * 另一个(就在凭据里,形如 `<id>.<密钥>`),两边不互通、也不该互通;
39
+ * · `baseUrl`:A 的地址,**存进来的时候就规整过**(见 normalizeBaseUrl);
40
+ * · `lastOk`:**最近一次成功换到票**的时刻;`0` = 还没成功过。
41
+ * 只记时间,不记地址、不记 IP(和 devices.js 的 `lastSeen` 同一条口径)。
42
+ */
43
+ import { randomBytes } from 'node:crypto'
44
+ import { mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs'
45
+ import { dirname } from 'node:path'
46
+
47
+ import { credentialLooksLikeOurs } from './devices.js'
48
+
49
+ /** id 128 位。它只是 B 内部的名字,不是任何凭据。 */
50
+ const ID_BYTES = 16
51
+
52
+ /** 名字最长多少字。只管住界面(理由同 lib/devices.js 的 NAME_MAX)。 */
53
+ const NAME_MAX = 40
54
+
55
+ /** 地址最长多少字:够写下一整条 https 地址,又不至于把列表撑变形。 */
56
+ const URL_MAX = 300
57
+
58
+ /**
59
+ * 把用户给的地址规整成我们要存的那一条:去空格、去尾斜杠;**必须带 scheme**,
60
+ * 而且不带查询串和片段(那种地址后面拼 `/mini-remote/pair` 就拼坏了)。
61
+ *
62
+ * 为什么不"少了就替他补一个 `http://`":Tailscale 那条路是 **https**,补错了会在
63
+ * 对面表现成"连不上",而用户手里那条地址本来是对的——错在我们。所以宁可当场说清
64
+ * 怎么改,也不猜。
65
+ *
66
+ * @param {unknown} value
67
+ * @returns {string} 规整过的地址;`''` = 这个值不能用(调用方据此说人话)
68
+ */
69
+ export function normalizeBaseUrl(value) {
70
+ if (typeof value !== 'string') return ''
71
+ const text = value.trim().replace(/\/+$/, '')
72
+ if (!text || text.length > URL_MAX) return ''
73
+ let url
74
+ try {
75
+ url = new URL(text)
76
+ } catch {
77
+ return ''
78
+ }
79
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return ''
80
+ if (url.search || url.hash) return ''
81
+ return text
82
+ }
83
+
84
+ /**
85
+ * 名字没给的时候用什么。取地址里的 host(`192.168.1.5:3090`、`a.example.ts.net`)——
86
+ * 列表上要能一眼分出是哪一台;统一叫「另一台电脑」等于没名字。
87
+ */
88
+ function defaultNameFor(baseUrl) {
89
+ return new URL(baseUrl).host.slice(0, NAME_MAX)
90
+ }
91
+
92
+ export function createRemotes({ file }) {
93
+ /** 内存这份就是当前名册;磁盘那份是它的落点。 */
94
+ let table = []
95
+ /** 磁盘那份的「改动标记」(mtime + 大小)。null = 还没对过。 */
96
+ let stamp = null
97
+
98
+ function readFromDisk() {
99
+ try {
100
+ const raw = JSON.parse(readFileSync(file, 'utf8'))
101
+ const list = Array.isArray(raw?.remotes) ? raw.remotes : []
102
+ return list
103
+ .filter((r) => r && typeof r.id === 'string' && typeof r.baseUrl === 'string' && typeof r.credential === 'string')
104
+ .map((r) => ({
105
+ id: r.id,
106
+ name: typeof r.name === 'string' ? r.name : '',
107
+ baseUrl: r.baseUrl,
108
+ credential: r.credential,
109
+ // 缺字段当 0 = 「还没有过」,**不拿 addedAt 顶替 lastOk**(那会凭空造出一次成功)。
110
+ addedAt: Number(r.addedAt) || 0,
111
+ lastOk: Number(r.lastOk) || 0,
112
+ }))
113
+ } catch {
114
+ // 首次运行没有这个文件,或文件损坏——都不该让插件起不来。
115
+ // 损坏时名册当作空的(那是"这台电脑还没连过别的电脑"最接近的说法)。
116
+ return []
117
+ }
118
+ }
119
+
120
+ function fileTag() {
121
+ try {
122
+ const info = statSync(file)
123
+ return `${info.mtimeMs}:${info.size}`
124
+ } catch {
125
+ return 'missing'
126
+ }
127
+ }
128
+
129
+ /**
130
+ * 把内存那份对齐到磁盘那份。**只在磁盘真的变过时才重读。**
131
+ *
132
+ * 和 devices.js 那边同一条理由:这份文件是明文 JSON,用户完全可能自己去删一行
133
+ * ("这台电脑我不想连了")。要是拿启动时抄的那一份当真相,这种改动就得等重启才生效
134
+ * ——列表上照旧列着它,而用户以为自己已经断开了。每次多一次 stat,代价看不见。
135
+ */
136
+ function syncFromDisk() {
137
+ const tag = fileTag()
138
+ if (tag === stamp) return
139
+ stamp = tag
140
+ table = readFromDisk()
141
+ }
142
+
143
+ /**
144
+ * 一条记录对外的那一面(**credential 不在里面**)。
145
+ *
146
+ * 这是「凭据不进浏览器」这条规矩的**落点**:界面(以及日志、错误信息)能拿到的
147
+ * 只有这里这五个字段。谁要改这里,先想清楚是不是在把门卡递给界面。
148
+ */
149
+ function publicRecord(record) {
150
+ return {
151
+ id: record.id,
152
+ name: record.name,
153
+ baseUrl: record.baseUrl,
154
+ addedAt: record.addedAt,
155
+ lastOk: record.lastOk,
156
+ }
157
+ }
158
+
159
+ /**
160
+ * 落盘。**照 lib/store.js:378-392 / lib/devices.js:182-192 那一段原子写**
161
+ * (先写临时文件、再改名过去):直接往目标文件上写,写到一半断电就留下一份半截
162
+ * JSON,名册里那几台电脑一起没了。
163
+ *
164
+ * `mode: 0o600`:这份文件里有**别人发给我的长期凭据**,不该给别人看。
165
+ * (Windows 上这条基本被忽略,留着的意义是别的系统上一致——和 devices.js 同样的说明。)
166
+ *
167
+ * 失败不影响本次运行:内存里那份照常用,界面这一轮不会看到"添加失败"。
168
+ */
169
+ function save() {
170
+ try {
171
+ mkdirSync(dirname(file), { recursive: true })
172
+ const tmp = `${file}.tmp`
173
+ writeFileSync(tmp, `${JSON.stringify({ version: 1, remotes: table }, null, 2)}\n`, { mode: 0o600 })
174
+ renameSync(tmp, file)
175
+ } catch {
176
+ // 落盘失败不影响本次运行——内存里那台电脑这一轮照常能用。
177
+ }
178
+ stamp = fileTag()
179
+ }
180
+
181
+ return {
182
+ /**
183
+ * 名册(**不含 credential**),最近添加的排在前面。
184
+ *
185
+ * @returns {{id:string,name:string,baseUrl:string,addedAt:number,lastOk:number}[]}
186
+ */
187
+ list() {
188
+ syncFromDisk()
189
+ return table
190
+ .map(publicRecord)
191
+ .sort((a, b) => (b.addedAt - a.addedAt) || (a.id < b.id ? -1 : 1))
192
+ },
193
+
194
+ /**
195
+ * 拿一台电脑的**地址和凭据**。
196
+ *
197
+ * ⚠️ **这是整份名册里唯一交出凭据的地方**,只该被「取票」那一条宿主路由调用,
198
+ * 而且调用方有义务做到两件事:凭据**只进发给 A 的请求头**、**不进任何回给界面的
199
+ * JSON**(见 lib/index.js 的 `/mini-remote/remotes/window`)。
200
+ *
201
+ * 每次现查磁盘那份(见 syncFromDisk):用户手删了一行,下一次取票就找不到它了。
202
+ *
203
+ * @returns {{baseUrl:string, credential:string} | null}
204
+ */
205
+ secret(id) {
206
+ if (typeof id !== 'string' || !id) return null
207
+ syncFromDisk()
208
+ const hit = table.find((r) => r.id === id)
209
+ return hit ? { baseUrl: hit.baseUrl, credential: hit.credential } : null
210
+ },
211
+
212
+ /**
213
+ * 收下一台电脑(凭据是从 A 换回来的那枚)。
214
+ *
215
+ * 地址和凭据都在这儿验一遍(**唯一**一份校验,路由那一层只负责把错话递出去):
216
+ * 地址不像样、或者那枚凭据的形状不对,**一律不落盘**——脏记录比没有记录难查得多。
217
+ *
218
+ * @param {{name?: string, baseUrl?: unknown, credential?: unknown}} [opts]
219
+ * @returns {{ok:true, remote:object} | {ok:false, error:string}}
220
+ */
221
+ add({ name, baseUrl, credential } = {}) {
222
+ const clean = normalizeBaseUrl(baseUrl)
223
+ if (!clean) return { ok: false, error: '那台电脑的地址要写成 http:// 或 https:// 开头的一整条。' }
224
+ if (!credentialLooksLikeOurs(credential)) {
225
+ return { ok: false, error: '那台电脑回了话,但那枚凭据不像是它发出来的——多半是它那边的版本和这边对不上。' }
226
+ }
227
+ const label = typeof name === 'string' && name.trim() ? name.trim().slice(0, NAME_MAX) : defaultNameFor(clean)
228
+ syncFromDisk()
229
+ const record = {
230
+ id: randomBytes(ID_BYTES).toString('hex'),
231
+ name: label,
232
+ baseUrl: clean,
233
+ credential,
234
+ addedAt: Date.now(),
235
+ // 刚加进来还没换过票:0 = 「还没成功过」(第一次换票成功才写上)。
236
+ lastOk: 0,
237
+ }
238
+ table.push(record)
239
+ save()
240
+ return { ok: true, remote: publicRecord(record) }
241
+ },
242
+
243
+ /**
244
+ * 「这台电脑刚成功换到过票」——`lastOk` 的唯一写入点。
245
+ *
246
+ * @returns {boolean} 真的记上了(找不到那条记录就 false,比如另一个标签页刚断开它)
247
+ */
248
+ markOk(id) {
249
+ if (typeof id !== 'string' || !id) return false
250
+ syncFromDisk()
251
+ const hit = table.find((r) => r.id === id)
252
+ if (!hit) return false
253
+ hit.lastOk = Date.now()
254
+ save()
255
+ return true
256
+ },
257
+
258
+ /**
259
+ * **忘掉**一台电脑:删掉那条记录。
260
+ *
261
+ * ⚠️ 它和 A 那边的「撤销」(`/mini-remote/devices/revoke`)**不是一回事**,而且
262
+ * 这一层**不许**替用户去 A 撤销(用户拍板的:**B 不管理它连的那台 A**)。
263
+ * 所以这里只删 B 本地这一条——**B 手上那枚凭据跟着没了**(删记录本身就把它带走了),
264
+ * 但 A 名册里那条记录**照旧还在**,那台电脑照旧认它。要彻底收回,只能去 A 的设置页
265
+ * 撤销那一台(给用户的话写在 lib/index.js 的 `REMOTE_FORGOTTEN_NOTE` 里)。
266
+ *
267
+ * @returns {boolean} 真的删掉了一条
268
+ */
269
+ forget(id) {
270
+ if (typeof id !== 'string' || !id) return false
271
+ syncFromDisk()
272
+ const at = table.findIndex((r) => r.id === id)
273
+ if (at < 0) return false
274
+ table.splice(at, 1)
275
+ save()
276
+ return true
277
+ },
278
+ }
279
+ }