Добавлено больше уроков, AI подсказки, генерация заданий, поиск и фильтрация уроков
This commit is contained in:
parent
3c1c263c41
commit
cc03166579
@ -1,274 +0,0 @@
|
||||
<script>
|
||||
export let buffer;
|
||||
export let onBufferChange;
|
||||
|
||||
let destinationMAC = 'AA:BB:CC:DD:EE:FF';
|
||||
let sourceMAC = 'AA:BB:CC:DD:EE:FF';
|
||||
let etherType = '0800';
|
||||
let errors = { dest: '', src: '' };
|
||||
let currentChecksum = [0x00, 0x00, 0x00, 0x00];
|
||||
|
||||
function validateMAC(mac) {
|
||||
const macRegex = /^[0-9A-Fa-f]{2}(:[0-9A-Fa-f]{2}){5}$/;
|
||||
if (!macRegex.test(mac)) {
|
||||
return 'Неверный формат MAC или некорректные значения';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function calculateChecksum(headerBuffer) {
|
||||
//имитация контрольной суммы (на практике CRC32)
|
||||
let sum = 0;
|
||||
for (let i = 0; i < headerBuffer.length; i++) {
|
||||
sum = (sum + headerBuffer[i]) & 0xFFFFFFFF;
|
||||
}
|
||||
|
||||
for (let i = 0; i < 46; i++) {
|
||||
sum = (sum + 0x00) & 0xFFFFFFFF;
|
||||
}
|
||||
|
||||
return [
|
||||
(sum >> 24) & 0xFF,
|
||||
(sum >> 16) & 0xFF,
|
||||
(sum >> 8) & 0xFF,
|
||||
sum & 0xFF
|
||||
];
|
||||
}
|
||||
|
||||
function updateBuffer() {
|
||||
const destError = validateMAC(destinationMAC);
|
||||
const srcError = validateMAC(sourceMAC);
|
||||
errors = { dest: destError, src: srcError };
|
||||
|
||||
if (destError || srcError) {
|
||||
onBufferChange(new Uint8Array(0));
|
||||
return;
|
||||
}
|
||||
|
||||
const headerBuffer = new Uint8Array(14);
|
||||
|
||||
const destBytes = destinationMAC.split(':').map(byte => parseInt(byte, 16));
|
||||
destBytes.forEach((byte, i) => headerBuffer[i] = byte);
|
||||
|
||||
const srcBytes = sourceMAC.split(':').map(byte => parseInt(byte, 16));
|
||||
srcBytes.forEach((byte, i) => headerBuffer[i + 6] = byte);
|
||||
|
||||
headerBuffer[12] = parseInt(etherType.substring(0, 2), 16);
|
||||
headerBuffer[13] = parseInt(etherType.substring(2, 4), 16);
|
||||
|
||||
currentChecksum = calculateChecksum(headerBuffer);
|
||||
|
||||
const fullBuffer = new Uint8Array(64);
|
||||
fullBuffer.set(headerBuffer, 0);
|
||||
for (let i = 14; i < 60; i++) {
|
||||
fullBuffer[i] = 0x00;
|
||||
}
|
||||
fullBuffer.set(new Uint8Array(currentChecksum), 60);
|
||||
|
||||
onBufferChange(fullBuffer);
|
||||
}
|
||||
|
||||
function handleMACInput(field, value) {
|
||||
const upperValue = value.toUpperCase();
|
||||
if (field === 'dest') {
|
||||
destinationMAC = upperValue;
|
||||
} else {
|
||||
sourceMAC = upperValue;
|
||||
}
|
||||
updateBuffer();
|
||||
}
|
||||
|
||||
function handleTypeChange(value) {
|
||||
etherType = value;
|
||||
updateBuffer();
|
||||
}
|
||||
|
||||
$: if (buffer) {
|
||||
//инициализация при первом рендере
|
||||
if (buffer.length === 0) {
|
||||
updateBuffer();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="ethernet-builder">
|
||||
<h3>Конструктор Ethernet кадра</h3>
|
||||
|
||||
<div class="field">
|
||||
<label>Destination MAC:</label>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={destinationMAC}
|
||||
on:input={(e) => handleMACInput('dest', e.target.value)}
|
||||
placeholder="AA:BB:CC:DD:EE:FF"
|
||||
class:error={errors.dest}
|
||||
/>
|
||||
{#if errors.dest}
|
||||
<span class="error-text">{errors.dest}</span>
|
||||
{/if}
|
||||
<span class="hint">Адрес получателя</span>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Source MAC:</label>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={sourceMAC}
|
||||
on:input={(e) => handleMACInput('src', e.target.value)}
|
||||
placeholder="AA:BB:CC:DD:EE:FF"
|
||||
class:error={errors.src}
|
||||
/>
|
||||
{#if errors.src}
|
||||
<span class="error-text">{errors.src}</span>
|
||||
{/if}
|
||||
<span class="hint">Адрес отправителя</span>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>EtherType:</label>
|
||||
<select bind:value={etherType} on:change={(e) => handleTypeChange(e.target.value)}>
|
||||
<option value="0800">0x0800 - IPv4</option>
|
||||
<option value="0806">0x0806 - ARP</option>
|
||||
<option value="86DD">0x86DD - IPv6</option>
|
||||
</select>
|
||||
<span class="hint">Тип инкапсулированного протокола</span>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Data (46 байт):</label>
|
||||
<div class="data-field">
|
||||
<code>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</code>
|
||||
</div>
|
||||
<span class="hint">Полезная нагрузка (заполнена нулями)</span>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>FCS (Frame Check Sequence):</label>
|
||||
<div class="checksum-field">
|
||||
<code>
|
||||
{'0x' + currentChecksum.map(b => b.toString(16).padStart(2,'0')).join('').toUpperCase()}
|
||||
</code>
|
||||
</div>
|
||||
<span class="hint">Контрольная сумма (рассчитывается автоматически)</span>
|
||||
</div>
|
||||
|
||||
<div class="frame-preview">
|
||||
<h4>Структура кадра (64 байта):</h4>
|
||||
<div class="frame-layout">
|
||||
<div class="frame-field" style="background: #e3f2fd;">
|
||||
<span>Destination MAC</span>
|
||||
<small>6 bytes</small>
|
||||
</div>
|
||||
<div class="frame-field" style="background: #fff3e0;">
|
||||
<span>Source MAC</span>
|
||||
<small>6 bytes</small>
|
||||
</div>
|
||||
<div class="frame-field" style="background: #e8f5e8;">
|
||||
<span>EtherType</span>
|
||||
<small>2 bytes</small>
|
||||
</div>
|
||||
<div class="frame-field" style="background: #f3e5f5;">
|
||||
<span>Data</span>
|
||||
<small>46 bytes</small>
|
||||
</div>
|
||||
<div class="frame-field" style="background: #ffebee;">
|
||||
<span>FCS</span>
|
||||
<small>4 bytes</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.ethernet-builder {
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.field {
|
||||
margin: 15px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.field label {
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.field input, .field select {
|
||||
padding: 8px 12px;
|
||||
border: 2px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.field input:focus, .field select:focus {
|
||||
border-color: #2196F3;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.error {
|
||||
border-color: #f44336 !important;
|
||||
background-color: #ffebee;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
color: #f44336;
|
||||
font-size: 0.8em;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 0.8em;
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.data-field, .checksum-field {
|
||||
background: #f5f5f5;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #ddd;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.frame-preview {
|
||||
margin-top: 20px;
|
||||
padding-top: 15px;
|
||||
border-top: 1px solid #eee;
|
||||
}
|
||||
|
||||
.frame-layout {
|
||||
display: flex;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.frame-field {
|
||||
flex: 1;
|
||||
padding: 10px;
|
||||
text-align: center;
|
||||
border-right: 1px solid #ccc;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.frame-field:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.frame-field span {
|
||||
display: block;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.frame-field small {
|
||||
color: #666;
|
||||
}
|
||||
</style>
|
||||
366
src/lib/components/HttpEditor.svelte
Normal file
366
src/lib/components/HttpEditor.svelte
Normal file
@ -0,0 +1,366 @@
|
||||
<!--принимает buffer (Uint8Array, закодированный TextEncoder текст)
|
||||
и вызывает onBufferChange с новым закодированным текстом-->
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
export let buffer = new Uint8Array([]);
|
||||
export let onBufferChange = () => {};
|
||||
export let readOnlyRanges = []; //для совместимости
|
||||
|
||||
let text = '';
|
||||
let parsed = null;
|
||||
let lineCount = 1;
|
||||
|
||||
onMount(() => {
|
||||
if (buffer.length > 0) {
|
||||
text = new TextDecoder().decode(buffer);
|
||||
parsed = parseRequest(text);
|
||||
} else {
|
||||
text = 'GET / HTTP/1.1\nHost: \n\n';
|
||||
sync();
|
||||
}
|
||||
updateLineCount();
|
||||
});
|
||||
|
||||
function sync() {
|
||||
parsed = parseRequest(text);
|
||||
onBufferChange(new TextEncoder().encode(text));
|
||||
}
|
||||
|
||||
function handleInput() {
|
||||
updateLineCount();
|
||||
sync();
|
||||
}
|
||||
|
||||
function updateLineCount() {
|
||||
lineCount = (text.match(/\n/g) ?? []).length + 1;
|
||||
}
|
||||
|
||||
function parseRequest(raw) {
|
||||
if (!raw.trim()) return null;
|
||||
|
||||
const normalized = raw.replace(/\r\n/g, '\n');
|
||||
const lines = normalized.split('\n');
|
||||
|
||||
const rlMatch = lines[0]?.match(/^(\S+)\s+(\S+)\s+(\S+)$/);
|
||||
if (!rlMatch) {
|
||||
return { error: 'Некорректная строка запроса. Ожидается: МЕТОД /путь HTTP/1.1' };
|
||||
}
|
||||
|
||||
const [, method, path, version] = rlMatch;
|
||||
|
||||
const headers = {};
|
||||
let bodyStart = lines.length;
|
||||
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
if (lines[i].trim() === '') {
|
||||
bodyStart = i + 1;
|
||||
break;
|
||||
}
|
||||
const colon = lines[i].indexOf(':');
|
||||
if (colon > 0) {
|
||||
const key = lines[i].slice(0, colon).trim();
|
||||
const value = lines[i].slice(colon + 1).trim();
|
||||
headers[key.toLowerCase()] = { key, value };
|
||||
}
|
||||
}
|
||||
|
||||
const bodyLines = lines.slice(bodyStart);
|
||||
const body = bodyLines.join('\n').trim();
|
||||
const hasBlankLine = normalized.includes('\n\n');
|
||||
|
||||
return { method, path, version, headers, body, hasBlankLine, error: null };
|
||||
}
|
||||
|
||||
function methodColor(method) {
|
||||
const colors = {
|
||||
GET: '#4caf50',
|
||||
POST: '#2196f3',
|
||||
PUT: '#ff9800',
|
||||
PATCH: '#9c27b0',
|
||||
DELETE: '#f44336',
|
||||
HEAD: '#607d8b',
|
||||
};
|
||||
return colors[method?.toUpperCase()] ?? '#555';
|
||||
}
|
||||
|
||||
function versionOk(v) {
|
||||
return /^HTTP\/\d+\.\d+$/.test(v ?? '');
|
||||
}
|
||||
|
||||
function headerEntries(headers) {
|
||||
return Object.values(headers ?? {});
|
||||
}
|
||||
|
||||
//примерное количество строк для textarea
|
||||
$: rows = Math.max(lineCount + 2, 8);
|
||||
</script>
|
||||
|
||||
<div class="http-editor">
|
||||
<!--textarea-->
|
||||
<div class="editor-area">
|
||||
<div class="line-numbers" aria-hidden="true">
|
||||
{#each { length: lineCount } as _, i}
|
||||
<span>{i + 1}</span>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
bind:value={text}
|
||||
on:input={handleInput}
|
||||
spellcheck="false"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
placeholder="Введите HTTP-запрос..."
|
||||
{rows}
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!--разбивка-->
|
||||
{#if parsed}
|
||||
<div class="parsed-view">
|
||||
<h4>Разбор запроса</h4>
|
||||
|
||||
{#if parsed.error}
|
||||
<div class="parse-error">⚠ {parsed.error}</div>
|
||||
{:else}
|
||||
<!--строка запроса-->
|
||||
<div class="section">
|
||||
<div class="section-title">Строка запроса</div>
|
||||
<div class="request-line">
|
||||
<span class="badge method" style="background:{methodColor(parsed.method)}">
|
||||
{parsed.method}
|
||||
</span>
|
||||
<span class="badge path">{parsed.path}</span>
|
||||
<span class="badge version" class:ok={versionOk(parsed.version)} class:bad={!versionOk(parsed.version)}>
|
||||
{parsed.version}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!--заголовки-->
|
||||
<div class="section">
|
||||
<div class="section-title">
|
||||
Заголовки
|
||||
<span class="count">({headerEntries(parsed.headers).length})</span>
|
||||
</div>
|
||||
{#if headerEntries(parsed.headers).length === 0}
|
||||
<div class="empty-hint">— нет заголовков</div>
|
||||
{:else}
|
||||
<table class="headers-table">
|
||||
{#each headerEntries(parsed.headers) as h}
|
||||
<tr>
|
||||
<td class="hkey">{h.key}</td>
|
||||
<td class="hval">{h.value}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</table>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!--разделитель-->
|
||||
<div class="section">
|
||||
<div class="section-title">Разделитель (пустая строка)</div>
|
||||
{#if parsed.hasBlankLine}
|
||||
<span class="ok-badge">✓ присутствует</span>
|
||||
{:else}
|
||||
<span class="bad-badge">✗ отсутствует — заголовки не отделены от тела</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!--тело-->
|
||||
<div class="section">
|
||||
<div class="section-title">
|
||||
Тело запроса
|
||||
{#if parsed.body}
|
||||
<span class="count">({new TextEncoder().encode(parsed.body).length} байт)</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if parsed.body}
|
||||
<pre class="body-preview">{parsed.body}</pre>
|
||||
{:else}
|
||||
<div class="empty-hint">— тело отсутствует</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.http-editor {
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
margin: 16px 0;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
/*textarea area*/
|
||||
.editor-area {
|
||||
display: flex;
|
||||
background: #1e1e1e;
|
||||
}
|
||||
|
||||
.line-numbers {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 12px 8px;
|
||||
background: #252526;
|
||||
color: #666;
|
||||
font-size: 0.8em;
|
||||
line-height: 1.6;
|
||||
text-align: right;
|
||||
user-select: none;
|
||||
min-width: 36px;
|
||||
border-right: 1px solid #333;
|
||||
}
|
||||
|
||||
.line-numbers span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
textarea {
|
||||
flex: 1;
|
||||
background: #1e1e1e;
|
||||
color: #d4d4d4;
|
||||
border: none;
|
||||
outline: none;
|
||||
padding: 12px;
|
||||
font-family: 'Courier New', Consolas, monospace;
|
||||
font-size: 0.9em;
|
||||
line-height: 1.6;
|
||||
resize: vertical;
|
||||
min-height: 140px;
|
||||
tab-size: 4;
|
||||
white-space: pre;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
textarea::placeholder {
|
||||
color: #555;
|
||||
}
|
||||
|
||||
/*parsed view*/
|
||||
.parsed-view {
|
||||
background: #fafafa;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.parsed-view h4 {
|
||||
margin: 0 0 14px;
|
||||
font-size: 0.85em;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #888;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 0.75em;
|
||||
font-weight: bold;
|
||||
color: #555;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.count {
|
||||
font-weight: normal;
|
||||
color: #999;
|
||||
font-size: 0.9em;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.parse-error {
|
||||
background: #fff3e0;
|
||||
border-left: 4px solid #ff9800;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
font-family: sans-serif;
|
||||
font-size: 0.85em;
|
||||
color: #e65100;
|
||||
}
|
||||
|
||||
/*строка запроса*/
|
||||
.request-line {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 3px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85em;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.badge.method {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge.path {
|
||||
background: #f0f0f0;
|
||||
color: #333;
|
||||
font-weight: normal;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.badge.version.ok { background: #e8f5e9; color: #2e7d32; }
|
||||
.badge.version.bad { background: #ffebee; color: #c62828; }
|
||||
|
||||
/*заголовки*/
|
||||
.headers-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.headers-table tr:nth-child(even) { background: #f5f5f5; }
|
||||
|
||||
.hkey {
|
||||
padding: 4px 10px 4px 0;
|
||||
color: #1976d2;
|
||||
font-weight: bold;
|
||||
white-space: nowrap;
|
||||
vertical-align: top;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.hval {
|
||||
padding: 4px 0;
|
||||
color: #333;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/*разделитель/тело*/
|
||||
.ok-badge { color: #2e7d32; font-size: 0.85em; }
|
||||
.bad-badge { color: #c62828; font-size: 0.85em; }
|
||||
|
||||
.empty-hint {
|
||||
color: #aaa;
|
||||
font-size: 0.85em;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.body-preview {
|
||||
background: #f5f5f5;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 4px;
|
||||
padding: 10px;
|
||||
font-size: 0.85em;
|
||||
margin: 0;
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
color: #333;
|
||||
}
|
||||
</style>
|
||||
@ -1,6 +1,4 @@
|
||||
<script>
|
||||
import { lessons } from "$lib/data/lessons";
|
||||
|
||||
export let lesson;
|
||||
</script>
|
||||
|
||||
@ -26,11 +24,6 @@
|
||||
|
||||
<section class="practice-section">
|
||||
<h2>Практика</h2>
|
||||
<div class="objective">
|
||||
<h2>Задание</h2>
|
||||
<p>{lesson.objective}</p>
|
||||
</div>
|
||||
|
||||
<slot></slot>
|
||||
</section>
|
||||
</div>
|
||||
@ -121,20 +114,6 @@
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
<!--.objective {
|
||||
background: #fffcf6;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid #FF9800;
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
|
||||
.objective p {
|
||||
margin: 0;
|
||||
font-size: 1.1em;
|
||||
font-weight: 500;
|
||||
}-->
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.lesson-body {
|
||||
padding: 20px 15px;
|
||||
|
||||
@ -1,97 +1,169 @@
|
||||
<!--универсальный компонент "Wireshark"
|
||||
принимает описание полей из данных урока (lesson.wiresharkFields)-->
|
||||
<script>
|
||||
import {
|
||||
formatIPv4Address,
|
||||
formatIPv4Protocol,
|
||||
formatVersionIHL,
|
||||
formatFlagsFragment,
|
||||
} from '$lib/utils/protocols/ipv4.js';
|
||||
|
||||
import {
|
||||
formatPort,
|
||||
formatSeqAck,
|
||||
formatDataOffset,
|
||||
formatTCPFlags,
|
||||
formatWindowSize,
|
||||
} from '$lib/utils/protocols/tcp.js';
|
||||
|
||||
import { formatUDPLength } from '$lib/utils/protocols/udp.js';
|
||||
|
||||
import {
|
||||
formatDNSFlags,
|
||||
formatDNSQType,
|
||||
formatDNSQClass,
|
||||
formatDNSQName,
|
||||
} from '$lib/utils/protocols/dns.js';
|
||||
|
||||
export let data = new Uint8Array([]);
|
||||
export let fields = [];
|
||||
export let title = 'Структура пакета';
|
||||
|
||||
$: destinationMAC = data.slice(0, 6);
|
||||
$: sourceMAC = data.slice(6, 12);
|
||||
$: etherType = data.slice(12, 14);
|
||||
$: frameData = data.slice(14, 60);
|
||||
$: fcs = data.slice(60, 64);
|
||||
$: fieldValues = fields.map(field => ({
|
||||
...field,
|
||||
bytes: data.slice(field.start, field.start + field.length),
|
||||
}));
|
||||
|
||||
function formatMAC(bytes) {
|
||||
function formatBytes(bytes, format) {
|
||||
if (!bytes || bytes.length === 0) return 'N/A';
|
||||
|
||||
switch (format) {
|
||||
case 'mac':
|
||||
return Array.from(bytes)
|
||||
.map(b => b.toString(16).padStart(2, '0').toUpperCase())
|
||||
.join(':');
|
||||
|
||||
case 'ip':
|
||||
return formatIPv4Address(bytes);
|
||||
|
||||
case 'decimal': {
|
||||
let val = 0;
|
||||
for (const b of bytes) val = (val << 8) | b;
|
||||
return String(val);
|
||||
}
|
||||
|
||||
function formatHex(bytes) {
|
||||
if (bytes.length === 0) return 'N/A';
|
||||
return '0x' + Array.from(bytes)
|
||||
case 'ethertype': {
|
||||
const val = (bytes[0] << 8) | bytes[1];
|
||||
const names = { 0x0800: 'IPv4', 0x0806: 'ARP', 0x86dd: 'IPv6' };
|
||||
const name = names[val] ?? 'Unknown';
|
||||
return `${name} (0x${val.toString(16).padStart(4, '0').toUpperCase()})`;
|
||||
}
|
||||
|
||||
case 'ip_protocol':
|
||||
return formatIPv4Protocol(bytes[0]);
|
||||
|
||||
case 'version_ihl':
|
||||
return formatVersionIHL(bytes[0]);
|
||||
|
||||
case 'flags_fragment':
|
||||
return formatFlagsFragment(bytes);
|
||||
|
||||
case 'port':
|
||||
return formatPort(bytes);
|
||||
|
||||
case 'seq_ack':
|
||||
return formatSeqAck(bytes);
|
||||
|
||||
case 'tcp_data_offset':
|
||||
return formatDataOffset(bytes[0]);
|
||||
|
||||
case 'tcp_flags':
|
||||
return formatTCPFlags(bytes[0]);
|
||||
|
||||
case 'window_size':
|
||||
return formatWindowSize(bytes);
|
||||
|
||||
case 'udp_length':
|
||||
return formatUDPLength(bytes);
|
||||
|
||||
case 'dns_flags':
|
||||
return formatDNSFlags(bytes);
|
||||
|
||||
case 'dns_qtype':
|
||||
return formatDNSQType(bytes);
|
||||
|
||||
case 'dns_qclass':
|
||||
return formatDNSQClass(bytes);
|
||||
|
||||
case 'dns_qname':
|
||||
return formatDNSQName(bytes);
|
||||
|
||||
case 'hex':
|
||||
default:
|
||||
return (
|
||||
'0x' +
|
||||
Array.from(bytes)
|
||||
.map(b => b.toString(16).padStart(2, '0').toUpperCase())
|
||||
.join(' ');
|
||||
.join(' ')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function formatEtherType(bytes) {
|
||||
if (bytes.length < 2) return 'N/A';
|
||||
const value = (bytes[0] << 8) | bytes[1];
|
||||
const types = {
|
||||
0x0800: 'IPv4',
|
||||
0x0806: 'ARP',
|
||||
0x86DD: 'IPv6'
|
||||
};
|
||||
return `${types[value] || 'Unknown'} (0x${value.toString(16).padStart(4, '0').toUpperCase()})`;
|
||||
}
|
||||
//размеры frame-layout
|
||||
const PX_PER_BYTE = 28;
|
||||
const MIN_CELL_PX = 56;
|
||||
|
||||
$: totalBytesCount = fieldValues.length
|
||||
? fieldValues[fieldValues.length - 1].start + fieldValues[fieldValues.length - 1].length
|
||||
: 0;
|
||||
$: layoutMinWidth = Math.max(totalBytesCount * PX_PER_BYTE, 480);
|
||||
</script>
|
||||
|
||||
<div class="wireshark-view">
|
||||
<h3>Структура Ethernet кадра</h3>
|
||||
<h3>{title}</h3>
|
||||
|
||||
<!--список полей Wireshark-->
|
||||
<div class="fields">
|
||||
{#each fieldValues as fv (fv.name)}
|
||||
<div class="field">
|
||||
<label>Destination MAC:</label>
|
||||
<div class="value">{formatMAC(destinationMAC)}</div>
|
||||
<span class="hint">MAC адрес получателя</span>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Source MAC:</label>
|
||||
<div class="value">{formatMAC(sourceMAC)}</div>
|
||||
<span class="hint">MAC адрес отправителя</span>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>EtherType:</label>
|
||||
<div class="value">{formatEtherType(etherType)}</div>
|
||||
<span class="hint">Тип протокола</span>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Data:</label>
|
||||
<div class="value data-field">{formatHex(frameData)}</div>
|
||||
<span class="hint">Полезная нагрузка</span>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>FCS (Frame Check Sequence):</label>
|
||||
<div class="value checksum-field">{formatHex(fcs)}</div>
|
||||
<span class="hint">Контрольная сумма</span>
|
||||
</div>
|
||||
<div class="field-label">
|
||||
<span class="field-name">{fv.name}:</span>
|
||||
<span class="field-range">
|
||||
{fv.length === 1
|
||||
? `байт ${fv.start}`
|
||||
: `байты ${fv.start}–${fv.start + fv.length - 1}`}
|
||||
</span>
|
||||
</div>
|
||||
<div class="value" style="border-left: 4px solid {fv.color ?? '#2196F3'}">
|
||||
{formatBytes(fv.bytes, fv.format)}
|
||||
</div>
|
||||
{#if fv.description}
|
||||
<span class="field-hint">{fv.description}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!--визуальная схема пакета-->
|
||||
{#if fieldValues.length > 0}
|
||||
<div class="frame-preview">
|
||||
<h4>Структура кадра (64 байта):</h4>
|
||||
<div class="frame-layout">
|
||||
<div class="frame-field" style="background: #e3f2fd;">
|
||||
<span>Destination MAC</span>
|
||||
<small>6 bytes</small>
|
||||
<h4>Структура пакета ({totalBytesCount} байт):</h4>
|
||||
<div class="frame-scroll">
|
||||
<div class="frame-layout" style="min-width: {layoutMinWidth}px">
|
||||
{#each fieldValues as fv (fv.name)}
|
||||
{@const cellPx = Math.max(fv.length * PX_PER_BYTE, MIN_CELL_PX)}
|
||||
<div class="frame-field"
|
||||
style="width: {cellPx}px; flex: 0 0 {cellPx}px; background: {fv.color ?? '#e3f2fd'};"
|
||||
title="{fv.name} ({fv.length}B) — {formatBytes(fv.bytes, fv.format)}"
|
||||
>
|
||||
<span class="frame-field-name">{fv.name}</span>
|
||||
<small>{fv.length}B</small>
|
||||
</div>
|
||||
<div class="frame-field" style="background: #fff3e0;">
|
||||
<span>Source MAC</span>
|
||||
<small>6 bytes</small>
|
||||
</div>
|
||||
<div class="frame-field" style="background: #e8f5e8;">
|
||||
<span>EtherType</span>
|
||||
<small>2 bytes</small>
|
||||
</div>
|
||||
<div class="frame-field" style="background: #f3e5f5;">
|
||||
<span>Data</span>
|
||||
<small>46 bytes</small>
|
||||
</div>
|
||||
<div class="frame-field" style="background: #ffebee;">
|
||||
<span>FCS</span>
|
||||
<small>4 bytes</small>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@ -103,47 +175,78 @@
|
||||
background: #fdfdfd;
|
||||
}
|
||||
|
||||
.wireshark-view h3 {
|
||||
margin: 0 0 16px;
|
||||
color: #333;
|
||||
font-size: 1em;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.fields {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.field {
|
||||
margin: 15px 0;
|
||||
margin: 12px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.field label {
|
||||
.field-label {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.field-name {
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.field-range {
|
||||
font-size: 0.75em;
|
||||
color: #999;
|
||||
font-family: 'Courier New', monospace;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.value {
|
||||
background: white;
|
||||
padding: 8px 12px;
|
||||
padding: 7px 12px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #ddd;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.9em;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.data-field, .checksum-field {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 0.8em;
|
||||
color: #666;
|
||||
.field-hint {
|
||||
font-size: 0.78em;
|
||||
color: #777;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/*frame layout*/
|
||||
.frame-preview {
|
||||
margin-top: 20px;
|
||||
padding-top: 15px;
|
||||
border-top: 1px solid #eee;
|
||||
}
|
||||
|
||||
.frame-preview h4 {
|
||||
margin: 0 0 10px;
|
||||
color: #555;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
/*горизонтальный скролл*/
|
||||
.frame-scroll {
|
||||
overflow-x: auto;
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
|
||||
.frame-layout {
|
||||
display: flex;
|
||||
border: 1px solid #ccc;
|
||||
@ -152,23 +255,32 @@
|
||||
}
|
||||
|
||||
.frame-field {
|
||||
flex: 1;
|
||||
padding: 10px;
|
||||
flex-shrink: 0;
|
||||
padding: 8px 4px;
|
||||
text-align: center;
|
||||
border-right: 1px solid #ccc;
|
||||
font-size: 0.9em;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
min-height: 54px;
|
||||
}
|
||||
|
||||
.frame-field:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.frame-field span {
|
||||
.frame-field-name {
|
||||
display: block;
|
||||
font-weight: bold;
|
||||
font-size: 0.7em;
|
||||
line-height: 1.2;
|
||||
word-break: break-word;
|
||||
hyphens: auto;
|
||||
}
|
||||
|
||||
.frame-field small {
|
||||
color: #666;
|
||||
color: #555;
|
||||
font-size: 0.65em;
|
||||
}
|
||||
</style>
|
||||
@ -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: `
|
||||
<div style="max-width: 1000px; margin: 0 auto; line-height: 1.6;">
|
||||
<h2 style="color: #2196F3; border-bottom: 2px solid #2196F3; padding-bottom: 10px;">
|
||||
Бит и байт - основа сетевых протоколов
|
||||
</h2>
|
||||
|
||||
<div style="background: #f9fcff; padding: 20px; border-radius: 8px; margin: 20px 0;">
|
||||
<h3 style="color: #1976D2; margin-top: 0;">Что такое бит?</h3>
|
||||
<ul>
|
||||
<li><strong>Бит</strong> - минимальная единица информации (0 или 1)</li>
|
||||
<li>Основа всей компьютерной техники</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div style="background: #f9fcff; padding: 20px; border-radius: 8px; margin: 20px 0;">
|
||||
<h3 style="color: #1976D2; margin-top: 0;">Что такое байт?</h3>
|
||||
<ul>
|
||||
<li><strong>Байт</strong> = 8 битов</li>
|
||||
<li>Пример: <code>01011011</code> = 1 байт</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div style="background: #f9fcff; padding: 20px; border-radius: 8px; margin: 20px 0;">
|
||||
<h3 style="color: #1976D2; margin-top: 0;">Двоичная арифметика:</h3>
|
||||
<p>Каждая позиция бита - это степень двойки:</p>
|
||||
<div style="background: white; padding: 15px; border-radius: 6px; border-left: 4px solid #4CAF50;">
|
||||
<code>10101010</code> = 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
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="background: #f9fcff; padding: 20px; border-radius: 8px; margin: 20px 0;">
|
||||
<h3 style="color: #1976D2; margin-top: 0;">Шестнадцатеричная система (Hex):</h3>
|
||||
<p>В обычной жизни мы используем десятичную систему (0-9). В шестнадцатеричной системе 16 цифр:</p>
|
||||
|
||||
<div style="display: flex; gap: 20px; margin: 20px 0;">
|
||||
<table style="flex: 1; border-collapse: collapse; border: 1px solid #ddd;">
|
||||
<tr><th style="background: #e3f2fd; padding: 8px; border: 1px solid #ddd;">Десятичная</th><th style="background: #e3f2fd; padding: 8px; border: 1px solid #ddd;">Hex</th><th style="background: #e3f2fd; padding: 8px; border: 1px solid #ddd;">Двоичная</th></tr>
|
||||
<tr><td style="padding: 6px; border: 1px solid #ddd; text-align: center;">0</td><td style="padding: 6px; border: 1px solid #ddd; text-align: center;">0</td><td style="padding: 6px; border: 1px solid #ddd; text-align: center;">0000</td></tr>
|
||||
<tr><td style="padding: 6px; border: 1px solid #ddd; text-align: center;">1</td><td style="padding: 6px; border: 1px solid #ddd; text-align: center;">1</td><td style="padding: 6px; border: 1px solid #ddd; text-align: center;">0001</td></tr>
|
||||
<tr><td style="padding: 6px; border: 1px solid #ddd; text-align: center;">2</td><td style="padding: 6px; border: 1px solid #ddd; text-align: center;">2</td><td style="padding: 6px; border: 1px solid #ddd; text-align: center;">0010</td></tr>
|
||||
<tr><td style="padding: 6px; border: 1px solid #ddd; text-align: center;">3</td><td style="padding: 6px; border: 1px solid #ddd; text-align: center;">3</td><td style="padding: 6px; border: 1px solid #ddd; text-align: center;">0011</td></tr>
|
||||
<tr><td style="padding: 6px; border: 1px solid #ddd; text-align: center;">4</td><td style="padding: 6px; border: 1px solid #ddd; text-align: center;">4</td><td style="padding: 6px; border: 1px solid #ddd; text-align: center;">0100</td></tr>
|
||||
<tr><td style="padding: 6px; border: 1px solid #ddd; text-align: center;">5</td><td style="padding: 6px; border: 1px solid #ddd; text-align: center;">5</td><td style="padding: 6px; border: 1px solid #ddd; text-align: center;">0101</td></tr>
|
||||
<tr><td style="padding: 6px; border: 1px solid #ddd; text-align: center;">6</td><td style="padding: 6px; border: 1px solid #ddd; text-align: center;">6</td><td style="padding: 6px; border: 1px solid #ddd; text-align: center;">0110</td></tr>
|
||||
<tr><td style="padding: 6px; border: 1px solid #ddd; text-align: center;">7</td><td style="padding: 6px; border: 1px solid #ddd; text-align: center;">7</td><td style="padding: 6px; border: 1px solid #ddd; text-align: center;">0111</td></tr>
|
||||
<tr><td style="padding: 6px; border: 1px solid #ddd; text-align: center;">8</td><td style="padding: 6px; border: 1px solid #ddd; text-align: center;">8</td><td style="padding: 6px; border: 1px solid #ddd; text-align: center;">1000</td></tr>
|
||||
</table>
|
||||
|
||||
<table style="flex: 1; border-collapse: collapse; border: 1px solid #ddd;">
|
||||
<tr><th style="background: #e3f2fd; padding: 8px; border: 1px solid #ddd;">Десятичная</th><th style="background: #e3f2fd; padding: 8px; border: 1px solid #ddd;">Hex</th><th style="background: #e3f2fd; padding: 8px; border: 1px solid #ddd;">Двоичная</th></tr>
|
||||
<tr><td style="padding: 6px; border: 1px solid #ddd; text-align: center;">9</td><td style="padding: 6px; border: 1px solid #ddd; text-align: center;">9</td><td style="padding: 6px; border: 1px solid #ddd; text-align: center;">1001</td></tr>
|
||||
<tr><td style="padding: 6px; border: 1px solid #ddd; text-align: center;">10</td><td style="padding: 6px; border: 1px solid #ddd; text-align: center;">A</td><td style="padding: 6px; border: 1px solid #ddd; text-align: center;">1010</td></tr>
|
||||
<tr><td style="padding: 6px; border: 1px solid #ddd; text-align: center;">11</td><td style="padding: 6px; border: 1px solid #ddd; text-align: center;">B</td><td style="padding: 6px; border: 1px solid #ddd; text-align: center;">1011</td></tr>
|
||||
<tr><td style="padding: 6px; border: 1px solid #ddd; text-align: center;">12</td><td style="padding: 6px; border: 1px solid #ddd; text-align: center;">C</td><td style="padding: 6px; border: 1px solid #ddd; text-align: center;">1100</td></tr>
|
||||
<tr><td style="padding: 6px; border: 1px solid #ddd; text-align: center;">13</td><td style="padding: 6px; border: 1px solid #ddd; text-align: center;">D</td><td style="padding: 6px; border: 1px solid #ddd; text-align: center;">1101</td></tr>
|
||||
<tr><td style="padding: 6px; border: 1px solid #ddd; text-align: center;">14</td><td style="padding: 6px; border: 1px solid #ddd; text-align: center;">E</td><td style="padding: 6px; border: 1px solid #ddd; text-align: center;">1110</td></tr>
|
||||
<tr><td style="padding: 6px; border: 1px solid #ddd; text-align: center;">15</td><td style="padding: 6px; border: 1px solid #ddd; text-align: center;">F</td><td style="padding: 6px; border: 1px solid #ddd; text-align: center;">1111</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="background: #f9fcff; padding: 20px; border-radius: 8px; margin: 20px 0;">
|
||||
<h3 style="color: #1976D2; margin-top: 0;">Как преобразовать двоичное в Hex:</h3>
|
||||
<p>Разбиваем байт на две половинки по 4 бита и преобразуем каждую отдельно:</p>
|
||||
|
||||
<div style="background: white; padding: 15px; border-radius: 6px; border-left: 4px solid #4CAF50;">
|
||||
<p><strong>Пример: <code>11010011</code></strong></p>
|
||||
<ol>
|
||||
<li>Разбиваем на две группы: <code>1101</code> и <code>0011</code></li>
|
||||
<li><code>1101</code> = 1×8 + 1×4 + 0×2 + 1×1 = 13 = <code>D</code></li>
|
||||
<li><code>0011</code> = 0×8 + 0×4 + 1×2 + 1×1 = 3 = <code>3</code></li>
|
||||
<li>Результат: <code>0xD3</code></li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<p>Удобнее читать <code>0xA1</code>, чем <code>10100001</code>!</p>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
|
||||
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: `
|
||||
<div style="max-width: 1000px; margin: 0 auto; line-height: 1.6;">
|
||||
<h2 style="color: #2196F3; border-bottom: 2px solid #2196F3; padding-bottom: 10px;">
|
||||
MAC адреса
|
||||
</h2>
|
||||
|
||||
<div style="background: #f9fcff; padding: 20px; border-radius: 8px; margin: 20px 0;">
|
||||
<h3 style="color: #1976D2; margin-top: 0;">Что такое MAC адрес?</h3>
|
||||
<ul>
|
||||
<li><strong>MAC адрес</strong> - уникальный идентификатор сетевого устройства</li>
|
||||
<li><strong>Формат:</strong> XX:XX:XX:XX:XX:XX (6 байтов)</li>
|
||||
<li><strong>Пример:</strong> <code>12:34:56:78:9A:BC</code></li>
|
||||
<li>Первые 3 байта - идентификатор производителя (OUI)</li>
|
||||
<li>Последние 3 байта - серийный номер устройства</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div style="background: #f9fcff; padding: 20px; border-radius: 8px; margin: 20px 0;">
|
||||
<h3 style="color: #1976D2; margin-top: 0;">Как редактировать MAC-адрес?</h2>
|
||||
|
||||
<h3 style="color: #1976D2; margin-top: 0;">Форматы представления чисел:</h3>
|
||||
<ul>
|
||||
<li><strong>Hex (16-ричный):</strong> 0xFF, 0x1A3B</li>
|
||||
<li><strong>Decimal (10-ричный):</strong> 255, 6715</li>
|
||||
<li><strong>Binary (2-ичный):</strong> 11111111, 110100011011</li>
|
||||
<li><strong>Octal (8-ричный):</strong> 377, 15073</li>
|
||||
</ul>
|
||||
|
||||
<h3 style="color: #1976D2; margin-top: 0;">Endianness (порядок байт):</h3>
|
||||
<ul>
|
||||
<li><strong>Big Endian:</strong> старший байт по младшему адресу (сетевой порядок)</li>
|
||||
<li><strong>Little Endian:</strong> младший байт по младшему адресу (x86, ARM)</li>
|
||||
<li><strong>Пример:</strong> 0x12345678
|
||||
<ul>
|
||||
<li>Big Endian: 12 34 56 78</li>
|
||||
<li>Little Endian: 78 56 34 12</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
|
||||
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: `
|
||||
<div style="max-width: 1000px; margin: 0 auto; line-height: 1.6;">
|
||||
<h2 style="color: #2196F3; border-bottom: 2px solid #2196F3; padding-bottom: 10px;">
|
||||
Ethernet кадр
|
||||
</h2>
|
||||
|
||||
<div style="background: #f9fcff; padding: 20px; border-radius: 8px; margin: 20px 0;">
|
||||
<p><strong>Ethernet II и IEEE 802.3</strong> - это два стандарта Ethernet-кадров. Они идентичны по структуре, кроме одного поля: в Ethernet II это поле Type (указывает тип протокола), а в IEEE 802.3 это поле Length (указывает длину поля данных в байтах).</p>
|
||||
<p> На практике Ethernet II используется чаще, потому что поле Type более полезно для определения, какой протокол находится внутри кадра.</p>
|
||||
</div>
|
||||
|
||||
<div style="background: #f9fcff; padding: 20px; border-radius: 8px; margin: 20px 0;">
|
||||
<h3 style="color: #1976D2; margin-top: 0;">Структура кадра Ethernet II:</h3>
|
||||
|
||||
<div style="background: white; padding: 15px; border-radius: 6px; border-left: 4px solid #4CAF50; font-family: 'Courier New', monospace; font-size: 0.9em;">
|
||||
<div style="display: flex; margin: 5px 0;">
|
||||
<span style="min-width: 140px; font-weight: bold;">Destination MAC:</span>
|
||||
<span>6 байт | MAC адрес получателя</span>
|
||||
</div>
|
||||
<div style="display: flex; margin: 5px 0;">
|
||||
<span style="min-width: 140px; font-weight: bold;">Source MAC:</span>
|
||||
<span>6 байт | MAC адрес отправителя</span>
|
||||
</div>
|
||||
<div style="display: flex; margin: 5px 0;">
|
||||
<span style="min-width: 140px; font-weight: bold;">Type:</span>
|
||||
<span>2 байта | Тип протокола</span>
|
||||
</div>
|
||||
<div style="display: flex; margin: 5px 0;">
|
||||
<span style="min-width: 140px; font-weight: bold;">Data:</span>
|
||||
<span>46-1500 байт | Полезная нагрузка</span>
|
||||
</div>
|
||||
<div style="display: flex; margin: 5px 0;">
|
||||
<span style="min-width: 140px; font-weight: bold;">FCS:</span>
|
||||
<span>4 байта | Контрольная сумма</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="background: #f9fcff; padding: 20px; border-radius: 8px; margin: 20px 0;">
|
||||
<h3 style="color: #1976D2; margin-top: 0;">Типы протоколов (EtherType):</h3>
|
||||
|
||||
<div style="display: flex; gap: 20px; margin: 15px 0;">
|
||||
<table style="flex: 1; border-collapse: collapse; border: 1px solid #ddd;">
|
||||
<tr><th style="background: #e3f2fd; padding: 8px; border: 1px solid #ddd;">Код</th><th style="background: #e3f2fd; padding: 8px; border: 1px solid #ddd;">Протокол</th></tr>
|
||||
<tr><td style="padding: 6px; border: 1px solid #ddd; text-align: center;"><code>0x0800</code></td><td style="padding: 6px; border: 1px solid #ddd; text-align: center;">IPv4</td></tr>
|
||||
<tr><td style="padding: 6px; border: 1px solid #ddd; text-align: center;"><code>0x0806</code></td><td style="padding: 6px; border: 1px solid #ddd; text-align: center;">ARP</td></tr>
|
||||
<tr><td style="padding: 6px; border: 1px solid #ddd; text-align: center;"><code>0x86DD</code></td><td style="padding: 6px; border: 1px solid #ddd; text-align: center;">IPv6</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
|
||||
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;
|
||||
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'],
|
||||
|
||||
hints: [
|
||||
'Используйте широковещательный адрес FF:FF:FF:FF:FF:FF',
|
||||
'Установите EtherType 0x0806 для ARP',
|
||||
'MAC адрес отправителя должен быть 12:34:56:78:9A:BC'
|
||||
]
|
||||
}
|
||||
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' },
|
||||
},
|
||||
];
|
||||
463
src/lib/data/theory.js
Normal file
463
src/lib/data/theory.js
Normal file
@ -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': `
|
||||
<div style="max-width:1000px;margin:0 auto;line-height:1.6;">
|
||||
<h2 style="${H2}">Бит и байт — основа сетевых протоколов</h2>
|
||||
|
||||
<div style="${B}">
|
||||
<h3 style="${H3}">Бит и байт</h3>
|
||||
<p><strong>Бит</strong> — минимальная единица информации, принимает значение 0 или 1.</p>
|
||||
<p><strong>Байт</strong> = 8 бит. Один байт хранит число от 0 до 255. Все сетевые пакеты — это последовательность байт. Зная позицию и длину поля в байтах, можно прочитать или изменить любое поле заголовка вручную.</p>
|
||||
</div>
|
||||
|
||||
<div style="${B}">
|
||||
<h3 style="${H3}">Двоичная и шестнадцатеричная запись</h3>
|
||||
<p>Один байт записывают в разных системах счисления:</p>
|
||||
<div style="${DARK}">
|
||||
<div>Десятичная: 184</div>
|
||||
<div>Двоичная: 1011 1000</div>
|
||||
<div>Шестнадцатеричная: 0xB8</div>
|
||||
</div>
|
||||
<p>Шестнадцатеричная запись удобна: каждая цифра (0–F) точно соответствует 4 битам.</p>
|
||||
</div>
|
||||
|
||||
<div style="${B}">
|
||||
<h3 style="${H3}">Нумерация битов</h3>
|
||||
<p>Биты нумеруются справа налево: бит 7 — старший (вес 128), бит 0 — младший (вес 1).</p>
|
||||
<table style="border-collapse:collapse;font-family:'Courier New',monospace;font-size:0.9em;margin:10px 0;">
|
||||
<tr>
|
||||
<td style="padding:6px 10px 6px 0;color:#1976D2;font-weight:bold;">Бит:</td>
|
||||
${[7,6,5,4,3,2,1,0].map(n =>
|
||||
`<td style="padding:6px 8px;text-align:center;color:#d4d4d4;background:#1e1e1e;">${n}</td>`
|
||||
).join('')}
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:6px 10px 6px 0;color:#1976D2;font-weight:bold;">Вес:</td>
|
||||
${[128,64,32,16,8,4,2,1].map(n =>
|
||||
`<td style="padding:6px 8px;text-align:center;color:#ce9178;background:#1e1e1e;">${n}</td>`
|
||||
).join('')}
|
||||
</tr>
|
||||
</table>
|
||||
<p>Чтобы получить значение байта, сложите веса всех битов равных 1.<br>
|
||||
Например, биты 7, 4, 3 = 128 + 16 + 8 = <strong>152 = 0x98</strong>.</p>
|
||||
</div>
|
||||
|
||||
<div style="${B}">
|
||||
<h3 style="${H3}">Почему это важно?</h3>
|
||||
<p>В заголовках протоколов многие поля занимают несколько битов. Например, флаги TCP — каждый флаг один бит. Умение работать с отдельными битами — базовый навык при анализе сетевых пакетов.</p>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
|
||||
'mac-address': `
|
||||
<div style="max-width:1000px;margin:0 auto;line-height:1.6;">
|
||||
<h2 style="${H2}">MAC-адрес</h2>
|
||||
|
||||
<div style="${B}">
|
||||
<h3 style="${H3}">Что такое MAC-адрес?</h3>
|
||||
<p><strong>MAC-адрес</strong> (Media Access Control) — идентификатор сетевого интерфейса на канальном уровне. Используется в Ethernet и Wi-Fi для адресации внутри одной сети. Длина: <strong>6 байт (48 бит)</strong>.</p>
|
||||
</div>
|
||||
|
||||
<div style="${B}">
|
||||
<h3 style="${H3}">Структура MAC-адреса</h3>
|
||||
<div style="${DARK}">
|
||||
<div>00:50:56 : AB:CD:EF</div>
|
||||
<div>└───┬───┘ └───┬───┘</div>
|
||||
<div> OUI Номер интерфейса</div>
|
||||
<div>(производитель) (назначает производитель)</div>
|
||||
</div>
|
||||
<p>Первые 3 байта — <strong>OUI</strong>, выдаются IEEE производителям. Примеры: <code>00:00:0C</code> — Cisco, <code>00:02:B3</code> — Intel.</p>
|
||||
</div>
|
||||
|
||||
<div style="${B}">
|
||||
<h3 style="${H3}">Типы MAC-адресов</h3>
|
||||
<table style="${TBL}">
|
||||
<tr><th style="${TH}">Тип</th><th style="${TH}">Бит 0 первого байта</th><th style="${TH}">Пример</th></tr>
|
||||
<tr><td style="${TD}">Unicast</td><td style="${TD}">0 (чётный байт)</td><td style="${TD}"><code>00:1A:2B:3C:4D:5E</code></td></tr>
|
||||
<tr><td style="${TD2}">Multicast</td><td style="${TD2}">1 (нечётный байт)</td><td style="${TD2}"><code>01:00:5E:00:00:01</code></td></tr>
|
||||
<tr><td style="${TD}">Broadcast</td><td style="${TD}">все биты = 1</td><td style="${TD}"><code>FF:FF:FF:FF:FF:FF</code></td></tr>
|
||||
</table>
|
||||
<p style="margin-top:12px;"><strong>Бит 1 первого байта</strong>: 0 — назначен производителем, 1 — локально администратором.</p>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
|
||||
'ethernet-frame': `
|
||||
<div style="max-width:1000px;margin:0 auto;line-height:1.6;">
|
||||
<h2 style="${H2}">Структура Ethernet-кадра</h2>
|
||||
|
||||
<div style="${B}">
|
||||
<h3 style="${H3}">Ethernet — основа проводных сетей</h3>
|
||||
<p>Ethernet — самая популярная технология проводных сетей. Данные передаются в виде <strong>кадров (frames)</strong>. На практике используется стандарт <strong>Ethernet II</strong>.</p>
|
||||
</div>
|
||||
|
||||
<div style="${B}">
|
||||
<h3 style="${H3}">Структура кадра Ethernet II</h3>
|
||||
<div style="${DARK}">
|
||||
<div>+----------+----------+----------+------------------+------+</div>
|
||||
<div>| Dst MAC | Src MAC |EtherType | Data | FCS |</div>
|
||||
<div>| 6 байт | 6 байт | 2 байта | 46-1500 байт | 4 б |</div>
|
||||
<div>+----------+----------+----------+------------------+------+</div>
|
||||
</div>
|
||||
<p><strong>Dst MAC</strong> — получатель. <strong>Src MAC</strong> — отправитель.</p>
|
||||
<p><strong>EtherType</strong> — тип протокола: <code>0x0800</code> — IPv4, <code>0x0806</code> — ARP, <code>0x86DD</code> — IPv6.</p>
|
||||
<p><strong>FCS</strong> (Frame Check Sequence) — контрольная сумма, рассчитывается автоматически.</p>
|
||||
</div>
|
||||
|
||||
<div style="${B}">
|
||||
<h3 style="${H3}">MTU</h3>
|
||||
<p>Максимальный размер данных — <strong>1500 байт (MTU)</strong>. Если IP-пакет больше MTU, он фрагментируется. Минимум данных — 46 байт.</p>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
|
||||
'ipv4-header': `
|
||||
<div style="max-width:1000px;margin:0 auto;line-height:1.6;">
|
||||
<h2 style="${H2}">IPv4 — протокол сетевого уровня</h2>
|
||||
|
||||
<div style="${B}">
|
||||
<h3 style="${H3}">Что делает IP?</h3>
|
||||
<p>IP обеспечивает доставку пакетов через составную сеть — объединяет разные технологии канального уровня и выполняет <strong>маршрутизацию</strong>. Работает без гарантии доставки и без соединения.</p>
|
||||
</div>
|
||||
|
||||
<div style="${B}">
|
||||
<h3 style="${H3}">Структура заголовка IPv4 (минимум 20 байт)</h3>
|
||||
<div style="${DARK}">
|
||||
<div>Байт 0: Version (4 бит) + IHL (4 бит)</div>
|
||||
<div>Байт 1: DSCP/ECN</div>
|
||||
<div>Байты 2–3: Total Length — полная длина пакета</div>
|
||||
<div>Байты 4–5: Identification — ID для сборки фрагментов</div>
|
||||
<div>Байты 6–7: Flags (3 бит) + Fragment Offset (13 бит)</div>
|
||||
<div>Байт 8: TTL — уменьшается на 1 на каждом маршрутизаторе</div>
|
||||
<div>Байт 9: Protocol — TCP=6, UDP=17, ICMP=1</div>
|
||||
<div>Байты 10–11: Header Checksum (пересчитывается автоматически)</div>
|
||||
<div>Байты 12–15: Source IP</div>
|
||||
<div>Байты 16–19: Destination IP</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="${B}">
|
||||
<h3 style="${H3}">Специальные диапазоны IPv4</h3>
|
||||
<table style="${TBL}">
|
||||
<tr><th style="${TH}">Диапазон</th><th style="${TH}">Назначение</th></tr>
|
||||
<tr><td style="${TD}"><code>10.x.x.x</code>, <code>172.16-31.x.x</code>, <code>192.168.x.x</code></td><td style="${TD}">Частные (не маршрутизируются в интернете)</td></tr>
|
||||
<tr><td style="${TD2}"><code>127.0.0.1</code></td><td style="${TD2}">Loopback (localhost)</td></tr>
|
||||
<tr><td style="${TD}"><code>169.254.x.x</code></td><td style="${TD}">Link-local (без DHCP)</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
|
||||
'ipv4-ttl': `
|
||||
<div style="max-width:1000px;margin:0 auto;line-height:1.6;">
|
||||
<h2 style="${H2}">TTL — время жизни пакета</h2>
|
||||
|
||||
<div style="${B}">
|
||||
<h3 style="${H3}">Зачем нужен TTL?</h3>
|
||||
<p><strong>TTL (Time To Live)</strong> — счётчик прыжков. Каждый маршрутизатор уменьшает TTL на 1. При TTL = 0 пакет отбрасывается и отправителю уходит ICMP «Time Exceeded». Защита от бесконечной петли маршрутизации.</p>
|
||||
</div>
|
||||
|
||||
<div style="${B}">
|
||||
<h3 style="${H3}">Стандартные значения TTL</h3>
|
||||
<table style="${TBL}">
|
||||
<tr><th style="${TH}">ОС / устройство</th><th style="${TH}">TTL</th><th style="${TH}">Hex</th></tr>
|
||||
<tr><td style="${TD}">Linux, macOS, Android</td><td style="${TD}">64</td><td style="${TD}">0x40</td></tr>
|
||||
<tr><td style="${TD2}">Windows</td><td style="${TD2}">128</td><td style="${TD2}">0x80</td></tr>
|
||||
<tr><td style="${TD}">Cisco IOS, сетевые устройства</td><td style="${TD}">255</td><td style="${TD}">0xFF</td></tr>
|
||||
</table>
|
||||
<p style="margin-top:10px;">По TTL в ответе можно примерно определить ОС удалённого хоста.</p>
|
||||
</div>
|
||||
|
||||
<div style="${B}">
|
||||
<h3 style="${H3}">traceroute и TTL</h3>
|
||||
<p><code>traceroute</code> отправляет пакеты с TTL=1, 2, 3… Каждый маршрутизатор отвечает ICMP «Time Exceeded» — так строится полная цепочка узлов до цели.</p>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
|
||||
'ipv4-fragmentation': `
|
||||
<div style="max-width:1000px;margin:0 auto;line-height:1.6;">
|
||||
<h2 style="${H2}">Фрагментация IPv4</h2>
|
||||
|
||||
<div style="${B}">
|
||||
<h3 style="${H3}">MTU и фрагментация</h3>
|
||||
<p><strong>MTU</strong> — максимальный размер данных кадра. Для Ethernet это 1500 байт. Если IP-пакет больше MTU, маршрутизатор <strong>фрагментирует</strong> его на части. Получатель собирает фрагменты по Identification, Flags и Fragment Offset.</p>
|
||||
</div>
|
||||
|
||||
<div style="${B}">
|
||||
<h3 style="${H3}">Поля байтов 6–7 (Flags + Fragment Offset)</h3>
|
||||
<div style="${DARK}">
|
||||
<div>Бит 15: Reserved (всегда 0)</div>
|
||||
<div>Бит 14: DF — Don't Fragment (1 = запрет фрагментации)</div>
|
||||
<div>Бит 13: MF — More Fragments (1 = есть ещё фрагменты)</div>
|
||||
<div>Биты 12–0: Fragment Offset (смещение в 8-байтовых блоках)</div>
|
||||
</div>
|
||||
<table style="${TBL}">
|
||||
<tr><th style="${TH}">Ситуация</th><th style="${TH}">Байт 6</th><th style="${TH}">Байт 7</th></tr>
|
||||
<tr><td style="${TD}">DF=1, не фрагментировать</td><td style="${TD}">0x40</td><td style="${TD}">0x00</td></tr>
|
||||
<tr><td style="${TD2}">Первый фрагмент (MF=1)</td><td style="${TD2}">0x20</td><td style="${TD2}">0x00</td></tr>
|
||||
<tr><td style="${TD}">Последний фрагмент, Offset=185</td><td style="${TD}">0x00</td><td style="${TD}">0xB9</td></tr>
|
||||
</table>
|
||||
<p style="margin-top:10px;">Fragment Offset = байты ÷ 8. Смещение 1480 байт: 1480 ÷ 8 = <strong>185 = 0xB9</strong>.</p>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
|
||||
'tcp-header': `
|
||||
<div style="max-width:1000px;margin:0 auto;line-height:1.6;">
|
||||
<h2 style="${H2}">TCP — протокол надёжной передачи</h2>
|
||||
|
||||
<div style="${B}">
|
||||
<h3 style="${H3}">Что делает TCP?</h3>
|
||||
<p>TCP обеспечивает надёжность: <strong>подтверждения (ACK)</strong>, повторную отправку при потере, сохранение порядка. TCP нумерует не сегменты, а <strong>байты потока</strong> — Sequence Number это номер первого байта в сегменте.</p>
|
||||
</div>
|
||||
|
||||
<div style="${B}">
|
||||
<h3 style="${H3}">Структура заголовка TCP (минимум 20 байт)</h3>
|
||||
<div style="${DARK}">
|
||||
<div>Байты 0–1: Source Port</div>
|
||||
<div>Байты 2–3: Destination Port</div>
|
||||
<div>Байты 4–7: Sequence Number — номер первого байта данных</div>
|
||||
<div>Байты 8–11: Acknowledgment Number — следующий ожидаемый байт</div>
|
||||
<div>Байт 12: Data Offset (длина заголовка, мин. 5 × 4 = 20 байт)</div>
|
||||
<div>Байт 13: Flags — SYN, ACK, FIN, RST, PSH...</div>
|
||||
<div>Байты 14–15: Window Size — размер буфера приёма</div>
|
||||
<div>Байты 16–17: Checksum (авто)</div>
|
||||
<div>Байты 18–19: Urgent Pointer</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="${B}">
|
||||
<h3 style="${H3}">Популярные TCP-порты</h3>
|
||||
<table style="${TBL}">
|
||||
<tr><th style="${TH}">Порт</th><th style="${TH}">Протокол</th><th style="${TH}">Порт</th><th style="${TH}">Протокол</th></tr>
|
||||
<tr><td style="${TD}">22</td><td style="${TD}">SSH</td><td style="${TD}">443</td><td style="${TD}">HTTPS</td></tr>
|
||||
<tr><td style="${TD2}">25</td><td style="${TD2}">SMTP</td><td style="${TD2}">3306</td><td style="${TD2}">MySQL</td></tr>
|
||||
<tr><td style="${TD}">80</td><td style="${TD}">HTTP</td><td style="${TD}">5432</td><td style="${TD}">PostgreSQL</td></tr>
|
||||
</table>
|
||||
<p style="margin-top:10px;">Ephemeral-порты клиента: 49152–65535.</p>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
|
||||
'tcp-flags': `
|
||||
<div style="max-width:1000px;margin:0 auto;line-height:1.6;">
|
||||
<h2 style="${H2}">TCP-флаги и трёхстороннее рукопожатие</h2>
|
||||
|
||||
<div style="${B}">
|
||||
<h3 style="${H3}">Байт флагов (байт 13 заголовка)</h3>
|
||||
<table style="${TBL}">
|
||||
<tr><th style="${TH}">Бит</th><th style="${TH}">Флаг</th><th style="${TH}">Назначение</th></tr>
|
||||
<tr><td style="${TD}">4</td><td style="${TD}"><strong>ACK</strong></td><td style="${TD}">Поле Acknowledgment Number значимо</td></tr>
|
||||
<tr><td style="${TD2}">3</td><td style="${TD2}">PSH</td><td style="${TD2}">Доставить данные приложению немедленно</td></tr>
|
||||
<tr><td style="${TD}">2</td><td style="${TD}"><strong>RST</strong></td><td style="${TD}">Немедленный сброс соединения</td></tr>
|
||||
<tr><td style="${TD2}">1</td><td style="${TD2}"><strong>SYN</strong></td><td style="${TD2}">Установка соединения</td></tr>
|
||||
<tr><td style="${TD}">0</td><td style="${TD}"><strong>FIN</strong></td><td style="${TD}">Завершение соединения</td></tr>
|
||||
</table>
|
||||
<p style="margin-top:10px;">Несколько флагов через OR: SYN+ACK = 0x02 | 0x10 = <strong>0x12</strong>.</p>
|
||||
</div>
|
||||
|
||||
<div style="${B}">
|
||||
<h3 style="${H3}">Трёхстороннее рукопожатие (3-Way Handshake)</h3>
|
||||
<div style="${DARK}">
|
||||
<div><span style="color:#4ec9b0;">Клиент → Сервер:</span> <span style="color:#ce9178;">SYN</span> Seq=X</div>
|
||||
<div><span style="color:#4ec9b0;">Сервер → Клиент:</span> <span style="color:#ce9178;">SYN + ACK</span> Seq=Y, Ack=X+1</div>
|
||||
<div><span style="color:#4ec9b0;">Клиент → Сервер:</span> <span style="color:#ce9178;">ACK</span> Seq=X+1, Ack=Y+1</div>
|
||||
<div style="color:#555;"> ↑ соединение установлено</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
|
||||
'udp-header': `
|
||||
<div style="max-width:1000px;margin:0 auto;line-height:1.6;">
|
||||
<h2 style="${H2}">UDP — быстрый протокол без гарантий</h2>
|
||||
|
||||
<div style="${B}">
|
||||
<h3 style="${H3}">Что такое UDP?</h3>
|
||||
<p>UDP — транспортный протокол без соединения. Не гарантирует доставку и порядок. Заголовок всего <strong>8 байт</strong>, нет накладных расходов на рукопожатие. Применяется в DNS, DHCP, NTP, VoIP, онлайн-играх.</p>
|
||||
</div>
|
||||
|
||||
<div style="${B}">
|
||||
<h3 style="${H3}">Структура заголовка UDP (8 байт)</h3>
|
||||
<div style="${DARK}">
|
||||
<div>Байты 0–1: Source Port</div>
|
||||
<div>Байты 2–3: Destination Port</div>
|
||||
<div>Байты 4–5: Length — длина датаграммы (минимум 8)</div>
|
||||
<div>Байты 6–7: Checksum (авто, в IPv4 необязателен)</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="${B}">
|
||||
<h3 style="${H3}">Популярные UDP-порты</h3>
|
||||
<table style="${TBL}">
|
||||
<tr><th style="${TH}">Порт</th><th style="${TH}">Протокол</th><th style="${TH}">Порт</th><th style="${TH}">Протокол</th></tr>
|
||||
<tr><td style="${TD}">53</td><td style="${TD}">DNS</td><td style="${TD}">161</td><td style="${TD}">SNMP</td></tr>
|
||||
<tr><td style="${TD2}">67/68</td><td style="${TD2}">DHCP</td><td style="${TD2}">514</td><td style="${TD2}">Syslog</td></tr>
|
||||
<tr><td style="${TD}">123</td><td style="${TD}">NTP</td><td style="${TD}">5353</td><td style="${TD}">mDNS</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
|
||||
'http-get-request': `
|
||||
<div style="max-width:1000px;margin:0 auto;line-height:1.6;">
|
||||
<h2 style="${H2}">HTTP — протокол передачи гипертекста</h2>
|
||||
|
||||
<div style="${B}">
|
||||
<h3 style="${H3}">Что такое HTTP?</h3>
|
||||
<p>HTTP — <strong>текстовый</strong> протокол прикладного уровня. Работает поверх TCP, порт 80 (HTTPS — 443). Режим работы: <strong>запрос–ответ</strong>. В отличие от Ethernet/IP — не бинарный, а текстовый.</p>
|
||||
</div>
|
||||
|
||||
<div style="${B}">
|
||||
<h3 style="${H3}">Структура HTTP-запроса</h3>
|
||||
<div style="${DARK}">
|
||||
<div><span style="color:#ce9178;">МЕТОД /путь HTTP/версия</span> <span style="color:#555;">← строка запроса (обязательна)</span></div>
|
||||
<div><span style="color:#9cdcfe;">Заголовок1:</span> значение <span style="color:#555;">← заголовки</span></div>
|
||||
<div><span style="color:#9cdcfe;">Заголовок2:</span> значение</div>
|
||||
<div style="color:#555;"> ← пустая строка (ОБЯЗАТЕЛЬНА!)</div>
|
||||
<div><span style="color:#ce9178;">[тело запроса]</span> <span style="color:#555;">← для GET обычно отсутствует</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="${B}">
|
||||
<h3 style="${H3}">Основные методы</h3>
|
||||
<table style="${TBL}">
|
||||
<tr><th style="${TH}">Метод</th><th style="${TH}">Назначение</th><th style="${TH}">Тело</th></tr>
|
||||
<tr><td style="${TD}"><strong style="color:#4caf50">GET</strong></td><td style="${TD}">Получить ресурс</td><td style="${TD}">Нет</td></tr>
|
||||
<tr><td style="${TD2}"><strong style="color:#2196f3">POST</strong></td><td style="${TD2}">Создать / передать данные</td><td style="${TD2}">Да</td></tr>
|
||||
<tr><td style="${TD}"><strong style="color:#ff9800">PUT</strong></td><td style="${TD}">Заменить ресурс целиком</td><td style="${TD}">Да</td></tr>
|
||||
<tr><td style="${TD2}"><strong style="color:#f44336">DELETE</strong></td><td style="${TD2}">Удалить ресурс</td><td style="${TD2}">Нет</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div style="${WARN}">
|
||||
<h3 style="color:#F57F17;margin-top:0;">⚠ Пустая строка после заголовков — обязательна!</h3>
|
||||
<p style="margin:0;">Нажмите Enter <strong>дважды</strong> после последнего заголовка.</p>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
|
||||
'http-post-request': `
|
||||
<div style="max-width:1000px;margin:0 auto;line-height:1.6;">
|
||||
<h2 style="${H2}">HTTP POST — отправка данных</h2>
|
||||
|
||||
<div style="${B}">
|
||||
<h3 style="${H3}">POST vs GET</h3>
|
||||
<p>GET получает данные. POST отправляет данные в <strong>теле запроса</strong> — для создания ресурсов, передачи форм, авторизации.</p>
|
||||
</div>
|
||||
|
||||
<div style="${B}">
|
||||
<h3 style="${H3}">Структура POST-запроса</h3>
|
||||
<div style="${DARK}">
|
||||
<div><span style="color:#4ec9b0;">POST</span> <span style="color:#ce9178;">/api/users</span> <span style="color:#569cd6;">HTTP/1.1</span></div>
|
||||
<div><span style="color:#9cdcfe;">Host:</span> api.example.com</div>
|
||||
<div><span style="color:#9cdcfe;">Content-Type:</span> application/json</div>
|
||||
<div><span style="color:#9cdcfe;">Content-Length:</span> 18</div>
|
||||
<div style="color:#555;"> ← пустая строка</div>
|
||||
<div><span style="color:#ce9178;">{"name":"Student"}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="${B}">
|
||||
<h3 style="${H3}">Заголовки при наличии тела</h3>
|
||||
<p><strong>Content-Type</strong> — формат тела: <code>application/json</code>, <code>application/x-www-form-urlencoded</code>.</p>
|
||||
<p><strong>Content-Length</strong> — размер тела в <strong>байтах</strong>. Для ASCII 1 символ = 1 байт.</p>
|
||||
</div>
|
||||
|
||||
<div style="${B}">
|
||||
<h3 style="${H3}">Коды ответа HTTP</h3>
|
||||
<table style="${TBL}">
|
||||
<tr><th style="${TH}">Код</th><th style="${TH}">Смысл</th></tr>
|
||||
<tr><td style="${TD}">200 OK</td><td style="${TD}">Успешно</td></tr>
|
||||
<tr><td style="${TD2}">201 Created</td><td style="${TD2}">Ресурс создан (ответ на POST)</td></tr>
|
||||
<tr><td style="${TD}">400 Bad Request</td><td style="${TD}">Ошибка в запросе</td></tr>
|
||||
<tr><td style="${TD2}">404 Not Found</td><td style="${TD2}">Ресурс не найден</td></tr>
|
||||
<tr><td style="${TD}">500 Internal Server Error</td><td style="${TD}">Ошибка сервера</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
|
||||
'dns-header': `
|
||||
<div style="max-width:1000px;margin:0 auto;line-height:1.6;">
|
||||
<h2 style="${H2}">DNS — система доменных имён</h2>
|
||||
|
||||
<div style="${B}">
|
||||
<h3 style="${H3}">Зачем нужен DNS?</h3>
|
||||
<p>DNS переводит имена в IP: <code>www.yandex.ru</code> → <code>77.88.55.66</code>. Бинарный протокол, работает поверх <strong>UDP, порт 53</strong>. При ответах >512 байт использует TCP.</p>
|
||||
</div>
|
||||
|
||||
<div style="${B}">
|
||||
<h3 style="${H3}">Структура заголовка DNS (12 байт)</h3>
|
||||
<div style="${DARK}">
|
||||
<div>Байты 0–1: Transaction ID — одинаковый в запросе и ответе</div>
|
||||
<div>Байты 2–3: Flags</div>
|
||||
<div>Байты 4–5: QDCOUNT — число вопросов</div>
|
||||
<div>Байты 6–7: ANCOUNT — число ответов (в запросе = 0)</div>
|
||||
<div>Байты 8–9: NSCOUNT — авторитативные серверы</div>
|
||||
<div>Байты 10–11: ARCOUNT — дополнительные записи</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="${B}">
|
||||
<h3 style="${H3}">Поле Flags (байты 2–3)</h3>
|
||||
<table style="${TBL}">
|
||||
<tr><th style="${TH}">Биты</th><th style="${TH}">Поле</th><th style="${TH}">Значение</th></tr>
|
||||
<tr><td style="${TD}">15</td><td style="${TD}">QR</td><td style="${TD}">0 = запрос, 1 = ответ</td></tr>
|
||||
<tr><td style="${TD2}">8</td><td style="${TD2}"><strong>RD</strong></td><td style="${TD2}">1 = просить рекурсию у сервера</td></tr>
|
||||
<tr><td style="${TD}">3–0</td><td style="${TD}">RCODE</td><td style="${TD}">0=OK, 3=NXDOMAIN</td></tr>
|
||||
</table>
|
||||
<p style="margin-top:10px;"><strong>0x0100</strong> = рекурсивный запрос (RD=1). <strong>0x0000</strong> = итеративный.</p>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
|
||||
'dns-query': `
|
||||
<div style="max-width:1000px;margin:0 auto;line-height:1.6;">
|
||||
<h2 style="${H2}">DNS: кодирование доменного имени</h2>
|
||||
|
||||
<div style="${B}">
|
||||
<h3 style="${H3}">Секция Question</h3>
|
||||
<p>После 12-байтного заголовка: <strong>QNAME + QTYPE + QCLASS</strong>.</p>
|
||||
</div>
|
||||
|
||||
<div style="${B}">
|
||||
<h3 style="${H3}">Кодирование QNAME</h3>
|
||||
<p>Каждая метка: 1 байт длины + байты символов. В конце — нулевой байт.</p>
|
||||
<div style="${DARK}">
|
||||
<div>example.com →</div>
|
||||
<div> 07 65 78 61 6D 70 6C 65 (длина 7 + "example")</div>
|
||||
<div> 03 63 6F 6D (длина 3 + "com")</div>
|
||||
<div> 00 (конец имени)</div>
|
||||
<div>Итого: 13 байт</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="${B}">
|
||||
<h3 style="${H3}">QTYPE и QCLASS</h3>
|
||||
<table style="${TBL}">
|
||||
<tr><th style="${TH}">QTYPE</th><th style="${TH}">Тип</th><th style="${TH}">Что запрашивает</th></tr>
|
||||
<tr><td style="${TD}">1</td><td style="${TD}">A</td><td style="${TD}">IPv4-адрес</td></tr>
|
||||
<tr><td style="${TD2}">28</td><td style="${TD2}">AAAA</td><td style="${TD2}">IPv6-адрес</td></tr>
|
||||
<tr><td style="${TD}">15</td><td style="${TD}">MX</td><td style="${TD}">Почтовый сервер</td></tr>
|
||||
<tr><td style="${TD2}">2</td><td style="${TD2}">NS</td><td style="${TD2}">Авторитативный DNS-сервер</td></tr>
|
||||
<tr><td style="${TD}">5</td><td style="${TD}">CNAME</td><td style="${TD}">Псевдоним</td></tr>
|
||||
<tr><td style="${TD2}">16</td><td style="${TD2}">TXT</td><td style="${TD2}">Текстовая запись</td></tr>
|
||||
</table>
|
||||
<p style="margin-top:10px;"><strong>QCLASS = 1</strong> = IN (Internet) — единственный используемый класс.</p>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
|
||||
};
|
||||
@ -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;
|
||||
}
|
||||
|
||||
65
src/lib/utils/protocols/dns.js
Normal file
65
src/lib/utils/protocols/dns.js
Normal file
@ -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)';
|
||||
}
|
||||
@ -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) => {
|
||||
|
||||
63
src/lib/utils/protocols/ipv4.js
Normal file
63
src/lib/utils/protocols/ipv4.js
Normal file
@ -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}`;
|
||||
}
|
||||
80
src/lib/utils/protocols/tcp.js
Normal file
80
src/lib/utils/protocols/tcp.js
Normal file
@ -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`;
|
||||
}
|
||||
56
src/lib/utils/protocols/udp.js
Normal file
56
src/lib/utils/protocols/udp.js
Normal file
@ -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})`;
|
||||
}
|
||||
@ -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;
|
||||
},
|
||||
};
|
||||
365
src/lib/utils/taskGenerator.js
Normal file
365
src/lib/utils/taskGenerator.js
Normal file
@ -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;i<lines.length;i++){
|
||||
if(!lines[i].trim()) break;
|
||||
const c=lines[i].indexOf(':');
|
||||
if(c>0) 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;i<lines.length;i++){
|
||||
if(!lines[i].trim()){bs=i+1;break;}
|
||||
const c=lines[i].indexOf(':');
|
||||
if(c>0) 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{};
|
||||
}
|
||||
}
|
||||
@ -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';
|
||||
}
|
||||
</script>
|
||||
|
||||
<h1>Обучение сетевым протоколам</h1>
|
||||
<div class="page">
|
||||
|
||||
<div class="lessons-list">
|
||||
{#each lessons as lesson}
|
||||
<a href="/lessons/{lesson.slug}" class="lesson-card">
|
||||
<!--шапка-->
|
||||
<header class="page-header">
|
||||
<h1>Обучение сетевым протоколам</h1>
|
||||
<p class="subtitle">Изучайте структуру сетевых пакетов через интерактивные задания</p>
|
||||
{#if completedCount > 0}
|
||||
<div class="progress-summary">
|
||||
✅ Пройдено уроков: <strong>{completedCount} / {lessons.length}</strong>
|
||||
</div>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<!--поиск и фильтры-->
|
||||
<div class="controls">
|
||||
|
||||
<!--поиск-->
|
||||
<div class="search-wrap">
|
||||
<span class="search-icon">🔍</span>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={searchQuery}
|
||||
placeholder="Поиск по названию..."
|
||||
class="search-input"
|
||||
/>
|
||||
{#if searchQuery}
|
||||
<button class="clear-btn" on:click={() => searchQuery = ''}>✕</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!--фильтр по категории-->
|
||||
<div class="filter-group">
|
||||
<span class="filter-label">Протокол:</span>
|
||||
<div class="tags">
|
||||
{#each categories as cat}
|
||||
<button
|
||||
class="tag"
|
||||
class:active={activeCategory === cat}
|
||||
on:click={() => activeCategory = cat}
|
||||
>{cat}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!--фильтр по сложности-->
|
||||
<div class="filter-group">
|
||||
<span class="filter-label">Сложность:</span>
|
||||
<div class="tags">
|
||||
{#each difficulties as diff}
|
||||
<button
|
||||
class="tag diff"
|
||||
class:active={activeDifficulty === diff}
|
||||
style={activeDifficulty === diff && diff !== 'Все'
|
||||
? `background:${difficultyColor(diff)};color:white;border-color:${difficultyColor(diff)};`
|
||||
: ''}
|
||||
on:click={() => activeDifficulty = diff}
|
||||
>{diff}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!--сброс-->
|
||||
{#if searchQuery || activeCategory !== 'Все' || activeDifficulty !== 'Все'}
|
||||
<button class="reset-btn" on:click={clearFilters}>Сбросить фильтры</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!--счётчик результатов-->
|
||||
<div class="results-count">
|
||||
{#if filtered.length === lessons.length}
|
||||
Всего уроков: {lessons.length}
|
||||
{:else}
|
||||
Найдено: {filtered.length} из {lessons.length}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!--список уроков-->
|
||||
<div class="lessons-list">
|
||||
{#each filtered as lesson (lesson.id)}
|
||||
<a href="/lessons/{lesson.slug}" class="lesson-card" class:done={progress[lesson.id]}>
|
||||
<div class="lesson-header">
|
||||
<h3>{lesson.title}</h3>
|
||||
{#if progress[lesson.id]}
|
||||
<span class="completed-badge">✅ Пройден</span>
|
||||
{/if}
|
||||
</div>
|
||||
<p>{lesson.objective}</p>
|
||||
<div class="lesson-footer">
|
||||
<span class="difficulty">{lesson.difficulty}</span>
|
||||
<span class="category">{lesson.category}</span>
|
||||
<span class="tag-badge category">{lesson.category}</span>
|
||||
<span class="tag-badge difficulty" style="background:{difficultyColor(lesson.difficulty)}20;color:{difficultyColor(lesson.difficulty)};border-color:{difficultyColor(lesson.difficulty)}40;">
|
||||
{lesson.difficulty}
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
{:else}
|
||||
<div class="empty">
|
||||
Ничего не найдено. <button class="link-btn" on:click={clearFilters}>Сбросить фильтры</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<style>
|
||||
h1 {
|
||||
text-align: center;
|
||||
color: #2196F3;
|
||||
margin-bottom: 30px;
|
||||
.page {
|
||||
max-width: 860px;
|
||||
margin: 0 auto;
|
||||
padding: 30px 20px 60px;
|
||||
}
|
||||
|
||||
/*шапка*/
|
||||
.page-header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
color: #2196F3;
|
||||
margin: 0 0 8px;
|
||||
font-size: 1.8em;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #777;
|
||||
margin: 0 0 12px;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
.progress-summary {
|
||||
display: inline-block;
|
||||
background: #e8f5e9;
|
||||
color: #2e7d32;
|
||||
padding: 6px 16px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
/*controls*/
|
||||
.controls {
|
||||
background: #f9f9f9;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 12px;
|
||||
padding: 18px 20px;
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
/*поиск*/
|
||||
.search-wrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
font-size: 0.9em;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: 9px 36px 9px 36px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
font-size: 0.95em;
|
||||
background: white;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.search-input:focus { border-color: #2196F3; }
|
||||
|
||||
.clear-btn {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: #aaa;
|
||||
font-size: 0.9em;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
.clear-btn:hover { color: #555; }
|
||||
|
||||
/*группа фильтров*/
|
||||
.filter-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
font-size: 0.82em;
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
white-space: nowrap;
|
||||
min-width: 72px;
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
padding: 4px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 20px;
|
||||
background: white;
|
||||
font-size: 0.85em;
|
||||
cursor: pointer;
|
||||
color: #555;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.tag:hover { border-color: #2196F3; color: #2196F3; }
|
||||
.tag.active {
|
||||
background: #2196F3;
|
||||
color: white;
|
||||
border-color: #2196F3;
|
||||
}
|
||||
|
||||
.reset-btn {
|
||||
align-self: flex-start;
|
||||
background: none;
|
||||
border: 1px solid #f44336;
|
||||
color: #f44336;
|
||||
padding: 5px 14px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85em;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.reset-btn:hover { background: #f44336; color: white; }
|
||||
|
||||
/* счётчик*/
|
||||
.results-count {
|
||||
font-size: 0.82em;
|
||||
color: #999;
|
||||
margin-bottom: 12px;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
/*карточки*/
|
||||
.lessons-list {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.lesson-card {
|
||||
display: block;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin: 16px 0;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 10px;
|
||||
padding: 18px 20px;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: all 0.2s;
|
||||
background: white;
|
||||
transition: all 0.18s;
|
||||
}
|
||||
|
||||
.lesson-card:hover {
|
||||
border-color: #2196F3;
|
||||
box-shadow: 0 2px 8px rgba(33, 150, 243, 0.2);
|
||||
box-shadow: 0 3px 10px rgba(33,150,243,0.15);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.lesson-card.done {
|
||||
border-left: 4px solid #4caf50;
|
||||
}
|
||||
|
||||
.lesson-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.lesson-card h3 {
|
||||
margin: 0;
|
||||
color: #2196F3;
|
||||
color: #1565c0;
|
||||
font-size: 1em;
|
||||
flex-grow: 1;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.completed-badge {
|
||||
background: #E8F5E8;
|
||||
color: #2E7D32;
|
||||
padding: 4px 8px;
|
||||
background: #e8f5e9;
|
||||
color: #2e7d32;
|
||||
padding: 3px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.8em;
|
||||
font-size: 0.78em;
|
||||
font-weight: bold;
|
||||
white-space: nowrap;
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
.lesson-footer {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.difficulty, .category {
|
||||
padding: 4px 8px;
|
||||
.tag-badge {
|
||||
padding: 3px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.8em;
|
||||
font-size: 0.78em;
|
||||
font-weight: 500;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.difficulty {
|
||||
background: #E3F2FD;
|
||||
color: #1976D2;
|
||||
.tag-badge.category {
|
||||
background: #ede7f6;
|
||||
color: #6a1b9a;
|
||||
}
|
||||
|
||||
.category {
|
||||
background: #F3E5F5;
|
||||
color: #7B1FA2;
|
||||
/*пусто*/
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #999;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
.link-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #2196F3;
|
||||
cursor: pointer;
|
||||
font-size: inherit;
|
||||
text-decoration: underline;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.filter-group { flex-direction: column; align-items: flex-start; }
|
||||
.filter-label { min-width: unset; }
|
||||
}
|
||||
</style>
|
||||
69
src/routes/api/hint/+server.js
Normal file
69
src/routes/api/hint/+server.js
Normal file
@ -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)}
|
||||
|
||||
Дай подсказку — как двигаться к решению, НЕ называя конечный ответ прямо.
|
||||
Можно намекнуть на нужный байт, бит или концепцию.`;
|
||||
}
|
||||
@ -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);
|
||||
let aiMessage = null; //текст от модели
|
||||
let aiLoading = false; //ждём ответ
|
||||
let aiError = null; //сообщение об ошибке сети
|
||||
|
||||
if (lesson) {
|
||||
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 || [];
|
||||
}
|
||||
}
|
||||
});
|
||||
additionalComponents = getAdditionalComponents(lesson.additionalComponents ?? []);
|
||||
readOnlyRanges = lesson.readOnlyRanges ?? [];
|
||||
|
||||
function getLessonComponent(componentName) {
|
||||
switch(componentName) {
|
||||
case 'BitEditor':
|
||||
return BitEditor;
|
||||
case 'HexEditor':
|
||||
return HexEditor;
|
||||
default:
|
||||
return null;
|
||||
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);
|
||||
//обработчики
|
||||
function handleBufferChange(newBuffer) {
|
||||
currentBuffer = lesson
|
||||
? processBuffer(newBuffer, lesson.id)
|
||||
: newBuffer;
|
||||
|
||||
if (lesson) {
|
||||
lessonComponent = getLessonComponent(lesson.component);
|
||||
additionalComponents = getAdditionalComponents(lesson.additionalComponents || []);
|
||||
readOnlyRanges = lesson.readOnlyRanges || [];
|
||||
currentBuffer = new Uint8Array(lesson.initialBuffer);
|
||||
isCompleted = progressStorage.isCompleted(lesson.id.toString());
|
||||
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 {
|
||||
taskJustSolved = false;
|
||||
showNotification('Пока неверно. Попробуйте ещё раз!', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
}),
|
||||
});
|
||||
|
||||
function handleBufferChange(newBuffer) {
|
||||
//обрабатываем буфер через отдельный процессор
|
||||
if (lesson) {
|
||||
const processedBuffer = processBuffer(newBuffer, lesson.id);
|
||||
currentBuffer = processedBuffer;
|
||||
const data = await res.json();
|
||||
if (data.error) {
|
||||
aiError = data.error;
|
||||
} else {
|
||||
currentBuffer = newBuffer;
|
||||
aiMessage = data.hint;
|
||||
}
|
||||
} catch {
|
||||
aiError = 'Не удалось связаться с сервером подсказок.';
|
||||
} finally {
|
||||
aiLoading = false;
|
||||
}
|
||||
|
||||
function showNextHint() {
|
||||
if (!lesson) return;
|
||||
showHint = true;
|
||||
currentHintIndex = (currentHintIndex + 1) % lesson.hints.length;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if lesson}
|
||||
<LessonLayout {lesson}>
|
||||
<div class="lesson-content">
|
||||
|
||||
<!--задание + прогресс-->
|
||||
<div class="task-header">
|
||||
<div class="task-objective">
|
||||
<span class="task-label">Задание</span>
|
||||
{#if tasksCompleted >= 0}
|
||||
<span class="task-counter" class:done={isCompleted}>{tasksLabel}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if currentTask}
|
||||
<p class="objective-text">{currentTask.objective}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!--прогресс-бар-->
|
||||
<div class="progress-bar-wrap">
|
||||
<div
|
||||
class="progress-bar-fill"
|
||||
class:done={isCompleted}
|
||||
style="width: {isCompleted ? 100 : Math.min(tasksCompleted / TASKS_REQUIRED * 100, 100)}%"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!--основной редактор-->
|
||||
{#key currentTaskNum}
|
||||
<svelte:component
|
||||
this={lessonComponent}
|
||||
bind:buffer={currentBuffer}
|
||||
onBufferChange={handleBufferChange}
|
||||
readOnlyRanges={readOnlyRanges || []}
|
||||
readOnlyRanges={readOnlyRanges}
|
||||
/>
|
||||
{/key}
|
||||
|
||||
<!--дополнительные компоненты (HexViewer, WiresharkView, ...)-->
|
||||
{#each additionalComponents as Component}
|
||||
{#if currentBuffer}
|
||||
{#if isWiresharkView(Component)}
|
||||
<!--WiresharkView получает описание полей из данных урока-->
|
||||
<svelte:component
|
||||
this={Component}
|
||||
data={currentBuffer}
|
||||
fields={lesson.wiresharkFields ?? []}
|
||||
title={lesson.wiresharkTitle ?? 'Структура пакета'}
|
||||
/>
|
||||
{:else}
|
||||
<svelte:component this={Component} data={currentBuffer} />
|
||||
{/if}
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<!--AI-сообщение-->
|
||||
{#if aiLoading}
|
||||
<div class="ai-block loading">
|
||||
<span class="ai-icon">🤖</span>
|
||||
<span class="ai-text ai-thinking">Думаю над подсказкой...</span>
|
||||
</div>
|
||||
{:else if aiMessage}
|
||||
<div class="ai-block">
|
||||
<span class="ai-icon">🤖</span>
|
||||
<span class="ai-text">{aiMessage}</span>
|
||||
</div>
|
||||
{:else if aiError}
|
||||
<div class="ai-block error">
|
||||
<span class="ai-icon">⚠️</span>
|
||||
<span class="ai-text">{aiError}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!--кнопки управления-->
|
||||
<div class="controls">
|
||||
<button on:click={checkSolution} class="check-button">
|
||||
Проверить решение
|
||||
</button>
|
||||
|
||||
<button on:click={showNextHint} class="hint-button">
|
||||
Подсказка
|
||||
{#if showNextBtn}
|
||||
<button on:click={nextTask} class="next-button">
|
||||
Новое задание →
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if showHint && lesson.hints[currentHintIndex]}
|
||||
<div class="hint">
|
||||
💡 {lesson.hints[currentHintIndex]}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !taskJustSolved}
|
||||
<button on:click={requestHint} class="hint-button" disabled={aiLoading}>
|
||||
{aiLoading ? '...' : '💡 Подсказка'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!--завершение урока-->
|
||||
{#if isCompleted}
|
||||
<div class="completion-badge">
|
||||
✅ Урок завершен!
|
||||
✅ Урок завершен! Решено заданий: {tasksCompleted}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@ -173,56 +295,158 @@
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/*задание*/
|
||||
.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-bottom: 6px;
|
||||
}
|
||||
|
||||
.task-label {
|
||||
font-weight: bold;
|
||||
font-size: 0.85em;
|
||||
text-transform: uppercase;
|
||||
color: #e65100;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.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;
|
||||
gap: 12px;
|
||||
margin: 20px 0;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.check-button {
|
||||
background: #4CAF50;
|
||||
background: #4caf50;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 24px;
|
||||
padding: 11px 22px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 1em;
|
||||
}
|
||||
.check-button:hover { background: #388e3c; }
|
||||
|
||||
.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;
|
||||
}
|
||||
.next-button:hover { background: #1565c0; }
|
||||
|
||||
.hint-button {
|
||||
background: #FF9800;
|
||||
background: #ff9800;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 24px;
|
||||
padding: 11px 20px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.hint-button:hover {
|
||||
background: #F57C00;
|
||||
}
|
||||
|
||||
.hint {
|
||||
background: #FFF3E0;
|
||||
border-left: 4px solid #FF9800;
|
||||
padding: 12px 16px;
|
||||
margin: 16px 0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
|
||||
</style>
|
||||
Loading…
Reference in New Issue
Block a user