kiku-agent 1.3.2 → 1.3.4
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 +69 -12
- package/package.json +2 -1
- package/server/agent/agentCore.js +319 -46
- package/server/agent/appPaths.js +31 -0
- package/server/agent/audioPlayer.js +339 -12
- package/server/agent/autoContext.js +193 -0
- package/server/agent/catalogEntityResolver.js +170 -28
- package/server/agent/clarifyingQuestions.js +137 -35
- package/server/agent/cli.js +177 -48
- package/server/agent/cloudAdapters.js +116 -0
- package/server/agent/commandRegistry.js +409 -80
- package/server/agent/commands.js +1056 -125
- package/server/agent/config.js +1088 -690
- package/server/agent/contextCompactor.js +213 -21
- package/server/agent/cpuMetrics.js +199 -0
- package/server/agent/engineService.js +591 -0
- package/server/agent/eval/modules/evaluation/runner.js +4 -14
- package/server/agent/eval/modules/evaluation/scoring.js +15 -3
- package/server/agent/eval/modules/server/evalApi.js +5 -5
- package/server/agent/eval/modules/ui/evalUi.js +7 -4
- package/server/agent/eventBus.js +1 -0
- package/server/agent/ink/main.jsx +196 -191
- package/server/agent/jobManager.js +11 -1
- package/server/agent/lanDiscovery.js +721 -23
- package/server/agent/llmClient.js +317 -71
- package/server/agent/lmStudioWakeup.js +517 -65
- package/server/agent/locationService.js +797 -0
- package/server/agent/mcpQuizData.js +509 -0
- package/server/agent/memory/contextAllocator.js +19 -7
- package/server/agent/memory.js +3 -1
- package/server/agent/modelCompatibility.js +352 -0
- package/server/agent/modelDefaults.js +1 -0
- package/server/agent/modelManager.js +46 -2
- package/server/agent/newsWatch.js +9 -3
- package/server/agent/processManager.js +229 -0
- package/server/agent/prompts.js +174 -8
- package/server/agent/routingInterceptor.js +9 -1
- package/server/agent/sanitizer.js +41 -6
- package/server/agent/searchQueryPlanner.js +202 -0
- package/server/agent/session.js +59 -4
- package/server/agent/tests/agentFollowUps.test.js +128 -2
- package/server/agent/tests/agentMusicControl.test.js +245 -0
- package/server/agent/tests/apiRouter.test.js +1 -1
- package/server/agent/tests/autoContext.test.js +176 -0
- package/server/agent/tests/bilibiliEmbed.test.js +45 -0
- package/server/agent/tests/chatHardening.test.js +121 -0
- package/server/agent/tests/chineseWebSearchEscalation.test.js +62 -0
- package/server/agent/tests/clarifyingQuestions.test.js +33 -0
- package/server/agent/tests/cliAutomation.test.js +38 -1
- package/server/agent/tests/cloudProviders.test.js +91 -3
- package/server/agent/tests/commands.test.js +92 -3
- package/server/agent/tests/contextIntelligence.test.js +50 -3
- package/server/agent/tests/cpuMetrics.test.js +101 -0
- package/server/agent/tests/decisionAndSearchEnhancements.test.js +32 -0
- package/server/agent/tests/edgeCasesScenarios.test.js +82 -0
- package/server/agent/tests/engineService.test.js +436 -0
- package/server/agent/tests/engineUnload.test.js +94 -0
- package/server/agent/tests/envCommand.test.js +69 -0
- package/server/agent/tests/evalMetrics.test.js +8 -0
- package/server/agent/tests/fetchApi.test.js +99 -0
- package/server/agent/tests/hybridWebSearch.test.js +2 -0
- package/server/agent/tests/lanDiscovery.test.js +5 -0
- package/server/agent/tests/lanInterfaces.test.js +510 -0
- package/server/agent/tests/lmStudioWakeup.test.js +278 -0
- package/server/agent/tests/locationTool.test.js +278 -0
- package/server/agent/tests/memoryRoute.test.js +173 -0
- package/server/agent/tests/memoryUiNormalization.test.js +75 -0
- package/server/agent/tests/modelCompatibility.test.js +148 -0
- package/server/agent/tests/modelManager.test.js +32 -0
- package/server/agent/tests/musicControlUiBridge.test.js +113 -0
- package/server/agent/tests/nextGenArchitecture.test.js +4 -2
- package/server/agent/tests/openDomainPrompt.test.js +197 -0
- package/server/agent/tests/openDomainSearch.test.js +121 -0
- package/server/agent/tests/processManager.test.js +144 -0
- package/server/agent/tests/providerPersistence.test.js +10 -5
- package/server/agent/tests/quickActionsAndModals.test.js +180 -0
- package/server/agent/tests/searchQueryPlanner.test.js +108 -0
- package/server/agent/tests/securityAuditRemediation.test.js +16 -9
- package/server/agent/tests/securityAuditRemediations2.test.js +154 -0
- package/server/agent/tests/securityHardening.test.js +4 -4
- package/server/agent/tests/sessionAndCopy.test.js +39 -4
- package/server/agent/tests/sidebarControls.test.js +34 -0
- package/server/agent/tests/statefulMemory.test.js +8 -1
- package/server/agent/tests/textProtocolLoop.test.js +71 -0
- package/server/agent/tests/textToolParsing.test.js +48 -0
- package/server/agent/tests/toolRoutingRegressions.test.js +41 -0
- package/server/agent/tests/unifiedEndpoints.test.js +13 -6
- package/server/agent/tests/v110Features.test.js +1 -1
- package/server/agent/tests/vramEstimator.test.js +75 -0
- package/server/agent/tests/webChatDocs.test.js +199 -0
- package/server/agent/tests/webChatInferenceControl.test.js +298 -0
- package/server/agent/tests/webChatInspection.test.js +256 -5
- package/server/agent/tests/webChatMcp.test.js +94 -0
- package/server/agent/tests/webExtract.test.js +23 -0
- package/server/agent/tests/webSources.test.js +46 -12
- package/server/agent/textToolProtocol.js +173 -46
- package/server/agent/toolRouter.js +318 -44
- package/server/agent/tools.js +877 -96
- package/server/agent/ui.js +39 -34
- package/server/agent/vramService.js +35 -54
- package/server/agent/webSearch.js +441 -162
- package/server/agent/websearch/orchestrator.js +15 -5
- package/server/agent/websearch/resilience/cache.js +9 -2
- package/server/agent/websearch/security/ssrf.js +41 -10
- package/server/agent/youtubeAuth.js +5 -20
- package/server/apiRouter.js +5558 -4422
- package/server/mcp/data/discography.js +2 -2
- package/server/mcp/examples/antigravity.example.json +17 -0
- package/server/mcp/handler.js +8 -4
- package/server/mcp/security.js +220 -21
- package/server/mcp/server.js +2 -1
- package/server/mcp/tests/connection.test.js +14 -8
- package/server/mcp/tests/mcpKeyAuth.test.js +156 -0
- package/server/mcp/tools.js +7 -1
- package/server/supabase_migration_prefix.sql +102 -0
- package/server/supabase_schema.sql +18 -18
- package/server/web-chat/core/connection.js +179 -218
- package/server/web-chat/core/conversationContext.js +9 -2
- package/server/web-chat/core/directCatalogEngine.js +52 -28
- package/server/web-chat/core/dramaKnowledge.js +33 -22
- package/server/web-chat/core/followUpEngine.js +735 -81
- package/server/web-chat/core/futureEventGuard.js +5 -1
- package/server/web-chat/core/musicControlAction.js +70 -0
- package/server/web-chat/core/security.js +32 -21
- package/server/web-chat/core/toolInspector.js +6 -6
- package/server/web-chat/core/urlGuard.js +45 -0
- package/server/web-chat/router.js +204 -157
- package/server/web-chat/routes/chatRoute.js +885 -488
- package/server/web-chat/routes/engineRoute.js +270 -0
- package/server/web-chat/routes/exportRoute.js +1 -1
- package/server/web-chat/routes/lanDiscoveryRoute.js +17 -2
- package/server/web-chat/routes/lmStudioLoadRoute.js +172 -0
- package/server/web-chat/routes/mcpRoute.js +386 -0
- package/server/web-chat/routes/memoryRoute.js +253 -0
- package/server/web-chat/routes/providerRoute.js +49 -16
- package/server/web-chat/routes/staticRoute.js +306 -17
- package/server/web-chat/routes/statusRoute.js +194 -73
- package/server/web-chat/routes/suggestRoute.js +11 -3
- package/server/web-chat/routes/verifyRoute.js +26 -4
- package/server/web-chat/server.js +7 -6
- package/server/web-chat/ui/docsBuilder.js +3558 -0
- package/server/web-chat/ui/htmlBuilder.js +12 -0
- package/server/web-chat/ui/scripts/client.js +3401 -1302
- package/server/web-chat/ui/scripts/webchatI18n.js +155 -153
- package/server/web-chat/ui/settingsBuilder.js +7870 -0
- package/server/web-chat/ui/styles/lan-card.css +165 -0
- package/server/web-chat/ui/styles/main.css +4289 -447
- package/server/web-chat/ui/sw.js +64 -0
- package/server/web-chat/ui/templates/body.html +407 -281
- package/src/data/awardsData.js +146 -146
- package/src/data/musicVideoData.js +6 -4
- package/src/lib/vram/estimator.js +195 -19
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
import net from 'node:net';
|
|
2
2
|
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { promises as fsp } from 'node:fs';
|
|
5
|
+
import { execFile } from 'node:child_process';
|
|
6
|
+
import { promisify } from 'node:util';
|
|
3
7
|
import { isPrivateIPv4 } from './sanitizer.js';
|
|
8
|
+
import { KIKU_DATA_DIR } from './appPaths.js';
|
|
4
9
|
|
|
5
10
|
export const DEFAULT_LLM_PORTS = Object.freeze([1234, 8000, 11434, 8080]);
|
|
11
|
+
export const LAN_INTERFACE_EXEC_TIMEOUT_MS = 4000;
|
|
12
|
+
export const EXPAND_CIDR_MAX_HOSTS = 65536;
|
|
13
|
+
|
|
14
|
+
const execFileAsync = promisify(execFile);
|
|
6
15
|
|
|
7
16
|
const CACHE_TTL_MS = 60_000;
|
|
8
17
|
|
|
@@ -27,6 +36,57 @@ function parseIpv4(host) {
|
|
|
27
36
|
return octets;
|
|
28
37
|
}
|
|
29
38
|
|
|
39
|
+
export function netmaskToPrefix(netmask) {
|
|
40
|
+
const octets = parseIpv4(netmask);
|
|
41
|
+
if (!octets) return null;
|
|
42
|
+
let bits = 0;
|
|
43
|
+
let seenZero = false;
|
|
44
|
+
for (const octet of octets) {
|
|
45
|
+
for (let bit = 7; bit >= 0; bit--) {
|
|
46
|
+
const on = (octet >> bit) & 1;
|
|
47
|
+
if (on) {
|
|
48
|
+
if (seenZero) return null;
|
|
49
|
+
bits++;
|
|
50
|
+
} else {
|
|
51
|
+
seenZero = true;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return bits;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function netmaskFromPrefix(prefix) {
|
|
59
|
+
const n = Number(prefix);
|
|
60
|
+
if (!Number.isInteger(n) || n < 0 || n > 32) return null;
|
|
61
|
+
const mask = n === 0 ? 0 : (0xffffffff << (32 - n)) >>> 0;
|
|
62
|
+
return uint32ToIp(mask);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function usableHostCount(prefix) {
|
|
66
|
+
const n = Number(prefix);
|
|
67
|
+
if (!Number.isInteger(n) || n < 0 || n > 32) return 0;
|
|
68
|
+
const size = 2 ** (32 - n);
|
|
69
|
+
return n <= 30 ? Math.max(0, size - 2) : size;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function networkCidr(address, prefix) {
|
|
73
|
+
const octets = parseIpv4(address);
|
|
74
|
+
const n = Number(prefix);
|
|
75
|
+
if (!octets || !Number.isInteger(n) || n < 0 || n > 32) return null;
|
|
76
|
+
const mask = n === 0 ? 0 : (0xffffffff << (32 - n)) >>> 0;
|
|
77
|
+
const network = (ipv4ToUint32(octets) & mask) >>> 0;
|
|
78
|
+
return `${uint32ToIp(network)}/${n}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function prefixFromEntry(entry = {}) {
|
|
82
|
+
const cidrText = String(entry.cidr || '');
|
|
83
|
+
const cidrPrefix = cidrText.includes('/') ? Number(cidrText.split('/')[1]) : NaN;
|
|
84
|
+
if (Number.isInteger(cidrPrefix) && cidrPrefix >= 0 && cidrPrefix <= 32) return cidrPrefix;
|
|
85
|
+
const fromMask = netmaskToPrefix(entry.netmask);
|
|
86
|
+
if (fromMask !== null) return fromMask;
|
|
87
|
+
return 24;
|
|
88
|
+
}
|
|
89
|
+
|
|
30
90
|
export function getLanCandidateSubnets(interfaces = os.networkInterfaces()) {
|
|
31
91
|
const seen = new Set();
|
|
32
92
|
const subnets = [];
|
|
@@ -37,23 +97,40 @@ export function getLanCandidateSubnets(interfaces = os.networkInterfaces()) {
|
|
|
37
97
|
if (!octets || octets[0] === 127) continue;
|
|
38
98
|
if (octets[0] === 169 && octets[1] === 254) continue;
|
|
39
99
|
if (!isPrivateIPv4(entry.address)) continue;
|
|
40
|
-
const
|
|
100
|
+
const prefix = prefixFromEntry(entry);
|
|
101
|
+
const cidr = networkCidr(entry.address, prefix);
|
|
41
102
|
if (seen.has(cidr)) continue;
|
|
42
103
|
seen.add(cidr);
|
|
43
|
-
subnets.push({
|
|
104
|
+
subnets.push({
|
|
105
|
+
cidr,
|
|
106
|
+
interfaceName: name,
|
|
107
|
+
address: entry.address,
|
|
108
|
+
netmask: netmaskFromPrefix(prefix),
|
|
109
|
+
prefix,
|
|
110
|
+
hostCount: usableHostCount(prefix),
|
|
111
|
+
});
|
|
44
112
|
}
|
|
45
113
|
}
|
|
46
114
|
return subnets;
|
|
47
115
|
}
|
|
48
116
|
|
|
49
|
-
export function expandCidrToHosts(cidr) {
|
|
50
|
-
const base = String(cidr || '').split('/')
|
|
117
|
+
export function expandCidrToHosts(cidr, { maxHosts = EXPAND_CIDR_MAX_HOSTS } = {}) {
|
|
118
|
+
const [base, prefixStr] = String(cidr || '').split('/');
|
|
51
119
|
const octets = parseIpv4(base);
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
120
|
+
const prefix = prefixStr === undefined || prefixStr === '' ? 24 : Number(prefixStr);
|
|
121
|
+
if (!octets || !Number.isInteger(prefix) || prefix < 8 || prefix > 32) {
|
|
122
|
+
throw new Error(`Invalid CIDR "${cidr}"`);
|
|
123
|
+
}
|
|
124
|
+
const network = (ipv4ToUint32(octets) & (0xffffffff << (32 - prefix))) >>> 0;
|
|
125
|
+
const size = 2 ** (32 - prefix);
|
|
126
|
+
const usable = prefix <= 30 ? size - 2 : size;
|
|
127
|
+
if (usable > maxHosts) {
|
|
128
|
+
throw new Error(`CIDR "${cidr}" expands to ${usable.toLocaleString()} hosts (cap ${maxHosts.toLocaleString()}). Scan a smaller prefix instead.`);
|
|
56
129
|
}
|
|
130
|
+
const first = prefix <= 30 ? network + 1 : network;
|
|
131
|
+
const last = prefix <= 30 ? network + size - 2 : network + size - 1;
|
|
132
|
+
const hosts = [];
|
|
133
|
+
for (let ip = first; ip <= last; ip++) hosts.push(uint32ToIp(ip >>> 0));
|
|
57
134
|
return hosts;
|
|
58
135
|
}
|
|
59
136
|
|
|
@@ -71,7 +148,6 @@ export function expandTargets(specs, { maxHosts = DEFAULT_MAX_SCAN_HOSTS } = {})
|
|
|
71
148
|
const list = Array.isArray(specs) ? specs : String(specs || '').split(',');
|
|
72
149
|
const out = [];
|
|
73
150
|
const seen = new Set();
|
|
74
|
-
let planned = 0;
|
|
75
151
|
|
|
76
152
|
const pushIp = value => {
|
|
77
153
|
const ip = uint32ToIp(value);
|
|
@@ -94,14 +170,14 @@ export function expandTargets(specs, { maxHosts = DEFAULT_MAX_SCAN_HOSTS } = {})
|
|
|
94
170
|
}
|
|
95
171
|
const network = prefix === 0 ? 0 : (ipv4ToUint32(octets) & (0xffffffff << (32 - prefix))) >>> 0;
|
|
96
172
|
const size = 2 ** (32 - prefix);
|
|
97
|
-
const usable = prefix <= 30 ? size - 2 : size;
|
|
98
|
-
planned += usable;
|
|
99
|
-
if (planned > maxHosts) {
|
|
100
|
-
throw new Error(`Scan target limit exceeded (>${maxHosts} hosts). Narrow the range or raise KIKU_LAN_SCAN_MAX_HOSTS.`);
|
|
101
|
-
}
|
|
102
173
|
const first = prefix <= 30 ? network + 1 : network;
|
|
103
174
|
const last = prefix <= 30 ? network + size - 2 : network + size - 1;
|
|
104
|
-
for (let ip = first; ip <= last; ip++)
|
|
175
|
+
for (let ip = first; ip <= last; ip++) {
|
|
176
|
+
pushIp(ip >>> 0);
|
|
177
|
+
if (out.length > maxHosts) {
|
|
178
|
+
throw new Error(`Scan target limit exceeded (>${maxHosts} hosts). Narrow the range or raise KIKU_LAN_SCAN_MAX_HOSTS.`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
105
181
|
} else if (spec.includes('-')) {
|
|
106
182
|
const [startStr, endStr] = spec.split('-');
|
|
107
183
|
const startOctets = parseIpv4(startStr);
|
|
@@ -112,23 +188,615 @@ export function expandTargets(specs, { maxHosts = DEFAULT_MAX_SCAN_HOSTS } = {})
|
|
|
112
188
|
const start = ipv4ToUint32(startOctets);
|
|
113
189
|
const end = ipv4ToUint32(endOctets);
|
|
114
190
|
if (end < start) throw new Error(`Invalid IP range "${spec}" (start is after end)`);
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
191
|
+
for (let ip = start; ip <= end; ip++) {
|
|
192
|
+
pushIp(ip >>> 0);
|
|
193
|
+
if (out.length > maxHosts) {
|
|
194
|
+
throw new Error(`Scan target limit exceeded (>${maxHosts} hosts). Narrow the range or raise KIKU_LAN_SCAN_MAX_HOSTS.`);
|
|
195
|
+
}
|
|
119
196
|
}
|
|
120
|
-
for (let ip = start; ip <= end; ip++) pushIp(ip >>> 0);
|
|
121
197
|
} else {
|
|
122
198
|
const octets = parseIpv4(spec);
|
|
123
199
|
if (!octets) throw new Error(`Invalid IP address "${spec}"`);
|
|
124
|
-
planned += 1;
|
|
125
200
|
pushIp(ipv4ToUint32(octets));
|
|
201
|
+
if (out.length > maxHosts) {
|
|
202
|
+
throw new Error(`Scan target limit exceeded (>${maxHosts} hosts). Narrow the range or raise KIKU_LAN_SCAN_MAX_HOSTS.`);
|
|
203
|
+
}
|
|
126
204
|
}
|
|
127
205
|
}
|
|
128
206
|
|
|
129
207
|
return out;
|
|
130
208
|
}
|
|
131
209
|
|
|
210
|
+
const VIRTUAL_IFACE_RE = /(docker|veth\d*|br-[0-9a-f]|bridge|vmnet|vbox|virtualbox|tailscale|twingate|wg\d*|wireguard|utun\d*|ppp\d*|hamachi|zerotier|zt\d*|lxc|vethernet|hyper-v|\bvpn\b|\btap\b|\btun\d*)/i;
|
|
211
|
+
const WIFI_IFACE_RE = /(wi-?fi|wlan\d*|ath\d*|wlp\d+s\d+)/i;
|
|
212
|
+
const ETHERNET_IFACE_RE = /^(eth\d+|ethernet(\s*\d*)?|eno\d+|ens\d+|em\d+|enp\d+s\d+|lan\d*)$/i;
|
|
213
|
+
|
|
214
|
+
export function inferInterfaceType(name = '', detail = '') {
|
|
215
|
+
const text = `${name} ${detail}`.trim();
|
|
216
|
+
if (!text) return 'unknown';
|
|
217
|
+
if (/^(lo(\d+)?|loopback)(?![a-z])/i.test(text)) return 'loopback';
|
|
218
|
+
if (VIRTUAL_IFACE_RE.test(text)) return 'virtual';
|
|
219
|
+
if (/local area connection/i.test(text)) return 'virtual';
|
|
220
|
+
if (WIFI_IFACE_RE.test(text)) return 'wifi';
|
|
221
|
+
if (ETHERNET_IFACE_RE.test(text)) return 'ethernet';
|
|
222
|
+
return 'unknown';
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function normalizeIfaceStatus(value) {
|
|
226
|
+
const text = String(value || '').trim().toLowerCase();
|
|
227
|
+
if (!text) return 'unknown';
|
|
228
|
+
if (['up', 'active', 'associated', 'connected', 'enabled', 'ok', '2'].includes(text)) return 'up';
|
|
229
|
+
if (['down', 'inactive', 'disconnected', 'disabled', 'dormant', '1', 'testing', 'notpresent', 'lowerlayerdown'].includes(text)) return 'down';
|
|
230
|
+
return 'unknown';
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function buildInterfaceBaseline(interfaces = {}) {
|
|
234
|
+
const list = [];
|
|
235
|
+
let index = 0;
|
|
236
|
+
for (const [name, entries] of Object.entries(interfaces || {})) {
|
|
237
|
+
const all = Array.isArray(entries) ? entries : [];
|
|
238
|
+
const v4 = all.filter(e => e && e.family === 'IPv4' && e.address);
|
|
239
|
+
const internal = all.length > 0 && all.every(e => e && e.internal);
|
|
240
|
+
list.push({
|
|
241
|
+
index: index++,
|
|
242
|
+
name,
|
|
243
|
+
displayName: name,
|
|
244
|
+
detail: '',
|
|
245
|
+
internal,
|
|
246
|
+
mac: (all.find(e => e && e.mac && !/^0{2}(:0{2}){5}$/i.test(e.mac)) || {}).mac || null,
|
|
247
|
+
type: inferInterfaceType(name, ''),
|
|
248
|
+
status: internal ? 'up' : 'unknown',
|
|
249
|
+
speed: null,
|
|
250
|
+
gateway: null,
|
|
251
|
+
isDefaultRoute: false,
|
|
252
|
+
addresses: v4.map(e => {
|
|
253
|
+
const prefix = prefixFromEntry(e);
|
|
254
|
+
return {
|
|
255
|
+
address: e.address,
|
|
256
|
+
netmask: e.netmask || netmaskFromPrefix(prefix),
|
|
257
|
+
prefix,
|
|
258
|
+
cidr: networkCidr(e.address, prefix),
|
|
259
|
+
};
|
|
260
|
+
}),
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
return list;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function matchBaseline(baseline, predicate) {
|
|
267
|
+
return baseline.find(predicate) || null;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function matchByName(baseline, name) {
|
|
271
|
+
const target = String(name || '').trim().toLowerCase();
|
|
272
|
+
if (!target) return null;
|
|
273
|
+
return matchBaseline(baseline, i => i.name.toLowerCase() === target)
|
|
274
|
+
|| matchBaseline(baseline, i => (i.displayName || '').toLowerCase() === target);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function safeJsonParse(text) {
|
|
278
|
+
try {
|
|
279
|
+
const parsed = JSON.parse(String(text || ''));
|
|
280
|
+
return parsed === undefined ? null : parsed;
|
|
281
|
+
} catch {
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function asArray(value) {
|
|
287
|
+
if (Array.isArray(value)) return value;
|
|
288
|
+
if (value && typeof value === 'object') return [value];
|
|
289
|
+
return [];
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export function parseWindowsAdapters(jsonText) {
|
|
293
|
+
const rows = asArray(safeJsonParse(jsonText));
|
|
294
|
+
return rows.map(row => ({
|
|
295
|
+
name: String(row?.Name || row?.name || '').trim(),
|
|
296
|
+
description: String(row?.InterfaceDescription || row?.description || '').trim(),
|
|
297
|
+
status: normalizeIfaceStatus(row?.Status ?? row?.status),
|
|
298
|
+
speed: row?.LinkSpeed ?? row?.speed ?? null,
|
|
299
|
+
mediaType: String(row?.MediaType || row?.mediaType || ''),
|
|
300
|
+
interfaceIndex: Number(row?.InterfaceIndex ?? row?.ifIndex ?? NaN),
|
|
301
|
+
})).filter(a => a.name);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export function parseWindowsDefaultRoutes(jsonText) {
|
|
305
|
+
const rows = asArray(safeJsonParse(jsonText));
|
|
306
|
+
return rows
|
|
307
|
+
.map(row => ({
|
|
308
|
+
interfaceIndex: Number(row?.InterfaceIndex ?? row?.ifIndex ?? NaN),
|
|
309
|
+
gateway: String(row?.NextHop || row?.gateway || '').trim(),
|
|
310
|
+
metric: Number(row?.RouteMetric ?? row?.metric ?? 0),
|
|
311
|
+
}))
|
|
312
|
+
.filter(r => Number.isInteger(r.interfaceIndex) && parseIpv4(r.gateway));
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export async function enrichWindowsInterfaces(baseline, run) {
|
|
316
|
+
const snapshot = safeJsonParse(await run('powershell.exe', [
|
|
317
|
+
'-NoProfile', '-NonInteractive', '-Command',
|
|
318
|
+
"@{ adapters = @(Get-NetAdapter -IncludeHidden | Select-Object Name,InterfaceDescription,Status,LinkSpeed,MediaType,InterfaceIndex); routes = @(Get-NetRoute -DestinationPrefix '0.0.0.0/0' -ErrorAction SilentlyContinue | Select-Object InterfaceIndex,NextHop,RouteMetric) } | ConvertTo-Json -Compress -Depth 3",
|
|
319
|
+
]).catch(() => ''));
|
|
320
|
+
const adapters = parseWindowsAdapters(JSON.stringify(snapshot?.adapters ?? []));
|
|
321
|
+
const routes = parseWindowsDefaultRoutes(JSON.stringify(snapshot?.routes ?? []));
|
|
322
|
+
const byName = new Map(adapters.map(a => [a.name.toLowerCase(), a]));
|
|
323
|
+
const byIndex = new Map();
|
|
324
|
+
for (const adapter of adapters) {
|
|
325
|
+
if (Number.isInteger(adapter.interfaceIndex)) byIndex.set(adapter.interfaceIndex, adapter);
|
|
326
|
+
}
|
|
327
|
+
let defaultRoute = null;
|
|
328
|
+
for (const route of routes) {
|
|
329
|
+
if (!defaultRoute || route.metric < defaultRoute.metric) defaultRoute = route;
|
|
330
|
+
}
|
|
331
|
+
const gatewayByAdapter = new Map();
|
|
332
|
+
for (const route of routes) {
|
|
333
|
+
const adapter = byIndex.get(route.interfaceIndex);
|
|
334
|
+
if (!adapter) continue;
|
|
335
|
+
const key = adapter.name.toLowerCase();
|
|
336
|
+
const prev = gatewayByAdapter.get(key);
|
|
337
|
+
if (!prev || route.metric < prev.metric) gatewayByAdapter.set(key, route);
|
|
338
|
+
}
|
|
339
|
+
const enriched = baseline.map(iface => {
|
|
340
|
+
const adapter = byName.get(iface.name.toLowerCase());
|
|
341
|
+
if (!adapter) return iface;
|
|
342
|
+
const next = { ...iface };
|
|
343
|
+
if (adapter.description) {
|
|
344
|
+
next.displayName = iface.name;
|
|
345
|
+
next.detail = adapter.description;
|
|
346
|
+
}
|
|
347
|
+
if (adapter.status !== 'unknown') next.status = adapter.status;
|
|
348
|
+
if (adapter.speed !== null && adapter.speed !== undefined) next.speed = String(adapter.speed);
|
|
349
|
+
const nameType = inferInterfaceType(iface.name, adapter.description);
|
|
350
|
+
if (nameType === 'virtual') {
|
|
351
|
+
next.type = 'virtual';
|
|
352
|
+
} else {
|
|
353
|
+
const mediaLower = adapter.mediaType.toLowerCase();
|
|
354
|
+
if (mediaLower.includes('802.11')) next.type = 'wifi';
|
|
355
|
+
else if (mediaLower.includes('802.3')) next.type = 'ethernet';
|
|
356
|
+
else if (next.type === 'unknown' && nameType !== 'unknown') next.type = nameType;
|
|
357
|
+
}
|
|
358
|
+
const gw = gatewayByAdapter.get(iface.name.toLowerCase());
|
|
359
|
+
if (gw) {
|
|
360
|
+
next.gateway = gw.gateway;
|
|
361
|
+
if (defaultRoute && gw.gateway === defaultRoute.gateway && gw.interfaceIndex === defaultRoute.interfaceIndex) {
|
|
362
|
+
next.isDefaultRoute = true;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
return next;
|
|
366
|
+
});
|
|
367
|
+
let nextIndex = enriched.reduce((top, i) => Math.max(top, Number(i.index) || 0), -1) + 1;
|
|
368
|
+
for (const adapter of adapters) {
|
|
369
|
+
if (enriched.some(i => i.name.toLowerCase() === adapter.name.toLowerCase())) continue;
|
|
370
|
+
const nameType = inferInterfaceType(adapter.name, adapter.description);
|
|
371
|
+
let type = 'unknown';
|
|
372
|
+
if (nameType === 'virtual') type = 'virtual';
|
|
373
|
+
else {
|
|
374
|
+
const mediaLower = adapter.mediaType.toLowerCase();
|
|
375
|
+
if (mediaLower.includes('802.11')) type = 'wifi';
|
|
376
|
+
else if (mediaLower.includes('802.3')) type = 'ethernet';
|
|
377
|
+
else if (nameType !== 'unknown') type = nameType;
|
|
378
|
+
}
|
|
379
|
+
const gw = gatewayByAdapter.get(adapter.name.toLowerCase());
|
|
380
|
+
enriched.push({
|
|
381
|
+
index: nextIndex++,
|
|
382
|
+
name: adapter.name,
|
|
383
|
+
displayName: adapter.name,
|
|
384
|
+
detail: adapter.description || '',
|
|
385
|
+
internal: false,
|
|
386
|
+
mac: null,
|
|
387
|
+
type,
|
|
388
|
+
status: adapter.status !== 'unknown' ? adapter.status : 'down',
|
|
389
|
+
speed: adapter.speed !== null && adapter.speed !== undefined ? String(adapter.speed) : null,
|
|
390
|
+
gateway: gw ? gw.gateway : null,
|
|
391
|
+
isDefaultRoute: Boolean(gw && defaultRoute && gw.gateway === defaultRoute.gateway && gw.interfaceIndex === defaultRoute.interfaceIndex),
|
|
392
|
+
addresses: [],
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
return enriched;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function flagsCarryUp(flags) {
|
|
399
|
+
const list = Array.isArray(flags) ? flags : String(flags || '').split(/[\s,<>|]+/);
|
|
400
|
+
return list.some(f => String(f || '').trim().toUpperCase() === 'UP');
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function statusWithFlagFallback(rawStatus, flags) {
|
|
404
|
+
const status = normalizeIfaceStatus(rawStatus);
|
|
405
|
+
if (status === 'unknown' && flagsCarryUp(flags)) return 'up';
|
|
406
|
+
return status;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
export function parseLinuxIpAddr(jsonText) {
|
|
410
|
+
const rows = asArray(safeJsonParse(jsonText));
|
|
411
|
+
return rows.map(row => ({
|
|
412
|
+
name: String(row?.ifname || row?.ifname === 0 ? row.ifname : (row?.ifname || '')).trim(),
|
|
413
|
+
status: statusWithFlagFallback(row?.operstate, row?.flags),
|
|
414
|
+
mac: String(row?.address || '').trim() || null,
|
|
415
|
+
mtu: Number(row?.mtu) || null,
|
|
416
|
+
})).filter(a => a.name);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
export function parseLinuxIpAddrText(text) {
|
|
420
|
+
const links = [];
|
|
421
|
+
const blocks = String(text || '').split(/^(?=\d+:\s+\S+:)/m);
|
|
422
|
+
for (const block of blocks) {
|
|
423
|
+
const header = /^(\d+):\s+([^:]+):\s*<([^>]*)>.*state\s+(\S+)/im.exec(block);
|
|
424
|
+
if (!header) continue;
|
|
425
|
+
const name = header[2].trim().split('@')[0];
|
|
426
|
+
if (!name) continue;
|
|
427
|
+
links.push({
|
|
428
|
+
name,
|
|
429
|
+
status: statusWithFlagFallback(header[4], header[3]),
|
|
430
|
+
mac: /link\/ether\s+([0-9a-f:]{17})/i.exec(block)?.[1]?.toLowerCase() || null,
|
|
431
|
+
mtu: Number(/mtu\s+(\d+)/i.exec(block)?.[1]) || null,
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
return links;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
export function parseLinuxDefaultRoutes(jsonText) {
|
|
438
|
+
const rows = asArray(safeJsonParse(jsonText));
|
|
439
|
+
return rows
|
|
440
|
+
.map(row => ({
|
|
441
|
+
device: String(row?.dev || row?.device || '').trim(),
|
|
442
|
+
gateway: String(row?.gateway || '').trim(),
|
|
443
|
+
metric: Number(row?.metric ?? 0),
|
|
444
|
+
}))
|
|
445
|
+
.filter(r => r.device && parseIpv4(r.gateway));
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
export async function enrichLinuxInterfaces(baseline, run) {
|
|
449
|
+
let links = [];
|
|
450
|
+
try {
|
|
451
|
+
links = parseLinuxIpAddr(await run('ip', ['-j', 'addr']));
|
|
452
|
+
} catch {
|
|
453
|
+
links = [];
|
|
454
|
+
}
|
|
455
|
+
if (links.length === 0) {
|
|
456
|
+
try {
|
|
457
|
+
links = parseLinuxIpAddrText(await run('ip', ['addr']));
|
|
458
|
+
} catch {
|
|
459
|
+
links = [];
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
const byName = new Map(links.map(l => [l.name, l]));
|
|
463
|
+
const routes = parseLinuxDefaultRoutes(await run('ip', ['-j', 'route', 'show', 'default']).catch(() => ''));
|
|
464
|
+
let defaultRoute = null;
|
|
465
|
+
for (const route of routes) {
|
|
466
|
+
if (!defaultRoute || route.metric < defaultRoute.metric) defaultRoute = route;
|
|
467
|
+
}
|
|
468
|
+
return baseline.map(iface => {
|
|
469
|
+
const next = { ...iface };
|
|
470
|
+
const link = byName.get(iface.name);
|
|
471
|
+
if (link) {
|
|
472
|
+
if (link.status !== 'unknown') next.status = link.status;
|
|
473
|
+
if (!next.mac && link.mac) next.mac = link.mac;
|
|
474
|
+
if (next.type === 'unknown') next.type = inferInterfaceType(iface.name, '');
|
|
475
|
+
}
|
|
476
|
+
const route = routes.find(r => r.device === iface.name);
|
|
477
|
+
if (route) {
|
|
478
|
+
next.gateway = route.gateway;
|
|
479
|
+
if (defaultRoute && defaultRoute.device === iface.name) next.isDefaultRoute = true;
|
|
480
|
+
}
|
|
481
|
+
return next;
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
export function parseMacHardwarePorts(text) {
|
|
486
|
+
const ports = [];
|
|
487
|
+
const blocks = String(text || '').split(/\n\s*\n/);
|
|
488
|
+
for (const block of blocks) {
|
|
489
|
+
const port = /Hardware Port:\s*(.+)/i.exec(block)?.[1]?.trim();
|
|
490
|
+
const device = /Device:\s*(\S+)/i.exec(block)?.[1]?.trim();
|
|
491
|
+
if (port && device) ports.push({ port, device });
|
|
492
|
+
}
|
|
493
|
+
return ports;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
export function parseMacDefaultRoute(text) {
|
|
497
|
+
const gateway = /gateway:\s*([0-9.]+)/i.exec(String(text || ''))?.[1] || null;
|
|
498
|
+
const device = /interface:\s*(\S+)/i.exec(String(text || ''))?.[1] || null;
|
|
499
|
+
return {
|
|
500
|
+
gateway: gateway && parseIpv4(gateway) ? gateway : null,
|
|
501
|
+
device,
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
export function parseMacIfconfig(text) {
|
|
506
|
+
const result = new Map();
|
|
507
|
+
const blocks = String(text || '').split(/^(?=\S+:)/m);
|
|
508
|
+
for (const block of blocks) {
|
|
509
|
+
const name = /^([A-Za-z0-9_.]+):/.exec(block)?.[1];
|
|
510
|
+
if (!name) continue;
|
|
511
|
+
const statusText = /status:\s*(\S+)/i.exec(block)?.[1] || '';
|
|
512
|
+
const media = /media:\s*([^\n<]+)/i.exec(block)?.[1]?.trim() || '';
|
|
513
|
+
const flags = /^.+flags=\d+<([^>]+)>/m.exec(block)?.[1] || '';
|
|
514
|
+
let status = normalizeIfaceStatus(statusText);
|
|
515
|
+
if (status === 'unknown') {
|
|
516
|
+
status = /,UP,/i.test(`,${flags},`) ? 'up' : status;
|
|
517
|
+
}
|
|
518
|
+
result.set(name, { status, media });
|
|
519
|
+
}
|
|
520
|
+
return result;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
export async function enrichMacInterfaces(baseline, run) {
|
|
524
|
+
const [ifconfigText, routeText, portsText] = await Promise.all([
|
|
525
|
+
run('ifconfig', []).catch(() => ''),
|
|
526
|
+
run('route', ['-n', 'get', 'default']).catch(() => ''),
|
|
527
|
+
run('networksetup', ['-listallhardwareports']).catch(() => ''),
|
|
528
|
+
]);
|
|
529
|
+
const states = parseMacIfconfig(ifconfigText);
|
|
530
|
+
const defRoute = parseMacDefaultRoute(routeText);
|
|
531
|
+
const ports = parseMacHardwarePorts(portsText);
|
|
532
|
+
const portByDevice = new Map(ports.map(p => [p.device, p.port]));
|
|
533
|
+
return baseline.map(iface => {
|
|
534
|
+
const next = { ...iface };
|
|
535
|
+
const state = states.get(iface.name);
|
|
536
|
+
if (state) {
|
|
537
|
+
if (state.status !== 'unknown') next.status = state.status;
|
|
538
|
+
if (next.type === 'unknown') {
|
|
539
|
+
const mediaLower = state.media.toLowerCase();
|
|
540
|
+
if (/wi-?fi|wlan|802\.11/.test(mediaLower)) next.type = 'wifi';
|
|
541
|
+
else if (/ethernet|thunderbolt|1000base|100base|10gbase/.test(mediaLower)) next.type = 'ethernet';
|
|
542
|
+
else next.type = inferInterfaceType(iface.name, state.media);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
const port = portByDevice.get(iface.name);
|
|
546
|
+
if (port) {
|
|
547
|
+
next.displayName = `${port} (${iface.name})`;
|
|
548
|
+
if (next.type === 'unknown') next.type = inferInterfaceType(port, '');
|
|
549
|
+
}
|
|
550
|
+
if (defRoute.device === iface.name) {
|
|
551
|
+
next.gateway = defRoute.gateway;
|
|
552
|
+
next.isDefaultRoute = true;
|
|
553
|
+
}
|
|
554
|
+
return next;
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function defaultExecFn(cmd, args, { timeoutMs = LAN_INTERFACE_EXEC_TIMEOUT_MS } = {}) {
|
|
559
|
+
return execFileAsync(cmd, args, { timeout: timeoutMs, windowsHide: true, maxBuffer: 4 * 1024 * 1024 })
|
|
560
|
+
.then(result => String(result?.stdout ?? ''));
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
export async function listLanInterfaces({
|
|
564
|
+
interfaces = os.networkInterfaces(),
|
|
565
|
+
platform = process.platform,
|
|
566
|
+
execFn = null,
|
|
567
|
+
timeoutMs = LAN_INTERFACE_EXEC_TIMEOUT_MS,
|
|
568
|
+
} = {}) {
|
|
569
|
+
const baseline = buildInterfaceBaseline(interfaces);
|
|
570
|
+
const enricher = platform === 'win32'
|
|
571
|
+
? enrichWindowsInterfaces
|
|
572
|
+
: platform === 'linux'
|
|
573
|
+
? enrichLinuxInterfaces
|
|
574
|
+
: platform === 'darwin'
|
|
575
|
+
? enrichMacInterfaces
|
|
576
|
+
: null;
|
|
577
|
+
if (!enricher) return baseline;
|
|
578
|
+
const run = execFn || ((cmd, args) => defaultExecFn(cmd, args, { timeoutMs }));
|
|
579
|
+
try {
|
|
580
|
+
return await enricher(baseline, run);
|
|
581
|
+
} catch {
|
|
582
|
+
return baseline;
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function ifacePrivateCidrs(iface) {
|
|
587
|
+
const cidrs = [];
|
|
588
|
+
for (const addr of iface?.addresses || []) {
|
|
589
|
+
if (!addr?.address || !isPrivateIPv4(addr.address)) continue;
|
|
590
|
+
if (addr.address.startsWith('127.') || addr.address.startsWith('169.254.')) continue;
|
|
591
|
+
if (addr.cidr) cidrs.push(addr.cidr);
|
|
592
|
+
}
|
|
593
|
+
return [...new Set(cidrs)];
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
export function classifyScanToken(token = '') {
|
|
597
|
+
const spec = String(token || '').trim();
|
|
598
|
+
if (!spec) return 'empty';
|
|
599
|
+
if (spec.includes('/')) return 'subnet';
|
|
600
|
+
if (/^\d+\.\d+\.\d+\.\d+\s*-\s*\d+\.\d+\.\d+\.\d+$/.test(spec)) return 'subnet';
|
|
601
|
+
if (parseIpv4(spec)) return 'subnet';
|
|
602
|
+
if (/^\d+\.\d+\.\d+\.\d+/.test(spec)) return 'subnet';
|
|
603
|
+
return 'name';
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
export async function resolveScanSubnets({
|
|
607
|
+
iface = null,
|
|
608
|
+
subnets = null,
|
|
609
|
+
interfaces = null,
|
|
610
|
+
platform = process.platform,
|
|
611
|
+
execFn = null,
|
|
612
|
+
maxHosts = DEFAULT_MAX_SCAN_HOSTS,
|
|
613
|
+
} = {}) {
|
|
614
|
+
if (Array.isArray(subnets) && subnets.length > 0) return [...subnets];
|
|
615
|
+
if (iface === null || iface === undefined || String(iface).trim() === '' || String(iface).trim().toLowerCase() === 'auto') {
|
|
616
|
+
return null;
|
|
617
|
+
}
|
|
618
|
+
const spec = String(iface).trim();
|
|
619
|
+
if (classifyScanToken(spec) === 'subnet' && !/^\d+$/.test(spec)) {
|
|
620
|
+
return [spec];
|
|
621
|
+
}
|
|
622
|
+
const list = await listLanInterfaces({ ...(interfaces ? { interfaces } : {}), platform, execFn });
|
|
623
|
+
let matched = null;
|
|
624
|
+
if (/^\d+$/.test(spec)) {
|
|
625
|
+
matched = list[Number(spec)] || null;
|
|
626
|
+
} else {
|
|
627
|
+
matched = matchByName(list, spec);
|
|
628
|
+
if (!matched) {
|
|
629
|
+
const lower = spec.toLowerCase();
|
|
630
|
+
const partials = list.filter(i =>
|
|
631
|
+
i.name.toLowerCase().includes(lower) || (i.displayName || '').toLowerCase().includes(lower));
|
|
632
|
+
if (partials.length === 1) {
|
|
633
|
+
matched = partials[0];
|
|
634
|
+
} else if (partials.length > 1) {
|
|
635
|
+
throw new Error(`Ambiguous interface "${spec}" — matches: ${partials.map(i => `${i.index}:${i.name}`).join(', ')}. Use the index from /lan ifs.`);
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
if (!matched) {
|
|
640
|
+
const available = list.map(i => `${i.index}:${i.name}`).join(', ') || 'none';
|
|
641
|
+
throw new Error(`Unknown LAN interface "${spec}". Available: ${available}`);
|
|
642
|
+
}
|
|
643
|
+
if (matched.internal || matched.type === 'loopback') {
|
|
644
|
+
throw new Error(`Interface "${matched.name}" is internal/loopback and has no LAN subnet to scan`);
|
|
645
|
+
}
|
|
646
|
+
const cidrs = ifacePrivateCidrs(matched);
|
|
647
|
+
if (cidrs.length === 0) {
|
|
648
|
+
throw new Error(`Interface "${matched.name}" has no private IPv4 subnet to scan`);
|
|
649
|
+
}
|
|
650
|
+
const cap = Number(maxHosts) > 0 ? Math.floor(Number(maxHosts)) : DEFAULT_MAX_SCAN_HOSTS;
|
|
651
|
+
let total = 0;
|
|
652
|
+
for (const cidr of cidrs) {
|
|
653
|
+
const prefix = Number(String(cidr).split('/')[1]);
|
|
654
|
+
total += Number.isInteger(prefix) ? usableHostCount(prefix) : 0;
|
|
655
|
+
}
|
|
656
|
+
if (total > cap) {
|
|
657
|
+
const first = String(cidrs[0]).split('/')[0].split('.').slice(0, 3).join('.');
|
|
658
|
+
throw new Error(
|
|
659
|
+
`Interface "${matched.name}" covers ${cidrs.join(', ')} (${total.toLocaleString()} hosts) — over the ${cap.toLocaleString()}-host cap. ` +
|
|
660
|
+
`Scan a slice instead, e.g. /lan scan ${first}.0/24, or raise KIKU_LAN_SCAN_MAX_HOSTS.`
|
|
661
|
+
);
|
|
662
|
+
}
|
|
663
|
+
return cidrs;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
export function parseLanScanArgs(restTokens = []) {
|
|
667
|
+
const tokens = [...(restTokens || []).map(t => String(t ?? '').trim()).filter(Boolean)];
|
|
668
|
+
let ports = null;
|
|
669
|
+
let iface = null;
|
|
670
|
+
let force = false;
|
|
671
|
+
let error = null;
|
|
672
|
+
|
|
673
|
+
const takeOption = (names) => {
|
|
674
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
675
|
+
const token = tokens[i];
|
|
676
|
+
for (const name of names) {
|
|
677
|
+
if (token === name) {
|
|
678
|
+
const value = tokens[i + 1] ?? null;
|
|
679
|
+
tokens.splice(i, 2);
|
|
680
|
+
return value;
|
|
681
|
+
}
|
|
682
|
+
if (token.startsWith(`${name}=`)) {
|
|
683
|
+
tokens.splice(i, 1);
|
|
684
|
+
return token.slice(name.length + 1);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
return undefined;
|
|
689
|
+
};
|
|
690
|
+
|
|
691
|
+
const forceIdx = tokens.findIndex(t => t === '--force' || t === '-f');
|
|
692
|
+
if (forceIdx >= 0) {
|
|
693
|
+
force = true;
|
|
694
|
+
tokens.splice(forceIdx, 1);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
const portsRaw = takeOption(['--ports']);
|
|
698
|
+
if (portsRaw !== undefined) {
|
|
699
|
+
const parsed = String(portsRaw || '').split(',').map(Number)
|
|
700
|
+
.filter(p => Number.isInteger(p) && p >= 1 && p <= 65535);
|
|
701
|
+
if (!portsRaw || parsed.length === 0) {
|
|
702
|
+
error = 'ports';
|
|
703
|
+
} else {
|
|
704
|
+
ports = [...new Set(parsed)];
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
const ifaceRaw = takeOption(['--iface', '--interface']);
|
|
709
|
+
if (ifaceRaw !== undefined) {
|
|
710
|
+
if (!ifaceRaw || !String(ifaceRaw).trim()) {
|
|
711
|
+
error = error || 'iface';
|
|
712
|
+
} else {
|
|
713
|
+
iface = String(ifaceRaw).trim();
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
const subnets = tokens.join(',').split(',').map(s => s.trim()).filter(Boolean);
|
|
718
|
+
return {
|
|
719
|
+
subnets: subnets.length > 0 ? subnets : null,
|
|
720
|
+
iface,
|
|
721
|
+
ports,
|
|
722
|
+
force,
|
|
723
|
+
error,
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
export function describeLanInterface(iface = {}) {
|
|
728
|
+
const cidrs = (iface.addresses || []).map(a => a?.cidr).filter(Boolean).join(', ') || 'no IPv4';
|
|
729
|
+
const parts = [
|
|
730
|
+
`[${iface.index ?? '?'}] ${iface.displayName || iface.name || 'unknown'}`,
|
|
731
|
+
`type=${iface.type || 'unknown'}`,
|
|
732
|
+
`status=${iface.status || 'unknown'}`,
|
|
733
|
+
`subnet=${cidrs}`,
|
|
734
|
+
];
|
|
735
|
+
if (iface.gateway) parts.push(`gw=${iface.gateway}`);
|
|
736
|
+
if (iface.isDefaultRoute) parts.push('default');
|
|
737
|
+
if (iface.internal) parts.push('loopback');
|
|
738
|
+
return parts.join(' • ');
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
export const LAN_STATE_FILENAME = 'lan-state.json';
|
|
742
|
+
|
|
743
|
+
export function lanStateFile(dataDir = KIKU_DATA_DIR) {
|
|
744
|
+
return path.join(String(dataDir || KIKU_DATA_DIR), LAN_STATE_FILENAME);
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
export async function loadLanState({ dataDir = KIKU_DATA_DIR } = {}) {
|
|
748
|
+
try {
|
|
749
|
+
const raw = await fsp.readFile(lanStateFile(dataDir), 'utf8');
|
|
750
|
+
const parsed = JSON.parse(raw);
|
|
751
|
+
if (!parsed || typeof parsed !== 'object') return { lastIface: null, lastScan: null };
|
|
752
|
+
return {
|
|
753
|
+
lastIface: typeof parsed.lastIface === 'string' ? parsed.lastIface : null,
|
|
754
|
+
lastScan: parsed.lastScan && typeof parsed.lastScan === 'object' ? parsed.lastScan : null,
|
|
755
|
+
};
|
|
756
|
+
} catch {
|
|
757
|
+
return { lastIface: null, lastScan: null };
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
export async function saveLanState(patch = {}, { dataDir = KIKU_DATA_DIR } = {}) {
|
|
762
|
+
const current = await loadLanState({ dataDir });
|
|
763
|
+
const next = {
|
|
764
|
+
lastIface: patch.lastIface !== undefined ? patch.lastIface : current.lastIface,
|
|
765
|
+
lastScan: patch.lastScan !== undefined ? patch.lastScan : current.lastScan,
|
|
766
|
+
};
|
|
767
|
+
await fsp.mkdir(String(dataDir || KIKU_DATA_DIR), { recursive: true });
|
|
768
|
+
await fsp.writeFile(lanStateFile(dataDir), JSON.stringify(next, null, 2), 'utf8');
|
|
769
|
+
return next;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
export function describeLanScanAge(savedAt, now = Date.now()) {
|
|
773
|
+
const then = Number(new Date(savedAt || 0).getTime());
|
|
774
|
+
if (!Number.isFinite(then) || then <= 0) return 'unknown age';
|
|
775
|
+
const minutes = Math.max(0, Math.floor((now - then) / 60000));
|
|
776
|
+
if (minutes < 1) return 'just now';
|
|
777
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
778
|
+
const hours = Math.floor(minutes / 60);
|
|
779
|
+
if (hours < 24) return `${hours}h ago`;
|
|
780
|
+
return `${Math.floor(hours / 24)}d ago`;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
export function pickLanEndpointModel(endpoint = {}, wanted = null) {
|
|
784
|
+
const models = Array.isArray(endpoint.models) ? endpoint.models.filter(Boolean) : [];
|
|
785
|
+
if (models.length === 0) return { model: null, matched: false };
|
|
786
|
+
const query = String(wanted || '').trim();
|
|
787
|
+
if (!query) return { model: models[0], matched: true, via: 'first' };
|
|
788
|
+
if (/^\d+$/.test(query)) {
|
|
789
|
+
const pick = models[Number(query) - 1];
|
|
790
|
+
return pick ? { model: pick, matched: true, via: 'index' } : { model: null, matched: false };
|
|
791
|
+
}
|
|
792
|
+
const lower = query.toLowerCase();
|
|
793
|
+
const exact = models.find(m => String(m).toLowerCase() === lower);
|
|
794
|
+
if (exact) return { model: exact, matched: true, via: 'exact' };
|
|
795
|
+
const partials = models.filter(m => String(m).toLowerCase().includes(lower));
|
|
796
|
+
if (partials.length === 1) return { model: partials[0], matched: true, via: 'substring' };
|
|
797
|
+
return { model: null, matched: false, ambiguous: partials };
|
|
798
|
+
}
|
|
799
|
+
|
|
132
800
|
function assertAllPrivate(hosts) {
|
|
133
801
|
const offenders = hosts.filter(h => {
|
|
134
802
|
const octets = parseIpv4(h);
|
|
@@ -300,6 +968,10 @@ export async function scanLan({
|
|
|
300
968
|
probeTimeoutMs = 2500,
|
|
301
969
|
concurrency = 128,
|
|
302
970
|
subnets = null,
|
|
971
|
+
iface = null,
|
|
972
|
+
interfaces = null,
|
|
973
|
+
platform = process.platform,
|
|
974
|
+
execFn = null,
|
|
303
975
|
extraHosts = [],
|
|
304
976
|
maxHosts = Number(process.env.KIKU_LAN_SCAN_MAX_HOSTS) || DEFAULT_MAX_SCAN_HOSTS,
|
|
305
977
|
connector = defaultTcpProbe,
|
|
@@ -314,12 +986,37 @@ export async function scanLan({
|
|
|
314
986
|
);
|
|
315
987
|
if (portList.length === 0) throw new Error('At least one valid port is required');
|
|
316
988
|
|
|
317
|
-
const
|
|
989
|
+
const explicitSubnets = Array.isArray(subnets) && subnets.length > 0 ? [...subnets] : null;
|
|
990
|
+
const ifaceSubnets = explicitSubnets
|
|
991
|
+
? null
|
|
992
|
+
: await resolveScanSubnets({ iface, interfaces, platform, execFn, maxHosts });
|
|
993
|
+
let subnetList = explicitSubnets || ifaceSubnets;
|
|
994
|
+
let skippedSubnets = [];
|
|
995
|
+
if (!subnetList) {
|
|
996
|
+
const ordered = getLanCandidateSubnets(interfaces ?? os.networkInterfaces())
|
|
997
|
+
.sort((a, b) => a.hostCount - b.hostCount);
|
|
998
|
+
const picked = [];
|
|
999
|
+
let used = 0;
|
|
1000
|
+
for (const candidate of ordered) {
|
|
1001
|
+
if (used + candidate.hostCount <= maxHosts) {
|
|
1002
|
+
picked.push(candidate);
|
|
1003
|
+
used += candidate.hostCount;
|
|
1004
|
+
} else {
|
|
1005
|
+
skippedSubnets.push({
|
|
1006
|
+
cidr: candidate.cidr,
|
|
1007
|
+
interfaceName: candidate.interfaceName,
|
|
1008
|
+
hostCount: candidate.hostCount,
|
|
1009
|
+
reason: 'over-budget',
|
|
1010
|
+
});
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
subnetList = picked.map(c => c.cidr);
|
|
1014
|
+
}
|
|
318
1015
|
const detectedHosts = expandTargets(subnetList, { maxHosts });
|
|
319
1016
|
const hostList = [...new Set(['127.0.0.1', ...detectedHosts, ...extraHosts.map(String)])];
|
|
320
1017
|
assertAllPrivate(hostList);
|
|
321
1018
|
|
|
322
|
-
const cacheKey = JSON.stringify({ ports: portList, hosts: hostList });
|
|
1019
|
+
const cacheKey = JSON.stringify({ ports: portList, hosts: hostList, iface: iface ?? null });
|
|
323
1020
|
if (!force && scanCache && scanCache.key === cacheKey && now() - scanCache.at < CACHE_TTL_MS) {
|
|
324
1021
|
return { ...scanCache.result, cached: true };
|
|
325
1022
|
}
|
|
@@ -356,6 +1053,7 @@ export async function scanLan({
|
|
|
356
1053
|
const result = {
|
|
357
1054
|
scannedAt: new Date(started).toISOString(),
|
|
358
1055
|
subnets: subnetList,
|
|
1056
|
+
skippedSubnets,
|
|
359
1057
|
hostsScanned: hostList.length,
|
|
360
1058
|
ports: portList,
|
|
361
1059
|
targetsScanned: targets.length,
|