@apocaliss92/scrypted-reolink-native 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.vscode/settings.json +1 -1
- package/dist/main.nodejs.js +1 -1
- package/dist/plugin.zip +0 -0
- package/package.json +1 -1
- package/pcap/network_cam.txt +108321 -0
- package/pcap/wifi0ap0-2412MHz-UnifiRuocco.pcap +0 -0
- package/src/camera-battery.ts +106 -106
- package/src/camera.ts +1 -2
- package/src/common.ts +49 -27
- package/src/connect.ts +9 -4
- package/src/debug-options.ts +105 -0
- package/src/intercom.ts +16 -0
- package/src/stream-utils.ts +74 -8
- package/pcap/analyze_json.py +0 -248
- package/pcap/analyze_pcap.py +0 -135
- package/pcap/compare_pcaps.py +0 -274
- package/pcap/compare_stream_flow.py +0 -222
- package/pcap/scrypted.json +0 -307560
- package/pcap/scrypted.pcapng +0 -0
- package/pcap/simple_compare.py +0 -178
package/src/stream-utils.ts
CHANGED
|
@@ -20,6 +20,16 @@ export interface StreamManagerOptions {
|
|
|
20
20
|
*/
|
|
21
21
|
createStreamClient: () => Promise<ReolinkBaichuanApi>;
|
|
22
22
|
getLogger: () => Console;
|
|
23
|
+
/**
|
|
24
|
+
* Credentials to include in the TCP stream (username, password).
|
|
25
|
+
* Uses the same credentials as the main connection.
|
|
26
|
+
*/
|
|
27
|
+
credentials: {
|
|
28
|
+
username: string;
|
|
29
|
+
password: string;
|
|
30
|
+
};
|
|
31
|
+
/** If true, the stream client is shared with the main connection. Default: false. */
|
|
32
|
+
sharedConnection?: boolean;
|
|
23
33
|
}
|
|
24
34
|
|
|
25
35
|
export function parseStreamProfileFromId(id: string | undefined): StreamProfile | undefined {
|
|
@@ -207,7 +217,7 @@ export async function createRfc4571MediaObjectFromStreamManager(params: {
|
|
|
207
217
|
}): Promise<MediaObject> {
|
|
208
218
|
const { streamManager, channel, profile, streamKey, expectedVideoType, selected, sourceId, onDetectedCodec } = params;
|
|
209
219
|
|
|
210
|
-
const { host, port, sdp, audio } = await streamManager.getRfcStream(channel, profile, streamKey, expectedVideoType);
|
|
220
|
+
const { host, port, sdp, audio, username, password } = await streamManager.getRfcStream(channel, profile, streamKey, expectedVideoType);
|
|
211
221
|
|
|
212
222
|
// Update cached stream options with the detected codec (helps prebuffer/NVR avoid mismatch).
|
|
213
223
|
try {
|
|
@@ -230,8 +240,13 @@ export async function createRfc4571MediaObjectFromStreamManager(params: {
|
|
|
230
240
|
mso.audio.channels = audio.channels;
|
|
231
241
|
}
|
|
232
242
|
|
|
243
|
+
// Build URL with credentials: tcp://username:password@host:port
|
|
244
|
+
const encodedUsername = encodeURIComponent(username || '');
|
|
245
|
+
const encodedPassword = encodeURIComponent(password || '');
|
|
246
|
+
const url = `tcp://${encodedUsername}:${encodedPassword}@${host}:${port}`;
|
|
247
|
+
|
|
233
248
|
const rfc = {
|
|
234
|
-
url
|
|
249
|
+
url,
|
|
235
250
|
sdp,
|
|
236
251
|
mediaStreamOptions: mso as ResponseMediaStreamOptions,
|
|
237
252
|
};
|
|
@@ -241,9 +256,18 @@ export async function createRfc4571MediaObjectFromStreamManager(params: {
|
|
|
241
256
|
});
|
|
242
257
|
}
|
|
243
258
|
|
|
259
|
+
type RfcServerInfo = {
|
|
260
|
+
host: string;
|
|
261
|
+
port: number;
|
|
262
|
+
sdp: string;
|
|
263
|
+
audio?: { codec: string; sampleRate: number; channels: number };
|
|
264
|
+
username: string;
|
|
265
|
+
password: string;
|
|
266
|
+
};
|
|
267
|
+
|
|
244
268
|
export class StreamManager {
|
|
245
269
|
private nativeRfcServers = new Map<string, ScryptedRfc4571TcpServer>();
|
|
246
|
-
private nativeRfcServerCreatePromises = new Map<string, Promise<
|
|
270
|
+
private nativeRfcServerCreatePromises = new Map<string, Promise<RfcServerInfo>>();
|
|
247
271
|
|
|
248
272
|
constructor(private opts: StreamManagerOptions) {
|
|
249
273
|
}
|
|
@@ -257,7 +281,7 @@ export class StreamManager {
|
|
|
257
281
|
channel: number,
|
|
258
282
|
profile: StreamProfile,
|
|
259
283
|
expectedVideoType?: 'H264' | 'H265',
|
|
260
|
-
): Promise<
|
|
284
|
+
): Promise<RfcServerInfo> {
|
|
261
285
|
const existingCreate = this.nativeRfcServerCreatePromises.get(streamKey);
|
|
262
286
|
if (existingCreate) {
|
|
263
287
|
return await existingCreate;
|
|
@@ -272,7 +296,14 @@ export class StreamManager {
|
|
|
272
296
|
);
|
|
273
297
|
}
|
|
274
298
|
else {
|
|
275
|
-
return {
|
|
299
|
+
return {
|
|
300
|
+
host: cached.host,
|
|
301
|
+
port: cached.port,
|
|
302
|
+
sdp: cached.sdp,
|
|
303
|
+
audio: cached.audio,
|
|
304
|
+
username: (cached as any).username || this.opts.credentials.username,
|
|
305
|
+
password: (cached as any).password || this.opts.credentials.password,
|
|
306
|
+
};
|
|
276
307
|
}
|
|
277
308
|
}
|
|
278
309
|
|
|
@@ -288,13 +319,22 @@ export class StreamManager {
|
|
|
288
319
|
|
|
289
320
|
const api = await this.opts.createStreamClient();
|
|
290
321
|
const { createScryptedRfc4571TcpServer } = await import('@apocaliss92/reolink-baichuan-js');
|
|
322
|
+
|
|
323
|
+
// Use the same credentials as the main connection
|
|
324
|
+
const { username, password } = this.opts.credentials;
|
|
325
|
+
|
|
326
|
+
// If connection is shared, don't close it when stream teardown happens
|
|
327
|
+
const closeApiOnTeardown = !(this.opts.sharedConnection ?? false);
|
|
328
|
+
|
|
291
329
|
const created = await createScryptedRfc4571TcpServer({
|
|
292
330
|
api,
|
|
293
331
|
channel,
|
|
294
332
|
profile,
|
|
295
333
|
logger: this.getLogger(),
|
|
296
334
|
expectedVideoType: expectedVideoType as VideoType | undefined,
|
|
297
|
-
closeApiOnTeardown
|
|
335
|
+
closeApiOnTeardown,
|
|
336
|
+
username,
|
|
337
|
+
password,
|
|
298
338
|
});
|
|
299
339
|
|
|
300
340
|
this.nativeRfcServers.set(streamKey, created);
|
|
@@ -303,7 +343,14 @@ export class StreamManager {
|
|
|
303
343
|
if (current?.server === created.server) this.nativeRfcServers.delete(streamKey);
|
|
304
344
|
});
|
|
305
345
|
|
|
306
|
-
return {
|
|
346
|
+
return {
|
|
347
|
+
host: created.host,
|
|
348
|
+
port: created.port,
|
|
349
|
+
sdp: created.sdp,
|
|
350
|
+
audio: created.audio,
|
|
351
|
+
username: (created as any).username || this.opts.credentials.username,
|
|
352
|
+
password: (created as any).password || this.opts.credentials.password,
|
|
353
|
+
};
|
|
307
354
|
})();
|
|
308
355
|
|
|
309
356
|
this.nativeRfcServerCreatePromises.set(streamKey, createPromise);
|
|
@@ -320,7 +367,26 @@ export class StreamManager {
|
|
|
320
367
|
profile: StreamProfile,
|
|
321
368
|
streamKey: string,
|
|
322
369
|
expectedVideoType?: 'H264' | 'H265',
|
|
323
|
-
): Promise<
|
|
370
|
+
): Promise<RfcServerInfo> {
|
|
324
371
|
return await this.ensureNativeRfcServer(streamKey, channel, profile, expectedVideoType);
|
|
325
372
|
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Close all active stream servers.
|
|
376
|
+
* Useful when the main connection is reset and streams need to be recreated.
|
|
377
|
+
*/
|
|
378
|
+
async closeAllStreams(reason?: string): Promise<void> {
|
|
379
|
+
const servers = Array.from(this.nativeRfcServers.values());
|
|
380
|
+
this.nativeRfcServers.clear();
|
|
381
|
+
|
|
382
|
+
await Promise.allSettled(
|
|
383
|
+
servers.map(async (server) => {
|
|
384
|
+
try {
|
|
385
|
+
await server.close(reason || 'connection reset');
|
|
386
|
+
} catch (e) {
|
|
387
|
+
this.getLogger().debug('Error closing stream server', e);
|
|
388
|
+
}
|
|
389
|
+
})
|
|
390
|
+
);
|
|
391
|
+
}
|
|
326
392
|
}
|
package/pcap/analyze_json.py
DELETED
|
@@ -1,248 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
3
|
-
Analyze Wireshark JSON exports to compare neolink vs scrypted UDP flows.
|
|
4
|
-
"""
|
|
5
|
-
import json
|
|
6
|
-
import sys
|
|
7
|
-
import struct
|
|
8
|
-
from typing import List, Dict, Any
|
|
9
|
-
|
|
10
|
-
BCUDP_MAGIC_DISCOVERY = 0x3acf872a
|
|
11
|
-
BCUDP_MAGIC_DATA = 0x10cf872a
|
|
12
|
-
BCUDP_MAGIC_ACK = 0x20cf872a
|
|
13
|
-
|
|
14
|
-
def hex_to_bytes(hex_str: str) -> bytes:
|
|
15
|
-
"""Convert hex string (e.g., '10:cf:87:2a') to bytes."""
|
|
16
|
-
try:
|
|
17
|
-
return bytes.fromhex(hex_str.replace(':', '').replace(' ', ''))
|
|
18
|
-
except:
|
|
19
|
-
return b''
|
|
20
|
-
|
|
21
|
-
def parse_bcudp_packet(udp_data_hex: str) -> Dict[str, Any] | None:
|
|
22
|
-
"""Parse BCUDP packet from hex string."""
|
|
23
|
-
try:
|
|
24
|
-
data = hex_to_bytes(udp_data_hex)
|
|
25
|
-
if len(data) < 4:
|
|
26
|
-
return None
|
|
27
|
-
|
|
28
|
-
# Wireshark exports hex strings in the order they appear in the packet
|
|
29
|
-
# BCUDP magic numbers are stored as little-endian in the packet
|
|
30
|
-
# But when we convert hex string to bytes, we get the bytes in order
|
|
31
|
-
# So 3a:cf:87:2a becomes bytes [0x3a, 0xcf, 0x87, 0x2a]
|
|
32
|
-
# Reading as little-endian: 0x2a87cf3a (wrong)
|
|
33
|
-
# Reading as big-endian: 0x3acf872a (correct!)
|
|
34
|
-
# Actually wait - let me check the actual byte order in the hex string
|
|
35
|
-
# The hex shows "3a:cf:87:2a" which as bytes is [0x3a, 0xcf, 0x87, 0x2a]
|
|
36
|
-
# As little-endian uint32: 0x2a87cf3a
|
|
37
|
-
# As big-endian uint32: 0x3acf872a
|
|
38
|
-
# So we need big-endian for the magic comparison
|
|
39
|
-
magic = struct.unpack('>I', data[0:4])[0]
|
|
40
|
-
|
|
41
|
-
if magic == BCUDP_MAGIC_DISCOVERY:
|
|
42
|
-
return {'type': 'discovery', 'magic': hex(magic), 'payload': udp_data_hex}
|
|
43
|
-
elif magic == BCUDP_MAGIC_DATA:
|
|
44
|
-
if len(data) < 20:
|
|
45
|
-
return None
|
|
46
|
-
connection_id = int.from_bytes(data[4:8], 'little', signed=True)
|
|
47
|
-
packet_id = int.from_bytes(data[12:16], 'little')
|
|
48
|
-
payload_len = int.from_bytes(data[16:20], 'little')
|
|
49
|
-
return {
|
|
50
|
-
'type': 'data',
|
|
51
|
-
'magic': hex(magic),
|
|
52
|
-
'connection_id': connection_id,
|
|
53
|
-
'packet_id': packet_id,
|
|
54
|
-
'payload_len': payload_len,
|
|
55
|
-
'payload_hex': udp_data_hex[:80] # First 40 bytes hex
|
|
56
|
-
}
|
|
57
|
-
elif magic == BCUDP_MAGIC_ACK:
|
|
58
|
-
if len(data) < 28:
|
|
59
|
-
return None
|
|
60
|
-
connection_id = int.from_bytes(data[4:8], 'little', signed=True)
|
|
61
|
-
group_id = int.from_bytes(data[12:16], 'little')
|
|
62
|
-
packet_id = int.from_bytes(data[16:20], 'little')
|
|
63
|
-
return {
|
|
64
|
-
'type': 'ack',
|
|
65
|
-
'magic': hex(magic),
|
|
66
|
-
'connection_id': connection_id,
|
|
67
|
-
'group_id': group_id,
|
|
68
|
-
'packet_id': packet_id,
|
|
69
|
-
'payload_hex': udp_data_hex[:56] # First 28 bytes hex
|
|
70
|
-
}
|
|
71
|
-
except Exception as e:
|
|
72
|
-
return None
|
|
73
|
-
|
|
74
|
-
return None
|
|
75
|
-
|
|
76
|
-
def extract_packets(json_file: str) -> List[Dict[str, Any]]:
|
|
77
|
-
"""Extract BCUDP packets from Wireshark JSON export."""
|
|
78
|
-
packets = []
|
|
79
|
-
|
|
80
|
-
with open(json_file, 'r') as f:
|
|
81
|
-
data = json.load(f)
|
|
82
|
-
|
|
83
|
-
for entry in data:
|
|
84
|
-
source = entry.get('_source', {})
|
|
85
|
-
layers = source.get('layers', {})
|
|
86
|
-
|
|
87
|
-
# Get frame info
|
|
88
|
-
frame = layers.get('frame', {})
|
|
89
|
-
frame_num = int(frame.get('frame.number', '0'))
|
|
90
|
-
frame_time = float(frame.get('frame.time_relative', '0'))
|
|
91
|
-
|
|
92
|
-
# Get UDP info
|
|
93
|
-
udp = layers.get('udp', {})
|
|
94
|
-
if not udp:
|
|
95
|
-
continue
|
|
96
|
-
|
|
97
|
-
src_port = int(udp.get('udp.srcport', '0'))
|
|
98
|
-
dst_port = int(udp.get('udp.dstport', '0'))
|
|
99
|
-
|
|
100
|
-
# Get UDP data (from udp.payload field)
|
|
101
|
-
udp_data_hex = udp.get('udp.payload', '')
|
|
102
|
-
if not udp_data_hex:
|
|
103
|
-
continue
|
|
104
|
-
|
|
105
|
-
# Get IP addresses to determine direction
|
|
106
|
-
ip = layers.get('ip', {})
|
|
107
|
-
src_ip = ip.get('ip.src', '')
|
|
108
|
-
dst_ip = ip.get('ip.dst', '')
|
|
109
|
-
|
|
110
|
-
# Parse BCUDP packet
|
|
111
|
-
bcudp = parse_bcudp_packet(udp_data_hex)
|
|
112
|
-
if bcudp:
|
|
113
|
-
packets.append({
|
|
114
|
-
'frame_num': frame_num,
|
|
115
|
-
'time': frame_time,
|
|
116
|
-
'src_ip': src_ip,
|
|
117
|
-
'dst_ip': dst_ip,
|
|
118
|
-
'src_port': src_port,
|
|
119
|
-
'dst_port': dst_port,
|
|
120
|
-
'bcudp': bcudp
|
|
121
|
-
})
|
|
122
|
-
|
|
123
|
-
return packets
|
|
124
|
-
|
|
125
|
-
def print_flow_summary(packets: List[Dict[str, Any]], name: str, limit: int = 100):
|
|
126
|
-
"""Print summary of packet flow."""
|
|
127
|
-
print(f"\n{'='*100}")
|
|
128
|
-
print(f"{name}: {len(packets)} BCUDP packets")
|
|
129
|
-
print(f"{'='*100}")
|
|
130
|
-
|
|
131
|
-
# Count by type
|
|
132
|
-
discovery_count = sum(1 for p in packets if p['bcudp']['type'] == 'discovery')
|
|
133
|
-
data_count = sum(1 for p in packets if p['bcudp']['type'] == 'data')
|
|
134
|
-
ack_count = sum(1 for p in packets if p['bcudp']['type'] == 'ack')
|
|
135
|
-
|
|
136
|
-
print(f"\nCounts: Discovery={discovery_count}, Data={data_count}, ACK={ack_count}")
|
|
137
|
-
|
|
138
|
-
# Show first N packets
|
|
139
|
-
print(f"\nFirst {min(limit, len(packets))} packets:")
|
|
140
|
-
for i, pkt in enumerate(packets[:limit]):
|
|
141
|
-
bcudp = pkt['bcudp']
|
|
142
|
-
direction = '→' if '192.168' in pkt['dst_ip'] else '←'
|
|
143
|
-
pkt_type = bcudp['type'].upper()
|
|
144
|
-
|
|
145
|
-
info = [f"#{pkt['frame_num']:4d}", f"{pkt['time']:8.3f}s", direction, pkt_type]
|
|
146
|
-
|
|
147
|
-
if pkt_type == 'DATA':
|
|
148
|
-
info.append(f"conn={bcudp.get('connection_id', '?')}")
|
|
149
|
-
info.append(f"pid={bcudp.get('packet_id', '?')}")
|
|
150
|
-
info.append(f"len={bcudp.get('payload_len', '?')}")
|
|
151
|
-
elif pkt_type == 'ACK':
|
|
152
|
-
info.append(f"conn={bcudp.get('connection_id', '?')}")
|
|
153
|
-
info.append(f"pid={bcudp.get('packet_id', '?')}")
|
|
154
|
-
info.append(f"gid={bcudp.get('group_id', '?')}")
|
|
155
|
-
|
|
156
|
-
info.append(f"{pkt['src_port']}→{pkt['dst_port']}")
|
|
157
|
-
|
|
158
|
-
print(' '.join(info))
|
|
159
|
-
|
|
160
|
-
if i < 20: # Show hex for first 20 packets
|
|
161
|
-
if 'payload_hex' in bcudp:
|
|
162
|
-
print(f" Hex: {bcudp['payload_hex']}")
|
|
163
|
-
|
|
164
|
-
def compare_initial_sequence(neolink_packets: List[Dict[str, Any]], scrypted_packets: List[Dict[str, Any]]):
|
|
165
|
-
"""Compare the initial sequence after discovery."""
|
|
166
|
-
print(f"\n{'='*100}")
|
|
167
|
-
print("INITIAL SEQUENCE COMPARISON (first 50 packets after discovery)")
|
|
168
|
-
print(f"{'='*100}")
|
|
169
|
-
|
|
170
|
-
# Find first discovery completion (look for D2C_C_R response)
|
|
171
|
-
nl_discovery_end = None
|
|
172
|
-
for i, pkt in enumerate(neolink_packets):
|
|
173
|
-
if pkt['bcudp']['type'] == 'discovery':
|
|
174
|
-
# Check if it's a response (coming from camera)
|
|
175
|
-
if '192.168' in pkt['src_ip']: # From camera
|
|
176
|
-
nl_discovery_end = i
|
|
177
|
-
break
|
|
178
|
-
|
|
179
|
-
sc_discovery_end = None
|
|
180
|
-
for i, pkt in enumerate(scrypted_packets):
|
|
181
|
-
if pkt['bcudp']['type'] == 'discovery':
|
|
182
|
-
if '192.168' in pkt['src_ip']: # From camera
|
|
183
|
-
sc_discovery_end = i
|
|
184
|
-
break
|
|
185
|
-
|
|
186
|
-
print(f"\nDiscovery completed at:")
|
|
187
|
-
print(f" Neolink: packet #{nl_discovery_end}" if nl_discovery_end else " Neolink: not found")
|
|
188
|
-
print(f" Scrypted: packet #{sc_discovery_end}" if sc_discovery_end else " Scrypted: not found")
|
|
189
|
-
|
|
190
|
-
# Get next 50 packets after discovery
|
|
191
|
-
nl_next = neolink_packets[nl_discovery_end+1:nl_discovery_end+51] if nl_discovery_end else []
|
|
192
|
-
sc_next = scrypted_packets[sc_discovery_end+1:sc_discovery_end+51] if sc_discovery_end else []
|
|
193
|
-
|
|
194
|
-
print(f"\nNext 50 packets after discovery:")
|
|
195
|
-
print(f" Neolink: {len(nl_next)} packets")
|
|
196
|
-
print(f" Scrypted: {len(sc_next)} packets")
|
|
197
|
-
|
|
198
|
-
print(f"\nNeolink sequence:")
|
|
199
|
-
for i, pkt in enumerate(nl_next[:30]):
|
|
200
|
-
bcudp = pkt['bcudp']
|
|
201
|
-
direction = '→' if '192.168' in pkt['dst_ip'] else '←'
|
|
202
|
-
pkt_type = bcudp['type'].upper()
|
|
203
|
-
time_rel = pkt['time'] - (neolink_packets[nl_discovery_end]['time'] if nl_discovery_end else 0)
|
|
204
|
-
|
|
205
|
-
info = [f"{time_rel:6.3f}s", direction, pkt_type]
|
|
206
|
-
if pkt_type == 'DATA':
|
|
207
|
-
info.append(f"pid={bcudp.get('packet_id', '?')}")
|
|
208
|
-
elif pkt_type == 'ACK':
|
|
209
|
-
info.append(f"pid={bcudp.get('packet_id', '?')}")
|
|
210
|
-
|
|
211
|
-
print(' '.join(info))
|
|
212
|
-
|
|
213
|
-
print(f"\nScrypted sequence:")
|
|
214
|
-
for i, pkt in enumerate(sc_next[:30]):
|
|
215
|
-
bcudp = pkt['bcudp']
|
|
216
|
-
direction = '→' if '192.168' in pkt['dst_ip'] else '←'
|
|
217
|
-
pkt_type = bcudp['type'].upper()
|
|
218
|
-
time_rel = pkt['time'] - (scrypted_packets[sc_discovery_end]['time'] if sc_discovery_end else 0)
|
|
219
|
-
|
|
220
|
-
info = [f"{time_rel:6.3f}s", direction, pkt_type]
|
|
221
|
-
if pkt_type == 'DATA':
|
|
222
|
-
info.append(f"pid={bcudp.get('packet_id', '?')}")
|
|
223
|
-
elif pkt_type == 'ACK':
|
|
224
|
-
info.append(f"pid={bcudp.get('packet_id', '?')}")
|
|
225
|
-
|
|
226
|
-
print(' '.join(info))
|
|
227
|
-
|
|
228
|
-
def main():
|
|
229
|
-
if len(sys.argv) < 3:
|
|
230
|
-
print("Usage: python3 analyze_json.py <neolink.json> <scrypted.json>")
|
|
231
|
-
sys.exit(1)
|
|
232
|
-
|
|
233
|
-
neolink_file = sys.argv[1]
|
|
234
|
-
scrypted_file = sys.argv[2]
|
|
235
|
-
|
|
236
|
-
print("Loading neolink.json...")
|
|
237
|
-
neolink_packets = extract_packets(neolink_file)
|
|
238
|
-
|
|
239
|
-
print("Loading scrypted.json...")
|
|
240
|
-
scrypted_packets = extract_packets(scrypted_file)
|
|
241
|
-
|
|
242
|
-
print_flow_summary(neolink_packets, "NEOLINK", limit=50)
|
|
243
|
-
print_flow_summary(scrypted_packets, "SCRYPTED", limit=50)
|
|
244
|
-
compare_initial_sequence(neolink_packets, scrypted_packets)
|
|
245
|
-
|
|
246
|
-
if __name__ == '__main__':
|
|
247
|
-
main()
|
|
248
|
-
|
package/pcap/analyze_pcap.py
DELETED
|
@@ -1,135 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""Simple PCAP analyzer to compare neolink vs scrypted UDP flows"""
|
|
3
|
-
import sys
|
|
4
|
-
import struct
|
|
5
|
-
|
|
6
|
-
def read_pcap_header(f):
|
|
7
|
-
"""Read PCAP file header (24 bytes)"""
|
|
8
|
-
magic = f.read(4)
|
|
9
|
-
if magic != b'\xd4\xc3\xb2\xa1': # pcap magic number (little endian)
|
|
10
|
-
return None
|
|
11
|
-
version_major, version_minor = struct.unpack('<HH', f.read(4))
|
|
12
|
-
thiszone, sigfigs = struct.unpack('<II', f.read(8))
|
|
13
|
-
snaplen, network = struct.unpack('<II', f.read(8))
|
|
14
|
-
return {
|
|
15
|
-
'version_major': version_major,
|
|
16
|
-
'version_minor': version_minor,
|
|
17
|
-
'snaplen': snaplen,
|
|
18
|
-
'network': network
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
def read_packet_header(f):
|
|
22
|
-
"""Read PCAP packet header (16 bytes)"""
|
|
23
|
-
data = f.read(16)
|
|
24
|
-
if len(data) < 16:
|
|
25
|
-
return None
|
|
26
|
-
ts_sec, ts_usec, incl_len, orig_len = struct.unpack('<IIII', data)
|
|
27
|
-
return {
|
|
28
|
-
'ts_sec': ts_sec,
|
|
29
|
-
'ts_usec': ts_usec,
|
|
30
|
-
'incl_len': incl_len,
|
|
31
|
-
'orig_len': orig_len
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
def analyze_pcap(filename):
|
|
35
|
-
"""Basic analysis of UDP packets in PCAP"""
|
|
36
|
-
with open(filename, 'rb') as f:
|
|
37
|
-
header = read_pcap_header(f)
|
|
38
|
-
if not header:
|
|
39
|
-
print(f"Error: {filename} is not a valid PCAP file")
|
|
40
|
-
return
|
|
41
|
-
|
|
42
|
-
packets = []
|
|
43
|
-
packet_num = 0
|
|
44
|
-
|
|
45
|
-
while True:
|
|
46
|
-
pkt_header = read_packet_header(f)
|
|
47
|
-
if not pkt_header:
|
|
48
|
-
break
|
|
49
|
-
|
|
50
|
-
packet_data = f.read(pkt_header['incl_len'])
|
|
51
|
-
if len(packet_data) < pkt_header['incl_len']:
|
|
52
|
-
break
|
|
53
|
-
|
|
54
|
-
# Check if this is an Ethernet frame with IPv4 UDP
|
|
55
|
-
if len(packet_data) >= 42: # Min Ethernet + IP + UDP headers
|
|
56
|
-
# Skip Ethernet header (14 bytes), check IP
|
|
57
|
-
ip_header = packet_data[14:34]
|
|
58
|
-
if ip_header[0] == 0x45: # IPv4
|
|
59
|
-
protocol = ip_header[9]
|
|
60
|
-
if protocol == 17: # UDP
|
|
61
|
-
src_port = struct.unpack('>H', ip_header[20:22])[0]
|
|
62
|
-
dst_port = struct.unpack('>H', ip_header[22:24])[0]
|
|
63
|
-
udp_data = packet_data[42:]
|
|
64
|
-
|
|
65
|
-
# Check for BCUDP magic numbers
|
|
66
|
-
if len(udp_data) >= 4:
|
|
67
|
-
magic = udp_data[:4]
|
|
68
|
-
if magic == b'\x3a\xcf\x87\x2a': # Discovery
|
|
69
|
-
packets.append({
|
|
70
|
-
'num': packet_num,
|
|
71
|
-
'ts': pkt_header['ts_sec'] + pkt_header['ts_usec'] / 1000000,
|
|
72
|
-
'src_port': src_port,
|
|
73
|
-
'dst_port': dst_port,
|
|
74
|
-
'type': 'discovery',
|
|
75
|
-
'data': udp_data[:100] # First 100 bytes
|
|
76
|
-
})
|
|
77
|
-
elif magic == b'\x20\xcf\x87\x2a': # ACK
|
|
78
|
-
packets.append({
|
|
79
|
-
'num': packet_num,
|
|
80
|
-
'ts': pkt_header['ts_sec'] + pkt_header['ts_usec'] / 1000000,
|
|
81
|
-
'src_port': src_port,
|
|
82
|
-
'dst_port': dst_port,
|
|
83
|
-
'type': 'ack',
|
|
84
|
-
'data': udp_data[:50]
|
|
85
|
-
})
|
|
86
|
-
elif magic == b'\x10\xcf\x87\x2a': # DATA
|
|
87
|
-
packets.append({
|
|
88
|
-
'num': packet_num,
|
|
89
|
-
'ts': pkt_header['ts_sec'] + pkt_header['ts_usec'] / 1000000,
|
|
90
|
-
'src_port': src_port,
|
|
91
|
-
'dst_port': dst_port,
|
|
92
|
-
'type': 'data',
|
|
93
|
-
'data': udp_data[:50]
|
|
94
|
-
})
|
|
95
|
-
|
|
96
|
-
packet_num += 1
|
|
97
|
-
|
|
98
|
-
print(f"\n=== {filename} ===")
|
|
99
|
-
print(f"Total packets analyzed: {packet_num}")
|
|
100
|
-
print(f"BCUDP packets found: {len(packets)}")
|
|
101
|
-
|
|
102
|
-
# Group by type
|
|
103
|
-
by_type = {}
|
|
104
|
-
for pkt in packets:
|
|
105
|
-
by_type.setdefault(pkt['type'], []).append(pkt)
|
|
106
|
-
|
|
107
|
-
for pkt_type, pkts in by_type.items():
|
|
108
|
-
print(f"\n{pkt_type.upper()}: {len(pkts)} packets")
|
|
109
|
-
if pkts:
|
|
110
|
-
print(f" First at {pkts[0]['ts']:.3f}s, last at {pkts[-1]['ts']:.3f}s")
|
|
111
|
-
print(f" Ports: src={set(p['src_port'] for p in pkts)}, dst={set(p['dst_port'] for p in pkts)}")
|
|
112
|
-
|
|
113
|
-
# Show first few discovery packets in detail
|
|
114
|
-
discovery_pkts = [p for p in packets if p['type'] == 'discovery']
|
|
115
|
-
if discovery_pkts:
|
|
116
|
-
print(f"\nFirst 5 discovery packets:")
|
|
117
|
-
for pkt in discovery_pkts[:5]:
|
|
118
|
-
print(f" #{pkt['num']} at {pkt['ts']:.3f}s: {pkt['src_port']} -> {pkt['dst_port']}")
|
|
119
|
-
# Look for XML-like content
|
|
120
|
-
data_str = pkt['data'].decode('latin1', errors='ignore')
|
|
121
|
-
if 'C2D' in data_str or 'D2C' in data_str:
|
|
122
|
-
# Try to extract XML tag
|
|
123
|
-
start = data_str.find('<')
|
|
124
|
-
if start >= 0:
|
|
125
|
-
end = data_str.find('>', start)
|
|
126
|
-
if end > start:
|
|
127
|
-
tag = data_str[start:end+1]
|
|
128
|
-
print(f" Tag: {tag}")
|
|
129
|
-
|
|
130
|
-
return packets
|
|
131
|
-
|
|
132
|
-
if __name__ == '__main__':
|
|
133
|
-
neolink_packets = analyze_pcap('neolink.pcapng')
|
|
134
|
-
print("\n" + "="*60)
|
|
135
|
-
scrypted_packets = analyze_pcap('scrypted.pcapng')
|