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"
+ }
+}