Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docker/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ RUN apt-get update && apt-get install -y \
curl \
iputils-ping \
traceroute \
dnsutils \
libcap2-bin \
libssl3 \
gosu \
Expand Down
7 changes: 6 additions & 1 deletion src/NetworkOptimizer.Agent/ProbeRequestRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,12 @@ public async Task HandleAsync(ProbeRequest request, CancellationToken ct)
string.IsNullOrEmpty(request.SourceIp) ? null : request.SourceIp);

string json;
if (request.Traceroute)
if (string.Equals(request.Kind, "dns", StringComparison.OrdinalIgnoreCase))
{
var result = await _executor.LookupAsync(target, request.Reverse, ct);
json = JsonSerializer.Serialize(result);
}
else if (request.Traceroute)
{
var result = await _executor.TracerouteAsync(
target,
Expand Down
6 changes: 6 additions & 0 deletions src/NetworkOptimizer.AgentProtocol/Protos/agent_tunnel.proto
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,12 @@ message ProbeRequest {
bool traceroute = 6; // false = ping burst, true = traceroute
int32 count = 7; // ping count (ping only)
int32 max_hops = 8; // traceroute max hops
// Probe verb, added after ping/traceroute. Empty means honor `traceroute` above, so an
// agent predating this field is unaffected. "dns" asks for a lookup; an agent that does
// not know the verb ignores this field and runs a ping, which the server detects from the
// missing Kind marker on the returned result rather than showing ping data as DNS.
string kind = 9; // "" | dns
bool reverse = 10; // dns: PTR lookup rather than forward
}

message ProbeResponse {
Expand Down
5 changes: 4 additions & 1 deletion src/NetworkOptimizer.Audit/Analyzers/VlanAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1178,7 +1178,10 @@ public List<AuditIssue> AnalyzeInfrastructureVlanPlacement(JsonElement deviceDat
if (string.IsNullOrEmpty(deviceType))
continue;

var parsedType = FromUniFiApiType(deviceType);
var model = device.GetStringOrNull("model");
var shortname = device.GetStringOrNull("shortname");
var parsedType = NetworkOptimizer.UniFi.UniFiProductDatabase.ClassifyDeviceType(
deviceType, model, shortname);

// Skip gateways - they're typically on VLAN 1 by default and that's OK
if (parsedType.IsGateway())
Expand Down
3 changes: 3 additions & 0 deletions src/NetworkOptimizer.Core/Enums/DeviceType.cs
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,7 @@ public static DeviceType FromUniFiApiType(string? apiType) =>
/// - ugw/usg/udm/uxg/ucg: Gateway (includes USG, UDM, UXG, Cloud Gateway)
/// - utr: Travel Router
/// - usw: Switch
/// - usp: Smart Power device
/// - umbb: Cellular Modem (Mobile Broadband)
/// - ubb: Building Bridge
/// - udb/uacc: Device Bridge
Expand Down Expand Up @@ -250,6 +251,8 @@ public static DeviceType FromUniFiApiType(string? apiType, string? model)
"utr" => DeviceType.TravelRouter,
// Switches
"usw" => DeviceType.Switch,
// Smart Power devices
"usp" => DeviceType.SmartPower,
// Modems
"umbb" => DeviceType.CellularModem,
"uci" => DeviceType.CableModem,
Expand Down
27 changes: 21 additions & 6 deletions src/NetworkOptimizer.Core/Helpers/NetworkFormatHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -136,15 +136,29 @@ public static string PonVariantLabel(string? sfpPart, string? sfpVendor)
"B.V", "N.V", "Pty", "A/S", "AB", "Oy", "AS"
};

/// <summary>
/// The last step of the boil-down: what a company is actually called, for the ones whose
/// stripped legal name is not the name anyone knows them by. Exact-match against the fully
/// stripped form, never a substring - the suffix passes have already run by the time this is
/// consulted, so a key is written the way the name comes out of them.
/// </summary>
private static readonly Dictionary<string, string> OrgAliases = new(StringComparer.OrdinalIgnoreCase)
{
// AS14593 registers as "Space Exploration Technologies Corporation", which strips to
// "Space Exploration" - a target list nobody would recognize as Starlink's operator.
["Space Exploration"] = "SpaceX",
};

/// <summary>
/// The storage-time ASN/org-name cleaner: strips industry suffixes (Communications, Telecom,
/// Broadband, Networks, Services, Parent, Holdings ...) and legal forms (LLC, Inc, AB ...) off
/// the tail ("Hisense Broadband Technologies Co Ltd" -> "Hisense", "Level 3 Parent, LLC" ->
/// "Level 3"). Applied once when a target's AsnName is persisted - auto-discovery
/// (UpstreamTracerService.CleanAsnName) and manual target add (LatencyTargetsCard) both call it,
/// so the two paths store identical names. This is the HEAVIER of the two ASN-name cleaners;
/// the lighter resolve/display pass that also carries brand overrides (e.g. Arelion Sweden ->
/// Arelion, applied without re-discovery) is AsnNameCleanup in AsnResolutionService.
/// "Level 3"), then applies <see cref="OrgAliases"/> to whatever is left. Applied once when a
/// target's AsnName is persisted - auto-discovery (UpstreamTracerService.CleanAsnName) and
/// manual target add (LatencyTargetsCard) both call it, so the two paths store identical
/// names. This is the HEAVIER of the two ASN-name cleaners; the lighter resolve/display pass
/// that also carries brand overrides (e.g. Arelion Sweden -> Arelion, applied without
/// re-discovery) is AsnNameCleanup in AsnResolutionService.
/// </summary>
public static string CleanOrgName(string? name)
{
Expand All @@ -163,6 +177,7 @@ public static string CleanOrgName(string? name)
}
}
} while (changed && cleaned.Contains(' '));
return cleaned.Trim();
cleaned = cleaned.Trim();
return OrgAliases.TryGetValue(cleaned, out var alias) ? alias : cleaned;
}
}
60 changes: 60 additions & 0 deletions src/NetworkOptimizer.Monitoring/Models/CellularModemStats.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,25 @@ public enum CellularNetworkMode
Nr5gSa
}

