打造 .NET Core 链接转发服务
我最近使用 .NET Core 2.2 造了個(gè)名為"Link Forwarder" (鏈接轉(zhuǎn)發(fā)器)的 URL 轉(zhuǎn)發(fā)服務(wù),并已開源。目前預(yù)覽版已部署到我的子域"go.edi.wang"。本文將分享我如何構(gòu)建這個(gè)項(xiàng)目,以及我學(xué)到的東西。
為了幫助大家了解系統(tǒng)并瀏覽代碼,請(qǐng)查看我的 GitHub 存儲(chǔ)庫:https://github.com/EdiWang/LinkForwarder
面向的問題
互聯(lián)網(wǎng)上的資源有時(shí)會(huì)更改其 URL。例如,當(dāng)我 10 年前創(chuàng)建網(wǎng)站時(shí),一個(gè)典型的博客文章 URL 就像"https://myolddomain.net/viewarticle.aspx?id=123"。我朋友在其他網(wǎng)站的帖子上引用了這個(gè)URL,或講它發(fā)給其他人。幾年后,我擁有了一個(gè)新域名,并推出了一個(gè)新的博客系統(tǒng),完全改變了該文章的URL,例如"https://edi.wang/post/2009/1/1/an-old-article",這使得任何舊的URL引用都失效。還好我的博客不盈利,所以沒太大關(guān)系。
但是,這個(gè)問題可能發(fā)生在企業(yè)的產(chǎn)品上。尤其是對(duì)于客戶端系統(tǒng)和應(yīng)用程序。比如將產(chǎn)品的支持鏈接寫入安裝在客戶端的產(chǎn)品中,結(jié)果有一天該鏈接更改了,那么您就必須將所有客戶端推送更新。
為了解決這個(gè)問題,我想以微軟為榜樣。微軟創(chuàng)建了"go.microsoft.com",它使用不會(huì)更改的靜態(tài) ID,以重定向到可能隨時(shí)間變化的實(shí)際 URL。例如,https://go.microsoft.com/fwlink/?linkid=2049807 ?指向的是基于Chromium 的 Edge 瀏覽器的幫助文檔,該文檔目前 URL 是 https://microsoftedgesupport.microsoft.com/hc/en-us ?。如果文檔的 URL 隨時(shí)間而變化,Edge 瀏覽器不必更改其內(nèi)置幫助鏈接。微軟只需要更新其數(shù)據(jù)庫以更改鏈接 ID 2049807 的目標(biāo) URL。這種"go.microsoft.com"服務(wù)在微軟產(chǎn)品中隨處可見。
這是鏈接轉(zhuǎn)發(fā)器的基本思想。
基本流程
管理員為有效的 URL (例如https://www.some-website.com/1234/abcd/1.html) 創(chuàng)建Token URL(例如https://go.edi.wang/fw/e66fad1e)。然后,用戶可以使用生成的Token URL 重定向到原始 URL。每次成功重定向都將偷偷記錄用戶的瀏覽器 UA 和 IP 地址,以便管理員可以查看報(bào)表并暗中觀察一切(得加個(gè)隱私協(xié)議)。
報(bào)表頁面
創(chuàng)建/編輯鏈接
分享鏈接
并非短鏈接服務(wù)
鏈接轉(zhuǎn)發(fā)器非常像,但并不是短鏈接。關(guān)鍵差異在于:
短鏈接的目標(biāo)是創(chuàng)建盡可能短的 URL,通常部署到非常短的域名。鏈接轉(zhuǎn)發(fā)器并不關(guān)心是否將其部署到長域名。
大多數(shù)短鏈接服務(wù)不允許在創(chuàng)建鏈接后再修改。但是鏈接轉(zhuǎn)發(fā)器的目標(biāo)是面向更改。
并不簡單
鏈接轉(zhuǎn)發(fā)器不只是將Token映射到 URL。需要考慮以下問題。
它需要足夠快,并能處理一定量的流量
我當(dāng)前的設(shè)計(jì)會(huì)緩存有效的 URL 重定向,因此對(duì)于對(duì)同一令牌的請(qǐng)求,系統(tǒng)不會(huì)每次都查詢數(shù)據(jù)庫。
如何處理無效的令牌或有效但不存在的 URL?
對(duì)于無效令牌,停止請(qǐng)求。對(duì)于該有效的令牌,但它指向不存在的 URL(數(shù)據(jù)庫中沒有記錄),將用戶重定向到預(yù)先設(shè)置的默認(rèn) URL。
系統(tǒng)需要保護(hù)用戶免受潛在有害鏈接的侵害
例如,鏈接轉(zhuǎn)發(fā)器的數(shù)據(jù)庫遭到破壞,并且 URL 指向"https://127.0.0.1/some-virus",可以觸發(fā)一個(gè)事先安裝在本地的病毒。用戶就可能會(huì)受到攻擊。其他 URL (如"/abc"、"123") 也被視為無效 URL,不會(huì)執(zhí)行重定向。
對(duì)于可能包含惡意代碼的互聯(lián)網(wǎng) URL,目前不在設(shè)計(jì)范圍中。但是,也許將來我們可以集成第三方服務(wù)來識(shí)別鏈接。
系統(tǒng)需要自我保護(hù)
指向系統(tǒng)本身的鏈接可能會(huì)導(dǎo)致重定向死循環(huán)并把服務(wù)器爆上天。
例如:
https://go.edi.wang/fw/a? 指向 https://go.edi.wang/fw/b?
https://go.edi.wang/fw/b?又指向 https://go.edi.wang/fw/a?
如果將鏈接轉(zhuǎn)發(fā)器或其他類似的系統(tǒng)部署到另一個(gè)域,也會(huì)發(fā)生類似的情況。甚至可以有多個(gè)節(jié)點(diǎn)參與在循環(huán)中:
盡管現(xiàn)代瀏覽器會(huì)停止這種重定向循環(huán),但攻擊者可以通過不使用現(xiàn)代瀏覽器或根本不使用瀏覽器來繞過此限制。
對(duì)于指向服務(wù)器域本身的鏈接,我們可以輕松地識(shí)別和阻止它。但對(duì)于有多放參與的重定向環(huán),我找不到識(shí)別和阻止請(qǐng)求的可靠方法。因此,我只能繞彎解決,將特定時(shí)間段內(nèi)同一 IP 地址的同一令牌的請(qǐng)求數(shù)做限制,本文稍后將對(duì)此進(jìn)行說明。
重定向流程
下圖說明了URL重定向流程。(手機(jī)上看不清可以稍后查看原文)
數(shù)據(jù)庫設(shè)計(jì)
我們只需要兩張表就能進(jìn)行重定向和跟蹤用戶事件。我選擇的數(shù)據(jù)庫引擎是用于開發(fā)的 LocalDB 和用于生產(chǎn)的 Microsoft Azure SQL Database。
SQL腳本:
IF NOT EXISTS(SELECT TOP 1 1 FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = N'Link')
CREATE TABLE [Link](
[Id] [int] IDENTITY(1,1) PRIMARY KEY NOT NULL,
[OriginUrl] [nvarchar](256) NULL,
[FwToken] [varchar](32) NULL,
[Note] [nvarchar](max) NULL,
[IsEnabled] [bit] NOT NULL,
[UpdateTimeUtc] [datetime] NOT NULL)
IF NOT EXISTS(SELECT TOP 1 1 FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = N'LinkTracking')
CREATE TABLE [LinkTracking](
[Id] UNIQUEIDENTIFIER PRIMARY KEY NOT NULL,
[LinkId] [int] NOT NULL,
[UserAgent] [nvarchar](256) NULL,
[IpAddress] [varchar](64) NULL,
[RequestTimeUtc] [datetime] NOT NULL)
IF NOT EXISTS(SELECT TOP 1 1 FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS WHERE CONSTRAINT_NAME = N'FK_LinkTracking_Link')
ALTER TABLE [LinkTracking]? WITH CHECK ADD? CONSTRAINT [FK_LinkTracking_Link] FOREIGN KEY([LinkId])
REFERENCES [Link] ([Id])
ON UPDATE CASCADE
ON DELETE CASCADE
ALTER TABLE [LinkTracking] CHECK CONSTRAINT [FK_LinkTracking_Link]
ASP.NET Core 應(yīng)用程序設(shè)計(jì)
為了避免篇幅又臭又長,本文不列出代碼的每處細(xì)節(jié)。完整參考請(qǐng)查看項(xiàng)目 GitHub 倉庫:https://github.com/EdiWang/LinkForwarder
LinkForwarder.Web
ASP.NET Core MVC 應(yīng)用程序作為入口點(diǎn)。它控制 URL 重定向、鏈接驗(yàn)證、本地帳戶或 Azure AD 的身份驗(yàn)證、創(chuàng)建或編輯鏈接以及查看報(bào)告。
LinkForwarder.Services
定義對(duì)數(shù)據(jù)庫的 CRUD 操作,并通過 ILinkForwarderService?接口和實(shí)現(xiàn) LinkForwarderService 獲取報(bào)告數(shù)據(jù)。稍后解釋的 ITokenGenerator?也在此項(xiàng)目中。
LinkForwarder.Setup
用于運(yùn)行 SQL 腳本以為新服務(wù)器設(shè)置數(shù)據(jù)庫。這僅在系統(tǒng)的第一次運(yùn)行中使用。
關(guān)鍵點(diǎn)
Token生成
"/fw"后面的參數(shù)是一個(gè) Token。它用于在數(shù)據(jù)庫中查找源 URL。我不使用 Link.Id 的原因是,當(dāng)執(zhí)行數(shù)據(jù)庫遷移或從多個(gè)服務(wù)器合并數(shù)據(jù)庫時(shí),Id 可能會(huì)更改。但Token將保持不變。
系統(tǒng)使用 ITokenGenerator?接口生成Token。
public interface ITokenGenerator
{
? ? string GenerateToken();
? ? bool TryParseToken(string input, out string token);
}
GenerateToken() 用于在提交新 URL 時(shí)創(chuàng)建新Token。
TryParseToken() 用于驗(yàn)證客戶端請(qǐng)求的Token格式。
目前,ITokenGenerator?接口的唯一實(shí)現(xiàn)是ShortGuidTokenGenerator。它將以 GUID 的前 8 個(gè)字符作為Token。
public class ShortGuidTokenGenerator : ITokenGenerator
{
? ? private const int Length = 8;
? ? public string GenerateToken()
? ? {
? ? ? ? return Guid.NewGuid().ToString().Substring(0, Length).ToLower();
? ? }
? ? public bool TryParseToken(string input, out string token)
? ? {
? ? ? ? token = null;
? ? ? ? if (input.Length != Length)
? ? ? ? {
? ? ? ? ? ? return false;
? ? ? ? }
? ? ? ? token = input;
? ? ? ? return true;
? ? }
}
注意:在此示例中,TryParseToken() 并不總是可靠的,因?yàn)闊o法判斷 8 個(gè)字符的字符串是否屬于 GUID。您當(dāng)然可以根據(jù)自己的規(guī)則創(chuàng)建另一個(gè)Token生成器,這些規(guī)則可以進(jìn)行準(zhǔn)確的Token驗(yàn)證。
創(chuàng)建新鏈接
首先,我們需要防止為已經(jīng)存在的 URL 創(chuàng)建新Token。對(duì)于現(xiàn)有 URL,我們可以查找舊記錄并返回舊Token,而不是生成新Token。在此之前,我們還需要再次驗(yàn)證現(xiàn)有URL的Token,以確保數(shù)據(jù)良好。例如,黑客可以將數(shù)據(jù)庫中的Token更改為某個(gè)惡意字符串,我不希望它最終追加到 URL 上。
所以,TryParseToken() 必須比我目前的設(shè)計(jì)更可靠。
其次,我們需要防止生成已存在的令牌。完整 GUID 是可靠的,但部分 GUID 不是。
基于這兩個(gè)因素,創(chuàng)建新鏈接的代碼將是:
const string sqlLinkExist = "SELECT TOP 1 FwToken FROM Link l WHERE l.OriginUrl = @originUrl";
var tempToken = await conn.ExecuteScalarAsync<string>(sqlLinkExist, new { originUrl });
if (null != tempToken)
{
? ? if (_tokenGenerator.TryParseToken(tempToken, out var tk))
? ? {
? ? ? ? _logger.LogInformation($"Link already exists for token '{tk}'");
? ? ? ? return new SuccessResponse<string>(tk);
? ? }
? ? string message = $"Invalid token '{tempToken}' found for existing url '{originUrl}'";
? ? _logger.LogError(message);
}
const string sqlTokenExist = "SELECT TOP 1 1 FROM Link l WHERE l.FwToken = @token";
string token;
do
{
? ? token = _tokenGenerator.GenerateToken();
} while (await conn.ExecuteScalarAsync<int>(sqlTokenExist, new { token }) == 1);
_logger.LogInformation($"Generated Token '{token}' for url '{originUrl}'");
var link = new Link
{
? ? FwToken = token,
? ? IsEnabled = isEnabled,
? ? Note = note,
? ? OriginUrl = originUrl,
? ? UpdateTimeUtc = DateTime.UtcNow
};
const string sqlInsertLk = @"INSERT INTO Link (OriginUrl, FwToken, Note, IsEnabled, UpdateTimeUtc)
? ? ? ? ? ? ? ? ? ? ? ? ? ? ?VALUES (@OriginUrl, @FwToken, @Note, @IsEnabled, @UpdateTimeUtc)";
await conn.ExecuteAsync(sqlInsertLk, link);
return new SuccessResponse<string>(link.FwToken);
驗(yàn)證重定向 URL
系統(tǒng)使用 ILinkVerifier 接口在將其發(fā)送到鏈接到客戶端之前驗(yàn)證 URL。有 3 種無效狀態(tài):
無效格式: 例如"865c8gyiB"
本地 URL: 例如"/some-path"
自引用 URL: 例如"https://go.edi.wang/some-path"
public enum LinkVerifyResult
{
? ? Valid,
? ? InvalidFormat,
? ? InvalidLocal,
? ? InvalidSelfReference
}
public interface ILinkVerifier
{
? ? LinkVerifyResult Verify(string url, IUrlHelper urlHelper, HttpRequest currentRequest);
}
我們可以利用ASP.NET MVC 的 IUrlHelper 接口執(zhí)行前兩個(gè)無效情況的驗(yàn)證。
public LinkVerifyResult Verify(string url, IUrlHelper urlHelper, HttpRequest currentRequest)
{
? ? if (!url.IsValidUrl())
? ? {
? ? ? ? return LinkVerifyResult.InvalidFormat;
? ? }
? ? if (urlHelper.IsLocalUrl(url))
? ? {
? ? ? ? return LinkVerifyResult.InvalidLocal;
? ? }
? ? if (Uri.TryCreate(url, UriKind.Absolute, out var testUri))
? ? {
? ? ? ? if (string.Compare(testUri.Authority, currentRequest.Host.ToString(), StringComparison.OrdinalIgnoreCase) == 0
? ? ? ? ? ? && string.Compare(testUri.Scheme, currentRequest.Scheme, StringComparison.OrdinalIgnoreCase) == 0
? ? ? ? ? ? && testUri.AbsolutePath != "/")
? ? ? ? {
? ? ? ? ? ? return LinkVerifyResult.InvalidSelfReference;
? ? ? ? }
? ? }
? ? return LinkVerifyResult.Valid;
}
要檢查 URL 是否采用有效格式:
public enum UrlScheme
{
? ? Http,
? ? Https,
? ? All
}
public static bool IsValidUrl(this string url, UrlScheme urlScheme = UrlScheme.All)
{
? ? bool isValidUrl = Uri.TryCreate(url, UriKind.Absolute, out var uriResult);
? ? if (!isValidUrl)
? ? {
? ? ? ? return false;
? ? }
? ? switch (urlScheme)
? ? {
? ? ? ? case UrlScheme.All:
? ? ? ? ? ? isValidUrl &= uriResult.Scheme == Uri.UriSchemeHttps || uriResult.Scheme == Uri.UriSchemeHttp;
? ? ? ? ? ? break;
? ? ? ? case UrlScheme.Https:
? ? ? ? ? ? isValidUrl &= uriResult.Scheme == Uri.UriSchemeHttps;
? ? ? ? ? ? break;
? ? ? ? case UrlScheme.Http:
? ? ? ? ? ? isValidUrl &= uriResult.Scheme == Uri.UriSchemeHttp;
? ? ? ? ? ? break;
? ? }
? ? return isValidUrl;
}
IP 請(qǐng)求速率限制
對(duì)于單個(gè) IP,重定向入口 (/fw/{token} ) 在一分鐘內(nèi)最多包含 30 個(gè)請(qǐng)求。
[Route("/fw/{token}")]
public async Task<IActionResult> Forward(string token)
appsettings.json中的配置控制 IP 限制規(guī)則:
"IpRateLimiting": {
? "EnableEndpointRateLimiting": true,
? "StackBlockedRequests": false,
? "RealIpHeader": "X-Real-IP",
? "ClientIdHeader": "X-ClientId",
? "HttpStatusCode": 429,
? "GeneralRules": [
? ? {
? ? ? "Endpoint": "*:/fw/*",
? ? ? "Period": "1m",
? ? ? "Limit": 30
? ? }
? ]
}
有關(guān)如何進(jìn)行 IP 速率限制的更完整介紹,請(qǐng)查看我之前的博客文章《IP Rate Limit for ASP.NET Core》 https://edi.wang/post/2019/6/16/ip-rate-limit-for-aspnet-core
從User Agent里暗中觀察
典型的 User Agent 字符串如下:
Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/76.0.3809.12 Safari/537.36 Edg/76.0.182.6
為了最方便地從中獲取信息,我使用一個(gè)名為 UAParser 的庫。(有了輪子就別自己造,.NET程序員不需要福報(bào))
var uaParser = Parser.GetDefault();
string GetClientTypeName(string userAgent)
{
? ? ClientInfo c = uaParser.Parse(userAgent);
? ? return $"{c.OS.Family}-{c.UA.Family}";
}
此代碼允許我按 操作系統(tǒng)-瀏覽器 對(duì)數(shù)據(jù)進(jìn)行分組。例如,Windows 7 + Chrome 60 的用戶和 Windows 10 + Chrome 62 的用戶都將分組為 Windows-Chrome。因此,最終的餅圖不會(huì)顯示太多碎片序列。
var q = from d in userAgentCounts
? ? ? ? group d by GetClientTypeName(d.UserAgent)
? ? ? ? into g
? ? ? ? select new ClientTypeCount
? ? ? ? {
? ? ? ? ? ? ClientTypeName = g.Key,
? ? ? ? ? ? Count = g.Sum(gp => gp.RequestCount)
? ? ? ? };
還沒完事
鏈接轉(zhuǎn)發(fā)器項(xiàng)目處于早期階段。我能想到很多改進(jìn)和新功能。例如為第三方提供 REST API、為管理鏈接添加Tag、甚至在ASP.NET Core 3.0 發(fā)布后使用 Blazor。技術(shù)上也存在可以優(yōu)化的地方,比如是否需要引入HASH查找、LinkTracking表到底用不用GUID主鍵、索引怎么加等等,類似這些需要經(jīng)過一段時(shí)間的線上實(shí)踐才能做決定。這是一個(gè)開源項(xiàng)目,所以我歡迎大家一起幫它變得更牛逼!
總結(jié)
以上是生活随笔為你收集整理的打造 .NET Core 链接转发服务的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: C# 内存的理解 通俗说
- 下一篇: 几种设计良好结构以提高.NET性能的方法