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
2 changes: 1 addition & 1 deletion docker/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

# ===== Network Binding =====
# BIND_LOCALHOST_ONLY: Controls which network interfaces the app listens on
# - false (default): Binds to 0.0.0.0:8042 (accessible from network)
# - false (default): Binds to *:8042 (accessible from network, IPv4 + IPv6)
# - true: Binds to 127.0.0.1:8042 (localhost only, use with reverse proxy on same host)
# BIND_LOCALHOST_ONLY=false

Expand Down
6 changes: 3 additions & 3 deletions docker/NATIVE-DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH"

# Environment configuration
export TZ="America/Chicago" # Change to your timezone
export ASPNETCORE_URLS="http://0.0.0.0:8042"
export ASPNETCORE_URLS="http://*:8042"

# Host IP - required for iperf3 client result tracking
export HOST_IP="192.168.1.100" # Change to this Mac's IP address
Expand Down Expand Up @@ -310,7 +310,7 @@ cd "$(dirname "$0")"

# Environment configuration
export TZ="America/Chicago" # Change to your timezone
export ASPNETCORE_URLS="http://0.0.0.0:8042"
export ASPNETCORE_URLS="http://*:8042"

# Host IP - required for iperf3 client result tracking
export HOST_IP="192.168.1.100" # Change to this server's IP address
Expand Down Expand Up @@ -594,7 +594,7 @@ chmod +x /opt/network-optimizer/start.sh

Change the port in your startup script:
```bash
export ASPNETCORE_URLS="http://0.0.0.0:8080" # Use different port
export ASPNETCORE_URLS="http://*:8080" # Use different port
```

### Check Application Logs
Expand Down
4 changes: 2 additions & 2 deletions docker/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ if [ "${BIND_LOCALHOST_ONLY,,}" = "true" ]; then
export ASPNETCORE_URLS="http://127.0.0.1:8042"
echo "Binding to localhost only (127.0.0.1:8042)"
else
export ASPNETCORE_URLS="http://0.0.0.0:8042"
echo "Binding to all interfaces (0.0.0.0:8042)"
export ASPNETCORE_URLS="http://*:8042"
echo "Binding to all interfaces (*:8042, IPv4 + IPv6)"
fi

# Drop to app user and run the application
Expand Down
2 changes: 1 addition & 1 deletion scripts/install-macos-native.sh
Original file line number Diff line number Diff line change
Expand Up @@ -340,7 +340,7 @@ export PATH="$BREW_PREFIX/bin:/usr/local/bin:\$PATH"

# Environment configuration
export TZ="${TZ:-America/Chicago}"
export ASPNETCORE_URLS="http://0.0.0.0:8042"
export ASPNETCORE_URLS="http://*:8042"

# Enable iperf3 server for CLI-based client speed testing (port 5201)
export Iperf3Server__Enabled=true
Expand Down
45 changes: 43 additions & 2 deletions src/NetworkOptimizer.Audit/Dns/DnsSecurityAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,26 @@ private void ExtractWanDnsFromDevices(JsonElement deviceData, DnsSecurityResult
if (!device.TryGetProperty("port_table", out var portTable) || portTable.ValueKind != JsonValueKind.Array)
continue;

// Collect cellular WAN network names from device-level wan1/wan2 properties.
// Device keys "wan1","wan2" map to port_table network_name "wan","wan2".
var cellularWanNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var prop in device.EnumerateObject())
{
if (!prop.Name.StartsWith("wan", StringComparison.OrdinalIgnoreCase)
|| (prop.Name.Length > 3 && !prop.Name[3..].All(char.IsDigit)))
continue;
if (prop.Value.ValueKind != JsonValueKind.Object)
continue;

var wanType = prop.Value.GetStringOrNull("type");
if (wanType is "wireless_5g" or "lte" or "wireless_lte")
{
cellularWanNames.Add(prop.Name);
if (prop.Name == "wan1")
cellularWanNames.Add("wan");
}
}