/// <summary>
/// EN-DC (5G NSA) state, derived from the modem's 5G NSA availability and DCNR
/// restriction flags. Distinguishes the one case a radio reset can fix - the network
/// permits EN-DC but the serving cell does not offer it - from cases where it cannot.
/// </summary>
public enum EnDcState
{
/// <summary>System info was unavailable, so nothing can be concluded.</summary>
Unknown,
/// <summary>An NR secondary cell is attached - 5G is up.</summary>
Attached,
/// <summary>The network is withholding EN-DC from this UE. No anchor change can help.</summary>
NetworkRestricted,
/// <summary>The serving cell offers EN-DC and the NR leg has not attached yet.</summary>
AnchorCapable,
/// <summary>EN-DC is permitted but the serving cell does not offer it.</summary>
AnchorMissing
}

/// <summary>
/// Comprehensive cellular modem statistics from qmicli commands
/// Supports LTE and 5G NR data from UniFi U5G-Max and similar modems
Expand Down Expand Up @@ -44,6 +63,32 @@ public class CellularModemStats
// Band info
public BandInfo? ActiveBand { get; set; }

// EN-DC (5G NSA) anchor state, from the modem's system info
/// <summary>Serving cell advertises EN-DC support. Null when system info was unavailable.</summary>
public bool? Is5gNsaAvailable { get; set; }

/// <summary>Network is withholding EN-DC from this UE. Null when system info was unavailable.</summary>
public bool? IsDcnrRestricted { get; set; }

/// <summary>
/// Why 5G is or is not up, from the pair of EN-DC flags. The pair matters: a missing
/// NR leg is only worth acting on when the network permits EN-DC and the serving cell
/// is the one withholding it.
/// </summary>
public EnDcState EnDc
{
get
{
if (!Is5gNsaAvailable.HasValue || !IsDcnrRestricted.HasValue)
return EnDcState.Unknown;
if (Nr5g?.Rsrp.HasValue == true)
return EnDcState.Attached;
if (IsDcnrRestricted.Value)
return EnDcState.NetworkRestricted;
return Is5gNsaAvailable.Value ? EnDcState.AnchorCapable : EnDcState.AnchorMissing;
}
}

/// <summary>
/// Detected network mode (LTE, 5G NSA, 5G SA)
/// </summary>
Expand Down Expand Up @@ -252,6 +297,21 @@ public class CellInfo

/// <summary>Is this the serving cell?</summary>
public bool IsServing { get; set; }

/// <summary>
/// eNodeB (site) identifier: the upper 20 bits of the 28-bit LTE cell id.
/// Null when <see cref="GlobalCellId"/> is absent or non-numeric.
/// </summary>
public int? EnbId => ParsedCellId >> 8;

/// <summary>
/// Sector within the site: the low 8 bits of the LTE cell id. Neighbor cells
/// sharing an <see cref="EnbId"/> are other sectors of the same tower.
/// </summary>
public int? SectorId => ParsedCellId & 0xFF;

private int? ParsedCellId =>
int.TryParse(GlobalCellId, out var eci) && eci > 0 ? eci : null;
}

/// <summary>
Expand Down
21 changes: 21 additions & 0 deletions src/NetworkOptimizer.Monitoring/Probes/IProbeExecutor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,27 @@ Task<PingProbeResult> PingAsync(
TimeSpan? perPingTimeout = null,
CancellationToken ct = default);

