import 'dart:convert'; import 'dart:io'; import 'icmp_probe.dart'; import 'measure.dart'; /// The usual traceroute notation for ICMP destination unreachable codes: /// !N network, !H host, !P protocol, !F fragmentation needed, !X /// administratively prohibited; '' for port unreachable (expected for UDP). String unreachableMarker(int? code, {required bool v6}) { if (code == null) return '!'; if (v6) { return switch (code) { 0 => '!N', 1 || 5 || 6 => '!X', 3 => '!H', 4 => '', _ => '!<$code>', }; } return switch (code) { 0 || 6 => '!N', 1 || 7 => '!H', 2 => '!P', 3 => '', 4 => '!F', 9 || 10 || 13 => '!X', _ => '!<$code>', }; } /// One hop over all repetitions of a traceroute. class HopAggregate { final int ttl; /// Responding addresses with how often each answered. final addresses = {}; final stats = StatsAccumulator(); /// Probes of the latest repetition, in order. List latest = []; bool reached = false; final markers = {}; HopAggregate(this.ttl); void add(ProbeEvent e, Family family, int cycle) { if (e.from != null) { addresses.update(e.from!.address, (n) => n + 1, ifAbsent: () => 1); } final answered = e.rttMs != null && e.status != ProbeStatus.timeout; stats.add( Sample( seq: e.seq, time: e.time ?? DateTime.now(), family: family, address: e.from?.address ?? '*', rttMs: answered ? e.rttMs : null, outcome: answered ? Outcome.ok : e.status == ProbeStatus.sendFailed ? Outcome.error : Outcome.timeout, ), ); if (e.status == ProbeStatus.reply) reached = true; if (e.status == ProbeStatus.unreachable) { final m = unreachableMarker(e.icmpCode, v6: family == Family.v6); if (m.isNotEmpty) markers.add(m); } } } /// A traceroute of one IP version, possibly repeated. class TraceLane { final Family family; final InternetAddress target; final hops = {}; int cycles = 0; TraceLane(this.family, this.target); /// Starts a new repetition: the latest probes of every hop are cleared. void nextCycle() { cycles++; for (final h in hops.values) { h.latest = []; } } void add(ProbeEvent e) { final hop = hops.putIfAbsent(e.ttl, () => HopAggregate(e.ttl)); hop.latest = [...hop.latest, e]; hop.add(e, family, cycles); } List get ordered => (hops.values.toList()..sort((a, b) => a.ttl.compareTo(b.ttl))); bool get reached => hops.values.any((h) => h.reached); /// A compact snapshot for comparing two measurements. Map snapshot() => { for (final h in ordered) h.ttl: HopSnapshot( h.addresses.keys.toSet(), h.stats.stats().mean, h.stats.stats().lossPercent, ), }; } class HopSnapshot { final Set addresses; final double? meanMs; final double lossPercent; const HopSnapshot(this.addresses, this.meanMs, this.lossPercent); } /// The difference of one hop between an earlier and a later measurement. class HopChange { final int ttl; final HopSnapshot? before, after; const HopChange(this.ttl, this.before, this.after); /// The responding routers differ (or a hop appeared / disappeared). bool get pathChanged => before == null || after == null || (before!.addresses.isNotEmpty && after!.addresses.isNotEmpty && before!.addresses.intersection(after!.addresses).isEmpty); double? get meanDelta => before?.meanMs == null || after?.meanMs == null ? null : after!.meanMs! - before!.meanMs!; } List compareTraces( Map before, Map after, ) { final ttls = {...before.keys, ...after.keys}.toList()..sort(); return [for (final t in ttls) HopChange(t, before[t], after[t])]; } String _csv(Object? v) { final s = v?.toString() ?? ''; return s.contains(RegExp(r'[",\n\r]')) ? '"${s.replaceAll('"', '""')}"' : s; } String _n(double? v) => v == null ? '' : v.toStringAsFixed(3); String _probeText(ProbeEvent p, bool v6) { if (p.status == ProbeStatus.timeout) return '*'; if (p.status == ProbeStatus.sendFailed) return '! (${p.note ?? 'send'})'; final marker = p.status == ProbeStatus.unreachable ? unreachableMarker(p.icmpCode, v6: v6) : ''; return '${_n(p.rttMs)} ms${marker.isEmpty ? '' : ' $marker'}'; } /// A traceroute report of [lanes]. [names] maps addresses to reverse DNS /// names where known. String traceReport( ReportFormat format, { required String host, required IpMode mode, required Map settings, required List lanes, Map names = const {}, }) { switch (format) { case ReportFormat.json: return const JsonEncoder.withIndent(' ').convert({ 'tool': 'SM Mobile', 'method': 'traceroute', 'host': host, 'ipMode': mode.name, 'settings': settings, 'routes': [ for (final lane in lanes) { 'ipVersion': lane.family.label, 'target': lane.target.address, 'repetitions': lane.cycles, 'reached': lane.reached, 'hops': [ for (final h in lane.ordered) { 'hop': h.ttl, 'addresses': [ for (final a in h.addresses.keys) {'address': a, 'hostname': ?names[a]}, ], 'statistics': h.stats.stats().toJson(), 'markers': h.markers.toList(), 'reached': h.reached, 'latestProbes': [ for (final p in h.latest) { 'probe': p.seq, 'status': p.status.name, 'from': p.from?.address, 'rttMs': p.status == ProbeStatus.timeout ? null : p.rttMs, 'icmpCode': p.icmpCode, 'note': p.note, }, ], }, ], }, ], }); case ReportFormat.csv: final b = StringBuffer( 'ip_version,hop,probe,status,address,hostname,rtt_ms,icmp_code,note\n', ); for (final lane in lanes) { for (final h in lane.ordered) { for (final p in h.latest) { final a = p.from?.address; b.writeln( [ lane.family.label, h.ttl, p.seq, p.status.name, a ?? '', a == null ? '' : names[a] ?? '', p.status == ProbeStatus.timeout ? '' : _n(p.rttMs), p.icmpCode ?? '', p.note ?? '', ].map(_csv).join(','), ); } } } return b.toString(); case ReportFormat.txt: final b = StringBuffer('SM Mobile · traceroute · $host (${mode.name})\n'); settings.forEach((k, v) => b.writeln('$k: $v')); for (final lane in lanes) { b ..writeln() ..writeln( '${lane.family.label} → ${lane.target.address}' '${lane.cycles > 1 ? ' (${lane.cycles}×)' : ''}', ); for (final h in lane.ordered) { final who = h.addresses.keys .map((a) => names[a] == null ? a : '${names[a]} ($a)') .join(', '); final st = h.stats.stats(); b.writeln( '${h.ttl.toString().padLeft(2)} ${who.isEmpty ? '*' : who} ' '${h.latest.map((p) => _probeText(p, lane.family == Family.v6)).join(' ')}' '${st.successes > 0 ? ' [min/avg/max ${_n(st.min)}/${_n(st.mean)}/${_n(st.max)} ms, loss ${st.lossPercent.toStringAsFixed(0)} %]' : ''}', ); } b.writeln( lane.reached ? 'destination reached' : 'destination not reached', ); } return b.toString(); } }