diff --git a/.github/workflows/dev-publish-function.yml b/.github/workflows/dev-publish-function.yml new file mode 100644 index 0000000..8d7aef0 --- /dev/null +++ b/.github/workflows/dev-publish-function.yml @@ -0,0 +1,56 @@ +name: DEV - Deploy DotNet project to Azure Function App + +on: + push: + branches: + - dev + +# CONFIGURATION +# For help, go to https://github.com/Azure/Actions +# +# 1. Set up the following secrets in your repository: +# AZURE_FUNCTIONAPP_PUBLISH_PROFILE +# +# 2. Change these variables for your configuration: +env: + AZURE_FUNCTIONAPP_NAME: 'IntuneTLSAuthDotNetDev' # set this to your function app name on Azure + AZURE_FUNCTIONAPP_PACKAGE_PATH: '.' # set this to the path to your function app project, defaults to the repository root + DOTNET_VERSION: '9.0.x' # set this to the dotnet version to use (e.g. '2.1.x', '3.1.x', '5.0.x') + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + permissions: + id-token: write # Required for OIDC + contents: read # Required for actions/checkout + environment: dev + steps: + - name: 'Checkout GitHub Action' + uses: actions/checkout@v3 + + - name: Setup DotNet ${{ env.DOTNET_VERSION }} Environment + uses: actions/setup-dotnet@v3 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + - name: 'Resolve Project Dependencies Using Dotnet' + shell: bash + run: | + pushd './${{ env.AZURE_FUNCTIONAPP_PACKAGE_PATH }}' + dotnet build --configuration Release --output ./output + popd + + - name: 'Log in to Azure with AZ CLI' + uses: azure/login@v2 + with: + client-id: ${{ vars.AZURE_CLIENT_ID }} # Required to log in with OIDC + tenant-id: ${{ vars.AZURE_TENANT_ID }} # Required to log in with OIDC + subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }} # Required to log in with OIDC + + - name: 'Run Azure Functions Action' + uses: Azure/functions-action@v1 + id: deploy-to-function-app + with: + app-name: ${{ env.AZURE_FUNCTIONAPP_NAME }} + package: '${{ env.AZURE_FUNCTIONAPP_PACKAGE_PATH }}/output' +# For more samples to get started with GitHub Action workflows to deploy to Azure, refer to https://github.com/Azure/actions-workflow-samples diff --git a/.github/workflows/prod-publish-function.yml b/.github/workflows/prod-publish-function.yml new file mode 100644 index 0000000..95dc8c8 --- /dev/null +++ b/.github/workflows/prod-publish-function.yml @@ -0,0 +1,56 @@ +name: PROD - Deploy DotNet project to Azure Function App + +on: + push: + branches: + - main + +# CONFIGURATION +# For help, go to https://github.com/Azure/Actions +# +# 1. Set up the following secrets in your repository: +# AZURE_FUNCTIONAPP_PUBLISH_PROFILE +# +# 2. Change these variables for your configuration: +env: + AZURE_FUNCTIONAPP_NAME: 'IntuneTLSAuthDotNet' # set this to your function app name on Azure + AZURE_FUNCTIONAPP_PACKAGE_PATH: '.' # set this to the path to your function app project, defaults to the repository root + DOTNET_VERSION: '9.0.x' # set this to the dotnet version to use (e.g. '2.1.x', '3.1.x', '5.0.x') + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + permissions: + id-token: write # Required for OIDC + contents: read # Required for actions/checkout + environment: prod + steps: + - name: 'Checkout GitHub Action' + uses: actions/checkout@v3 + + - name: Setup DotNet ${{ env.DOTNET_VERSION }} Environment + uses: actions/setup-dotnet@v3 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + - name: 'Resolve Project Dependencies Using Dotnet' + shell: bash + run: | + pushd './${{ env.AZURE_FUNCTIONAPP_PACKAGE_PATH }}' + dotnet build --configuration Release --output ./output + popd + + - name: 'Log in to Azure with AZ CLI' + uses: azure/login@v2 + with: + client-id: ${{ vars.AZURE_CLIENT_ID }} # Required to log in with OIDC + tenant-id: ${{ vars.AZURE_TENANT_ID }} # Required to log in with OIDC + subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }} # Required to log in with OIDC + + - name: 'Run Azure Functions Action' + uses: Azure/functions-action@v1 + id: deploy-to-function-app + with: + app-name: ${{ env.AZURE_FUNCTIONAPP_NAME }} + package: '${{ env.AZURE_FUNCTIONAPP_PACKAGE_PATH }}/output' +# For more samples to get started with GitHub Action workflows to deploy to Azure, refer to https://github.com/Azure/actions-workflow-samples diff --git a/AdminVerify.cs b/AdminVerify.cs new file mode 100644 index 0000000..18b761b --- /dev/null +++ b/AdminVerify.cs @@ -0,0 +1,51 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Extensions.Logging; +using IntuneTLSDotNet.Services; + +namespace IntuneTLSDotNet +{ + // Admin-only function endpoints secured by function key. + public class AdminVerify(IUnifiService unifiService) + { + [Function("AdminstuffAddIp")] // POST with body raw IP string + public async Task AddIp([ + HttpTrigger(AuthorizationLevel.Function, "post", Route = "ip")] HttpRequest req, + FunctionContext ctx) + { + var logger = ctx.GetLogger("AdminstuffAddIp"); + var body = await new StreamReader(req.Body).ReadToEndAsync(); + if (string.IsNullOrWhiteSpace(body)) return new BadRequestObjectResult("Body must contain IP"); + var ip = body.Trim(); + var success = await unifiService.AppendManualIpAsync(ip); + return success ? new OkObjectResult($"Added {ip}") : new BadRequestObjectResult($"Invalid or duplicate {ip}"); + } + + [Function("AdminstuffListIps")] // GET returns JSON array + public async Task ListIps([ + HttpTrigger(AuthorizationLevel.Function, "get", Route = "ips")] HttpRequest req, + FunctionContext ctx) + { + var logger = ctx.GetLogger("AdminstuffListIps"); + var list = await unifiService.GetAuthorizedIpListAsync(); + return new OkObjectResult(list); + } + + [Function("AdminstuffRefreshIps")] // POST triggers a forced refresh from Unifi API then returns list + public async Task RefreshIps([ + HttpTrigger(AuthorizationLevel.Function, "post", Route = "ips/refresh")] HttpRequest req, + FunctionContext ctx) + { + var logger = ctx.GetLogger("AdminstuffRefreshIps"); + if (unifiService is UnifiService concrete) + { + var list = await concrete.RefreshAuthorizedIpCacheAsync(); + logger.LogInformation("Cache refresh complete. {Count} IPs now in list.", list.Count); + return new OkObjectResult(list); + } + logger.LogError("Unable to refresh IP cache: service instance is not concrete UnifiService"); + return new StatusCodeResult(500); + } + } +} diff --git a/IntuneTLSDotNet.csproj b/IntuneTLSDotNet.csproj index a877502..692e6d2 100644 --- a/IntuneTLSDotNet.csproj +++ b/IntuneTLSDotNet.csproj @@ -12,18 +12,21 @@ - - - - - - - - - - - - + + + + + + + + + + + + + + + PreserveNewest diff --git a/IntuneTLSDotNet.sln b/IntuneTLSDotNet.sln index ad206b2..223dbde 100644 --- a/IntuneTLSDotNet.sln +++ b/IntuneTLSDotNet.sln @@ -1,13 +1,14 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.13.35818.85 d17.13 +# Visual Studio Version 18 +VisualStudioVersion = 18.0.11116.177 d18.0 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "IntuneTLSDotNet", "IntuneTLSDotNet.csproj", "{94FD06A9-2432-4461-B08F-3A4B64D5AB9C}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{8EC462FD-D22E-90A8-E5CE-7E832BA40C5D}" ProjectSection(SolutionItems) = preProject ApplicationInsights.config = ApplicationInsights.config + ..\..\..\AppData\Roaming\JetBrains\Rider2025.2\scratches\scratch.json = ..\..\..\AppData\Roaming\JetBrains\Rider2025.2\scratches\scratch.json EndProjectSection EndProject Global diff --git a/Program.cs b/Program.cs index 80e561b..6a06a34 100644 --- a/Program.cs +++ b/Program.cs @@ -3,25 +3,41 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Configuration; using IntuneTLSDotNet.Services; -using Azure.Monitor.OpenTelemetry.AspNetCore; -using Microsoft.Azure.Functions.Worker.OpenTelemetry; - +using Azure.Identity; +using StackExchange.Redis; // Create the function app builder var builder = FunctionsApplication.CreateBuilder(args); -// Ensure configuration is properly loaded from all sources -builder.Configuration.AddEnvironmentVariables(); - builder.ConfigureFunctionsWebApplication(); -// Register HttpClient and Unifi service +// Configure Azure Redis with Managed Identity +var redisConnectionString = builder.Configuration.GetValue("REDIS_CONNECTION_STRING"); +if (!string.IsNullOrEmpty(redisConnectionString)) +{ + var configurationOptions = ConfigurationOptions.Parse($"{redisConnectionString}"); + + // Use Azure Managed Identity for authentication + await configurationOptions.ConfigureForAzureWithTokenCredentialAsync(new DefaultAzureCredential()); + + builder.Services.AddStackExchangeRedisCache(options => + { + options.ConfigurationOptions = configurationOptions; + }); +} +else +{ + throw new InvalidOperationException("REDIS_CONNECTION_STRING is required for distributed caching"); +} + +// Register HttpClient and Unifi service with simplified logging builder.Services .AddHttpClient() - .AddSingleton(builder.Configuration) // Explicitly register IConfiguration + .AddSingleton(builder.Configuration) .AddSingleton() - .AddOpenTelemetry() - .UseAzureMonitor() - .UseFunctionsWorkerDefaults(); + .AddApplicationInsightsTelemetryWorkerService(); // Traditional App Insights integration + +// Remove OpenTelemetry completely +// builder.Services.AddOpenTelemetry().UseAzureMonitor().UseFunctionsWorkerDefaults(); builder.Build().Run(); \ No newline at end of file diff --git a/Services/IUnifiService.cs b/Services/IUnifiService.cs index 6f5fd36..9d96862 100644 --- a/Services/IUnifiService.cs +++ b/Services/IUnifiService.cs @@ -1,5 +1,7 @@ +using System.Net; using System.Text.Json; using System.Text.Json.Serialization; +using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; @@ -8,85 +10,176 @@ namespace IntuneTLSDotNet.Services public interface IUnifiService { Task IsIpAddressAuthorized(string ipAddress); + Task> GetAuthorizedIpListAsync(); + Task AppendManualIpAsync(string ipAddress); } - public class UnifiService : IUnifiService + public class UnifiService( + HttpClient httpClient, + IConfiguration configuration, + ILogger logger, + IDistributedCache cache) : IUnifiService { - private readonly HttpClient _httpClient; - private readonly ILogger _logger; - private readonly string _apiKey; - private readonly JsonSerializerOptions _jsonSerializerOptions; + private readonly string _apiKey = configuration["UNIFI_API_TOKEN"] ?? throw new InvalidOperationException("UNIFI_API_TOKEN not configured"); + private readonly JsonSerializerOptions _json = new() { PropertyNameCaseInsensitive = true }; + private readonly TimeSpan _cacheDuration = TimeSpan.FromMinutes(configuration.GetValue("UNIFI_CACHE_DURATION_MINUTES") ?? 5); + private const string CacheKey = "UnifiIpAddressList"; + private const string ManualCacheKey = "UnifiManualIpAddressList"; - public UnifiService(HttpClient httpClient, IConfiguration configuration, ILogger logger) + public async Task IsIpAddressAuthorized(string ipAddress) { - _httpClient = httpClient; - _logger = logger; + if (string.IsNullOrWhiteSpace(ipAddress)) return false; + var list = await GetAuthorizedIpListAsync(); + return list.Contains(ipAddress.Trim()); + } - // Try to get the API key from configuration with more detailed error handling - _apiKey = configuration["UNIFI_API_TOKEN"] ?? throw new InvalidOperationException("UNIFI_API_TOKEN not configured"); + public async Task> GetAuthorizedIpListAsync() + { + var apiIps = await GetOrFetchApiIpsAsync(); + var manualData = await cache.GetStringAsync(ManualCacheKey); + var manualIps = manualData != null ? JsonSerializer.Deserialize>(manualData, _json) ?? [] : []; + + return apiIps + .Concat(manualIps) + .Where(ip => !string.IsNullOrWhiteSpace(ip)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(ip => ip) + .ToList(); + } - // Initialize JsonSerializerOptions once and reuse it - _jsonSerializerOptions = new JsonSerializerOptions + public async Task AppendManualIpAsync(string ipAddress) + { + if (string.IsNullOrWhiteSpace(ipAddress)) return false; + ipAddress = ipAddress.Trim(); + if (!IPAddress.TryParse(ipAddress, out _)) return false; + + var manualData = await cache.GetStringAsync(ManualCacheKey); + var manualIps = manualData != null ? JsonSerializer.Deserialize>(manualData, _json) ?? [] : []; + if (manualIps.Contains(ipAddress)) return true; // already present + manualIps.Add(ipAddress); + + var serialized = JsonSerializer.Serialize(manualIps, _json); + await cache.SetStringAsync(ManualCacheKey, serialized, new DistributedCacheEntryOptions { - PropertyNameCaseInsensitive = true - }; + AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(12) + }); + var sanitizedIp = ipAddress.Replace("\r", "").Replace("\n", ""); + logger.LogInformation("Manual IP added {Ip}. Manual list count={Count}", sanitizedIp, manualIps.Count); + return true; } - public async Task IsIpAddressAuthorized(string ipAddress) + public async Task> RefreshAuthorizedIpCacheAsync() { - try + logger.LogInformation("Refreshing Unifi IP cache"); + var apiIps = await FetchIpAddressesFromApi(); + var serialized = JsonSerializer.Serialize(apiIps, _json); + await cache.SetStringAsync(CacheKey, serialized, new DistributedCacheEntryOptions { - _httpClient.DefaultRequestHeaders.Clear(); - _httpClient.DefaultRequestHeaders.Add("X-API-KEY", _apiKey); - - _logger.LogInformation("Sending request to Unifi API"); - var response = await _httpClient.GetAsync("https://api.ui.com/ea/hosts"); - response.EnsureSuccessStatusCode(); + AbsoluteExpirationRelativeToNow = _cacheDuration + }); + logger.LogInformation("Cache repopulated with {Count} IPs", apiIps.Count); + return await GetAuthorizedIpListAsync(); + } - var content = await response.Content.ReadAsStringAsync(); + private async Task> GetOrFetchApiIpsAsync() + { + var cachedData = await cache.GetStringAsync(CacheKey); + if (cachedData != null) + { + var list = JsonSerializer.Deserialize>(cachedData, _json) ?? []; + if (list.Count > 0) return list; + } - // Log the raw response to help with debugging - _logger.LogTrace("Raw Unifi API response: {RawResponse}", content); + logger.LogInformation("Cache miss -> fetching from API"); + var apiIps = await FetchIpAddressesFromApi(); + if (apiIps.Count > 0) + { + var serialized = JsonSerializer.Serialize(apiIps, _json); + await cache.SetStringAsync(CacheKey, serialized, new DistributedCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = _cacheDuration + }); + logger.LogInformation("Cached {Count} IPs for {Minutes} minutes", apiIps.Count, _cacheDuration.TotalMinutes); + } + return apiIps; + } - var unifiResponse = JsonSerializer.Deserialize(content, _jsonSerializerOptions); + private async Task> FetchIpAddressesFromApi() + { + httpClient.DefaultRequestHeaders.Clear(); + httpClient.DefaultRequestHeaders.Add("X-API-KEY", _apiKey); + + var response = await httpClient.GetAsync("https://api.ui.com/ea/hosts"); + response.EnsureSuccessStatusCode(); + var content = await response.Content.ReadAsStringAsync(); + var root = JsonSerializer.Deserialize(content, _json); + if (root?.Data == null) + { + logger.LogWarning("Unifi API returned no data"); + return []; + } - if (unifiResponse?.Data == null) + logger.LogInformation("Parsed {Count} hosts", root.Data.Count); + var publicIps = new HashSet(StringComparer.OrdinalIgnoreCase); + var rawIps = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var host in root.Data) + { + Collect(host.IpAddress); + var rs = host.ReportedState; + if (rs != null) { - _logger.LogWarning("No data returned from Unifi API"); - return false; + Collect(rs.Ip); + if (rs.Wans?.Count > 0) foreach (var w in rs.Wans) Collect(w.Ipv4); } - - // Log the list of IP addresses returned from the Unifi API - var ipAddresses = unifiResponse.Data.Select(host => host.IpAddress).ToList(); - _logger.LogInformation("Unifi API returned {Count} IP addresses: [{IpAddresses}]", - ipAddresses.Count, - string.Join(", ", ipAddresses)); - - bool isAuthorized = unifiResponse.Data.Any(host => host.IpAddress == ipAddress); - - return isAuthorized; - } - catch (JsonException ex) { - _logger.LogError(ex, "Error deserializing Unifi API response for IP address {IpAddress}", ipAddress); - return false; } - catch (Exception ex) { - _logger.LogError(ex, "Error calling Unifi API for IP address {IpAddress}", ipAddress); - return false; + void Collect(string? ip) + { + if (string.IsNullOrWhiteSpace(ip)) return; + ip = ip.Trim(); + if (!ip.Contains('.')) return; // skip IPv6 + rawIps.Add(ip); + if (IsPublicIpv4(ip)) publicIps.Add(ip); } + var final = publicIps.Count > 0 ? publicIps : rawIps; + logger.LogInformation("Collected {Public} public IPv4s (raw={Raw}) using {Final}", publicIps.Count, rawIps.Count, final.Count); + logger.LogDebug("Sample: {Sample}", string.Join(", ", final.Take(15))); + return final.OrderBy(ip => ip).ToList(); + } + + private static bool IsPublicIpv4(string ip) + { + if (!IPAddress.TryParse(ip, out var parsed)) return false; + if (parsed.AddressFamily != System.Net.Sockets.AddressFamily.InterNetwork) return false; + var b = parsed.GetAddressBytes(); + if (b[0] == 10) return false; + if (b[0] == 192 && b[1] == 168) return false; + if (b[0] == 172 && b[1] >= 16 && b[1] <= 31) return false; + if (b[0] == 127) return false; + if (b[0] == 169 && b[1] == 254) return false; + if (b[0] >= 224) return false; + return true; } } public class UnifiResponse { - [JsonPropertyName("data")] - public List Data { get; set; } = new List(); + [JsonPropertyName("data")] public List Data { get; set; } = []; } public class UnifiHost { - // Try the correct property name for IP address from Unifi API - [JsonPropertyName("ipAddress")] - public string IpAddress { get; set; } = string.Empty; + [JsonPropertyName("ipAddress")] public string IpAddress { get; set; } = string.Empty; + [JsonPropertyName("reportedState")] public ReportedState? ReportedState { get; set; } + } + + public class ReportedState + { + [JsonPropertyName("wans")] public List Wans { get; set; } = []; + [JsonPropertyName("ip")] public string? Ip { get; set; } + } + + public class Wan + { + [JsonPropertyName("ipv4")] public string Ipv4 { get; set; } = string.Empty; } } \ No newline at end of file diff --git a/Verify.cs b/Verify.cs index 18c7cde..f7c527b 100644 --- a/Verify.cs +++ b/Verify.cs @@ -6,36 +6,44 @@ namespace IntuneTLSDotNet { - public class Verify(ILogger logger, IUnifiService unifiService) + public class Verify(IUnifiService unifiService) { - private readonly ILogger _logger = logger; - private readonly IUnifiService _unifiService = unifiService; - [Function("Verify")] - public async Task Run([HttpTrigger(AuthorizationLevel.Anonymous, "get", "post")] HttpRequest req) + public async Task Run([HttpTrigger(AuthorizationLevel.Anonymous, "get", "post")] HttpRequest req, FunctionContext executionContext) { + const string testIp = "1.1.1.1"; + var logger = executionContext.GetLogger("Verify"); // Try to get the best client IP from available sources - string ipAddress = req.Headers["CLIENT-IP"]; + var ipAddress = !string.IsNullOrEmpty(req.Headers["CLIENT-IP"]) + ? req.Headers["CLIENT-IP"].ToString() + : testIp; + + // Remove any newline characters from ipAddress before logging (prevents log injection) + ipAddress = ipAddress.Replace("\r", "").Replace("\n", ""); + + // Log output for testing fallback IP + if (ipAddress == testIp) + logger.LogWarning("Testing IP is in use. This is likely being run in Local Dev. If not, abort immediately."); - // X-Forwarded-For can contain multiple IPs - we want the first one (client's original IP) + // Ensure no ports are present if (ipAddress.Contains(':')) { ipAddress = ipAddress.Split(':')[0].Trim(); } - _logger.LogInformation($"Using IP for authorization: {ipAddress}"); + logger.LogInformation("Using IP for authorization: {IpAddress}", ipAddress); // Check if the IP is authorized - bool isAuthorized = await _unifiService.IsIpAddressAuthorized(ipAddress); + var isAuthorized = await unifiService.IsIpAddressAuthorized(ipAddress); if (isAuthorized) { - _logger.LogInformation($"IP {ipAddress} is authorized"); + logger.LogInformation("IP {IpAddress} is authorized", ipAddress); return new OkObjectResult($"Authorization successful for {ipAddress}"); } else { - _logger.LogWarning($"IP {ipAddress} is not authorized"); + logger.LogWarning("IP {IpAddress} is not authorized", ipAddress); return new StatusCodeResult(403); } } diff --git a/azure-pipelines.yml b/azure-pipelines.yml deleted file mode 100644 index fe14d5b..0000000 --- a/azure-pipelines.yml +++ /dev/null @@ -1,40 +0,0 @@ -pool: - vmImage: ubuntu-latest - -trigger: - branches: - include: - - main - -steps: -- script: | - dotnet restore - dotnet build --configuration Release -- task: DotNetCoreCLI@2 - inputs: - command: publish - arguments: '--configuration Release --output publish_output' - projects: '*.csproj' - publishWebProjects: false - modifyOutputPath: false - zipAfterPublish: false -- task: ArchiveFiles@2 - displayName: "Archive files" - inputs: - rootFolderOrFile: "$(System.DefaultWorkingDirectory)/publish_output" - includeRootFolder: false - archiveFile: "$(Build.ArtifactStagingDirectory)/$(Build.BuildId).zip" -- task: PublishPipelineArtifact@1 - inputs: - targetPath: '$(Build.ArtifactStagingDirectory)/$(Build.BuildId).zip' - artifact: 'drop' - publishLocation: 'pipeline' - -- task: AzureFunctionApp@2 # Add this at the end of your file - inputs: - connectedServiceNameARM: 'IntuneTLSAuth' - appType: 'functionAppLinux' # This specifies a Linux-based function app - isFlexConsumption: true # Uncomment this line if you are deploying to a Flex Consumption app - appName: 'IntuneTLSAuthDotNet' - package: '$(Build.ArtifactStagingDirectory)/$(Build.BuildId).zip' - deploymentMethod: 'auto' # 'auto' | 'zipDeploy' | 'runFromPackage'. Required. Deployment method. Default: auto. \ No newline at end of file