dsh-plugin-mobile-gateway 0.4.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/index.mjs CHANGED
@@ -48,6 +48,8 @@
48
48
  // { "type": "agent-presets" } -> preset roster (+ isDefault)
49
49
  // { "type": "defaults" } -> default agent preset + default permission
50
50
  // { "type": "set-default", "target": "agent-preset"|"permission", "value" }
51
+ // { "type": "question-answer", "rpcId", "sessionId", "answers": [...] }
52
+ // { "type": "question-cancel", "rpcId", "sessionId" }
51
53
  // server -> client: { "kind": "hello", "protocol": 2, "authenticated", "device"?, "port", "clients" }
52
54
  // { "kind": "pong", "at" }
53
55
  // { "kind": "subscribed", "sessionId" }
@@ -55,8 +57,12 @@
55
57
  // { "kind": "workspaces" | "sessions" | "history" | "search", ...data }
56
58
  // { "kind": "error", "code", "message", "requestType"?, "sessionId"? }
57
59
  // { "kind": "event", "sessionId", "seq", "time", "event": { ... } }
60
+ // { "kind": "question-requested", "rpcId", "sessionId", "questions", "replay"? }
61
+ // { "kind": "question-response", "rpcId", "sessionId", "action", "accepted", "reason"? }
62
+ // { "kind": "question-resolved", "rpcId", "sessionId", "outcome" }
58
63
  import fs from 'node:fs'
59
64
  import fsp from 'node:fs/promises'
65
+ import http from 'node:http'
60
66
  import os from 'node:os'
61
67
  import path from 'node:path'
62
68
  import crypto from 'node:crypto'
@@ -67,6 +73,13 @@ import QRCode from 'qrcode'
67
73
 
68
74
  const { createRegistry } = devicesModule
69
75
 
76
+ const PLUGIN_VERSION = (() => {
77
+ try {
78
+ return JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version
79
+ } catch {
80
+ return 'unknown'
81
+ }
82
+ })()
70
83
  const MAX_PREVIEW = 400
71
84
  const LOG_FILE = '/tmp/mobile-gateway.log'
72
85
  const DEFAULT_WS_PATH = '/ws/mobile'
@@ -88,6 +101,10 @@ const Config = Schema.object({
88
101
  pairingTtlMs: Schema.natural().min(30_000).max(15 * 60 * 1000).default(DEFAULT_PAIRING_TTL_MS),
89
102
  allowQueryToken: Schema.boolean().default(false),
90
103
  publicUrlFile: Schema.string().default('/etc/dsh-mobile-gateway/public-url'),
104
+ lanEnabled: Schema.boolean().default(false),
105
+ lanHost: Schema.string().default('0.0.0.0'),
106
+ lanPort: Schema.natural().min(1).max(65535).default(3081),
107
+ lanAdvertiseHost: Schema.string().default(''),
91
108
  })
92
109
 
