Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
149 changes: 113 additions & 36 deletions plugins/interface/intf_base_plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const { v4: uuidv4 } = require("uuid");
const r = require('../../util/firerouter');

const exec = require('child-process-promise').exec;
const { spawn } = require('child_process');

const fs = require('fs');
const Promise = require('bluebird');
Expand Down Expand Up @@ -58,6 +59,7 @@ const OFF_ON_THRESHOLD = 5;
const CONNTRACK_MONITOR_SEC_DEFAULT = 5;
const CONNTRACK_MONITOR_SEC_AFTER_SUCCESS = 8;
const CONNTRACK_MONITOR_SEC_AFTER_FAILURE = 10;

const DUID_RECORD_MAX = 10;

// Ping test result enum: success = pass, failed = partial (some packets), all_failed = 0 received (dest may be unreachable)
Expand All @@ -68,6 +70,32 @@ const PING_RESULT_ALL_FAILED = 'all_failed';
const IP6_NUM_DISCARD_DEPRECATED = 100;
const IP6_NUM_MAX = 1000;

/** AbortController-compatible helper for environments where AbortController is not defined (e.g. older Node). */
function createAbortController() {
const listeners = [];
const signal = {
aborted: false,
addEventListener(ev, fn) {
if (ev === 'abort') listeners.push(fn);
},
removeEventListener(ev, fn) {
if (ev === 'abort') {
const i = listeners.indexOf(fn);
if (i !== -1) listeners.splice(i, 1);
}
}
};
return {
signal,
abort() {
if (signal.aborted) return;
signal.aborted = true;
listeners.forEach((fn) => fn());
}
};
}


