diff --git a/src/lib/components/EthernetBuilder.svelte b/src/lib/components/EthernetBuilder.svelte deleted file mode 100644 index e84875d..0000000 --- a/src/lib/components/EthernetBuilder.svelte +++ /dev/null @@ -1,274 +0,0 @@ - - -
-

Конструктор Ethernet кадра

- -
- - handleMACInput('dest', e.target.value)} - placeholder="AA:BB:CC:DD:EE:FF" - class:error={errors.dest} - /> - {#if errors.dest} - {errors.dest} - {/if} - Адрес получателя -
- -
- - handleMACInput('src', e.target.value)} - placeholder="AA:BB:CC:DD:EE:FF" - class:error={errors.src} - /> - {#if errors.src} - {errors.src} - {/if} - Адрес отправителя -
- -
- - - Тип инкапсулированного протокола -
- -
- -
- 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 -
- Полезная нагрузка (заполнена нулями) -
- -
- -
- - {'0x' + currentChecksum.map(b => b.toString(16).padStart(2,'0')).join('').toUpperCase()} - -
- Контрольная сумма (рассчитывается автоматически) -
- -
-

Структура кадра (64 байта):

-
-
- Destination MAC - 6 bytes -
-
- Source MAC - 6 bytes -
-
- EtherType - 2 bytes -
-
- Data - 46 bytes -
-
- FCS - 4 bytes -
-
-
-
- - \ No newline at end of file diff --git a/src/lib/components/HttpEditor.svelte b/src/lib/components/HttpEditor.svelte new file mode 100644 index 0000000..fc0b7a3 --- /dev/null +++ b/src/lib/components/HttpEditor.svelte @@ -0,0 +1,366 @@ + + + +
+ +
+ + + +
+ + + {#if parsed} +
+

Разбор запроса

+ + {#if parsed.error} +
⚠ {parsed.error}
+ {:else} + +
+
Строка запроса
+
+ + {parsed.method} + + {parsed.path} + + {parsed.version} + +
+
+ + +
+
+ Заголовки + ({headerEntries(parsed.headers).length}) +
+ {#if headerEntries(parsed.headers).length === 0} +
— нет заголовков
+ {:else} + + {#each headerEntries(parsed.headers) as h} + + + + + {/each} +
{h.key}{h.value}
+ {/if} +
+ + +
+
Разделитель (пустая строка)
+ {#if parsed.hasBlankLine} + ✓ присутствует + {:else} + ✗ отсутствует — заголовки не отделены от тела + {/if} +
+ + +
+
+ Тело запроса + {#if parsed.body} + ({new TextEncoder().encode(parsed.body).length} байт) + {/if} +
+ {#if parsed.body} +
{parsed.body}
+ {:else} +
— тело отсутствует
+ {/if} +
+ {/if} +
+ {/if} +
+ + \ No newline at end of file diff --git a/src/lib/components/LessonLayout.svelte b/src/lib/components/LessonLayout.svelte index 8e22fd2..b11063e 100644 --- a/src/lib/components/LessonLayout.svelte +++ b/src/lib/components/LessonLayout.svelte @@ -1,6 +1,4 @@ @@ -26,11 +24,6 @@

Практика

-
-

Задание

-

{lesson.objective}

-
-
@@ -120,20 +113,6 @@ padding-bottom: 10px; margin-bottom: 20px; } - - @media (max-width: 768px) { .lesson-body { diff --git a/src/lib/components/WiresharkView.svelte b/src/lib/components/WiresharkView.svelte index 89d5bd6..c35d0bd 100644 --- a/src/lib/components/WiresharkView.svelte +++ b/src/lib/components/WiresharkView.svelte @@ -1,97 +1,169 @@ +
-

Структура Ethernet кадра

+

{title}

+
-
- -
{formatMAC(destinationMAC)}
- MAC адрес получателя -
- -
- -
{formatMAC(sourceMAC)}
- MAC адрес отправителя -
- -
- -
{formatEtherType(etherType)}
- Тип протокола -
- -
- -
{formatHex(frameData)}
- Полезная нагрузка -
- -
- -
{formatHex(fcs)}
- Контрольная сумма -
+ {#each fieldValues as fv (fv.name)} +
+
+ {fv.name}: + + {fv.length === 1 + ? `байт ${fv.start}` + : `байты ${fv.start}–${fv.start + fv.length - 1}`} + +
+
+ {formatBytes(fv.bytes, fv.format)} +
+ {#if fv.description} + {fv.description} + {/if} +
+ {/each}
-
-

Структура кадра (64 байта):

-
-
- Destination MAC - 6 bytes -
-
- Source MAC - 6 bytes -
-
- EtherType - 2 bytes -
-
- Data - 46 bytes -
-
- FCS - 4 bytes + + {#if fieldValues.length > 0} +
+

Структура пакета ({totalBytesCount} байт):

+
+
+ {#each fieldValues as fv (fv.name)} + {@const cellPx = Math.max(fv.length * PX_PER_BYTE, MIN_CELL_PX)} +
+ {fv.name} + {fv.length}B +
+ {/each} +
-
+ {/if}
\ No newline at end of file diff --git a/src/lib/data/lessons.js b/src/lib/data/lessons.js index adf7da0..e204f6d 100644 --- a/src/lib/data/lessons.js +++ b/src/lib/data/lessons.js @@ -1,3 +1,5 @@ +import { theory } from './theory.js'; + export const lessons = [ { id: 1, @@ -7,162 +9,18 @@ export const lessons = [ difficulty: 'Начинающий', component: 'BitEditor', additionalComponents: ['HexViewer'], - theory: ` -
-

- Бит и байт - основа сетевых протоколов -

- -
-

Что такое бит?

-
    -
  • Бит - минимальная единица информации (0 или 1)
  • -
  • Основа всей компьютерной техники
  • -
-
- -
-

Что такое байт?

-
    -
  • Байт = 8 битов
  • -
  • Пример: 01011011 = 1 байт
  • -
-
- -
-

Двоичная арифметика:

-

Каждая позиция бита - это степень двойки:

-
- 10101010 = 1×2⁷ + 0×2⁶ + 1×2⁵ + 0×2⁴ + 1×2³ + 0×2² + 1×2¹ + 0×2⁰ = 1×128 + 0×64 + 1×32 + 0×16 + 1×8 + 0×4 + 1×2 + 0×1 = 170 -
-
- -
-

Шестнадцатеричная система (Hex):

-

В обычной жизни мы используем десятичную систему (0-9). В шестнадцатеричной системе 16 цифр:

- -
- - - - - - - - - - - -
ДесятичнаяHexДвоичная
000000
110001
220010
330011
440100
550101
660110
770111
881000
- - - - - - - - - - -
ДесятичнаяHexДвоичная
991001
10A1010
11B1011
12C1100
13D1101
14E1110
15F1111
-
-
- -
-

Как преобразовать двоичное в Hex:

-

Разбиваем байт на две половинки по 4 бита и преобразуем каждую отдельно:

- -
-

Пример: 11010011

-
    -
  1. Разбиваем на две группы: 1101 и 0011
  2. -
  3. 1101 = 1×8 + 1×4 + 0×2 + 1×1 = 13 = D
  4. -
  5. 0011 = 0×8 + 0×4 + 1×2 + 1×1 = 3 = 3
  6. -
  7. Результат: 0xD3
  8. -
-
- -

Удобнее читать 0xA1, чем 10100001!

-
-
-`, - - objective: 'Установите биты так, чтобы получить число 184 (0xB8)', - initialBuffer: new Uint8Array([0x00]), - validate: (buffer) => { - return buffer[0] === 0xB8; - }, - hints: [ - '184 в двоичной системе = 10111000', - 'Каждый бит представляет степень двойки', - 'Попробуйте установить биты в позициях 7,5,4,3' - ] + theory: theory['bit-manipulation'], + taskTemplate: { type: 'bit-set' }, }, { id: 2, - slug: 'mac-address-edit', + slug: 'mac-address', title: 'MAC адреса - редактирование 6 байт', category: 'Ethernet', difficulty: 'Начинающий', component: 'HexEditor', - - theory: ` -
-

- MAC адреса -

- -
-

Что такое MAC адрес?

-
    -
  • MAC адрес - уникальный идентификатор сетевого устройства
  • -
  • Формат: XX:XX:XX:XX:XX:XX (6 байтов)
  • -
  • Пример: 12:34:56:78:9A:BC
  • -
  • Первые 3 байта - идентификатор производителя (OUI)
  • -
  • Последние 3 байта - серийный номер устройства
  • -
-
- -
-

Как редактировать MAC-адрес?

- -

Форматы представления чисел:

-
    -
  • Hex (16-ричный): 0xFF, 0x1A3B
  • -
  • Decimal (10-ричный): 255, 6715
  • -
  • Binary (2-ичный): 11111111, 110100011011
  • -
  • Octal (8-ричный): 377, 15073
  • -
- -

Endianness (порядок байт):

-
    -
  • Big Endian: старший байт по младшему адресу (сетевой порядок)
  • -
  • Little Endian: младший байт по младшему адресу (x86, ARM)
  • -
  • Пример: 0x12345678 -
      -
    • Big Endian: 12 34 56 78
    • -
    • Little Endian: 78 56 34 12
    • -
    -
  • -
-
-
-`, - - objective: 'Установите MAC адрес 12:34:56:78:9A:BC', - - initialBuffer: new Uint8Array(6), - - validate: (buffer) => { - const expectedMAC = [0x12, 0x34, 0x56, 0x78, 0x9A, 0xBC]; - return expectedMAC.every((expected, i) => buffer[i] === expected); - }, - - hints: [ - 'Для HEX: разделите байт на две половинки по 4 бита. 0x34 = 00110100 → 0011(3) 0100(4)', - 'Кликайте на байты в HexView чтобы редактировать их', - 'Степени двойки по битам: 128,64,32,16,8,4,2,1' - ] + theory: theory['mac-address'], + taskTemplate: { type: 'mac-set' }, }, { id: 3, @@ -179,82 +37,355 @@ export const lessons = [ { name: 'Data', start: 14, length: 46, format: 'hex', color: '#f3e5f5' }, { name: 'FCS', start: 60, length: 4, format: 'hex', color: '#ffebee' } ], + wiresharkTitle: 'Структура Ethernet кадра', readOnlyRanges: [ { start: 14, end: 59 }, //Data (46 байт) { start: 60, end: 63 } //FCS (4 байта) - рассчитывается автоматически ], - - theory: ` -
-

- Ethernet кадр -

- -
-

Ethernet II и IEEE 802.3 - это два стандарта Ethernet-кадров. Они идентичны по структуре, кроме одного поля: в Ethernet II это поле Type (указывает тип протокола), а в IEEE 802.3 это поле Length (указывает длину поля данных в байтах).

-

На практике Ethernet II используется чаще, потому что поле Type более полезно для определения, какой протокол находится внутри кадра.

-
- -
-

Структура кадра Ethernet II:

- -
-
- Destination MAC: - 6 байт | MAC адрес получателя -
-
- Source MAC: - 6 байт | MAC адрес отправителя -
-
- Type: - 2 байта | Тип протокола -
-
- Data: - 46-1500 байт | Полезная нагрузка -
-
- FCS: - 4 байта | Контрольная сумма -
-
-
- -
-

Типы протоколов (EtherType):

- -
- - - - - -
КодПротокол
0x0800IPv4
0x0806ARP
0x86DDIPv6
-
-
-
-`, - - objective: 'Отправьте широковещательный ARP запрос с MAC адресом вашего компьютера 12:34:56:78:9A:BC', - initialBuffer: new Uint8Array(64), - validate: (buffer) => { - const isBroadcast = buffer.slice(0, 6).every(byte => byte === 0xFF); - - const expectedSource = [0x12, 0x34, 0x56, 0x78, 0x9A, 0xBC]; - const isSourceCorrect = buffer.slice(6, 12).every((byte, i) => byte === expectedSource[i]); - - const etherType = (buffer[12] << 8) | buffer[13]; - const isARP = etherType === 0x0806; - - return isBroadcast && isSourceCorrect && isARP; - }, - - hints: [ - 'Используйте широковещательный адрес FF:FF:FF:FF:FF:FF', - 'Установите EtherType 0x0806 для ARP', - 'MAC адрес отправителя должен быть 12:34:56:78:9A:BC' - ] - } + theory: theory['ethernet-frame'], + taskTemplate: { type: 'ethernet-frame' }, + }, + { + id: 4, + slug: 'ipv4-header', + title: 'Структура IPv4-заголовка', + category: 'IPv4', + difficulty: 'Средний', + component: 'HexEditor', + additionalComponents: ['WiresharkView'], + wiresharkTitle: 'Структура IPv4-заголовка', + wiresharkFields: [ + { + name: 'Version + IHL', + start: 0, length: 1, + format: 'version_ihl', + color: '#e3f2fd', + description: 'Версия протокола (4) и длина заголовка в 32-битных словах' + }, + { + name: 'DSCP + ECN', + start: 1, length: 1, + format: 'hex', + color: '#e8eaf6', + description: 'Приоритет трафика и уведомление о перегрузке' + }, + { + name: 'Total Length', + start: 2, length: 2, + format: 'decimal', + color: '#e8f5e9', + description: 'Общая длина пакета (заголовок + данные), байт' + }, + { + name: 'Identification', + start: 4, length: 2, + format: 'hex', + color: '#fff3e0', + description: 'Идентификатор пакета (для сборки фрагментов)' + }, + { + name: 'Flags + Fragment Offset', + start: 6, length: 2, + format: 'flags_fragment', + color: '#fce4ec', + description: 'Флаги фрагментации DF/MF и смещение фрагмента' + }, + { + name: 'TTL', + start: 8, length: 1, + format: 'decimal', + color: '#f3e5f5', + description: 'Время жизни пакета — уменьшается на 1 на каждом маршрутизаторе' + }, + { + name: 'Protocol', + start: 9, length: 1, + format: 'ip_protocol', + color: '#e0f7fa', + description: 'Протокол вышележащего уровня (TCP=6, UDP=17, ICMP=1)' + }, + { + name: 'Header Checksum', + start: 10, length: 2, + format: 'hex', + color: '#f1f8e9', + description: 'Контрольная сумма заголовка (пересчитывается автоматически)' + }, + { + name: 'Source IP', + start: 12, length: 4, + format: 'ip', + color: '#fff8e1', + description: 'IP-адрес отправителя' + }, + { + name: 'Destination IP', + start: 16, length: 4, + format: 'ip', + color: '#fbe9e7', + description: 'IP-адрес получателя' + }, + ], + //read-only диапазоны + //байты 0-7: version, IHL, DSCP, total length, identification, flags (установлены заранее) + //байты 10-11: checksum (пересчитывается автоматически в bufferProcessor) + readOnlyRanges: [ + { start: 0, end: 7 }, + { start: 10, end: 11 }, + ], + theory: theory['ipv4-header'], + taskTemplate: { type: 'ipv4-addresses' }, + }, + { + id: 5, + slug: 'ipv4-ttl', + title: 'TTL — время жизни пакета', + category: 'IPv4', + difficulty: 'Средний', + component: 'HexEditor', + additionalComponents: ['WiresharkView'], + wiresharkTitle: 'IPv4: поле TTL', + wiresharkFields: [ + { name: 'Version + IHL', start: 0, length: 1, format: 'version_ihl', color: '#e3f2fd', description: '' }, + { name: 'DSCP + ECN', start: 1, length: 1, format: 'hex', color: '#e8eaf6', description: '' }, + { name: 'Total Length', start: 2, length: 2, format: 'decimal', color: '#e8f5e9', description: 'Общая длина пакета' }, + { name: 'Identification', start: 4, length: 2, format: 'hex', color: '#fff3e0', description: '' }, + { name: 'Flags + Frag. Offset', start: 6, length: 2, format: 'flags_fragment', color: '#fce4ec', description: '' }, + { name: 'TTL', start: 8, length: 1, format: 'decimal', color: '#f3e5f5', description: 'Уменьшается на 1 на каждом маршрутизаторе' }, + { name: 'Protocol', start: 9, length: 1, format: 'ip_protocol', color: '#e0f7fa', description: '' }, + { name: 'Header Checksum', start: 10, length: 2, format: 'hex', color: '#f1f8e9', description: 'Рассчитывается автоматически' }, + { name: 'Source IP', start: 12, length: 4, format: 'ip', color: '#fff8e1', description: '' }, + { name: 'Destination IP', start: 16, length: 4, format: 'ip', color: '#fbe9e7', description: '' }, + ], + readOnlyRanges: [ + { start: 0, end: 7 }, //фиксированные поля заголовка + { start: 9, end: 11 }, //protocol + checksum + { start: 12, end: 19 }, //IP-адреса (уже заданы) + ], + theory: theory['ipv4-ttl'], + taskTemplate: { type: 'ipv4-ttl' }, + }, + { + id: 6, + slug: 'ipv4-fragmentation', + title: 'Фрагментация IPv4', + category: 'IPv4', + difficulty: 'Продвинутый', + component: 'HexEditor', + additionalComponents: ['WiresharkView'], + + wiresharkTitle: 'IPv4: флаги и фрагментация', + wiresharkFields: [ + { name: 'Version + IHL', start: 0, length: 1, format: 'version_ihl', color: '#e3f2fd', description: '' }, + { name: 'DSCP + ECN', start: 1, length: 1, format: 'hex', color: '#e8eaf6', description: '' }, + { name: 'Total Length', start: 2, length: 2, format: 'decimal', color: '#e8f5e9', description: 'Длина этого фрагмента' }, + { name: 'Identification', start: 4, length: 2, format: 'hex', color: '#fff3e0', description: 'Одинаковый у всех фрагментов одного пакета' }, + { name: 'Flags + Frag.Offset', start: 6, length: 2, format: 'flags_fragment', color: '#fce4ec', description: 'DF — не фрагментировать, MF — есть ещё фрагменты' }, + { name: 'TTL', start: 8, length: 1, format: 'decimal', color: '#f3e5f5', description: '' }, + { name: 'Protocol', start: 9, length: 1, format: 'ip_protocol', color: '#e0f7fa', description: '' }, + { name: 'Header Checksum', start: 10, length: 2, format: 'hex', color: '#f1f8e9', description: 'Рассчитывается автоматически' }, + { name: 'Source IP', start: 12, length: 4, format: 'ip', color: '#fff8e1', description: '' }, + { name: 'Destination IP', start: 16, length: 4, format: 'ip', color: '#fbe9e7', description: '' }, + ], + //редактируемы только байты 6-7 (Flags + Fragment Offset) + readOnlyRanges: [ + { start: 0, end: 5 }, + { start: 8, end: 19 }, + ], + theory: theory['ipv4-fragmentation'], + taskTemplate: { type: 'ipv4-fragmentation' }, + }, + { + id: 7, + slug: 'tcp-header', + title: 'Структура TCP-заголовка', + category: 'TCP', + difficulty: 'Средний', + component: 'HexEditor', + additionalComponents: ['WiresharkView'], + + wiresharkTitle: 'Структура TCP-заголовка (20 байт)', + wiresharkFields: [ + { name: 'Source Port', start: 0, length: 2, format: 'port', color: '#e3f2fd', description: 'Порт отправителя (1–65535)' }, + { name: 'Destination Port', start: 2, length: 2, format: 'port', color: '#e8f5e9', description: 'Порт получателя' }, + { name: 'Sequence Number', start: 4, length: 4, format: 'seq_ack', color: '#fff3e0', description: 'Порядковый номер первого байта данных в сегменте' }, + { name: 'Ack Number', start: 8, length: 4, format: 'seq_ack', color: '#fce4ec', description: 'Следующий ожидаемый байт (если ACK=1)' }, + { name: 'Data Offset', start: 12, length: 1, format: 'tcp_data_offset', color: '#e8eaf6', description: 'Длина заголовка в 32-битных словах (минимум 5)' }, + { name: 'Flags', start: 13, length: 1, format: 'tcp_flags', color: '#f3e5f5', description: 'CWR ECE URG ACK PSH RST SYN FIN' }, + { name: 'Window Size', start: 14, length: 2, format: 'window_size', color: '#e0f7fa', description: 'Размер окна приёма (буфера получателя)' }, + { name: 'Checksum', start: 16, length: 2, format: 'hex', color: '#f1f8e9', description: 'Контрольная сумма (с псевдозаголовком IPv4)' }, + { name: 'Urgent Pointer', start: 18, length: 2, format: 'hex', color: '#fff8e1', description: 'Актуален только при URG=1' }, + ], + //checksum (16-17) и Data Offset (12) — только чтение + readOnlyRanges: [ + { start: 12, end: 12 }, + { start: 16, end: 17 }, + { start: 18, end: 19 }, + ], + theory: theory['tcp-header'], + taskTemplate: { type: 'tcp-header' }, + }, + { + id: 8, + slug: 'tcp-flags', + title: 'TCP-флаги и трёхстороннее рукопожатие', + category: 'TCP', + difficulty: 'Средний', + component: 'HexEditor', + additionalComponents: ['WiresharkView'], + wiresharkTitle: 'TCP: байт флагов', + wiresharkFields: [ + { name: 'Source Port', start: 0, length: 2, format: 'port', color: '#e3f2fd', description: '' }, + { name: 'Destination Port', start: 2, length: 2, format: 'port', color: '#e8f5e9', description: '' }, + { name: 'Sequence Number', start: 4, length: 4, format: 'seq_ack', color: '#fff3e0', description: '' }, + { name: 'Ack Number', start: 8, length: 4, format: 'seq_ack', color: '#fce4ec', description: '' }, + { name: 'Data Offset', start: 12, length: 1, format: 'tcp_data_offset', color: '#e8eaf6', description: '' }, + { name: 'Flags', start: 13, length: 1, format: 'tcp_flags', color: '#f3e5f5', description: '' }, + { name: 'Window Size', start: 14, length: 2, format: 'window_size', color: '#e0f7fa', description: '' }, + { name: 'Checksum', start: 16, length: 2, format: 'hex', color: '#f1f8e9', description: 'Рассчитывается автоматически' }, + { name: 'Urgent Pointer', start: 18, length: 2, format: 'hex', color: '#fff8e1', description: '' }, + ], + //редактируется только байт флагов (13) + readOnlyRanges: [ + { start: 0, end: 7 }, //порты + sequence number (заданы) + { start: 8, end: 12 }, //Ack Number и data offset + { start: 14, end: 19 }, //window, checksum, urgent + ], + theory: theory['tcp-flags'], + taskTemplate: { type: 'tcp-flags' }, + }, + { + id: 9, + slug: 'udp-header', + title: 'Структура UDP-заголовка', + category: 'UDP', + difficulty: 'Начинающий', + component: 'HexEditor', + additionalComponents: ['WiresharkView'], + wiresharkTitle: 'Структура UDP-заголовка (8 байт)', + wiresharkFields: [ + { name: 'Source Port', start: 0, length: 2, format: 'port', color: '#e3f2fd', description: 'Порт отправителя' }, + { name: 'Destination Port', start: 2, length: 2, format: 'port', color: '#e8f5e9', description: 'Порт получателя' }, + { name: 'Length', start: 4, length: 2, format: 'udp_length', color: '#fff3e0', description: 'Длина UDP-заголовка + данных в байтах (мин. 8)' }, + { name: 'Checksum', start: 6, length: 2, format: 'hex', color: '#f1f8e9', description: 'Контрольная сумма' }, + ], + //Length (4-5) и Checksum (6-7) пересчитываются автоматически + readOnlyRanges: [ + { start: 4, end: 7 }, + ], + theory: theory['udp-header'], + taskTemplate: { type: 'udp-ports' }, + }, + { + id: 10, + slug: 'http-get-request', + title: 'HTTP GET-запрос', + category: 'HTTP', + difficulty: 'Средний', + component: 'HttpEditor', + additionalComponents: [], //HttpEditor содержит разбор внутри себя + theory: theory['http-get-request'], + taskTemplate: { type: 'http-get' }, + }, + { + id: 11, + slug: 'http-post-request', + title: 'HTTP POST-запрос с JSON', + category: 'HTTP', + difficulty: 'Средний', + component: 'HttpEditor', + additionalComponents: [], + theory: theory['http-post-request'], + taskTemplate: { type: 'http-post' }, + }, + { + id: 12, + slug: 'dns-header', + title: 'Структура DNS-заголовка', + category: 'DNS', + difficulty: 'Средний', + component: 'HexEditor', + additionalComponents: ['WiresharkView'], + wiresharkTitle: 'DNS-заголовок (12 байт)', + wiresharkFields: [ + { + name: 'Transaction ID', + start: 0, length: 2, + format: 'hex', + color: '#e3f2fd', + description: 'Произвольный идентификатор для сопоставления запроса и ответа', + }, + { + name: 'Flags', + start: 2, length: 2, + format: 'dns_flags', + color: '#f3e5f5', + description: 'QR, Opcode, AA, TC, RD, RA, RCODE', + }, + { + name: 'QDCOUNT', + start: 4, length: 2, + format: 'decimal', + color: '#e8f5e9', + description: 'Количество вопросов в секции Question', + }, + { + name: 'ANCOUNT', + start: 6, length: 2, + format: 'decimal', + color: '#fff3e0', + description: 'Количество записей в секции Answer', + }, + { + name: 'NSCOUNT', + start: 8, length: 2, + format: 'decimal', + color: '#fce4ec', + description: 'Количество записей в секции Authority', + }, + { + name: 'ARCOUNT', + start: 10, length: 2, + format: 'decimal', + color: '#e8eaf6', + description: 'Количество записей в секции Additional', + }, + ], + //Flags и счётчики ANCOUNT/NSCOUNT/ARCOUNT — только чтение + //пользователь меняет Transaction ID, RD-бит в Flags и QDCOUNT + readOnlyRanges: [ + { start: 6, end: 11 }, //ANCOUNT, NSCOUNT, ARCOUNT + ], + theory: theory['dns-header'], + taskTemplate: { type: 'dns-header' }, + }, + { + id: 13, + slug: 'dns-query', + title: 'Кодирование доменного имени в DNS', + category: 'DNS', + difficulty: 'Продвинутый', + component: 'HexEditor', + additionalComponents: ['WiresharkView'], + wiresharkTitle: 'DNS-запрос: заголовок + Question', + wiresharkFields: [ + //заголовок + { name: 'Transaction ID', start: 0, length: 2, format: 'hex', color: '#e3f2fd', description: 'Идентификатор запроса' }, + { name: 'Flags', start: 2, length: 2, format: 'dns_flags', color: '#f3e5f5', description: '' }, + { name: 'QDCOUNT', start: 4, length: 2, format: 'decimal', color: '#e8f5e9', description: 'Число вопросов' }, + { name: 'ANCOUNT', start: 6, length: 2, format: 'decimal', color: '#fff3e0', description: '' }, + { name: 'NSCOUNT', start: 8, length: 2, format: 'decimal', color: '#fce4ec', description: '' }, + { name: 'ARCOUNT', start: 10, length: 2, format: 'decimal', color: '#e8eaf6', description: '' }, + //Question + { name: 'QNAME', start: 12, length: 13, format: 'dns_qname', color: '#fff8e1', description: 'Доменное имя в DNS-кодировке (длина-метки)' }, + { name: 'QTYPE', start: 25, length: 2, format: 'dns_qtype', color: '#e0f7fa', description: 'Тип запроса: A=1, AAAA=28, MX=15, NS=2 ...' }, + { name: 'QCLASS', start: 27, length: 2, format: 'dns_qclass', color: '#f1f8e9', description: 'Класс: IN=1 (Internet)' }, + ], + //всё предзаполнено кроме QTYPE и QCLASS + readOnlyRanges: [ + { start: 0, end: 24 }, //заголовок + QNAME + ], + theory: theory['dns-query'], + taskTemplate: { type: 'dns-query' }, + }, ]; \ No newline at end of file diff --git a/src/lib/data/theory.js b/src/lib/data/theory.js new file mode 100644 index 0000000..d8785ec --- /dev/null +++ b/src/lib/data/theory.js @@ -0,0 +1,463 @@ +const B = 'background:#f9fcff;padding:20px;border-radius:8px;margin:20px 0;'; +const H2 = 'color:#2196F3;border-bottom:2px solid #2196F3;padding-bottom:10px;margin-top:0;'; +const H3 = 'color:#1976D2;margin-top:0;'; +const TH = 'background:#e3f2fd;padding:8px;border:1px solid #ccc;text-align:left;'; +const TD = 'padding:6px 8px;border:1px solid #ddd;'; +const TD2 = 'padding:6px 8px;border:1px solid #ddd;background:#f5f9ff;'; +const TBL = 'border-collapse:collapse;width:100%;margin-top:8px;font-size:0.92em;'; +const DARK = 'background:#1e1e1e;color:#d4d4d4;padding:15px;border-radius:6px;font-family:\'Courier New\',monospace;font-size:0.88em;line-height:1.9;margin:10px 0;'; +const WARN = 'background:#fff8e1;padding:16px 20px;border-radius:8px;margin:20px 0;border-left:4px solid #FFC107;'; + +export const theory = { + +'bit-manipulation': ` +
+

Бит и байт — основа сетевых протоколов

+ +
+

Бит и байт

+

Бит — минимальная единица информации, принимает значение 0 или 1.

+

Байт = 8 бит. Один байт хранит число от 0 до 255. Все сетевые пакеты — это последовательность байт. Зная позицию и длину поля в байтах, можно прочитать или изменить любое поле заголовка вручную.

+
+ +
+

Двоичная и шестнадцатеричная запись

+

Один байт записывают в разных системах счисления:

+
+
Десятичная: 184
+
Двоичная: 1011 1000
+
Шестнадцатеричная: 0xB8
+
+

Шестнадцатеричная запись удобна: каждая цифра (0–F) точно соответствует 4 битам.

+
+ +
+

Нумерация битов

+

Биты нумеруются справа налево: бит 7 — старший (вес 128), бит 0 — младший (вес 1).

+ + + + ${[7,6,5,4,3,2,1,0].map(n => + `` + ).join('')} + + + + ${[128,64,32,16,8,4,2,1].map(n => + `` + ).join('')} + +
Бит:${n}
Вес:${n}
+

Чтобы получить значение байта, сложите веса всех битов равных 1.
+ Например, биты 7, 4, 3 = 128 + 16 + 8 = 152 = 0x98.

+
+ +
+

Почему это важно?

+

В заголовках протоколов многие поля занимают несколько битов. Например, флаги TCP — каждый флаг один бит. Умение работать с отдельными битами — базовый навык при анализе сетевых пакетов.

+
+
+`, + +'mac-address': ` +
+

MAC-адрес

+ +
+

Что такое MAC-адрес?

+

MAC-адрес (Media Access Control) — идентификатор сетевого интерфейса на канальном уровне. Используется в Ethernet и Wi-Fi для адресации внутри одной сети. Длина: 6 байт (48 бит).

+
+ +
+

Структура MAC-адреса

+
+
00:50:56 : AB:CD:EF
+
└───┬───┘ └───┬───┘
+
   OUI Номер интерфейса
+
(производитель) (назначает производитель)
+
+

Первые 3 байта — OUI, выдаются IEEE производителям. Примеры: 00:00:0C — Cisco, 00:02:B3 — Intel.

+
+ +
+

Типы MAC-адресов

+ + + + + +
ТипБит 0 первого байтаПример
Unicast0 (чётный байт)00:1A:2B:3C:4D:5E
Multicast1 (нечётный байт)01:00:5E:00:00:01
Broadcastвсе биты = 1FF:FF:FF:FF:FF:FF
+

Бит 1 первого байта: 0 — назначен производителем, 1 — локально администратором.

+
+
+`, + +'ethernet-frame': ` +
+

Структура Ethernet-кадра

+ +
+

Ethernet — основа проводных сетей

+

Ethernet — самая популярная технология проводных сетей. Данные передаются в виде кадров (frames). На практике используется стандарт Ethernet II.

+
+ +
+

Структура кадра Ethernet II

+
+
+----------+----------+----------+------------------+------+
+
| Dst MAC | Src MAC |EtherType | Data | FCS |
+
| 6 байт | 6 байт | 2 байта | 46-1500 байт | 4 б |
+
+----------+----------+----------+------------------+------+
+
+

Dst MAC — получатель. Src MAC — отправитель.

+

EtherType — тип протокола: 0x0800 — IPv4, 0x0806 — ARP, 0x86DD — IPv6.

+

FCS (Frame Check Sequence) — контрольная сумма, рассчитывается автоматически.

+
+ +
+

MTU

+

Максимальный размер данных — 1500 байт (MTU). Если IP-пакет больше MTU, он фрагментируется. Минимум данных — 46 байт.

+
+
+`, + +'ipv4-header': ` +
+

IPv4 — протокол сетевого уровня

+ +
+

Что делает IP?

+

IP обеспечивает доставку пакетов через составную сеть — объединяет разные технологии канального уровня и выполняет маршрутизацию. Работает без гарантии доставки и без соединения.

+
+ +
+

Структура заголовка IPv4 (минимум 20 байт)

+
+
Байт 0: Version (4 бит) + IHL (4 бит)
+
Байт 1: DSCP/ECN
+
Байты 2–3: Total Length — полная длина пакета
+
Байты 4–5: Identification — ID для сборки фрагментов
+
Байты 6–7: Flags (3 бит) + Fragment Offset (13 бит)
+
Байт 8: TTL — уменьшается на 1 на каждом маршрутизаторе
+
Байт 9: Protocol — TCP=6, UDP=17, ICMP=1
+
Байты 10–11: Header Checksum (пересчитывается автоматически)
+
Байты 12–15: Source IP
+
Байты 16–19: Destination IP
+
+
+ +
+

Специальные диапазоны IPv4

+ + + + + +
ДиапазонНазначение
10.x.x.x, 172.16-31.x.x, 192.168.x.xЧастные (не маршрутизируются в интернете)
127.0.0.1Loopback (localhost)
169.254.x.xLink-local (без DHCP)
+
+
+`, + +'ipv4-ttl': ` +
+

TTL — время жизни пакета

+ +
+

Зачем нужен TTL?

+

TTL (Time To Live) — счётчик прыжков. Каждый маршрутизатор уменьшает TTL на 1. При TTL = 0 пакет отбрасывается и отправителю уходит ICMP «Time Exceeded». Защита от бесконечной петли маршрутизации.

+
+ +
+

Стандартные значения TTL

+ + + + + +
ОС / устройствоTTLHex
Linux, macOS, Android640x40
Windows1280x80
Cisco IOS, сетевые устройства2550xFF
+

По TTL в ответе можно примерно определить ОС удалённого хоста.

+
+ +
+

traceroute и TTL

+

traceroute отправляет пакеты с TTL=1, 2, 3… Каждый маршрутизатор отвечает ICMP «Time Exceeded» — так строится полная цепочка узлов до цели.

+
+
+`, + +'ipv4-fragmentation': ` +
+

Фрагментация IPv4

+ +
+

MTU и фрагментация

+

MTU — максимальный размер данных кадра. Для Ethernet это 1500 байт. Если IP-пакет больше MTU, маршрутизатор фрагментирует его на части. Получатель собирает фрагменты по Identification, Flags и Fragment Offset.

+
+ +
+

Поля байтов 6–7 (Flags + Fragment Offset)

+
+
Бит 15: Reserved (всегда 0)
+
Бит 14: DF — Don't Fragment (1 = запрет фрагментации)
+
Бит 13: MF — More Fragments (1 = есть ещё фрагменты)
+
Биты 12–0: Fragment Offset (смещение в 8-байтовых блоках)
+
+ + + + + +
СитуацияБайт 6Байт 7
DF=1, не фрагментировать0x400x00
Первый фрагмент (MF=1)0x200x00
Последний фрагмент, Offset=1850x000xB9
+

Fragment Offset = байты ÷ 8. Смещение 1480 байт: 1480 ÷ 8 = 185 = 0xB9.

+
+
+`, + +'tcp-header': ` +
+

TCP — протокол надёжной передачи

+ +
+

Что делает TCP?

+

TCP обеспечивает надёжность: подтверждения (ACK), повторную отправку при потере, сохранение порядка. TCP нумерует не сегменты, а байты потока — Sequence Number это номер первого байта в сегменте.

+
+ +
+

Структура заголовка TCP (минимум 20 байт)

+
+
Байты 0–1: Source Port
+
Байты 2–3: Destination Port
+
Байты 4–7: Sequence Number — номер первого байта данных
+
Байты 8–11: Acknowledgment Number — следующий ожидаемый байт
+
Байт 12: Data Offset (длина заголовка, мин. 5 × 4 = 20 байт)
+
Байт 13: Flags — SYN, ACK, FIN, RST, PSH...
+
Байты 14–15: Window Size — размер буфера приёма
+
Байты 16–17: Checksum (авто)
+
Байты 18–19: Urgent Pointer
+
+
+ +
+

Популярные TCP-порты

+ + + + + +
ПортПротоколПортПротокол
22SSH443HTTPS
25SMTP3306MySQL
80HTTP5432PostgreSQL
+

Ephemeral-порты клиента: 49152–65535.

+
+
+`, + +'tcp-flags': ` +
+

TCP-флаги и трёхстороннее рукопожатие

+ +
+

Байт флагов (байт 13 заголовка)

+ + + + + + + +
БитФлагНазначение
4ACKПоле Acknowledgment Number значимо
3PSHДоставить данные приложению немедленно
2RSTНемедленный сброс соединения
1SYNУстановка соединения
0FINЗавершение соединения
+

Несколько флагов через OR: SYN+ACK = 0x02 | 0x10 = 0x12.

+
+ +
+

Трёхстороннее рукопожатие (3-Way Handshake)

+
+
Клиент → Сервер: SYN Seq=X
+
Сервер → Клиент: SYN + ACK Seq=Y, Ack=X+1
+
Клиент → Сервер: ACK Seq=X+1, Ack=Y+1
+
↑ соединение установлено
+
+
+
+`, + +'udp-header': ` +
+

UDP — быстрый протокол без гарантий

+ +
+

Что такое UDP?

+

UDP — транспортный протокол без соединения. Не гарантирует доставку и порядок. Заголовок всего 8 байт, нет накладных расходов на рукопожатие. Применяется в DNS, DHCP, NTP, VoIP, онлайн-играх.

+
+ +
+

Структура заголовка UDP (8 байт)

+
+
Байты 0–1: Source Port
+
Байты 2–3: Destination Port
+
Байты 4–5: Length — длина датаграммы (минимум 8)
+
Байты 6–7: Checksum (авто, в IPv4 необязателен)
+
+
+ +
+

Популярные UDP-порты

+ + + + + +
ПортПротоколПортПротокол
53DNS161SNMP
67/68DHCP514Syslog
123NTP5353mDNS
+
+
+`, + +'http-get-request': ` +
+

HTTP — протокол передачи гипертекста

+ +
+

Что такое HTTP?

+

HTTP — текстовый протокол прикладного уровня. Работает поверх TCP, порт 80 (HTTPS — 443). Режим работы: запрос–ответ. В отличие от Ethernet/IP — не бинарный, а текстовый.

+
+ +
+

Структура HTTP-запроса

+
+
МЕТОД /путь HTTP/версия ← строка запроса (обязательна)
+
Заголовок1: значение ← заголовки
+
Заголовок2: значение
+
                        ← пустая строка (ОБЯЗАТЕЛЬНА!)
+
[тело запроса] ← для GET обычно отсутствует
+
+
+ +
+

Основные методы

+ + + + + + +
МетодНазначениеТело
GETПолучить ресурсНет
POSTСоздать / передать данныеДа
PUTЗаменить ресурс целикомДа
DELETEУдалить ресурсНет
+
+ +
+

⚠ Пустая строка после заголовков — обязательна!

+

Нажмите Enter дважды после последнего заголовка.

+
+
+`, + +'http-post-request': ` +
+

HTTP POST — отправка данных

+ +
+

POST vs GET

+

GET получает данные. POST отправляет данные в теле запроса — для создания ресурсов, передачи форм, авторизации.

+
+ +
+

Структура POST-запроса

+
+
POST /api/users HTTP/1.1
+
Host: api.example.com
+
Content-Type: application/json
+
Content-Length: 18
+
                                ← пустая строка
+
{"name":"Student"}
+
+
+ +
+

Заголовки при наличии тела

+

Content-Type — формат тела: application/json, application/x-www-form-urlencoded.

+

Content-Length — размер тела в байтах. Для ASCII 1 символ = 1 байт.

+
+ +
+

Коды ответа HTTP

+ + + + + + + +
КодСмысл
200 OKУспешно
201 CreatedРесурс создан (ответ на POST)
400 Bad RequestОшибка в запросе
404 Not FoundРесурс не найден
500 Internal Server ErrorОшибка сервера
+
+
+`, + +'dns-header': ` +
+

DNS — система доменных имён

+ +
+

Зачем нужен DNS?

+

DNS переводит имена в IP: www.yandex.ru77.88.55.66. Бинарный протокол, работает поверх UDP, порт 53. При ответах >512 байт использует TCP.

+
+ +
+

Структура заголовка DNS (12 байт)

+
+
Байты 0–1: Transaction ID — одинаковый в запросе и ответе
+
Байты 2–3: Flags
+
Байты 4–5: QDCOUNT — число вопросов
+
Байты 6–7: ANCOUNT — число ответов (в запросе = 0)
+
Байты 8–9: NSCOUNT — авторитативные серверы
+
Байты 10–11: ARCOUNT — дополнительные записи
+
+
+ +
+

Поле Flags (байты 2–3)

+ + + + + +
БитыПолеЗначение
15QR0 = запрос, 1 = ответ
8RD1 = просить рекурсию у сервера
3–0RCODE0=OK, 3=NXDOMAIN
+

0x0100 = рекурсивный запрос (RD=1). 0x0000 = итеративный.

+
+
+`, + +'dns-query': ` +
+

DNS: кодирование доменного имени

+ +
+

Секция Question

+

После 12-байтного заголовка: QNAME + QTYPE + QCLASS.

+
+ +
+

Кодирование QNAME

+

Каждая метка: 1 байт длины + байты символов. В конце — нулевой байт.

+
+
example.com →
+
  07 65 78 61 6D 70 6C 65 (длина 7 + "example")
+
  03 63 6F 6D (длина 3 + "com")
+
  00 (конец имени)
+
Итого: 13 байт
+
+
+ +
+

QTYPE и QCLASS

+ + + + + + + + +
QTYPEТипЧто запрашивает
1AIPv4-адрес
28AAAAIPv6-адрес
15MXПочтовый сервер
2NSАвторитативный DNS-сервер
5CNAMEПсевдоним
16TXTТекстовая запись
+

QCLASS = 1 = IN (Internet) — единственный используемый класс.

+
+
+`, + +}; \ No newline at end of file diff --git a/src/lib/utils/bufferProcessor.js b/src/lib/utils/bufferProcessor.js index 9bd73f0..ac8414b 100644 --- a/src/lib/utils/bufferProcessor.js +++ b/src/lib/utils/bufferProcessor.js @@ -1,9 +1,22 @@ import { updateEthernetBuffer } from './protocols/ethernet.js'; +import { updateIPv4Buffer } from './protocols/ipv4.js'; +import { updateTCPBuffer } from './protocols/tcp.js'; +import { updateUDPBuffer } from './protocols/udp.js'; export function processBuffer(buffer, lessonId) { - switch(lessonId) { - case 3: //Ethernet frame lesson + switch (lessonId) { + case 3: // Ethernet frame return updateEthernetBuffer(buffer); + case 4: // IPv4 header structure + case 5: // IPv4 TTL + case 6: // IPv4 fragmentation + return updateIPv4Buffer(buffer); + case 7: // TCP header structure + case 8: // TCP flags + return updateTCPBuffer(buffer); + case 9: // UDP header + return updateUDPBuffer(buffer); + // id 10 = HTTP GET, id 11 = HTTP POST — текстовые, не трогаем буфер default: return buffer; } diff --git a/src/lib/utils/protocols/dns.js b/src/lib/utils/protocols/dns.js new file mode 100644 index 0000000..e9f29fc --- /dev/null +++ b/src/lib/utils/protocols/dns.js @@ -0,0 +1,65 @@ +export function formatDNSFlags(bytes) { + if (bytes.length < 2) return 'N/A'; + const raw = (bytes[0] << 8) | bytes[1]; + + const qr = (raw >> 15) & 1; + const opcode = (raw >> 11) & 0xf; + const aa = (raw >> 10) & 1; + const tc = (raw >> 9) & 1; + const rd = (raw >> 8) & 1; + const ra = (raw >> 7) & 1; + const rcode = raw & 0xf; + + const opcodeNames = { 0: 'QUERY', 1: 'IQUERY', 2: 'STATUS' }; + const rcodeNames = { 0: 'NOERROR', 1: 'FORMERR', 2: 'SERVFAIL', 3: 'NXDOMAIN', 5: 'REFUSED' }; + + const parts = [ + `QR=${qr} (${qr === 0 ? 'Query' : 'Response'})`, + `Opcode=${opcodeNames[opcode] ?? opcode}`, + `RD=${rd}`, + `RA=${ra}`, + ]; + if (aa) parts.push('AA=1'); + if (tc) parts.push('TC=1'); + if (rcode) parts.push(`RCODE=${rcodeNames[rcode] ?? rcode}`); + + return parts.join(', '); +} + +export function formatDNSQType(bytes) { + if (bytes.length < 2) return 'N/A'; + const val = (bytes[0] << 8) | bytes[1]; + const types = { + 1: 'A (IPv4-адрес)', + 2: 'NS (Name Server)', + 5: 'CNAME (Canonical Name)', + 12: 'PTR (Pointer)', + 15: 'MX (Mail Exchange)', + 16: 'TXT (Text)', + 28: 'AAAA (IPv6-адрес)', + 255: 'ANY', + }; + return types[val] ?? `Unknown (${val})`; +} + +export function formatDNSQClass(bytes) { + if (bytes.length < 2) return 'N/A'; + const val = (bytes[0] << 8) | bytes[1]; + return val === 1 ? 'IN (Internet)' : `Unknown (${val})`; +} + +export function formatDNSQName(bytes) { + if (!bytes || bytes.length === 0) return 'N/A'; + const labels = []; + let i = 0; + while (i < bytes.length) { + const len = bytes[i]; + if (len === 0) break; + //защита от выхода за границы + if (i + 1 + len > bytes.length) return labels.join('.') + ' (truncated)'; + const label = String.fromCharCode(...bytes.slice(i + 1, i + 1 + len)); + labels.push(label); + i += 1 + len; + } + return labels.length ? labels.join('.') : '(empty)'; +} \ No newline at end of file diff --git a/src/lib/utils/protocols/ethernet.js b/src/lib/utils/protocols/ethernet.js index 30329a9..7f03cbb 100644 --- a/src/lib/utils/protocols/ethernet.js +++ b/src/lib/utils/protocols/ethernet.js @@ -1,5 +1,5 @@ //функции для работы с Ethernet -export function calculateFCS(headerBuffer) { +function calculateFCS(headerBuffer) { //упрощенная имитация CRC32 let sum = 0; for (let i = 0; i < headerBuffer.length; i++) { @@ -18,16 +18,8 @@ export function calculateFCS(headerBuffer) { ]; } -export function updateEthernetBuffer(buffer, changes = {}) { +export function updateEthernetBuffer(buffer) { const updatedBuffer = new Uint8Array(buffer); - - Object.keys(changes).forEach(key => { - const index = parseInt(key); - if (index >= 0 && index < updatedBuffer.length) { - updatedBuffer[index] = changes[key]; - } - }); - const headerBuffer = updatedBuffer.slice(0, 14); const fcs = calculateFCS(headerBuffer); fcs.forEach((byte, i) => { diff --git a/src/lib/utils/protocols/ipv4.js b/src/lib/utils/protocols/ipv4.js new file mode 100644 index 0000000..15f9e42 --- /dev/null +++ b/src/lib/utils/protocols/ipv4.js @@ -0,0 +1,63 @@ +function calculateIPv4Checksum(buffer) { + const ihl = (buffer[0] & 0x0f) * 4; + let sum = 0; + + for (let i = 0; i < ihl; i += 2) { + if (i === 10) continue; + sum += (buffer[i] << 8) | buffer[i + 1]; + } + + while (sum >> 16) { + sum = (sum & 0xffff) + (sum >> 16); + } + + return (~sum) & 0xffff; +} + +export function updateIPv4Buffer(buffer) { + const updated = new Uint8Array(buffer); + + updated[10] = 0x00; + updated[11] = 0x00; + + const checksum = calculateIPv4Checksum(updated); + updated[10] = (checksum >> 8) & 0xff; + updated[11] = checksum & 0xff; + + return updated; +} + +export function formatIPv4Address(bytes) { + if (bytes.length < 4) return 'N/A'; + return `${bytes[0]}.${bytes[1]}.${bytes[2]}.${bytes[3]}`; +} + +export function formatIPv4Protocol(proto) { + const protocols = { + 1: 'ICMP', + 6: 'TCP', + 17: 'UDP', + 47: 'GRE', + 50: 'ESP', + 51: 'AH', + 89: 'OSPF', + 132: 'SCTP', + }; + const name = protocols[proto] ?? 'Unknown'; + return `${name} (${proto} / 0x${proto.toString(16).padStart(2, '0').toUpperCase()})`; +} + +export function formatVersionIHL(byte) { + const version = (byte >> 4) & 0x0f; + const ihl = (byte & 0x0f) * 4; + return `IPv${version}, Header Length: ${ihl} bytes`; +} + +export function formatFlagsFragment(bytes) { + if (bytes.length < 2) return 'N/A'; + const raw = (bytes[0] << 8) | bytes[1]; + const df = (raw >> 14) & 1; + const mf = (raw >> 13) & 1; + const offset = raw & 0x1fff; + return `DF=${df}, MF=${mf}, Offset=${offset}`; +} \ No newline at end of file diff --git a/src/lib/utils/protocols/tcp.js b/src/lib/utils/protocols/tcp.js new file mode 100644 index 0000000..9c2589d --- /dev/null +++ b/src/lib/utils/protocols/tcp.js @@ -0,0 +1,80 @@ +//контрольная сумма TCP считается с псевдозаголовком IPv4 (src IP, dst IP, +//protocol=6, длина TCP-сегмента). В уроках IP-адреса фиксированы: +const LESSON_SRC_IP = [192, 168, 1, 1]; +const LESSON_DST_IP = [192, 168, 1, 2]; + +export function calculateTCPChecksum(tcpBuffer, srcIP = LESSON_SRC_IP, dstIP = LESSON_DST_IP) { + const tcpLen = tcpBuffer.length; + + //псевдозаголовок: src(4) + dst(4) + zero(1) + proto(1) + tcpLen(2) = 12 байт + const pseudo = new Uint8Array(12 + tcpLen); + pseudo.set(srcIP, 0); + pseudo.set(dstIP, 4); + pseudo[8] = 0x00; + pseudo[9] = 0x06; //протокол TCP + pseudo[10] = (tcpLen >> 8) & 0xff; + pseudo[11] = tcpLen & 0xff; + pseudo.set(tcpBuffer, 12); + + pseudo[28] = 0x00; + pseudo[29] = 0x00; + + let sum = 0; + for (let i = 0; i < pseudo.length - 1; i += 2) { + sum += (pseudo[i] << 8) | pseudo[i + 1]; + } + //дополняем нечётный байт + if (pseudo.length % 2 !== 0) { + sum += pseudo[pseudo.length - 1] << 8; + } + while (sum >> 16) { + sum = (sum & 0xffff) + (sum >> 16); + } + return (~sum) & 0xffff; +} + +export function updateTCPBuffer(buffer) { + const updated = new Uint8Array(buffer); + updated[16] = 0x00; + updated[17] = 0x00; + const cs = calculateTCPChecksum(updated); + updated[16] = (cs >> 8) & 0xff; + updated[17] = cs & 0xff; + return updated; +} + +export function formatPort(bytes) { + if (bytes.length < 2) return 'N/A'; + const port = (bytes[0] << 8) | bytes[1]; + const wellKnown = { + 20: 'FTP-data', 21: 'FTP', 22: 'SSH', 23: 'Telnet', + 25: 'SMTP', 53: 'DNS', 80: 'HTTP', 110: 'POP3', + 143: 'IMAP', 443: 'HTTPS', 3306: 'MySQL', 5432: 'PostgreSQL', + 6379: 'Redis', 8080: 'HTTP-alt', + }; + const name = wellKnown[port]; + return name ? `${port} (${name})` : String(port); +} + +export function formatSeqAck(bytes) { + if (bytes.length < 4) return 'N/A'; + const val = ((bytes[0] << 24) | (bytes[1] << 16) | (bytes[2] << 8) | bytes[3]) >>> 0; + return String(val); +} + +export function formatDataOffset(byte) { + const offset = (byte >> 4) & 0x0f; + return `${offset} (${offset * 4} bytes)`; +} + +export function formatTCPFlags(byte) { + const names = ['CWR', 'ECE', 'URG', 'ACK', 'PSH', 'RST', 'SYN', 'FIN']; + const active = names.filter((_, i) => byte & (1 << (7 - i))); + return active.length ? active.join(' | ') : 'none'; +} + +export function formatWindowSize(bytes) { + if (bytes.length < 2) return 'N/A'; + const val = (bytes[0] << 8) | bytes[1]; + return `${val} bytes`; +} \ No newline at end of file diff --git a/src/lib/utils/protocols/udp.js b/src/lib/utils/protocols/udp.js new file mode 100644 index 0000000..27f4089 --- /dev/null +++ b/src/lib/utils/protocols/udp.js @@ -0,0 +1,56 @@ +const LESSON_SRC_IP = [192, 168, 1, 1]; +const LESSON_DST_IP = [192, 168, 1, 2]; + +export function calculateUDPChecksum(udpBuffer, srcIP = LESSON_SRC_IP, dstIP = LESSON_DST_IP) { + const udpLen = udpBuffer.length; + + const pseudo = new Uint8Array(12 + udpLen); + pseudo.set(srcIP, 0); + pseudo.set(dstIP, 4); + pseudo[8] = 0x00; + pseudo[9] = 0x11; //протокол UDP = 17 + pseudo[10] = (udpLen >> 8) & 0xff; + pseudo[11] = udpLen & 0xff; + pseudo.set(udpBuffer, 12); + + pseudo[18] = 0x00; + pseudo[19] = 0x00; + + let sum = 0; + for (let i = 0; i < pseudo.length - 1; i += 2) { + sum += (pseudo[i] << 8) | pseudo[i + 1]; + } + if (pseudo.length % 2 !== 0) { + sum += pseudo[pseudo.length - 1] << 8; + } + while (sum >> 16) { + sum = (sum & 0xffff) + (sum >> 16); + } + const result = (~sum) & 0xffff; + //по RFC 768: если вычисленная сумма = 0, передаётся 0xFFFF + return result === 0 ? 0xffff : result; +} + +export function updateUDPBuffer(buffer) { + const updated = new Uint8Array(buffer); + + //Length = заголовок (8) + данные + const len = buffer.length; + updated[4] = (len >> 8) & 0xff; + updated[5] = len & 0xff; + + //Checksum + updated[6] = 0x00; + updated[7] = 0x00; + const cs = calculateUDPChecksum(updated); + updated[6] = (cs >> 8) & 0xff; + updated[7] = cs & 0xff; + + return updated; +} + +export function formatUDPLength(bytes) { + if (bytes.length < 2) return 'N/A'; + const val = (bytes[0] << 8) | bytes[1]; + return `${val} bytes (заголовок 8 + данные ${val - 8})`; +} \ No newline at end of file diff --git a/src/lib/utils/storage.js b/src/lib/utils/storage.js index d277133..7a912a7 100644 --- a/src/lib/utils/storage.js +++ b/src/lib/utils/storage.js @@ -1,5 +1,5 @@ export const progressStorage = { - getProgress() { + _getAll() { if (typeof window === 'undefined') return {}; try { return JSON.parse(localStorage.getItem('networking-progress') || '{}'); @@ -8,15 +8,44 @@ export const progressStorage = { } }, - setProgress(lessonId, completed) { + _save(data) { if (typeof window === 'undefined') return; - const progress = this.getProgress(); - progress[lessonId] = completed; - localStorage.setItem('networking-progress', JSON.stringify(progress)); + localStorage.setItem('networking-progress', JSON.stringify(data)); + }, + + _getLessonData(lessonId) { + const all = this._getAll(); + const raw = all[String(lessonId)]; + if (raw === undefined || raw === null) return { completed: false, tasksCompleted: 0 }; + if (typeof raw === 'boolean') return { completed: raw, tasksCompleted: raw ? 3 : 0 }; + return { completed: !!raw.completed, tasksCompleted: raw.tasksCompleted ?? 0 }; }, isCompleted(lessonId) { - const progress = this.getProgress(); - return !!progress[lessonId]; - } + return this._getLessonData(lessonId).completed; + }, + + getTasksCompleted(lessonId) { + return this._getLessonData(lessonId).tasksCompleted; + }, + + //сохраняет новое значение счётчика; автоматически выставляет completed + saveTasksCompleted(lessonId, tasksCompleted, tasksRequired = 3) { + const all = this._getAll(); + const completed = tasksCompleted >= tasksRequired; + all[String(lessonId)] = { completed, tasksCompleted }; + this._save(all); + return completed; + }, + + //используется на главной странице (список уроков) + getProgress() { + const all = this._getAll(); + const result = {}; + for (const [id, val] of Object.entries(all)) { + if (typeof val === 'boolean') result[id] = val; + else result[id] = !!val?.completed; + } + return result; + }, }; \ No newline at end of file diff --git a/src/lib/utils/taskGenerator.js b/src/lib/utils/taskGenerator.js new file mode 100644 index 0000000..b177c22 --- /dev/null +++ b/src/lib/utils/taskGenerator.js @@ -0,0 +1,365 @@ +//генерирует конкретные задачи из шаблона урока +//AI не участвует — числа выбираются случайно в заданном диапазоне + +function rnd(min, max) { return Math.floor(Math.random() * (max - min + 1)) + min; } +function pick(arr) { return arr[Math.floor(Math.random() * arr.length)]; } + +function randomUnicastMAC() { + const m = Array.from({ length: 6 }, () => rnd(0, 255)); + m[0] = m[0] & 0xFE; // бит 0 = 0 (unicast) + if (m[0] === 0) m[0] = 0x02; + return m; +} +function randomMulticastMAC() { + const m = Array.from({ length: 6 }, () => rnd(0, 255)); + m[0] = (m[0] | 0x01) & 0xFD; + if (m[0] === 0xFF) m[0] = 0x01; + return m; +} +function macStr(m) { return m.map(b => b.toString(16).padStart(2,'0').toUpperCase()).join(':'); } + +function randomPublicIP() { + let ip; + do { + ip = [rnd(1,223), rnd(0,255), rnd(0,255), rnd(1,254)]; + } while ( + ip[0]===0 || ip[0]===10 || ip[0]===127 || + (ip[0]===172 && ip[1]>=16 && ip[1]<=31) || + (ip[0]===192 && ip[1]===168) || + (ip[0]===169 && ip[1]===254) || + (ip[0]===100 && ip[1]>=64 && ip[1]<=127) || + ip[0]>=224 + ); + return ip; +} +function randomPrivateIP() { + const t = rnd(0,2); + if (t===0) return [10, rnd(0,255), rnd(0,255), rnd(1,254)]; + if (t===1) return [172, rnd(16,31), rnd(0,255), rnd(1,254)]; + return [192, 168, rnd(0,255), rnd(1,254)]; +} +function randomEphemeralPort() { return rnd(49152, 65535); } + +function randomHostname() { + const subs = ['api','cdn','app','mail','news','shop','auth','www','dev','data','files']; + const names = ['example','service','platform','network','cloud','tech','store','media','hub']; + const tlds = ['com','net','io','org','ru']; + return `${pick(subs)}.${pick(names)}.${pick(tlds)}`; +} +function randomPath() { + const a = ['users','products','orders','articles','posts','files','events','reports','tasks']; + const b = ['list','search','latest','popular','archive','create','delete']; + return Math.random()>0.5 ? '/'+pick(a) : '/'+pick(a)+'/'+pick(b); +} +function randomJSONBody() { + const bodies = [ + { name: pick(['Alice','Bob','Carol','Dave','Eve','Max']) }, + { user: pick(['admin','guest','student','teacher']), active: true }, + { title: pick(['Hello','Update','Report','Notice','Draft']) }, + { id: rnd(1,9999), status: pick(['active','pending','done']) }, + { email: `user${rnd(1,99)}@example.com` }, + { count: rnd(1,100), page: rnd(1,10) }, + ]; + return JSON.stringify(pick(bodies)); +} + + +export function generateTask(taskTemplate) { + switch (taskTemplate.type) { + + case 'bit-set': { + const target = rnd(1, 255); + const hex = '0x' + target.toString(16).toUpperCase().padStart(2,'0'); + return { + objective: `Установите биты так, чтобы получить число ${target} (${hex})`, + initialBuffer: new Uint8Array([0]), + validate: buf => buf[0] === target, + aiContext: { type:'bit-set', target, hex, binary: target.toString(2).padStart(8,'0'), + description: `Выставить биты байта = ${target} (${hex})` }, + }; + } + + case 'mac-set': { + const v = rnd(0,2); + if (v===0) { + const m = randomUnicastMAC(), s = macStr(m); + return { objective:`Установите unicast MAC-адрес: ${s}`, + initialBuffer: new Uint8Array(6), + validate: buf => m.every((b,i) => buf[i]===b), + aiContext:{ type:'mac-set', mac:s, description:`unicast MAC ${s}` }}; + } + if (v===1) return { objective:'Установите broadcast MAC-адрес', + initialBuffer: new Uint8Array(6), + validate: buf => Array.from(buf.slice(0,6)).every(b=>b===0xFF), + aiContext:{ type:'mac-set', mac:'FF:FF:FF:FF:FF:FF', description:'broadcast' }}; + const m = randomMulticastMAC(), s = macStr(m); + return { objective:`Установите multicast MAC-адрес: ${s}`, + initialBuffer: new Uint8Array(6), + validate: buf => m.every((b,i) => buf[i]===b), + aiContext:{ type:'mac-set', mac:s, description:`multicast MAC ${s}` }}; + } + + case 'ethernet-frame': { + const etTypes = [ + { et:[0x08,0x00], name:'IPv4' }, + { et:[0x08,0x06], name:'ARP' }, + { et:[0x86,0xDD], name:'IPv6' }, + ]; + const chosen = pick(etTypes); + const dv = rnd(0,2); + let dst, dstDesc; + if (dv===0) { dst=[0xFF,0xFF,0xFF,0xFF,0xFF,0xFF]; dstDesc='broadcast'; } + else if (dv===1) { dst=randomMulticastMAC(); dstDesc=macStr(dst)+' (multicast)'; } + else { dst=randomUnicastMAC(); dstDesc=macStr(dst)+' (unicast)'; } + const src=randomUnicastMAC(); + const etHex='0x'+chosen.et.map(b=>b.toString(16).padStart(2,'0').toUpperCase()).join(''); + return { + objective:`Соберите Ethernet-кадр: Dst MAC=${dstDesc}, Src MAC=${macStr(src)}, EtherType=${chosen.name}`, + initialBuffer: new Uint8Array(14+46+4), + validate: buf => buf.length>=14 && dst.every((b,i)=>buf[i]===b) + && src.every((b,i)=>buf[6+i]===b) && buf[12]===chosen.et[0] && buf[13]===chosen.et[1], + aiContext:{ type:'ethernet-frame', dstMAC:macStr(dst), srcMAC:macStr(src), + etherType:etHex, protoName:chosen.name, + description:`dst=${macStr(dst)} src=${macStr(src)} et=${etHex}` }, + }; + } + + case 'ipv4-addresses': { + const src=randomPrivateIP(), dst=randomPublicIP(); + const ttl=pick([64,128,255]); + const p=pick([{val:6,name:'TCP'},{val:17,name:'UDP'},{val:1,name:'ICMP'}]); + return { + objective:`Соберите IPv4 заголовок: Src=${src.join('.')}, Dst=${dst.join('.')}, Protocol=${p.name}, TTL=${ttl}`, + initialBuffer: new Uint8Array([0x45,0x00,0x00,0x14,0,0,0x40,0,0,0,0,0,0,0,0,0,0,0,0,0]), + validate: buf => buf.length>=20 && buf[8]===ttl && buf[9]===p.val + && src.every((b,i)=>buf[12+i]===b) && dst.every((b,i)=>buf[16+i]===b), + aiContext:{ type:'ipv4-addresses', srcIP:src.join('.'), dstIP:dst.join('.'), + ttl, protocol:p.val, protoName:p.name, + description:`src=${src.join('.')} dst=${dst.join('.')} ttl=${ttl} proto=${p.name}` }, + }; + } + + case 'ipv4-ttl': { + const ttl = Math.random()>0.4 ? pick([64,128,255,32]) : rnd(1,254); + const label={64:' (Linux/macOS)',128:' (Windows)',255:' (сетевые устройства)'}[ttl]??''; + const src=randomPrivateIP(), dst=randomPublicIP(); + return { + objective:`Установите TTL = ${ttl}${label}`, + initialBuffer: new Uint8Array([0x45,0x00,0x00,0x14,0xAB,0xCD,0x40,0x00, + 0x00,0x06,0x00,0x00,...src,...dst]), + validate: buf => buf[8]===ttl, + aiContext:{ type:'ipv4-ttl', ttl, hex:'0x'+ttl.toString(16).toUpperCase().padStart(2,'0'), + description:`TTL=${ttl} байт 8` }, + }; + } + + case 'ipv4-fragmentation': { + const scenarios=[ + {b:[0x20,0x00], desc:'первый фрагмент: MF=1, DF=0, Offset=0'}, + {b:[0x40,0x00], desc:"Don't Fragment (DF=1): фрагментация запрещена, MF=0, Offset=0"}, + {b:[0x00,0xB9], desc:'последний фрагмент: MF=0, DF=0, Offset=185 (1480 байт ÷ 8)'}, + {b:[0x20,0xB9], desc:'промежуточный фрагмент: MF=1, DF=0, Offset=185'}, + {b:[0x00,0x2E], desc:'фрагмент с Offset=46 (368 байт ÷ 8): MF=0, DF=0'}, + ]; + const s=pick(scenarios); + const src=randomPrivateIP(), dst=randomPublicIP(); + return { + objective: 'Установите ' + s.desc, + initialBuffer:new Uint8Array([0x45,0x00,0x00,0x14,rnd(0,255),rnd(0,255), + 0x40,0x00,0x40,0x11,0x00,0x00,...src,...dst]), + validate:buf=>buf[6]===s.b[0]&&buf[7]===s.b[1], + aiContext:{type:'ipv4-fragmentation',b6:s.b[0],b7:s.b[1],description:s.desc}, + }; + } + + case 'tcp-header': { + const svcs=[{p:80,n:'HTTP'},{p:443,n:'HTTPS'},{p:22,n:'SSH'}, + {p:25,n:'SMTP'},{p:3306,n:'MySQL'},{p:5432,n:'PostgreSQL'}]; + const svc=pick(svcs), sp=randomEphemeralPort(), seq=rnd(0,0xFFFFFF); + const win=pick([8192,16384,32768,65535]); + return { + objective:`Соберите TCP SYN к ${svc.n}: Src Port=${sp}, Dst Port=${svc.p}, Seq=${seq}, SYN (0x02), Window=${win}`, + initialBuffer:new Uint8Array(20).fill(0).map((_,i)=>i===12?0x50:0), + validate:buf=>buf.length>=20 + &&((buf[0]<<8)|buf[1])===sp&&((buf[2]<<8)|buf[3])===svc.p + &&(((buf[4]<<24)|(buf[5]<<16)|(buf[6]<<8)|buf[7])>>>0)===seq + &&buf[13]===0x02&&((buf[14]<<8)|buf[15])===win, + aiContext:{type:'tcp-header',srcPort:sp,dstPort:svc.p,seq,flags:0x02,window:win,service:svc.n, + description:`TCP SYN sp=${sp} dp=${svc.p} seq=${seq}`}, + }; + } + + case 'tcp-flags': { + const combos=[ + {f:0x02,n:'SYN', d:'инициация соединения (1-й шаг handshake)'}, + {f:0x10,n:'ACK', d:'подтверждение получения данных'}, + {f:0x12,n:'SYN+ACK', d:'ответ сервера (2-й шаг handshake)'}, + {f:0x01,n:'FIN', d:'инициация завершения соединения'}, + {f:0x11,n:'FIN+ACK', d:'завершение с подтверждением'}, + {f:0x04,n:'RST', d:'немедленный сброс соединения'}, + {f:0x18,n:'PSH+ACK', d:'передача данных без буферизации'}, + ]; + const c=pick(combos), sp=randomEphemeralPort(), dp=pick([80,443,22,25,3306]); + const seq=rnd(0,0xFFFFFF); + return { + objective:`Установите TCP-флаги: ${c.n} — ${c.d}. Байт флагов = 0x${c.f.toString(16).toUpperCase().padStart(2,'0')}`, + initialBuffer:new Uint8Array([(sp>>8)&0xFF,sp&0xFF,(dp>>8)&0xFF,dp&0xFF, + (seq>>24)&0xFF,(seq>>16)&0xFF,(seq>>8)&0xFF,seq&0xFF,0,0,0,0, + 0x50,0x00,0xFF,0xFF,0,0,0,0]), + validate:buf=>buf[13]===c.f, + aiContext:{type:'tcp-flags',flagName:c.n, + flagHex:'0x'+c.f.toString(16).toUpperCase().padStart(2,'0'), + description:`TCP флаги ${c.n} байт 13`}, + }; + } + + case 'udp-ports': { + const svcs=[{d:53,n:'DNS'},{d:123,n:'NTP'},{d:67,n:'DHCP'}, + {d:161,n:'SNMP'},{d:514,n:'Syslog'},{d:5353,n:'mDNS'}, + {d:1194,n:'OpenVPN'},{d:4500,n:'IPSec NAT-T'}]; + const svc=pick(svcs), sp=randomEphemeralPort(); + return { + objective:`Соберите UDP для ${svc.n}: Src Port=${sp}, Dst Port=${svc.d}`, + initialBuffer:new Uint8Array(8), + validate:buf=>((buf[0]<<8)|buf[1])===sp&&((buf[2]<<8)|buf[3])===svc.d, + aiContext:{type:'udp-ports',srcPort:sp,dstPort:svc.d,service:svc.n, + description:`UDP src=${sp} dst=${svc.d} (${svc.n})`}, + }; + } + + case 'http-get': { + const host=randomHostname(), path=randomPath(); + const accept=pick(['application/json','text/html','application/xml','text/plain']); + return { + objective:`Отправьте GET-запрос: ресурс ${path} с сервера ${host}, Accept: ${accept}`, + initialBuffer:new TextEncoder().encode('GET / HTTP/1.1\n'), + validate:buf=>{ + const txt=new TextDecoder().decode(buf).replace(/\r\n/g,'\n'); + const lines=txt.split('\n'); + const rl=lines[0]?.match(/^(\S+)\s+(\S+)\s+(\S+)$/); + if(!rl||rl[1]!=='GET'||rl[2]!==path||rl[3]!=='HTTP/1.1') return false; + const h={}; + for(let i=1;i0) h[lines[i].slice(0,c).trim().toLowerCase()]=lines[i].slice(c+1).trim(); + } + return h['host']===host&&(h['accept']??'').includes(accept)&&txt.includes('\n\n'); + }, + aiContext:{type:'http-get',host,path,accept,description:`GET ${path} от ${host}`}, + }; + } + + case 'http-post': { + const host=randomHostname(), path=randomPath(); + const body=randomJSONBody(), bl=new TextEncoder().encode(body).length; + return { + objective:`Отправьте POST на ${host}${path}: тело ${body}, Content-Type: application/json`, + initialBuffer:new TextEncoder().encode('POST / HTTP/1.1\n'), + validate:buf=>{ + const txt=new TextDecoder().decode(buf).replace(/\r\n/g,'\n'); + const lines=txt.split('\n'); + const rl=lines[0]?.match(/^(\S+)\s+(\S+)\s+(\S+)$/); + if(!rl||rl[1]!=='POST'||rl[2]!==path||rl[3]!=='HTTP/1.1') return false; + const h={};let bs=lines.length; + for(let i=1;i0) h[lines[i].slice(0,c).trim().toLowerCase()]=lines[i].slice(c+1).trim(); + } + if(h['host']!==host) return false; + if(!(h['content-type']??'').includes('application/json')) return false; + const bodyText=lines.slice(bs).join('\n').trim(); + try{ if(JSON.stringify(JSON.parse(bodyText))!==JSON.stringify(JSON.parse(body))) return false; } + catch{ return false; } + return parseInt(h['content-length']??'',10)===new TextEncoder().encode(bodyText).length + &&txt.includes('\n\n'); + }, + aiContext:{type:'http-post',host,path,body,bodyLength:bl, + description:`POST ${path}→${host}`}, + }; + } + + case 'dns-header': { + const txId=rnd(1,0xFFFE); + const wantRD=Math.random()>0.4; + const flags=wantRD?0x0100:0x0000; + const fdesc=wantRD?'рекурсивный запрос (RD=1)':'итеративный запрос (RD=0)'; + return { + objective:`Отправьте DNS-запрос: ID=${txId}, ${fdesc}, QDCOUNT=1`, + initialBuffer:new Uint8Array(12), + validate:buf=>buf.length>=6 + &&((buf[0]<<8)|buf[1])===txId + &&((buf[2]<<8)|buf[3])===flags + &&((buf[4]<<8)|buf[5])===1, + aiContext:{type:'dns-header',txId,flags,flagsDesc:fdesc, + txH:(txId>>8)&0xFF,txL:txId&0xFF, + description:`DNS ID=${txId} Flags=0x${flags.toString(16).padStart(4,'0')}`}, + }; + } + + case 'dns-query': { + const qtypes=[ + {val:1, n:'A', d:'IPv4-адрес'}, + {val:28,n:'AAAA', d:'IPv6-адрес'}, + {val:15,n:'MX', d:'почтовый сервер'}, + {val:2, n:'NS', d:'DNS-сервер зоны'}, + {val:5, n:'CNAME', d:'псевдоним'}, + {val:16,n:'TXT', d:'текстовая запись'}, + ]; + const qt=pick(qtypes), txId=rnd(1,0xFFFE); + return { + objective:`Отправьте запрос на example.com: QTYPE=${qt.n}, QCLASS=IN(1)`, + initialBuffer:new Uint8Array([ + (txId>>8)&0xFF,txId&0xFF,0x01,0x00,0x00,0x01,0,0,0,0,0,0, + 0x07,0x65,0x78,0x61,0x6D,0x70,0x6C,0x65, + 0x03,0x63,0x6F,0x6D,0x00, + 0x00,0x00,0x00,0x00, + ]), + validate:buf=>buf.length>=29&&((buf[25]<<8)|buf[26])===qt.val&&((buf[27]<<8)|buf[28])===1, + aiContext:{type:'dns-query',qtypeName:qt.n,qtypeVal:qt.val, + description:`QTYPE=${qt.n}(${qt.val}), QCLASS=IN`}, + }; + } + + default: + throw new Error(`Unknown task type: ${template.type}`); + } +} + +export function buildCurrentState(buf, ctx) { + if (!buf||!ctx) return {}; + switch(ctx.type){ + case 'bit-set': + return{value:buf[0],binary:buf[0].toString(2).padStart(8,'0'), + hex:'0x'+buf[0].toString(16).toUpperCase().padStart(2,'0')}; + case 'mac-set': case 'ethernet-frame': + return{dst:Array.from(buf.slice(0,6)).map(b=>b.toString(16).padStart(2,'0').toUpperCase()).join(':'), + src:Array.from(buf.slice(6,12)).map(b=>b.toString(16).padStart(2,'0').toUpperCase()).join(':')}; + case 'ipv4-addresses': case 'ipv4-fragmentation': + return{srcIP:Array.from(buf.slice(12,16)).join('.'),dstIP:Array.from(buf.slice(16,20)).join('.'), + ttl:buf[8],proto:buf[9], + b6:'0x'+(buf[6]??0).toString(16).padStart(2,'0').toUpperCase(), + b7:'0x'+(buf[7]??0).toString(16).padStart(2,'0').toUpperCase()}; + case 'ipv4-ttl': + return{ttl:buf[8],hex:'0x'+buf[8].toString(16).toUpperCase().padStart(2,'0')}; + case 'tcp-header': + return{srcPort:(buf[0]<<8)|buf[1],dstPort:(buf[2]<<8)|buf[3], + seq:((buf[4]<<24)|(buf[5]<<16)|(buf[6]<<8)|buf[7])>>>0, + flags:'0x'+buf[13].toString(16).padStart(2,'0').toUpperCase(), + window:(buf[14]<<8)|buf[15]}; + case 'tcp-flags': + return{flags:buf[13],hex:'0x'+buf[13].toString(16).toUpperCase().padStart(2,'0')}; + case 'udp-ports': + return{srcPort:(buf[0]<<8)|buf[1],dstPort:(buf[2]<<8)|buf[3]}; + case 'http-get': case 'http-post': + return{text:new TextDecoder().decode(buf).slice(0,300)}; + case 'dns-header': + return{id:(buf[0]<<8)|buf[1], + flags:'0x'+((buf[2]<<8)|buf[3]).toString(16).padStart(4,'0').toUpperCase(), + qdcount:(buf[4]<<8)|buf[5]}; + case 'dns-query': + return{qtype:(buf[25]<<8)|buf[26],qclass:(buf[27]<<8)|buf[28]}; + default: return{}; + } +} \ No newline at end of file diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index c473550..b6c71e8 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -4,104 +4,385 @@ import { onMount } from 'svelte'; let progress = {}; + let searchQuery = ''; + let activeCategory = 'Все'; + let activeDifficulty = 'Все'; onMount(() => { progress = progressStorage.getProgress(); }); + + //уникальные категории и сложности из уроков + $: categories = ['Все', ...new Set(lessons.map(l => l.category))]; + $: difficulties = ['Все', ...new Set(lessons.map(l => l.difficulty))]; + + //фильтрация + $: filtered = lessons.filter(l => { + const q = searchQuery.trim().toLowerCase(); + const matchSearch = !q + || l.title.toLowerCase().includes(q) + || l.category.toLowerCase().includes(q) + || l.difficulty.toLowerCase().includes(q); + const matchCat = activeCategory === 'Все' || l.category === activeCategory; + const matchDiff = activeDifficulty === 'Все' || l.difficulty === activeDifficulty; + return matchSearch && matchCat && matchDiff; + }); + + //счётчик завершённых уроков + $: completedCount = lessons.filter(l => progress[l.id]).length; + + function clearFilters() { + searchQuery = ''; + activeCategory = 'Все'; + activeDifficulty = 'Все'; + } + + //цвет тэга сложности + function difficultyColor(d) { + return { 'Начинающий': '#4caf50', 'Средний': '#ff9800', 'Продвинутый': '#f44336' }[d] ?? '#2196f3'; + } - -

Обучение сетевым протоколам

- -
- {#each lessons as lesson} - -
-

{lesson.title}

- {#if progress[lesson.id]} - ✅ Пройден - {/if} + +
+ + + + + +
+ + +
+ 🔍 + + {#if searchQuery} + + {/if} +
+ + +
+ + +
+ Сложность: +
+ {#each difficulties as diff} + + {/each} +
+
+ + + {#if searchQuery || activeCategory !== 'Все' || activeDifficulty !== 'Все'} + + {/if} +
+ + +
+ {#if filtered.length === lessons.length} + Всего уроков: {lessons.length} + {:else} + Найдено: {filtered.length} из {lessons.length} + {/if} +
+ + +
+ {#each filtered as lesson (lesson.id)} + +
+

{lesson.title}

+ {#if progress[lesson.id]} + ✅ Пройден + {/if} +
+ +
+ {:else} +
+ Ничего не найдено. +
+ {/each} +
+
- + \ No newline at end of file diff --git a/src/routes/api/hint/+server.js b/src/routes/api/hint/+server.js new file mode 100644 index 0000000..b48f1ed --- /dev/null +++ b/src/routes/api/hint/+server.js @@ -0,0 +1,69 @@ +import { json } from '@sveltejs/kit'; +import { OPENROUTER_API_KEY } from '$env/static/private'; + +const MODEL = 'openrouter/free'; // авто-роутер: всегда выбирает доступную бесплатную модель + +export async function POST({ request }) { + const body = await request.json(); + const { lessonType, taskDescription, currentState } = body; + + if (!lessonType || !taskDescription) { + return json({ hint: null, error: 'Недостаточно данных для подсказки' }, { status: 400 }); + } + + const prompt = buildPrompt(lessonType, taskDescription, currentState); + + try { + const response = await fetch('https://openrouter.ai/api/v1/chat/completions', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${OPENROUTER_API_KEY}`, + 'Content-Type': 'application/json', + 'HTTP-Referer': 'https://localhost', + }, + body: JSON.stringify({ + model: MODEL, + messages: [{ role: 'user', content: prompt }], + max_tokens: 300, + temperature: 0.4, + }), + }); + + if (!response.ok) { + const err = await response.text(); + console.error('OpenRouter error:', err); + return json({ hint: null, error: 'Сервис подсказок недоступен' }, { status: 502 }); + } + + const data = await response.json(); + const choice = data.choices?.[0]; + let hint = choice?.message?.content?.trim() || null; + const finishReason = choice?.finish_reason; + + if (!hint) { + return json({ hint: null, error: 'Модель не вернула ответ — попробуйте ещё раз' }); + } + + if (finishReason === 'length') { + hint = hint + '…'; + } + + return json({ hint }); + } catch (e) { + console.error('Fetch error:', e); + return json({ hint: null, error: 'Ошибка сети' }, { status: 500 }); + } +} + +function buildPrompt(lessonType, taskDescription, currentState) { + return `Ты — помощник в образовательном веб-приложении для изучения сетевых протоколов. +Отвечай ТОЛЬКО на русском языке. Ответ должен быть коротким — максимум 2-3 предложения. +Не используй markdown, не пиши заголовков. Пиши просто и понятно для начинающего. + +Задание: "${taskDescription}" +Тип задания: ${lessonType} +Состояние пользователя: ${JSON.stringify(currentState)} + +Дай подсказку — как двигаться к решению, НЕ называя конечный ответ прямо. +Можно намекнуть на нужный байт, бит или концепцию.`; +} \ No newline at end of file diff --git a/src/routes/lessons/[slug]/+page.svelte b/src/routes/lessons/[slug]/+page.svelte index 8dd53bd..078f9a8 100644 --- a/src/routes/lessons/[slug]/+page.svelte +++ b/src/routes/lessons/[slug]/+page.svelte @@ -2,155 +2,277 @@ import { page } from '$app/stores'; import { lessons } from '$lib/data/lessons'; import { progressStorage } from '$lib/utils/storage'; - import { onMount, afterUpdate } from 'svelte'; + import { onMount } from 'svelte'; import { processBuffer } from '$lib/utils/bufferProcessor'; + import { generateTask, buildCurrentState } from '$lib/utils/taskGenerator'; import HexViewer from '$lib/components/HexViewer.svelte'; import LessonLayout from '$lib/components/LessonLayout.svelte'; import Notification from '$lib/components/Notification.svelte'; import BitEditor from '$lib/components/BitEditor.svelte'; - //import EthernetBuilder from '$lib/components/EthernetBuilder.svelte'; import HexEditor from '$lib/components/HexEditor.svelte'; import WiresharkView from '$lib/components/WiresharkView.svelte'; + import HttpEditor from '$lib/components/HttpEditor.svelte'; + + const TASKS_REQUIRED = 3; //сколько правильных ответов для завершения урока let currentBuffer; - let isCompleted = false; - let showHint = false; - let currentHintIndex = 0; let notification = { show: false, message: '', type: 'success' }; + let lesson = null; let lessonComponent = null; let additionalComponents = []; let readOnlyRanges = []; - afterUpdate(() => { - if ($page.params.slug) { - lesson = lessons.find(l => l.slug === $page.params.slug); - - if (lesson) { - lessonComponent = getLessonComponent(lesson.component); - additionalComponents = getAdditionalComponents(lesson.additionalComponents || []); - readOnlyRanges = lesson.readOnlyRanges || []; - } - } - }); + let aiMessage = null; //текст от модели + let aiLoading = false; //ждём ответ + let aiError = null; //сообщение об ошибке сети - function getLessonComponent(componentName) { - switch(componentName) { - case 'BitEditor': - return BitEditor; - case 'HexEditor': - return HexEditor; - default: - return null; + let currentTask = null; // + let tasksCompleted = 0; //счётчик правильных ответов (из storage) + let taskJustSolved = false; //защита от двойного счёта при повторном "Проверить" + let currentTaskNum = 1; + + $: isCompleted = tasksCompleted >= TASKS_REQUIRED; + $: tasksLabel = `${currentTaskNum} / ${TASKS_REQUIRED}`; + //показываем "Новое задание" только если задача решена + $: showNextBtn = taskJustSolved; + + let mounted = false; + onMount(() => { mounted = true; }); + $: if (mounted) loadLesson($page.params.slug); + + function loadLesson(slug) { + const found = lessons.find(l => l.slug === slug) ?? null; + if (!found || lesson?.slug === slug) return; + + lesson = found; + lessonComponent = getLessonComponent(lesson.component); + additionalComponents = getAdditionalComponents(lesson.additionalComponents ?? []); + readOnlyRanges = lesson.readOnlyRanges ?? []; + + tasksCompleted = progressStorage.getTasksCompleted(lesson.id.toString()); + currentTaskNum = Math.min(tasksCompleted + 1, TASKS_REQUIRED); + taskJustSolved = false; + notification = { show: false, message: '', type: 'success' }; + + loadNextTask(true); + } + + function loadNextTask(isInitial = false) { + if (!isInitial) currentTaskNum = Math.min(currentTaskNum + 1, TASKS_REQUIRED + 1); + taskJustSolved = false; + aiMessage = null; + aiError = null; + + currentTask = generateTask(lesson.taskTemplate); + + const raw = new Uint8Array(currentTask.initialBuffer); + currentBuffer = processBuffer(raw, lesson.id); + } + + + //маппинг компонентов + function getLessonComponent(name) { + switch (name) { + case 'BitEditor': return BitEditor; + case 'HexEditor': return HexEditor; + case 'HttpEditor': return HttpEditor; + default: return null; } } - - function getAdditionalComponents(componentNames) { - return componentNames.map(name => { - switch(name) { - case 'HexViewer': - return HexViewer; - case 'WiresharkView': - return WiresharkView; - default: - return null; + + function getAdditionalComponents(names) { + return names.map(name => { + switch (name) { + case 'HexViewer': return HexViewer; + case 'WiresharkView': return WiresharkView; + default: return null; } }).filter(Boolean); } - - onMount(() => { - //инициализация при загрузке на клиенте - if ($page.params.slug) { - lesson = lessons.find(l => l.slug === $page.params.slug); - - if (lesson) { - lessonComponent = getLessonComponent(lesson.component); - additionalComponents = getAdditionalComponents(lesson.additionalComponents || []); - readOnlyRanges = lesson.readOnlyRanges || []; - currentBuffer = new Uint8Array(lesson.initialBuffer); - isCompleted = progressStorage.isCompleted(lesson.id.toString()); - } - } - }); - + + //обработчики function handleBufferChange(newBuffer) { - //обрабатываем буфер через отдельный процессор - if (lesson) { - const processedBuffer = processBuffer(newBuffer, lesson.id); - currentBuffer = processedBuffer; + currentBuffer = lesson + ? processBuffer(newBuffer, lesson.id) + : newBuffer; + + if (aiMessage) { aiMessage = null; aiError = null; } + } + + function checkSolution() { + if (!currentTask) return; + const ok = currentTask.validate(currentBuffer); + + if (ok) { + //cчитаем только один раз за попытку — защита от спама "Проверить" + if (!taskJustSolved) { + tasksCompleted += 1; + progressStorage.saveTasksCompleted(lesson.id.toString(), tasksCompleted, TASKS_REQUIRED); + } + taskJustSolved = true; + aiMessage = null; + aiError = null; + + const justReached = tasksCompleted === TASKS_REQUIRED; + const msg = justReached + ? `Правильно! 🎉 Урок завершён! (${tasksCompleted} / ${TASKS_REQUIRED})` + : `Правильно! 🎉 Выполнено ${tasksCompleted} / ${TASKS_REQUIRED}`; + showNotification(msg, 'success'); } else { - currentBuffer = newBuffer; + taskJustSolved = false; + showNotification('Пока неверно. Попробуйте ещё раз!', 'error'); } } - function showNextHint() { - if (!lesson) return; - showHint = true; - currentHintIndex = (currentHintIndex + 1) % lesson.hints.length; + function nextTask() { + loadNextTask(); } + //AI-запросы + async function requestHint() { + if (!currentTask?.aiContext || aiLoading) return; + + aiLoading = true; + aiMessage = null; + aiError = null; + + const currentState = buildCurrentState(currentBuffer, currentTask.aiContext); + + try { + const res = await fetch('/api/hint', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + lessonType: currentTask.aiContext.type, + taskDescription: currentTask.objective, + currentState, + }), + }); + + const data = await res.json(); + if (data.error) { + aiError = data.error; + } else { + aiMessage = data.hint; + } + } catch { + aiError = 'Не удалось связаться с сервером подсказок.'; + } finally { + aiLoading = false; + } + } + function showNotification(message, type = 'success') { notification = { show: true, message, type }; } - - function checkSolution() { - if (!lesson) return; - const isValid = lesson.validate(currentBuffer); - - if (isValid) { - isCompleted = true; - progressStorage.setProgress(lesson.id.toString(), true); - showNotification('Правильно! 🎉 Задание выполнено', 'success'); - } else { - showNotification('Пока неверно. Попробуйте еще раз!', 'error'); - } - } - + function hideNotification() { - notification.show = false; + notification = { ...notification, show: false }; + } + + //вспомогательная функция: нужно ли передавать wiresharkFields + function isWiresharkView(Component) { + return Component === WiresharkView; } {#if lesson}
- + + +
+
+ Задание + {#if tasksCompleted >= 0} + {tasksLabel} + {/if} +
+ {#if currentTask} +

{currentTask.objective}

+ {/if} +
+ + +
+
+
+ + + {#key currentTaskNum} + + {/key} + {#each additionalComponents as Component} {#if currentBuffer} - + {#if isWiresharkView(Component)} + + + {:else} + + {/if} {/if} {/each} -
- - - -
- - {#if showHint && lesson.hints[currentHintIndex]} -
- 💡 {lesson.hints[currentHintIndex]} + + {#if aiLoading} +
+ 🤖 + Думаю над подсказкой... +
+ {:else if aiMessage} +
+ 🤖 + {aiMessage} +
+ {:else if aiError} +
+ ⚠️ + {aiError}
{/if} + + +
+ + + {#if showNextBtn} + + {/if} + + {#if !taskJustSolved} + + {/if} +
+ {#if isCompleted}
- ✅ Урок завершен! + ✅ Урок завершен! Решено заданий: {tasksCompleted}
{/if}
@@ -172,57 +294,159 @@ max-width: 800px; margin: 0 auto; } - - .controls { + + /*задание*/ + .task-header { + background: #fffcf6; + border-left: 4px solid #FF9800; + border-radius: 4px; + padding: 14px 16px; + margin-bottom: 10px; + } + + .task-objective { display: flex; + align-items: center; gap: 12px; - margin: 20px 0; + margin-bottom: 6px; } - - .check-button { - background: #4CAF50; - color: white; - border: none; - padding: 12px 24px; - border-radius: 6px; - cursor: pointer; - font-size: 1em; + + .task-label { + font-weight: bold; + font-size: 0.85em; + text-transform: uppercase; + color: #e65100; + letter-spacing: 0.04em; } - - .check-button:hover { - background: #388E3C; - } - - .hint-button { + + .task-counter { background: #FF9800; color: white; + font-size: 0.75em; + font-weight: bold; + padding: 2px 8px; + border-radius: 10px; + } + + .task-counter.done { + background: #4caf50; + } + + .objective-text { + margin: 0; + font-size: 1em; + color: #333; + line-height: 1.5; + } + + /*прогресс-бар*/ + .progress-bar-wrap { + height: 4px; + background: #e0e0e0; + border-radius: 2px; + margin-bottom: 16px; + overflow: hidden; + } + + .progress-bar-fill { + height: 100%; + background: #4caf50; + border-radius: 2px; + transition: width 0.4s ease; + } + + /*AI-блок*/ + .ai-block { + display: flex; + gap: 10px; + align-items: flex-start; + background: #f0f4ff; + border-left: 4px solid #3f51b5; + border-radius: 4px; + padding: 12px 14px; + margin: 12px 0; + font-size: 0.92em; + line-height: 1.5; + } + + .ai-block.loading { + background: #f5f5f5; + border-color: #bbb; + } + + .ai-block.error { + background: #fff3e0; + border-color: #ff9800; + } + + .ai-icon { + font-size: 1.1em; + flex-shrink: 0; + margin-top: 1px; + } + + .ai-text { + color: #333; + } + + .ai-thinking { + color: #888; + font-style: italic; + } + + /*кнопки*/ + .controls { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin: 16px 0; + } + + .check-button { + background: #4caf50; + color: white; border: none; - padding: 12px 24px; + padding: 11px 22px; border-radius: 6px; cursor: pointer; font-size: 1em; } - - .hint-button:hover { - background: #F57C00; + .check-button:hover { background: #388e3c; } + + .next-button { + background: #2196f3; + color: white; + border: none; + padding: 11px 22px; + border-radius: 6px; + cursor: pointer; + font-size: 1em; + font-weight: 500; } - - .hint { - background: #FFF3E0; - border-left: 4px solid #FF9800; - padding: 12px 16px; - margin: 16px 0; - border-radius: 4px; + .next-button:hover { background: #1565c0; } + + .hint-button { + background: #ff9800; + color: white; + border: none; + padding: 11px 20px; + border-radius: 6px; + cursor: pointer; + font-size: 1em; } - + .hint-button:hover:not(:disabled) { background: #f57c00; } + .hint-button:disabled { opacity: 0.6; cursor: default; } + .completion-badge { - background: #E8F5E8; - border: 2px solid #4CAF50; - padding: 12px; + background: #e8f5e9; + border: 2px solid #4caf50; + padding: 16px; border-radius: 8px; text-align: center; font-weight: bold; - color: #2E7D32; + color: #2e7d32; margin: 20px 0; + font-size: 1.05em; } + \ No newline at end of file