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
15 changes: 15 additions & 0 deletions NetworkOptimizer.sln
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NetworkOptimizer.Agent", "s
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NetworkOptimizer.AgentProtocol", "src\NetworkOptimizer.AgentProtocol\NetworkOptimizer.AgentProtocol.csproj", "{CBF381E5-F3CF-4A4F-85C8-DD5BFAB51741}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NetworkOptimizer.AgentProtocol.Tests", "tests\NetworkOptimizer.AgentProtocol.Tests\NetworkOptimizer.AgentProtocol.Tests.csproj", "{EC6241C2-8CD8-4321-B93C-3ACC408050E9}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -381,6 +383,18 @@ Global
{CBF381E5-F3CF-4A4F-85C8-DD5BFAB51741}.Release|x64.Build.0 = Release|Any CPU
{CBF381E5-F3CF-4A4F-85C8-DD5BFAB51741}.Release|x86.ActiveCfg = Release|Any CPU
{CBF381E5-F3CF-4A4F-85C8-DD5BFAB51741}.Release|x86.Build.0 = Release|Any CPU
{EC6241C2-8CD8-4321-B93C-3ACC408050E9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{EC6241C2-8CD8-4321-B93C-3ACC408050E9}.Debug|Any CPU.Build.0 = Debug|Any CPU
{EC6241C2-8CD8-4321-B93C-3ACC408050E9}.Debug|x64.ActiveCfg = Debug|Any CPU
{EC6241C2-8CD8-4321-B93C-3ACC408050E9}.Debug|x64.Build.0 = Debug|Any CPU
{EC6241C2-8CD8-4321-B93C-3ACC408050E9}.Debug|x86.ActiveCfg = Debug|Any CPU
{EC6241C2-8CD8-4321-B93C-3ACC408050E9}.Debug|x86.Build.0 = Debug|Any CPU
{EC6241C2-8CD8-4321-B93C-3ACC408050E9}.Release|Any CPU.ActiveCfg = Release|Any CPU
{EC6241C2-8CD8-4321-B93C-3ACC408050E9}.Release|Any CPU.Build.0 = Release|Any CPU
{EC6241C2-8CD8-4321-B93C-3ACC408050E9}.Release|x64.ActiveCfg = Release|Any CPU
{EC6241C2-8CD8-4321-B93C-3ACC408050E9}.Release|x64.Build.0 = Release|Any CPU
{EC6241C2-8CD8-4321-B93C-3ACC408050E9}.Release|x86.ActiveCfg = Release|Any CPU
{EC6241C2-8CD8-4321-B93C-3ACC408050E9}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand All @@ -401,6 +415,7 @@ Global
{EC72C9AD-625C-4AA8-A7CC-744515E06F1E} = {5691A6DD-53B9-4CE0-A3C9-3D4F815E2120}
{01F2AE32-FE55-4892-B91B-CE2BC964F29D} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{CBF381E5-F3CF-4A4F-85C8-DD5BFAB51741} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{EC6241C2-8CD8-4321-B93C-3ACC408050E9} = {5691A6DD-53B9-4CE0-A3C9-3D4F815E2120}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {F7E6D5C4-B3A2-9180-7F6E-5D4C3B2A1908}
Expand Down
34 changes: 30 additions & 4 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -401,14 +401,35 @@ IPs. Commercial and even residential sites are stable enough in practice; a stol
from a random IP then dies at the firewall before the bearer key is presented. Bearer
key + rate-limiting stay as defense-in-depth behind it.

Implemented (agent-owned proxy controls; revises an earlier "not worth it" call):
- **Site-local proxy dial fence** (always on): `ProxyOpen` targets must be RFC1918 /
IPv6 unique-local / IPv6 link-local; hostnames are resolved once, every address
validated, and the dial uses the validated addresses. The earlier rationale ("gateway
SSH pivots anyway, so an allowlist contains nothing") was right about LAN containment
but missed the **internet-relay vector**: unrestricted `ProxyOpen` let a compromised
server use every site as a silent exit node against third parties - no gateway creds
needed, no footprint on customer equipment. The fence closes exactly that, and only
claims that.
- **Operator pin** (`proxyAllowedCidrs` in agent.json): replaces the fence with an
operator-owned CIDR list - real reach-capping the server can't override, and the only
escape hatch for public-IP targets.
- **Dial audit trail**: every proxy dial (allow + deny) journaled agent-side where the
server can't suppress it.
- NOT built (still on record as not worth it): a **server-pushed device allowlist**
(UniFi device list + custom targets enforced agent-side). The server is authoritative
for tunnel config, so a compromised server pushes its own list; signing doesn't help
(server holds the key) and TOFU/ratcheting either blocks legit changes (device
adoption, DHCP renumbering) or degrades to log-only. All complexity, no unforgeable
containment - the three controls above are the honest subset.

Considered and deliberately NOT implemented (rationale on record so it isn't re-litigated):
- **Agent-side proxy dial allowlist** (restrict `ProxyOpen` targets to the gateway/known
devices): not an effective control. Gateway SSH already reaches the whole LAN, so
anything with gateway access pivots through it regardless; restricting the agent's dial
targets contains nothing against a compromised server. Pure complexity.
- **Hard SSH host-key pinning**: impractical. UniFi regenerates SSH host keys on firmware
upgrades (and adoption/factory reset), so a strict pin breaks SSH after routine updates
and trains operators to click through warnings - worse than the soft tripwire above.
- **Agent-side SSH command filtering**: impossible at the agent - the proxied SSH session
is encrypted end-to-end between the central server and the gateway's sshd; the agent
pumps opaque bytes. Command safety lives server-side (parameterized command
construction); the gateway-side option is `authorized_keys` forced commands.

### Credential Key: Hardening Follow-ups

Expand Down Expand Up @@ -593,6 +614,11 @@ Goal: keep global channels applying everywhere, but let a site add its OWN addit
- **Fix:** Replace with an enum `DnatCoverageType` for type safety and discoverability
- **Scope:** `DnatDnsAnalyzer.cs` only - fully self-contained

### Relocate DefaultSiteSlug Constant to a Shared Project
- **Issue:** `SiteManagementService.DefaultSiteSlug` (`"main"`) lives in `NetworkOptimizer.Web`, but lower-level projects that reference only `NetworkOptimizer.Core` need the same value. `AlertProcessingService.ResolveSourceUrl` (`NetworkOptimizer.Alerts`) can't reference it - the dependency points the wrong way - so it hardcodes the literal `"main"` with a comment.
- **Fix:** Move the constant down into a shared low-level home (`NetworkOptimizer.Core`), then repoint every reference (`SiteManagementService`, `SiteContextService`, `AlertProcessingService`, and any others).
- **Scope:** Multiple projects; small but touches DI/reference sites, so needs a rebuild + test pass. Not urgent - the literal is correct as long as the constant stays `"main"`.

### ThirdPartyDnsDetector Probe Method Duplication
- **Issue:** Two overloads of `TryProbePiholeEndpointAsync` and `TryProbeAdGuardHomeEndpointAsync` - one takes a full URL, one takes IP+port+scheme. The logic is nearly identical.
- **Fix:** Unify into a single method that takes a URL string. The IP+port caller can construct the URL before calling.
Expand Down
27 changes: 15 additions & 12 deletions src/NetworkOptimizer.Agent/ProbeRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,16 @@ namespace NetworkOptimizer.Agent;
/// The site's latency/loss probe engine. The server pushes the site's
/// monitoring targets over the tunnel; this runner probes each on its own
/// cadence (2 s scheduler tick, per-target intervals, bounded concurrency,
/// mirroring the server's latency tier) and streams every result straight
/// back. Created per tunnel connection: the server re-pushes config on every
/// connect, and results have nowhere to go while the tunnel is down.
/// mirroring the server's latency tier) and enqueues every result into the
/// store-and-forward <see cref="ResultBuffer"/>. Runs for the life of the
/// agent process: probing continues through tunnel outages - which is exactly
/// when latency/loss data matters most - with the buffer holding the backlog
/// and the last pushed target set staying in effect until the server
/// re-pushes on reconnect.
/// </summary>
public sealed class ProbeRunner
{
private readonly Func<AgentMessage, bool> _send;
private readonly Action<AgentMessage> _send;
private readonly string? _defaultSourceIp;
private readonly LocalProbeExecutor _executor = new(NullLogger<LocalProbeExecutor>.Instance);
private readonly ConcurrentDictionary<string, DateTime> _lastProbed = new();
Expand All @@ -27,7 +30,7 @@ public sealed class ProbeRunner
/// own. This is the probe-only multi-WAN deployment knob: the gateway
/// policy-routes this source IP out a specific WAN.
/// </param>
public ProbeRunner(Func<AgentMessage, bool> send, string? defaultSourceIp = null)
public ProbeRunner(Action<AgentMessage> send, string? defaultSourceIp = null)
{
_send = send;
_defaultSourceIp = string.IsNullOrEmpty(defaultSourceIp) ? null : defaultSourceIp;
Expand All @@ -42,13 +45,13 @@ public void UpdateConfig(ProbeConfig config)
public async Task RunAsync(CancellationToken ct)
{
// Startup grace, mirroring the server latency tier's 20s initial delay
// (MonitoringCollectionAgent.RunTierAsync). A fresh ProbeRunner starts on every
// tunnel connect - the agent (re)starting, OR a NO-server restart that dropped
// and re-established the tunnel - and every target is "due" immediately. Probing
// the instant the process/link comes up catches the network still settling
// (routes, source-IP binds, the target device itself rebooting alongside) and
// records a false loss/latency spike right at the restart boundary. Wait for it
// to settle before the first tick, so no boundary sample is taken.
// (MonitoringCollectionAgent.RunTierAsync). At agent process start every target
// is "due" immediately, and probing the instant the process comes up catches
// the network still settling (routes, source-IP binds, the target device itself
// rebooting alongside) and records a false loss/latency spike right at the
// start boundary. Wait for it to settle before the first tick, so no boundary
// sample is taken. Tunnel reconnects no longer restart this runner, so no
// grace (or gap) applies there - probing runs continuously across outages.
try { await Task.Delay(TimeSpan.FromSeconds(20), ct); }
catch (OperationCanceledException) { return; }

Expand Down
89 changes: 77 additions & 12 deletions src/NetworkOptimizer.Agent/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using System.Runtime.InteropServices;
using System.Text.Json;
using NetworkOptimizer.Agent;
using NetworkOptimizer.AgentProtocol;

// On-site agent skeleton: enrolls against the central Network Optimizer server
// with a one-time token, persists its agent key and site slug, then heartbeats.
Expand Down Expand Up @@ -59,6 +60,28 @@ static bool IsHttpsUrl(string url) =>
return 1;
}

// Proxy dial policy: hardcoded site-local fence (RFC1918 / IPv6 local) unless the
// operator pins an explicit CIDR list in agent.json, which fully replaces it. The
// policy is agent-owned on purpose - nothing the server sends over the tunnel can
// widen it. An invalid entry aborts startup: failing loud beats silently running
// with a partial pin.
var proxyPolicy = NetworkOptimizer.Core.Helpers.ProxyDialPolicy.SiteLocal;
if (config.ProxyAllowedCidrs != null)
{
var pinned = NetworkOptimizer.Core.Helpers.ProxyDialPolicy.FromPinnedCidrs(config.ProxyAllowedCidrs, out var policyError);
if (pinned == null)
{
Console.Error.WriteLine($"Invalid proxyAllowedCidrs in {configPath}: {policyError}");
return 1;
}
proxyPolicy = pinned;
Console.WriteLine($"Proxy dials pinned to {pinned.PinnedCount} operator-configured CIDR(s)");
}
else
{
Console.WriteLine("Proxy dials restricted to site-local addresses (RFC1918 / IPv6 local)");
}

var version = Assembly.GetExecutingAssembly()
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion ?? "dev";

Expand Down Expand Up @@ -163,26 +186,41 @@ static bool IsHttpsUrl(string url) =>
speedTestServer is { } relayServer ? relayServer.PostIperf3ResultAsync : null, cts.Token);
}

// Monitoring collectors and their store-and-forward buffer live for the whole
// process, not per tunnel connection: probing and SNMP polling continue on the
// last pushed config while the tunnel is down - exactly when outage data
// matters most - and the buffered backlog (12 h / 64 MB caps, oldest dropped
// first) replays over the tunnel on reconnect. Only started when a tunnel is
// configured; without one no config ever arrives and there is nothing to run.
ResultBuffer? resultBuffer = null;
ProbeRunner? probeRunner = null;
SnmpRunner? snmpRunner = null;
Task? probeTask = null;
Task? snmpTask = null;
if (!string.IsNullOrEmpty(config.TunnelUrl))
{
resultBuffer = new ResultBuffer();
probeRunner = new ProbeRunner(resultBuffer.Enqueue, config.ProbeSourceIp);
snmpRunner = new SnmpRunner(resultBuffer.Enqueue);
probeTask = probeRunner.RunAsync(cts.Token);
snmpTask = snmpRunner.RunAsync(cts.Token);
}

// Prefer the persistent gRPC tunnel; REST heartbeats keep the agent visible as
// Online whenever the tunnel is unavailable (tunnel disabled server-side, an
// older server, or a network drop between reconnect attempts).
while (!cts.IsCancellationRequested)
{
if (!string.IsNullOrEmpty(config.TunnelUrl))
{
// The probe runner lives and dies with its tunnel connection: the
// server re-pushes probe config on every connect, and results have
// nowhere to go while the tunnel is down.
using var connectionCts = CancellationTokenSource.CreateLinkedTokenSource(cts.Token);
var tunnel = new TunnelClient();
var probeRunner = new ProbeRunner(tunnel.TrySend, config.ProbeSourceIp);
var snmpRunner = new SnmpRunner(tunnel);
var proxyHandler = new ProxyHandler(tunnel);
var proxyHandler = new ProxyHandler(tunnel, proxyPolicy);
var iperf3ClientRunner = new Iperf3ClientRunner(tunnel);
var uwnClientRunner = new UwnClientRunner(tunnel);
var probeRequestRunner = new ProbeRequestRunner(tunnel);
tunnel.OnProbeConfig = probeRunner.UpdateConfig;
tunnel.OnSnmpConfig = snmpRunner.UpdateConfig;
tunnel.OnProbeConfig = probeRunner!.UpdateConfig;
tunnel.OnSnmpConfig = snmpRunner!.UpdateConfig;
tunnel.OnWanSpeedTestConfig = wanConfig => speedTestServer?.UpdateWanServers(
wanConfig.Servers.Select(s => new SpeedTestServer.WanServerEntry(s.ServerId, s.Url)).ToList(),
wanConfig.DefaultServerId);
Expand All @@ -193,8 +231,17 @@ static bool IsHttpsUrl(string url) =>
tunnel.OnIperf3Request = iperf3ClientRunner.HandleAsync;
tunnel.OnUwnRequest = uwnClientRunner.HandleAsync;
tunnel.OnProbeRequest = probeRequestRunner.HandleAsync;
var probeTask = probeRunner.RunAsync(connectionCts.Token);
var snmpTask = snmpRunner.RunAsync(connectionCts.Token);
// Server confirms persisted result frames; trim them from the buffer so
// only unacked data is retained for replay.
tunnel.OnResultAck = seq => resultBuffer!.MarkAcked(seq);
var backlog = resultBuffer!.Count;
if (backlog > 0)
Console.WriteLine($"Buffered backlog: {backlog} result message(s) (~{resultBuffer.ApproxBytes / 1024} KB) will flush once the tunnel connects");
var droppedOffline = resultBuffer.TakeDroppedCount();
if (droppedOffline > 0)
Console.Error.WriteLine($"Result buffer caps dropped the {droppedOffline} oldest message(s) while the tunnel was down");

var drainTask = tunnel.DrainResultsAsync(resultBuffer, connectionCts.Token);
try
{
await tunnel.RunAsync(config.TunnelUrl, config.AgentKey!, version, lanIp, config.IgnoreSslErrors, cts.Token);
Expand All @@ -211,7 +258,9 @@ static bool IsHttpsUrl(string url) =>
finally
{
connectionCts.Cancel();
try { await Task.WhenAll(probeTask, snmpTask); } catch (OperationCanceledException) { }
// Nothing to salvage: the drain only peeks, so every unacked frame is
// still in the buffer and replays on the next connection.
try { await drainTask; } catch (OperationCanceledException) { }
}
}

Expand Down Expand Up @@ -248,13 +297,28 @@ static bool IsHttpsUrl(string url) =>
{
try { await iperf3Task; } catch (OperationCanceledException) { }
}
if (probeTask != null)
{
try { await probeTask; } catch (OperationCanceledException) { }
}
if (snmpTask != null)
{
try { await snmpTask; } catch (OperationCanceledException) { }
}

Console.WriteLine("Agent stopped");
return 0;

namespace NetworkOptimizer.Agent
{
/// <summary>Agent configuration file contents (agent.json).</summary>
/// <remarks>
/// ProxyAllowedCidrs: operator pin for tunnel proxy dial targets (IPs or CIDRs).
/// When present it fully replaces the built-in site-local fence - both the
/// narrowing knob (pin to a management VLAN) and the only escape hatch for a
/// public-IP target. Include every subnet holding the UniFi Console, gateway,
/// devices used for SSH/speed tests, and modem/ONT/hotspot status pages.
/// </remarks>
public record AgentConfig(
string ServerUrl,
string? EnrollmentToken,
Expand All @@ -264,7 +328,8 @@ public record AgentConfig(
string? TunnelUrl = null,
string? ProbeSourceIp = null,
bool LanSpeedTest = false,
int LanSpeedTestPort = 3000);
int LanSpeedTestPort = 3000,
IReadOnlyList<string>? ProxyAllowedCidrs = null);

public record EnrollmentResponse(string AgentKey, string SiteSlug, int? TunnelPort = null);

Expand Down
Loading
Loading