Skip to content

Commit 6554120

Browse files
committed
Add device suggestions from local interfaces
1 parent 93e5418 commit 6554120

7 files changed

Lines changed: 117 additions & 8 deletions

File tree

app.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import os
2+
import socket as socket_module
23
import sys
34

45
try:
@@ -45,7 +46,7 @@
4546

4647

4748
TRACE_OPTION_PATTERN = r"^\d+\.\s+(.+)$"
48-
DEVICE_PATTERN = r"^[A-Za-z]*\d*$"
49+
DEVICE_PATTERN = r"^[A-Za-z0-9][A-Za-z0-9_.:@-]{0,126}$"
4950
HOSTNAME_PATTERN = r"^(?=.{1,255}$)(?!-)[A-Za-z0-9-]{1,63}(?:\.(?!-)[A-Za-z0-9-]{1,63})*$"
5051
DATA_PROVIDER_ALLOWLIST = {
5152
"Ip2region",
@@ -596,6 +597,26 @@ def stop_nexttrace_for_sid(sid: str):
596597
task.request_stop()
597598

598599

600+
def list_available_devices() -> list[str]:
601+
try:
602+
discovered = socket_module.if_nameindex()
603+
except OSError:
604+
logging.warning("Failed to enumerate local interfaces", exc_info=True)
605+
return []
606+
607+
devices = []
608+
seen = set()
609+
for _index, raw_name in discovered:
610+
name = str(raw_name).strip()
611+
if not name or not re.match(DEVICE_PATTERN, name):
612+
continue
613+
if name in seen:
614+
continue
615+
seen.add(name)
616+
devices.append(name)
617+
return devices
618+
619+
599620
@app.before_request
600621
def validate_request_host():
601622
if not is_trusted_host(request.host):
@@ -628,6 +649,12 @@ def healthz():
628649
)
629650

630651

652+
@app.route("/api/devices")
653+
def api_devices():
654+
devices = list_available_devices()
655+
return jsonify({"devices": devices, "count": len(devices)})
656+
657+
631658
@socketio.on("connect")
632659
def handle_connect():
633660
if not is_trusted_host(request.host):

assets/js/main.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -698,7 +698,7 @@ function formatConnectionStatus(status) {
698698
}
699699

