-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathMessage.php
More file actions
324 lines (287 loc) · 11.7 KB
/
Message.php
File metadata and controls
324 lines (287 loc) · 11.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
<?php
namespace Utopia\DNS;
use Utopia\DNS\Exception\Message\DecodingException;
use Utopia\DNS\Exception\Message\PartialDecodingException;
use Utopia\DNS\Message\Header;
use Utopia\DNS\Message\Question;
use Utopia\DNS\Message\Record;
final class Message
{
public const int RCODE_NOERROR = 0;
public const int RCODE_FORMERR = 1;
public const int RCODE_SERVFAIL = 2;
public const int RCODE_NXDOMAIN = 3;
public const int RCODE_NOTIMP = 4;
public const int RCODE_REFUSED = 5;
public const int RCODE_YXDOMAIN = 6;
public const int RCODE_YXRRSET = 7;
public const int RCODE_NXRRSET = 8;
public const int RCODE_NOTAUTH = 9;
public const int RCODE_NOTZONE = 10;
/**
* @param Header $header The header of the message.
* @param Question[] $questions The question records.
* @param list<Record> $answers The answer records.
* @param list<Record> $authority The authority records.
* @param list<Record> $additional The additional records.
*/
public function __construct(
public readonly Header $header,
/** @var Question[] */
public readonly array $questions = [],
/** @var list<Record> */
public readonly array $answers = [],
/** @var list<Record> */
public readonly array $authority = [],
/** @var list<Record> */
public readonly array $additional = []
) {
if ($header->questionCount !== count($questions)) {
throw new \InvalidArgumentException('Invalid DNS response: question count mismatch');
}
if ($header->answerCount !== count($answers)) {
throw new \InvalidArgumentException('Invalid DNS response: answer count mismatch');
}
if ($header->authorityCount !== count($authority)) {
throw new \InvalidArgumentException('Invalid DNS response: authority count mismatch');
}
if ($header->additionalCount !== count($additional)) {
throw new \InvalidArgumentException('Invalid DNS response: additional count mismatch');
}
$soaAuthorityCount = count(array_filter(
$this->authority,
fn ($record) => $record->type === Record::TYPE_SOA
));
if ($header->isResponse && $header->authoritative && $soaAuthorityCount < 1) {
if ($header->responseCode === self::RCODE_NXDOMAIN) {
throw new \InvalidArgumentException('NXDOMAIN requires SOA in authority');
}
if ($header->responseCode === self::RCODE_NOERROR && $answers === []) {
throw new \InvalidArgumentException('NODATA should include SOA in authority');
}
}
}
public static function query(
Question $question,
?int $id = null,
bool $recursionDesired = true
): self {
if ($id === null) {
$id = random_int(0, 0xFFFF);
}
$header = new Header(
id: $id,
isResponse: false,
opcode: 0, // QUERY
authoritative: false,
truncated: false,
recursionDesired: $recursionDesired,
recursionAvailable: false,
responseCode: 0,
questionCount: 1,
answerCount: 0,
authorityCount: 0,
additionalCount: 0
);
return new self($header, [$question]);
}
/**
* Create a response message.
*
* @param Header $header The header of the query message to respond to.
* @param int $responseCode The response code.
* @param array<Question> $questions The question records.
* @param list<Record> $answers The answer records.
* @param list<Record> $authority The authority records.
* @param list<Record> $additional The additional records.
* @param bool $authoritative Whether the response is authoritative.
* @param bool $truncated Whether the response is truncated.
* @param bool $recursionAvailable Whether recursion is available.
* @return self The response message.
*/
public static function response(
Header $header,
int $responseCode,
array $questions = [],
array $answers = [],
array $authority = [],
array $additional = [],
bool $authoritative = false,
bool $truncated = false,
bool $recursionAvailable = false
): self {
$header = new Header(
id: $header->id,
isResponse: true,
opcode: $header->opcode,
authoritative: $authoritative,
truncated: $truncated,
recursionDesired: $header->recursionDesired,
recursionAvailable: $recursionAvailable,
responseCode: $responseCode,
questionCount: count($questions),
answerCount: count($answers),
authorityCount: count($authority),
additionalCount: count($additional)
);
return new self($header, $questions, $answers, $authority, $additional);
}
public static function decode(string $packet): self
{
if (strlen($packet) < Header::LENGTH) {
throw new DecodingException('Invalid DNS response: header too short');
}
// --- Parse header (12 bytes) ---
$header = Header::decode($packet);
// --- Parse Question Section ---
try {
$offset = Header::LENGTH;
$questions = [];
for ($i = 0; $i < $header->questionCount; $i++) {
$questions[] = Question::decode($packet, $offset);
}
// --- Decode Answer Section ---
$answers = [];
for ($i = 0; $i < $header->answerCount; $i++) {
$answers[] = Record::decode($packet, $offset);
}
// --- Decode Authority Section ---
$authority = [];
for ($i = 0; $i < $header->authorityCount; $i++) {
$authority[] = Record::decode($packet, $offset);
}
// --- Decode Additional Section ---
$additional = [];
for ($i = 0; $i < $header->additionalCount; $i++) {
$additional[] = Record::decode($packet, $offset);
}
if ($offset !== strlen($packet)) {
throw new DecodingException('Invalid packet length');
}
} catch (DecodingException $e) {
throw new PartialDecodingException($header, $e->getMessage(), $e);
}
return new self($header, $questions, $answers, $authority, $additional);
}
/**
* Encode the message to a binary DNS packet.
*
* When maxSize is specified, truncation follows RFC 1035 Section 6.2 and RFC 2181 Section 9:
* - Truncation starts at the end and works forward (additional → authority → answers)
* - TC flag is only set when required RRSets (answers) couldn't be fully included
* - Complete RRSets are preserved; partial RRSets are omitted entirely
* - Questions are always preserved
*
* @param int|null $maxSize Maximum packet size (e.g., 512 for UDP per RFC 1035)
* @return string The encoded DNS packet
*/
public function encode(?int $maxSize = null): string
{
// Build full packet first
$packet = $this->header->encode();
foreach ($this->questions as $question) {
$packet .= $question->encode();
}
foreach ($this->answers as $answer) {
$packet .= $answer->encode($packet);
}
foreach ($this->authority as $authority) {
$packet .= $authority->encode($packet);
}
foreach ($this->additional as $additional) {
$packet .= $additional->encode($packet);
}
// No truncation needed
if ($maxSize === null || strlen($packet) <= $maxSize) {
return $packet;
}
// RFC-compliant truncation: work backward from end
// Per RFC 1035 Section 6.2 and RFC 2181 Section 9
return $this->encodeWithTruncation($maxSize);
}
/**
* Encode with RFC-compliant truncation strategy.
*
* Truncation order per RFC 1035 Section 6.2:
* 1. Drop additional section first
* 2. If still too big, drop authority section
* 3. If still too big, include as many complete answer RRSets as fit, set TC
*
* TC flag is only set when answer section data is truncated (RFC 2181 Section 9).
*/
private function encodeWithTruncation(int $maxSize): string
{
// Step 1: Try without additional section
$withoutAdditional = self::response(
$this->header,
$this->header->responseCode,
questions: $this->questions,
answers: $this->answers,
authority: $this->authority,
additional: [],
authoritative: $this->header->authoritative,
truncated: false,
recursionAvailable: $this->header->recursionAvailable
);
$packet = $withoutAdditional->encode();
if (strlen($packet) <= $maxSize) {
return $packet;
}
// Step 2: Try without authority section.
// NODATA (NOERROR + no answers) and NXDOMAIN require SOA in authority per RFC;
// when we drop authority for size, mark as non-authoritative so validation allows it.
$isNodataOrNxdomain = ($this->header->responseCode === self::RCODE_NOERROR && $this->answers === [])
|| $this->header->responseCode === self::RCODE_NXDOMAIN;
$withoutAuthority = self::response(
$this->header,
$this->header->responseCode,
questions: $this->questions,
answers: $this->answers,
authority: [],
additional: [],
authoritative: $isNodataOrNxdomain ? false : $this->header->authoritative,
truncated: false,
recursionAvailable: $this->header->recursionAvailable
);
$packet = $withoutAuthority->encode();
if (strlen($packet) <= $maxSize) {
return $packet;
}
// Step 3: Truncate answers - find how many complete records fit
// Build base packet with header + questions
$basePacket = $this->header->encode();
foreach ($this->questions as $question) {
$basePacket .= $question->encode();
}
$fittingAnswers = [];
$tempPacket = $basePacket;
foreach ($this->answers as $answer) {
$encodedAnswer = $answer->encode($tempPacket);
if (strlen($tempPacket) + strlen($encodedAnswer) <= $maxSize) {
$tempPacket .= $encodedAnswer;
$fittingAnswers[] = $answer;
} else {
// This answer doesn't fit, stop here
break;
}
}
// Determine if we need to set TC flag
// Per RFC 2181 Section 9: TC is set only when required RRSet data couldn't fit
$needsTruncation = count($fittingAnswers) < count($this->answers);
// When authority is empty (dropped for truncation), NODATA/NXDOMAIN must be non-authoritative
$isNodataOrNxdomainTruncated = ($this->header->responseCode === self::RCODE_NOERROR && $fittingAnswers === [])
|| $this->header->responseCode === self::RCODE_NXDOMAIN;
$truncatedResponse = self::response(
$this->header,
$this->header->responseCode,
questions: $this->questions,
answers: $fittingAnswers,
authority: [],
additional: [],
authoritative: $isNodataOrNxdomainTruncated ? false : $this->header->authoritative,
truncated: $needsTruncation,
recursionAvailable: $this->header->recursionAvailable
);
return $truncatedResponse->encode();
}
}