打造 .NET Core 链接转发服务
我最近使用 .NET Core 2.2 造了個名為"Link Forwarder" (鏈接轉發器)的 URL 轉發服務,并已開源。目前預覽版已部署到我的子域"go.edi.wang"。本文將分享我如何構建這個項目,以及我學到的東西。
為了幫助大家了解系統并瀏覽代碼,請查看我的 GitHub 存儲庫:https://github.com/EdiWang/LinkForwarder
面向的問題
互聯網上的資源有時會更改其 URL。例如,當我 10 年前創建網站時,一個典型的博客文章 URL 就像"https://myolddomain.net/viewarticle.aspx?id=123"。我朋友在其他網站的帖子上引用了這個URL,或講它發給其他人。幾年后,我擁有了一個新域名,并推出了一個新的博客系統,完全改變了該文章的URL,例如"https://edi.wang/post/2009/1/1/an-old-article",這使得任何舊的URL引用都失效。還好我的博客不盈利,所以沒太大關系。
但是,這個問題可能發生在企業的產品上。尤其是對于客戶端系統和應用程序。比如將產品的支持鏈接寫入安裝在客戶端的產品中,結果有一天該鏈接更改了,那么您就必須將所有客戶端推送更新。
為了解決這個問題,我想以微軟為榜樣。微軟創建了"go.microsoft.com",它使用不會更改的靜態 ID,以重定向到可能隨時間變化的實際 URL。例如,https://go.microsoft.com/fwlink/?linkid=2049807 ?指向的是基于Chromium 的 Edge 瀏覽器的幫助文檔,該文檔目前 URL 是 https://microsoftedgesupport.microsoft.com/hc/en-us ?。如果文檔的 URL 隨時間而變化,Edge 瀏覽器不必更改其內置幫助鏈接。微軟只需要更新其數據庫以更改鏈接 ID 2049807 的目標 URL。這種"go.microsoft.com"服務在微軟產品中隨處可見。
這是鏈接轉發器的基本思想。
基本流程
管理員為有效的 URL (例如https://www.some-website.com/1234/abcd/1.html) 創建Token URL(例如https://go.edi.wang/fw/e66fad1e)。然后,用戶可以使用生成的Token URL 重定向到原始 URL。每次成功重定向都將偷偷記錄用戶的瀏覽器 UA 和 IP 地址,以便管理員可以查看報表并暗中觀察一切(得加個隱私協議)。
報表頁面
創建/編輯鏈接
分享鏈接
并非短鏈接服務
鏈接轉發器非常像,但并不是短鏈接。關鍵差異在于:
短鏈接的目標是創建盡可能短的 URL,通常部署到非常短的域名。鏈接轉發器并不關心是否將其部署到長域名。
大多數短鏈接服務不允許在創建鏈接后再修改。但是鏈接轉發器的目標是面向更改。
并不簡單
鏈接轉發器不只是將Token映射到 URL。需要考慮以下問題。
它需要足夠快,并能處理一定量的流量
我當前的設計會緩存有效的 URL 重定向,因此對于對同一令牌的請求,系統不會每次都查詢數據庫。
如何處理無效的令牌或有效但不存在的 URL?
對于無效令牌,停止請求。對于該有效的令牌,但它指向不存在的 URL(數據庫中沒有記錄),將用戶重定向到預先設置的默認 URL。
系統需要保護用戶免受潛在有害鏈接的侵害
例如,鏈接轉發器的數據庫遭到破壞,并且 URL 指向"https://127.0.0.1/some-virus",可以觸發一個事先安裝在本地的病毒。用戶就可能會受到攻擊。其他 URL (如"/abc"、"123") 也被視為無效 URL,不會執行重定向。
對于可能包含惡意代碼的互聯網 URL,目前不在設計范圍中。但是,也許將來我們可以集成第三方服務來識別鏈接。
系統需要自我保護
指向系統本身的鏈接可能會導致重定向死循環并把服務器爆上天。
例如:
https://go.edi.wang/fw/a? 指向 https://go.edi.wang/fw/b?
https://go.edi.wang/fw/b?又指向 https://go.edi.wang/fw/a?
如果將鏈接轉發器或其他類似的系統部署到另一個域,也會發生類似的情況。甚至可以有多個節點參與在循環中:
盡管現代瀏覽器會停止這種重定向循環,但攻擊者可以通過不使用現代瀏覽器或根本不使用瀏覽器來繞過此限制。
對于指向服務器域本身的鏈接,我們可以輕松地識別和阻止它。但對于有多放參與的重定向環,我找不到識別和阻止請求的可靠方法。因此,我只能繞彎解決,將特定時間段內同一 IP 地址的同一令牌的請求數做限制,本文稍后將對此進行說明。
重定向流程
下圖說明了URL重定向流程。(手機上看不清可以稍后查看原文)
數據庫設計
我們只需要兩張表就能進行重定向和跟蹤用戶事件。我選擇的數據庫引擎是用于開發的 LocalDB 和用于生產的 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 應用程序設計
為了避免篇幅又臭又長,本文不列出代碼的每處細節。完整參考請查看項目 GitHub 倉庫:https://github.com/EdiWang/LinkForwarder
LinkForwarder.Web
ASP.NET Core MVC 應用程序作為入口點。它控制 URL 重定向、鏈接驗證、本地帳戶或 Azure AD 的身份驗證、創建或編輯鏈接以及查看報告。
LinkForwarder.Services
定義對數據庫的 CRUD 操作,并通過 ILinkForwarderService?接口和實現 LinkForwarderService 獲取報告數據。稍后解釋的 ITokenGenerator?也在此項目中。
LinkForwarder.Setup
用于運行 SQL 腳本以為新服務器設置數據庫。這僅在系統的第一次運行中使用。
關鍵點
Token生成
"/fw"后面的參數是一個 Token。它用于在數據庫中查找源 URL。我不使用 Link.Id 的原因是,當執行數據庫遷移或從多個服務器合并數據庫時,Id 可能會更改。但Token將保持不變。
系統使用 ITokenGenerator?接口生成Token。
public interface ITokenGenerator
{
? ? string GenerateToken();
? ? bool TryParseToken(string input, out string token);
}
GenerateToken() 用于在提交新 URL 時創建新Token。
TryParseToken() 用于驗證客戶端請求的Token格式。
目前,ITokenGenerator?接口的唯一實現是ShortGuidTokenGenerator。它將以 GUID 的前 8 個字符作為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() 并不總是可靠的,因為無法判斷 8 個字符的字符串是否屬于 GUID。您當然可以根據自己的規則創建另一個Token生成器,這些規則可以進行準確的Token驗證。
創建新鏈接
首先,我們需要防止為已經存在的 URL 創建新Token。對于現有 URL,我們可以查找舊記錄并返回舊Token,而不是生成新Token。在此之前,我們還需要再次驗證現有URL的Token,以確保數據良好。例如,黑客可以將數據庫中的Token更改為某個惡意字符串,我不希望它最終追加到 URL 上。
所以,TryParseToken() 必須比我目前的設計更可靠。
其次,我們需要防止生成已存在的令牌。完整 GUID 是可靠的,但部分 GUID 不是。
基于這兩個因素,創建新鏈接的代碼將是:
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);
驗證重定向 URL
系統使用 ILinkVerifier 接口在將其發送到鏈接到客戶端之前驗證 URL。有 3 種無效狀態:
無效格式: 例如"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 接口執行前兩個無效情況的驗證。
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 請求速率限制
對于單個 IP,重定向入口 (/fw/{token} ) 在一分鐘內最多包含 30 個請求。
[Route("/fw/{token}")]
public async Task<IActionResult> Forward(string token)
appsettings.json中的配置控制 IP 限制規則:
"IpRateLimiting": {
? "EnableEndpointRateLimiting": true,
? "StackBlockedRequests": false,
? "RealIpHeader": "X-Real-IP",
? "ClientIdHeader": "X-ClientId",
? "HttpStatusCode": 429,
? "GeneralRules": [
? ? {
? ? ? "Endpoint": "*:/fw/*",
? ? ? "Period": "1m",
? ? ? "Limit": 30
? ? }
? ]
}
有關如何進行 IP 速率限制的更完整介紹,請查看我之前的博客文章《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
為了最方便地從中獲取信息,我使用一個名為 UAParser 的庫。(有了輪子就別自己造,.NET程序員不需要福報)
var uaParser = Parser.GetDefault();
string GetClientTypeName(string userAgent)
{
? ? ClientInfo c = uaParser.Parse(userAgent);
? ? return $"{c.OS.Family}-{c.UA.Family}";
}
此代碼允許我按 操作系統-瀏覽器 對數據進行分組。例如,Windows 7 + Chrome 60 的用戶和 Windows 10 + Chrome 62 的用戶都將分組為 Windows-Chrome。因此,最終的餅圖不會顯示太多碎片序列。
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)
? ? ? ? };
還沒完事
鏈接轉發器項目處于早期階段。我能想到很多改進和新功能。例如為第三方提供 REST API、為管理鏈接添加Tag、甚至在ASP.NET Core 3.0 發布后使用 Blazor。技術上也存在可以優化的地方,比如是否需要引入HASH查找、LinkTracking表到底用不用GUID主鍵、索引怎么加等等,類似這些需要經過一段時間的線上實踐才能做決定。這是一個開源項目,所以我歡迎大家一起幫它變得更牛逼!
總結
以上是生活随笔為你收集整理的打造 .NET Core 链接转发服务的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: C# 内存的理解 通俗说
- 下一篇: Abp 0.18.0 正式发布! -AB