foreach (var port in portTable.EnumerateArray())
{
var networkName = port.GetStringOrNull("network_name");
Expand All @@ -310,11 +330,31 @@ private void ExtractWanDnsFromDevices(JsonElement deviceData, DnsSecurityResult
var portMedia = port.GetStringOrNull("media") ?? "unknown";
var portUp = port.GetBoolOrDefault("up");
var portIp = port.GetStringOrNull("ip");
var portIfname = port.GetStringOrNull("ifname");

// Cellular WANs (U5G, LTE modems) don't allow DNS configuration
var isCellular = cellularWanNames.Contains(networkName)
|| (portIfname?.StartsWith("gre", StringComparison.OrdinalIgnoreCase) == true);

wanInterfacesChecked.Add(networkName);

_logger.LogInformation("WAN interface detected: {Interface} (name={Name}, media={Media}, up={Up}, ip={Ip})",
networkName, portName, portMedia, portUp, portIp ?? "none");
_logger.LogInformation("WAN interface detected: {Interface} (name={Name}, media={Media}, up={Up}, ip={Ip}, ifname={Ifname}, cellular={Cellular})",
networkName, portName, portMedia, portUp, portIp ?? "none", portIfname ?? "unknown", isCellular);

if (isCellular)
{
_logger.LogDebug("Skipping DNS check for cellular WAN interface {Interface}", networkName);
result.WanInterfaces.Add(new WanInterfaceDns
{
InterfaceName = networkName,
PortName = portName,
IpAddress = portIp,
IsUp = portUp,
IsCellular = true,
DnsServers = new List<string>()
});
continue;
}

// Check for DNS servers on this WAN port
var hasDnsProperty = port.TryGetProperty("dns", out var dnsArray);
Expand Down Expand Up @@ -2748,6 +2788,7 @@ public class WanInterfaceDns
public string? PortName { get; init; }
public string? IpAddress { get; init; }
public bool IsUp { get; init; }
public bool IsCellular { get; init; }
public List<string> DnsServers { get; init; } = new();
public bool HasStaticDns => DnsServers.Any();
public bool MatchesDoH { get; set; }
Expand Down
27 changes: 27 additions & 0 deletions src/NetworkOptimizer.UniFi/Models/UniFiNetworkConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,33 @@ public override void Write(Utf8JsonWriter writer, bool value, JsonSerializerOpti
}
}

/// <summary>
/// Nullable version of FlexibleBoolConverter for fields that may be absent from the response.
/// </summary>
public class FlexibleNullableBoolConverter : JsonConverter<bool?>
{
public override bool? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
return reader.TokenType switch
{
JsonTokenType.True => true,
JsonTokenType.False => false,
JsonTokenType.String => bool.TryParse(reader.GetString(), out var value) ? value : null,
JsonTokenType.Number => reader.GetInt32() != 0,
JsonTokenType.Null => null,
_ => null
};
}

public override void Write(Utf8JsonWriter writer, bool? value, JsonSerializerOptions options)
{
if (value.HasValue)
writer.WriteBooleanValue(value.Value);
else
writer.WriteNullValue();
}
}

/// <summary>
/// JSON converter that handles int values that may come as strings, empty strings, or null.
/// UniFi API sometimes returns VLAN IDs as strings or empty strings instead of numbers.
Expand Down
16 changes: 16 additions & 0 deletions src/NetworkOptimizer.UniFi/Models/UniFiWlanConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,22 @@ public class UniFiWlanConfig
[JsonPropertyName("minrate_na_advertising_rates")]
public bool MinrateNaAdvertisingRates { get; set; }

[JsonPropertyName("roaming_assistant_na_enabled")]
[JsonConverter(typeof(FlexibleNullableBoolConverter))]
public bool? RoamingAssistantNaEnabled { get; set; }

[JsonPropertyName("roaming_assistant_na_rssi")]
[JsonConverter(typeof(FlexibleIntConverter))]
public int? RoamingAssistantNaRssi { get; set; }

[JsonPropertyName("roaming_assistant_6e_enabled")]
[JsonConverter(typeof(FlexibleNullableBoolConverter))]
public bool? RoamingAssistant6eEnabled { get; set; }

[JsonPropertyName("roaming_assistant_6e_rssi")]
[JsonConverter(typeof(FlexibleIntConverter))]
public int? RoamingAssistant6eRssi { get; set; }

