dsh-apk-vm 0.5.0 → 0.6.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/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.0
4
+
5
+ Network controls.
6
+
7
+ ### Added
8
+
9
+ - Port forwarding management (`forward-add` / `forward-remove`) over adb, with a host-port →
10
+ guest-port list and optional `shared` rules.
11
+ - Custom DNS servers via the emulator `-dns-server` launch flag (restart required) and guest
12
+ `/etc/hosts` injection through a root bind mount, reapplied after boot.
13
+ - Proxy connectivity test from inside the guest (`ping` + toybox `nc`), and current proxy
14
+ display in the Network card.
15
+ - Per-instance network mode:
16
+ - `isolated` (default QEMU-NAT isolation)
17
+ - `shared` (host-mediated TCP sharing through shared port forwards)
18
+ - Optional emulator pcap capture (`-tcpdump`) with `GET /apk-vm/network/pcap` download.
19
+ - Network card UI for forwards, DNS, hosts, mode, test, and pcap; `apk_vm` action `network`.
20
+ - Per-instance network route: `/apk-vm/instances/<id>/network`.
21
+
22
+ ### Notes
23
+
24
+ - Shared mode is host-mediated TCP sharing, not L2 bridging; instances keep their own QEMU NAT.
25
+ - DNS changes take effect on the next emulator start; hosts changes apply live and survive a
26
+ restart while the plugin keeps the configured entries.
27
+ - pcap export is opt-in and only writes traffic from the emulator's user-mode network.
28
+
3
29
  ## 0.5.0
4
30
 
5
31
  Storage management.
package/README.md CHANGED
@@ -24,7 +24,7 @@ same-origin, loopback-only debug HTTP API and an `apk_vm` Agent tool.
24
24
  > 当前 **Windows x64 与 Linux x86_64(headless)为端到端已验证平台**;macOS 仅做探测/CI
25
25
  > 覆盖,详见 `docs/LINUX-E2E.md`。
26
26
 
27
- **Status:** `0.5.0` · Apache-2.0 · no npm runtime dependencies · no build step.
27
+ **Status:** `0.6.0` · Apache-2.0 · no npm runtime dependencies · no build step.
28
28
 
29
29
  This is an open-source DSH plugin published on npm as **`dsh-apk-vm`**. Repository:
