@goodandready/dsh-lanmode 0.4.0 → 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/README.md +112 -2
- package/lib/access.js +156 -0
- package/lib/assumptions.js +76 -0
- package/lib/bridge.js +146 -92
- package/lib/health.js +104 -0
- package/lib/index.js +214 -19
- package/lib/mode.js +120 -0
- package/lib/tls.js +173 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -58,7 +58,7 @@ bind(spec) {
|
|
|
58
58
|
```yaml
|
|
59
59
|
- id: dsh-lanmode
|
|
60
60
|
config:
|
|
61
|
-
mode: proxy # proxy | direct
|
|
61
|
+
mode: proxy # proxy | direct | auto
|
|
62
62
|
```
|
|
63
63
|
|
|
64
64
|
**`proxy`** (default) — something already listens on the network in front of the harness: nginx, Caddy, Tailscale serve, an SSH tunnel. The plugin only repairs the page and touches nothing else. This is the safe default: it cannot collide with whatever you already run.
|
|
@@ -77,17 +77,127 @@ Then open `http://<the machine's IP>:3088` from any device on the network.
|
|
|
77
77
|
|
|
78
78
|
A listener rather than rebinding the harness itself, for two reasons: a bind host lives in the config tree and cannot be a switch inside the plugin, and rebinding to `0.0.0.0` collides with a reverse proxy already holding that port.
|
|
79
79
|
|
|
80
|
+
**`auto`** — work it out. The plugin knocks on this machine's own network
|
|
81
|
+
addresses at the harness port: the harness itself listens on loopback only, so
|
|
82
|
+
anything answering there is a proxy, and the mode is `proxy`. When nothing
|
|
83
|
+
answers and the direct port is free, it is `direct`. When it cannot tell — no
|
|
84
|
+
addresses, no known port, a probe that errored — it picks `proxy` and opens
|
|
85
|
+
nothing: an unnecessary listener on a network address is an open door, and one
|
|
86
|
+
is not opened on a guess.
|
|
87
|
+
|
|
88
|
+
What `auto` cannot see is a proxy sitting on a *different* port. From outside
|
|
89
|
+
that is indistinguishable from nobody being there, and the plugin would open its
|
|
90
|
+
own listener beside it. Set the mode by hand in that case.
|
|
91
|
+
|
|
92
|
+
The decision is logged with its reason and shown on the diagnostics page.
|
|
93
|
+
|
|
80
94
|
Changing the mode takes effect on restart.
|
|
81
95
|
|
|
96
|
+
## HTTPS, and the microphone
|
|
97
|
+
|
|
98
|
+
This is the one thing no substitution can repair. A browser hands out
|
|
99
|
+
`navigator.mediaDevices` only over a secure connection, and behind it is a real
|
|
100
|
+
device — there is nothing to fake. Over plain HTTP on a network address, voice
|
|
101
|
+
input is impossible in principle.
|
|
102
|
+
|
|
103
|
+
So the direct-mode listener can speak HTTPS:
|
|
104
|
+
|
|
105
|
+
```yaml
|
|
106
|
+
- id: dsh-lanmode
|
|
107
|
+
config:
|
|
108
|
+
mode: direct
|
|
109
|
+
tls: self-signed # off | self-signed | files
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
**`self-signed`** — the plugin issues a certificate itself and keeps it in
|
|
113
|
+
`tlsDir` (by default a folder next to the harness data). It goes into the
|
|
114
|
+
certificate with every address this machine answers on, plus anything in
|
|
115
|
+
`tlsHosts`: a certificate issued for one name is refused for every other, even
|
|
116
|
+
after it has been accepted once. It is reissued when it is about to expire or
|
|
117
|
+
when a new address appears. The fingerprint is printed to the log at startup —
|
|
118
|
+
compare it in the browser instead of accepting blindly.
|
|
119
|
+
|
|
120
|
+
Issuing needs `openssl` on the machine. Without it the plugin says so plainly
|
|
121
|
+
and falls back to plain HTTP rather than pretending everything is fine.
|
|
122
|
+
|
|
123
|
+
**`files`** — your own certificate:
|
|
124
|
+
|
|
125
|
+
```yaml
|
|
126
|
+
tls: files
|
|
127
|
+
tlsCert: /path/to/cert.pem
|
|
128
|
+
tlsKey: /path/to/key.pem
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
A self-signed certificate is a compromise, not a solution: the browser will
|
|
132
|
+
still ask. But it turns "impossible" into "confirm once", and that is the whole
|
|
133
|
+
difference between voice input working over the network and not.
|
|
134
|
+
|
|
135
|
+
## Who may connect
|
|
136
|
+
|
|
137
|
+
The direct listener has no password and will not get one: the plugin does not
|
|
138
|
+
intercept anyone else's routes, and inventing its own way into the harness is
|
|
139
|
+
not its business. But between "no password" and "anyone on the network" there is
|
|
140
|
+
room:
|
|
141
|
+
|
|
142
|
+
```yaml
|
|
143
|
+
- id: dsh-lanmode
|
|
144
|
+
config:
|
|
145
|
+
mode: direct
|
|
146
|
+
allow:
|
|
147
|
+
- 192.168.1.0/24
|
|
148
|
+
- 10.0.0.5
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Addresses and CIDR ranges, IPv4 and IPv6. An empty list means everyone, which is
|
|
152
|
+
how the plugin behaves until you fill it in. Refused connections are logged, at
|
|
153
|
+
a limited rate so a scanner cannot drown the log.
|
|
154
|
+
|
|
155
|
+
Two honest limits. This is not authentication: whoever is on the list gets in
|
|
156
|
+
unchecked. And behind a reverse proxy it means nothing — every request arrives
|
|
157
|
+
from the proxy, so filter there instead.
|
|
158
|
+
|
|
159
|
+
## Diagnostics
|
|
160
|
+
|
|
161
|
+
`GET /dsh-lanmode/health` — one page answering the questions that otherwise take
|
|
162
|
+
half an hour: which mode is on and why, what is patched, whether the browser
|
|
163
|
+
considers the connection secure, and why the microphone is silent. Add
|
|
164
|
+
`?format=json` for the same data in a form you can paste into a bug report.
|
|
165
|
+
|
|
166
|
+
Half the answers can only come from the browser — a secure connection and a
|
|
167
|
+
microphone exist nowhere else — so the page checks those in the browser that
|
|
168
|
+
opened it.
|
|
169
|
+
|
|
170
|
+
Nothing secret is on that page: it is open to anyone who reached the harness.
|
|
171
|
+
Turn it off with `diagnostics: false`.
|
|
172
|
+
|
|
173
|
+
## When the harness changes underneath
|
|
174
|
+
|
|
175
|
+
The plugin holds on to the harness's internals: the index tap, the name of the
|
|
176
|
+
package the substitution must leave alone, the shape of the connection object.
|
|
177
|
+
An upgrade can move any of them, and a plugin that repairs someone else's
|
|
178
|
+
behaviour must not fail quietly — that already happened once, and it took days
|
|
179
|
+
of confusing symptoms to notice.
|
|
180
|
+
|
|
181
|
+
So at startup it checks its own assumptions and says what it found: one line
|
|
182
|
+
when everything is in place, a loud complaint naming what moved when it is not.
|
|
183
|
+
The same list is on the diagnostics page.
|
|
184
|
+
|
|
82
185
|
## Settings
|
|
83
186
|
|
|
84
187
|
All of it can be edited as the `dsh-lanmode` namespace — in `$DSH_HOME/settings.yaml`, or from the UI once the settings pages work.
|
|
85
188
|
|
|
86
189
|
| Setting | Default | Meaning |
|
|
87
190
|
|---|---|---|
|
|
88
|
-
| `mode` | `proxy` | `proxy` or `
|
|
191
|
+
| `mode` | `proxy` | `proxy`, `direct` or `auto` |
|
|
89
192
|
| `directHost` | `0.0.0.0` | `direct`: which address to listen on |
|
|
90
193
|
| `directPort` | `3088` | `direct`: which port to listen on |
|
|
194
|
+
| `tls` | `off` | `direct`: `off`, `self-signed` or `files` — what the microphone hangs on |
|
|
195
|
+
| `tlsDir` | — | `self-signed`: where the issued certificate is kept |
|
|
196
|
+
| `tlsHosts` | `[]` | `self-signed`: extra names and addresses for the certificate |
|
|
197
|
+
| `tlsCert` | — | `files`: path to the certificate in PEM |
|
|
198
|
+
| `tlsKey` | — | `files`: path to the private key in PEM |
|
|
199
|
+
| `allow` | `[]` | `direct`: addresses and CIDR ranges allowed in. Empty means everyone |
|
|
200
|
+
| `diagnostics` | `true` | serve `GET /dsh-lanmode/health` |
|
|
91
201
|
| `settings` | `true` | return the settings service |
|
|
92
202
|
| `randomUuid` | `true` | provide `crypto.randomUUID` on plain HTTP |
|
|
93
203
|
| `clipboard` | `true` | provide a clipboard fallback on plain HTTP |
|
package/lib/access.js
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// Кого пускать в прямом режиме.
|
|
2
|
+
//
|
|
3
|
+
// Пароля здесь нет и не будет: плагин чужие маршруты не перехватывает, а
|
|
4
|
+
// придумывать свой вход в харнесс — не его дело. Но между «без пароля» и «кто
|
|
5
|
+
// угодно из сети» есть промежуток, и список разрешённых адресов его занимает.
|
|
6
|
+
//
|
|
7
|
+
// Разбор записи CIDR свой, без зависимостей: адрес превращается в набор байтов,
|
|
8
|
+
// и сравниваются первые N бит. Для IPv4 и IPv6 это одна и та же арифметика,
|
|
9
|
+
// разной длины.
|
|
10
|
+
|
|
11
|
+
/** Байты адреса IPv4, или `null`, если это не он. */
|
|
12
|
+
function ipv4Bytes(text) {
|
|
13
|
+
const parts = text.split('.')
|
|
14
|
+
if (parts.length !== 4) return null
|
|
15
|
+
const bytes = []
|
|
16
|
+
for (const part of parts) {
|
|
17
|
+
if (!/^\d{1,3}$/.test(part)) return null
|
|
18
|
+
const value = Number(part)
|
|
19
|
+
if (value > 255) return null
|
|
20
|
+
bytes.push(value)
|
|
21
|
+
}
|
|
22
|
+
return bytes
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Байты адреса IPv6, или `null`.
|
|
27
|
+
*
|
|
28
|
+
* Отдельно разбирается запись с хвостом IPv4 (`::ffff:192.168.1.5`): именно её
|
|
29
|
+
* отдаёт Node для обычных подключений на сокете двойного стека, и без неё
|
|
30
|
+
* список разрешённых адресов не сработал бы вовсе.
|
|
31
|
+
*/
|
|
32
|
+
function ipv6Bytes(text) {
|
|
33
|
+
let body = text
|
|
34
|
+
let tail = []
|
|
35
|
+
const dot = body.lastIndexOf(':')
|
|
36
|
+
if (body.includes('.')) {
|
|
37
|
+
const four = ipv4Bytes(body.slice(dot + 1))
|
|
38
|
+
if (!four) return null
|
|
39
|
+
tail = four
|
|
40
|
+
body = body.slice(0, dot + 1) + '0:0'
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const halves = body.split('::')
|
|
44
|
+
if (halves.length > 2) return null
|
|
45
|
+
const head = halves[0] ? halves[0].split(':') : []
|
|
46
|
+
const rest = halves.length === 2 ? (halves[1] ? halves[1].split(':') : []) : []
|
|
47
|
+
if (halves.length === 1 && head.length !== 8) return null
|
|
48
|
+
|
|
49
|
+
const groups = []
|
|
50
|
+
for (const group of head) {
|
|
51
|
+
if (!/^[0-9a-fA-F]{1,4}$/.test(group)) return null
|
|
52
|
+
groups.push(Number.parseInt(group, 16))
|
|
53
|
+
}
|
|
54
|
+
const restGroups = []
|
|
55
|
+
for (const group of rest) {
|
|
56
|
+
if (!/^[0-9a-fA-F]{1,4}$/.test(group)) return null
|
|
57
|
+
restGroups.push(Number.parseInt(group, 16))
|
|
58
|
+
}
|
|
59
|
+
const missing = 8 - groups.length - restGroups.length
|
|
60
|
+
if (missing < 0) return null
|
|
61
|
+
const all = halves.length === 2
|
|
62
|
+
? groups.concat(new Array(missing).fill(0), restGroups)
|
|
63
|
+
: groups
|
|
64
|
+
|
|
65
|
+
const bytes = []
|
|
66
|
+
for (const group of all) bytes.push(group >> 8, group & 255)
|
|
67
|
+
if (tail.length) {
|
|
68
|
+
bytes.splice(12, 4, ...tail)
|
|
69
|
+
}
|
|
70
|
+
return bytes.length === 16 ? bytes : null
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Байты адреса — или `null`, если разобрать не вышло. */
|
|
74
|
+
export function addressBytes(text) {
|
|
75
|
+
const clean = String(text ?? '').trim()
|
|
76
|
+
if (!clean) return null
|
|
77
|
+
if (clean.includes(':')) return ipv6Bytes(clean)
|
|
78
|
+
return ipv4Bytes(clean)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Адрес IPv4, спрятанный внутри записи IPv6.
|
|
83
|
+
*
|
|
84
|
+
* `::ffff:192.168.1.5` — это тот же 192.168.1.5, и правило, написанное для
|
|
85
|
+
* IPv4, обязано на него распространяться. Иначе список выглядит рабочим, а
|
|
86
|
+
* пускает мимо.
|
|
87
|
+
*/
|
|
88
|
+
function unwrapped(bytes) {
|
|
89
|
+
if (!bytes || bytes.length !== 16) return null
|
|
90
|
+
for (let i = 0; i < 10; i++) if (bytes[i] !== 0) return null
|
|
91
|
+
if (bytes[10] !== 255 || bytes[11] !== 255) return null
|
|
92
|
+
return bytes.slice(12)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Разобрать одну запись списка: адрес или подсеть в записи CIDR.
|
|
97
|
+
*
|
|
98
|
+
* @returns `{ bytes, bits }` или `null`, если запись непонятна.
|
|
99
|
+
*/
|
|
100
|
+
export function parseRule(text) {
|
|
101
|
+
const clean = String(text ?? '').trim()
|
|
102
|
+
if (!clean) return null
|
|
103
|
+
const slash = clean.lastIndexOf('/')
|
|
104
|
+
const address = slash === -1 ? clean : clean.slice(0, slash)
|
|
105
|
+
const bytes = addressBytes(address)
|
|
106
|
+
if (!bytes) return null
|
|
107
|
+
|
|
108
|
+
const full = bytes.length * 8
|
|
109
|
+
if (slash === -1) return { bytes, bits: full }
|
|
110
|
+
const bits = Number(clean.slice(slash + 1))
|
|
111
|
+
if (!Number.isInteger(bits) || bits < 0 || bits > full) return null
|
|
112
|
+
return { bytes, bits }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Разобрать весь список, молча отбрасывая непонятные записи. */
|
|
116
|
+
export function parseAllow(list) {
|
|
117
|
+
const rules = []
|
|
118
|
+
const dropped = []
|
|
119
|
+
for (const item of Array.isArray(list) ? list : []) {
|
|
120
|
+
const rule = parseRule(item)
|
|
121
|
+
if (rule) rules.push(rule)
|
|
122
|
+
else if (String(item ?? '').trim()) dropped.push(String(item))
|
|
123
|
+
}
|
|
124
|
+
return { rules, dropped }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Совпадают ли первые `bits` бит. */
|
|
128
|
+
function samePrefix(left, right, bits) {
|
|
129
|
+
if (left.length !== right.length) return false
|
|
130
|
+
const whole = bits >> 3
|
|
131
|
+
for (let i = 0; i < whole; i++) if (left[i] !== right[i]) return false
|
|
132
|
+
const spare = bits & 7
|
|
133
|
+
if (spare === 0) return true
|
|
134
|
+
const mask = (255 << (8 - spare)) & 255
|
|
135
|
+
return (left[whole] & mask) === (right[whole] & mask)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Пускать ли этот адрес.
|
|
140
|
+
*
|
|
141
|
+
* Пустой список означает «никого не ограничиваем» — так плагин ведёт себя до
|
|
142
|
+
* того, как список задали, и так же, если задали одну мусорную строку: тихо
|
|
143
|
+
* запереть дверь из-за опечатки хуже, чем не запирать вовсе.
|
|
144
|
+
*/
|
|
145
|
+
export function allowed(address, rules) {
|
|
146
|
+
if (!Array.isArray(rules) || rules.length === 0) return true
|
|
147
|
+
const bytes = addressBytes(address)
|
|
148
|
+
if (!bytes) return false
|
|
149
|
+
const inner = unwrapped(bytes)
|
|
150
|
+
|
|
151
|
+
for (const rule of rules) {
|
|
152
|
+
if (samePrefix(bytes, rule.bytes, rule.bits)) return true
|
|
153
|
+
if (inner && rule.bytes.length === 4 && samePrefix(inner, rule.bytes, rule.bits)) return true
|
|
154
|
+
}
|
|
155
|
+
return false
|
|
156
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Проверка точек крепления.
|
|
2
|
+
//
|
|
3
|
+
// Плагин чинит чужое поведение и держится за внутренности харнесса: за точку
|
|
4
|
+
// вставки в index.html, за то, что вставка доезжает до отдаваемой страницы, и
|
|
5
|
+
// за имя пакета, который заплатка обязана обходить стороной. Любая из точек
|
|
6
|
+
// может уехать с обновлением ядра.
|
|
7
|
+
//
|
|
8
|
+
// Так уже было: подмена доходила до настроек ядра, но не до разделов плагинов,
|
|
9
|
+
// и это выяснилось не сразу, а через несколько дней жалоб на пустые карточки.
|
|
10
|
+
// Тихий отказ здесь хуже поломки — поэтому плагин проверяет свои допущения сам
|
|
11
|
+
// и жалуется громко.
|
|
12
|
+
|
|
13
|
+
/** Пакет, которому заплатка обязана оставлять настоящий ответ. */
|
|
14
|
+
export const EXCLUDED_BUNDLE = '@deepseek-ai/dsh-client-ui-deliverables'
|
|
15
|
+
|
|
16
|
+
/** Один вывод проверки. */
|
|
17
|
+
function verdict(name, ok, detail) {
|
|
18
|
+
return { name, ok, detail }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Проверить всё, что можно проверить со стороны хоста.
|
|
23
|
+
*
|
|
24
|
+
* Со стороны браузера проверяет страница диагностики: то, что происходит в нём,
|
|
25
|
+
* отсюда не видно, а гадать — то же самое, что не проверять.
|
|
26
|
+
*
|
|
27
|
+
* @param options {{webServer: object, fetchIndex: () => Promise<string>}}
|
|
28
|
+
*/
|
|
29
|
+
export async function checkAssumptions(options) {
|
|
30
|
+
const results = []
|
|
31
|
+
const webServer = options.webServer
|
|
32
|
+
|
|
33
|
+
results.push(verdict(
|
|
34
|
+
'точка вставки в index.html',
|
|
35
|
+
Boolean(webServer && typeof webServer.tapIndex === 'function'),
|
|
36
|
+
'webServer.tapIndex — то, чем плагин вставляет заплатку на страницу',
|
|
37
|
+
))
|
|
38
|
+
|
|
39
|
+
results.push(verdict(
|
|
40
|
+
'порт харнесса известен',
|
|
41
|
+
Boolean(webServer && webServer.port),
|
|
42
|
+
'webServer.port — без него не поднять прямой режим и не проверить, кто слушает сеть',
|
|
43
|
+
))
|
|
44
|
+
|
|
45
|
+
let html = ''
|
|
46
|
+
try {
|
|
47
|
+
html = await options.fetchIndex()
|
|
48
|
+
} catch (unreachable) {
|
|
49
|
+
results.push(verdict('страница отдаётся', false, String(unreachable.message || unreachable)))
|
|
50
|
+
return results
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
results.push(verdict(
|
|
54
|
+
'заплатка попала на страницу',
|
|
55
|
+
html.includes('data-dsh-lanmode'),
|
|
56
|
+
'если её там нет, всё остальное не имеет значения',
|
|
57
|
+
))
|
|
58
|
+
|
|
59
|
+
results.push(verdict(
|
|
60
|
+
'исключение на месте',
|
|
61
|
+
html.includes(EXCLUDED_BUNDLE),
|
|
62
|
+
'пакет ' + EXCLUDED_BUNDLE + ' обходится стороной, потому что решает, можно ли '
|
|
63
|
+
+ 'открыть файл локально. Исчез или переименован — исключение больше ничего не исключает',
|
|
64
|
+
))
|
|
65
|
+
|
|
66
|
+
return results
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Свести проверки в одну строку для журнала. */
|
|
70
|
+
export function summarize(results) {
|
|
71
|
+
const bad = results.filter((item) => !item.ok)
|
|
72
|
+
if (bad.length === 0) return 'точки крепления на месте: ' + results.length + ' из ' + results.length
|
|
73
|
+
return 'ТОЧКИ КРЕПЛЕНИЯ УЕХАЛИ (' + bad.length + ' из ' + results.length + '): '
|
|
74
|
+
+ bad.map((item) => item.name).join('; ')
|
|
75
|
+
+ '. Плагин может работать не так, как задумано, — вероятно, обновилось ядро'
|
|
76
|
+
}
|
package/lib/bridge.js
CHANGED
|
@@ -1,92 +1,146 @@
|
|
|
1
|
-
// Прямой режим: слушатель на сетевом адресе, который передаёт всё харнессу.
|
|
2
|
-
//
|
|
3
|
-
// Зачем не привязка харнесса к 0.0.0.0, как делают соседние плагины. Привязка
|
|
4
|
-
// задаётся в дереве конфигурации и меняется только перезапуском, то есть
|
|
5
|
-
// «переключателем в плагине» быть не может. Хуже того, если перед харнессом
|
|
6
|
-
// уже стоит обратный прокси на том же порту, привязка столкнётся с ним лбами.
|
|
7
|
-
// Отдельный слушатель включается и гасится вместе со строкой плагина и живёт
|
|
8
|
-
// рядом с любым прокси.
|
|
9
|
-
//
|
|
10
|
-
// Заголовки Host и Origin переписываются на локальные: харнесс пропускает
|
|
11
|
-
// запрос, только когда Origin совпадает с адресом, по которому он слушает.
|
|
12
|
-
// Это ровно то, что делает любой обратный
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
1
|
+
// Прямой режим: слушатель на сетевом адресе, который передаёт всё харнессу.
|
|
2
|
+
//
|
|
3
|
+
// Зачем не привязка харнесса к 0.0.0.0, как делают соседние плагины. Привязка
|
|
4
|
+
// задаётся в дереве конфигурации и меняется только перезапуском, то есть
|
|
5
|
+
// «переключателем в плагине» быть не может. Хуже того, если перед харнессом
|
|
6
|
+
// уже стоит обратный прокси на том же порту, привязка столкнётся с ним лбами.
|
|
7
|
+
// Отдельный слушатель включается и гасится вместе со строкой плагина и живёт
|
|
8
|
+
// рядом с любым прокси.
|
|
9
|
+
//
|
|
10
|
+
// Заголовки Host и Origin переписываются на локальные: харнесс пропускает
|
|
11
|
+
// запрос, только когда Origin совпадает с адресом, по которому он слушает.
|
|
12
|
+
// Это ровно то, что делает любой обратный прокси.
|
|
13
|
+
//
|
|
14
|
+
// Кого пускать — решает список разрешённых адресов, если он задан. Это не
|
|
15
|
+
// замена паролю: тот, кто в списке, входит без всякой проверки. Это сужение
|
|
16
|
+
// круга, и в описании плагина так и сказано.
|
|
17
|
+
|
|
18
|
+
import http from 'node:http'
|
|
19
|
+
import https from 'node:https'
|
|
20
|
+
|
|
21
|
+
import { allowed } from './access.js'
|
|
22
|
+
|
|
23
|
+
function rewritten(headers, authority) {
|
|
24
|
+
const out = { ...headers, host: authority }
|
|
25
|
+
if (out.origin) out.origin = 'http://' + authority
|
|
26
|
+
if (out.referer) out.referer = String(out.referer).replace(/^https?:\/\/[^/]+/, 'http://' + authority)
|
|
27
|
+
return out
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Ограничитель частоты жалоб.
|
|
32
|
+
*
|
|
33
|
+
* Сканер из сети даёт сотни отказов в минуту, и без ограничения журнал
|
|
34
|
+
* превращается в поток одинаковых строк, в котором не видно ничего другого.
|
|
35
|
+
*/
|
|
36
|
+
function throttle(log, everyMs) {
|
|
37
|
+
let last = 0
|
|
38
|
+
let skipped = 0
|
|
39
|
+
return (message) => {
|
|
40
|
+
const now = Date.now()
|
|
41
|
+
if (now - last < everyMs) {
|
|
42
|
+
skipped += 1
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
log(skipped ? message + ' (и ещё ' + skipped + ' таких же)' : message)
|
|
46
|
+
last = now
|
|
47
|
+
skipped = 0
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @param ctx контекст плагина (нужен ctx.webServer.port)
|
|
53
|
+
* @param options {{host: string, port: number, log: (message: string) => void,
|
|
54
|
+
* allow?: object[], tls?: {cert: string, key: string}}}
|
|
55
|
+
* @returns функция остановки
|
|
56
|
+
*/
|
|
57
|
+
export function startDirectBridge(ctx, options) {
|
|
58
|
+
const upstreamPort = ctx.webServer.port
|
|
59
|
+
if (!upstreamPort) {
|
|
60
|
+
options.log('прямой режим не поднят: веб-сервер ещё не сообщил порт')
|
|
61
|
+
return () => {}
|
|
62
|
+
}
|
|
63
|
+
const authority = '127.0.0.1:' + upstreamPort
|
|
64
|
+
const rules = options.allow ?? []
|
|
65
|
+
const refuse = throttle(options.log, 10000)
|
|
66
|
+
|
|
67
|
+
/** Пускать ли этого гостя; отказ пишется в журнал не чаще раза в десять секунд. */
|
|
68
|
+
const welcome = (address) => {
|
|
69
|
+
if (allowed(address, rules)) return true
|
|
70
|
+
refuse('отказано: адрес ' + String(address) + ' не в списке разрешённых')
|
|
71
|
+
return false
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const handle = (req, res) => {
|
|
75
|
+
if (!welcome(req.socket.remoteAddress)) {
|
|
76
|
+
res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' })
|
|
77
|
+
res.end('forbidden')
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
const upstream = http.request({
|
|
81
|
+
host: '127.0.0.1',
|
|
82
|
+
port: upstreamPort,
|
|
83
|
+
method: req.method,
|
|
84
|
+
path: req.url,
|
|
85
|
+
headers: rewritten(req.headers, authority),
|
|
86
|
+
}, (answer) => {
|
|
87
|
+
res.writeHead(answer.statusCode || 502, answer.headers)
|
|
88
|
+
answer.pipe(res)
|
|
89
|
+
})
|
|
90
|
+
upstream.on('error', () => {
|
|
91
|
+
if (!res.headersSent) res.writeHead(502, { 'content-type': 'text/plain; charset=utf-8' })
|
|
92
|
+
res.end('dsh-lanmode: харнесс не отвечает')
|
|
93
|
+
})
|
|
94
|
+
req.pipe(upstream)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const bridge = options.tls
|
|
98
|
+
? https.createServer({ cert: options.tls.cert, key: options.tls.key }, handle)
|
|
99
|
+
: http.createServer(handle)
|
|
100
|
+
|
|
101
|
+
// Веб-сокеты интерфейса идут через Upgrade: их надо передать сырыми. Проверка
|
|
102
|
+
// адреса здесь такая же: пропустить веб-сокеты — значит не сделать ничего,
|
|
103
|
+
// весь разговор с агентом идёт именно по ним.
|
|
104
|
+
bridge.on('upgrade', (req, socket, head) => {
|
|
105
|
+
if (!welcome(socket.remoteAddress)) {
|
|
106
|
+
try {
|
|
107
|
+
socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n')
|
|
108
|
+
socket.destroy()
|
|
109
|
+
} catch (already) { /* уже мертво */ }
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
const upstream = http.request({
|
|
113
|
+
host: '127.0.0.1',
|
|
114
|
+
port: upstreamPort,
|
|
115
|
+
method: req.method,
|
|
116
|
+
path: req.url,
|
|
117
|
+
headers: rewritten(req.headers, authority),
|
|
118
|
+
})
|
|
119
|
+
upstream.on('upgrade', (answer, upstreamSocket, upstreamHead) => {
|
|
120
|
+
const lines = ['HTTP/1.1 101 Switching Protocols']
|
|
121
|
+
for (const [key, value] of Object.entries(answer.headers)) lines.push(key + ': ' + value)
|
|
122
|
+
socket.write(lines.join('\r\n') + '\r\n\r\n')
|
|
123
|
+
if (upstreamHead && upstreamHead.length) socket.unshift(upstreamHead)
|
|
124
|
+
upstreamSocket.pipe(socket)
|
|
125
|
+
socket.pipe(upstreamSocket)
|
|
126
|
+
const drop = () => { try { upstreamSocket.destroy() } catch (already) { /* уже мертво */ } }
|
|
127
|
+
socket.on('error', drop)
|
|
128
|
+
socket.on('close', drop)
|
|
129
|
+
})
|
|
130
|
+
upstream.on('error', () => { try { socket.destroy() } catch (already) { /* уже мертво */ } })
|
|
131
|
+
if (head && head.length) upstream.write(head)
|
|
132
|
+
upstream.end()
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
bridge.on('error', (failure) => {
|
|
136
|
+
options.log('прямой режим не поднялся: ' + String(failure && failure.message || failure))
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
bridge.listen(options.port, options.host, () => {
|
|
140
|
+
options.log('прямой режим: слушаю ' + (options.tls ? 'https://' : 'http://')
|
|
141
|
+
+ options.host + ':' + options.port + ', передаю на ' + authority
|
|
142
|
+
+ (rules.length ? ', пускаю ' + rules.length + ' правил(о) из списка' : ''))
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
return () => { bridge.close() }
|
|
146
|
+
}
|
package/lib/health.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// Страница диагностики.
|
|
2
|
+
//
|
|
3
|
+
// Каждый разбор поломки по сети начинался одинаково: непонятно, какой режим,
|
|
4
|
+
// что подменено, видит ли браузер защищённое соединение и почему молчит
|
|
5
|
+
// микрофон. Это выяснялось руками и подолгу.
|
|
6
|
+
//
|
|
7
|
+
// Половину ответов знает хост, половину — только браузер, потому что защищённое
|
|
8
|
+
// соединение и наличие микрофона существуют исключительно на его стороне.
|
|
9
|
+
// Поэтому страница отдаёт и то и другое: серверное как есть, браузерное —
|
|
10
|
+
// скриптом, который выполняется у открывшего.
|
|
11
|
+
//
|
|
12
|
+
// Ничего секретного здесь быть не должно: страница открыта всем, кто дотянулся
|
|
13
|
+
// до харнесса.
|
|
14
|
+
|
|
15
|
+
/** Данные, которые знает хост. */
|
|
16
|
+
export function hostReport(state) {
|
|
17
|
+
return {
|
|
18
|
+
version: state.version,
|
|
19
|
+
mode: state.mode,
|
|
20
|
+
modeReason: state.modeReason ?? '',
|
|
21
|
+
listener: state.listener ?? null,
|
|
22
|
+
tls: state.tls ?? { enabled: false },
|
|
23
|
+
pieces: state.pieces,
|
|
24
|
+
allow: state.allow ?? [],
|
|
25
|
+
assumptions: state.assumptions ?? [],
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const BROWSER_SCRIPT = `
|
|
30
|
+
(function () {
|
|
31
|
+
var out = document.getElementById('browser')
|
|
32
|
+
var shim = window.__DSH_LANMODE__ || null
|
|
33
|
+
var checks = [
|
|
34
|
+
['защищённое соединение', window.isSecureContext === true,
|
|
35
|
+
'без него нет ни микрофона, ни crypto.randomUUID, ни буфера обмена. Лечится HTTPS: настройка tls в прямом режиме или прокси с сертификатом'],
|
|
36
|
+
['микрофон доступен браузеру', !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia),
|
|
37
|
+
'единственное, чего не лечит подмена: за ним настоящее устройство. Нужен именно HTTPS'],
|
|
38
|
+
['crypto.randomUUID', typeof (window.crypto && window.crypto.randomUUID) === 'function',
|
|
39
|
+
'интерфейс зовёт его при загрузке; на голом HTTP его подставляет заплатка'],
|
|
40
|
+
['буфер обмена', !!(navigator.clipboard && navigator.clipboard.writeText),
|
|
41
|
+
'кнопки «копировать»; на голом HTTP его подставляет заплатка'],
|
|
42
|
+
['заплатка отработала', !!shim,
|
|
43
|
+
'если нет — скрипт не попал на страницу или его выключили через ?lanmode=off'],
|
|
44
|
+
['страница считается своей', /^(localhost|\\[::1\\]|::1|127\\.)/.test(location.hostname),
|
|
45
|
+
'на чужом имени харнесс уводит настройки в режим памяти; это и чинит заплатка']
|
|
46
|
+
]
|
|
47
|
+
var rows = checks.map(function (item) {
|
|
48
|
+
return '<tr><td>' + (item[1] ? '✔' : '✘') + '</td><td>' + item[0] + '</td><td>'
|
|
49
|
+
+ (item[1] ? '' : item[2]) + '</td></tr>'
|
|
50
|
+
}).join('')
|
|
51
|
+
out.innerHTML = '<table>' + rows + '</table>'
|
|
52
|
+
window.__DSH_LANMODE_BROWSER__ = checks.map(function (item) {
|
|
53
|
+
return { name: item[0], ok: item[1] }
|
|
54
|
+
})
|
|
55
|
+
})()
|
|
56
|
+
`
|
|
57
|
+
|
|
58
|
+
function row(item) {
|
|
59
|
+
return '<tr><td>' + (item.ok ? '✔' : '✘') + '</td><td>' + escapeHtml(item.name)
|
|
60
|
+
+ '</td><td>' + (item.ok ? '' : escapeHtml(item.detail || '')) + '</td></tr>'
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function escapeHtml(text) {
|
|
64
|
+
return String(text)
|
|
65
|
+
.replace(/&/g, '&')
|
|
66
|
+
.replace(/</g, '<')
|
|
67
|
+
.replace(/>/g, '>')
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Страница целиком. */
|
|
71
|
+
export function healthPage(state) {
|
|
72
|
+
const report = hostReport(state)
|
|
73
|
+
const listener = report.listener
|
|
74
|
+
? escapeHtml(report.listener.scheme + '://' + report.listener.host + ':' + report.listener.port)
|
|
75
|
+
: 'нет — сеть обслуживает кто-то другой'
|
|
76
|
+
|
|
77
|
+
return '<!doctype html><html lang="ru"><head><meta charset="utf-8">'
|
|
78
|
+
+ '<meta name="viewport" content="width=device-width, initial-scale=1">'
|
|
79
|
+
+ '<title>dsh-lanmode</title><style>'
|
|
80
|
+
+ 'body{font:14px/1.5 system-ui,sans-serif;margin:0;padding:24px;max-width:900px}'
|
|
81
|
+
+ 'h1{font-size:20px;margin:0 0 4px}h2{font-size:15px;margin:24px 0 8px}'
|
|
82
|
+
+ 'table{border-collapse:collapse;width:100%}'
|
|
83
|
+
+ 'td{padding:4px 8px;border-top:1px solid rgba(128,128,128,.3);vertical-align:top}'
|
|
84
|
+
+ 'td:first-child{width:1.5em;text-align:center}td:nth-child(2){width:16em}'
|
|
85
|
+
+ 'td:nth-child(3){color:#888}dl{margin:0}dt{color:#888;font-size:12px;margin-top:8px}'
|
|
86
|
+
+ '@media(prefers-color-scheme:dark){body{background:#111;color:#ddd}}'
|
|
87
|
+
+ '</style></head><body>'
|
|
88
|
+
+ '<h1>dsh-lanmode ' + escapeHtml(report.version) + '</h1>'
|
|
89
|
+
+ '<dl><dt>режим</dt><dd>' + escapeHtml(report.mode)
|
|
90
|
+
+ (report.modeReason ? ' — ' + escapeHtml(report.modeReason) : '') + '</dd>'
|
|
91
|
+
+ '<dt>слушатель</dt><dd>' + listener + '</dd>'
|
|
92
|
+
+ '<dt>сертификат</dt><dd>' + (report.tls.enabled
|
|
93
|
+
? escapeHtml(report.tls.source + ', отпечаток ' + report.tls.fingerprint)
|
|
94
|
+
: 'нет') + '</dd>'
|
|
95
|
+
+ '<dt>подменено</dt><dd>' + escapeHtml(Object.entries(report.pieces)
|
|
96
|
+
.filter(([, on]) => on).map(([name]) => name).join(', ') || 'ничего') + '</dd>'
|
|
97
|
+
+ '<dt>пускаю</dt><dd>' + escapeHtml(report.allow.length ? report.allow.join(', ') : 'всех') + '</dd>'
|
|
98
|
+
+ '</dl>'
|
|
99
|
+
+ '<h2>Точки крепления</h2><table>' + report.assumptions.map(row).join('') + '</table>'
|
|
100
|
+
+ '<h2>Что видит браузер</h2><div id="browser"></div>'
|
|
101
|
+
+ '<h2>Для отчёта об ошибке</h2><p>Те же данные в JSON: '
|
|
102
|
+
+ '<a href="?format=json">?format=json</a></p>'
|
|
103
|
+
+ '<script>' + BROWSER_SCRIPT + '</script></body></html>'
|
|
104
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -32,7 +32,13 @@ import z from '@deepseek-ai/schemastery'
|
|
|
32
32
|
import { readFileSync } from 'node:fs'
|
|
33
33
|
import path from 'node:path'
|
|
34
34
|
import { fileURLToPath } from 'node:url'
|
|
35
|
+
|
|
36
|
+
import { parseAllow } from './access.js'
|
|
37
|
+
import { checkAssumptions, summarize } from './assumptions.js'
|
|
35
38
|
import { startDirectBridge } from './bridge.js'
|
|
39
|
+
import { healthPage, hostReport } from './health.js'
|
|
40
|
+
import { detectMode } from './mode.js'
|
|
41
|
+
import { ensureCertificate, localAddresses, readCertificate } from './tls.js'
|
|
36
42
|
|
|
37
43
|
export const name = 'dsh-lanmode'
|
|
38
44
|
export const inject = ['webServer']
|
|
@@ -44,7 +50,10 @@ export const Config = z.object({
|
|
|
44
50
|
+ '"proxy": something in front of it already listens on the network (nginx and friends) — '
|
|
45
51
|
+ 'the plugin only repairs the page. '
|
|
46
52
|
+ '"direct": the plugin also opens a listener of its own on the network and forwards to the '
|
|
47
|
-
+ 'harness, so nothing else is needed.'
|
|
53
|
+
+ 'harness, so nothing else is needed. '
|
|
54
|
+
+ '"auto": look whether anything already answers on this machine\'s network address at the '
|
|
55
|
+
+ 'harness port, and pick proxy if something does. When it cannot tell, it picks proxy: '
|
|
56
|
+
+ 'an unnecessary listener on a network address is an open door, and one is not opened on a guess.')
|
|
48
57
|
.default('proxy'),
|
|
49
58
|
directHost: z
|
|
50
59
|
.string()
|
|
@@ -69,6 +78,40 @@ export const Config = z.object({
|
|
|
69
78
|
.description('Provide a fallback for navigator.clipboard.writeText on plain HTTP, '
|
|
70
79
|
+ 'so the copy buttons keep working. A no-op where the real one exists.')
|
|
71
80
|
.default(true),
|
|
81
|
+
tls: z
|
|
82
|
+
.string()
|
|
83
|
+
.description('mode=direct: how the listener is secured. "off" — plain HTTP, as before. '
|
|
84
|
+
+ '"self-signed" — the plugin issues a certificate itself (needs openssl) and renews it. '
|
|
85
|
+
+ '"files" — use tlsCert and tlsKey. '
|
|
86
|
+
+ 'This is what the microphone hangs on: a browser hands out navigator.mediaDevices only over '
|
|
87
|
+
+ 'a secure connection, and no page-side substitution can help — there is a real device behind it.')
|
|
88
|
+
.default('off'),
|
|
89
|
+
tlsDir: z
|
|
90
|
+
.string()
|
|
91
|
+
.description('tls=self-signed: where the issued certificate is kept. '
|
|
92
|
+
+ 'Empty uses a folder next to the harness data.')
|
|
93
|
+
.default(''),
|
|
94
|
+
tlsHosts: z
|
|
95
|
+
.array(z.string())
|
|
96
|
+
.description('tls=self-signed: extra names and addresses to put into the certificate, '
|
|
97
|
+
+ 'on top of this machine\'s own. A certificate issued for one name is refused for every other, '
|
|
98
|
+
+ 'even after it has been accepted once.')
|
|
99
|
+
.default([]),
|
|
100
|
+
tlsCert: z.string().description('tls=files: path to the certificate in PEM.').default(''),
|
|
101
|
+
tlsKey: z.string().description('tls=files: path to the private key in PEM.').default(''),
|
|
102
|
+
allow: z
|
|
103
|
+
.array(z.string())
|
|
104
|
+
.description('mode=direct: who may connect — addresses and CIDR ranges. Empty means everyone, '
|
|
105
|
+
+ 'as before. This is not a password: whoever is on the list gets in unchecked. It narrows the '
|
|
106
|
+
+ 'circle, nothing more. Behind a reverse proxy it is meaningless — every request arrives from '
|
|
107
|
+
+ 'the proxy.')
|
|
108
|
+
.default([]),
|
|
109
|
+
diagnostics: z
|
|
110
|
+
.boolean()
|
|
111
|
+
.description('Serve GET /dsh-lanmode/health: what is patched, which mode is on, and what the '
|
|
112
|
+
+ 'browser actually sees. Nothing secret is on that page — it is open to anyone who reached '
|
|
113
|
+
+ 'the harness.')
|
|
114
|
+
.default(true),
|
|
72
115
|
})
|
|
73
116
|
|
|
74
117
|
const here = path.dirname(fileURLToPath(import.meta.url))
|
|
@@ -81,9 +124,36 @@ function shimSource() {
|
|
|
81
124
|
/** Namespace, который плагин объявляет: через него режим правится настройками. */
|
|
82
125
|
const NS = 'dsh-lanmode'
|
|
83
126
|
|
|
127
|
+
/** Версия из манифеста: она нужна странице диагностики и отчётам об ошибках. */
|
|
128
|
+
function version() {
|
|
129
|
+
try {
|
|
130
|
+
return JSON.parse(readFileSync(path.join(here, '..', 'package.json'), 'utf8')).version
|
|
131
|
+
} catch (unreadable) {
|
|
132
|
+
return 'неизвестна'
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const say = (message) => {
|
|
137
|
+
// eslint-disable-next-line no-console
|
|
138
|
+
console.info('[dsh-lanmode] ' + message)
|
|
139
|
+
}
|
|
140
|
+
|
|
84
141
|
export function apply(ctx, config) {
|
|
85
|
-
//
|
|
86
|
-
//
|
|
142
|
+
// Запуск ровно один. Раньше их было два: путь со службой настроек и путь без
|
|
143
|
+
// неё срабатывали оба, потому что в момент проверки службы ещё не было. С
|
|
144
|
+
// одной только вставкой заплатки это сходило с рук — она защищена от повтора.
|
|
145
|
+
// С появлением слушателя, списка адресов и страницы диагностики перестало:
|
|
146
|
+
// страница показывала одно состояние, а мост работал по другому.
|
|
147
|
+
let started = false
|
|
148
|
+
|
|
149
|
+
const once = (usingCtx, effective) => {
|
|
150
|
+
if (started) return
|
|
151
|
+
started = true
|
|
152
|
+
start(usingCtx, effective)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Значения берём из службы настроек, если она есть: тогда режим правится в
|
|
156
|
+
// настройках, а не только в дереве плагинов. Смена режима требует
|
|
87
157
|
// перезапуска — слушатель поднимается один раз при старте.
|
|
88
158
|
ctx.inject(['settings'], (sctx) => {
|
|
89
159
|
let effective = config
|
|
@@ -93,34 +163,159 @@ export function apply(ctx, config) {
|
|
|
93
163
|
} catch (alreadyRegistered) {
|
|
94
164
|
effective = config
|
|
95
165
|
}
|
|
96
|
-
|
|
166
|
+
once(sctx, effective)
|
|
97
167
|
})
|
|
98
168
|
|
|
99
|
-
// Без
|
|
169
|
+
// Без службы настроек тоже должно работать — тогда только дерево плагинов.
|
|
170
|
+
// Ждём: службы может не быть вовсе, а может не быть ещё. Различить это можно
|
|
171
|
+
// только временем, и лучше подождать, чем запуститься не тем набором.
|
|
100
172
|
ctx.effect(() => {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
}, 'dsh-lanmode: запуск без сервиса настроек')
|
|
173
|
+
const timer = setTimeout(() => once(ctx, config), 2000)
|
|
174
|
+
return () => clearTimeout(timer)
|
|
175
|
+
}, 'dsh-lanmode: запуск без службы настроек')
|
|
105
176
|
}
|
|
106
177
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
178
|
+
/**
|
|
179
|
+
* Куда складывать выпущенный сертификат.
|
|
180
|
+
*
|
|
181
|
+
* Рядом с прочими данными харнесса, если он сказал где; иначе — куда указал
|
|
182
|
+
* человек. Своего пути плагин не выдумывает: писать в неизвестное место чужой
|
|
183
|
+
* машины он не вправе.
|
|
184
|
+
*/
|
|
185
|
+
function certificateDir(config) {
|
|
186
|
+
if (config.tlsDir) return config.tlsDir
|
|
187
|
+
const home = process.env.DSH_HOME
|
|
188
|
+
return home ? path.join(home, 'dsh-lanmode') : ''
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Поднять слушатель прямого режима: сначала сертификат, потом мост. */
|
|
192
|
+
async function raiseListener(ctx, config, state) {
|
|
193
|
+
const host = config.directHost || '0.0.0.0'
|
|
194
|
+
const port = config.directPort || 3088
|
|
195
|
+
const { rules, dropped } = parseAllow(config.allow)
|
|
196
|
+
if (dropped.length) say('в списке разрешённых не разобраны записи: ' + dropped.join(', '))
|
|
197
|
+
|
|
198
|
+
let tls = null
|
|
199
|
+
if (config.tls === 'files') {
|
|
200
|
+
try {
|
|
201
|
+
tls = readCertificate(config.tlsCert, config.tlsKey)
|
|
202
|
+
state.tls = { enabled: true, source: 'свой сертификат', fingerprint: tls.fingerprint }
|
|
203
|
+
} catch (unreadable) {
|
|
204
|
+
say('сертификат не прочитан (' + String(unreadable.message || unreadable)
|
|
205
|
+
+ ') — поднимаю без защищённого соединения, микрофон работать не будет')
|
|
206
|
+
}
|
|
207
|
+
} else if (config.tls === 'self-signed') {
|
|
208
|
+
const dir = certificateDir(config)
|
|
209
|
+
if (!dir) {
|
|
210
|
+
say('некуда положить сертификат: задайте tlsDir — поднимаю без защищённого соединения')
|
|
211
|
+
} else {
|
|
212
|
+
try {
|
|
213
|
+
const hosts = [...new Set([...(config.tlsHosts ?? []), ...localAddresses()])]
|
|
214
|
+
const made = await ensureCertificate({ dir, hosts, log: say })
|
|
215
|
+
tls = made
|
|
216
|
+
state.tls = { enabled: true, source: 'самоподписанный', fingerprint: made.fingerprint }
|
|
217
|
+
say((made.issued ? 'выпущен' : 'взят') + ' сертификат на ' + hosts.length
|
|
218
|
+
+ ' имён, отпечаток ' + made.fingerprint)
|
|
219
|
+
say('браузер предупредит о нём один раз: сверьте отпечаток и подтвердите')
|
|
220
|
+
} catch (failed) {
|
|
221
|
+
say('сертификат не выпущен (' + String(failed.message || failed)
|
|
222
|
+
+ '). Обычно это значит, что нет openssl: поставьте его или задайте свой '
|
|
223
|
+
+ 'сертификат через tls=files. Поднимаю без защищённого соединения, '
|
|
224
|
+
+ 'микрофон работать не будет')
|
|
225
|
+
}
|
|
226
|
+
}
|
|
117
227
|
}
|
|
118
228
|
|
|
229
|
+
state.listener = { scheme: tls ? 'https' : 'http', host, port }
|
|
230
|
+
return startDirectBridge(ctx, { host, port, log: say, allow: rules, tls })
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function start(ctx, config) {
|
|
119
234
|
const pieces = {
|
|
120
235
|
settings: config.settings !== false,
|
|
121
236
|
randomUuid: config.randomUuid !== false,
|
|
122
237
|
clipboard: config.clipboard !== false,
|
|
123
238
|
}
|
|
239
|
+
|
|
240
|
+
const state = {
|
|
241
|
+
version: version(),
|
|
242
|
+
mode: config.mode,
|
|
243
|
+
modeReason: '',
|
|
244
|
+
listener: null,
|
|
245
|
+
tls: { enabled: false },
|
|
246
|
+
pieces,
|
|
247
|
+
allow: Array.isArray(config.allow) ? config.allow : [],
|
|
248
|
+
assumptions: [],
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Режим: заданный руками — как сказано, `auto` — по тому, обслуживает ли сеть
|
|
252
|
+
// кто-то другой. Слушатель поднимается один раз, поэтому и решение одно.
|
|
253
|
+
ctx.effect(() => {
|
|
254
|
+
let stop = () => {}
|
|
255
|
+
let alive = true
|
|
256
|
+
|
|
257
|
+
const decide = async () => {
|
|
258
|
+
let mode = config.mode
|
|
259
|
+
if (mode === 'auto') {
|
|
260
|
+
const verdict = await detectMode({
|
|
261
|
+
addresses: localAddresses(),
|
|
262
|
+
port: ctx.webServer && ctx.webServer.port,
|
|
263
|
+
directPort: config.directPort || 3088,
|
|
264
|
+
})
|
|
265
|
+
mode = verdict.mode
|
|
266
|
+
state.mode = mode
|
|
267
|
+
state.modeReason = verdict.reason
|
|
268
|
+
say('режим выбран сам: ' + mode + ' — ' + verdict.reason)
|
|
269
|
+
}
|
|
270
|
+
if (!alive || mode !== 'direct') return
|
|
271
|
+
stop = await raiseListener(ctx, config, state)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
decide().catch((failed) => say('прямой режим не поднялся: ' + String(failed.message || failed)))
|
|
275
|
+
return () => { alive = false; stop() }
|
|
276
|
+
}, 'dsh-lanmode: слушатель прямого режима')
|
|
277
|
+
|
|
278
|
+
// Проверка точек крепления: плагин чинит чужое поведение, и его собственная
|
|
279
|
+
// поломка обязана быть заметной.
|
|
280
|
+
ctx.effect(() => {
|
|
281
|
+
let alive = true
|
|
282
|
+
const port = ctx.webServer && ctx.webServer.port
|
|
283
|
+
const fetchIndex = () => fetch('http://127.0.0.1:' + port + '/').then((answer) => answer.text())
|
|
284
|
+
|
|
285
|
+
// Даём харнессу договорить о себе: страница отдаётся не в первый миг.
|
|
286
|
+
const timer = setTimeout(() => {
|
|
287
|
+
checkAssumptions({ webServer: ctx.webServer, fetchIndex })
|
|
288
|
+
.then((results) => {
|
|
289
|
+
if (!alive) return
|
|
290
|
+
state.assumptions = results
|
|
291
|
+
const line = summarize(results)
|
|
292
|
+
if (results.every((item) => item.ok)) say(line)
|
|
293
|
+
// eslint-disable-next-line no-console
|
|
294
|
+
else console.warn('[dsh-lanmode] ' + line)
|
|
295
|
+
})
|
|
296
|
+
.catch((failed) => say('проверить точки крепления не вышло: ' + String(failed.message || failed)))
|
|
297
|
+
}, 3000)
|
|
298
|
+
|
|
299
|
+
return () => { alive = false; clearTimeout(timer) }
|
|
300
|
+
}, 'dsh-lanmode: проверка точек крепления')
|
|
301
|
+
|
|
302
|
+
if (config.diagnostics !== false) {
|
|
303
|
+
ctx.effect(() => ctx.webServer.register({
|
|
304
|
+
kind: 'exact',
|
|
305
|
+
path: '/dsh-lanmode/health',
|
|
306
|
+
handler: (req, res) => {
|
|
307
|
+
const json = String(req.url ?? '').includes('format=json')
|
|
308
|
+
if (json) {
|
|
309
|
+
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
|
|
310
|
+
res.end(JSON.stringify(hostReport(state), null, 2))
|
|
311
|
+
return
|
|
312
|
+
}
|
|
313
|
+
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
|
|
314
|
+
res.end(healthPage(state))
|
|
315
|
+
},
|
|
316
|
+
}), 'dsh-lanmode: страница диагностики')
|
|
317
|
+
}
|
|
318
|
+
|
|
124
319
|
// Ничего не включено — и вставлять нечего.
|
|
125
320
|
if (!pieces.settings && !pieces.randomUuid && !pieces.clipboard) return
|
|
126
321
|
|
package/lib/mode.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// Автовыбор режима.
|
|
2
|
+
//
|
|
3
|
+
// Режим сейчас выставляется руками, а при переезде о нём забывают. Ошибка в обе
|
|
4
|
+
// стороны неприятна: лишний слушатель на занятом порту или его отсутствие там,
|
|
5
|
+
// где он нужен.
|
|
6
|
+
//
|
|
7
|
+
// Определяем по одному признаку: отвечает ли что-нибудь на сетевом адресе этой
|
|
8
|
+
// машины по порту харнесса. Харнесс слушает только петлю, поэтому ответ оттуда
|
|
9
|
+
// означает, что сеть уже обслуживает кто-то другой, — то есть прокси.
|
|
10
|
+
//
|
|
11
|
+
// Правило простое и осознанно осторожное: не удалось выяснить — считаем, что
|
|
12
|
+
// прокси есть, и ничего не поднимаем. Лишний слушатель на сетевом адресе — это
|
|
13
|
+
// открытая дверь, и заводить её по догадке нельзя.
|
|
14
|
+
//
|
|
15
|
+
// Чего эта проверка не умеет: заметить прокси, который слушает ДРУГОЙ порт.
|
|
16
|
+
// Такой случай неотличим снаружи от «никого нет», и для него режим задаётся
|
|
17
|
+
// руками. Так и написано в описании плагина.
|
|
18
|
+
|
|
19
|
+
import net from 'node:net'
|
|
20
|
+
|
|
21
|
+
/** Отвечает ли кто-нибудь по этому адресу и порту. */
|
|
22
|
+
function knock(host, port, timeoutMs) {
|
|
23
|
+
return new Promise((resolve) => {
|
|
24
|
+
const socket = net.connect({ host, port })
|
|
25
|
+
const done = (answer) => {
|
|
26
|
+
socket.removeAllListeners()
|
|
27
|
+
try { socket.destroy() } catch (already) { /* уже мертво */ }
|
|
28
|
+
resolve(answer)
|
|
29
|
+
}
|
|
30
|
+
socket.setTimeout(timeoutMs, () => done(false))
|
|
31
|
+
socket.on('connect', () => done(true))
|
|
32
|
+
socket.on('error', () => done(false))
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Свой ли это адрес: петля отвечает всегда и для проверки не годится. */
|
|
37
|
+
export function isLoopback(address) {
|
|
38
|
+
const clean = String(address).trim()
|
|
39
|
+
return clean === 'localhost' || clean === '::1' || clean === '[::1]' || /^127\./.test(clean)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Годится ли адрес для опроса.
|
|
44
|
+
*
|
|
45
|
+
* Связь-локальные адреса (fe80::) отбрасываются: без указания интерфейса к ним
|
|
46
|
+
* не подключиться, а с ним они всё равно ничего не говорят о том, обслуживает
|
|
47
|
+
* ли кто-то сеть.
|
|
48
|
+
*/
|
|
49
|
+
export function worthAsking(address) {
|
|
50
|
+
const clean = String(address ?? '').trim()
|
|
51
|
+
if (!clean || isLoopback(clean)) return false
|
|
52
|
+
if (/^fe80:/i.test(clean)) return false
|
|
53
|
+
if (/^169\.254\./.test(clean)) return false
|
|
54
|
+
// Имя машины опрашивать незачем: оно разрешается в один из тех же адресов.
|
|
55
|
+
return /^[0-9.]+$/.test(clean) || clean.includes(':')
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Выбрать режим.
|
|
60
|
+
*
|
|
61
|
+
* Все адреса опрашиваются разом: их у машины бывает под сотню, и опрос по
|
|
62
|
+
* одному с ожиданием превращает выбор режима в минуты молчания при старте.
|
|
63
|
+
*
|
|
64
|
+
* @param options {{addresses: string[], port: number, directPort?: number,
|
|
65
|
+
* timeoutMs?: number, probe?: Function}}
|
|
66
|
+
* @returns `{ mode: 'proxy'|'direct', reason: string }`
|
|
67
|
+
*/
|
|
68
|
+
export async function detectMode(options) {
|
|
69
|
+
const probe = options.probe ?? knock
|
|
70
|
+
const timeoutMs = options.timeoutMs ?? 1000
|
|
71
|
+
const addresses = (options.addresses ?? []).filter(worthAsking)
|
|
72
|
+
|
|
73
|
+
if (!options.port) {
|
|
74
|
+
return { mode: 'proxy', reason: 'порт харнесса неизвестен — ничего не поднимаю' }
|
|
75
|
+
}
|
|
76
|
+
if (addresses.length === 0) {
|
|
77
|
+
return { mode: 'proxy', reason: 'сетевых адресов не нашлось — ничего не поднимаю' }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
let answers
|
|
81
|
+
try {
|
|
82
|
+
answers = await Promise.all(addresses.map((address) => probe(address, options.port, timeoutMs)))
|
|
83
|
+
} catch (failed) {
|
|
84
|
+
return {
|
|
85
|
+
mode: 'proxy',
|
|
86
|
+
reason: 'проверить не удалось (' + String(failed.message || failed) + ') — ничего не поднимаю',
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const busy = addresses.filter((address, index) => answers[index])
|
|
91
|
+
if (busy.length) {
|
|
92
|
+
return {
|
|
93
|
+
mode: 'proxy',
|
|
94
|
+
reason: 'по адресу ' + busy[0] + ':' + options.port + ' уже кто-то отвечает — сеть обслуживают без меня',
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Свой порт уже занят — поднимать нечего, и молчать об этом нельзя: иначе
|
|
99
|
+
// человек будет искать, почему по нему отвечает не то, что он ждёт.
|
|
100
|
+
if (options.directPort) {
|
|
101
|
+
let taken = false
|
|
102
|
+
try {
|
|
103
|
+
taken = await probe('127.0.0.1', options.directPort, timeoutMs)
|
|
104
|
+
} catch (unknown) {
|
|
105
|
+
taken = false
|
|
106
|
+
}
|
|
107
|
+
if (taken) {
|
|
108
|
+
return {
|
|
109
|
+
mode: 'proxy',
|
|
110
|
+
reason: 'порт ' + options.directPort + ' уже занят — свой слушатель не поднимаю',
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
mode: 'direct',
|
|
117
|
+
reason: 'на ' + addresses.length + ' сетевых адресах по порту ' + options.port
|
|
118
|
+
+ ' никто не отвечает — поднимаю свой слушатель',
|
|
119
|
+
}
|
|
120
|
+
}
|
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
|
+
"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",
|