700700
function deviceValidateInput() {
701-
var allowedPattern = '^[a-zA-Z]*\\d*$';
701+
var allowedPattern = '^[A-Za-z0-9][A-Za-z0-9_.:@-]{0,126}$';
702702
var inputElement = $('devInput');
703703
var errorMessageElement = $('dev-error-message');
704704

assets/js/settingsmenu.js

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ var intervalTimeRange = document.getElementById('intervalTimeRange');
77
var intervalTimeInput = document.getElementById('intervalTimeInput');
88
var packetSizeRange = document.getElementById('packetSizeRange');
99
var packetSizeInput = document.getElementById('packetSizeInput');
10+
var deviceOptions = document.getElementById('deviceOptions');
1011
var settingsFirstFocusable = document.getElementById('language');
1112
var lastSettingsTrigger = null;
1213

@@ -49,6 +50,8 @@ packetSizeInput.addEventListener('input', function () {
4950
packetSizeRange.value = packetSizeInput.value;
5051
});
5152

53+
loadAvailableDevices();
54+
5255
saveBtn.addEventListener('click', function (event) {
5356
event.preventDefault();
5457

@@ -165,6 +168,44 @@ function loadStoredSettings() {
165168
}
166169
}
167170

171+
function loadAvailableDevices() {
172+
if (!deviceOptions || typeof fetch !== 'function') {
173+
return Promise.resolve();
174+
}
175+
176+
return fetch('/api/devices', {
177+
headers: { accept: 'application/json' }
178+
})
179+
.then(function (response) {
180+
if (!response.ok) {
181+
throw new Error('device list request failed');
182+
}
183+
return response.json();
184+
})
185+
.then(function (payload) {
186+
renderDeviceOptions(payload && payload.devices);
187+
})
188+
.catch(function () {
189+
renderDeviceOptions([]);
190+
});
191+
}
192+
193+
function renderDeviceOptions(devices) {
194+
deviceOptions.innerHTML = '';
195+
if (!Array.isArray(devices)) {
196+
return;
197+
}
198+
199+
devices.forEach(function (deviceName) {
200+
if (typeof deviceName !== 'string' || deviceName.trim() === '') {
201+
return;
202+
}
203+
var option = document.createElement('option');
204+
option.value = deviceName;
205+
deviceOptions.appendChild(option);
206+
});
207+
}
208+
168209
function notifySettingsChanged() {
169210
document.dispatchEvent(new CustomEvent('ntwa:settings-changed'));
170211
if (typeof window.syncSettingsSummary === 'function') {

templates/index.html

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -240,7 +240,8 @@ <h2 id="settingsDrawerTitle">Trace Settings</h2>
240240

241241
<label class="field" for="devInput">
242242
<span class="field-label">Device</span>
243-
<input type="text" id="devInput" class="text-input" placeholder="Device">
243+
<input type="text" id="devInput" class="text-input" list="deviceOptions" placeholder="Device">
244+
<datalist id="deviceOptions"></datalist>
244245
<span id="dev-error-message" class="validation-message">Invalid device name.</span>
245246
</label>
246247

tests/browser-controls.check.cjs

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -97,8 +97,12 @@ async function checkSettingsInputSync() {
9797
}
9898

9999
async function checkSettingsPersistenceAndSummary() {
100-
const harness = createBrowserHarness({ console: quietConsole });
100+
const harness = createBrowserHarness({
101+
console: quietConsole,
102+
devices: ['en0', 'br-test0']
103+
});
101104
const { localStorage, elements } = harness;
105+
await harness.flushPromises();
102106

103107
elements.ipVersion.value = 'ipv6';
104108
harness.dispatchChange(elements.ipVersion);
@@ -115,7 +119,10 @@ async function checkSettingsPersistenceAndSummary() {
115119
elements.maxHopInput.value = '12';
116120
elements.minHopInput.value = '2';
117121
elements.portInput.value = '443';
118-
elements.devInput.value = 'en0';
122+
assert.equal(elements.devInput.getAttribute('list'), 'deviceOptions');
123+
assert.equal(elements.deviceOptions.children.length, 2);
124+
assert.equal(elements.deviceOptions.children[1].value, 'br-test0');
125+
elements.devInput.value = 'br-test0';
119126
elements.dataProvider.value = 'IP.SB';
120127

121128
elements.saveBtn.click();
@@ -128,7 +135,7 @@ async function checkSettingsPersistenceAndSummary() {
128135
assert.equal(localStorage.getItem('maxHop'), '12');
129136
assert.equal(localStorage.getItem('minHop'), '2');
130137
assert.equal(localStorage.getItem('port'), '443');
131-
assert.equal(localStorage.getItem('device'), 'en0');
138+
assert.equal(localStorage.getItem('device'), 'br-test0');
132139
assert.equal(localStorage.getItem('dataProvider'), 'IP.SB');
133140
assert.equal(elements.settingMenu.classList.contains('is-open'), false);
134141
assert.match(elements.settingsSummaryInline.textContent, /EN/);

tests/browser-harness.cjs

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,21 @@ function flushPromises() {
352352
return new Promise((resolve) => setImmediate(resolve));
353353
}
354354

355+
function createDefaultFetch(devices) {
356+
const deviceList = Array.isArray(devices) ? devices : ['en0', 'utun0'];
357+
return function fetch(url) {
358+
if (url === '/api/devices') {
359+
return Promise.resolve({
360+
ok: true,
361+
json() {
362+
return Promise.resolve({ devices: deviceList, count: deviceList.length });
363+
}
364+
});
365+
}
366+
return Promise.reject(new Error(`fetch not mocked for ${url}`));
367+
};
368+
}
369+
355370
function createBrowserHarness(options = {}) {
356371
const document = new FakeDocument(options.url || 'https://example.test/');
357372
const localStorage = new FakeStorage(options.storage || {});
@@ -431,7 +446,12 @@ function createBrowserHarness(options = {}) {
431446
appendElement(document, settingMenu, 'input', 'maxHopInput', { type: 'number', value: '30' });
432447
appendElement(document, settingMenu, 'input', 'minHopInput', { type: 'number', value: '1' });
433448
appendElement(document, settingMenu, 'input', 'portInput', { type: 'number', value: '80' });
434-
appendElement(document, settingMenu, 'input', 'devInput', { type: 'text', value: '' });
449+
appendElement(document, settingMenu, 'input', 'devInput', {
450+
type: 'text',
451+
value: '',
452+
attributes: { list: 'deviceOptions' }
453+
});
454+
appendElement(document, settingMenu, 'datalist', 'deviceOptions');
435455
appendElement(document, settingMenu, 'span', 'dev-error-message');
436456
appendElement(document, settingMenu, 'input', 'dataProvider', { type: 'text', value: '' });
437457
appendElement(document, settingMenu, 'span', 'dp-error-message');
@@ -455,7 +475,7 @@ function createBrowserHarness(options = {}) {
455475
URLSearchParams,
456476
CustomEvent: FakeCustomEvent,
457477
Event: FakeEvent,
458-
fetch: options.fetch || (() => Promise.reject(new Error('fetch not mocked'))),
478+
fetch: options.fetch || createDefaultFetch(options.devices),
459479
io: {
460480
connect() {
461481
return socket;
@@ -511,6 +531,7 @@ function createBrowserHarness(options = {}) {
511531
minHopInput: document.getElementById('minHopInput'),
512532
portInput: document.getElementById('portInput'),
513533
devInput: document.getElementById('devInput'),
534+
deviceOptions: document.getElementById('deviceOptions'),
514535
devError: document.getElementById('dev-error-message'),
515536
dataProvider: document.getElementById('dataProvider'),
516537
dpError: document.getElementById('dp-error-message'),

tests/test_app_runtime.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,11 @@ def test_build_trace_params_rejects_invalid_device(self):
189189

190190
self.assertEqual(ctx.exception.code, "invalid_payload")
191191

192+
def test_build_trace_params_accepts_detected_device_name(self):
193+
params = app_module.build_trace_params("example.com", {"device": "br-test0"})
194+
195+
self.assertEqual(params, ["example.com", "--dev", "br-test0"])
196+
192197
def test_healthz_reports_ok_when_binary_exists(self):
193198
app_module.app.config["NTWA_NEXTTRACE_PATH"] = sys.executable
194199

@@ -205,6 +210,13 @@ def test_healthz_reports_degraded_when_binary_missing(self):
205210
self.assertEqual(response.status_code, 503)
206211
self.assertEqual(response.get_json()["status"], "degraded")
207212

213+
@mock.patch.object(app_module.socket_module, "if_nameindex", return_value=[(1, "lo0"), (2, "br-test0"), (3, "bad name")])
214+
def test_api_devices_returns_filtered_detected_interfaces(self, _if_nameindex):
215+
response = self.flask_client.get("/api/devices")
216+
217+
self.assertEqual(response.status_code, 200)
218+
self.assertEqual(response.get_json(), {"devices": ["lo0", "br-test0"], "count": 2})
219+
208220
def test_start_nexttrace_invalid_json_emits_structured_error(self):
209221
self.socket_client.emit("start_nexttrace", "{bad json")
210222

0 commit comments

Comments
 (0)