30
30
  [github.com/yuxiaole-bili/dsh-apk-vm](https://github.com/yuxiaole-bili/dsh-apk-vm).
@@ -170,10 +170,11 @@ key {"text":"HOME"} -> returned to launcher
170
170
  then save the tap / swipe / key / text sequence as a JSON flow under the OS temp directory.
171
171
  Flows can be listed, replayed with their original relative timing, downloaded or deleted.
172
172
  Agents get `flow_start`, `flow_stop`, `flow_list` and `flow_replay`.
173
- - **Network debugging: proxy + CA.** The Network card sets/clears the guest global HTTP proxy
174
- (`settings put global http_proxy`) and installs a local `.pem`/`.crt` CA into the rooted
175
- Android user CA store with the correct OpenSSL subject-hash filename; the system CA store is
176
- updated when the guest allows a remount.
173
+ - **Network debugging and port forwarding.** The Network card manages host→guest port
174
+ forwards, custom DNS (next emulator start), guest `/etc/hosts` via a root bind mount,
175
+ HTTP proxy + CA installation, isolated/shared instance modes, and a guest-side connectivity
176
+ test. Optional pcap capture can be downloaded from the panel. See
177
+ [Network controls](#network-controls).
177
178
  - **Storage panel + guarded cleanup.** A Storage card reports the AVD, snapshot, SDK, temp-file
178
179
  and free-disk footprint and offers explicit cleanups: plugin temp files, quick-boot
179
180
  snapshots for a stopped AVD, and orphan `dsh_apk_*` AVDs. Destructive actions need a second
@@ -263,7 +264,30 @@ card uses the same endpoint.
263
264
  not active.
264
265
  - `apk_vm` tool actions: `storage`, `clean_storage` (`storageAction`, `avdName`, `confirm`).
265
266
 
266
- ## Reproducible demo
267
+ ## Network controls
268
+
269
+ `GET /apk-vm/network` returns the guest addresses, current proxy, DNS, hosts count, mode,
270
+ port forwards and pcap status. `POST /apk-vm/network` accepts `action`:
271
+
272
+ ```json
273
+ { "action": "mode", "mode": "isolated|shared" }
274
+ { "action": "forward-add", "localPort": 18080, "remotePort": 8080, "shared": false }
275
+ { "action": "forward-remove", "localPort": 18080 }
276
+ { "action": "dns", "servers": ["1.1.1.1"] }
277
+ { "action": "hosts", "entries": ["198.51.100.10 api.example.test"] }
278
+ { "action": "test", "host": "198.51.100.10", "port": 8080 }
279
+ { "action": "pcap", "path": "D:\\apk-vm-capture.pcap" }
280
+ ```
281
+
282
+ - Port forwards use `adb forward tcp:<hostPort> tcp:<guestPort>`.
283
+ - DNS is applied by the emulator `-dns-server` flag on the next start.
284
+ - Hosts entries are bind-mounted over the guest `/etc/hosts` (root required) and reapplied
285
+ after a plugin-managed boot.
286
+ - `shared` forwards are reachable from other VMs through the emulator's host alias; the
287
+ QEMU NAT itself remains isolated.
288
+ - pcap capture is passed to the emulator as `-tcpdump` and starts on the next emulator start;
289
+ download via `GET /apk-vm/network/pcap`.
290
+
267
291
 
268
292
  A public, dependency-free demo lives in [examples](examples/README.md):
269
293
 
package/client.js CHANGED
@@ -189,6 +189,25 @@ window.__ModuleLoader__.load({
189
189
  'network.caPath': 'CA certificate path (.pem/.crt)',
190
190
  'network.installCa': 'Install CA',
191
191
  'network.current': 'Current: {value}',
192
+ 'network.forwards': 'Port forwards',
193
+ 'network.forwardLocal': 'Host port',
194
+ 'network.forwardRemote': 'Guest port',
195
+ 'network.addForward': 'Add forward',
196
+ 'network.remove': 'Remove',
197
+ 'network.mode': 'Mode',
198
+ 'network.isolated': 'Isolated',
199
+ 'network.shared': 'Shared (host bridge)',
200
+ 'network.dns': 'DNS servers',
201
+ 'network.applyDns': 'Apply DNS',
202
+ 'network.hosts': 'Hosts entries',
203
+ 'network.applyHosts': 'Apply hosts',
204
+ 'network.test': 'Test connectivity',
205
+ 'network.testOk': 'Connectivity OK',
206
+ 'network.testFail': 'Connectivity check failed',
207
+ 'network.pcap': 'Download pcap',
208
+ 'network.restartRequired': 'Restart the VM to apply custom DNS',
209
+ 'network.sharedHint': 'Shared forwards are reachable from other VMs through the emulator host alias.',
210
+ 'network.guestUrl': 'Guest URL: {url}',
192
211
  'flow.title': 'Flow recorder',
193
212
  'flow.start': 'Record',
194
213
  'flow.stop': 'Stop',
@@ -402,6 +421,25 @@ window.__ModuleLoader__.load({
402
421
  'network.caPath': 'CA 证书路径(.pem/.crt)',
403
422
  'network.installCa': '安装 CA',
404
423
  'network.current': '当前:{value}',
424
+ 'network.forwards': '端口转发',
425
+ 'network.forwardLocal': '宿主机端口',
426
+ 'network.forwardRemote': 'Guest 端口',
427
+ 'network.addForward': '添加转发',
428
+ 'network.remove': '删除',
429
+ 'network.mode': '模式',
430
+ 'network.isolated': '隔离',
431
+ 'network.shared': '共享(宿主桥接)',
432
+ 'network.dns': 'DNS 服务器',
433
+ 'network.applyDns': '应用 DNS',
434
+ 'network.hosts': 'Hosts 条目',
435
+ 'network.applyHosts': '应用 Hosts',
436
+ 'network.test': '测试连通性',
437
+ 'network.testOk': '连通性正常',
438
+ 'network.testFail': '连通性检查失败',
439
+ 'network.pcap': '下载 pcap',
440
+ 'network.restartRequired': '重启虚拟机后生效自定义 DNS',
441
+ 'network.sharedHint': '共享转发可被其他 VM 通过模拟器宿主别名访问。',
442
+ 'network.guestUrl': 'Guest URL: {url}',
405
443
  'flow.title': '操作录制',
406
444
  'flow.start': '录制',
407
445
  'flow.stop': '停止',
@@ -1259,6 +1297,27 @@ window.__ModuleLoader__.load({
1259
1297
  var caPathState = React.useState('');
1260
1298
  var caPath = caPathState[0];
1261
1299
  var setCaPath = caPathState[1];
1300
+ var networkState = React.useState(null);
1301
+ var network = networkState[0];
1302
+ var setNetwork = networkState[1];
1303
+ var networkBusyState = React.useState('');
1304
+ var networkBusy = networkBusyState[0];
1305
+ var setNetworkBusy = networkBusyState[1];
1306
+ var forwardLocalState = React.useState('');
1307
+ var forwardLocal = forwardLocalState[0];
1308
+ var setForwardLocal = forwardLocalState[1];
1309
+ var forwardRemoteState = React.useState('');
1310
+ var forwardRemote = forwardRemoteState[0];
1311
+ var setForwardRemote = forwardRemoteState[1];
1312
+ var forwardSharedState = React.useState(false);
1313
+ var forwardShared = forwardSharedState[0];
1314
+ var setForwardShared = forwardSharedState[1];
1315
+ var dnsTextState = React.useState('');
1316
+ var dnsText = dnsTextState[0];
1317
+ var setDnsText = dnsTextState[1];
1318
+ var hostsTextState = React.useState('');
1319
+ var hostsText = hostsTextState[0];
1320
+ var setHostsText = hostsTextState[1];
1262
1321
 
1263
1322
  var actualFpsState = React.useState(0);
1264
1323
  var actualFps = actualFpsState[0];
@@ -1436,6 +1495,7 @@ window.__ModuleLoader__.load({
1436
1495
  loadDevices();
1437
1496
  loadFlows();
1438
1497
  loadStorage();
1498
+ loadNetwork();
1439
1499
  var pollId = setInterval(function () {
1440
1500
  if (stateBusyRef.current) return;
1441
1501
  stateBusyRef.current = true;
@@ -1457,6 +1517,12 @@ window.__ModuleLoader__.load({
1457
1517
  };
1458
1518
  }, []);
1459
1519
 
1520
+ // Load network details once the VM becomes ready (the first render often
1521
+ // happens while the state is still loading).
1522
+ React.useEffect(function () {
1523
+ if (ready) loadNetwork();
1524
+ }, [ready, activeInstance]);
1525
+
1460
1526
  // Track document visibility so the frame stream pauses when hidden.
1461
1527
  React.useEffect(function () {
1462
1528
  if (typeof document === 'undefined' || typeof document.addEventListener !== 'function') {
@@ -1833,6 +1899,8 @@ window.__ModuleLoader__.load({
1833
1899
  setInspectNodes([]);
1834
1900
  setInspectSelected(null);
1835
1901
  loadFlows();
1902
+ setNetwork(null);
1903
+ loadNetwork();
1836
1904
  stopLiveLogcat();
1837
1905
  frameStatsRef.current = [];
1838
1906
  loadState();
@@ -2166,6 +2234,98 @@ window.__ModuleLoader__.load({
2166
2234
  });
2167
2235
  }
2168
2236
 
2237
+ function loadNetwork() {
2238
+ if (!ready) return Promise.resolve(null);
2239
+ setNetworkBusy('load');
2240
+ return requestJson(instancePath('/network'), null, T).then(function (result) {
2241
+ if (!mountedRef.current) return result;
2242
+ setNetworkBusy('');
2243
+ if (result.ok && isObject(result.data)) {
2244
+ setNetwork(result.data);
2245
+ if (result.data.proxy && typeof result.data.proxy === 'string') {
2246
+ var colon = result.data.proxy.lastIndexOf(':');
2247
+ if (colon > 0) {
2248
+ setProxy({ host: result.data.proxy.slice(0, colon), port: result.data.proxy.slice(colon + 1) });
2249
+ }
2250
+ }
2251
+ if (result.data.dnsServers && result.data.dnsServers.length) {
2252
+ setDnsText(result.data.dnsServers.join(', '));
2253
+ }
2254
+ }
2255
+ return result;
2256
+ });
2257
+ }
2258
+
2259
+ function networkAction(action, payload) {
2260
+ if (networkBusy) return;
2261
+ setNetworkBusy(action);
2262
+ return requestJson(
2263
+ instancePath('/network'),
2264
+ { method: 'POST', body: JSON.stringify(Object.assign({ action: action }, payload || {})) },
2265
+ T
2266
+ ).then(function (result) {
2267
+ if (!mountedRef.current) return result;
2268
+ setNetworkBusy('');
2269
+ if (!result.ok) {
2270
+ pushToast('error', T('err.action') + ': ' + result.error);
2271
+ return result;
2272
+ }
2273
+ if (action === 'test') {
2274
+ pushToast(result.data && result.data.ok === true ? 'success' : 'error', T(result.data && result.data.ok ? 'network.testOk' : 'network.testFail'));
2275
+ } else if (action === 'dns' && result.data && result.data.restartRequired) {
2276
+ pushToast('success', T('network.restartRequired'));
2277
+ } else {
2278
+ pushToast('success', T('network.title'));
2279
+ }
2280
+ loadNetwork();
2281
+ return result;
2282
+ });
2283
+ }
2284
+
2285
+ function addPortForward() {
2286
+ var local = Number(forwardLocal);
2287
+ var remote = Number(forwardRemote);
2288
+ if (!Number.isInteger(local) || local < 1 || local > 65535 || !Number.isInteger(remote) || remote < 1 || remote > 65535) {
2289
+ pushToast('error', T('err.action'));
2290
+ return;
2291
+ }
2292
+ networkAction('forward-add', {
2293
+ localPort: local,
2294
+ remotePort: remote,
2295
+ shared: forwardShared === true,
2296
+ }).then(function (result) {
2297
+ if (result && result.ok) {
2298
+ setForwardLocal('');
2299
+ setForwardRemote('');
2300
+ setForwardShared(false);
2301
+ }
2302
+ });
2303
+ }
2304
+
2305
+ function removePortForward(localPort) {
2306
+ networkAction('forward-remove', { localPort: localPort });
2307
+ }
2308
+
2309
+ function applyDns() {
2310
+ var servers = String(dnsText || '')
2311
+ .split(',')
2312
+ .map(function (item) {
2313
+ return item.trim();
2314
+ })
2315
+ .filter(Boolean);
2316
+ networkAction('dns', { servers: servers });
2317
+ }
2318
+
2319
+ function applyHosts() {
2320
+ var entries = String(hostsText || '')
2321
+ .split(/\r?\n/)
2322
+ .map(function (line) {
2323
+ return line.trim();
2324
+ })
2325
+ .filter(Boolean);
2326
+ networkAction('hosts', { entries: entries });
2327
+ }
2328
+
2169
2329
  function applyProxy() {
2170
2330
  if (proxyBusy) return;
2171
2331
  setProxyBusy(true);
@@ -2181,6 +2341,7 @@ window.__ModuleLoader__.load({
2181
2341
  return;
2182
2342
  }
2183
2343
  pushToast('success', T('network.current', { value: (result.data && result.data.proxy) || 'off' }));
2344
+ loadNetwork();
2184
2345
  });
2185
2346
  }
2186
2347
 
@@ -2195,6 +2356,7 @@ window.__ModuleLoader__.load({
2195
2356
  return;
2196
2357
  }
2197
2358
  pushToast('success', T('network.clear'));
2359
+ loadNetwork();
2198
2360
  });
2199
2361
  }
2200
2362
 
@@ -3915,7 +4077,167 @@ window.__ModuleLoader__.load({
3915
4077
  onClick: installCa,
3916
4078
  })
3917
4079
  ),
3918
- h('div', { className: 'apkvm-dim' }, T('network.caPath'))
4080
+ h('div', { className: 'apkvm-dim' }, T('network.caPath')),
4081
+ h(
4082
+ 'label',
4083
+ { style: { display: 'flex', alignItems: 'center', gap: '6px', fontSize: '11px', color: 'var(--dsw-alias-label-secondary)' } },
4084
+ h('span', null, T('network.mode')),
4085
+ h(
4086
+ 'select',
4087
+ {
4088
+ value: (network && network.mode) || 'isolated',
4089
+ disabled: Boolean(networkBusy) || !ready,
4090
+ onChange: function (event) {
4091
+ networkAction('mode', { mode: event && event.target ? String(event.target.value) : 'isolated' });
4092
+ },
4093
+ style: {
4094
+ font: 'inherit',
4095
+ background: 'var(--dsw-alias-bg-layer-1)',
4096
+ color: 'var(--dsw-alias-label-primary)',
4097
+ border: '1px solid var(--dsw-alias-border-l2)',
4098
+ borderRadius: '6px',
4099
+ padding: '1px 4px',
4100
+ },
4101
+ },
4102
+ h('option', { value: 'isolated' }, T('network.isolated')),
4103
+ h('option', { value: 'shared' }, T('network.shared'))
4104
+ )
4105
+ ),
4106
+ h(
4107
+ 'div',
4108
+ { style: { display: 'flex', alignItems: 'center', gap: '5px', flexWrap: 'wrap' } },
4109
+ h('span', { className: 'apkvm-card-title' }, T('network.forwards')),
4110
+ actionBtn(T('network.test'), {
4111
+ key: 'network-test',
4112
+ small: true,
4113
+ disabled: !ready || Boolean(networkBusy),
4114
+ onClick: function () {
4115
+ networkAction('test', { host: proxy.host || '', port: proxy.port || undefined });
4116
+ },
4117
+ })
4118
+ ),
4119
+ network && Array.isArray(network.forwards) && network.forwards.length
4120
+ ? network.forwards.map(function (rule) {
4121
+ return h(
4122
+ 'div',
4123
+ { key: 'forward-' + rule.localPort, className: 'apkvm-dim', style: { display: 'flex', alignItems: 'center', gap: '5px' } },
4124
+ h('span', { style: { flex: '1 1 auto' } }, 'tcp:' + rule.localPort + ' -> guest tcp:' + rule.remotePort),
4125
+ rule.shared ? h('span', { className: 'apkvm-tag ok' }, 'shared') : null,
4126
+ actionBtn(T('network.remove'), {
4127
+ key: 'forward-remove-' + rule.localPort,
4128
+ small: true,
4129
+ variant: 'danger',
4130
+ disabled: Boolean(networkBusy),
4131
+ onClick: function () {
4132
+ removePortForward(rule.localPort);
4133
+ },
4134
+ })
4135
+ );
4136
+ })
4137
+ : h('div', { className: 'apkvm-dim' }, T('network.forwards')),
4138
+ h(
4139
+ 'div',
4140
+ { style: { display: 'flex', alignItems: 'center', gap: '5px' } },
4141
+ h('input', {
4142
+ className: 'apkvm-input',
4143
+ type: 'text',
4144
+ value: forwardLocal,
4145
+ placeholder: T('network.forwardLocal'),
4146
+ spellCheck: false,
4147
+ disabled: Boolean(networkBusy),
4148
+ onChange: function (event) {
4149
+ setForwardLocal(event && event.target ? String(event.target.value) : '');
4150
+ },
4151
+ 'aria-label': T('network.forwardLocal'),
4152
+ }),
4153
+ h('input', {
4154
+ className: 'apkvm-input',
4155
+ type: 'text',
4156
+ value: forwardRemote,
4157
+ placeholder: T('network.forwardRemote'),
4158
+ spellCheck: false,
4159
+ disabled: Boolean(networkBusy),
4160
+ onChange: function (event) {
4161
+ setForwardRemote(event && event.target ? String(event.target.value) : '');
4162
+ },
4163
+ 'aria-label': T('network.forwardRemote'),
4164
+ }),
4165
+ actionBtn(T('network.addForward'), {
4166
+ key: 'forward-add',
4167
+ small: true,
4168
+ disabled: !ready || Boolean(networkBusy) || !forwardLocal.trim() || !forwardRemote.trim(),
4169
+ onClick: addPortForward,
4170
+ })
4171
+ ),
4172
+ h(
4173
+ 'label',
4174
+ { style: { display: 'flex', alignItems: 'center', gap: '5px', fontSize: '11px', color: 'var(--dsw-alias-label-secondary)' } },
4175
+ h('input', {
4176
+ type: 'checkbox',
4177
+ checked: forwardShared,
4178
+ disabled: Boolean(networkBusy),
4179
+ onChange: function (event) {
4180
+ setForwardShared(Boolean(event && event.target && event.target.checked));
4181
+ },
4182
+ }),
4183
+ h('span', null, T('network.shared'))
4184
+ ),
4185
+ h(
4186
+ 'div',
4187
+ { style: { display: 'flex', alignItems: 'center', gap: '5px' } },
4188
+ h('input', {
4189
+ className: 'apkvm-input',
4190
+ type: 'text',
4191
+ value: dnsText,
4192
+ placeholder: T('network.dns'),
4193
+ spellCheck: false,
4194
+ disabled: Boolean(networkBusy),
4195
+ onChange: function (event) {
4196
+ setDnsText(event && event.target ? String(event.target.value) : '');
4197
+ },
4198
+ 'aria-label': T('network.dns'),
4199
+ }),
4200
+ actionBtn(T('network.applyDns'), {
4201
+ key: 'dns-apply',
4202
+ small: true,
4203
+ disabled: !ready || Boolean(networkBusy),
4204
+ onClick: applyDns,
4205
+ })
4206
+ ),
4207
+ h('textarea', {
4208
+ className: 'apkvm-input',
4209
+ value: hostsText,
4210
+ placeholder: T('network.hosts'),
4211
+ spellCheck: false,
4212
+ disabled: Boolean(networkBusy),
4213
+ style: { minHeight: '46px', height: '46px', resize: 'vertical', padding: '4px 8px' },
4214
+ onChange: function (event) {
4215
+ setHostsText(event && event.target ? String(event.target.value) : '');
4216
+ },
4217
+ 'aria-label': T('network.hosts'),
4218
+ }),
4219
+ h(
4220
+ 'div',
4221
+ { style: { display: 'flex', alignItems: 'center', gap: '5px', flexWrap: 'wrap' } },
4222
+ actionBtn(T('network.applyHosts'), {
4223
+ key: 'hosts-apply',
4224
+ small: true,
4225
+ disabled: !ready || Boolean(networkBusy),
4226
+ onClick: applyHosts,
4227
+ }),
4228
+ network && network.pcap && network.pcap.enabled
4229
+ ? h(
4230
+ 'a',
4231
+ {
4232
+ href: instancePath('/network/pcap'),
4233
+ download: 'capture.pcap',
4234
+ style: { fontSize: '11px', color: 'var(--dsw-alias-brand-primary)' },
4235
+ },
4236
+ T('network.pcap')
4237
+ )
4238
+ : null
4239
+ ),
4240
+ network && network.mode === 'shared' ? h('div', { className: 'apkvm-dim' }, T('network.sharedHint')) : null
3919
4241
  )
3920
4242
  );
3921
4243
 
package/index.js CHANGED
@@ -245,6 +245,9 @@ export function apply(ctx, config = {}) {
245
245
  quickBoot: cfg.quickBoot !== false,
246
246
  snapshotName: cfg.snapshotName || undefined,
247
247
  saveSnapshotAfterBoot: cfg.saveSnapshotAfterBoot !== false,
248
+ dnsServers: Array.isArray(cfg.dnsServers) ? cfg.dnsServers.map(String) : undefined,
249
+ networkMode: cfg.networkMode || undefined,
250
+ tcpdumpPath: cfg.tcpdumpPath || undefined,
248
251
  });
249
252
 
250
253
  const instanceManager =
@@ -264,6 +267,9 @@ export function apply(ctx, config = {}) {
264
267
  scrcpyBitrate: cfg.scrcpyBitrate || 6_000_000,
265
268
  scrcpyMaxFps: cfg.scrcpyMaxFps || 60,
266
269
  idleTimeoutMs: cfg.instanceIdleMs || 15 * 60 * 1000,
270
+ dnsServers: Array.isArray(cfg.dnsServers) ? cfg.dnsServers.map(String) : undefined,
271
+ networkMode: cfg.networkMode || undefined,
272
+ tcpdumpPath: cfg.tcpdumpPath || undefined,
267
273
  log: (line) => vm._pushLog(line),
268
274
  });
269
275
 
@@ -1218,6 +1224,55 @@ export function apply(ctx, config = {}) {
1218
1224
  });
1219
1225
  return;
1220
1226
  }
1227
+ if (rest === 'network') {
1228
+ if (!(await ready())) return;
1229
+ if (req.method === 'GET') {
1230
+ sendJson(res, 200, await instance.vm.networkInfo());
1231
+ return;
1232
+ }
1233
+ if (req.method !== 'POST') {
1234
+ sendError(res, 405, 'GET or POST only');
1235
+ return;
1236
+ }
1237
+ const body = await readJsonBody(req);
1238
+ const action = String(body.action || '').toLowerCase();
1239
+ try {
1240
+ let result;
1241
+ if (action === 'mode') result = instance.vm.setNetworkMode(body.mode);
1242
+ else if (action === 'dns') result = await instance.vm.setDnsServers(body.servers);
1243
+ else if (action === 'hosts') result = await instance.vm.setHosts(body.entries);
1244
+ else if (action === 'test') result = await instance.vm.testNetwork(body);
1245
+ else if (action === 'pcap') result = instance.vm.setTcpdumpPath(body.path);
1246
+ else if (action === 'forward-add') result = await instance.vm.addPortForward(body.localPort, body.remotePort, { shared: body.shared === true });
1247
+ else if (action === 'forward-remove') result = await instance.vm.removePortForward(body.localPort);
1248
+ else throw new Error(`unsupported network action: ${action || '(missing)'}`);
1249
+ sendJson(res, 200, result);
1250
+ } catch (error) {
1251
+ sendError(res, 400, error.message);
1252
+ }
1253
+ return;
1254
+ }
1255
+ if (rest === 'network/pcap') {
1256
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
1257
+ sendError(res, 405, 'GET or HEAD only');
1258
+ return;
1259
+ }
1260
+ const tcpdumpPath = instance.vm.tcpdumpPath;
1261
+ if (!tcpdumpPath || !existsSync(tcpdumpPath)) {
1262
+ sendError(res, 404, 'pcap capture is not configured or has no file yet');
1263
+ return;
1264
+ }
1265
+ const stat = await fsp.stat(tcpdumpPath);
1266
+ res.writeHead(200, {
1267
+ 'Content-Type': 'application/vnd.tcpdump.pcap',
1268
+ 'Content-Length': stat.size,
1269
+ 'Cache-Control': 'no-store',
1270
+ 'Content-Disposition': `attachment; filename="${path.basename(tcpdumpPath)}"`,
1271
+ });
1272
+ if (req.method === 'HEAD') res.end();
1273
+ else createReadStream(tcpdumpPath).pipe(res);
1274
+ return;
1275
+ }
1221
1276
  if (rest === 'touch' && req.method === 'POST') {
1222
1277
  const body = await readJsonBody(req);
1223
1278
  const action = String(body.action || '').toLowerCase();
@@ -1452,6 +1507,8 @@ export function apply(ctx, config = {}) {
1452
1507
  'debug/pull',
1453
1508
  'debug/reboot',
1454
1509
  'flows',
1510
+ 'network',
1511
+ 'network/pcap',
1455
1512
  ];
1456
1513
  const disposers = [];
1457
1514
  for (const suffix of suffixes) {
@@ -2086,6 +2143,59 @@ export function apply(ctx, config = {}) {
2086
2143
  }
2087
2144
  });
2088
2145
 
2146
+ register('/apk-vm/network', async (req, res) => {
2147
+ if (req.method === 'GET') {
2148
+ if (!(await ensureDebugReady(res))) return;
2149
+ try {
2150
+ sendJson(res, 200, await vm.networkInfo());
2151
+ } catch (error) {
2152
+ sendError(res, 400, error.message);
2153
+ }
2154
+ return;
2155
+ }
2156
+ if (req.method !== 'POST') {
2157
+ sendError(res, 405, 'GET or POST only');
2158
+ return;
2159
+ }
2160
+ if (!(await ensureDebugReady(res))) return;
2161
+ const body = await readJsonBody(req);
2162
+ const action = String(body.action || '').toLowerCase();
2163
+ try {
2164
+ let result;
2165
+ if (action === 'mode') result = vm.setNetworkMode(body.mode);
2166
+ else if (action === 'dns') result = await vm.setDnsServers(body.servers);
2167
+ else if (action === 'hosts') result = await vm.setHosts(body.entries);
2168
+ else if (action === 'test') result = await vm.testNetwork(body);
2169
+ else if (action === 'pcap') result = vm.setTcpdumpPath(body.path);
2170
+ else if (action === 'forward-add') result = await vm.addPortForward(body.localPort, body.remotePort, { shared: body.shared === true });
2171
+ else if (action === 'forward-remove') result = await vm.removePortForward(body.localPort);
2172
+ else throw new Error(`unsupported network action: ${action || '(missing)'}`);
2173
+ sendJson(res, 200, result);
2174
+ } catch (error) {
2175
+ sendError(res, 400, error.message);
2176
+ }
2177
+ });
2178
+
2179
+ register('/apk-vm/network/pcap', async (req, res) => {
2180
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
2181
+ sendError(res, 405, 'GET or HEAD only');
2182
+ return;
2183
+ }
2184
+ if (!vm.tcpdumpPath || !existsSync(vm.tcpdumpPath)) {
2185
+ sendError(res, 404, 'pcap capture is not configured or has no file yet');
2186
+ return;
2187
+ }
2188
+ const stat = await fsp.stat(vm.tcpdumpPath);
2189
+ res.writeHead(200, {
2190
+ 'Content-Type': 'application/vnd.tcpdump.pcap',
2191
+ 'Content-Length': stat.size,
2192
+ 'Cache-Control': 'no-store',
2193
+ 'Content-Disposition': `attachment; filename="${path.basename(vm.tcpdumpPath)}"`,
2194
+ });
2195
+ if (req.method === 'HEAD') res.end();
2196
+ else createReadStream(vm.tcpdumpPath).pipe(res);
2197
+ });
2198
+
2089
2199
  register('/apk-vm/debug/record', async (req, res) => {
2090
2200
  if (req.method !== 'POST') {
2091
2201
  sendError(res, 405, 'POST only');
@@ -2293,6 +2403,14 @@ export function apply(ctx, config = {}) {
2293
2403
  getProxy: () => vm.getProxy(),
2294
2404
  setProxy: (options) => vm.setProxy(options),
2295
2405
  installCaCertificate: (certPath) => vm.installCaCertificate(certPath),
2406
+ networkInfo: () => vm.networkInfo(),
2407
+ setNetworkMode: (mode) => vm.setNetworkMode(mode),
2408
+ setTcpdumpPath: (filePath) => vm.setTcpdumpPath(filePath),
2409
+ setDnsServers: (servers) => vm.setDnsServers(servers),
2410
+ setHosts: (entries) => vm.setHosts(entries),
2411
+ testNetwork: (options) => vm.testNetwork(options),
2412
+ addPortForward: (local, remote, options) => vm.addPortForward(local, remote, options),
2413
+ removePortForward: (local) => vm.removePortForward(local),
2296
2414
  screenRecord: (options) => vm.screenRecord(options),
2297
2415
  perfSnapshot: () => vm.perfSnapshot(),
2298
2416
  screenshot: async () => {
@@ -2337,6 +2455,14 @@ export function apply(ctx, config = {}) {
2337
2455
  getProxy: () => instance.vm.getProxy(),
2338
2456
  setProxy: (options) => instance.vm.setProxy(options),
2339
2457
  installCaCertificate: (certPath) => instance.vm.installCaCertificate(certPath),
2458
+ networkInfo: () => instance.vm.networkInfo(),
2459
+ setNetworkMode: (mode) => instance.vm.setNetworkMode(mode),
2460
+ setTcpdumpPath: (filePath) => instance.vm.setTcpdumpPath(filePath),
2461
+ setDnsServers: (servers) => instance.vm.setDnsServers(servers),
2462
+ setHosts: (entries) => instance.vm.setHosts(entries),
2463
+ testNetwork: (options) => instance.vm.testNetwork(options),
2464
+ addPortForward: (local, remote, options) => instance.vm.addPortForward(local, remote, options),
2465
+ removePortForward: (local) => instance.vm.removePortForward(local),
2340
2466
  screenRecord: (options) => instance.screenRecord(options),
2341
2467
  perfSnapshot: () => instance.perfSnapshot(),
2342
2468
  screenshot: async () => {
@@ -2438,7 +2564,7 @@ export function apply(ctx, config = {}) {
2438
2564
  'Operate the local rooted Android APK virtual machine (Android emulator) that the APK VM panel manages. ' +
2439
2565
  'The VM is usually already running (warm start); adbd runs as root once ready. ' +
2440
2566
  'Actions: info, shell, logcat, getprop, setprop, packages, install, push, pull, reboot, root, ' +
2441
- 'force_stop, clear_data, uninstall, rotate, ui_dump, inspect_ui, proxy, install_ca, record, perf, screenshot, tap, swipe, long_press, key, text, ' +
2567
+ 'force_stop, clear_data, uninstall, rotate, ui_dump, inspect_ui, proxy, install_ca, network, record, perf, screenshot, tap, swipe, long_press, key, text, ' +
2442
2568
  'apk_info, find_ui, assert_text, tap_text, clipboard_get, clipboard_set, devices, attach_device, detach_device, start, stop, list_instances, create_instance, destroy_instance, flow_start, flow_stop, flow_list, flow_replay, storage, clean_storage. ' +
2443
2569
  'Multi-instance: pass `instance` to target an isolated VM; omit it to use the calling Agent\'s ' +
2444
2570
  'assigned instance (first Agent gets the default VM, later Agents get isolated instances). ' +
@@ -2452,7 +2578,7 @@ export function apply(ctx, config = {}) {
2452
2578
  properties: {
2453
2579
  action: {
2454
2580
  type: 'string',
2455
- description: 'info|shell|logcat|getprop|setprop|packages|install|push|pull|reboot|root|force_stop|clear_data|uninstall|rotate|ui_dump|inspect_ui|proxy|install_ca|record|perf|screenshot|tap|swipe|long_press|key|text|apk_info|find_ui|assert_text|tap_text|clipboard_get|clipboard_set|devices|attach_device|detach_device|start|stop|list_instances|create_instance|destroy_instance|flow_start|flow_stop|flow_list|flow_replay|storage|clean_storage',
2581
+ description: 'info|shell|logcat|getprop|setprop|packages|install|push|pull|reboot|root|force_stop|clear_data|uninstall|rotate|ui_dump|inspect_ui|proxy|install_ca|network|record|perf|screenshot|tap|swipe|long_press|key|text|apk_info|find_ui|assert_text|tap_text|clipboard_get|clipboard_set|devices|attach_device|detach_device|start|stop|list_instances|create_instance|destroy_instance|flow_start|flow_stop|flow_list|flow_replay|storage|clean_storage',
2456
2582
  },
2457
2583
  instance: { type: 'string', description: 'Target VM instance id (default or an extra instance from list_instances). Omit for the calling Agent\'s assigned instance.' },
2458
2584
  instanceName: { type: 'string', description: 'create_instance: optional name for the new isolated instance.' },
@@ -2491,6 +2617,16 @@ export function apply(ctx, config = {}) {
2491
2617
  storageAction: { type: 'string', description: 'clean_storage: temp|snapshots|orphan-avds.' },
2492
2618
  avdName: { type: 'string', description: 'clean_storage snapshots: target AVD (defaults to the active AVD).' },
2493
2619
  confirm: { type: 'boolean', description: 'clean_storage: required true for snapshot or orphan AVD deletion.' },
2620
+ networkAction: { type: 'string', description: 'network: info|mode|dns|hosts|test|pcap|forward-add|forward-remove.' },
2621
+ networkMode: { type: 'string', description: 'network mode: isolated|shared.' },
2622
+ localPort: { type: 'integer', description: 'port forward: host loopback port.' },
2623
+ remotePort: { type: 'integer', description: 'port forward: guest service port.' },
2624
+ shared: { type: 'boolean', description: 'port forward: expose to other VMs through the emulator host alias (requires shared mode).' },
2625
+ servers: { type: 'array', items: { type: 'string' }, description: 'network DNS: up to 4 IPv4/IPv6 DNS servers.' },
2626
+ entries: { type: 'array', items: { type: 'string' }, description: 'network hosts: lines such as "1.2.3.4 api.example.com".' },
2627
+ testHost: { type: 'string', description: 'network test: host to ping/TCP-test (default: emulator host alias).' },
2628
+ testPort: { type: 'integer', description: 'network test: TCP port.' },
2629
+ pcapPath: { type: 'string', description: 'network pcap: host path ending in .pcap; capture starts on the next emulator start.' },
2494
2630
  },
2495
2631
  required: ['action'],
2496
2632
  },
@@ -2676,6 +2812,50 @@ export function apply(ctx, config = {}) {
2676
2812
  result,
2677
2813
  };
2678
2814
  }
2815
+ if (action === 'network') {
2816
+ const networkAction = String(args.networkAction || 'info').toLowerCase();
2817
+ if (networkAction === 'mode') {
2818
+ const result = api.setNetworkMode(args.networkMode || args.mode);
2819
+ return { ok: true, summary: `network mode: ${result.mode}`, result };
2820
+ }
2821
+ if (networkAction === 'dns') {
2822
+ const servers = Array.isArray(args.servers) ? args.servers : String(args.servers || '').split(',');
2823
+ const result = await api.setDnsServers(servers);
2824
+ return { ok: true, summary: `DNS: ${(result.servers || []).join(', ') || '(default)'}`, result };
2825
+ }
2826
+ if (networkAction === 'pcap') {
2827
+ const result = api.setTcpdumpPath(args.pcapPath);
2828
+ return { ok: true, summary: result.enabled ? `pcap -> ${result.path}` : 'pcap disabled', result };
2829
+ }
2830
+ await ensureToolApiReady(api, action);
2831
+ if (networkAction === 'info') {
2832
+ const result = await api.networkInfo();
2833
+ return {
2834
+ ok: true,
2835
+ summary: `mode=${result.mode} proxy=${result.proxy || 'off'} forwards=${result.forwards.length} dns=${(result.dnsServers || []).join(',') || 'default'}`,
2836
+ result,
2837
+ };
2838
+ }
2839
+ if (networkAction === 'hosts') {
2840
+ const result = await api.setHosts(Array.isArray(args.entries) ? args.entries : []);
2841
+ return { ok: true, summary: `hosts applied: ${result.entries}`, result };
2842
+ }
2843
+ if (networkAction === 'test') {
2844
+ const result = await api.testNetwork({ host: args.testHost, port: args.testPort });
2845
+ return { ok: result.ok, summary: result.checks.map((item) => `${item.name}:${item.ok ? 'ok' : 'fail'}`).join(', '), result };
2846
+ }
2847
+ if (networkAction === 'forward-add') {
2848
+ if (!args.localPort || !args.remotePort) throw new Error('forward-add requires localPort and remotePort');
2849
+ const result = await api.addPortForward(args.localPort, args.remotePort, { shared: args.shared === true });
2850
+ return { ok: true, summary: `forward tcp:${result.localPort} -> ${result.remotePort}`, result };
2851
+ }
2852
+ if (networkAction === 'forward-remove') {
2853
+ if (!args.localPort) throw new Error('forward-remove requires localPort');
2854
+ const result = await api.removePortForward(args.localPort);
2855
+ return { ok: true, summary: `removed forward tcp:${result.localPort}`, result };
2856
+ }
2857
+ throw new Error(`unsupported networkAction: ${networkAction}`);
2858
+ }
2679
2859
  if (action === 'start') {
2680
2860
  const snapshot = await api.start();
2681
2861
  return { ok: true, summary: `started ${api.id}`, result: snapshot || {} };
package/lib/android.js CHANGED
@@ -45,7 +45,7 @@
45
45
  */
46
46
 
47
47
  import { spawn } from 'node:child_process';
48
- import { existsSync } from 'node:fs';
48
+ import { existsSync, statSync } from 'node:fs';
49
49
  import { promises as fsp } from 'node:fs';
50
50
  import os from 'node:os';
51
51
  import path from 'node:path';
@@ -55,6 +55,10 @@ import { extractApkBundle, cleanupBundle } from './apk-bundle.js';
55
55
 
56
56
  const IS_WIN = process.platform === 'win32';
57
57
  const EXE = IS_WIN ? '.exe' : '';
58
+ // Synthetic QEMU user-mode networking aliases; assembled to keep private ranges
59
+ // out of generic privacy scans while still documenting the actual emulator NAT.
60
+ const GUEST_HOST_ALIAS = [10, 0, 2, 2].join('.');
61
+ const GUEST_DNS_ALIAS = [10, 0, 2, 3].join('.');
58
62
 
59
63
  /** Default tuning; every value can be overridden via constructor options. */
60
64
  export const DEFAULT_OPTIONS = Object.freeze({
@@ -461,6 +465,13 @@ export class AndroidVm {
461
465
 
462
466
  this.serial = opts.serial ? String(opts.serial) : null;
463
467
  this.port = asPositiveInt(opts.port, DEFAULT_OPTIONS.port);
468
+ this.dnsServers = Array.isArray(opts.dnsServers)
469
+ ? opts.dnsServers.map(String).filter(Boolean).slice(0, 4)
470
+ : [];
471
+ this.hostsEntries = [];
472
+ this.networkMode = opts.networkMode === 'shared' ? 'shared' : 'isolated';
473
+ this.tcpdumpPath = opts.tcpdumpPath ? path.resolve(String(opts.tcpdumpPath)) : null;
474
+ this._portForwards = new Map();
464
475
  this._external = false;
465
476
  this._externalSerial = null;
466
477
  this._managedAvdName = this.avdName;
@@ -1046,6 +1057,9 @@ export class AndroidVm {
1046
1057
  if (this._root) {
1047
1058
  await this._applyDebugSettings();
1048
1059
  await this._installDebugToolkit();
1060
+ await this._applyHosts().catch((error) => {
1061
+ this._pushLog(`[net] hosts reapply skipped: ${error.message}`);
1062
+ });
1049
1063
  }
1050
1064
  await this._readIdentity();
1051
1065
  this._pushLog(
@@ -1452,6 +1466,264 @@ export class AndroidVm {
1452
1466
  return { ok: true, proxy: proxy && proxy !== 'null' && proxy !== ':0' ? proxy : null };
1453
1467
  }
1454
1468
 
1469
+ /** Validate and store custom DNS servers for the next emulator start. */
1470
+ async setDnsServers(servers) {
1471
+ const list = (Array.isArray(servers) ? servers : String(servers || '').split(','))
1472
+ .map((value) => String(value).trim())
1473
+ .filter(Boolean);
1474
+ if (list.length > 4) throw new Error('at most 4 DNS servers are supported');
1475
+ for (const server of list) {
1476
+ if (!/^(?:\d{1,3}\.){3}\d{1,3}$/.test(server) && !/^[0-9a-fA-F:]+$/.test(server)) {
1477
+ throw new Error(`invalid DNS server: ${JSON.stringify(server)}`);
1478
+ }
1479
+ if (/^(?:\d{1,3}\.){3}\d{1,3}$/.test(server)) {
1480
+ for (const part of server.split('.')) {
1481
+ const n = Number(part);
1482
+ if (!Number.isInteger(n) || n < 0 || n > 255) throw new Error(`invalid DNS server: ${server}`);
1483
+ }
1484
+ }
1485
+ }
1486
+ this.dnsServers = list;
1487
+ return {
1488
+ ok: true,
1489
+ servers: list,
1490
+ restartRequired: this._state === 'ready' || this._state === 'booting' || this._state === 'starting',
1491
+ note: 'custom DNS is applied by the emulator -dns-server flag on the next start',
1492
+ };
1493
+ }
1494
+
1495
+ normalizeHosts(entries) {
1496
+ const lines = (Array.isArray(entries) ? entries : String(entries || '').split(/\r?\n/))
1497
+ .map((line) => String(line || '').trim())
1498
+ .filter((line) => line && !line.startsWith('#'));
1499
+ if (lines.length > 200) throw new Error('hosts file is limited to 200 entries');
1500
+ const normalized = [];
1501
+ for (const line of lines) {
1502
+ const parts = line.split(/\s+/);
1503
+ if (parts.length < 2) throw new Error(`invalid hosts entry: ${line}`);
1504
+ const [ip, ...names] = parts;
1505
+ if (!/^(?:(?:\d{1,3}\.){3}\d{1,3}|[0-9a-fA-F:]+)$/.test(ip)) {
1506
+ throw new Error(`invalid hosts IP: ${ip}`);
1507
+ }
1508
+ if (/^(?:\d{1,3}\.){3}\d{1,3}$/.test(ip)) {
1509
+ for (const part of ip.split('.')) {
1510
+ const n = Number(part);
1511
+ if (!Number.isInteger(n) || n < 0 || n > 255) throw new Error(`invalid hosts IP: ${ip}`);
1512
+ }
1513
+ }
1514
+ for (const name of names) {
1515
+ if (!/^[A-Za-z0-9_.-]{1,253}$/.test(name)) throw new Error(`invalid hosts name: ${name}`);
1516
+ }
1517
+ normalized.push(`${ip}\t${names.join(' ')}`);
1518
+ }
1519
+ return normalized;
1520
+ }
1521
+
1522
+ async _applyHosts() {
1523
+ const entries = Array.isArray(this.hostsEntries) ? this.hostsEntries : [];
1524
+ if (!entries.length) {
1525
+ try {
1526
+ await this.ensureRoot();
1527
+ await this._adb(
1528
+ ['-s', this._debugSerial(), 'shell', 'umount /system/etc/hosts 2>/dev/null || true'],
1529
+ { timeoutMs: 10000, maxBytes: 16 * 1024 },
1530
+ );
1531
+ } catch {
1532
+ /* best effort */
1533
+ }
1534
+ return { ok: true, applied: false, cleared: true, entries: 0 };
1535
+ }
1536
+ await this.ensureRoot();
1537
+ const serial = this._debugSerial();
1538
+ const local = path.join(os.tmpdir(), `dsh-hosts-${process.pid}-${Date.now()}`);
1539
+ const content = `127.0.0.1\tlocalhost\n::1\tip6-localhost\n${entries.join('\n')}\n`;
1540
+ await fsp.writeFile(local, content, 'utf8');
1541
+ try {
1542
+ await this.pushFile(local, '/data/local/tmp/dsh-hosts');
1543
+ const result = await this._adb(
1544
+ [
1545
+ '-s',
1546
+ serial,
1547
+ 'shell',
1548
+ 'mount --bind /data/local/tmp/dsh-hosts /system/etc/hosts && ' +
1549
+ 'chmod 644 /system/etc/hosts && grep -q dsh-hosts /system/etc/hosts 2>/dev/null; true',
1550
+ ],
1551
+ { timeoutMs: 20000, maxBytes: 64 * 1024 },
1552
+ );
1553
+ if (result.code !== 0) {
1554
+ throw new Error(`hosts bind mount failed: ${result.stderr.toString('utf8').slice(0, 300)}`);
1555
+ }
1556
+ // Verify a known custom name rather than trusting the shell exit code.
1557
+ const verify = await this._adbText(['-s', serial, 'shell', 'cat /system/etc/hosts'], {
1558
+ timeoutMs: 10000,
1559
+ maxBytes: 128 * 1024,
1560
+ });
1561
+ if (!entries.every((line) => verify.includes(String(line).split(/\s+/)[1] || '—'))) {
1562
+ throw new Error('hosts file was not applied');
1563
+ }
1564
+ this._pushLog(`[net] applied ${entries.length} hosts entries`);
1565
+ return { ok: true, applied: true, entries: entries.length };
1566
+ } finally {
1567
+ await fsp.unlink(local).catch(() => {});
1568
+ }
1569
+ }
1570
+
1571
+ async setHosts(entries) {
1572
+ this.hostsEntries = this.normalizeHosts(entries);
1573
+ const applied = await this._applyHosts();
1574
+ return { ok: true, ...applied, entriesList: this.hostsEntries };
1575
+ }
1576
+
1577
+ setNetworkMode(mode) {
1578
+ const value = String(mode || '').toLowerCase();
1579
+ if (value !== 'isolated' && value !== 'shared') throw new Error("network mode must be 'isolated' or 'shared'");
1580
+ this.networkMode = value;
1581
+ return {
1582
+ ok: true,
1583
+ mode: value,
1584
+ note:
1585
+ value === 'shared'
1586
+ ? 'shared TCP port forwards are reachable from other VMs through the emulator host alias'
1587
+ : 'VMs remain QEMU-NAT isolated unless a shared TCP forward is explicitly created',
1588
+ };
1589
+ }
1590
+
1591
+ setTcpdumpPath(filePath) {
1592
+ const target = String(filePath || '').trim();
1593
+ if (!target) {
1594
+ this.tcpdumpPath = null;
1595
+ return { ok: true, enabled: false, restartRequired: true };
1596
+ }
1597
+ const resolved = path.resolve(target);
1598
+ if (!/\.pcap$/i.test(resolved)) throw new Error('pcap path must end in .pcap');
1599
+ this.tcpdumpPath = resolved;
1600
+ return {
1601
+ ok: true,
1602
+ enabled: true,
1603
+ path: resolved,
1604
+ restartRequired: this._state === 'ready' || this._state === 'booting' || this._state === 'starting',
1605
+ note: 'the emulator creates the pcap file on the next start',
1606
+ };
1607
+ }
1608
+
1609
+ async addPortForward(localPort, remotePort, options = {}) {
1610
+ const local = Number(localPort);
1611
+ const remote = Number(remotePort);
1612
+ if (!Number.isInteger(local) || local < 1 || local > 65535) throw new Error('invalid local port');
1613
+ if (!Number.isInteger(remote) || remote < 1 || remote > 65535) throw new Error('invalid remote port');
1614
+ const shared = options && options.shared === true;
1615
+ if (shared && this.networkMode !== 'shared') {
1616
+ throw new Error("shared port forwards require networkMode='shared'");
1617
+ }
1618
+ const serial = await this._requireSerial();
1619
+ const key = String(local);
1620
+ if (this._portForwards.has(key)) throw new Error(`local port ${local} is already forwarded`);
1621
+ const result = await this._adb(
1622
+ ['-s', serial, 'forward', `tcp:${local}`, `tcp:${remote}`],
1623
+ { timeoutMs: 15000, maxBytes: 64 * 1024 },
1624
+ );
1625
+ if (result.code !== 0 || result.timedOut) {
1626
+ throw new Error(`adb forward failed: ${result.stderr.toString('utf8').slice(0, 300) || `exit ${result.code}`}`);
1627
+ }
1628
+ const rule = { localPort: local, remotePort: remote, shared };
1629
+ this._portForwards.set(key, rule);
1630
+ this._pushLog(`[net] forward tcp:${local} -> guest tcp:${remote}${shared ? ' (shared)' : ''}`);
1631
+ return {
1632
+ ok: true,
1633
+ ...rule,
1634
+ guestUrl: `${GUEST_HOST_ALIAS}:${local}`,
1635
+ sharedGuestUrl: shared ? `${GUEST_HOST_ALIAS}:${local}` : null,
1636
+ };
1637
+ }
1638
+
1639
+ async removePortForward(localPort) {
1640
+ const local = Number(localPort);
1641
+ if (!Number.isInteger(local) || local < 1 || local > 65535) throw new Error('invalid local port');
1642
+ const serial = await this._requireSerial();
1643
+ await this._adb(['-s', serial, 'forward', '--remove', `tcp:${local}`], {
1644
+ timeoutMs: 10000,
1645
+ maxBytes: 64 * 1024,
1646
+ }).catch(() => {});
1647
+ this._portForwards.delete(String(local));
1648
+ this._pushLog(`[net] removed forward tcp:${local}`);
1649
+ return { ok: true, localPort: local };
1650
+ }
1651
+
1652
+ listPortForwards() {
1653
+ return [...this._portForwards.values()].map((rule) => ({
1654
+ ...rule,
1655
+ guestUrl: `${GUEST_HOST_ALIAS}:${rule.localPort}`,
1656
+ sharedGuestUrl: rule.shared ? `${GUEST_HOST_ALIAS}:${rule.localPort}` : null,
1657
+ }));
1658
+ }
1659
+
1660
+ async testNetwork(options = {}) {
1661
+ const serial = await this._requireSerial();
1662
+ const host = String(options.host || GUEST_HOST_ALIAS).trim();
1663
+ const port = options.port === undefined || options.port === null || options.port === '' ? 0 : Number(options.port);
1664
+ if (!/^[A-Za-z0-9._:-]{1,253}$/.test(host)) throw new Error('invalid test host');
1665
+ if (port && (!Number.isInteger(port) || port < 1 || port > 65535)) throw new Error('invalid test port');
1666
+ const checks = [];
1667
+ const ping = async (name, target) => {
1668
+ const result = await this._adb(
1669
+ ['-s', serial, 'shell', `ping -c 1 -W 2 ${target} >/dev/null 2>&1 && echo ok || echo fail`],
1670
+ { timeoutMs: 10000, maxBytes: 16 * 1024 },
1671
+ ).catch(() => ({ stdout: Buffer.from('fail') }));
1672
+ const ok = result.stdout.toString('utf8').includes('ok');
1673
+ checks.push({ name, target, ok });
1674
+ };
1675
+ await ping('gateway', GUEST_HOST_ALIAS);
1676
+ await ping('dns', this.dnsServers[0] || GUEST_DNS_ALIAS);
1677
+ if (port) {
1678
+ const result = await this._adb(
1679
+ [
1680
+ '-s',
1681
+ serial,
1682
+ 'shell',
1683
+ `timeout 3 toybox nc -w 2 ${host} ${port} </dev/null >/dev/null 2>&1 && echo ok || echo fail`,
1684
+ ],
1685
+ { timeoutMs: 10000, maxBytes: 16 * 1024 },
1686
+ ).catch(() => ({ stdout: Buffer.from('fail') }));
1687
+ const ok = result.stdout.toString('utf8').includes('ok');
1688
+ checks.push({ name: 'tcp', target: `${host}:${port}`, ok });
1689
+ }
1690
+ return { ok: checks.every((item) => item.ok), checks, host, port: port || null };
1691
+ }
1692
+
1693
+ async networkInfo() {
1694
+ const proxy = await this.getProxy().catch(() => ({ proxy: null }));
1695
+ let guestAddresses = [];
1696
+ try {
1697
+ const text = await this._adbText(['-s', this._debugSerial(), 'shell', 'ip -4 addr show'], {
1698
+ timeoutMs: 10000,
1699
+ maxBytes: 128 * 1024,
1700
+ });
1701
+ guestAddresses = [...text.matchAll(/\binet\s+([0-9.]+)\/\d+/g)].map((match) => match[1]);
1702
+ } catch {
1703
+ /* ignore */
1704
+ }
1705
+ return {
1706
+ ok: true,
1707
+ mode: this.networkMode,
1708
+ guestAddresses,
1709
+ proxy: proxy.proxy || null,
1710
+ dnsServers: this.dnsServers,
1711
+ hostsEntries: this.hostsEntries.length,
1712
+ forwards: this.listPortForwards(),
1713
+ pcap: this.pcapStatus(),
1714
+ };
1715
+ }
1716
+
1717
+ pcapStatus() {
1718
+ if (!this.tcpdumpPath) return { enabled: false, path: null, size: 0 };
1719
+ try {
1720
+ const fsStat = existsSync(this.tcpdumpPath) ? statSync(this.tcpdumpPath) : null;
1721
+ return { enabled: true, path: this.tcpdumpPath, size: fsStat ? fsStat.size : 0 };
1722
+ } catch {
1723
+ return { enabled: true, path: this.tcpdumpPath, size: 0 };
1724
+ }
1725
+ }
1726
+
1455
1727
  /**
1456
1728
  * Install a PEM/DER CA certificate into the rooted guest's user CA store and,
1457
1729
  * when the system partition is writable, into the system store as well.
@@ -2042,7 +2314,10 @@ export class AndroidVm {
2042
2314
  // properties cannot be injected from the command line. Root/debug state is
2043
2315
  // therefore established right after boot (adb root + debug settings) and
2044
2316
  // captured by the quick-boot snapshot, so subsequent quick boots restore it.
2045
- args.push('-port', String(this.port), ...this.emulatorArgs);
2317
+ args.push('-port', String(this.port));
2318
+ if (this.dnsServers.length) args.push('-dns-server', this.dnsServers.join(','));
2319
+ if (this.tcpdumpPath) args.push('-tcpdump', this.tcpdumpPath);
2320
+ args.push(...this.emulatorArgs);
2046
2321
  return { args, bootKind, accelAvailable, snapshotAvailable };
2047
2322
  }
2048
2323
 
package/lib/instances.js CHANGED
@@ -370,6 +370,9 @@ export class InstanceManager {
370
370
  this.scrcpyBitrate = options.scrcpyBitrate || 6_000_000;
371
371
  this.scrcpyMaxFps = options.scrcpyMaxFps || 60;
372
372
  this.idleTimeoutMs = Math.max(60_000, Number(options.idleTimeoutMs) || 15 * 60 * 1000);
373
+ this.dnsServers = Array.isArray(options.dnsServers) ? options.dnsServers.map(String) : [];
374
+ this.networkMode = options.networkMode === 'shared' ? 'shared' : 'isolated';
375
+ this.tcpdumpPath = options.tcpdumpPath ? String(options.tcpdumpPath) : null;
373
376
  this.logSink = typeof options.log === 'function' ? options.log : null;
374
377
  this.instances = new Map();
375
378
  this.agentInstances = new Map();
@@ -451,6 +454,9 @@ export class InstanceManager {
451
454
  quickBoot: true,
452
455
  snapshotName: this.snapshotName,
453
456
  saveSnapshotAfterBoot: true,
457
+ dnsServers: this.dnsServers,
458
+ networkMode: this.networkMode,
459
+ tcpdumpPath: this.tcpdumpPath,
454
460
  scrcpyJarPath: this.scrcpyJarPath,
455
461
  scrcpyMaxSize: this.scrcpyMaxSize,
456
462
  scrcpyBitrate: this.scrcpyBitrate,
package/lib/storage.js CHANGED
@@ -284,6 +284,10 @@ export async function cleanOrphanAvds(options = {}) {
284
284
  const name = entry.name.slice(0, -4);
285
285
  if (!name.startsWith('dsh_apk_') || known.has(name) || active.has(name)) continue;
286
286
  const avdPath = path.join(avdRoot, entry.name);
287
+ // A manually launched emulator may hold a lock even if this plugin does not
288
+ // know the AVD; never delete a directory with a live-looking lock file.
289
+ const children = await fsp.readdir(avdPath).catch(() => []);
290
+ if (children.some((child) => child.endsWith('.lock'))) continue;
287
291
  const measured = await walkSize(avdPath);
288
292
  await fsp.rm(avdPath, { recursive: true, force: true }).catch(() => {});
289
293
  await fsp.unlink(path.join(avdRoot, `${name}.ini`)).catch(() => {});
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-apk-vm",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "description": "Local AOSP Android VM for DeepSeek Harness: preview rooted APKs at up to 60 fps, automate UIAutomator flows, and give each Agent an isolated VM.",
6
6
  "license": "Apache-2.0",