()
| 5521 | if (ve) { |
| 5522 | let veHtml; |
| 5523 | if (ve.via_vpn) { |
| 5524 | const via = escapeHtml(String(ve.interface || 'tunnel')) |
| 5525 | + (ve.kind ? ' · ' + escapeHtml(ve.kind) : '') |
| 5526 | + (ve.endpoint ? ' → ' + escapeHtml(ve.endpoint) : ''); |
| 5527 | veHtml = '<span class="text-amber-300">yes</span> <span class="text-gray-500">(via ' + via + ')</span>'; |
| 5528 | } else { |
| 5529 | veHtml = '<span id="vpn-egress-result" class="text-gray-500">no local tunnel</span> ' |
| 5530 | + '<select id="vpn-egress-iface" class="bg-slate-800 border border-slate-700 rounded text-xs text-gray-300 px-1 py-0.5 mx-1">' |
| 5531 | + '<option value="">auto (default route)</option></select>' |
| 5532 | + '<button onclick="checkVpnEgress()" class="text-xs text-cyan-400 hover:text-cyan-300 underline">check egress (catches VPN/Tor on the router)</button>'; |
| 5533 | } |
| 5534 | row('Traffic via VPN', veHtml); |
| 5535 | } |
| 5536 | |
| 5537 | const scopeNote = d.interface |
| 5538 | ? `<p class="text-[11px] text-cyan-400/80 mt-2">Scoped to <span class="font-mono">${escapeHtml(d.interface)}</span> — gateway + nameservers are what this interface's own network provides.</p>` : ''; |
| 5539 | const src = (d.sources && d.sources.length) |
| 5540 | ? `<p class="text-[11px] text-gray-500 mt-2">Sources: ${d.sources.map(escapeHtml).join(', ')}</p>` : ''; |
| 5541 | out.innerHTML = `<table class="min-w-full text-sm"> |
| 5542 | <tbody>${rows.join('')}</tbody></table>${scopeNote}${src}`; |
| 5543 | // Fill the egress-check interface selector (auto + each real NIC) so |
| 5544 | // the LAN path can be tested even when WiFi carries the default route. |
| 5545 | const sel = document.getElementById('vpn-egress-iface'); |
| 5546 | if (sel) { |
| 5547 | fetchAPI('/api/net/interfaces').then(x => { |
| 5548 | (x.interfaces || []).forEach(i => { |
| 5549 | const o = document.createElement('option'); |
| 5550 | o.value = i.name; |
| 5551 | o.textContent = i.name + (i.type && i.type !== 'ethernet' ? ' (' + i.type + ')' : ''); |
| 5552 | sel.appendChild(o); |
| 5553 | }); |
| 5554 | // A scoped identity view should test the same interface's egress. |
| 5555 | if (d.interface) sel.value = d.interface; |
| 5556 | }).catch(() => {}); |
| 5557 | } |
| 5558 | } catch (e) { |
| 5559 | out.innerHTML = '<p class="text-red-400">Failed: ' + escapeHtml(e.message) + '</p>'; |
| 5560 | } |
| 5561 | } |
| 5562 | |
| 5563 | async function checkVpnEgress() { |
| 5564 | const cell = document.getElementById('vpn-egress-result'); |
| 5565 | if (!cell) return; |
| 5566 | const sel = document.getElementById('vpn-egress-iface'); |
| 5567 | const iface = sel && sel.value ? sel.value : ''; |
| 5568 | const label = iface ? 'via ' + escapeHtml(iface) : 'default route'; |
| 5569 | cell.innerHTML = '<span class="text-gray-400">checking egress ' + label + '… (public IP, known-VPN IP list, Tor exit)</span>'; |
| 5570 | try { |
| 5571 | const d = await fetchAPI('/api/net/vpn-check' + (iface ? '?interface=' + encodeURIComponent(iface) : '')); |
| 5572 | const ifNote = d.interface ? ' <span class="text-gray-500">[' + escapeHtml(d.interface) + ']</span>' : ''; |
| 5573 | const why = (d.reasons && d.reasons.length) |
| 5574 | ? ' <span class="text-gray-500">(' + d.reasons.map(escapeHtml).join('; ') + ')</span>' : ''; |
| 5575 | if (d.verdict === 'vpn') { |
| 5576 | cell.innerHTML = '<span class="text-amber-300">yes</span>' + ifNote + why; |
| 5577 | } else if (d.verdict === 'likely') { |
| 5578 | cell.innerHTML = '<span class="text-amber-300">likely</span>' + ifNote + why; |
| 5579 | } else if (d.verdict === 'no') { |
| 5580 | const via = [d.isp, d.public_ip].filter(Boolean).map(escapeHtml).join(' · '); |
no test coverage detected