()
| 5996 | // Gateway |
| 5997 | let gwHtml = d.interface |
| 5998 | ? '<span class="text-gray-500">none — no route via ' + escapeHtml(d.interface) + ' (segment without a gateway, or DHCP still pending)</span>' |
| 5999 | : '<span class="text-gray-500">—</span>'; |
| 6000 | if (d.gateway && d.gateway.ip) { |
| 6001 | gwHtml = escapeHtml(d.gateway.ip); |
| 6002 | if (d.gateway.ptr) gwHtml += ' <span class="text-gray-500">(' + escapeHtml(d.gateway.ptr) + ')</span>'; |
| 6003 | } |
| 6004 | row(d.interface ? 'Gateway via ' + d.interface : 'Default gateway', gwHtml); |
| 6005 | |
| 6006 | // Traffic via VPN — a local tunnel on the default route is shown |
| 6007 | // instantly; anything else needs the *egress* checked (VPN/Tor on the |
| 6008 | // router is invisible locally), which makes outbound calls, so that |
| 6009 | // runs on demand via the button (checkVpnEgress → /api/net/vpn-check). |
| 6010 | const ve = d.vpn_egress; |
| 6011 | if (ve) { |
| 6012 | let veHtml; |
| 6013 | if (ve.via_vpn) { |
| 6014 | const via = escapeHtml(String(ve.interface || 'tunnel')) |
| 6015 | + (ve.kind ? ' · ' + escapeHtml(ve.kind) : '') |
| 6016 | + (ve.endpoint ? ' → ' + escapeHtml(ve.endpoint) : ''); |
| 6017 | veHtml = '<span class="text-amber-300">yes</span> <span class="text-gray-500">(via ' + via + ')</span>'; |
| 6018 | } else { |
| 6019 | veHtml = '<span id="vpn-egress-result" class="text-gray-500">no local tunnel</span> ' |
| 6020 | + '<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">' |
| 6021 | + '<option value="">auto (default route)</option></select>' |
| 6022 | + '<button onclick="checkVpnEgress()" class="text-xs text-cyan-400 hover:text-cyan-300 underline">check egress (catches VPN/Tor on the router)</button>'; |
| 6023 | } |
| 6024 | row('Traffic via VPN', veHtml); |
| 6025 | } |
| 6026 | |
| 6027 | const scopeNote = d.interface |
| 6028 | ? `<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>` : ''; |
| 6029 | const src = (d.sources && d.sources.length) |
| 6030 | ? `<p class="text-[11px] text-gray-500 mt-2">Sources: ${d.sources.map(escapeHtml).join(', ')}</p>` : ''; |
| 6031 | out.innerHTML = `<table class="min-w-full text-sm"> |
| 6032 | <tbody>${rows.join('')}</tbody></table>${scopeNote}${src}`; |
| 6033 | // Fill the egress-check interface selector (auto + each real NIC) so |
| 6034 | // the LAN path can be tested even when WiFi carries the default route. |
| 6035 | const sel = document.getElementById('vpn-egress-iface'); |
| 6036 | if (sel) { |
| 6037 | fetchAPI('/api/net/interfaces').then(x => { |
| 6038 | (x.interfaces || []).forEach(i => { |
| 6039 | const o = document.createElement('option'); |
| 6040 | o.value = i.name; |
| 6041 | o.textContent = i.name + (i.type && i.type !== 'ethernet' ? ' (' + i.type + ')' : ''); |
| 6042 | sel.appendChild(o); |
| 6043 | }); |
| 6044 | // A scoped identity view should test the same interface's egress. |
| 6045 | if (d.interface) sel.value = d.interface; |
| 6046 | }).catch(() => {}); |
| 6047 | } |
| 6048 | } catch (e) { |
| 6049 | out.innerHTML = '<p class="text-red-400">Failed: ' + escapeHtml(e.message) + '</p>'; |
| 6050 | } |
| 6051 | } |
| 6052 | |
| 6053 | async function checkVpnEgress() { |
| 6054 | const cell = document.getElementById('vpn-egress-result'); |
| 6055 | if (!cell) return; |
no test coverage detected