93
110
  function log(line) {
@@ -665,7 +682,7 @@ function extractClientDeviceId(req) {
665
682
  }
666
683
 
667
684
  function rejectUpgrade(socket, status, message) {
668
- const reason = status === 401 ? 'Unauthorized' : status === 503 ? 'Service Unavailable' : 'Bad Request'
685
+ const reason = status === 401 ? 'Unauthorized' : status === 403 ? 'Forbidden' : status === 404 ? 'Not Found' : status === 503 ? 'Service Unavailable' : 'Bad Request'
669
686
  socket.write(`HTTP/1.1 ${status} ${reason}\r\nConnection: close\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: ${Buffer.byteLength(message)}\r\n\r\n${message}`)
670
687
  socket.destroy()
671
688
  }
@@ -675,6 +692,47 @@ function isLocalHostname(hostname) {
675
692
  return normalized === 'localhost' || normalized === '::1' || /^127(?:\.\d{1,3}){3}$/.test(normalized)
676
693
  }
677
694
 
695
+ function isPrivateNetworkHostname(hostname) {
696
+ let normalized = hostname.replace(/^\[|\]$/g, '').split('%')[0].toLowerCase()
697
+ if (normalized.startsWith('::ffff:')) normalized = normalized.slice('::ffff:'.length)
698
+ if (isLocalHostname(normalized) || normalized.endsWith('.local')) return true
699
+ const octets = normalized.split('.').map((part) => Number(part))
700
+ if (octets.length === 4 && octets.every((part) => Number.isInteger(part) && part >= 0 && part <= 255)) {
701
+ return octets[0] === 10 ||
702
+ (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31) ||
703
+ (octets[0] === 192 && octets[1] === 168) ||
704
+ (octets[0] === 169 && octets[1] === 254)
705
+ }
706
+ return /^(?:f[cd][0-9a-f]{2}:|fe[89ab][0-9a-f]:)/i.test(normalized)
707
+ }
708
+
709
+ function privateLanAddresses() {
710
+ const physical = []
711
+ const fallback = []
712
+ const virtualInterface = /^(?:docker|br-|veth|utun|awdl|llw|vmnet|vbox|virbr|tailscale|wg)/i
713
+ for (const [name, records] of Object.entries(os.networkInterfaces())) {
714
+ for (const record of records || []) {
715
+ if (!record || record.internal || record.family !== 'IPv4' || !isPrivateNetworkHostname(record.address)) continue
716
+ const target = virtualInterface.test(name) ? fallback : physical
717
+ if (!target.includes(record.address)) target.push(record.address)
718
+ }
719
+ }
720
+ return physical.length ? physical : fallback
721
+ }
722
+
723
+ function lanWebSocketUrls(options, wsPath, boundPort) {
724
+ if (!options.lanEnabled) return []
725
+ const advertised = typeof options.lanAdvertiseHost === 'string' ? options.lanAdvertiseHost.trim() : ''
726
+ const hosts = advertised
727
+ ? [advertised]
728
+ : options.lanHost && options.lanHost !== '0.0.0.0' && options.lanHost !== '::'
729
+ ? [options.lanHost]
730
+ : privateLanAddresses()
731
+ return hosts
732
+ .filter((host) => isPrivateNetworkHostname(host))
733
+ .map((host) => `ws://${host.includes(':') ? `[${host}]` : host}:${boundPort}${wsPath}`)
734
+ }
735
+
678
736
  function badRequest(message) {
679
737
  const error = new Error(message)
680
738
  error.status = 400
@@ -690,9 +748,9 @@ function normalizePublicUrl(value, req, wsPath) {
690
748
  const url = new URL(raw)
691
749
  if (url.protocol === 'https:') url.protocol = 'wss:'
692
750
  if (url.protocol === 'http:') url.protocol = 'ws:'
693
- if (url.protocol !== 'ws:' && url.protocol !== 'wss:') throw badRequest('publicUrl must use wss:// (ws:// is allowed only for localhost)')
751
+ if (url.protocol !== 'ws:' && url.protocol !== 'wss:') throw badRequest('publicUrl must use wss:// (ws:// is allowed only for localhost and private LAN addresses)')
694
752
  if (url.username || url.password || url.search || url.hash) throw badRequest('publicUrl must not contain credentials, query parameters, or a fragment')
695
- if (url.protocol === 'ws:' && !isLocalHostname(url.hostname)) throw badRequest('publicUrl must use wss:// for non-localhost endpoints')
753
+ if (url.protocol === 'ws:' && !isPrivateNetworkHostname(url.hostname)) throw badRequest('publicUrl must use wss:// outside localhost or a private LAN')
696
754
  return url.toString()
697
755
  }
698
756
 
@@ -775,12 +833,22 @@ const plugin = {
775
833
  pairingTtlMs: DEFAULT_PAIRING_TTL_MS,
776
834
  allowQueryToken: false,
777
835
  publicUrlFile: '/etc/dsh-mobile-gateway/public-url',
836
+ lanEnabled: false,
837
+ lanHost: '0.0.0.0',
838
+ lanPort: 3081,
839
+ lanAdvertiseHost: '',
778
840
  ...(config || {}),
779
841
  }
780
842
  const wsPath = options.path
781
843
  if (typeof wsPath !== 'string' || !wsPath.startsWith('/') || wsPath.endsWith('/') || wsPath.includes('?') || wsPath.includes('#')) {
782
844
  throw new Error('mobile-gateway: path must be an absolute pathname without a trailing slash, query, or fragment')
783
845
  }
846
+ if (options.lanEnabled === true && (typeof options.lanHost !== 'string' || !options.lanHost.trim())) {
847
+ throw new Error('mobile-gateway: lanHost must be a non-empty listen address')
848
+ }
849
+ if (options.lanEnabled === true && options.lanAdvertiseHost && !isPrivateNetworkHostname(options.lanAdvertiseHost)) {
850
+ throw new Error('mobile-gateway: lanAdvertiseHost must be localhost or a private LAN address')
851
+ }
784
852
  let requireAuth = options.requireAuth !== false
785
853
  const adminLoopbackOnly = options.adminLoopbackOnly !== false
786
854
  const deviceFile = options.deviceFile || path.join(os.homedir(), '.dsh', 'mobile-gateway-devices.json')
@@ -795,6 +863,8 @@ const plugin = {
795
863
  },
796
864
  })
