net-sim: network-condition simulation in the Phone tab (v2.5.0)

Server (pysim_simple_server/netsim.py + POST /api/net-sim):
- pure builders for the observed phone write vocabulary (UICC_NAA.md §13):
  EPSNSC (A0 TLV with KSI/KASME/counts/algo, padded to the card's FCP record
  size; invalidate-wipe and invalidate-keep-key), LOCI/PSLOCI/EPSLOCI real
  and dummy forms (PLMN kept, LAC FFFE, status 01/02), Kc/KcGPRS (9-byte
  USIM and 33-byte GSM forms, 07 invalidates), EF.SMSstatus counter bump
  (read-modify-write), CBMI/CBMIR, the Location status event (9B 01 <st>,
  with the optional 13 07 location info for normal service) and the
  AUTHENTICATE APDU (TS 31.102 7.1.2.1) with DB/DC response parsing
- scenario runner executes the recipes under _CARD_LOCK, picks the first
  existing candidate path (ADF.USIM vs DF.GSM/DF.TELECOM), reads FCP/current
  records where the format is card-specific, skips the event unless the card
  subscribed to Location status, honours the scenario toggles and returns a
  per-step log; only D6/DC, ENVELOPE and AUTHENTICATE are ever sent
- GET /api/mcc-mnc serves the optional worldwide operator list
  (--mcc-mnc-list, default <workspace>/samples/mcc-mnc-list.json): ?q=
  search and ?random=1&exclude= for roaming; the list stays out of the repo

PWA:
- Phone pill gains a 'Network simulation' fieldset: one button per scenario
  (cold boot, EPS/2G attach, service lost, limited service, roaming denied,
  churn, SMS received, CB reconfig, AUTHENTICATE), a collapsed Parameters
  block (operator search + random roaming, LAC/Cell ID/TAC/RAC, optional
  identities, toggles, churn count/delay) and a step log with SWs
- i18n EN/RU, help/README/api.md/AGENTS.md updated; version 2.5.0,
  SW cache simple-v193

