/**
* tunnel.js — internet-access panel
* Modes:
* off — LAN only
* quick — Cloudflare Quick Tunnel (random address, no account)
* custom — own domain via a Cloudflare Tunnel token
*/
(function () {
'use strict';
let state = { mode:'off', status:'stopped', publicUrl:'', localUrl:'', error:'', certDaysLeft: null };
let _saveTimer = null;
let _refreshTimer = null;
// ── Load state and config ─────────────────────────────────────────────────────
async function load() {
try {
// NOTE: auth.js patches the global window.fetch to automatically add
// the Bearer token to every /api/* request. No _auth() helper needed.
// An earlier version had an _auth() call that did NOT exist ->
// ReferenceError -> load() failed silently -> the UI never got the current status.
const [sr, cr] = await Promise.all([
fetch('/api/tunnel/status'),
fetch('/api/tunnel/config'),
]);
if (!sr.ok || !cr.ok) {
console.warn('[tunnel] load: HTTP', sr.status, cr.status);
return;
}
const st = await sr.json();
state = { ...state, ...st };
const cfg = await cr.json();
applyConfig(cfg);
render();
} catch(e) { console.warn('[tunnel] load:', e); }
}
function startAutoRefresh() {
stopAutoRefresh();
_refreshTimer = setInterval(load, 10000);
}
function stopAutoRefresh() {
if (_refreshTimer) { clearInterval(_refreshTimer); _refreshTimer = null; }
}
function applyConfig(cfg) {
const _s = (id, val) => { const el = document.getElementById(id); if (el) el.value = val || ''; };
const _c = (id, val) => { const el = document.getElementById(id); if (el) el.checked = !!val; };
_s('tn-mode', cfg.mode);
_s('tn-token', cfg.token);
_s('tn-hostname', cfg.hostname);
_s('tn-duck-domain', cfg.duckDomain);
_s('tn-duck-token', cfg.duckToken);
_s('tn-static-ip', cfg.staticIp);
_s('tn-static-port', cfg.staticPort || '8001');
_s('tn-custom-hostname', cfg.customHostname);
_s('tn-custom-port', cfg.customPort || '8001');
_s('tn-custom-certpath', cfg.customCertPath);
_s('tn-custom-keypath', cfg.customKeyPath);
_c('tn-autostart', cfg.autoStart);
modeChanged(cfg.mode || 'off');
}
// ── WS handler ────────────────────────────────────────────────────────────────
function handleWS(msg) {
if (msg.type !== 'tunnel_update') return;
state = msg.tunnel;
render();
if (msg.tunnel.publicUrl && msg.tunnel.status === 'connected') {
window.UI?.showToast(I18n.t('in_toast_tunnel_active').replace('{url}', msg.tunnel.publicUrl));
}
}
// ── Render ────────────────────────────────────────────────────────────────────
function render() {
renderBadge();
renderUrl();
renderError();
renderDuckPanel();
}
function renderDuckPanel() {
const days = state.certDaysLeft;
const daysEl = document.getElementById('tn-cert-days');
const stEl = document.getElementById('tn-duck-tunnel-status');
const addrEl = document.getElementById('tn-duck-addr');
if (daysEl) {
if (days === null || days === undefined) {
daysEl.textContent = I18n.t('in_no_cert');
daysEl.style.color = 'var(--red)';
} else if (days < 7) {
daysEl.textContent = I18n.t('in_days_warn').replace('{n}', days);
daysEl.style.color = 'var(--red)';
} else if (days < 30) {
daysEl.textContent = I18n.t('in_days_warn').replace('{n}', days);
daysEl.style.color = 'var(--amber)';
} else {
daysEl.textContent = I18n.t('in_days_ok').replace('{n}', days);
daysEl.style.color = 'var(--green)';
}
}
if (stEl) {
const map = {
connected: { txt: I18n.t('in_active'), color: 'var(--green)' },
starting: { txt: I18n.t('in_connecting_dots'), color: 'var(--amber)' },
error: { txt: I18n.t('in_error_badge'), color: 'var(--red)' },
stopped: { txt: I18n.t('in_stopped_tunnel'), color: 'var(--dim)' },
};
const s = map[state.status] || map.stopped;
stEl.textContent = s.txt;
stEl.style.color = s.color;
}
if (addrEl) {
if (state.publicUrl && state.status === 'connected') {
addrEl.innerHTML = `${state.publicUrl}`;
} else {
addrEl.textContent = '—';
}
}
}
function renderBadge() {
const el = document.getElementById('tn-status-badge');
const dot = document.getElementById('tn-status-dot');
if (!el) return;
const map = {
connected: { txt: I18n.t('in_active'), color:'var(--green)', bg:'rgba(var(--green-rgb),0.1)' },
starting: { txt: I18n.t('in_connecting_dots'), color:'var(--amber)', bg:'rgba(var(--amber-rgb),0.1)' },
error: { txt: I18n.t('in_error_badge'), color:'var(--red)', bg:'rgba(var(--red-rgb),0.1)' },
stopped: { txt: I18n.t('in_stopped_badge'), color:'var(--dim)', bg:'transparent' },
};
const st = map[state.status] || map.stopped;
el.textContent = st.txt;
el.style.color = st.color;
el.style.background = st.bg;
if (dot) { dot.style.background = st.color; }
}
function renderUrl() {
const pubEl = document.getElementById('tn-public-url');
const localEl = document.getElementById('tn-local-url');
if (pubEl) {
if (state.publicUrl && state.status === 'connected') {
// Simple link, no inline copy button - the global COPY is in the card header
pubEl.innerHTML = `${state.publicUrl}`;
} else if (state.status === 'starting') {
pubEl.innerHTML = `${I18n.t('in_waiting_url')}`;
} else {
pubEl.innerHTML = `—`;
}
}
if (localEl) {
if (state.localUrl) {
localEl.innerHTML = `${state.localUrl}`;
} else {
localEl.textContent = '—';
}
}
}
function renderError() {
const el = document.getElementById('tn-error-msg');
if (!el) return;
el.textContent = state.error || '';
el.style.display = state.error ? 'block' : 'none';
}
function copyUrl(url) {
navigator.clipboard?.writeText(url).then(() => {
window.UI?.showToast(I18n.t('in_toast_addr_copied'));
}).catch(() => {
const inp = document.createElement('input');
inp.value = url; document.body.appendChild(inp);
inp.select(); document.execCommand('copy');
document.body.removeChild(inp);
window.UI?.showToast(I18n.t('in_toast_copied_plain'));
});
}
// ── Mode change ───────────────────────────────────────────────────────────────
function modeChanged(mode) {
// Hide all panels
['tn-named-fields','tn-duckdns-fields','tn-staticip-fields','tn-customcert-fields'].forEach(id => {
const el = document.getElementById(id);
if (el) el.style.display = 'none';
});
// Show the matching panel
if (mode === 'named') { const el = document.getElementById('tn-named-fields'); if (el) el.style.display = 'block'; }
if (mode === 'duckdns') { const el = document.getElementById('tn-duckdns-fields'); if (el) el.style.display = 'block'; }
if (mode === 'staticip') { const el = document.getElementById('tn-staticip-fields'); if (el) el.style.display = 'block'; }
if (mode === 'customcert') { const el = document.getElementById('tn-customcert-fields'); if (el) el.style.display = 'block'; }
// Highlight the active mode
document.querySelectorAll('.tn-mode-btn').forEach(b => {
const active = b.dataset.mode === mode;
b.style.borderColor = active ? 'var(--green)' : 'var(--border)';
b.style.color = active ? 'var(--green)' : 'var(--dim)';
b.style.background = active ? 'rgba(var(--green-rgb),0.08)' : 'var(--panel2)';
});
// Show cloudflared diagnostics only for Cloudflare modes
const cfStatus = document.getElementById('tn-cf-status');
if (cfStatus) {
const showCf = (mode === 'quick' || mode === 'named');
cfStatus.style.display = showCf ? 'flex' : 'none';
}
// Show detail pills based on mode (cert for DuckDNS/static IP/custom cert, tunnel for Cloudflare)
const showCert = (mode === 'duckdns' || mode === 'staticip' || mode === 'customcert');
const showTunnel = (mode === 'quick' || mode === 'named' || mode === 'duckdns');
const cert = document.getElementById('tn-detail-cert');
const tun = document.getElementById('tn-detail-tunnel');
if (cert) cert.style.display = showCert ? 'inline-block' : 'none';
if (tun) tun.style.display = showTunnel ? 'inline-block' : 'none';
const modeEl = document.getElementById('tn-mode');
if (modeEl) modeEl.value = mode;
}
// Copy the public address to the clipboard
async function copyPublic() {
const el = document.getElementById('tn-public-url');
if (!el) return;
// Extract the URL - may come from an or plain text
const link = el.querySelector('a');
const url = (link ? link.href : el.textContent).trim();
if (!url || url === '—') {
window.UI?.showToast(I18n.t('in_toast_no_addr'), 'error');
return;
}
try {
await navigator.clipboard.writeText(url);
window.UI?.showToast(I18n.t('in_toast_copied_prefix') + url);
} catch (e) {
// Fallback for older browsers
const tmp = document.createElement('textarea');
tmp.value = url;
document.body.appendChild(tmp);
tmp.select();
try { document.execCommand('copy'); window.UI?.showToast(I18n.t('in_toast_copied_plain')); }
catch { window.UI?.showToast(I18n.t('in_toast_copy_failed') + e.message, 'error'); }
document.body.removeChild(tmp);
}
}
// ── Actions ───────────────────────────────────────────────────────────────────
async function startTunnel() {
const mode = document.getElementById('tn-mode')?.value?.trim() || 'quick';
const token = document.getElementById('tn-token')?.value?.trim() || '';
const duckDomain = document.getElementById('tn-duck-domain')?.value?.trim() || '';
const duckToken = document.getElementById('tn-duck-token')?.value?.trim() || '';
const staticIp = document.getElementById('tn-static-ip')?.value?.trim() || '';
const customHostname = document.getElementById('tn-custom-hostname')?.value?.trim() || '';
// Per-mode validation
if (mode === 'named' && !token) {
window.UI?.showToast(I18n.t('in_toast_paste_token'), 'error'); return;
}
if (mode === 'duckdns' && (!duckDomain || !duckToken)) {
window.UI?.showToast(I18n.t('in_toast_duckdns_fields'), 'error'); return;
}
if (mode === 'staticip' && !staticIp) {
window.UI?.showToast(I18n.t('in_toast_enter_ip'), 'error'); return;
}
if (mode === 'customcert' && !customHostname) {
window.UI?.showToast(I18n.t('in_toast_enter_domain'), 'error'); return;
}
// Save the config before starting
await saveTunnelConfig(false);
const btn = document.getElementById('tn-start-btn');
// Remove the static HTML's data-i18n - otherwise the next
// I18n.setLang() would overwrite this button back to "START" while it's
// actually connecting (see the same note at rot-status-badge in rotormini.js).
if (btn) { btn.removeAttribute('data-i18n'); btn.textContent = I18n.t('in_connecting_btn'); btn.disabled = true; }
const tkn = localStorage.getItem('token') || '';
try {
const r = await fetch('/api/tunnel/start', {
method: 'POST',
headers: {'Content-Type':'application/json',
...(tkn ? {'Authorization': `Bearer ${tkn}`} : {})},
body: JSON.stringify({ mode }),
});
const res = await r.json();
if (!res.ok) {
window.UI?.showToast('✗ ' + (res.error||I18n.t('profile_error_fallback')), 'error');
}
} catch(e) {
window.UI?.showToast('✗ ' + I18n.t('cfg_conn_error'), 'error');
} finally {
if (btn) { btn.textContent = I18n.t('in_start_btn'); btn.disabled = false; }
}
}
async function stopTunnel() {
await fetch('/api/tunnel/stop', { method:'POST' });
state.status = 'stopped'; state.publicUrl = '';
render();
window.UI?.showToast(I18n.t('in_toast_tunnel_stopped'));
}
// Save the tunnel config
async function saveTunnelConfig(showToast = true) {
const mode = document.getElementById('tn-mode')?.value?.trim() || 'off';
const token = document.getElementById('tn-token')?.value?.trim() || '';
const hostname = document.getElementById('tn-hostname')?.value?.trim() || '';
const duckDomain = document.getElementById('tn-duck-domain')?.value?.trim() || '';
const duckToken = document.getElementById('tn-duck-token')?.value?.trim() || '';
const staticIp = document.getElementById('tn-static-ip')?.value?.trim() || '';
const staticPort = document.getElementById('tn-static-port')?.value?.trim() || '8001';
const customHostname = document.getElementById('tn-custom-hostname')?.value?.trim() || '';
const customPort = document.getElementById('tn-custom-port')?.value?.trim() || '8001';
const customCertPath = document.getElementById('tn-custom-certpath')?.value?.trim() || '';
const customKeyPath = document.getElementById('tn-custom-keypath')?.value?.trim() || '';
const autoStart = document.getElementById('tn-autostart')?.checked || false;
// DuckDNS address preview
const duckPreview = document.getElementById('tn-duck-preview');
if (duckPreview) duckPreview.textContent = duckDomain ? `${duckDomain}.duckdns.org` : 'mojradio.duckdns.org';
const tkn = localStorage.getItem('token') || '';
try {
await fetch('/api/tunnel/config', {
method: 'POST',
headers: {'Content-Type':'application/json',
...(tkn ? {'Authorization': `Bearer ${tkn}`} : {})},
body: JSON.stringify({ mode, token, hostname, duckDomain, duckToken, staticIp, staticPort,
customHostname, customPort, customCertPath, customKeyPath, autoStart }),
});
if (showToast) window.UI?.showToast(I18n.t('in_toast_config_saved'));
} catch(e) {
if (showToast) window.UI?.showToast(I18n.t('in_toast_save_error'), 'error');
}
}
async function checkCF() {
const el = document.getElementById('tn-cf-status');
const verEl = document.getElementById('tn-cf-version');
const staleEl = document.getElementById('tn-cf-stale');
const svcEl = document.getElementById('tn-cf-svc');
if (!el) return;
el.style.display = 'flex';
if (verEl) verEl.textContent = I18n.t('in_cf_checking');
try {
const token = localStorage.getItem('token') || '';
const r = await fetch('/api/tunnel/check', {
headers: token ? {'Authorization': `Bearer ${token}`} : {}
});
const res = await r.json();
if (verEl) {
if (res.available) {
verEl.innerHTML = `${I18n.t('in_cf_available').replace('{ver}', res.version||'')}`;
} else {
verEl.innerHTML = `${I18n.t('in_cf_missing')}`;
}
}
// Stale processes
if (staleEl) {
staleEl.style.display = (res.stale_procs > 0) ? 'inline' : 'none';
if (res.stale_procs > 0) staleEl.textContent = I18n.t('in_stale_procs').replace('{n}', res.stale_procs);
}
// Windows service
if (svcEl) svcEl.style.display = res.svc_installed ? 'inline' : 'none';
} catch(e) {
if (verEl) verEl.textContent = I18n.t('in_cf_check_error');
}
}
async function genCert() {
await saveTunnelConfig(false);
window.UI?.showToast(I18n.t('in_toast_generating_cert'));
const h = {'Authorization': `Bearer ${localStorage.getItem('token')||''}`};
try {
await fetch('/api/tunnel/gen-cert', {method:'POST', headers:h});
} catch(e) { window.UI?.showToast('✗ ' + e.message, 'error'); }
}
async function cleanup() {
if (!await window.UI?.confirmModal(I18n.t('in_confirm_cleanup'))) return;
try {
const r = await fetch('/api/tunnel/cleanup', {method:'POST'});
const res = await r.json();
if (res.ok) {
window.UI?.showToast(I18n.t('in_toast_cleaned') + (res.messages?.[0] || 'OK'));
await checkCF();
}
} catch(e) {
window.UI?.showToast(I18n.t('in_toast_cleanup_error') + e.message, 'error');
}
}
window.Tunnel = { load, handleWS, startTunnel, stopTunnel, saveTunnelConfig, checkCF, modeChanged, cleanup, genCert, startAutoRefresh, stopAutoRefresh, copyPublic };
})();