Skip to content

Commit ecbfcd7

Browse files
Merge pull request #365 from alichherawalla/fix/lan-discovery-ipv6-fallback-subnet
fix(lan-discovery): restore IPv6 fallback subnet scan broken in 56790cf
2 parents 2682e05 + 00def5a commit ecbfcd7

1 file changed

Lines changed: 37 additions & 25 deletions

File tree

src/services/networkDiscovery.ts

Lines changed: 37 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -69,30 +69,30 @@ const FALLBACK_SUBNETS = ['192.168.1', '192.168.0'];
6969
* Returns the first reachable subnet base, or null if none respond.
7070
* Uses a short timeout so we bail fast when on cellular.
7171
*/
72-
async function findReachableSubnet(subnets: string[]): Promise<string | null> {
72+
async function findReachableSubnet(subnets: string[], log: (msg: string) => void): Promise<string | null> {
7373
const GATEWAY_TIMEOUT_MS = 800;
7474
const results = await Promise.all(
7575
subnets.map(async (base) => {
7676
const gateway = `${base}.1`;
77-
// Try any HTTP connection to the gateway — we don't care about the response,
78-
// just that something is listening on the local network.
7977
const controller = new AbortController();
8078
const timer = setTimeout(() => controller.abort(), GATEWAY_TIMEOUT_MS);
8179
try {
8280
await fetch(`http://${gateway}:80/`, { signal: controller.signal }); // NOSONAR — LAN gateway probe
8381
clearTimeout(timer);
82+
log(`Gateway ${gateway}:80 responded`);
8483
return base;
8584
} catch {
8685
clearTimeout(timer);
87-
// Also try the Ollama port since routers may not serve HTTP on :80
8886
const controller2 = new AbortController();
8987
const timer2 = setTimeout(() => controller2.abort(), GATEWAY_TIMEOUT_MS);
9088
try {
9189
await fetch(`http://${gateway}:11434/`, { signal: controller2.signal }); // NOSONAR — LAN Ollama probe
9290
clearTimeout(timer2);
91+
log(`Gateway ${gateway}:11434 responded`);
9392
return base;
9493
} catch {
9594
clearTimeout(timer2);
95+
log(`Gateway ${gateway} did not respond on :80 or :11434`);
9696
return null;
9797
}
9898
}
@@ -107,53 +107,64 @@ async function findReachableSubnet(subnets: string[]): Promise<string | null> {
107107
* Throws with a human-readable message if setup fails (no WiFi IP, non-private network).
108108
* Errors during probing are swallowed — only setup errors propagate.
109109
*/
110-
export async function discoverLANServers(): Promise<DiscoveredServer[]> {
110+
export async function discoverLANServers(onLog?: (msg: string) => void): Promise<DiscoveredServer[]> {
111+
const log = (msg: string) => {
112+
logger.warn('[Discovery]', msg);
113+
onLog?.(msg);
114+
};
115+
111116
let runningOnEmulator: boolean;
112117
try {
113118
runningOnEmulator = await isEmulator();
114119
} catch (err) {
115-
logger.warn('[Discovery] isEmulator() threw:', (err as Error).message);
120+
log(`isEmulator() threw: ${(err as Error).message} — assuming not emulator`);
116121
runningOnEmulator = false;
117122
}
118123
if (runningOnEmulator) {
119-
logger.warn('[Discovery] Running on emulator — skipping LAN scan (emulator network stack cannot handle concurrent probes)');
124+
log('Running on emulator — skipping scan (emulator network stack cannot handle concurrent probes)');
120125
return [];
121126
}
122127

128+
log('Not an emulator — proceeding');
129+
123130
let ip: string | null;
124131
try {
125132
ip = await getIpAddress();
126133
} catch (err) {
127-
logger.warn('[Discovery] getIpAddress threw:', (err as Error).message);
134+
log(`getIpAddress() threw: ${(err as Error).message}`);
128135
ip = null;
129136
}
130137

138+
const ipv6 = ip ? isIPv6(ip) : false;
139+
const privateV4 = ip ? isPrivateIPv4(ip) : false;
140+
log(`Device IP: ${ip ?? 'null'} | IPv6: ${ipv6} | privateIPv4: ${privateV4}`);
141+
131142
let subnetsToScan: string[];
132143

133144
if (!ip || ip === '0.0.0.0' || ip === '127.0.0.1') {
134-
logger.warn('[Discovery] No WiFi IP (got:', ip || 'null', ') — skipping LAN scan');
145+
log(`No usable IP (got: ${ip ?? 'null'}) — skipping scan`);
135146
return [];
136-
} else if (isIPv6(ip)) {
137-
// IPv6 primary address — could be WiFi or cellular, but we can't scan IPv4 subnets
138-
// without a real IP. Quick-probe the two most common gateways before committing to a
139-
// full subnet scan so we don't waste time on cellular.
140-
logger.warn('[Discovery] Got IPv6 address:', ip, '— quick-probing common gateways');
141-
const reachableSubnet = await findReachableSubnet(FALLBACK_SUBNETS);
142-
if (!reachableSubnet) {
143-
logger.warn('[Discovery] No gateway responded — likely not on WiFi, skipping scan');
144-
return [];
147+
} else if (ipv6) {
148+
log(`IPv6 address detected — probing gateways on fallback subnets: ${FALLBACK_SUBNETS.join(', ')}`);
149+
const reachableSubnet = await findReachableSubnet(FALLBACK_SUBNETS, log);
150+
if (reachableSubnet) {
151+
log(`Gateway responded on subnet ${reachableSubnet} — scanning that subnet only`);
152+
subnetsToScan = [reachableSubnet];
153+
} else {
154+
log('No gateway responded — scanning all fallback subnets anyway (device may still be on WiFi)');
155+
subnetsToScan = FALLBACK_SUBNETS;
145156
}
146-
subnetsToScan = [reachableSubnet];
147157
} else {
148158
const base = subnetBase(ip);
149159
if (!base) {
150-
logger.warn('[Discovery] IP is not on a private network:', ip, '— skipping LAN scan');
160+
log(`IP ${ip} is not on a private network — skipping scan`);
151161
return [];
152162
}
163+
log(`IPv4 private address — subnet base: ${base}`);
153164
subnetsToScan = [base];
154165
}
155166

156-
logger.log('[Discovery] Scanning subnets:', subnetsToScan.map(s => `${s}.0/24`).join(', '));
167+
log(`Scanning ${subnetsToScan.length} subnet(s): ${subnetsToScan.map(s => `${s}.0/24`).join(', ')} | ${subnetsToScan.length * 254 * PROVIDERS.length} total probes | batch size: ${BATCH_SIZE} | timeout: ${TIMEOUT_MS}ms`);
157168

158169
try {
159170
const discovered: DiscoveredServer[] = [];
@@ -164,26 +175,27 @@ export async function discoverLANServers(): Promise<DiscoveredServer[]> {
164175
const endpoint = `http://${target}:${provider.port}`; // NOSONAR — LAN endpoint
165176
if (!seenEndpoints.has(endpoint)) {
166177
seenEndpoints.add(endpoint);
167-
logger.log(`[Discovery] Found ${provider.name} at ${target}:${provider.port}`);
178+
log(`Found ${provider.name} at ${target}:${provider.port}`);
168179
discovered.push({ endpoint, type: provider.type, name: `${provider.name} (${target})` });
169180
}
170181
};
171182

172-
// Scan all subnets in parallel — each subnet is independent
173183
await Promise.all(subnetsToScan.map(async (base) => {
174184
for (const provider of PROVIDERS) {
185+
log(`Probing ${base}.1-254 for ${provider.name} on port ${provider.port}...`);
175186
const tasks = Array.from({ length: 254 }, (_, i) => {
176187
const target = `${base}.${i + 1}`;
177188
return () => probe(target, provider.port, provider.probePath).then(recordIfFound(target, provider));
178189
});
179190
await runBatch(tasks);
191+
log(`Done probing ${base}.x for ${provider.name}`);
180192
}
181193
}));
182194

183-
logger.log('[Discovery] Scan complete, found:', discovered.length, 'servers');
195+
log(`Scan completefound ${discovered.length} server(s)`);
184196
return discovered;
185197
} catch (error) {
186-
logger.warn('[Discovery] Scan error during probing:', error);
198+
log(`Scan error: ${error instanceof Error ? error.message : String(error)}`);
187199
return [];
188200
}
189201
}

0 commit comments

Comments
 (0)