Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 0 additions & 46 deletions src/TickerQ.Dashboard/DependencyInjection/AspNetCoreExtensions.cs

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,14 @@ private static void UseDashboardDelegate<TTimeTicker, TCronTicker>(this TickerOp
{
tickerConfiguration.UseDashboardApplication((appObj) =>
{
var app = (IApplicationBuilder)appObj;
if (appObj is not IApplicationBuilder app)
throw new InvalidOperationException(
"TickerQ Dashboard can only be used in ASP.NET Core applications. " +
"The current host does not provide an HTTP application pipeline " +
"(IApplicationBuilder is not available). " +
"If you are running a Worker Service, Console app, or background node, " +
"remove the dashboard configuration or move it to a WebApplication."
);
// Configure static files and middleware with endpoints
app.UseDashboardWithEndpoints<TTimeTicker, TCronTicker>(dashboardConfig);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -629,11 +629,20 @@ public async Task DeleteCronTickerOccurrenceByIdAsync(Guid id, CancellationToken
foreach (var tickerFunction in TickerFunctionProvider.TickerFunctions.Select(x => new { x.Key, x.Value.Priority }))
{
if (TickerFunctionProvider.TickerFunctionRequestTypes.TryGetValue(tickerFunction.Key,
out var functionTypeContext))
out var functionTypeContext) &&
functionTypeContext.Item2 != null)
{
JsonExampleGenerator.TryGenerateExampleJson(functionTypeContext.Item2, out var exampleJson);
yield return (tickerFunction.Key, (functionTypeContext.Item1, exampleJson, tickerFunction.Priority));
}
else if (TickerFunctionProvider.TickerFunctionRequestInfos.TryGetValue(tickerFunction.Key,
out var requestInfo))
{
var exampleJson = string.IsNullOrWhiteSpace(requestInfo.RequestExampleJson)
? null
: requestInfo.RequestExampleJson;
yield return (tickerFunction.Key, (requestInfo.RequestType ?? string.Empty, exampleJson, tickerFunction.Priority));
}
else
{
yield return (tickerFunction.Key, (string.Empty, null, tickerFunction.Priority));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ public sealed class Function
public string Id { get; set; } = string.Empty;
public string FunctionName { get; set; } = string.Empty;
public string RequestType { get; set; } = string.Empty;
public string? RequestExampleJson { get; set; }
public string? CronExpression { get; set; }
public int TaskPriority { get; set; }
public DateTime? AppliedAt { get; set; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ public sealed class RemoteTickerFunctionDescriptor
public string Name { get; set; } = string.Empty;
public string CronExpression { get; set; } = string.Empty;
public string Callback { get; set; } = string.Empty;
public string RequestType { get; set; } = string.Empty;
public string RequestExampleJson { get; set; } = string.Empty;
public TickerTaskPriority Priority { get; set; }
public bool IsActive { get; set; } = true;
}
74 changes: 74 additions & 0 deletions src/TickerQ.RemoteExecutor/RemoteExecutionDelegateFactory.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using TickerQ.Utilities;

namespace TickerQ.RemoteExecutor;

internal static class RemoteExecutionDelegateFactory
{
public static TickerFunctionDelegate Create(
string callbackUrl,
Func<IServiceProvider, string?> secretProvider,
bool allowEmptySecret)
{
if (string.IsNullOrWhiteSpace(callbackUrl))
throw new ArgumentException("Callback URL is required.", nameof(callbackUrl));

return async (ct, serviceProvider, context) =>
{
var httpClientFactory = serviceProvider.GetRequiredService<IHttpClientFactory>();
var httpClient = httpClientFactory.CreateClient("tickerq-callback");

var payload = new
{
context.Id,
context.FunctionName,
context.Type,
context.RetryCount,
context.ScheduledFor
};

var json = JsonSerializer.Serialize(payload);
var bodyBytes = Encoding.UTF8.GetBytes(json);

var uri = new Uri($"{callbackUrl}/execute");
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture);
var secret = secretProvider(serviceProvider);
var signature = ComputeSignature(secret, HttpMethod.Post.Method, uri.PathAndQuery, timestamp, bodyBytes, allowEmptySecret);

using var request = new HttpRequestMessage(HttpMethod.Post, uri);
request.Content = new StringContent(json, Encoding.UTF8, "application/json");
request.Headers.Add("X-TickerQ-Signature", signature);
request.Headers.Add("X-Timestamp", timestamp);

using var response = await httpClient.SendAsync(request, ct);
response.EnsureSuccessStatusCode();
};
}

private static string ComputeSignature(
string? secret,
string method,
string pathAndQuery,
string timestamp,
byte[] bodyBytes,
bool allowEmptySecret)
{
if (allowEmptySecret && string.IsNullOrWhiteSpace(secret))
return string.Empty;

var safeSecret = secret ?? string.Empty;
var header = $"{method}\n{pathAndQuery}\n{timestamp}\n";
var headerBytes = Encoding.UTF8.GetBytes(header);
var payload = new byte[headerBytes.Length + bodyBytes.Length];
Buffer.BlockCopy(headerBytes, 0, payload, 0, headerBytes.Length);
Buffer.BlockCopy(bodyBytes, 0, payload, headerBytes.Length, bodyBytes.Length);

var secretKey = Encoding.UTF8.GetBytes(safeSecret);
var signatureBytes = HMACSHA256.HashData(secretKey, payload);
return Convert.ToBase64String(signatureBytes);
}
}
103 changes: 38 additions & 65 deletions src/TickerQ.RemoteExecutor/RemoteExecutionEndpoints.cs
Original file line number Diff line number Diff line change
@@ -1,11 +1,6 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Globalization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using TickerQ.RemoteExecutor.Models;
using TickerQ.Utilities;
using TickerQ.Utilities.Entities;
Expand Down Expand Up @@ -108,62 +103,45 @@ private static void MapFunctionRegistration(IEndpointRouteBuilder group)
.ToArray();

var functionDict = TickerFunctionProvider.TickerFunctions.ToDictionary();
var requestInfoDict = TickerFunctionProvider.TickerFunctionRequestInfos?.ToDictionary()
?? new Dictionary<string, (string RequestType, string RequestExampleJson)>();

foreach (var newFunction in newFunctions)
{
// Handle inactive functions by removing them
if (!newFunction.IsActive)
{
functionDict.Remove(newFunction.Name);
if (RemoteFunctionRegistry.IsRemote(newFunction.Name) &&
functionDict.Remove(newFunction.Name))
{
RemoteFunctionRegistry.Remove(newFunction.Name);
requestInfoDict.Remove(newFunction.Name);
}
continue;
}

// Capture callback URL to avoid closure issues
var callbackUrl = newFunction.Callback.TrimEnd('/');

var newFunctionDelegate = new TickerFunctionDelegate(async (ct, serviceProvider, context) =>
{
var httpClientFactory = serviceProvider.GetRequiredService<IHttpClientFactory>();
var remoteOptions = serviceProvider.GetRequiredService<TickerQRemoteExecutionOptions>();
var httpClient = httpClientFactory.CreateClient("tickerq-callback");
var newFunctionDelegate = RemoteExecutionDelegateFactory.Create(
callbackUrl,
sp => sp.GetRequiredService<TickerQRemoteExecutionOptions>().WebHookSignature,
allowEmptySecret: true);

// Build a minimal payload describing the execution
var payload = new
{
context.Id,
context.FunctionName,
context.Type,
context.RetryCount,
context.ScheduledFor
};

// Serialize payload for signature computation
var json = JsonSerializer.Serialize(payload);
var bodyBytes = Encoding.UTF8.GetBytes(json);

// Build request with HMAC signature
var uri = new Uri($"{callbackUrl}/execute");
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture);
var signature = ComputeCallbackSignature(
remoteOptions.WebHookSignature,
HttpMethod.Post.Method,
uri.PathAndQuery,
timestamp,
bodyBytes);

using var request = new HttpRequestMessage(HttpMethod.Post, uri);
request.Content = new StringContent(json, Encoding.UTF8, "application/json");
request.Headers.Add("X-TickerQ-Signature", signature);
request.Headers.Add("X-Timestamp", timestamp);

using var response = await httpClient.SendAsync(request, ct);
response.EnsureSuccessStatusCode();
});

functionDict.TryAdd(newFunction.Name, (newFunction.CronExpression, newFunction.Priority, newFunctionDelegate));
if (functionDict.TryAdd(newFunction.Name, (newFunction.CronExpression, newFunction.Priority, newFunctionDelegate)))
{
RemoteFunctionRegistry.MarkRemote(newFunction.Name);
requestInfoDict[newFunction.Name] = (newFunction.RequestType, newFunction.RequestExampleJson);
}
}

TickerFunctionProvider.RegisterFunctions(functionDict);

var existingRequestTypes = TickerFunctionProvider.TickerFunctionRequestTypes;
if (existingRequestTypes != null && existingRequestTypes.Count > 0)
TickerFunctionProvider.RegisterRequestType(existingRequestTypes.ToDictionary());

TickerFunctionProvider.RegisterRequestInfo(requestInfoDict);
TickerFunctionProvider.Build();

if (cronPairs.Length > 0)
Expand Down Expand Up @@ -471,11 +449,26 @@ private static async Task<byte[]> ReadBodyBytesAsync(HttpRequest request, Cancel

private static bool RemoveFunctionByName(string functionName)
{
if (!RemoteFunctionRegistry.IsRemote(functionName))
return false;

var functionDict = TickerFunctionProvider.TickerFunctions.ToDictionary();
if (!functionDict.Remove(functionName))
return false;

RemoteFunctionRegistry.Remove(functionName);
var requestInfoDict = TickerFunctionProvider.TickerFunctionRequestInfos?.ToDictionary()
?? new Dictionary<string, (string RequestType, string RequestExampleJson)>();
requestInfoDict.Remove(functionName);
TickerFunctionProvider.RegisterFunctions(functionDict);
var existingRequestTypes = TickerFunctionProvider.TickerFunctionRequestTypes;
if (existingRequestTypes != null && existingRequestTypes.Count > 0)
{
var requestTypesDict = existingRequestTypes.ToDictionary();
requestTypesDict.Remove(functionName);
TickerFunctionProvider.RegisterRequestType(requestTypesDict);
}
TickerFunctionProvider.RegisterRequestInfo(requestInfoDict);
TickerFunctionProvider.Build();
return true;
}
Expand All @@ -491,24 +484,4 @@ private sealed class RemoveFunctionPayload
public string FunctionName { get; set; } = string.Empty;
}

private static string ComputeCallbackSignature(
string secret,
string method,
string pathAndQuery,
string timestamp,
byte[] bodyBytes)
{
if (string.IsNullOrWhiteSpace(secret))
return string.Empty;

var header = $"{method}\n{pathAndQuery}\n{timestamp}\n";
var headerBytes = Encoding.UTF8.GetBytes(header);
var payload = new byte[headerBytes.Length + bodyBytes.Length];
Buffer.BlockCopy(headerBytes, 0, payload, 0, headerBytes.Length);
Buffer.BlockCopy(bodyBytes, 0, payload, headerBytes.Length, bodyBytes.Length);

var secretKey = Encoding.UTF8.GetBytes(secret);
var signatureBytes = HMACSHA256.HashData(secretKey, payload);
return Convert.ToBase64String(signatureBytes);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ public static TickerOptionsBuilder<TTimeTicker, TCronTicker> AddTickerRemoteExec
cfg.DefaultRequestHeaders.Add("X-Api-Secret", tickerqRemoteExecutionOptions.ApiSecret);
});
services.AddHttpClient("tickerq-callback");
services.AddSingleton<ITickerExecutionTaskHandler, TickerRemoteExecutionTaskHandler>();
services.AddSingleton<TickerRemoteExecutionTaskHandler>();
services.AddSingleton<ITickerExecutionTaskHandler, TickerExecutionTaskHandlerRouter>();

// Register options as singleton so background service can access it
services.AddSingleton(tickerqRemoteExecutionOptions);
Expand Down
27 changes: 27 additions & 0 deletions src/TickerQ.RemoteExecutor/RemoteFunctionRegistry.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
using System.Collections.Concurrent;

namespace TickerQ.RemoteExecutor;

internal static class RemoteFunctionRegistry
{
private static readonly ConcurrentDictionary<string, byte> RemoteFunctions = new();

public static void MarkRemote(string functionName)
{
if (string.IsNullOrWhiteSpace(functionName))
return;

RemoteFunctions[functionName] = 0;
}

public static void Remove(string functionName)
{
if (string.IsNullOrWhiteSpace(functionName))
return;

RemoteFunctions.TryRemove(functionName, out _);
}

public static bool IsRemote(string functionName)
=> !string.IsNullOrWhiteSpace(functionName) && RemoteFunctions.ContainsKey(functionName);
}
Loading
Loading