-
Notifications
You must be signed in to change notification settings - Fork 183
/
Copy pathRouter.cs
292 lines (233 loc) · 11.9 KB
/
Router.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
using Azure.Data.AppConfiguration;
using Azure.Identity;
using Azure.Messaging.EventHubs;
using Azure.Messaging.EventHubs.Producer;
using Azure.Sdk.Tools.WebhookRouter.Integrations.GitHub;
using Azure.Security.KeyVault.Secrets;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.IO;
using System.Runtime.CompilerServices;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
namespace Azure.Sdk.Tools.WebhookRouter.Routing
{
public class Router : IRouter
{
public Router(IMemoryCache cache, ILogger<IRouter> logger, ConfigurationClient configurationClient, SecretClient secretClient)
{
this.cache = cache;
this.logger = logger;
this.configurationClient = configurationClient;
this.secretClient = secretClient;
}
private IMemoryCache cache;
private ILogger logger;
private ConfigurationClient configurationClient;
private SecretClient secretClient;
private async Task<string> GetSecretAsync(string secretName)
{
var secretCacheKey = $"{secretName}_secretCacheKey";
logger.LogInformation("Fetching secret with cache key: {secretCacheKey}", secretCacheKey);
var cachedSecret = await cache.GetOrCreateAsync<string>(secretCacheKey, async (entry) =>
{
logger.LogInformation("Cache empty, fetching secret from KeyVault for cache key: {secretCacheKey}", secretCacheKey);
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(60);
KeyVaultSecret response = await secretClient.GetSecretAsync(secretName);
var secret = response.Value;
return secret;
});
return cachedSecret;
}
// This key filter template is used to select all the configuration values
// for a particular routing rule. We do this to fetch all the configuration
// values at once rather than making a call to the app config service
// multiple times.
private const string SettingSelectorKeyPrefixTemplate = "webhookrouter/rules/{0}/";
private async Task<Dictionary<string, string>> GetSettingsAsync(Guid route)
{
var routeSettingsCacheKey = $"{route}_routeSettingsCacheKey";
var cachedSettings = await cache.GetOrCreateAsync<Dictionary<string, string>>(routeSettingsCacheKey, async (entry) =>
{
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(15);
return await GetSettingsFromAppConfigurationService(route);
});
return cachedSettings;
}
private async Task<Dictionary<string, string>> GetSettingsFromAppConfigurationService(Guid route)
{
var settingSelectorKeyPrefix = string.Format(SettingSelectorKeyPrefixTemplate, route);
var settingSelectorKeyFilter = $"{settingSelectorKeyPrefix}*";
logger.LogInformation("Fetching settings for route {route} with filter {filter}", route, settingSelectorKeyFilter);
var settingSelector = new SettingSelector()
{
KeyFilter = settingSelectorKeyFilter
};
var settingsPages = configurationClient.GetConfigurationSettingsAsync(settingSelector);
var settings = new Dictionary<string, string>();
await foreach (var setting in settingsPages)
{
var relativeSettingKey = setting.Key.Substring(settingSelectorKeyPrefix.Length);
settings.Add(relativeSettingKey, setting.Value);
}
if (!settings.ContainsKey("payload-type") && !settings.ContainsKey("eventhubs-namespace") && !settings.ContainsKey("eventhub-name"))
{
throw new RouterConfigurationException($"Critical settings for route {route} not present.");
}
return settings;
}
private async Task<Rule> GetRuleAsync(Guid route)
{
logger.LogInformation("Fetching routing rule for route: {route}", route);
var settings = await GetSettingsAsync(route);
logger.LogInformation("Route {route} had {dictionaryCount} in settings dictionary.", route, settings.Count);
var payloadType = (PayloadType)Enum.Parse(typeof(PayloadType), settings["payload-type"], true);
var eventHubsNamespace = settings["eventhubs-namespace"];
var eventHubName = settings["eventhub-name"];
logger.LogInformation(
"Route {route} points to namespace {namespace} with hub name {hubName} with payload type {payloadType}.",
route,
eventHubsNamespace,
eventHubName,
payloadType
);
Rule rule = payloadType switch
{
PayloadType.GitHub => new GitHubRule(
route,
eventHubsNamespace,
eventHubName,
settings["github-webhook-secret"]),
PayloadType.AzureDevOps => new AzureDevOpsRule(
route,
eventHubsNamespace,
eventHubName,
settings["azure-devops-webhook-credential-hash"],
settings["azure-devops-webhook-credential-salt"]),
_ => new GenericRule(
route,
eventHubsNamespace,
eventHubName)
};
return rule;
}
private async Task<byte[]> ReadAndValidateContentFromGitHubAsync(GitHubRule rule, HttpRequest request)
{
var payloadContent = await ReadAndValidateContentFromGenericAsync(rule, request);
var secret = await GetSecretAsync(rule.WebhookSecret);
var signature = request.Headers[GitHubWebhookSignatureValidator.GitHubWebhookSignatureHeader];
bool isValid = GitHubWebhookSignatureValidator.IsValid(payloadContent, signature, secret);
if (!isValid)
{
throw new RouterAuthorizationException("Signature validation failed.");
}
return payloadContent;
}
private SHA256 sha256 = SHA256.Create();
private async Task<byte[]> ReadAndValidateContentFromAzureDevOpsAsync(AzureDevOpsRule rule, HttpRequest request)
{
var payloadContent = await ReadAndValidateContentFromGenericAsync(rule, request);
var credentialHash = await GetSecretAsync(rule.CredentialHash);
var credentialSalt = await GetSecretAsync(rule.CredentialSalt);
var authorizationHeader = request.Headers["Authorization"].ToString();
var base64EncodedCredentials = authorizationHeader.Replace("Basic ", "");
var base64EncodedCredentialsWithSalt = $"{base64EncodedCredentials}{credentialSalt}";
var base64EncodedCredentialsWithSaltBytes = Encoding.UTF8.GetBytes(base64EncodedCredentialsWithSalt);
var generatedCredentialHashBytes = sha256.ComputeHash(base64EncodedCredentialsWithSaltBytes);
var generatedCredentialHash = Convert.ToBase64String(generatedCredentialHashBytes);
if (credentialHash != generatedCredentialHash)
{
throw new RouterAuthorizationException("Credential validation failed.");
}
return payloadContent;
}
private async Task<byte[]> ReadContentFromRequest(HttpRequest request)
{
using var stream = new MemoryStream();
await request.Body.CopyToAsync(stream);
var payloadContent = stream.ToArray();
logger.LogInformation("Payload length was {length} bytes.", payloadContent.Length);
return payloadContent;
}
private async Task<byte[]> ReadAndValidateContentFromGenericAsync(Rule rule, HttpRequest request)
{
// At this point generic rules don't do anything
// other than read the content from the request
// and return it. However, for the sake of clarity
// I've broken the logic to do this into a seperate
// method so it can be used in a seperate exception
// handling scenario when a rule does not exist.
return await ReadContentFromRequest(request);
}
private async Task<Payload> CreateAndValidatePayloadAsync(Rule rule, HttpRequest request)
{
var payloadContent = rule switch
{
GitHubRule gitHubRule => await ReadAndValidateContentFromGitHubAsync(gitHubRule, request),
AzureDevOpsRule azureDevopsRule => await ReadAndValidateContentFromAzureDevOpsAsync(azureDevopsRule, request),
_ => await ReadAndValidateContentFromGenericAsync(rule, request)
};
var payload = new Payload(request.Headers, payloadContent);
return payload;
}
private EventHubProducerClient GetEventHubProducerClient(string eventHubsNamespace, string eventHubName)
{
var eventHubProducerClientCacheKey = $"{eventHubsNamespace}/{eventHubName}_eventHubProducerClientCacheKey";
logger.LogInformation("Fetching cached EventHubProducerClient for cache key: {cacheKey}", eventHubProducerClientCacheKey);
var cachedProdocer = cache.GetOrCreate<EventHubProducerClient>(eventHubProducerClientCacheKey, (entry) =>
{
logger.LogInformation("Cache empty, populating cache key: {cacheKey}", eventHubProducerClientCacheKey);
var fullyQualifiedEventHubsNamespace = $"{eventHubsNamespace}.servicebus.windows.net";
var credential = new DefaultAzureCredential();
var producer = new EventHubProducerClient(fullyQualifiedEventHubsNamespace, eventHubName, credential);
return producer;
});
return cachedProdocer;
}
public async Task RouteAsync(Guid route, HttpRequest request)
{
try
{
logger.LogInformation("Routing request for route: {route}", route);
var rule = await GetRuleAsync(route);
var payload = await CreateAndValidatePayloadAsync(rule, request);
var payloadJson = JsonSerializer.Serialize(payload);
logger.LogInformation("Payload Content: {payloadContent}", payload.Content);
var payloadBytes = Encoding.UTF8.GetBytes(payloadJson);
var @event = new EventData(payloadBytes);
logger.LogInformation(
"Sending event from route {route} to namespace {namespace} with hub name {hubName}",
route,
rule.EventHubsNamespace,
rule.EventHubName
);
var producer = GetEventHubProducerClient(rule.EventHubsNamespace, rule.EventHubName);
var batch = await producer.CreateBatchAsync();
batch.TryAdd(@event);
await producer.SendAsync(batch);
logger.LogInformation(
"Sent event from route {route} to namespace {namespace} with hub name {hubName}",
route,
rule.EventHubsNamespace,
rule.EventHubName
);
}
catch (RouterConfigurationException ex)
{
var payloadBytes = await ReadContentFromRequest(request);
var payload = Encoding.UTF8.GetString(payloadBytes);
logger.LogError(
ex,
"Router configuration error, payload was: {payload}",
payload
);
throw;
}
}
}
}