Fix cascade silent dead internet with per-step settings and handshake checks.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
orohi
2026-07-26 06:46:02 +03:00
co-authored by Cursor
parent 8fa7f27223
commit 7230697b7f
5 changed files with 434 additions and 54 deletions
+150 -3
View File
@@ -522,6 +522,84 @@
</div>
</div>
<div class="form-hint" style="margin-bottom:var(--space-md);">{{ _('cascade_hint') }}</div>
<details id="cascadeSettingsPanel" open style="margin-bottom:var(--space-md); border:1px solid var(--border); border-radius:var(--radius-md); padding:var(--space-sm) var(--space-md);">
<summary style="cursor:pointer; font-weight:600; margin-bottom:var(--space-sm);">{{ _('cascade_settings_title') }}</summary>
<p class="form-hint" style="margin:0 0 var(--space-md);">{{ _('cascade_settings_desc') }}</p>
<div class="grid" style="display:grid; grid-template-columns:1fr 1fr; gap:var(--space-sm) var(--space-md);">
<label class="form-check" style="display:flex; gap:var(--space-sm); align-items:flex-start;">
<input type="checkbox" id="cascadePinExitRoute" checked>
<span>
<strong>{{ _('cascade_opt_pin_exit') }}</strong>
<span class="form-hint" style="display:block;">{{ _('cascade_opt_pin_exit_hint') }}</span>
</span>
</label>
<label class="form-check" style="display:flex; gap:var(--space-sm); align-items:flex-start;">
<input type="checkbox" id="cascadeRemoveEthMasq" checked>
<span>
<strong>{{ _('cascade_opt_remove_eth_masq') }}</strong>
<span class="form-hint" style="display:block;">{{ _('cascade_opt_remove_eth_masq_hint') }}</span>
</span>
</label>
<label class="form-check" style="display:flex; gap:var(--space-sm); align-items:flex-start;">
<input type="checkbox" id="cascadeForceForward" checked>
<span>
<strong>{{ _('cascade_opt_force_forward') }}</strong>
<span class="form-hint" style="display:block;">{{ _('cascade_opt_force_forward_hint') }}</span>
</span>
</label>
<label class="form-check" style="display:flex; gap:var(--space-sm); align-items:flex-start;">
<input type="checkbox" id="cascadeMssClamp" checked>
<span>
<strong>{{ _('cascade_opt_mss_clamp') }}</strong>
<span class="form-hint" style="display:block;">{{ _('cascade_opt_mss_clamp_hint') }}</span>
</span>
</label>
<label class="form-check" style="display:flex; gap:var(--space-sm); align-items:flex-start;">
<input type="checkbox" id="cascadeWaitHandshake" checked>
<span>
<strong>{{ _('cascade_opt_wait_handshake') }}</strong>
<span class="form-hint" style="display:block;">{{ _('cascade_opt_wait_handshake_hint') }}</span>
</span>
</label>
<label class="form-check" style="display:flex; gap:var(--space-sm); align-items:flex-start;">
<input type="checkbox" id="cascadeKeepExitDns">
<span>
<strong>{{ _('cascade_opt_keep_dns') }}</strong>
<span class="form-hint" style="display:block;">{{ _('cascade_opt_keep_dns_hint') }}</span>
</span>
</label>
</div>
<div class="grid" style="display:grid; grid-template-columns:1fr 1fr 1fr; gap:var(--space-md); margin-top:var(--space-md);">
<div class="form-group" style="margin:0;">
<label class="form-label" for="cascadeHandshakeTimeout">{{ _('cascade_opt_handshake_timeout') }}</label>
<input type="number" class="form-input" id="cascadeHandshakeTimeout" min="5" max="120" value="25">
</div>
<div class="form-group" style="margin:0;">
<label class="form-label" for="cascadeTableId">{{ _('cascade_opt_table_id') }}</label>
<input type="number" class="form-input" id="cascadeTableId" min="1" max="252" value="200">
</div>
<div class="form-group" style="margin:0;">
<label class="form-label" for="cascadeRulePriority">{{ _('cascade_opt_rule_priority') }}</label>
<input type="number" class="form-input" id="cascadeRulePriority" min="1" max="32765" value="100">
</div>
</div>
<div class="grid" style="display:grid; grid-template-columns:1fr 1fr; gap:var(--space-md); margin-top:var(--space-md);">
<div class="form-group" style="margin:0;">
<label class="form-label" for="cascadeAllowedIps">{{ _('cascade_opt_allowed_ips') }}</label>
<input type="text" class="form-input" id="cascadeAllowedIps" value="0.0.0.0/0, ::/0">
<span class="form-hint">{{ _('cascade_opt_allowed_ips_hint') }}</span>
</div>
<div class="form-group" style="margin:0;">
<label class="form-label" for="cascadeVpnSubnet">{{ _('cascade_opt_vpn_subnet') }}</label>
<input type="text" class="form-input" id="cascadeVpnSubnet" placeholder="10.8.1.0/24" value="">
<span class="form-hint">{{ _('cascade_opt_vpn_subnet_hint') }}</span>
</div>
</div>
</details>
<div id="cascadeDiagBox" class="hidden" style="margin-bottom:var(--space-md); padding:var(--space-sm) var(--space-md); background:var(--bg-muted, rgba(0,0,0,.04)); border-radius:var(--radius-md); font-family:ui-monospace,monospace; font-size:12px; white-space:pre-wrap; max-height:160px; overflow:auto;"></div>
<div class="flex gap-sm" style="flex-wrap:wrap;">
<button type="button" class="btn btn-primary" id="cascadeEnableBtn" onclick="setupCascade(true)">
{{ _('cascade_enable') }}
@@ -2703,15 +2781,78 @@
if (dis) dis.disabled = !!on;
}
function cascadeBool(id, fallback) {
const el = document.getElementById(id);
return el ? !!el.checked : !!fallback;
}
function cascadeNum(id, fallback) {
const el = document.getElementById(id);
const n = el ? parseInt(el.value, 10) : NaN;
return Number.isFinite(n) ? n : fallback;
}
function collectCascadeSettings() {
return {
pin_exit_route: cascadeBool('cascadePinExitRoute', true),
remove_eth_masquerade: cascadeBool('cascadeRemoveEthMasq', true),
force_forward: cascadeBool('cascadeForceForward', true),
mss_clamp: cascadeBool('cascadeMssClamp', true),
wait_handshake: cascadeBool('cascadeWaitHandshake', true),
keep_exit_dns: cascadeBool('cascadeKeepExitDns', false),
handshake_timeout_sec: cascadeNum('cascadeHandshakeTimeout', 25),
table_id: cascadeNum('cascadeTableId', 200),
rule_priority: cascadeNum('cascadeRulePriority', 100),
allowed_ips: (document.getElementById('cascadeAllowedIps') || {}).value || '0.0.0.0/0, ::/0',
vpn_subnet_override: (document.getElementById('cascadeVpnSubnet') || {}).value || '',
};
}
function applyCascadeSettingsToForm(settings, defaults) {
const s = Object.assign({}, defaults || {}, settings || {});
const setCheck = (id, val) => { const el = document.getElementById(id); if (el) el.checked = !!val; };
const setVal = (id, val) => { const el = document.getElementById(id); if (el && val !== undefined && val !== null) el.value = val; };
setCheck('cascadePinExitRoute', s.pin_exit_route !== false);
setCheck('cascadeRemoveEthMasq', s.remove_eth_masquerade !== false);
setCheck('cascadeForceForward', s.force_forward !== false);
setCheck('cascadeMssClamp', s.mss_clamp !== false);
setCheck('cascadeWaitHandshake', s.wait_handshake !== false);
setCheck('cascadeKeepExitDns', !!s.keep_exit_dns);
setVal('cascadeHandshakeTimeout', s.handshake_timeout_sec ?? 25);
setVal('cascadeTableId', s.table_id ?? 200);
setVal('cascadeRulePriority', s.rule_priority ?? 100);
setVal('cascadeAllowedIps', s.allowed_ips || '0.0.0.0/0, ::/0');
setVal('cascadeVpnSubnet', s.vpn_subnet_override || '');
}
function updateCascadeDiag(cascade, live) {
const box = document.getElementById('cascadeDiagBox');
if (!box) return;
const parts = [];
if (cascade && cascade.enabled) {
parts.push('handshake: ' + ((live && live.handshake) || cascade.handshake ? 'ok' : 'NONE'));
if (cascade.subnet || (live && live.raw)) { /* keep */ }
if (cascade.diagnostics) parts.push(String(cascade.diagnostics).trim());
else if (live && live.raw) parts.push(String(live.raw).trim());
if (cascade.last_error) parts.push('error: ' + cascade.last_error);
}
const text = parts.filter(Boolean).join('\n\n');
box.textContent = text;
box.classList.toggle('hidden', !text);
}
function updateCascadeBadge(cascade, live) {
const badge = document.getElementById('cascadeStatusBadge');
if (!badge) return;
const enabled = !!(cascade && cascade.enabled);
const up = !!(live && live.up);
badge.className = 'badge ' + (enabled && up ? 'badge-success' : (enabled ? 'badge-warn' : 'badge-warn'));
const hs = !!(live && live.handshake) || !!(cascade && cascade.handshake);
badge.className = 'badge ' + (enabled && up && hs ? 'badge-success' : (enabled ? 'badge-warn' : 'badge-warn'));
if (!enabled) badge.textContent = _('cascade_off');
else if (up) badge.textContent = _('cascade_on');
else if (up && hs) badge.textContent = _('cascade_on');
else if (up && !hs) badge.textContent = _('cascade_no_handshake');
else badge.textContent = _('cascade_configured_down');
updateCascadeDiag(cascade, live);
}
async function loadCascadeStatus() {
@@ -2729,6 +2870,7 @@
exitProto.value = c.exit_protocol;
}
}
applyCascadeSettingsToForm(c.settings || {}, res.defaults || {});
updateCascadeBadge(c, res.live || {});
} catch (err) {
console.error(err);
@@ -2749,13 +2891,18 @@
setCascadeBusy(true, enable ? _('cascade_working') : _('cascade_disabling'));
showToast(enable ? _('cascade_working') : _('cascade_disabling'), 'info');
try {
const settings = collectCascadeSettings();
const res = await apiCall(`/api/servers/${SERVER_ID}/cascade/setup`, 'POST', {
enabled: !!enable,
entry_protocol: entryProtocol,
exit_server_id: parseInt(exitServerId || '0', 10),
exit_protocol: exitProtocol,
...settings,
});
updateCascadeBadge(res.cascade || {}, {
up: !!(res.cascade && res.cascade.up),
handshake: !!(res.cascade && res.cascade.handshake),
});
updateCascadeBadge(res.cascade || {}, { up: !!(res.cascade && res.cascade.up) });
showToast(enable ? _('cascade_enabled') : _('cascade_disabled'), 'success');
await loadCascadeStatus();
} catch (err) {