@goodandready/dsh-lanmode 0.3.1 → 0.5.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/lib/tls.js ADDED
@@ -0,0 +1,173 @@
1
+ // Сертификат для прямого режима.
2
+ //
3
+ // Зачем вообще. Микрофон браузер отдаёт только на защищённом соединении, и это
4
+ // единственное, чего не лечит подмена на странице: `navigator.mediaDevices`
5
+ // подделать нечем — за ним настоящее устройство. Пока прямой режим слушает
6
+ // голый HTTP, голосовой ввод по сети невозможен в принципе.
7
+ //
8
+ // Самоподписанный сертификат — компромисс, а не решение: браузер всё равно
9
+ // спросит. Но он превращает «невозможно» в «подтвердить один раз», а это
10
+ // разница между «не работает» и «работает».
11
+ //
12
+ // Сертификат делается через openssl. Node умеет породить ключевую пару, но не
13
+ // собрать из неё X.509: это ASN.1 вручную, сотни строк ради того, что уже
14
+ // лежит в /usr/bin. Нет openssl — честно говорим об этом и предлагаем свой
15
+ // сертификат, а не делаем вид, что всё в порядке.
16
+
17
+ import { execFile } from 'node:child_process'
18
+ import { X509Certificate } from 'node:crypto'
19
+ import fs from 'node:fs'
20
+ import os from 'node:os'
21
+ import path from 'node:path'
22
+
23
+ import { addressBytes } from './access.js'
24
+
25
+ /** За сколько до истечения перевыпускать. */
26
+ const RENEW_BEFORE_MS = 30 * 24 * 60 * 60 * 1000
27
+
28
+ /** Сколько живёт выпущенный нами сертификат. */
29
+ const LIFETIME_DAYS = 397
30
+
31
+ /**
32
+ * Адреса, по которым к этой машине могут обратиться.
33
+ *
34
+ * В сертификат идут все: браузер сверяет тот адрес, который набрали в строке,
35
+ * и сертификат, выписанный на одно имя, ругается на все остальные — даже после
36
+ * того, как его один раз приняли.
37
+ */
38
+ export function localAddresses() {
39
+ const found = new Set(['localhost', '127.0.0.1', '::1'])
40
+ const interfaces = os.networkInterfaces()
41
+ for (const list of Object.values(interfaces)) {
42
+ for (const item of list ?? []) {
43
+ if (!item || item.internal) continue
44
+ if (item.address) found.add(item.address)
45
+ }
46
+ }
47
+ const hostname = os.hostname()
48
+ if (hostname) found.add(hostname)
49
+ return [...found]
50
+ }
51
+
52
+ /** Строка subjectAltName для openssl: имена — DNS, адреса — IP. */
53
+ export function altNames(hosts) {
54
+ const parts = []
55
+ for (const host of hosts) {
56
+ const clean = String(host ?? '').trim()
57
+ if (!clean) continue
58
+ const isAddress = /^[0-9.]+$/.test(clean) || clean.includes(':')
59
+ parts.push((isAddress ? 'IP:' : 'DNS:') + clean)
60
+ }
61
+ return parts.join(',')
62
+ }
63
+
64
+ function run(command, args) {
65
+ return new Promise((resolve, reject) => {
66
+ execFile(command, args, { timeout: 30000 }, (error, stdout, stderr) => {
67
+ if (error) reject(new Error(String(stderr || error.message).trim()))
68
+ else resolve(String(stdout))
69
+ })
70
+ })
71
+ }
72
+
73
+ /** Что известно о лежащем сертификате: до какого числа и на какие имена. */
74
+ export function inspect(certPem) {
75
+ const certificate = new X509Certificate(certPem)
76
+ const names = String(certificate.subjectAltName ?? '')
77
+ .split(',')
78
+ .map((part) => part.trim().replace(/^(DNS|IP Address|IP):/, ''))
79
+ .filter(Boolean)
80
+ return {
81
+ validTo: new Date(certificate.validTo),
82
+ fingerprint: certificate.fingerprint256,
83
+ names,
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Годится ли лежащий сертификат: не истекает ли и покрывает ли нужные адреса.
89
+ *
90
+ * Второе важнее первого. Появился новый сетевой адрес — старый сертификат
91
+ * формально жив, но по новому адресу браузер его не примет, и человек будет
92
+ * гадать, почему «вчера работало».
93
+ */
94
+ /**
95
+ * Одно и то же имя, записанное одинаково.
96
+ *
97
+ * Адрес можно записать по-разному: openssl превращает в
98
+ * , и сравнение строк на этом рассыпается. Молча, с
99
+ * единственным следствием: сертификат перевыпускается при каждом запуске, а
100
+ * браузер каждый раз требует подтверждения заново — то есть смысл затеи
101
+ * пропадает.
102
+ */
103
+ function canonical(name) {
104
+ const bytes = addressBytes(name)
105
+ if (bytes) return bytes.map((byte) => byte.toString(16).padStart(2, '0')).join('')
106
+ return String(name).trim().toLowerCase()
107
+ }
108
+
109
+ export function stillGood(info, hosts, now) {
110
+ if (!info) return false
111
+ if (info.validTo.getTime() - now < RENEW_BEFORE_MS) return false
112
+ const covered = new Set(info.names.map(canonical))
113
+ return hosts.every((host) => covered.has(canonical(host)))
114
+ }
115
+
116
+ /**
117
+ * Взять готовый сертификат или выпустить новый.
118
+ *
119
+ * @param options {{dir: string, hosts: string[], log: (message: string) => void, now?: number}}
120
+ * @returns `{ cert, key, fingerprint, issued }`
121
+ */
122
+ export async function ensureCertificate(options) {
123
+ const dir = options.dir
124
+ const hosts = options.hosts
125
+ const now = options.now ?? Date.now()
126
+ const certPath = path.join(dir, 'lanmode-cert.pem')
127
+ const keyPath = path.join(dir, 'lanmode-key.pem')
128
+
129
+ if (fs.existsSync(certPath) && fs.existsSync(keyPath)) {
130
+ try {
131
+ const cert = fs.readFileSync(certPath, 'utf8')
132
+ const info = inspect(cert)
133
+ if (stillGood(info, hosts, now)) {
134
+ return {
135
+ cert,
136
+ key: fs.readFileSync(keyPath, 'utf8'),
137
+ fingerprint: info.fingerprint,
138
+ issued: false,
139
+ }
140
+ }
141
+ options.log('сертификат перевыпускается: истекает или не покрывает все адреса')
142
+ } catch (unreadable) {
143
+ options.log('сертификат нечитаем, выпускаю заново: ' + String(unreadable.message || unreadable))
144
+ }
145
+ }
146
+
147
+ fs.mkdirSync(dir, { recursive: true })
148
+ await run('openssl', [
149
+ 'req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-sha256',
150
+ '-days', String(LIFETIME_DAYS),
151
+ '-keyout', keyPath,
152
+ '-out', certPath,
153
+ '-subj', '/CN=' + (hosts[0] || 'localhost'),
154
+ '-addext', 'subjectAltName=' + altNames(hosts),
155
+ ])
156
+ // Ключ читаем не только мы: пусть его не читает никто, кроме владельца.
157
+ fs.chmodSync(keyPath, 0o600)
158
+
159
+ const cert = fs.readFileSync(certPath, 'utf8')
160
+ return {
161
+ cert,
162
+ key: fs.readFileSync(keyPath, 'utf8'),
163
+ fingerprint: inspect(cert).fingerprint,
164
+ issued: true,
165
+ }
166
+ }
167
+
168
+ /** Прочитать сертификат и ключ, указанные человеком. */
169
+ export function readCertificate(certPath, keyPath) {
170
+ const cert = fs.readFileSync(certPath, 'utf8')
171
+ const key = fs.readFileSync(keyPath, 'utf8')
172
+ return { cert, key, fingerprint: inspect(cert).fingerprint, issued: false }
173
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-lanmode",
3
- "version": "0.3.1",
3
+ "version": "0.5.0",
4
4
  "description": "LAN and reverse-proxy access for the DeepSeek Harness Web UI: returns the settings service on pages that are not localhost, fills in the Web APIs the browser withholds on plain HTTP, and can open a listener of its own so nothing else is needed.",
5
5
  "license": "MIT",
6
6
  "type": "module",