/// <summary>
/// Resolve a name, or a name for an address when <paramref name="reverse"/> is set, using
/// whatever resolver this vantage is configured with. Which resolver answers is the point:
/// two vantages on different WANs can legitimately disagree.
/// </summary>
/// <remarks>
/// Defaults to unsupported so a vantage with no lookup path (and any test double) keeps
/// compiling and says so plainly rather than returning an empty answer.
/// </remarks>
Task<DnsLookupResult> LookupAsync(
ProbeTarget target,
bool reverse = false,
CancellationToken ct = default)
=> Task.FromResult(new DnsLookupResult
{
Target = target,
Vantage = Vantage,
Timestamp = DateTime.UtcNow,
ErrorMessage = "This vantage cannot run DNS lookups."
});

/// <summary>Run a TCP-connect probe.</summary>
Task<TcpProbeResult> TcpProbeAsync(
ProbeTarget target,
Expand Down
91 changes: 91 additions & 0 deletions src/NetworkOptimizer.Monitoring/Probes/LocalProbeExecutor.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Diagnostics;
using System.Net;
using System.Net.NetworkInformation;
using System.Net.Sockets;
using Microsoft.Extensions.Logging;
Expand Down Expand Up @@ -285,6 +286,7 @@ private async Task<PingProbeResult> ManagedPingAsync(ProbeTarget target, int cou
var rtts = new List<double>();
int received = 0;
string? lastError = null;
string? resolvedAddress = null;

using var ping = new Ping();
for (int i = 0; i < count; i++)
Expand All @@ -305,6 +307,8 @@ private async Task<PingProbeResult> ManagedPingAsync(ProbeTarget target, int cou
{
received++;
rtts.Add(sw.Elapsed.TotalMilliseconds);
// Only a successful reply carries a real peer; a failure reports 0.0.0.0.
resolvedAddress ??= reply.Address?.ToString();
}
else
{
Expand Down Expand Up @@ -340,11 +344,98 @@ private async Task<PingProbeResult> ManagedPingAsync(ProbeTarget target, int cou
RttAvgMs = avg,
RttMaxMs = max,
JitterMs = jitter,
ResolvedAddress = string.Equals(resolvedAddress, target.Address, StringComparison.OrdinalIgnoreCase)
? null
: resolvedAddress,
ErrorMessage = received == 0 ? lastError : null,
Timestamp = DateTime.UtcNow
};
}

/// <inheritdoc/>
/// <remarks>
/// Prefers the nslookup binary, which names the resolver that answered - the field that makes
/// two vantages comparable. Windows and macOS ship it; the Docker image installs it. Where it
/// is missing the managed resolver still answers, minus the resolver identity.
/// </remarks>
public async Task<DnsLookupResult> LookupAsync(
ProbeTarget target,
bool reverse = false,
CancellationToken ct = default)
{
try
{
var psi = new ProcessStartInfo("nslookup", target.Address)
{
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};

using var proc = Process.Start(psi);
if (proc == null) return await ManagedLookupAsync(target, reverse, ct);

var stdoutTask = proc.StandardOutput.ReadToEndAsync(ct);
var stderrTask = proc.StandardError.ReadToEndAsync(ct);
using var killCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
killCts.CancelAfter(TimeSpan.FromSeconds(10));
try { await proc.WaitForExitAsync(killCts.Token); }
catch (OperationCanceledException) { try { proc.Kill(entireProcessTree: true); } catch { } }

var stdout = await SafeReadAsync(stdoutTask);
var stderr = await SafeReadAsync(stderrTask);
var combined = string.Concat(stdout, "\n", stderr);

if (string.IsNullOrWhiteSpace(combined))
return await ManagedLookupAsync(target, reverse, ct);

return NslookupOutputParser.Parse(combined, target, Vantage, reverse);
}
catch (Exception)
{
// No nslookup on this host (Win32Exception) or it misbehaved - the managed
// resolver still answers the question, just without naming the server.
return await ManagedLookupAsync(target, reverse, ct);
}
}

/// <summary>
/// Resolver-less fallback via the .NET resolver. It cannot report which server answered,
/// so Resolver stays null rather than naming something we did not observe.
/// </summary>
private async Task<DnsLookupResult> ManagedLookupAsync(
ProbeTarget target, bool reverse, CancellationToken ct)
{
var result = new DnsLookupResult
{
Kind = NslookupOutputParser.ResultKind,
Target = target,
Vantage = Vantage,
Timestamp = DateTime.UtcNow
};

try
{
if (reverse)
{
var entry = await Dns.GetHostEntryAsync(target.Address, ct);
return result with { CanonicalName = entry.HostName };
}

var addresses = await Dns.GetHostAddressesAsync(target.Address, ct);
return result with { Addresses = addresses.Select(a => a.ToString()).ToList() };
}
catch (SocketException ex) when (ex.SocketErrorCode == SocketError.HostNotFound)
{
return result with { NotFound = true };
}
catch (Exception ex)
{
return result with { ErrorMessage = ex.Message };
}
}

public async Task<TcpProbeResult> TcpProbeAsync(
ProbeTarget target,
TimeSpan? timeout = null,
Expand Down
Loading
Loading