class InterfaceBasePlugin extends Plugin {

async isInterfacePresent() {
Expand Down Expand Up @@ -1515,56 +1543,105 @@ class InterfaceBasePlugin extends Plugin {
* Fallback: check if there is recent real traffic on this WAN (conntrack UPDATE with reply to our IP).
* Used when all ping targets had 0 packets (ping dest may be unreachable). Counts lines where original
* dst is a public (non-private) IP. Returns true if at least one such flow is seen.
* Uses spawn + kill on first match to avoid waiting the full monitorSec when a match is found early.
* Optional abortSignal: when aborted, settles with false and kills the conntrack process.
*/
async _checkWanStatusByConntrackForReplyDst(replyDst, monitorSec = CONNTRACK_MONITOR_SEC_DEFAULT) {
async _checkWanStatusByConntrackForReplyDst(replyDst, monitorSec = CONNTRACK_MONITOR_SEC_DEFAULT, abortSignal = null) {
if (!replyDst)
return false;
const execTimeoutMs = (monitorSec + 1) * 1000;
try {
const cmd = `sudo timeout ${monitorSec} conntrack -E -e UPDATE --reply-dst ${replyDst} 2>/dev/null | grep '\\[ASSURED\\]' | grep -E '(udp|ESTABLISHED)'`;
const result = await exec(cmd, { timeout: execTimeoutMs });
const stdout = (result && result.stdout) ? result.stdout.trim() : '';
const lines = stdout.split('\n').filter(l => l.length > 0);
let validCount = 0;
for (const line of lines) {
const m = line.match(/dst=([^\s]+)/);
if (m && m[1]) {
const origDst = m[1];
if (!ip.isPrivate(origDst))
validCount++;
return new Promise((resolve) => {
const child = spawn('sudo', [
'conntrack', '-E', '-e', 'UPDATE', '--reply-dst', replyDst
], { stdio: ['ignore', 'pipe', 'pipe'] });
Comment on lines +1552 to +1555
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new spawn/stream parsing + early-kill logic introduced here is behaviorally significant and currently has no targeted tests. Add unit tests that stub child_process.spawn to validate: resolves true on first matching line (public dst=), ignores private dst, handles stdout chunk/line splitting, resolves false on timeout/error/close, and terminates the child when finishing early to avoid lingering conntrack -E processes.

Copilot generated this review using guidance from organization custom instructions.

let settled = false;
let timer = null;
let abortListener = null;
const killChild = () => {
if (typeof child.pid === 'number' && child.pid > 0) {
const k = spawn('sudo', ['pkill', '-9', '-P', String(child.pid)], { stdio: 'ignore' });
k.on('error', () => {});
}
};
const finish = (value) => {
if (settled) return;
settled = true;
if (timer) clearTimeout(timer);
if (abortListener && abortSignal) {
try { abortSignal.removeEventListener('abort', abortListener); } catch (_) {}
}
killChild();
resolve(value);
};
abortListener = abortSignal ? () => finish(false) : null;
if (abortSignal) {
if (abortSignal.aborted) return finish(false);
abortSignal.addEventListener('abort', abortListener);
}
return validCount > 0;
} catch (err) {
// If conntrack fails to match an UPDATE message within monitorSec,
// the grep command will return err code 1, thus entering the catch block and returning false.
return false;
}
timer = setTimeout(() => finish(false), monitorSec * 1000);
let buf = '';
const checkLine = (line) => {
if (!line.includes('[ASSURED]') || !/(udp|ESTABLISHED)/.test(line)) return;
const m = line.match(/dst=([^\s]+)/);
if (!m || !m[1] || ip.isPrivate(m[1])) return;
finish(true);
};
child.stdout.on('data', (chunk) => {
buf += chunk.toString();
const lines = buf.split('\n');
buf = lines.pop() || '';
for (const line of lines)
if (line.trim()) checkLine(line);
});
child.stderr.on('data', () => {});
child.on('error', () => finish(false));
Comment on lines +1596 to +1597
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

child.stderr is fully ignored, which makes it hard to diagnose common failure modes (e.g., sudo not permitted, conntrack missing, or kernel permissions). Capturing a small amount of stderr (or logging it at debug level) would make fallback behavior more observable and help distinguish “no traffic seen” from “monitor failed to run.”

Copilot uses AI. Check for mistakes.
child.on('close', () => {
if (buf.trim()) checkLine(buf.trim());
finish(false);
});
});
}

async _checkWanStatusByConntrack() {
const ip4s = await this.getIPv4Addresses();
if (!ip4s || ip4s.length === 0)
return false;

if (!ip4s || ip4s.length === 0) return false;
const state = this._wanConnState || {};
const failureCount = (state.failureCount != null) ? state.failureCount : 0;
const successCount = (state.successCount != null) ? state.successCount : 0;
const { failureCount = 0, successCount = 0 } = state;
let monitorSec = CONNTRACK_MONITOR_SEC_DEFAULT;
if (failureCount > 0 && failureCount <= ON_OFF_THRESHOLD)
// longer after initial failures to reduce false "down"

if (failureCount > 0 && failureCount <= ON_OFF_THRESHOLD) {
monitorSec = CONNTRACK_MONITOR_SEC_AFTER_FAILURE;
else if (successCount > 0 && successCount <= OFF_ON_THRESHOLD)
// longer after initial successes for stability
} else if (successCount > 0 && successCount <= OFF_ON_THRESHOLD) {
monitorSec = CONNTRACK_MONITOR_SEC_AFTER_SUCCESS;

}

const replyDsts = [...new Set(ip4s.map(a => a.split('/')[0].trim()).filter(Boolean))];
if (replyDsts.length === 0)
return false;
const results = await Promise.all(
replyDsts.map(d => this._checkWanStatusByConntrackForReplyDst(d, monitorSec))
);
return results.some(Boolean);
if (replyDsts.length === 0) return false;

const controller = createAbortController();
const { signal } = controller;
const promises = replyDsts.map((d) => this._checkWanStatusByConntrackForReplyDst(d, monitorSec, signal));

// node 10 and 12 not support Promise.any
return new Promise((resolve) => {
let settled = false;
let falseCount = 0;
const len = promises.length;
const onTrue = () => {
if (settled) return;
settled = true;
controller.abort();
resolve(true);
};
const onFalse = () => {
falseCount += 1;
if (falseCount === len && !settled) {
settled = true;
resolve(false);
}
};
promises.forEach((p) => p.then((v) => (v ? onTrue() : onFalse())).catch(() => onFalse()));
});
}

setPendingTest(v = false) {
Expand Down