herdr-remote 0.2.3 → 0.2.5
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 +16 -121
- package/README.zh-CN.md +50 -0
- package/bin/herdr-remote.js +14 -15
- package/config.example.json +5 -1
- package/dist/tui.mjs +353 -191
- package/herdr-plugin.toml +6 -6
- package/package.json +4 -3
- package/src/config.js +22 -0
- package/src/exit-codes.js +3 -1
- package/src/host-connector.js +130 -11
- package/src/i18n/en.js +76 -72
- package/src/i18n/zh.js +77 -73
- package/src/service.js +18 -6
- package/src/settings-model.js +44 -1
- package/src/supervisor.js +9 -1
- package/src/updater.js +127 -23
package/src/i18n/zh.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
module.exports = {
|
|
6
6
|
'app.name': 'Herdr Remote',
|
|
7
|
-
'app.tagline': '
|
|
7
|
+
'app.tagline': '在浏览器中使用 Herdr 工作区',
|
|
8
8
|
|
|
9
9
|
'common.yes': '是',
|
|
10
10
|
'common.no': '否',
|
|
@@ -23,11 +23,11 @@ module.exports = {
|
|
|
23
23
|
'common.disabled': '已停用',
|
|
24
24
|
'common.loading': '处理中…',
|
|
25
25
|
'common.save': '保存设置',
|
|
26
|
-
'common.saved': '
|
|
26
|
+
'common.saved': '配置已保存至 {path}',
|
|
27
27
|
'common.back': '返回',
|
|
28
28
|
'common.cancel': '取消',
|
|
29
29
|
'common.confirm': '确认',
|
|
30
|
-
'common.pid': '
|
|
30
|
+
'common.pid': 'PID {pid}',
|
|
31
31
|
'common.notApplicable': '不适用',
|
|
32
32
|
|
|
33
33
|
'nav.overview': '概览',
|
|
@@ -40,12 +40,12 @@ module.exports = {
|
|
|
40
40
|
|
|
41
41
|
'mode.local': '仅本机',
|
|
42
42
|
'mode.lan': '局域网 / Tailscale',
|
|
43
|
-
'mode.official': '官方
|
|
44
|
-
'mode.remote': '自建
|
|
45
|
-
'mode.local.description': '
|
|
46
|
-
'mode.lan.description': '
|
|
47
|
-
'mode.official.description': '
|
|
48
|
-
'mode.remote.description': '
|
|
43
|
+
'mode.official': '官方 Relay',
|
|
44
|
+
'mode.remote': '自建 Relay',
|
|
45
|
+
'mode.local.description': '监听 127.0.0.1,仅本机可访问。',
|
|
46
|
+
'mode.lan.description': '监听 0.0.0.0,局域网与 Tailscale 设备可访问。',
|
|
47
|
+
'mode.official.description': '使用官方 Relay 跨网访问,无需自建服务器。',
|
|
48
|
+
'mode.remote.description': '连接自建 Relay 跨网访问。',
|
|
49
49
|
|
|
50
50
|
'overview.title': '状态',
|
|
51
51
|
'overview.mode': '访问方式',
|
|
@@ -56,26 +56,26 @@ module.exports = {
|
|
|
56
56
|
'overview.keepalive': '保活服务',
|
|
57
57
|
'overview.devices': '浏览器',
|
|
58
58
|
'overview.hosts': '工作站',
|
|
59
|
-
'overview.uptime': 'Relay
|
|
59
|
+
'overview.uptime': 'Relay 运行时间',
|
|
60
60
|
'overview.relayLocal': '本地,{bind}:{port}',
|
|
61
61
|
'overview.relayRemote': '远程,{url}',
|
|
62
|
-
'overview.socketMissing': '
|
|
62
|
+
'overview.socketMissing': '未找到,请确认 Herdr 是否运行',
|
|
63
63
|
'overview.unreachable': '无法访问:{message}',
|
|
64
|
-
'overview.notStarted': '
|
|
64
|
+
'overview.notStarted': '服务未运行,可前往「服务」启动。',
|
|
65
65
|
|
|
66
66
|
'pair.title': '配对设备',
|
|
67
|
-
'pair.generate': '
|
|
68
|
-
'pair.regenerate': '
|
|
69
|
-
'pair.working': '
|
|
67
|
+
'pair.generate': '生成配对码',
|
|
68
|
+
'pair.regenerate': '重新生成',
|
|
69
|
+
'pair.working': '生成配对码中…',
|
|
70
70
|
'pair.code': '配对码',
|
|
71
|
-
'pair.url': '
|
|
71
|
+
'pair.url': '访问地址',
|
|
72
72
|
'pair.expires': '剩余 {minutes}(至 {time})',
|
|
73
|
-
'pair.expired': '
|
|
74
|
-
'pair.instructions': '
|
|
75
|
-
'pair.qrHint': '
|
|
76
|
-
'pair.qrUnavailable': '
|
|
73
|
+
'pair.expired': '配对码已过期,需重新生成。',
|
|
74
|
+
'pair.instructions': '打开链接并输入配对码。',
|
|
75
|
+
'pair.qrHint': '扫描二维码打开链接。',
|
|
76
|
+
'pair.qrUnavailable': '终端宽度不足,无法显示二维码。',
|
|
77
77
|
'pair.failed': '生成配对码失败:{message}',
|
|
78
|
-
'pair.hostOffline': '
|
|
78
|
+
'pair.hostOffline': 'Relay 未连接工作站,需先启动服务。',
|
|
79
79
|
|
|
80
80
|
'services.title': '服务',
|
|
81
81
|
'services.start': '启动服务',
|
|
@@ -86,39 +86,42 @@ module.exports = {
|
|
|
86
86
|
'services.restarted': '服务已重启。',
|
|
87
87
|
'services.startFailed': '启动服务失败:{message}',
|
|
88
88
|
'services.stopFailed': '停止服务失败:{message}',
|
|
89
|
-
'services.unsavedBlocked': '
|
|
90
|
-
'services.managedNotice': '
|
|
89
|
+
'services.unsavedBlocked': '配置未保存,请先保存再重启。',
|
|
90
|
+
'services.managedNotice': '由 {manager} 托管,已通过保活服务执行。',
|
|
91
91
|
'services.logs': '最近日志',
|
|
92
|
-
'services.logEmpty': '
|
|
92
|
+
'services.logEmpty': '暂无日志。',
|
|
93
93
|
'services.logRelay': 'Relay',
|
|
94
94
|
'services.logHost': '主机连接器',
|
|
95
95
|
|
|
96
96
|
'relay.title': 'Relay 设置',
|
|
97
97
|
'relay.settings': '设置',
|
|
98
|
+
'relay.locked': '固定',
|
|
99
|
+
'relay.officialFixed': '官方 relay 地址固定,不可修改。',
|
|
100
|
+
'relay.officialHint': '官方 relay:地址固定,无需密码。',
|
|
98
101
|
'relay.password': 'Relay 密码',
|
|
99
|
-
'relay.passwordHint': '
|
|
102
|
+
'relay.passwordHint': '须与 Relay 端的 RELAY_PASSWORD 一致。留空为公用 Relay。',
|
|
100
103
|
'relay.passwordSet': '已设置',
|
|
101
|
-
'relay.passwordEmpty': '
|
|
102
|
-
'relay.passwordSaved': 'Relay
|
|
104
|
+
'relay.passwordEmpty': '未设置(公用)',
|
|
105
|
+
'relay.passwordSaved': 'Relay 密码已保存,重启服务生效。',
|
|
103
106
|
'relay.showPassword': '显示密码',
|
|
104
107
|
'relay.hidePassword': '隐藏密码',
|
|
105
108
|
'relay.identity': '工作站标识',
|
|
106
|
-
'relay.regenerate': '
|
|
107
|
-
'relay.regenerated': '
|
|
108
|
-
'relay.envSnippet': '
|
|
109
|
-
'relay.envHint': '在
|
|
110
|
-
'relay.test': '测试
|
|
111
|
-
'relay.testOk': '
|
|
112
|
-
'relay.testFailed': '
|
|
113
|
-
'relay.selectAddress': '
|
|
109
|
+
'relay.regenerate': '重新生成标识',
|
|
110
|
+
'relay.regenerated': '已生成新标识,重启后重新注册。',
|
|
111
|
+
'relay.envSnippet': 'Relay 启动命令',
|
|
112
|
+
'relay.envHint': '在 Relay 主机执行此命令。',
|
|
113
|
+
'relay.test': '测试 Relay 连接',
|
|
114
|
+
'relay.testOk': 'Relay 可访问:版本 {version},已连接 {hosts} 台工作站。',
|
|
115
|
+
'relay.testFailed': 'Relay 无法访问:{message}',
|
|
116
|
+
'relay.selectAddress': '选择公布地址',
|
|
114
117
|
'relay.listenAddress': '监听地址',
|
|
115
|
-
'relay.listenAddressHint': '
|
|
118
|
+
'relay.listenAddressHint': '服务器绑定地址;浏览器访问下方公布地址。',
|
|
116
119
|
'relay.addressTailscale': 'Tailscale',
|
|
117
120
|
'relay.addressLan': '局域网',
|
|
118
121
|
'relay.addressVirtual': '虚拟网卡',
|
|
119
122
|
'relay.addressLoopback': '回环',
|
|
120
|
-
'relay.noAddresses': '
|
|
121
|
-
'relay.docsHint': '
|
|
123
|
+
'relay.noAddresses': '未找到非回环地址。',
|
|
124
|
+
'relay.docsHint': '自建指南:docs/self-hosted-relay.zh-CN.md',
|
|
122
125
|
|
|
123
126
|
'keepalive.title': '后台保活服务',
|
|
124
127
|
'keepalive.manager': '管理方式',
|
|
@@ -132,12 +135,12 @@ module.exports = {
|
|
|
132
135
|
'keepalive.failed': '保活操作失败:{message}',
|
|
133
136
|
'keepalive.unitPath': '单元文件',
|
|
134
137
|
'keepalive.logsHint': '查看日志:{command}',
|
|
135
|
-
'keepalive.linger': '
|
|
136
|
-
'keepalive.lingerEnabled': '
|
|
137
|
-
'keepalive.lingerDisabled': '
|
|
138
|
+
'keepalive.linger': '开机自启',
|
|
139
|
+
'keepalive.lingerEnabled': '已开启开机自启。',
|
|
140
|
+
'keepalive.lingerDisabled': '未开启开机自启(仅登录期间运行)。',
|
|
138
141
|
'keepalive.enableLinger': '开启开机自启',
|
|
139
|
-
'keepalive.lingerDone': '已为 {username}
|
|
140
|
-
'keepalive.fallbackNote': '
|
|
142
|
+
'keepalive.lingerDone': '已为 {username} 开启开机自启。',
|
|
143
|
+
'keepalive.fallbackNote': '无系统服务管理器,使用后台守护进程,重启后不自动恢复。',
|
|
141
144
|
|
|
142
145
|
'herdr.title': 'Herdr 集成',
|
|
143
146
|
'herdr.socketPath': '套接字路径',
|
|
@@ -145,12 +148,12 @@ module.exports = {
|
|
|
145
148
|
'herdr.plugin': '插件注册',
|
|
146
149
|
'herdr.pluginRegistered': '已注册到 Herdr。',
|
|
147
150
|
'herdr.pluginMissing': '尚未注册到 Herdr。',
|
|
148
|
-
'herdr.register': '
|
|
149
|
-
'herdr.unregister': '取消注册
|
|
151
|
+
'herdr.register': '注册为 Herdr 插件',
|
|
152
|
+
'herdr.unregister': '取消注册',
|
|
150
153
|
'herdr.registerDone': '已注册:{path}',
|
|
151
|
-
'herdr.unregisterDone': '
|
|
154
|
+
'herdr.unregisterDone': '已取消注册。',
|
|
152
155
|
'herdr.registerFailed': '注册失败:{message}',
|
|
153
|
-
'herdr.cliMissing': 'PATH
|
|
156
|
+
'herdr.cliMissing': 'PATH 中未找到 herdr 命令。',
|
|
154
157
|
|
|
155
158
|
'about.title': '语言与关于',
|
|
156
159
|
'about.language': '界面语言',
|
|
@@ -161,24 +164,24 @@ module.exports = {
|
|
|
161
164
|
'about.configPath': '配置文件',
|
|
162
165
|
'about.statePath': '状态目录',
|
|
163
166
|
'about.relayPackage': 'Relay 包',
|
|
164
|
-
'about.docs': '
|
|
167
|
+
'about.docs': '自建指南:docs/self-hosted-relay.zh-CN.md',
|
|
165
168
|
|
|
166
169
|
'wizard.title': '首次设置',
|
|
167
170
|
'wizard.step': '第 {current} / {total} 步',
|
|
168
171
|
'wizard.languageTitle': '选择语言',
|
|
169
|
-
'wizard.accessTitle': '
|
|
170
|
-
'wizard.accessHint': '
|
|
171
|
-
'wizard.addressTitle': '
|
|
172
|
-
'wizard.relayTitle': '
|
|
172
|
+
'wizard.accessTitle': '选择访问方式',
|
|
173
|
+
'wizard.accessHint': '后续可在 Relay 页面修改。',
|
|
174
|
+
'wizard.addressTitle': '选择访问地址',
|
|
175
|
+
'wizard.relayTitle': 'Relay 服务器',
|
|
173
176
|
'wizard.relayUrlLabel': 'Relay 地址(wss://…)',
|
|
174
|
-
'wizard.relayHint': '
|
|
175
|
-
'wizard.passwordTitle': 'Relay 密码',
|
|
176
|
-
'wizard.passwordHint': '
|
|
177
|
+
'wizard.relayHint': 'Relay 需先运行。搭建指南见 docs/self-hosted-relay.zh-CN.md。',
|
|
178
|
+
'wizard.passwordTitle' : 'Relay 密码',
|
|
179
|
+
'wizard.passwordHint': 'Relay 的 RELAY_PASSWORD。未设留空。',
|
|
177
180
|
'wizard.finishTitle': '完成',
|
|
178
|
-
'wizard.finishHint': '
|
|
181
|
+
'wizard.finishHint': '配置将保存至 {path}。',
|
|
179
182
|
'wizard.startNow': '立即启动服务',
|
|
180
|
-
'wizard.installKeepalive': '
|
|
181
|
-
'wizard.finish': '
|
|
183
|
+
'wizard.installKeepalive': '后台保活',
|
|
184
|
+
'wizard.finish': '保存并完成',
|
|
182
185
|
|
|
183
186
|
'field.mode': '访问方式',
|
|
184
187
|
'field.port': 'Relay 端口',
|
|
@@ -194,14 +197,14 @@ module.exports = {
|
|
|
194
197
|
'placeholder.none': '无',
|
|
195
198
|
|
|
196
199
|
'error.invalidMode': '未知的访问方式。',
|
|
197
|
-
'error.invalidPort': '
|
|
198
|
-
'error.invalidLanHost': '
|
|
199
|
-
'error.invalidRelayUrl': 'Relay
|
|
200
|
-
'error.invalidPublicUrl': '
|
|
200
|
+
'error.invalidPort': '端口须为 1 到 65535 之间的数字。',
|
|
201
|
+
'error.invalidLanHost': '浏览器地址须为可访问局域网或 Tailscale 地址,不能为回环或 0.0.0.0。',
|
|
202
|
+
'error.invalidRelayUrl': 'Relay 地址须以 wss://、ws://、https:// 或 http:// 开头。',
|
|
203
|
+
'error.invalidPublicUrl': '浏览器地址须以 https:// 或 http:// 开头,不能为 0.0.0.0 或 ::。',
|
|
201
204
|
'error.invalidLanguage': '未知的语言。',
|
|
202
205
|
'error.invalidKeepalive': '未知的保活管理方式。',
|
|
203
206
|
'error.unknownField': '未知的设置项。',
|
|
204
|
-
'error.remoteUrlRequired': '自建
|
|
207
|
+
'error.remoteUrlRequired': '自建 Relay 模式须填写 Relay 地址。',
|
|
205
208
|
'error.saveFailed': '保存配置失败:{message}',
|
|
206
209
|
|
|
207
210
|
'hint.navigate': '↑↓ 移动',
|
|
@@ -213,20 +216,21 @@ module.exports = {
|
|
|
213
216
|
'hint.mouseOn': 'm 关闭鼠标',
|
|
214
217
|
'hint.mouseOff': 'm 开启鼠标',
|
|
215
218
|
'hint.mouseUnsupported': '不支持鼠标',
|
|
216
|
-
'hint.restartRequired': '
|
|
217
|
-
'hint.unsavedChanges': '
|
|
219
|
+
'hint.restartRequired': '重启服务生效。',
|
|
220
|
+
'hint.unsavedChanges': '有未保存修改(按 s 保存)。',
|
|
218
221
|
'hint.save': 's 保存',
|
|
219
222
|
'hint.editing': '↵ 确认 · esc 取消',
|
|
220
223
|
'update.title': '更新',
|
|
221
224
|
'update.check': '检查更新',
|
|
222
|
-
'update.checking': '
|
|
225
|
+
'update.checking': '查询 npm 中…',
|
|
223
226
|
'update.upToDate': '已是最新({version})',
|
|
224
|
-
'update.available': '
|
|
225
|
-
'update.updating': '
|
|
226
|
-
'update.done': '
|
|
227
|
-
'update.restartHint': '更新已安装,重启 herdr-remote
|
|
228
|
-
'update.errorNetwork': '
|
|
227
|
+
'update.available': '新版本 {version} 可用(按 Enter 安装)',
|
|
228
|
+
'update.updating': '安装 {version} 中…',
|
|
229
|
+
'update.done': '已更新至 {version}',
|
|
230
|
+
'update.restartHint': '更新已安装,重启 herdr-remote 生效。',
|
|
231
|
+
'update.errorNetwork': '所有 npm registry 均无法连接',
|
|
232
|
+
'update.errorNetworkDetail': '无 registry 应答:{message}',
|
|
229
233
|
'update.errorFailed': '更新失败,可手动执行:npm install -g herdr-remote@latest',
|
|
230
|
-
'update.cannot.source': '
|
|
231
|
-
'update.cannot.linked': '
|
|
234
|
+
'update.cannot.source': '源码目录运行,请使用 git 更新',
|
|
235
|
+
'update.cannot.linked': 'npm link 工作副本,由 npm link 管理',
|
|
232
236
|
};
|
package/src/service.js
CHANGED
|
@@ -166,6 +166,10 @@ function serviceSpecs(config = loadConfig(), state = ensureRuntime()) {
|
|
|
166
166
|
RELAY_AUTH_STATE_FILE: relayAuthStatePath(),
|
|
167
167
|
RELAY_ALLOWED_ORIGINS: (config.relay.allowedOrigins || []).join(','),
|
|
168
168
|
RELAY_MAX_CLIENTS_PER_HOST: String(config.relay.maxClientsPerHost),
|
|
169
|
+
RELAY_MAX_HOSTS: String(config.relay.maxHosts),
|
|
170
|
+
RELAY_MAX_PENDING_HANDSHAKES: String(config.relay.maxPendingHandshakes),
|
|
171
|
+
RELAY_MAX_BUFFERED_BYTES_PER_CLIENT: String(config.relay.maxBufferedBytesPerClient),
|
|
172
|
+
RELAY_HOST_RECONNECT_GRACE_MS: String(config.relay.hostReconnectGraceMs),
|
|
169
173
|
},
|
|
170
174
|
});
|
|
171
175
|
}
|
|
@@ -377,18 +381,26 @@ async function waitForRelay(config, { attempts = 20, delayMs = 100 } = {}) {
|
|
|
377
381
|
}
|
|
378
382
|
|
|
379
383
|
async function waitForHost(config, { attempts = 30, delayMs = 100 } = {}) {
|
|
380
|
-
|
|
384
|
+
const state = ensureRuntime();
|
|
385
|
+
const statusEndpoint = `${resolveAdminOrigin(config)}/api/status`;
|
|
386
|
+
let lastStatus = null;
|
|
381
387
|
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
382
388
|
try {
|
|
383
|
-
|
|
384
|
-
|
|
389
|
+
lastStatus = await requestJson(statusEndpoint, {
|
|
390
|
+
timeout: 800,
|
|
391
|
+
headers: {
|
|
392
|
+
'X-Herdr-Host-Id': state.hostId,
|
|
393
|
+
'X-Herdr-Host-Token': state.hostToken,
|
|
394
|
+
},
|
|
395
|
+
});
|
|
396
|
+
if (lastStatus.hosts?.some((host) => host.id === state.hostId)) return lastStatus;
|
|
385
397
|
} catch (error) {
|
|
386
|
-
|
|
398
|
+
lastStatus = { ok: false, message: error.message };
|
|
387
399
|
}
|
|
388
400
|
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
389
401
|
}
|
|
390
|
-
const error = new Error(
|
|
391
|
-
error.health =
|
|
402
|
+
const error = new Error(lastStatus?.message || 'the Herdr host connector did not register with the relay');
|
|
403
|
+
error.health = lastStatus;
|
|
392
404
|
throw error;
|
|
393
405
|
}
|
|
394
406
|
|
package/src/settings-model.js
CHANGED
|
@@ -11,6 +11,7 @@ const {
|
|
|
11
11
|
ACCESS_MODES,
|
|
12
12
|
KEEPALIVE_MANAGERS,
|
|
13
13
|
LANGUAGES,
|
|
14
|
+
OFFICIAL_RELAY_URL,
|
|
14
15
|
configDir,
|
|
15
16
|
configPath,
|
|
16
17
|
isLoopbackHost,
|
|
@@ -40,6 +41,30 @@ const FIELDS = [
|
|
|
40
41
|
|
|
41
42
|
const EMPTY = '';
|
|
42
43
|
|
|
44
|
+
/**
|
|
45
|
+
* The access modes a *person* chooses between.
|
|
46
|
+
*
|
|
47
|
+
* The official relay is stored as `remote` with a known URL, because that is
|
|
48
|
+
* exactly what it is and nothing downstream should have to learn a fourth mode.
|
|
49
|
+
* It is still a separate answer to "how do I reach this workstation", though:
|
|
50
|
+
* one option needs no server and no credentials, the other needs both. Keeping
|
|
51
|
+
* that distinction here — rather than in whichever screen happens to draw it —
|
|
52
|
+
* is what stops the official relay from being displayed as "self-hosted relay"
|
|
53
|
+
* that merely happens to hold our address.
|
|
54
|
+
*/
|
|
55
|
+
const SELECTABLE_MODES = ['local', 'lan', 'official', 'remote'];
|
|
56
|
+
|
|
57
|
+
/** Which of `SELECTABLE_MODES` this draft represents. */
|
|
58
|
+
function selectedMode(draft) {
|
|
59
|
+
if (draft.relay.mode === 'remote' && draft.relay.remoteUrl === OFFICIAL_RELAY_URL) return 'official';
|
|
60
|
+
return draft.relay.mode;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** True when the relay is the one we run, so its address and password are ours. */
|
|
64
|
+
function isOfficialRelay(draft) {
|
|
65
|
+
return selectedMode(draft) === 'official';
|
|
66
|
+
}
|
|
67
|
+
|
|
43
68
|
function clone(value) {
|
|
44
69
|
return JSON.parse(JSON.stringify(value));
|
|
45
70
|
}
|
|
@@ -121,7 +146,22 @@ function setField(draft, id, rawValue) {
|
|
|
121
146
|
|
|
122
147
|
switch (id) {
|
|
123
148
|
case 'mode': {
|
|
124
|
-
if (!
|
|
149
|
+
if (!SELECTABLE_MODES.includes(value)) return { draft, errorKey: 'error.invalidMode' };
|
|
150
|
+
// Picking the official relay fills in its address in the same edit: a
|
|
151
|
+
// remote mode with no URL is not a valid state, and asking for the
|
|
152
|
+
// address we already know would be asking the user to do our filing.
|
|
153
|
+
if (value === 'official') {
|
|
154
|
+
next.relay.mode = 'remote';
|
|
155
|
+
next.relay.remoteUrl = OFFICIAL_RELAY_URL;
|
|
156
|
+
next.relay.publicUrl = EMPTY;
|
|
157
|
+
break;
|
|
158
|
+
}
|
|
159
|
+
// Leaving the official relay clears its address, so the self-hosted URL
|
|
160
|
+
// field is empty and asking to be filled in rather than pre-loaded with
|
|
161
|
+
// an address that belongs to somebody else's server.
|
|
162
|
+
if (value === 'remote' && next.relay.remoteUrl === OFFICIAL_RELAY_URL) {
|
|
163
|
+
next.relay.remoteUrl = EMPTY;
|
|
164
|
+
}
|
|
125
165
|
next.relay.mode = value;
|
|
126
166
|
if (value === 'lan'
|
|
127
167
|
&& (isLoopbackHost(next.relay.lanHost) || isUnspecifiedAddress(next.relay.lanHost))) {
|
|
@@ -254,8 +294,11 @@ function requiresRestart(before, after) {
|
|
|
254
294
|
|
|
255
295
|
module.exports = {
|
|
256
296
|
FIELDS,
|
|
297
|
+
SELECTABLE_MODES,
|
|
257
298
|
createDraft,
|
|
258
299
|
fieldsForMode,
|
|
300
|
+
isOfficialRelay,
|
|
301
|
+
selectedMode,
|
|
259
302
|
getField,
|
|
260
303
|
getFieldPlaceholder,
|
|
261
304
|
setField,
|
package/src/supervisor.js
CHANGED
|
@@ -5,7 +5,7 @@ const { spawn } = require('node:child_process');
|
|
|
5
5
|
const { PACKAGE_ROOT, loadConfig, runtimeStatePath, stateDir } = require('./config');
|
|
6
6
|
const { ensureDir, readJson, writeJsonAtomic } = require('./state');
|
|
7
7
|
const { baseEnvironment, ensureRuntime, logPath, managedPids, pidAlive, recordManagedPid, serviceSpecs } = require('./service');
|
|
8
|
-
const { EXIT_REPLACED } = require('./exit-codes');
|
|
8
|
+
const { EXIT_REPLACED, EXIT_AUTH_FAILED } = require('./exit-codes');
|
|
9
9
|
|
|
10
10
|
const MIN_BACKOFF_MS = 500;
|
|
11
11
|
const MAX_BACKOFF_MS = 30_000;
|
|
@@ -132,6 +132,14 @@ class Supervisor {
|
|
|
132
132
|
});
|
|
133
133
|
return;
|
|
134
134
|
}
|
|
135
|
+
if (code === EXIT_AUTH_FAILED) {
|
|
136
|
+
this.emit({
|
|
137
|
+
type: 'fatal',
|
|
138
|
+
name,
|
|
139
|
+
message: `${name} stopped: relay authentication failed. Update credentials before restarting it.`,
|
|
140
|
+
});
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
135
143
|
if (uptimeMs >= HEALTHY_UPTIME_MS) entry.backoffMs = MIN_BACKOFF_MS;
|
|
136
144
|
this.emit({
|
|
137
145
|
type: 'exited',
|
package/src/updater.js
CHANGED
|
@@ -8,12 +8,23 @@
|
|
|
8
8
|
// checkout would replace the tree someone is working in.
|
|
9
9
|
|
|
10
10
|
const fs = require('node:fs');
|
|
11
|
+
const os = require('node:os');
|
|
11
12
|
const path = require('node:path');
|
|
12
13
|
const { spawn } = require('node:child_process');
|
|
13
14
|
const { PACKAGE_ROOT } = require('./config');
|
|
14
15
|
|
|
15
16
|
const PACKAGE_NAME = 'herdr-remote';
|
|
16
|
-
const
|
|
17
|
+
const DEFAULT_REGISTRY = 'https://registry.npmjs.org';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A public mirror, tried last.
|
|
21
|
+
*
|
|
22
|
+
* `registry.npmjs.org` is not reachable from every network this runs on — in
|
|
23
|
+
* mainland China it usually is not — and "could not reach npm registry" on a
|
|
24
|
+
* machine that installs packages perfectly well is a bug report, not a
|
|
25
|
+
* diagnosis. The mirror is read-only and only ever asked for a version number.
|
|
26
|
+
*/
|
|
27
|
+
const MIRROR_REGISTRY = 'https://registry.npmmirror.com';
|
|
17
28
|
|
|
18
29
|
function currentVersion() {
|
|
19
30
|
try {
|
|
@@ -50,6 +61,59 @@ function canSelfUpdate() {
|
|
|
50
61
|
return installKind() === 'npm';
|
|
51
62
|
}
|
|
52
63
|
|
|
64
|
+
function normalizeRegistry(value) {
|
|
65
|
+
if (typeof value !== 'string') return null;
|
|
66
|
+
const trimmed = value.trim().replace(/\/+$/, '');
|
|
67
|
+
if (!/^https?:\/\//i.test(trimmed)) return null;
|
|
68
|
+
return trimmed;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The `registry=` line from an npmrc file, if it has one. */
|
|
72
|
+
function registryFromNpmrc(filePath) {
|
|
73
|
+
let contents;
|
|
74
|
+
try {
|
|
75
|
+
contents = fs.readFileSync(filePath, 'utf8');
|
|
76
|
+
} catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
let found = null;
|
|
80
|
+
for (const rawLine of contents.split(/\r?\n/)) {
|
|
81
|
+
const line = rawLine.trim();
|
|
82
|
+
if (!line || line.startsWith('#') || line.startsWith(';')) continue;
|
|
83
|
+
const match = /^registry\s*=\s*(.+)$/i.exec(line);
|
|
84
|
+
// The last assignment wins, as it does for npm itself.
|
|
85
|
+
if (match) found = normalizeRegistry(match[1].replace(/^["']|["']$/g, ''));
|
|
86
|
+
}
|
|
87
|
+
return found;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Where to ask, in the order to ask.
|
|
92
|
+
*
|
|
93
|
+
* Whatever npm itself is configured to use comes first: a machine behind a
|
|
94
|
+
* corporate proxy or on a mirror has already answered this question, and
|
|
95
|
+
* ignoring that answer is what made the check fail on a machine where
|
|
96
|
+
* `npm install` works. The public registry and then a public mirror follow, so
|
|
97
|
+
* a private registry that does not carry this package is not the end of it.
|
|
98
|
+
*/
|
|
99
|
+
function registryCandidates({ env = process.env, home = os.homedir(), cwd = process.cwd() } = {}) {
|
|
100
|
+
const candidates = [];
|
|
101
|
+
const add = (value) => {
|
|
102
|
+
const normalized = normalizeRegistry(value);
|
|
103
|
+
if (normalized && !candidates.includes(normalized)) candidates.push(normalized);
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// npm exports its whole config into the environment of anything it runs.
|
|
107
|
+
add(env.npm_config_registry);
|
|
108
|
+
add(env.NPM_CONFIG_REGISTRY);
|
|
109
|
+
add(env.HERDR_REMOTE_REGISTRY);
|
|
110
|
+
add(registryFromNpmrc(path.join(cwd, '.npmrc')));
|
|
111
|
+
add(registryFromNpmrc(path.join(home, '.npmrc')));
|
|
112
|
+
add(DEFAULT_REGISTRY);
|
|
113
|
+
add(MIRROR_REGISTRY);
|
|
114
|
+
return candidates;
|
|
115
|
+
}
|
|
116
|
+
|
|
53
117
|
/** Compare two `MAJOR.MINOR.PATCH` strings. Returns 1, -1 or 0. */
|
|
54
118
|
function compareVersions(a, b) {
|
|
55
119
|
const parse = (value) => String(value)
|
|
@@ -66,40 +130,69 @@ function compareVersions(a, b) {
|
|
|
66
130
|
return 0;
|
|
67
131
|
}
|
|
68
132
|
|
|
69
|
-
/**
|
|
70
|
-
|
|
71
|
-
*
|
|
72
|
-
* Never throws: an update check is a convenience, and a machine that is offline
|
|
73
|
-
* or behind a proxy should still get a working settings screen.
|
|
74
|
-
*/
|
|
75
|
-
async function checkForUpdate({ timeoutMs = 8000, fetchImpl = globalThis.fetch } = {}) {
|
|
76
|
-
const current = currentVersion();
|
|
133
|
+
/** One registry, one question: what is the latest published version? */
|
|
134
|
+
async function askRegistry(registry, { timeoutMs, fetchImpl }) {
|
|
77
135
|
const controller = new AbortController();
|
|
78
136
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
79
137
|
try {
|
|
80
|
-
const response = await fetchImpl(
|
|
138
|
+
const response = await fetchImpl(`${registry}/${PACKAGE_NAME}/latest`, {
|
|
81
139
|
signal: controller.signal,
|
|
82
140
|
headers: { Accept: 'application/vnd.npm.install-v1+json' },
|
|
83
141
|
});
|
|
84
|
-
if (!response.ok) {
|
|
85
|
-
return { ok: false, current, errorKey: 'update.errorNetwork' };
|
|
86
|
-
}
|
|
142
|
+
if (!response.ok) return { ok: false, message: `HTTP ${response.status ?? '?'}` };
|
|
87
143
|
const body = await response.json();
|
|
88
144
|
const latest = typeof body?.version === 'string' ? body.version : null;
|
|
89
|
-
if (!latest) return { ok: false,
|
|
90
|
-
return {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
latest,
|
|
94
|
-
updateAvailable: compareVersions(latest, current) > 0,
|
|
95
|
-
};
|
|
96
|
-
} catch {
|
|
97
|
-
return { ok: false, current, errorKey: 'update.errorNetwork' };
|
|
145
|
+
if (!latest) return { ok: false, message: 'registry answered without a version' };
|
|
146
|
+
return { ok: true, latest };
|
|
147
|
+
} catch (error) {
|
|
148
|
+
return { ok: false, message: error?.name === 'AbortError' ? 'timed out' : String(error?.message || error) };
|
|
98
149
|
} finally {
|
|
99
150
|
clearTimeout(timer);
|
|
100
151
|
}
|
|
101
152
|
}
|
|
102
153
|
|
|
154
|
+
/**
|
|
155
|
+
* Ask what the current release is.
|
|
156
|
+
*
|
|
157
|
+
* Never throws: an update check is a convenience, and a machine that is offline
|
|
158
|
+
* or behind a proxy should still get a working settings screen. Each configured
|
|
159
|
+
* registry is tried in turn, because the first one is only a guess at which
|
|
160
|
+
* mirror this machine can actually reach.
|
|
161
|
+
*/
|
|
162
|
+
async function checkForUpdate({
|
|
163
|
+
timeoutMs = 6000,
|
|
164
|
+
fetchImpl = globalThis.fetch,
|
|
165
|
+
registries = registryCandidates(),
|
|
166
|
+
} = {}) {
|
|
167
|
+
const current = currentVersion();
|
|
168
|
+
const failures = [];
|
|
169
|
+
|
|
170
|
+
for (const registry of registries) {
|
|
171
|
+
const attempt = await askRegistry(registry, { timeoutMs, fetchImpl });
|
|
172
|
+
if (attempt.ok) {
|
|
173
|
+
return {
|
|
174
|
+
ok: true,
|
|
175
|
+
current,
|
|
176
|
+
latest: attempt.latest,
|
|
177
|
+
registry,
|
|
178
|
+
updateAvailable: compareVersions(attempt.latest, current) > 0,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
failures.push(`${registry}: ${attempt.message}`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// The reason is carried out with the failure. "Could not reach npm registry"
|
|
185
|
+
// with nothing after it leaves the user guessing between DNS, a proxy, a
|
|
186
|
+
// firewall and a mirror that does not carry the package.
|
|
187
|
+
return {
|
|
188
|
+
ok: false,
|
|
189
|
+
current,
|
|
190
|
+
errorKey: 'update.errorNetwork',
|
|
191
|
+
message: failures.join('; '),
|
|
192
|
+
triedRegistries: registries,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
103
196
|
/**
|
|
104
197
|
* Install the newest release over this one.
|
|
105
198
|
*
|
|
@@ -113,6 +206,11 @@ function performUpdate({
|
|
|
113
206
|
// Seam for tests: the suite runs from a checkout, where the guard below is
|
|
114
207
|
// correctly the only reachable outcome.
|
|
115
208
|
installKindImpl = installKind,
|
|
209
|
+
// The registry that just answered the version check. Installing from
|
|
210
|
+
// somewhere the machine has been shown to reach beats installing from a
|
|
211
|
+
// default it may have no route to. Empty means "whatever npm is configured
|
|
212
|
+
// with", which is the right answer when no check has run.
|
|
213
|
+
registry = '',
|
|
116
214
|
} = {}) {
|
|
117
215
|
return new Promise((resolve) => {
|
|
118
216
|
const kind = installKindImpl();
|
|
@@ -120,7 +218,10 @@ function performUpdate({
|
|
|
120
218
|
resolve({ ok: false, errorKey: `update.cannot.${kind}` });
|
|
121
219
|
return;
|
|
122
220
|
}
|
|
123
|
-
const
|
|
221
|
+
const args = ['install', '-g', `${PACKAGE_NAME}@latest`];
|
|
222
|
+
const normalizedRegistry = normalizeRegistry(registry);
|
|
223
|
+
if (normalizedRegistry) args.push('--registry', normalizedRegistry);
|
|
224
|
+
const child = spawnImpl('npm', args, {
|
|
124
225
|
encoding: 'utf8',
|
|
125
226
|
timeout: timeoutMs,
|
|
126
227
|
});
|
|
@@ -139,10 +240,13 @@ function performUpdate({
|
|
|
139
240
|
|
|
140
241
|
module.exports = {
|
|
141
242
|
PACKAGE_NAME,
|
|
243
|
+
DEFAULT_REGISTRY,
|
|
244
|
+
MIRROR_REGISTRY,
|
|
142
245
|
canSelfUpdate,
|
|
143
246
|
checkForUpdate,
|
|
144
247
|
compareVersions,
|
|
145
248
|
currentVersion,
|
|
146
249
|
installKind,
|
|
147
250
|
performUpdate,
|
|
251
|
+
registryCandidates,
|
|
148
252
|
};
|