diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..fe1152b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,30 @@ +**/.classpath +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/azds.yaml +**/bin +**/charts +**/docker-compose* +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +**/secrets.dev.yaml +**/values.dev.yaml +LICENSE +README.md +!**/.gitignore +!.git/HEAD +!.git/config +!.git/packed-refs +!.git/refs/heads/** \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..eb92615 --- /dev/null +++ b/.gitignore @@ -0,0 +1,370 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +# NCrunch +*.ncrunch*.user +_NCrunch_* +*.crunch.xml +nCrunchTemp_* + +sample-data/ \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..d7b0d5f --- /dev/null +++ b/README.md @@ -0,0 +1,2 @@ +# Livebox Exporter for Prometheus + diff --git a/src/Directory.Build.props b/src/Directory.Build.props new file mode 100644 index 0000000..1344b06 --- /dev/null +++ b/src/Directory.Build.props @@ -0,0 +1,8 @@ + + + true + false + false + false + + \ No newline at end of file diff --git a/src/Dockerfile b/src/Dockerfile new file mode 100644 index 0000000..575fa1f --- /dev/null +++ b/src/Dockerfile @@ -0,0 +1,23 @@ +#See https://aka.ms/customizecontainer to learn how to customize your debug container and how Visual Studio uses this Dockerfile to build your images for faster debugging. + +FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base +WORKDIR /app +EXPOSE 9105 + +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +ARG BUILD_CONFIGURATION=Release +WORKDIR /src +COPY ["LiveboxExporter/LiveboxExporter.csproj", "LiveboxExporter/"] +RUN dotnet restore "./LiveboxExporter/./LiveboxExporter.csproj" +COPY . . +WORKDIR "/src/LiveboxExporter" +RUN dotnet build "./LiveboxExporter.csproj" -c $BUILD_CONFIGURATION -o /app/build + +FROM build AS publish +ARG BUILD_CONFIGURATION=Release +RUN dotnet publish "./LiveboxExporter.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false + +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "LiveboxExporter.dll"] \ No newline at end of file diff --git a/src/GlobalAssemblyInfo.cs b/src/GlobalAssemblyInfo.cs new file mode 100644 index 0000000..cfaecec --- /dev/null +++ b/src/GlobalAssemblyInfo.cs @@ -0,0 +1,5 @@ +using System.Reflection; +using System.Runtime.CompilerServices; + +[assembly: AssemblyVersion("1.0.0.0")] +[assembly: AssemblyFileVersion("1.0.0.0")] \ No newline at end of file diff --git a/src/LiveboxExporter.sln b/src/LiveboxExporter.sln new file mode 100644 index 0000000..e8ced3f --- /dev/null +++ b/src/LiveboxExporter.sln @@ -0,0 +1,25 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.8.34408.163 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LiveboxExporter", "LiveboxExporter\LiveboxExporter.csproj", "{C69A75C8-C7A8-4453-A9F9-9BA302DB0680}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {C69A75C8-C7A8-4453-A9F9-9BA302DB0680}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C69A75C8-C7A8-4453-A9F9-9BA302DB0680}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C69A75C8-C7A8-4453-A9F9-9BA302DB0680}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C69A75C8-C7A8-4453-A9F9-9BA302DB0680}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {F0B6B109-64CB-4376-81DD-5247B0E96E2A} + EndGlobalSection +EndGlobal diff --git a/src/LiveboxExporter/Components/LiveboxAuthorizationHandler.cs b/src/LiveboxExporter/Components/LiveboxAuthorizationHandler.cs new file mode 100644 index 0000000..b482721 --- /dev/null +++ b/src/LiveboxExporter/Components/LiveboxAuthorizationHandler.cs @@ -0,0 +1,177 @@ +using Microsoft.Extensions.Options; +using Newtonsoft.Json; +using System.Net.Http.Headers; +using System.Net; + +namespace LiveboxExporter.Components +{ + public sealed class LiveboxAuthorizationHandler : DelegatingHandler + { + private readonly ILogger _logger; + private readonly string? _createContextJsonInput; + private string? _contextId; + + public static readonly HttpRequestOptionsKey ForceAuthOptionKey = new HttpRequestOptionsKey("force-auth"); + + record CreateContextData(string contextID); + record CreateContextResult(int status, CreateContextData data); + + public LiveboxAuthorizationHandler(IOptions options, ILogger logger) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + string? pwd = TryGetPassword(options.Value); + if (pwd is not null) + { + _createContextJsonInput = JsonConvert.SerializeObject(new LiveboxClient.SaHRequest("sah.Device.Information", "createContext", new Dictionary + { + { "applicationName", "webui"}, + { "username", "admin"}, + { "password", pwd } + })); + } + else + { + logger.LogWarning("Livebox admin password was not provided in application settings: some metrics will be missing."); + } + } + + private static string? TryGetPassword(LiveboxAuthorizationHandlerOptions options) + { + if (!string.IsNullOrEmpty(options.PasswordFile)) + { + if (!File.Exists(options.PasswordFile)) + throw new FileNotFoundException($"File not found or not accessible: '{options.PasswordFile}'.", options.PasswordFile); + return Nito.AsyncEx.AsyncContext.Run(() => File.ReadAllTextAsync(options.PasswordFile)); + } + return string.IsNullOrEmpty(options.Password) ? null : options.Password; + } + + protected override HttpResponseMessage Send(HttpRequestMessage request, CancellationToken cancellationToken) + { + if (request.Headers.Authorization is null && _createContextJsonInput is null) + { + if (_contextId is null || ShouldForceAuth(request.Options)) + { + _contextId = CreateContext(new Uri($"http://{request.RequestUri!.Authority}/"), cancellationToken); + } + + if (_contextId != null) + { + request.Headers.Authorization = new AuthenticationHeaderValue("X-Sah", _contextId); + } + } + + try + { + return base.Send(request, cancellationToken); + } + catch (HttpRequestException) + { + if (_contextId != null) + { + // Forget contextId in case of connectivity issue: if livebox reboot, it will not be fully recognized. + _contextId = null; + _logger.LogWarning("Authentication context cleared because of connectivity issue with Livebox."); + } + throw; + } + } + + private bool ShouldForceAuth(HttpRequestOptions requestOptions) + { + return requestOptions.TryGetValue(ForceAuthOptionKey, out bool flag) && flag; + } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + if (request.Headers.Authorization is null && _createContextJsonInput != null) + { + if (_contextId is null || ShouldForceAuth(request.Options)) + { + _contextId = await CreateContextAsync(new Uri($"http://{request.RequestUri!.Authority}/"), cancellationToken).ConfigureAwait(false); + } + + if (_contextId != null) + { + request.Headers.Authorization = new AuthenticationHeaderValue("X-Sah", _contextId); + } + } + + try + { + return await base.SendAsync(request, cancellationToken); + } + catch (HttpRequestException) + { + if (_contextId != null) + { + // Forget contextId in case of connectivity issue: if livebox reboot, it will not be fully recognized. + _contextId = null; + _logger.LogWarning("Authentication context cleared because of connectivity issue with Livebox."); + } + throw; + } + } + + private HttpRequestMessage CreateLoginRequest(Uri baseAddress) + { + if (_createContextJsonInput is null) + throw new InvalidOperationException(); + var request = LiveboxClient.CreateWsRequest(_createContextJsonInput, baseAddress); + request.Headers.Authorization = new AuthenticationHeaderValue("X-Sah-Login"); + return request; + } + + private string CreateContext(Uri baseAddress, CancellationToken cancellationToken) + { + using var request = CreateLoginRequest(baseAddress); + using var response = base.Send(request, cancellationToken); + string? responseJson = null; + if (response.StatusCode == HttpStatusCode.OK && + response.Content != null && + response.Content.Headers.ContentType?.MediaType?.EndsWith("json") == true) + { + using Stream responseStream = response.Content.ReadAsStream(cancellationToken); + using var reader = new StreamReader(responseStream); + responseJson = reader.ReadToEnd(); + var result = JsonConvert.DeserializeObject(responseJson); + if (result?.data?.contextID != null) + { + _logger.LogInformation("New authentication context created."); + return result.data.contextID; + } + } + throw new Exception($"Failed to create session context (authentication). Response: {response.StatusCode} - {response.Content?.Headers.ContentType} {responseJson}"); + } + + private async Task CreateContextAsync(Uri baseAddress, CancellationToken cancellationToken) + { + using var request = CreateLoginRequest(baseAddress); + using var response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false); + string? responseJson = null; + try + { + if (response.StatusCode == HttpStatusCode.OK && + response.Content != null && + response.Content.Headers.ContentType?.MediaType?.EndsWith("json") == true) + { + using Stream responseStream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + using var reader = new StreamReader(responseStream); + responseJson = await reader.ReadToEndAsync().ConfigureAwait(false); + var result = JsonConvert.DeserializeObject(responseJson); + if (result?.data?.contextID != null) + { + _logger.LogInformation("New authentication context created."); + return result.data.contextID; + } + } + } + catch + { + // eg: Response: OK - application/x-sah-ws-4-call+json; charset=UTF-8 {"status":null,"errors":[{"error":13,"description":"Permission denied","info":"sah.Device.Information"}]} + throw new Exception($"Failed to create session context (authentication). Response: {response.StatusCode} - {response.Content?.Headers.ContentType} {responseJson}"); + } + throw new Exception($"Failed to create session context (authentication). Response: {response.StatusCode} - {response.Content?.Headers.ContentType} {responseJson}"); + } + } +} diff --git a/src/LiveboxExporter/Components/LiveboxAuthorizationHandlerOptions.cs b/src/LiveboxExporter/Components/LiveboxAuthorizationHandlerOptions.cs new file mode 100644 index 0000000..3742a37 --- /dev/null +++ b/src/LiveboxExporter/Components/LiveboxAuthorizationHandlerOptions.cs @@ -0,0 +1,9 @@ +namespace LiveboxExporter.Components +{ + public sealed class LiveboxAuthorizationHandlerOptions + { + public string? Password { get; set; } + + public string? PasswordFile { get; set; } + } +} diff --git a/src/LiveboxExporter/Components/LiveboxClient.cs b/src/LiveboxExporter/Components/LiveboxClient.cs new file mode 100644 index 0000000..6056767 --- /dev/null +++ b/src/LiveboxExporter/Components/LiveboxClient.cs @@ -0,0 +1,72 @@ +using System.Net.Http.Headers; +using System.Net; + +namespace LiveboxExporter.Components +{ + public sealed class LiveboxClient + { + internal static readonly MediaTypeHeaderValue SahJsonContentType = new MediaTypeHeaderValue("application/x-sah-ws-4-call+json"); + internal record SaHRequest(string service, string method, Dictionary parameters); + internal record SaHResponse(HttpStatusCode status, string? json); + + private readonly HttpClient _httpClient; + private bool _forceAuthOnNextRequest; + + readonly Uri _wsBaseAddress; + static readonly Uri WsRelativeUrl = new Uri("ws", UriKind.Relative); + + public LiveboxClient(HttpClient httpClient) + { + _httpClient = httpClient; + if (httpClient.BaseAddress is null) + throw new ArgumentException("A base address must be provided.", nameof(httpClient.BaseAddress)); + _wsBaseAddress = new Uri(httpClient.BaseAddress, WsRelativeUrl); + } + + public void ForceAuthOnNextRequest() => _forceAuthOnNextRequest = true; + + public async Task RawCallFunctionWithoutParameter(string service, string method, CancellationToken cancellationToken) + { + using var request = CreateWsRequest(new SaHRequest(service, method, new Dictionary())); + if (_forceAuthOnNextRequest) + { + request.Options.Set(LiveboxAuthorizationHandler.ForceAuthOptionKey, true); + } + + using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + string? responseJson = null; + if (response.StatusCode == HttpStatusCode.OK && + response.Content != null && + response.Content.Headers.ContentType?.MediaType?.EndsWith("json") == true) + { + using Stream responseStream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + using var reader = new StreamReader(responseStream); + responseJson = await reader.ReadToEndAsync().ConfigureAwait(false); + _forceAuthOnNextRequest = false; + return responseJson; + } + return null; + } + + internal HttpRequestMessage CreateWsRequest(T payload) + { + var request = new HttpRequestMessage(HttpMethod.Post, _wsBaseAddress); + request.Content = JsonContent.Create(payload, SahJsonContentType); + return request; + } + + internal static HttpRequestMessage CreateWsRequest(T payload, Uri? baseAddress) + { + var request = new HttpRequestMessage(HttpMethod.Post, baseAddress is null ? WsRelativeUrl : new Uri(baseAddress, WsRelativeUrl)); + request.Content = JsonContent.Create(payload, SahJsonContentType); + return request; + } + + internal static HttpRequestMessage CreateWsRequest(string payloadJson, Uri? baseAddress) + { + var request = new HttpRequestMessage(HttpMethod.Post, baseAddress is null ? WsRelativeUrl : new Uri(baseAddress, WsRelativeUrl)); + request.Content = new StringContent(payloadJson, SahJsonContentType); + return request; + } + } +} diff --git a/src/LiveboxExporter/Components/LiveboxClientDiscovery.cs b/src/LiveboxExporter/Components/LiveboxClientDiscovery.cs new file mode 100644 index 0000000..2679f5e --- /dev/null +++ b/src/LiveboxExporter/Components/LiveboxClientDiscovery.cs @@ -0,0 +1,138 @@ +using Newtonsoft.Json; +using System.Net.Sockets; +using System.Net; +using System.Net.NetworkInformation; +using LiveboxExporter.Utility; +using System.Runtime.CompilerServices; + +namespace LiveboxExporter.Components +{ + public readonly record struct LiveboxDiscoveryResult(Uri? address, + string? productClass, + string? serialNumber, + string? softwareVersion, + string? baseMac, + IReadOnlyDictionary other); + + /// + /// Best effort to discover Livebox. Should work + /// if it's located in same subnet. + /// + /// + /// + public sealed class LiveboxClientDiscovery(HttpClient httpClient, ILogger logger) + { + private static readonly Uri[] DefaultGatewayAddresses = + [ + new Uri("http://192.168.1.1/"), + new Uri("http://livebox.home/") + ]; + + public async Task TryDiscoverLiveboxAddress(CancellationToken cancellationToken) + { + var tracertResult = TraceRoute(IPAddress.Parse("8.8.8.8"), cancellationToken).ConfigureAwait(false); + await foreach (IPAddress ipAddress in tracertResult.ConfigureAwait(false)) + { + if (ipAddress.AddressFamily == AddressFamily.InterNetwork && + IPAddressRange.IsPrivateAddress(ipAddress)) + { + var address = new Uri($"http://{ipAddress}/"); + var result = await ProbeLivebox(address, cancellationToken).ConfigureAwait(false); + if (result.address != null) + { + return result; + } + } + } + + foreach (Uri item in DefaultGatewayAddresses) + { + var result = await ProbeLivebox(item, cancellationToken).ConfigureAwait(false); + if (result.address != null) + { + return result; + } + } + + return default; + } + + private async Task ProbeLivebox(Uri baseAddress, CancellationToken cancellationToken) + { + try + { + using (var request = LiveboxClient.CreateWsRequest(new LiveboxClient.SaHRequest("DeviceInfo", "get", new Dictionary()), baseAddress)) + { + using (var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false)) + { + if (response.StatusCode == HttpStatusCode.OK && + response.Content != null && + response.Content.Headers.ContentType?.MediaType?.EndsWith("json") == true) + { + var deviceInfoResponseContent = new + { + status = new Dictionary() + }; + string json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + var info = JsonConvert.DeserializeAnonymousType(json, deviceInfoResponseContent); + if (info?.status?.Count > 0) + { + info.status.TryGetValue("ProductClass", out string? productClass); + info.status.TryGetValue("SerialNumber", out string? serial); + info.status.TryGetValue("SoftwareVersion", out string? version); + info.status.TryGetValue("BaseMAC", out string? baseMac); + var filter = new string[] + { + "ProductClass", + "SerialNumber", + "SoftwareVersion", + "BaseMAC" + }; + logger.LogInformation($"Discovery success: {baseMac} {baseAddress} - {productClass} {serial} {version}"); + return new LiveboxDiscoveryResult(baseAddress, + productClass, + serial, + version, + baseMac, + info.status.Where(s => !filter.Contains(s.Key)).ToDictionary(kvp => kvp.Key, kvp => kvp.Value)); + } + } + } + } + } + catch (Exception ex) + { + if (ex is not OperationCanceledException) + { + logger.LogError(ex, $"Failed to probe Livebox on {baseAddress}."); + } + } + return default; + } + + private static async IAsyncEnumerable TraceRoute(IPAddress target, [EnumeratorCancellation]CancellationToken cancellationToken) + { + // Src: https://stackoverflow.com/a/45565253/249742 + TimeSpan timeout = TimeSpan.FromMilliseconds(10000); + const int maxTTL = 30; + const int bufferSize = 32; + + byte[] buffer = new byte[bufferSize]; + new Random().NextBytes(buffer); + + using (var pinger = new Ping()) + { + for (int ttl = 1; ttl <= maxTTL; ttl++) + { + PingOptions options = new PingOptions(ttl, dontFragment: true); + PingReply reply = await pinger.SendPingAsync(target, timeout, buffer, options, cancellationToken).ConfigureAwait(false); + + yield return reply.Address; + + if (reply.Status != IPStatus.TtlExpired && reply.Status != IPStatus.TimedOut) + break; + } + } + } + } +} diff --git a/src/LiveboxExporter/Components/LiveboxMetricsBackgroundWorker.cs b/src/LiveboxExporter/Components/LiveboxMetricsBackgroundWorker.cs new file mode 100644 index 0000000..8eb6683 --- /dev/null +++ b/src/LiveboxExporter/Components/LiveboxMetricsBackgroundWorker.cs @@ -0,0 +1,354 @@ + +using LiveboxExporter.Components.Model; +using Microsoft.Extensions.Options; +using Newtonsoft.Json; +using Prometheus; + +namespace LiveboxExporter.Components +{ + public sealed class LiveboxMetricsBackgroundWorker : BackgroundService + { + private readonly ILogger _logger; + private readonly bool _authIsDisabled; + private readonly LiveboxClient _liveboxClient; + private readonly LiveboxMetrics _metrics; + private SysBusDeviceInfo? _lastDeviceInfo; + private readonly TimeSpan _timerInterval; + + private sealed class LiveboxMetrics + { + /* + Recommended order metrics to display: + Exporter Up (scraping ok) + Connection state + GPON state + * + device info Status (last to pass) + */ + private const string MetricPrefix = "livebox_"; + + private readonly bool _authIsDisabled; + + private readonly ILogger _logger; + + private readonly Counter + _deviceInfoNumberOfReboots; + + private readonly Gauge _exporterIsUp, _exporterMetricsOk; + + private readonly Gauge + _deviceInfoUpTime, + _deviceInfoStatus; + + private readonly Gauge + _deviceActive, + _deviceLinkState, + _deviceConnectionState, + _deviceInternet, + _deviceIpTv, + _deviceTelephony, + _deviceDownstreamCurrRate, + _deviceUpstreamCurrRate, + _deviceDownstreamMaxBitRate, + _deviceUpstreamMaxBitRate; + + private readonly Gauge + _nmcWanState, + _nmcLinkState, + _nmcGponState, + _nmcConnectionState; + + public LiveboxMetrics(bool authIsDisabled, ILogger logger) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _authIsDisabled = authIsDisabled; + + var gaugeConfig = new GaugeConfiguration { SuppressInitialValue = true }; + var counterConfig = new CounterConfiguration { SuppressInitialValue = true }; + + _exporterIsUp = Metrics.CreateGauge(MetricPrefix + "exporter_up", "Connectivity with Livebox is up (last cached value)", gaugeConfig); + _exporterMetricsOk = Metrics.CreateGauge(MetricPrefix + "exporter_metrics_up", "All exporter metrics are up to date (last cached value)", gaugeConfig); + + _deviceInfoUpTime = Metrics.CreateGauge(MetricPrefix + "device_info_uptime", "Up Time (last cached value)", gaugeConfig); + _deviceInfoStatus = Metrics.CreateGauge(MetricPrefix + "device_info_status", "Device Status (last cached value)", gaugeConfig); + _deviceInfoNumberOfReboots = Metrics.CreateCounter(MetricPrefix + "device_info_reboots_total", "Number of Reboots (last cached value)", counterConfig); + + _deviceActive = Metrics.CreateGauge(MetricPrefix + "device_active", "Device Active (last cached value)", gaugeConfig); + _deviceLinkState = Metrics.CreateGauge(MetricPrefix + "device_link_state", "Device Link State (last cached value)", gaugeConfig); + _deviceConnectionState = Metrics.CreateGauge(MetricPrefix + "device_connection_state", "Device Connection State (last cached value)", gaugeConfig); + + _deviceInternet = Metrics.CreateGauge(MetricPrefix + "device_internet", "Internet enabled (last cached value)", gaugeConfig); + _deviceIpTv = Metrics.CreateGauge(MetricPrefix + "device_iptv", "IPTV enabled (last cached value)", gaugeConfig); + _deviceTelephony = Metrics.CreateGauge(MetricPrefix + "device_telephony", "Telephony enabled (last cached value)", gaugeConfig); + _deviceDownstreamCurrRate = Metrics.CreateGauge(MetricPrefix + "device_downstream_curr_rate", "Downstream current rate (last cached value)", gaugeConfig); + _deviceUpstreamCurrRate = Metrics.CreateGauge(MetricPrefix + "device_upstream_curr_rate", "Upstream current rate (last cached value)", gaugeConfig); + _deviceDownstreamMaxBitRate = Metrics.CreateGauge(MetricPrefix + "device_downstream_max_bit_rate", "Downstream max bit rate (last cached value)", gaugeConfig); + _deviceUpstreamMaxBitRate = Metrics.CreateGauge(MetricPrefix + "device_upstream_max_bit_rate", "Upstream max bit rate (last cached value)", gaugeConfig); + + _nmcWanState = Metrics.CreateGauge(MetricPrefix + "nmc_wan_state", "NMC WAN state (last cached value)", gaugeConfig); + _nmcLinkState = Metrics.CreateGauge(MetricPrefix + "nmc_link_state", "NMC Link state (last cached value)", gaugeConfig); + _nmcGponState = Metrics.CreateGauge(MetricPrefix + "nmc_gpon_state", "NMC GPON state (last cached value)", gaugeConfig); + _nmcConnectionState = Metrics.CreateGauge(MetricPrefix + "nmc_connection_state", "NMC Connection state (last cached value)", gaugeConfig); + } + + public void Update(SysBusDeviceInfo? deviceInfo, SysBusDevice? device, SysBusNmc? nmc) + { + try + { + bool anyConnectivity = nmc != null || device != null || deviceInfo != null; + bool allGood = + nmc != null && nmc.status && nmc.data != null && + (device != null || _authIsDisabled) && + deviceInfo != null; + + _exporterIsUp.Set(MapToBooleanInteger(anyConnectivity)); + + if (deviceInfo != null) + { + SysBusDeviceInfo.Status status = deviceInfo.status; + if (status.UpTime.HasValue) + _deviceInfoUpTime.Set(status.UpTime.Value); + if (status.NumberOfReboots.HasValue) + _deviceInfoNumberOfReboots.IncTo(status.NumberOfReboots.Value); + + if (status.DeviceStatus != null) + { + // null if not auth. + _deviceInfoStatus.Set(MapToBooleanInteger(status.DeviceStatus)); + } + } + else if (!_authIsDisabled) + { + // With or without any connectivity: missing data when auth is enabled = not good device status. + _deviceInfoStatus.Set(0); + } + + if (device != null && device.status != null) + { + SysBusDevice.Status status = device.status; + _deviceDownstreamCurrRate.Set(status.DownstreamCurrRate); + _deviceUpstreamCurrRate.Set(status.UpstreamCurrRate); + _deviceDownstreamMaxBitRate.Set(status.DownstreamMaxBitRate); + _deviceUpstreamMaxBitRate.Set(status.UpstreamMaxBitRate); + + _deviceActive.Set(MapToBooleanInteger(status.Active)); + _deviceLinkState.Set(MapToBooleanInteger(status.LinkState)); + _deviceConnectionState.Set(MapToBooleanInteger(status.ConnectionState)); + _deviceInternet.Set(MapToBooleanInteger(status.Internet)); + _deviceTelephony.Set(MapToBooleanInteger(status.Telephony)); + _deviceIpTv.Set(MapToBooleanInteger(status.IPTV)); + } + else if (anyConnectivity && !_authIsDisabled) + { + // None of this data is available if not auth. + _deviceDownstreamCurrRate.Set(0); + _deviceUpstreamCurrRate.Set(0); + _deviceActive.Set(0); + _deviceLinkState.Set(0); + _deviceConnectionState.Set(0); + _deviceInternet.Set(0); + _deviceTelephony.Set(0); + _deviceIpTv.Set(0); + } + + if (nmc != null && nmc.status && nmc.data != null) + { + // All this is available even when not auth. + SysBusNmc.Data data = nmc.data; + _nmcGponState.Set(data.GponState == "O5_Operation" ? 1 : 0); + _nmcConnectionState.Set(MapToBooleanInteger(data.ConnectionState)); + _nmcLinkState.Set(MapToBooleanInteger(data.LinkState)); + _nmcWanState.Set(MapToBooleanInteger(data.WanState)); + } + else if (anyConnectivity) + { + _nmcGponState.Set(0); + _nmcConnectionState.Set(0); + _nmcLinkState.Set(0); + _nmcWanState.Set(0); + } + + _exporterMetricsOk.Set(MapToBooleanInteger(allGood)); + } + catch (Exception ex) + { + _logger.LogError(ex, $"Exception occured while updating metric values: {ex.Message}"); + _exporterIsUp.Set(0); + _exporterMetricsOk.Set(0); + } + } + + private static int MapToBooleanInteger(bool value) + { + return value ? 1 : 0; + } + + private static int MapToBooleanInteger(string? upOrBoundValue) + { + switch (upOrBoundValue) + { + case "Up": + case "up": + case "Bound": + return 1; + default: + return 0; + } + } + } + + public LiveboxMetricsBackgroundWorker(IOptions options, + LiveboxClient liveboxClient, + ILogger logger) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _liveboxClient = liveboxClient ?? throw new ArgumentNullException(nameof(liveboxClient)); + _authIsDisabled = options.Value.AuthIsDisabled; + _timerInterval = options.Value.TimerInterval ?? LiveboxMetricsBackgroundWorkerOptions.DefaultTimerInterval; + _metrics = new LiveboxMetrics(_authIsDisabled, logger); + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + using var timer = new PeriodicTimer(_timerInterval); + + _logger.LogInformation("Timer started"); + while (await timer.WaitForNextTickAsync(stoppingToken)) + { + try + { + await TimerElapsed(stoppingToken); + } + catch (OperationCanceledException) + { + break; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to collect Livebox metrics."); + } + } + } + + enum ScrapeStatus + { + Success, + ReAuthRequired, + Error + } + + private async Task Scrape(bool forceReAuth, CancellationToken cancellationToken) + { + SysBusNmc? nmc = null; + SysBusDeviceInfo? deviceInfo = null; + SysBusDevice? device = null; + try + { + if (forceReAuth || + _lastDeviceInfo is null || + _lastDeviceInfo.status.DeviceStatus != "Up" || + Random.Shared.NextDouble() > 0.5) + { + // We avoid getting device info each time. + if (forceReAuth) + { + _liveboxClient.ForceAuthOnNextRequest(); + } + + deviceInfo = await TryGetDeviceInfo(cancellationToken); + if (deviceInfo != null && deviceInfo.status is null) + { + // unexpected. + deviceInfo = null; + } + + if (deviceInfo != null && + deviceInfo.status != null && + deviceInfo.status.DeviceStatus != null) + { + // Gathered info from device info is incomplete when auth is invalid/expired. + // Cache only a full valid state. + _lastDeviceInfo = deviceInfo; + } + } + else + { + deviceInfo = _lastDeviceInfo; + } + + if (deviceInfo is not null) + { + // gathered info from device is available ONLY when auth context is valid. + device = await TryGetDevice(deviceInfo, cancellationToken); + if (device != null) + { + if (device.errors != null && device.errors.Length != 0) + { + if (!forceReAuth && + device.errors.Any(t => t.error == 13)) + { + // Permission denied + if (!_authIsDisabled) + { + return ScrapeStatus.ReAuthRequired; + } + } + else + { + _logger.LogError(string.Join(Environment.NewLine, device.errors.Select(t => $"{t.error}: {t.description}"))); + } + } + + if (device.status is null) + { + device = null; + } + } + } + + // gathered info from nmc is available even when auth is invalid/expired. + nmc = await TryGetNmcWanStatus(cancellationToken); + } + catch (Exception ex) + { + _logger.LogError(ex, $"Exception occured while scraping metrics: {ex.Message}"); + _metrics.Update(deviceInfo, device, nmc); + return ScrapeStatus.Error; + } + _metrics.Update(deviceInfo, device, nmc); + return ScrapeStatus.Success; + } + + private async Task TimerElapsed(CancellationToken cancellationToken) + { + ScrapeStatus status = await Scrape(false, cancellationToken); + if (status == ScrapeStatus.ReAuthRequired) + { + _logger.LogInformation("Auth context seems invalid or expired. Retry with new auth..."); + status = await Scrape(true, cancellationToken); + if (status == ScrapeStatus.ReAuthRequired) + { + _logger.LogError("Unable to scrape metrics due to authentication issue (maybe due to unexpected responses from livebox)."); + } + } + } + + private async Task TryGetDeviceInfo(CancellationToken cancellationToken) + { + string? raw = await _liveboxClient.RawCallFunctionWithoutParameter("sysbus.DeviceInfo", "get", cancellationToken); + return string.IsNullOrEmpty(raw) ? null : JsonConvert.DeserializeObject(raw); + } + + private async Task TryGetDevice(SysBusDeviceInfo info, CancellationToken cancellationToken) + { + string? raw = await _liveboxClient.RawCallFunctionWithoutParameter("sysbus.Devices.Device." + info.status.BaseMAC.ToUpper(), "get", cancellationToken); + return string.IsNullOrEmpty(raw) ? null : JsonConvert.DeserializeObject(raw); + } + + private async Task TryGetNmcWanStatus(CancellationToken cancellationToken) + { + string? raw = await _liveboxClient.RawCallFunctionWithoutParameter("sysbus.NMC", "getWANStatus", cancellationToken); + return string.IsNullOrEmpty(raw) ? null : JsonConvert.DeserializeObject(raw); + } + } +} diff --git a/src/LiveboxExporter/Components/LiveboxMetricsBackgroundWorkerOptions.cs b/src/LiveboxExporter/Components/LiveboxMetricsBackgroundWorkerOptions.cs new file mode 100644 index 0000000..5f10960 --- /dev/null +++ b/src/LiveboxExporter/Components/LiveboxMetricsBackgroundWorkerOptions.cs @@ -0,0 +1,11 @@ +namespace LiveboxExporter.Components +{ + public sealed class LiveboxMetricsBackgroundWorkerOptions + { + public static readonly TimeSpan DefaultTimerInterval = TimeSpan.FromSeconds(10); + + public bool AuthIsDisabled { get; set; } + + public TimeSpan? TimerInterval { get; set; } + } +} diff --git a/src/LiveboxExporter/Components/Model/SysBusDevice.cs b/src/LiveboxExporter/Components/Model/SysBusDevice.cs new file mode 100644 index 0000000..953aa7d --- /dev/null +++ b/src/LiveboxExporter/Components/Model/SysBusDevice.cs @@ -0,0 +1,32 @@ +namespace LiveboxExporter.Components.Model +{ + public class SysBusDevice + { + public Status? status { get; set; } + + public Error[]? errors { get; set; } + + public class Status + { + public bool Active { get; set; } + public string LinkState { get; set; } + public string ConnectionState { get; set; } + public bool Internet { get; set; } + public bool IPTV { get; set; } + public bool Telephony { get; set; } + public int DownstreamCurrRate { get; set; } + public int UpstreamCurrRate { get; set; } + public int DownstreamMaxBitRate { get; set; } + public int UpstreamMaxBitRate { get; set; } + } + + public class Error + { + public int error { get; set; } + public string description { get; set; } + public string info { get; set; } + } + } + + +} diff --git a/src/LiveboxExporter/Components/Model/SysBusDeviceInfo.cs b/src/LiveboxExporter/Components/Model/SysBusDeviceInfo.cs new file mode 100644 index 0000000..b9097ab --- /dev/null +++ b/src/LiveboxExporter/Components/Model/SysBusDeviceInfo.cs @@ -0,0 +1,15 @@ +namespace LiveboxExporter.Components.Model +{ + public class SysBusDeviceInfo + { + public Status status { get; set; } + + public class Status + { + public int? UpTime { get; set; } + public string? DeviceStatus { get; set; } + public int? NumberOfReboots { get; set; } + public string BaseMAC { get; set; } + } + } +} diff --git a/src/LiveboxExporter/Components/Model/SysBusNmc.cs b/src/LiveboxExporter/Components/Model/SysBusNmc.cs new file mode 100644 index 0000000..5cd65a8 --- /dev/null +++ b/src/LiveboxExporter/Components/Model/SysBusNmc.cs @@ -0,0 +1,16 @@ +namespace LiveboxExporter.Components.Model +{ + public class SysBusNmc + { + public bool status { get; set; } + public Data data { get; set; } + + public class Data + { + public string WanState { get; set; } + public string LinkState { get; set; } + public string GponState { get; set; } + public string ConnectionState { get; set; } + } + } +} diff --git a/src/LiveboxExporter/LiveboxExporter.csproj b/src/LiveboxExporter/LiveboxExporter.csproj new file mode 100644 index 0000000..96dc9c9 --- /dev/null +++ b/src/LiveboxExporter/LiveboxExporter.csproj @@ -0,0 +1,23 @@ + + + + net8.0 + enable + enable + true + Linux + + + + + + + + + + + + + + + diff --git a/src/LiveboxExporter/LiveboxExporter.http b/src/LiveboxExporter/LiveboxExporter.http new file mode 100644 index 0000000..8ebd9b7 --- /dev/null +++ b/src/LiveboxExporter/LiveboxExporter.http @@ -0,0 +1,5 @@ +@LiveboxExporter_HostAddress = http://localhost:5027 + +GET {{LiveboxExporter_HostAddress}}/metrics/ + +### diff --git a/src/LiveboxExporter/Program.cs b/src/LiveboxExporter/Program.cs new file mode 100644 index 0000000..587abff --- /dev/null +++ b/src/LiveboxExporter/Program.cs @@ -0,0 +1,80 @@ +using LiveboxExporter.Components; +using Prometheus; + +namespace LiveboxExporter +{ + public class Program + { + public static void Main(string[] args) + { + Metrics.SuppressDefaultMetrics(); + + WebApplicationBuilder builder = WebApplication.CreateBuilder(args); + builder.Services.AddHostedService(); + + builder.Services.AddTransient(); + builder.Services.Configure(builder.Configuration.GetSection("Livebox")); + builder.Services.Configure(builder.Configuration.GetSection("Livebox")); + + builder.Services + .AddHttpClient((services, client) => + { + string? host = builder.Configuration.GetValue("Livebox:Host"); + if (!string.IsNullOrEmpty(host)) + { + client.BaseAddress = new Uri($"http://{host}/"); + } + else + { + client.BaseAddress = TryDiscoverLiveboxAddressOrFail(services.GetRequiredService>()); + } + }) + .AddHttpMessageHandler(); + + var app = builder.Build(); + + app.Map("/metrics", builder => + { + builder.UseMetricServer(settings => settings.EnableOpenMetrics = false, url: null); + }); + + app.Map("/", () => "Livebox Exporter is up. See /metrics endpoint."); + + app.Run(); + } + + private static Uri TryDiscoverLiveboxAddressOrFail(ILogger logger) + { + return Nito.AsyncEx.AsyncContext.Run(() => TryDiscoverLiveboxAddressOrFailAsync(logger)); + } + + private static async Task TryDiscoverLiveboxAddressOrFailAsync(ILogger logger) + { + logger.LogInformation("Livebox address discovery..."); + using (var client = new HttpClient()) + { + client.Timeout = TimeSpan.FromSeconds(3); + var discovery = new LiveboxClientDiscovery(client, logger); + LiveboxDiscoveryResult result = await discovery.TryDiscoverLiveboxAddress(default); + return result.address ?? throw new NotSupportedException("Could not discovery Livebox address. Please configure it in application settings."); + } + } + + sealed class EmptyDiscoveryLogger : ILogger + { + public IDisposable? BeginScope(TState state) where TState : notnull + { + return null; + } + + public bool IsEnabled(LogLevel logLevel) + { + return false; + } + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + } + } + } +} diff --git a/src/LiveboxExporter/Properties/launchSettings.json b/src/LiveboxExporter/Properties/launchSettings.json new file mode 100644 index 0000000..aa68011 --- /dev/null +++ b/src/LiveboxExporter/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "http": { + "commandName": "Project", + "launchBrowser": true, + "launchUrl": "metrics", + "dotnetRunMessages": true, + "applicationUrl": "http://localhost:9105" + } + }, + "$schema": "http://json.schemastore.org/launchsettings.json" +} \ No newline at end of file diff --git a/src/LiveboxExporter/Utility/IPAddressRange.cs b/src/LiveboxExporter/Utility/IPAddressRange.cs new file mode 100644 index 0000000..8b159ab --- /dev/null +++ b/src/LiveboxExporter/Utility/IPAddressRange.cs @@ -0,0 +1,67 @@ +using System.Net.Sockets; +using System.Net; + +namespace LiveboxExporter.Utility +{ + public sealed class IPAddressRange + { + // Src: https://stackoverflow.com/a/2138724/249742 + + private static readonly IPAddressRange[] PrivateAddressRanges = + [ + new IPAddressRange(IPAddress.Parse("10.0.0.0"), IPAddress.Parse("10.255.255.255")), + new IPAddressRange(IPAddress.Parse("172.16.0.0"), IPAddress.Parse("172.31.255.255")), + new IPAddressRange(IPAddress.Parse("192.168.0.0"), IPAddress.Parse("192.168.255.255")), + ]; + + readonly AddressFamily addressFamily; + readonly byte[] lowerBytes; + readonly byte[] upperBytes; + + public IPAddressRange(IPAddress lowerInclusive, IPAddress upperInclusive) + { + addressFamily = lowerInclusive.AddressFamily; + lowerBytes = lowerInclusive.GetAddressBytes(); + upperBytes = upperInclusive.GetAddressBytes(); + } + + public static bool IsPrivateAddress(IPAddress address) + { + foreach (var range in PrivateAddressRanges) + { + if (range.IsInRange(address)) + return true; + } + + return false; + } + + public bool IsInRange(IPAddress address) + { + if (address.AddressFamily != addressFamily) + { + return false; + } + + byte[] addressBytes = address.GetAddressBytes(); + + bool lowerBoundary = true, upperBoundary = true; + + for (int i = 0; i < lowerBytes.Length && + (lowerBoundary || upperBoundary); i++) + { + if (lowerBoundary && addressBytes[i] < lowerBytes[i] || + upperBoundary && addressBytes[i] > upperBytes[i]) + { + return false; + } + + lowerBoundary &= addressBytes[i] == lowerBytes[i]; + upperBoundary &= addressBytes[i] == upperBytes[i]; + } + + return true; + } + } + +} diff --git a/src/LiveboxExporter/appsettings.Development.json b/src/LiveboxExporter/appsettings.Development.json new file mode 100644 index 0000000..79e0528 --- /dev/null +++ b/src/LiveboxExporter/appsettings.Development.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "System.Net.Http.HttpClient": "Information" + } + } +} diff --git a/src/LiveboxExporter/appsettings.json b/src/LiveboxExporter/appsettings.json new file mode 100644 index 0000000..a1eb591 --- /dev/null +++ b/src/LiveboxExporter/appsettings.json @@ -0,0 +1,17 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "System.Net.Http.HttpClient": "Warning" + } + }, + "AllowedHosts": "*", + "urls": "http://localhost:9105", + "Livebox": { + "Host": "", + "AuthIsDisabled": true, + "Password": "", + "TimerInterval": "00:00:10" + } +}