/// <summary>
/// AP group mode: "all" means broadcast on all APs, otherwise check ap_group_ids
/// </summary>
Expand Down
2 changes: 1 addition & 1 deletion src/NetworkOptimizer.Web/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
|| !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("ASPNETCORE_HTTP_PORTS"));
if (!urlsConfigured)
{
builder.WebHost.UseUrls("http://0.0.0.0:8042");
builder.WebHost.UseUrls("http://*:8042");
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,25 @@ else
log "journald already configured."
fi

# ─── Part 1b: syslog-ng persist file → tmpfs ───
#
# syslog-ng writes its state to a persist file on every config/state change.
# Default location is /var/log/.syslog-ng.persist (eMMC). Redirect to /run/
# (tmpfs) to eliminate these continuous eMMC writes. The persist file only
# tracks source read positions — losing it on reboot just means syslog-ng
# re-reads from the current journal position, which is fine for volatile logs.

PERSIST_CONF="/etc/default/syslog-ng-persist"
PERSIST_TMPFS="/run/syslog-ng.persist"

CURRENT_PERSIST=$(grep "^SYSLOGNG_OPTS" "$PERSIST_CONF" 2>/dev/null | grep -o 'persist-file=[^ "]*' | cut -d= -f2)

if [ "$CURRENT_PERSIST" != "$PERSIST_TMPFS" ]; then
echo "SYSLOGNG_OPTS=\"--persist-file=$PERSIST_TMPFS\"" > "$PERSIST_CONF"
log "Redirected syslog-ng persist file to tmpfs ($PERSIST_TMPFS)"
SYSLOG_CHANGED=true
fi

# ─── Part 2: syslog-ng local file destinations ───
#
# Comment out log{} lines that route to local file destinations on eMMC.
Expand Down
12 changes: 12 additions & 0 deletions src/NetworkOptimizer.WiFi/Models/WlanConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,18 @@ public class WlanConfiguration
/// </summary>
public bool MloEnabled { get; set; }

/// <summary>Whether Roaming Assistant is enabled on 5 GHz (SSID-level setting)</summary>
public bool? RoamingAssistant5GHzEnabled { get; set; }

/// <summary>Roaming Assistant RSSI threshold for 5 GHz (dBm)</summary>
public int? RoamingAssistant5GHzRssi { get; set; }

/// <summary>Whether Roaming Assistant is enabled on 6 GHz (SSID-level setting)</summary>
public bool? RoamingAssistant6GHzEnabled { get; set; }

/// <summary>Roaming Assistant RSSI threshold for 6 GHz (dBm)</summary>
public int? RoamingAssistant6GHzRssi { get; set; }

/// <summary>Minimum data rate settings</summary>
public MinRateSettings? MinRateSettings { get; set; }

Expand Down
4 changes: 4 additions & 0 deletions src/NetworkOptimizer.WiFi/Providers/UniFiLiveDataProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,10 @@ public async Task<List<WlanConfiguration>> GetWlanConfigurationsAsync(Cancellati
MinRate5GHz = w.MinrateNaEnabled ? w.MinrateNaDataRateKbps : null,
AdvertiseLowerRates = w.MinrateNgAdvertisingRates || w.MinrateNaAdvertisingRates
},
RoamingAssistant5GHzEnabled = w.RoamingAssistantNaEnabled,
RoamingAssistant5GHzRssi = w.RoamingAssistantNaRssi,
RoamingAssistant6GHzEnabled = w.RoamingAssistant6eEnabled,
RoamingAssistant6GHzRssi = w.RoamingAssistant6eRssi,
NetworkId = w.NetworkConfId,
PrivatePresharedKeysEnabled = w.PrivatePresharedKeysEnabled,
PpskNetworkIds = w.PrivatePresharedKeys?
Expand Down
91 changes: 71 additions & 20 deletions src/NetworkOptimizer.WiFi/Rules/RoamingAssistantRule.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,44 +2,95 @@

namespace NetworkOptimizer.WiFi.Rules;

/// <summary>
/// Rule that recommends enabling Roaming Assistant on 5 GHz radios.
/// Unlike Minimum RSSI, Roaming Assistant uses BSS transition frames (soft nudge)
/// instead of hard-disconnecting clients.
/// </summary>
public class RoamingAssistantRule : IWiFiOptimizerRule
{
public string RuleId => "WIFI-ROAMING-ASSISTANT-001";

public HealthIssue? Evaluate(WiFiOptimizerContext ctx)
{
// Only relevant for multi-AP deployments
if (ctx.AccessPoints.Count <= 1)
return null;

var apsWithout5gRoamingAssistant = ctx.AccessPoints
// Try SSID-level settings first (newer UniFi Network versions)
var enabledWlans = ctx.Wlans.Where(w => w.Enabled).ToList();
var hasSsidLevelSettings = enabledWlans.Any(w => w.RoamingAssistant5GHzEnabled.HasValue);

if (hasSsidLevelSettings)
return EvaluateSsidLevel(ctx, enabledWlans);

// Fallback: per-AP radio_table settings (older UniFi Network versions)
return EvaluatePerAp(ctx);
}

private static HealthIssue? EvaluateSsidLevel(WiFiOptimizerContext ctx, List<WlanConfiguration> enabledWlans)
{
var issues = new List<string>();

if (ctx.Has5GHzCoverage)
{
var wlansWithout5g = enabledWlans
.Where(w => w.EnabledBands.Contains(RadioBand.Band5GHz) &&
w.RoamingAssistant5GHzEnabled != true)
.ToList();

if (wlansWithout5g.Count > 0)
issues.Add($"5 GHz: {string.Join(", ", wlansWithout5g.Select(w => w.Name))}");
}

if (ctx.Has6GHzCoverage)
{
var wlansWithout6g = enabledWlans
.Where(w => w.EnabledBands.Contains(RadioBand.Band6GHz) &&
w.RoamingAssistant6GHzEnabled != true)
.ToList();

if (wlansWithout6g.Count > 0)
issues.Add($"6 GHz: {string.Join(", ", wlansWithout6g.Select(w => w.Name))}");
}

if (issues.Count == 0)
return null;

var affected = string.Join("; ", issues);
return BuildIssue(
$"SSIDs without Roaming Assistant - {affected}. " +
"Roaming Assistant uses BSS transition frames (soft nudge) instead of hard-disconnecting clients.",
affected,
"Per SSID: Settings > WiFi > (SSID) > Advanced > Roaming Assistant. " +
"Recommended threshold: -70 to -75 dBm.");
}

private static HealthIssue? EvaluatePerAp(WiFiOptimizerContext ctx)
{
var apsWithout = ctx.AccessPoints
.Where(ap => ap.Radios.Any(r =>
r.Band == RadioBand.Band5GHz &&
r.Channel.HasValue &&
!r.RoamingAssistantEnabled))
.ToList();

if (apsWithout5gRoamingAssistant.Count == 0)
if (apsWithout.Count == 0)
return null;

return new HealthIssue
{
Severity = HealthIssueSeverity.Info,
Dimensions = { HealthDimension.RoamingPerformance },
Title = "Enable Roaming Assistant (Recommended)",
Description = $"{apsWithout5gRoamingAssistant.Count} AP(s) don't have Roaming Assistant enabled on 5 GHz. " +
var names = string.Join(", ", apsWithout.Select(ap => ap.Name));
return BuildIssue(
$"{apsWithout.Count} AP(s) don't have Roaming Assistant enabled on 5 GHz. " +
"Unlike Minimum RSSI, this uses BSS transition frames (soft nudge) instead of hard-disconnecting clients.",
AffectedEntity = string.Join(", ", apsWithout5gRoamingAssistant.Select(ap => ap.Name)),
Recommendation = "Per AP: Devices > (AP) > Settings > Radios > 5 GHz > Roaming Assistant. " +
names,
"Per AP: Devices > (AP) > Settings > Radios > 5 GHz > Roaming Assistant. " +
"Or globally: Settings > WiFi > 5 GHz Roaming Assistant with 'Override All APs'. " +
"Recommended threshold: -70 to -75 dBm.",
ScoreImpact = -3,
ShowOnOverview = false // Informational, only relevant to Roaming tab
};
"Recommended threshold: -70 to -75 dBm.");
}

private static HealthIssue BuildIssue(string description, string affected, string recommendation) => new()
{
Severity = HealthIssueSeverity.Info,
Dimensions = { HealthDimension.RoamingPerformance },
Title = "Enable Roaming Assistant (Recommended)",
Description = description,
AffectedEntity = affected,
Recommendation = recommendation,
ScoreImpact = -3,
ShowOnOverview = false
};
}
Loading