Tests: tests/test_netsim.py (19), frontend/tests/netsim.test.js (3) and
html guards - 423 frontend / 280 Python, all passing
This commit is contained in:
2026-09-19 22:24:57 +03:00
parent 158f1c676f
commit 0d92ea8f24
15 changed files with 1571 additions and 4 deletions
+13
View File
@@ -474,6 +474,19 @@
</ul>
<p class="text-sm mb-3">Значения хранятся на сервере до перезапуска. Когда карта выдаёт PLI, сервер вставляет значения словаря в TERMINAL RESPONSE.</p>
<h3 id="network-simulation" class="text-lg font-medium mb-2">8.7 Симуляция сети</h3>
<p class="text-sm mb-2">Воспроизводит шаблоны записи реального телефона при смене сетевых условий (исследование трасс в <code class="font-mono text-sm">projects/UICC_NAA.md</code>): подключение EPS, потеря сервиса / ограниченный сервис, запрет роуминга, откат на 2G, приём SMS, перенастройка cell broadcast и AUTHENTICATE. По кнопке на сценарий; карта должна быть подключена.</p>
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
<li><strong>Холодная загрузка</strong> — инвалидация EPSNSC (KSI 07, ключ стирается) и фиктивные location-файлы.</li>
<li><strong>Подключение EPS / 2G</strong> — запись реального контекста EPS NAS (KSI, KASME, счётчики NAS, алгоритм) и реальных LOCI/PSLOCI/EPSLOCI (в 2G дополнительно реальные Kc/KcGPRS).</li>
<li><strong>Потеря сервиса / ограниченный сервис / запрет роуминга</strong> — событие Location status (только если карта на него подписана), инвалидация EPSNSC (по желанию с сохранением старого KASME), фиктивные location-файлы (PLMN сохраняется, LAC <code class="font-mono text-sm">FFFE</code>, статус <code class="font-mono text-sm">01</code>/<code class="font-mono text-sm">02</code>) и инвалидация Kc.</li>
<li><strong>Серия переподключений</strong> — реальная → невалидная запись EPSNSC подряд (число циклов и задержка настраиваются).</li>
<li><strong>Принято SMS</strong> — инкремент счётчика EF.SMSstatus (чтение-изменение-запись) и, по желанию, перезапись location-файлов.</li>
<li><strong>Перенастройка CB</strong> — запись списков CBMI/CBMIR или их очистка (все FF).</li>
<li><strong>AUTHENTICATE</strong> — команда AUTHENTICATE (3G/EPS/5G, <code class="font-mono text-sm">00 88 00 81 22</code>) с заданными или случайными RAND/AUTN и показ ответа (успех <code class="font-mono text-sm">DB</code> или ошибка синхронизации <code class="font-mono text-sm">DC</code> с AUTS).</li>
</ul>
<p class="text-sm mb-3"><strong>Параметры</strong> (свёрнуты) задают оператора (поиск по мировому списку MCC/MNC с сервера плюс выбор случайного роуминг-оператора), LAC/Cell ID/TAC/RAC, необязательные идентификаторы (пусто = случайно: TMSI, GUTI, KSI, KASME, Kc, счётчики NAS, алгоритм, RAND/AUTN), переключатели сценария и число циклов/задержку. Журнал шагов показывает каждую запись, ENVELOPE и AUTHENTICATE с их SW. Отправляются только UPDATE BINARY/RECORD, ENVELOPE и AUTHENTICATE — FPLMN и 5GS location-файлы никогда не записываются (ни один наблюдаемый телефон этого не делает); записи меняют карту и видны в последующих сравнениях снимков.</p>
</section>
<section class="mb-10">
+13
View File
@@ -474,6 +474,19 @@
</ul>
<p class="text-sm mb-3">Values persist server-side until restart. When the card issues PLI, the server injects the dictionary values into the TERMINAL RESPONSE.</p>
<h3 id="network-simulation" class="text-lg font-medium mb-2">8.7 Network simulation</h3>
<p class="text-sm mb-2">Replays the write patterns a real phone performs when the network condition changes (trace study in <code class="font-mono text-sm">projects/UICC_NAA.md</code>): EPS attach, service loss / limited service, roaming denial, 2G fallback, SMS delivery, cell-broadcast reconfiguration and AUTHENTICATE. One button per scenario; the card must be equipped.</p>
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
<li><strong>Cold boot</strong> — invalidate EPSNSC (KSI 07, key wiped) and dummy the location files.</li>
<li><strong>EPS attach / 2G attach</strong> — store a real EPS NAS context (KSI, KASME, NAS counts, algorithm) and write real LOCI/PSLOCI/EPSLOCI (2G also writes real Kc/KcGPRS).</li>
<li><strong>Service lost / Limited service / Roaming denied</strong> — send the Location status event (only when the card subscribed to it), invalidate EPSNSC (optionally keeping the old KASME), dummy the location files (PLMN kept, LAC <code class="font-mono text-sm">FFFE</code>, status <code class="font-mono text-sm">01</code>/<code class="font-mono text-sm">02</code>) and invalidate Kc.</li>
<li><strong>Churn</strong> — replay real → invalid EPSNSC records back-to-back (count and delay configurable).</li>
<li><strong>SMS received</strong> — bump the EF.SMSstatus counter (read-modify-write) and optionally rewrite the location files.</li>
<li><strong>CB reconfig</strong> — write the CBMI/CBMIR message-ID lists or clear them (all FF).</li>
<li><strong>AUTHENTICATE</strong> — send AUTHENTICATE (3G/EPS/5G, <code class="font-mono text-sm">00 88 00 81 22</code>) with the given or random RAND/AUTN and show the response (success <code class="font-mono text-sm">DB</code> or synchronisation failure <code class="font-mono text-sm">DC</code> with AUTS).</li>
</ul>
<p class="text-sm mb-3"><strong>Parameters</strong> (collapsed) provide the operator (searchable worldwide MCC/MNC list served from the server, plus a random roaming picker), LAC/Cell ID/TAC/RAC, optional identity values (empty = random: TMSI, GUTI, KSI, KASME, Kc, NAS counts, algorithm, RAND/AUTN), the scenario toggles and the churn count/delay. The step log lists every write, ENVELOPE and AUTHENTICATE with its SW. Only UPDATE BINARY/RECORD, ENVELOPE and AUTHENTICATE are sent — FPLMN and 5GS location files are never written (no observed phone does); the writes change the card and are visible to later snapshot comparisons.</p>
</section>
<section class="mb-10">
+274 -1
View File
@@ -943,6 +943,93 @@
<legend class="px-1 text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="Fetched proactive commands:">Fetched proactive commands:</legend>
<div id="pysim-proactive-log" class="text-sm font-mono text-gray-600 dark:text-slate-400"></div>
</fieldset>
<fieldset class="border border-gray-200 dark:border-slate-700 rounded p-3 mt-4">
<legend class="px-1 text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="Network simulation">Network simulation</legend>
<div class="text-xs text-gray-500 dark:text-slate-400 mb-2" data-l10n="Replays the write patterns a real phone performs on network-condition changes (EPS attach, service loss, roaming denial, 2G fallback, SMS delivery, cell-broadcast reconfiguration, AUTHENTICATE) - see projects/UICC_NAA.md. Only UPDATE BINARY/RECORD, ENVELOPE and AUTHENTICATE are sent; FPLMN and 5GS location files are never touched.">Replays the write patterns a real phone performs on network-condition changes (EPS attach, service loss, roaming denial, 2G fallback, SMS delivery, cell-broadcast reconfiguration, AUTHENTICATE) - see projects/UICC_NAA.md. Only UPDATE BINARY/RECORD, ENVELOPE and AUTHENTICATE are sent; FPLMN and 5GS location files are never touched.</div>
<div class="flex flex-wrap gap-1.5 mb-2">
<button data-needs="card" onclick="netSimRun('cold_boot')" class="px-2.5 py-1 text-xs rounded bg-gray-600 text-white hover:bg-gray-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Cold boot">Cold boot</button>
<button data-needs="card" onclick="netSimRun('attach_eps')" class="px-2.5 py-1 text-xs rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="EPS attach">EPS attach</button>
<button data-needs="card" onclick="netSimRun('attach_2g')" class="px-2.5 py-1 text-xs rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="2G attach">2G attach</button>
<button data-needs="card" onclick="netSimRun('service_lost')" class="px-2.5 py-1 text-xs rounded bg-red-600 text-white hover:bg-red-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Service lost">Service lost</button>
<button data-needs="card" onclick="netSimRun('limited_service')" class="px-2.5 py-1 text-xs rounded bg-amber-600 text-white hover:bg-amber-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Limited service">Limited service</button>
<button data-needs="card" onclick="netSimRun('roaming_denied')" class="px-2.5 py-1 text-xs rounded bg-amber-600 text-white hover:bg-amber-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Roaming denied">Roaming denied</button>
<button data-needs="card" onclick="netSimRun('churn')" class="px-2.5 py-1 text-xs rounded bg-violet-600 text-white hover:bg-violet-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Churn">Churn</button>
<button data-needs="card" onclick="netSimRun('sms_received')" class="px-2.5 py-1 text-xs rounded bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="SMS received">SMS received</button>
<button data-needs="card" onclick="netSimRun('cb_reconfig')" class="px-2.5 py-1 text-xs rounded bg-gray-600 text-white hover:bg-gray-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="CB reconfig">CB reconfig</button>
<button data-needs="card" onclick="netSimRun('authenticate')" class="px-2.5 py-1 text-xs rounded bg-slate-600 text-white hover:bg-slate-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="AUTHENTICATE">AUTHENTICATE</button>
</div>
<details id="netsim-params" class="mb-2">
<summary class="cursor-pointer select-none text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="Parameters">Parameters</summary>
<div class="mt-2 space-y-2">
<div class="flex flex-wrap items-end gap-2">
<div class="flex-1 min-w-[14rem]">
<label class="block mb-1 text-xs font-medium text-gray-600 dark:text-slate-400" data-l10n="Operator search (country / brand / MCC-MNC)">Operator search (country / brand / MCC-MNC)</label>
<input id="netsim-op-search" oninput="netSimSearchInput()" placeholder="Telekom / Germany / 26201" class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1 dark:bg-slate-800">
</div>
<div>
<label class="block mb-1 text-xs font-medium text-gray-600 dark:text-slate-400" data-l10n="MCC">MCC</label>
<input id="netsim-mcc" value="001" maxlength="3" class="w-16 font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1 dark:bg-slate-800">
</div>
<div>
<label class="block mb-1 text-xs font-medium text-gray-600 dark:text-slate-400" data-l10n="MNC">MNC</label>
<input id="netsim-mnc" value="01" maxlength="3" class="w-16 font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1 dark:bg-slate-800">
</div>
<button data-needs="server" onclick="netSimRandomOperator()" class="px-2 py-1 text-xs rounded bg-gray-200 dark:bg-slate-700 text-gray-700 dark:text-slate-300 hover:bg-gray-300 dark:hover:bg-slate-600 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Random roaming operator">Random roaming operator</button>
</div>
<div id="netsim-op-results" class="text-xs font-mono max-h-32 overflow-auto"></div>
<div class="flex flex-wrap items-end gap-2">
<div>
<label class="block mb-1 text-xs font-medium text-gray-600 dark:text-slate-400" data-l10n="LAC">LAC</label>
<input id="netsim-lac" maxlength="4" placeholder="random" class="w-20 font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1 dark:bg-slate-800">
</div>
<button onclick="netSimDice('netsim-lac', 2)" class="px-1.5 py-1 text-xs rounded bg-gray-200 dark:bg-slate-700 text-gray-700 dark:text-slate-300 hover:bg-gray-300 dark:hover:bg-slate-600" title="random">🎲</button>
<div>
<label class="block mb-1 text-xs font-medium text-gray-600 dark:text-slate-400" data-l10n="Cell ID">Cell ID</label>
<input id="netsim-cell" maxlength="4" placeholder="random" class="w-20 font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1 dark:bg-slate-800">
</div>
<button onclick="netSimDice('netsim-cell', 2)" class="px-1.5 py-1 text-xs rounded bg-gray-200 dark:bg-slate-700 text-gray-700 dark:text-slate-300 hover:bg-gray-300 dark:hover:bg-slate-600" title="random">🎲</button>
<div>
<label class="block mb-1 text-xs font-medium text-gray-600 dark:text-slate-400" data-l10n="TAC">TAC</label>
<input id="netsim-tac" maxlength="4" placeholder="= LAC" class="w-20 font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1 dark:bg-slate-800">
</div>
<button onclick="netSimDice('netsim-tac', 2)" class="px-1.5 py-1 text-xs rounded bg-gray-200 dark:bg-slate-700 text-gray-700 dark:text-slate-300 hover:bg-gray-300 dark:hover:bg-slate-600" title="random">🎲</button>
<div>
<label class="block mb-1 text-xs font-medium text-gray-600 dark:text-slate-400" data-l10n="RAC">RAC</label>
<input id="netsim-rac" maxlength="2" placeholder="random" class="w-14 font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1 dark:bg-slate-800">
</div>
<button onclick="netSimDice('netsim-rac', 1)" class="px-1.5 py-1 text-xs rounded bg-gray-200 dark:bg-slate-700 text-gray-700 dark:text-slate-300 hover:bg-gray-300 dark:hover:bg-slate-600" title="random">🎲</button>
</div>
<details>
<summary class="cursor-pointer select-none text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="Advanced identity (empty = random)">Advanced identity (empty = random)</summary>
<div class="mt-2 flex flex-wrap items-end gap-2">
<div><label class="block mb-1 text-xs font-medium text-gray-600 dark:text-slate-400">TMSI</label><input id="netsim-tmsi" maxlength="8" class="w-24 font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1 dark:bg-slate-800"></div>
<div><label class="block mb-1 text-xs font-medium text-gray-600 dark:text-slate-400">GUTI</label><input id="netsim-guti" maxlength="24" class="w-56 font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1 dark:bg-slate-800"></div>
<div><label class="block mb-1 text-xs font-medium text-gray-600 dark:text-slate-400">KSI</label><input id="netsim-ksi" maxlength="2" class="w-12 font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1 dark:bg-slate-800"></div>
<div><label class="block mb-1 text-xs font-medium text-gray-600 dark:text-slate-400">KASME</label><input id="netsim-kasme" maxlength="64" class="w-72 font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1 dark:bg-slate-800"></div>
<div><label class="block mb-1 text-xs font-medium text-gray-600 dark:text-slate-400">Kc</label><input id="netsim-kc" maxlength="16" class="w-32 font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1 dark:bg-slate-800"></div>
<div><label class="block mb-1 text-xs font-medium text-gray-600 dark:text-slate-400">UL</label><input id="netsim-ul" maxlength="8" class="w-20 font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1 dark:bg-slate-800"></div>
<div><label class="block mb-1 text-xs font-medium text-gray-600 dark:text-slate-400">DL</label><input id="netsim-dl" maxlength="8" class="w-20 font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1 dark:bg-slate-800"></div>
<div><label class="block mb-1 text-xs font-medium text-gray-600 dark:text-slate-400" data-l10n="Algorithm">Algorithm</label><input id="netsim-algo" maxlength="2" class="w-12 font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1 dark:bg-slate-800"></div>
<div><label class="block mb-1 text-xs font-medium text-gray-600 dark:text-slate-400">RAND</label><input id="netsim-rand" maxlength="32" class="w-64 font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1 dark:bg-slate-800"></div>
<div><label class="block mb-1 text-xs font-medium text-gray-600 dark:text-slate-400">AUTN</label><input id="netsim-autn" maxlength="32" class="w-64 font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1 dark:bg-slate-800"></div>
</div>
</details>
<div class="flex flex-wrap gap-x-4 gap-y-1 text-xs text-gray-600 dark:text-slate-300">
<label class="flex items-center gap-1"><input type="checkbox" id="netsim-send-event" checked class="rounded"> <span data-l10n="Send Location status event (if subscribed)">Send Location status event (if subscribed)</span></label>
<label class="flex items-center gap-1"><input type="checkbox" id="netsim-dummy-locations" checked class="rounded"> <span data-l10n="Dummy location files">Dummy location files</span></label>
<label class="flex items-center gap-1"><input type="checkbox" id="netsim-invalidate-epsnsc" checked class="rounded"> <span data-l10n="Invalidate EPSNSC">Invalidate EPSNSC</span></label>
<label class="flex items-center gap-1"><input type="checkbox" id="netsim-keep-kasme" checked class="rounded"> <span data-l10n="Keep KASME (KSI 07 only)">Keep KASME (KSI 07 only)</span></label>
<label class="flex items-center gap-1"><input type="checkbox" id="netsim-write-kc" checked class="rounded"> <span data-l10n="Write/invalidate Kc">Write/invalidate Kc</span></label>
<label class="flex items-center gap-1"><input type="checkbox" id="netsim-sms-location" checked class="rounded"> <span data-l10n="SMS: rewrite location too">SMS: rewrite location too</span></label>
<label class="flex items-center gap-1"><input type="checkbox" id="netsim-cb-clear" class="rounded"> <span data-l10n="CB: clear lists instead of writing IDs">CB: clear lists instead of writing IDs</span></label>
<label class="flex items-center gap-1"><span data-l10n="Churn count">Churn count</span><input id="netsim-churn-count" type="number" value="3" min="1" max="20" class="w-14 border border-gray-300 dark:border-slate-600 text-xs rounded px-1 py-0.5 dark:bg-slate-800"></label>
<label class="flex items-center gap-1"><span data-l10n="Delay ms">Delay ms</span><input id="netsim-churn-delay" type="number" value="150" min="0" max="5000" class="w-20 border border-gray-300 dark:border-slate-600 text-xs rounded px-1 py-0.5 dark:bg-slate-800"></label>
</div>
</div>
</details>
<div id="netsim-status" class="text-xs text-gray-400 dark:text-slate-500 mb-1"></div>
<div id="netsim-log" class="text-xs font-mono text-gray-600 dark:text-slate-400 max-h-[30vh] overflow-auto"></div>
</fieldset>
</div>
<div id="phone-sub-tr" class="hidden">
<div class="flex justify-between items-center mb-2">
@@ -1301,7 +1388,7 @@
// ===== Version =====
// Single source of truth for the PWA version: shown in the header and used
// by the server version check in pysimConnect().
const SIMPLE_VERSION = '2.4.0';
const SIMPLE_VERSION = '2.5.0';
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
// ===== Tab switching =====
@@ -12231,6 +12318,160 @@ function efContentDiff(name, fid, expectedHex, actualHex) {
return { rows: rows, sameDecoded: rows.length === 0 };
}
// ===== Network simulation (Phone tab) =====
// Sends the scenario parameters to POST /api/net-sim and renders the step
// log. The scenarios are implemented in pysim_simple_server/netsim.py
// (trace study: projects/UICC_NAA.md section 13).
let _netSimOpResults = [];
let _netSimSearchTimer = null;
function netSimRandomHex(nbytes) {
let s = '';
for (let i = 0; i < nbytes; i++) s += Math.floor(Math.random() * 256).toString(16).padStart(2, '0').toUpperCase();
return s;
}
function netSimDice(id, nbytes) {
const el = document.getElementById(id);
if (el) el.value = netSimRandomHex(nbytes);
}
function netSimVal(id) {
const el = document.getElementById(id);
return el ? el.value.trim() : '';
}
function netSimChk(id) {
const el = document.getElementById(id);
return el ? el.checked : false;
}
function netSimParams() {
const p = {
send_event: netSimChk('netsim-send-event'),
dummy_locations: netSimChk('netsim-dummy-locations'),
invalidate_epsnsc: netSimChk('netsim-invalidate-epsnsc'),
keep_kasme: netSimChk('netsim-keep-kasme'),
write_kc: netSimChk('netsim-write-kc'),
sms_location: netSimChk('netsim-sms-location'),
cb_clear: netSimChk('netsim-cb-clear'),
};
const map = {
mcc: 'netsim-mcc', mnc: 'netsim-mnc', lac: 'netsim-lac', cell_id: 'netsim-cell',
tac: 'netsim-tac', rac: 'netsim-rac', tmsi: 'netsim-tmsi', guti: 'netsim-guti',
ksi: 'netsim-ksi', kasme: 'netsim-kasme', kc: 'netsim-kc', ul: 'netsim-ul',
dl: 'netsim-dl', algo: 'netsim-algo', rand: 'netsim-rand', autn: 'netsim-autn',
churn_count: 'netsim-churn-count', churn_delay_ms: 'netsim-churn-delay',
};
for (const [key, id] of Object.entries(map)) {
const v = netSimVal(id);
if (v !== '') p[key] = v;
}
return p;
}
async function netSimRun(scenario) {
const statusEl = document.getElementById('netsim-status');
statusEl.textContent = t('Running') + '...';
statusEl.className = 'text-xs text-gray-500 dark:text-slate-400 mb-1';
try {
const body = netSimParams();
body.scenario = scenario;
const res = await pysimFetch('/api/net-sim', body);
netSimRenderLog(res);
if (res.success) {
statusEl.textContent = t('Done') + ' — ' + (res.steps || []).length + ' ' + t('steps');
statusEl.className = 'text-xs text-emerald-600 dark:text-emerald-400 mb-1';
} else {
statusEl.textContent = t('Failed') + ': ' + (res.error || '');
statusEl.className = 'text-xs text-red-500 mb-1';
}
pysimEventsRender();
} catch (e) {
statusEl.textContent = t('Error') + ': ' + e.message;
statusEl.className = 'text-xs text-red-500 mb-1';
}
}
function netSimStepText(s) {
const sw = (s.sw !== undefined && s.sw !== null) ? ' → SW ' + s.sw : '';
if (s.action === 'skip') return t('skip') + ' ' + (s.file || '') + (s.note ? ' — ' + s.note : '');
if (s.action === 'event') return 'ENVELOPE ' + (s.file || '') + ' ' + (s.data || '') + sw;
if (s.action === 'authenticate') {
const parsed = s.parsed ? s.parsed.type : '';
return 'AUTHENTICATE ' + parsed + sw + (s.response ? ' ' + s.response : '');
}
return s.action + ' ' + (s.file || '') + (s.path ? ' (' + s.path + ')' : '') + (s.data ? ' ' + s.data : '') + sw;
}
function netSimRenderLog(res) {
const el = document.getElementById('netsim-log');
if (!el) return;
let html = '';
for (const s of res.steps || []) {
const cls = s.ok === false ? 'text-red-500' : (s.action === 'skip' ? 'text-yellow-600' : 'text-gray-600 dark:text-slate-400');
html += '<div class="' + cls + ' break-all">' + esc(netSimStepText(s)) + '</div>';
}
el.innerHTML = html || '<span class="text-gray-400">' + esc(t('No steps')) + '</span>';
}
function netSimSearchInput() {
clearTimeout(_netSimSearchTimer);
_netSimSearchTimer = setTimeout(netSimSearch, 250);
}
async function netSimSearch() {
const q = netSimVal('netsim-op-search');
const el = document.getElementById('netsim-op-results');
if (!el) return;
if (q.length < 2) { el.innerHTML = ''; return; }
try {
const res = await pysimFetch('/api/mcc-mnc?q=' + encodeURIComponent(q));
if (!res.available) {
el.innerHTML = '<span class="text-yellow-600">' + esc(t('Operator list not loaded (server --mcc-mnc-list)')) + '</span>';
return;
}
_netSimOpResults = res.results || [];
el.innerHTML = _netSimOpResults.map((r, i) =>
'<div class="cursor-pointer hover:bg-gray-100 dark:hover:bg-slate-700 rounded px-1" onclick="netSimPickOperator(' + i + ')">' +
esc((r.countryName || '') + ' — ' + (r.brand || r.operator || '') + ' (' + r.mcc + '/' + r.mnc + ')') + '</div>').join('');
} catch (e) {
el.textContent = e.message;
}
}
function netSimPickOperator(i) {
const r = _netSimOpResults[i];
if (!r) return;
document.getElementById('netsim-mcc').value = r.mcc;
document.getElementById('netsim-mnc').value = r.mnc;
const el = document.getElementById('netsim-op-results');
if (el) el.innerHTML = '';
}
async function netSimRandomOperator() {
const statusEl = document.getElementById('netsim-status');
try {
const exclude = (netSimVal('netsim-mcc') || '') + (netSimVal('netsim-mnc') || '');
const res = await pysimFetch('/api/mcc-mnc?random=1' + (exclude ? '&exclude=' + encodeURIComponent(exclude) : ''));
if (!res.available) {
statusEl.textContent = t('Operator list not loaded (server --mcc-mnc-list)');
statusEl.className = 'text-xs text-yellow-600 mb-1';
return;
}
if (res.result) {
document.getElementById('netsim-mcc').value = res.result.mcc;
document.getElementById('netsim-mnc').value = res.result.mnc;
statusEl.textContent = (res.result.countryName || '') + ' — ' + (res.result.brand || res.result.operator || '') + ' (' + res.result.mcc + '/' + res.result.mnc + ')';
statusEl.className = 'text-xs text-gray-500 dark:text-slate-400 mb-1';
}
} catch (e) {
statusEl.textContent = t('Error') + ': ' + e.message;
statusEl.className = 'text-xs text-red-500 mb-1';
}
}
// ===== i18n =====
const LANG_RU = {
'Generate APDU': 'Сгенерировать APDU',
@@ -12488,6 +12729,38 @@ const LANG_RU = {
'Events that the card monitors (SET UP EVENT LIST):': 'События, отслеживаемые картой (SET UP EVENT LIST):',
'No events configured.': 'Нет настроенных событий.',
'Fetched proactive commands:': 'Извлечённые проактивные команды:',
'Network simulation': 'Симуляция сети',
'Replays the write patterns a real phone performs on network-condition changes (EPS attach, service loss, roaming denial, 2G fallback, SMS delivery, cell-broadcast reconfiguration, AUTHENTICATE) - see projects/UICC_NAA.md. Only UPDATE BINARY/RECORD, ENVELOPE and AUTHENTICATE are sent; FPLMN and 5GS location files are never touched.': 'Воспроизводит шаблоны записи реального телефона при смене сетевых условий (подключение EPS, потеря сервиса, запрет роуминга, откат на 2G, приём SMS, перенастройка cell broadcast, AUTHENTICATE) — см. projects/UICC_NAA.md. Отправляются только UPDATE BINARY/RECORD, ENVELOPE и AUTHENTICATE; FPLMN и 5GS location-файлы не затрагиваются.',
'Cold boot': 'Холодная загрузка',
'EPS attach': 'Подключение EPS',
'2G attach': 'Подключение 2G',
'Service lost': 'Потеря сервиса',
'Limited service': 'Ограниченный сервис',
'Roaming denied': 'Роуминг запрещён',
'Churn': 'Серия переподключений',
'SMS received': 'Принято SMS',
'CB reconfig': 'Перенастройка CB',
'Parameters': 'Параметры',
'Operator search (country / brand / MCC-MNC)': 'Поиск оператора (страна / бренд / MCC-MNC)',
'Random roaming operator': 'Случайный роуминг-оператор',
'Advanced identity (empty = random)': 'Дополнительные идентификаторы (пусто = случайно)',
'Algorithm': 'Алгоритм',
'Send Location status event (if subscribed)': 'Отправлять событие Location status (если подписано)',
'Dummy location files': 'Фиктивные location-файлы',
'Invalidate EPSNSC': 'Инвалидировать EPSNSC',
'Keep KASME (KSI 07 only)': 'Сохранять KASME (только KSI 07)',
'Write/invalidate Kc': 'Записывать/инвалидировать Kc',
'SMS: rewrite location too': 'SMS: перезаписывать и location',
'CB: clear lists instead of writing IDs': 'CB: очищать списки вместо записи ID',
'Churn count': 'Число циклов',
'Delay ms': 'Задержка, мс',
'Running': 'Выполняется',
'Done': 'Готово',
'Failed': 'Ошибка',
'steps': 'шагов',
'No steps': 'Нет шагов',
'skip': 'пропуск',
'Operator list not loaded (server --mcc-mnc-list)': 'Список операторов не загружен (--mcc-mnc-list на сервере)',
'No commands logged yet.': 'Команды пока не регистрировались.',
'Send event notification to the card?': 'Отправить уведомление о событии на карту?',
'This event type is not yet supported.': 'Этот тип события пока не поддерживается.',
+22
View File
@@ -823,6 +823,10 @@ video {
max-height: 14rem;
}
.max-h-\[30vh\] {
max-height: 30vh;
}
.max-h-\[35vh\] {
max-height: 35vh;
}
@@ -919,6 +923,10 @@ video {
width: 1.5rem;
}
.w-64 {
width: 16rem;
}
.w-72 {
width: 18rem;
}
@@ -940,6 +948,10 @@ video {
min-width: 0px;
}
.min-w-\[14rem\] {
min-width: 14rem;
}
.min-w-\[180px\] {
min-width: 180px;
}
@@ -1265,6 +1277,11 @@ video {
border-top-color: transparent;
}
.bg-amber-600 {
--tw-bg-opacity: 1;
background-color: rgb(217 119 6 / var(--tw-bg-opacity, 1));
}
.bg-black\/50 {
background-color: rgb(0 0 0 / 0.5);
}
@@ -1693,6 +1710,11 @@ video {
color: #f1f5f9;
}
.hover\:bg-amber-700:hover {
--tw-bg-opacity: 1;
background-color: rgb(180 83 9 / var(--tw-bg-opacity, 1));
}
.hover\:bg-blue-700:hover {
--tw-bg-opacity: 1;
background-color: rgb(29 78 216 / var(--tw-bg-opacity, 1));
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'simple-v192';
const CACHE = 'simple-v193';
const URLS = [
'index.html',
'help.html',
+15
View File
@@ -202,6 +202,21 @@ test('every help anchor used by the UI exists in help.html', () => {
assert.ok(html.includes("? 'scp81-scripts' : 'scp81-listener'"));
});
test('phone simulator has the network-simulation fieldset', () => {
assert.ok(html.includes('data-l10n="Network simulation"'));
for (const s of ['cold_boot', 'attach_eps', 'attach_2g', 'service_lost',
'limited_service', 'roaming_denied', 'churn', 'sms_received',
'cb_reconfig', 'authenticate']) {
assert.ok(html.includes("netSimRun('" + s + "')"), s);
}
assert.ok(html.includes('id="netsim-op-search"'));
assert.ok(html.includes('id="netsim-log"'));
assert.ok(html.includes('id="netsim-status"'));
assert.match(html, /id="netsim-mcc" value="001"/);
// scenario buttons need the card (the server writes real EFs)
assert.match(html, /data-needs="card" onclick="netSimRun\('service_lost'\)"/);
});
test('SCP81 listener exposes its four modes with the matching notes', () => {
for (const v of ['tls', 'redirect', 'passthru', 'dump']) {
assert.ok(html.includes('value="' + v + '"'), v);
+86
View File
@@ -0,0 +1,86 @@
const { test } = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
function extractFunc(src, name) {
const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
const m = re.exec(src);
if (!m) throw new Error('function ' + name + ' not found');
let i = m.index + m[0].length - 1;
let depth = 0;
for (; i < src.length; i++) {
if (src[i] === '{') depth++;
else if (src[i] === '}') {
depth--;
if (depth === 0) break;
}
}
return src.slice(m.index, i + 1);
}
let code = '';
for (const f of ['netSimRandomHex', 'netSimDice', 'netSimVal', 'netSimChk', 'netSimParams', 'netSimStepText']) {
code += extractFunc(html, f) + '\n';
}
code += 'function t(k){return k;}\n';
code += 'var _fields = {};\nvar document = { getElementById: function(id){ return _fields[id] || null; } };\n';
eval(code);
test('netSimRandomHex produces uppercase hex of the requested length', () => {
const h = netSimRandomHex(4);
assert.match(h, /^[0-9A-F]{8}$/);
assert.match(netSimRandomHex(1), /^[0-9A-F]{2}$/);
});
test('netSimParams maps form fields and checkboxes', () => {
_fields = {
'netsim-mcc': { value: '262' },
'netsim-mnc': { value: '01' },
'netsim-lac': { value: ' 6CD7 ' },
'netsim-lac-empty': { value: '' },
'netsim-churn-count': { value: '5' },
'netsim-send-event': { checked: true },
'netsim-dummy-locations': { checked: false },
'netsim-invalidate-epsnsc': { checked: true },
'netsim-keep-kasme': { checked: true },
'netsim-write-kc': { checked: false },
'netsim-sms-location': { checked: true },
'netsim-cb-clear': { checked: false },
};
const p = netSimParams();
assert.strictEqual(p.mcc, '262');
assert.strictEqual(p.mnc, '01');
assert.strictEqual(p.lac, '6CD7');
assert.strictEqual(p.churn_count, '5');
assert.strictEqual(p.send_event, true);
assert.strictEqual(p.dummy_locations, false);
assert.strictEqual(p.invalidate_epsnsc, true);
assert.strictEqual(p.keep_kasme, true);
assert.strictEqual(p.write_kc, false);
assert.strictEqual(p.sms_location, true);
assert.strictEqual(p.cb_clear, false);
// unset fields are omitted so the server fills its own defaults
assert.ok(!('tmsi' in p));
assert.ok(!('kasme' in p));
});
test('netSimStepText renders the step log lines', () => {
assert.strictEqual(
netSimStepText({ action: 'update_binary', file: 'loci', path: 'ADF.USIM/6F7E', data: 'FF01', sw: '9000' }),
'update_binary loci (ADF.USIM/6F7E) FF01 → SW 9000');
assert.strictEqual(
netSimStepText({ action: 'update_record', file: 'epsnsc', path: 'ADF.USIM/6FE4', data: 'A0', sw: '9000' }),
'update_record epsnsc (ADF.USIM/6FE4) A0 → SW 9000');
assert.strictEqual(
netSimStepText({ action: 'event', file: 'location_status', data: '9B0102', sw: '9000' }),
'ENVELOPE location_status 9B0102 → SW 9000');
assert.strictEqual(
netSimStepText({ action: 'skip', file: 'location_status', note: 'event 0x03 not in SET UP EVENT LIST' }),
'skip location_status — event 0x03 not in SET UP EVENT LIST');
assert.match(
netSimStepText({ action: 'authenticate', parsed: { type: 'synchronisation_failure' }, sw: '9000', response: 'DC10AA' }),
/^AUTHENTICATE synchronisation_failure → SW 9000 DC10AA$/);
});