797
865
  const clients = new Set()
866
+ const pendingQuestions = new Map()
867
+ const questionMuxAbort = new AbortController()
798
868
  let counter = 0
799
869
  let gatewayEnabled = options.gatewayEnabled === true
800
870
  let waitExpiresAt = null
@@ -802,6 +872,10 @@ const plugin = {
802
872
  let connectedSinceEnabled = false
803
873
  let lastAuthRejectLogAt = 0
804
874
  let suppressedAuthRejects = 0
875
+ let lanServer = null
876
+ let lanListening = false
877
+ let lanListenError = null
878
+ let lanBoundPort = options.lanPort
805
879
  const configuredPublicUrl = () => {
806
880
  if (typeof options.publicUrl === 'string' && options.publicUrl.trim()) return options.publicUrl.trim()
807
881
  if (typeof options.publicUrlFile !== 'string' || !options.publicUrlFile.trim()) return ''
@@ -852,6 +926,72 @@ const plugin = {
852
926
  }
853
927
  }
854
928
 
929
+ const questionFrameFor = (rpcId, payload, replay = false) => ({
930
+ kind: 'question-requested',
931
+ rpcId: String(rpcId),
932
+ sessionId: String(payload.sessionId),
933
+ questions: payload.questions,
934
+ ...(replay ? { replay: true } : {}),
935
+ })
936
+
937
+ const broadcastQuestionFrame = (frame) => {
938
+ const wire = JSON.stringify(frame)
939
+ for (const client of clients) {
940
+ if (client.filterSessionId && client.filterSessionId !== frame.sessionId) continue
941
+ if (client.readyState === 1) client.send(wire)
942
+ }
943
+ }
944
+
945
+ const respondToQuestion = async (msg, cancel = false) => {
946
+ const rpcId = typeof msg.rpcId === 'string' && msg.rpcId.trim() ? msg.rpcId.trim() : null
947
+ const sessionId = typeof msg.sessionId === 'string' && msg.sessionId.trim() ? msg.sessionId.trim() : null
948
+ if (!rpcId || !sessionId) {
949
+ return { kind: 'error', code: 'bad-request', message: `${msg.type} requires rpcId and sessionId`, requestType: msg.type }
950
+ }
951
+
952
+ if (cancel) {
953
+ const pending = pendingQuestions.get(rpcId)
954
+ if (pending && pending.sessionId !== sessionId) {
955
+ return { kind: 'error', code: 'bad-request', message: 'sessionId does not match the pending question', requestType: msg.type, sessionId }
956
+ }
957
+ } else if (!Array.isArray(msg.answers)) {
958
+ return { kind: 'error', code: 'bad-request', message: 'question-answer requires an answers array', requestType: msg.type, sessionId }
959
+ }
960
+
961
+ const result = cancel
962
+ ? { ok: false, error: { code: 'cancelled', message: 'question cancelled by mobile user', details: {} } }
963
+ : {
964
+ ok: true,
965
+ value: {
966
+ sessionId,
967
+ answer: {
968
+ answers: msg.answers.map((answer) => ({
969
+ id: answer && answer.id,
970
+ selected: answer && answer.selected,
971
+ ...(answer && answer.custom !== undefined ? { custom: answer.custom } : {}),
972
+ })),
973
+ },
974
+ },
975
+ }
976
+
977
+ try {
978
+ const receipt = await api.respond({ type: 'client-response', rpcId, result })
979
+ log(`question response: rpcId=${rpcId} session=${sessionId} action=${cancel ? 'cancel' : 'answer'} accepted=${receipt.accepted}${receipt.accepted ? '' : ` reason=${receipt.reason}`}`)
980
+ return {
981
+ kind: 'question-response',
982
+ rpcId,
983
+ sessionId,
984
+ action: cancel ? 'cancel' : 'answer',
985
+ accepted: receipt.accepted,
986
+ ...(!receipt.accepted ? { reason: receipt.reason } : {}),
987
+ }
988
+ } catch (error) {
989
+ const message = error && error.message ? error.message : String(error)
990
+ log(`question response failed: rpcId=${rpcId} ${message}`)
991
+ return { kind: 'error', code: 'internal', message, requestType: msg.type, sessionId }
992
+ }
993
+ }
994
+
855
995
  if (gatewayEnabled) setGatewayEnabled(true, 'enabled by startup config')
856
996
 
857
997
  log(`applying: path=${wsPath}, webServer.port=${webServer.port}, gatewayEnabled=${gatewayEnabled}, requireAuth=${requireAuth}, devices=${registry.count()}`)
@@ -874,12 +1014,22 @@ const plugin = {
874
1014
  const p = url.pathname
875
1015
  if (req.method === 'GET' && p === '/mgw/status') {
876
1016
  sendJson(res, 200, {
1017
+ version: PLUGIN_VERSION,
877
1018
  requireAuth,
878
1019
  gatewayEnabled,
879
1020
  waitExpiresAt,
880
1021
  connectedClients: clients.size,
881
1022
  wsPath,
882
1023
  publicUrl: configuredPublicUrl() || null,
1024
+ lan: {
1025
+ enabled: options.lanEnabled === true,
1026
+ listening: lanListening,
1027
+ host: options.lanHost,
1028
+ port: lanBoundPort,
1029
+ urls: lanWebSocketUrls(options, wsPath, lanBoundPort),
1030
+ requireAuth: true,
1031
+ error: lanListenError,
1032
+ },
883
1033
  pairingTtlMs: options.pairingTtlMs,
884
1034
  queryTokenAllowed: !!options.allowQueryToken,
885
1035
  })
@@ -960,9 +1110,7 @@ const plugin = {
960
1110
  })
961
1111
  log(`management routes registered: /mgw (loopbackOnly=${adminLoopbackOnly})`)
962
1112
 
963
- const disposeUpgrade = webServer.registerUpgrade({
964
- path: wsPath,
965
- handler(req, socket, head) {
1113
+ const handleMobileUpgrade = (req, socket, head, transport = {}) => {
966
1114
  if (!gatewayEnabled) {
967
1115
  rejectUpgrade(socket, 503, 'mobile gateway is disabled')
968
1116
  return
@@ -988,7 +1136,11 @@ const plugin = {
988
1136
  device = registry.authenticate(credential.value, extractClientDeviceId(req))
989
1137
  }
990
1138
 
991
- if (requireAuth && !device) {
1139
+ // The separately exposed LAN listener is always authenticated. The
1140
+ // management UI's debug auth switch only affects the loopback DSH
1141
+ // listener and can never turn a LAN endpoint into an open control
1142
+ // plane.
1143
+ if ((requireAuth || transport.lan === true) && !device) {
992
1144
  logAuthRejected(req)
993
1145
  rejectUpgrade(socket, 401, 'missing or invalid device credential')
994
1146
  return
@@ -1026,6 +1178,10 @@ const plugin = {
1026
1178
  } else if (msg.type === 'unsubscribe') {
1027
1179
  ws.filterSessionId = undefined
1028
1180
  ws.send(JSON.stringify({ kind: 'subscribed', sessionId: null }))
1181
+ } else if (msg.type === 'question-answer' || msg.type === 'question-cancel') {
1182
+ respondToQuestion(msg, msg.type === 'question-cancel').then((frame) => {
1183
+ if (frame && ws.readyState === 1) ws.send(JSON.stringify(frame))
1184
+ })
1029
1185
  } else if (msg.type === 'message') {
1030
1186
  admitMessage(api, msg).then((frame) => {
1031
1187
  if (frame && ws.readyState === 1) ws.send(JSON.stringify(frame))
@@ -1062,16 +1218,67 @@ const plugin = {
1062
1218
  ws.send(JSON.stringify({
1063
1219
  kind: 'hello',
1064
1220
  protocol: 2,
1065
- port: webServer.port,
1221
+ port: transport.port || webServer.port,
1066
1222
  clients: clients.size,
1067
1223
  authenticated: !!device,
1068
1224
  ...(device ? { device: { id: device.id, name: device.name } } : {}),
1069
1225
  }))
1226
+ for (const pending of pendingQuestions.values()) {
1227
+ if (ws.filterSessionId && ws.filterSessionId !== pending.sessionId) continue
1228
+ ws.send(JSON.stringify(questionFrameFor(pending.rpcId, pending, true)))
1229
+ }
1070
1230
  })
1231
+ }
1232
+
1233
+ const disposeUpgrade = webServer.registerUpgrade({
1234
+ path: wsPath,
1235
+ handler(req, socket, head) {
1236
+ handleMobileUpgrade(req, socket, head, { port: webServer.port })
1071
1237
  },
1072
1238
  })
1073
1239
  log(`upgrade route registered: ${wsPath}`)
1074
1240
 
1241
+ // DSH intentionally keeps its own WebUI listener on loopback. When LAN
1242
+ // mode is enabled, expose a second, narrowly scoped listener that accepts
1243
+ // only the authenticated mobile WebSocket path. It does not serve the
1244
+ // WebUI or any /mgw management route.
1245
+ if (options.lanEnabled === true) {
1246
+ lanServer = http.createServer((req, res) => {
1247
+ sendJson(res, 404, { error: 'not-found' })
1248
+ })
1249
+ lanServer.on('upgrade', (req, socket, head) => {
1250
+ let pathname
1251
+ try {
1252
+ pathname = new URL(req.url || '/', 'http://lan.invalid').pathname
1253
+ } catch (error) {
1254
+ rejectUpgrade(socket, 400, 'invalid WebSocket path')
1255
+ return
1256
+ }
1257
+ if (pathname !== wsPath) {
1258
+ rejectUpgrade(socket, 404, 'not found')
1259
+ return
1260
+ }
1261
+ if (!req.socket || !req.socket.remoteAddress || !isPrivateNetworkHostname(req.socket.remoteAddress)) {
1262
+ rejectUpgrade(socket, 403, 'LAN listener accepts private-network clients only')
1263
+ return
1264
+ }
1265
+ handleMobileUpgrade(req, socket, head, { lan: true, port: lanBoundPort })
1266
+ })
1267
+ lanServer.on('error', (error) => {
1268
+ lanListening = false
1269
+ lanListenError = error && error.message ? error.message : String(error)
1270
+ log(`LAN listener failed: ${lanListenError}`)
1271
+ })
1272
+ lanServer.listen(options.lanPort, options.lanHost, () => {
1273
+ lanListening = true
1274
+ lanListenError = null
1275
+ const address = lanServer.address()
1276
+ if (address && typeof address === 'object') lanBoundPort = address.port
1277
+ const urls = lanWebSocketUrls(options, wsPath, lanBoundPort)
1278
+ log(`LAN listener ready: ${options.lanHost}:${lanBoundPort}${wsPath}${urls.length ? ` (${urls.join(', ')})` : ''}; authentication forced`)
1279
+ })
1280
+ }
1281
+
1075
1282
  const disposeEvents = ctx.on('session/event', (session, event) => {
1076
1283
  if (clients.size === 0) return
1077
1284
  const wire = buildWireEvent(session, event)
@@ -1084,11 +1291,55 @@ const plugin = {
1084
1291
  })
1085
1292
  log('session/event listener attached')
1086
1293
 
1294
+ const questionMuxTask = api.events && typeof api.events.mux === 'function' && typeof api.respond === 'function'
1295
+ ? (async () => {
1296
+ try {
1297
+ for await (const envelope of api.events.mux(
1298
+ { rpcId: crypto.randomUUID(), payload: {} },
1299
+ questionMuxAbort.signal,
1300
+ )) {
1301
+ const payload = envelope && envelope.payload
1302
+ if (!payload || typeof payload.type !== 'string') continue
1303
+ if (payload.type === 'question/requested') {
1304
+ const rpcId = String(envelope.rpcId)
1305
+ const pending = {
1306
+ rpcId,
1307
+ sessionId: String(payload.sessionId),
1308
+ questions: payload.questions,
1309
+ }
1310
+ pendingQuestions.set(rpcId, pending)
1311
+ broadcastQuestionFrame(questionFrameFor(rpcId, pending))
1312
+ log(`question requested: rpcId=${rpcId} session=${pending.sessionId} questions=${Array.isArray(pending.questions) ? pending.questions.length : 0}`)
1313
+ } else if (payload.type === 'question/resolved') {
1314
+ const questionRpcId = String(payload.questionRpcId)
1315
+ pendingQuestions.delete(questionRpcId)
1316
+ broadcastQuestionFrame({
1317
+ kind: 'question-resolved',
1318
+ rpcId: questionRpcId,
1319
+ sessionId: String(payload.sessionId),
1320
+ outcome: payload.outcome,
1321
+ })
1322
+ log(`question resolved: rpcId=${questionRpcId} session=${payload.sessionId} outcome=${payload.outcome}`)
1323
+ }
1324
+ }
1325
+ } catch (error) {
1326
+ if (!questionMuxAbort.signal.aborted) {
1327
+ log(`question mux failed: ${error && error.message ? error.message : String(error)}`)
1328
+ }
1329
+ }
1330
+ })()
1331
+ : null
1332
+ if (questionMuxTask) log('api gateway question mux attached')
1333
+ else log('api gateway question mux unavailable; human-in-the-loop disabled')
1334
+
1087
1335
  ctx.effect(() => () => {
1088
1336
  if (waitTimer) clearTimeout(waitTimer)
1337
+ questionMuxAbort.abort()
1338
+ pendingQuestions.clear()
1089
1339
  disposeUpgrade()
1090
1340
  disposeMgmt()
1091
1341
  disposeEvents()
1342
+ if (lanServer) lanServer.close()
1092
1343
  for (const client of clients) client.terminate()
1093
1344
  clients.clear()
1094
1345
  wss.close()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-mobile-gateway",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "Persistent WebSocket gateway for dsh: exposes /ws/mobile on the web server and forwards agent session output to connected mobile clients",
5
5
  "main": "lib/index.mjs",
6
6
  "files": [
@@ -25,7 +25,7 @@
25
25
  },
26
26
  "bin": "bin/setup-ip.mjs",
27
27
  "scripts": {
28
- "test": "node test/setup-ip.test.mjs && node test/gateway.test.mjs && node test/auth.test.mjs"
28
+ "test": "node test/setup-ip.test.mjs && node test/gateway.test.mjs && node test/auth.test.mjs && node test/lan.test.mjs"
29
29
  },
30
30
  "exports": {
31
31
  ".": "./lib/index.mjs",