专栏首页汪宇杰博客打造 .NET Core 链接转发服务

打造 .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主键、索引怎么加等等,类似这些需要经过一段时间的线上实践才能做决定。这是一个开源项目,所以我欢迎大家一起帮它变得更牛逼!

本文分享自微信公众号 - 汪宇杰博客(ediwangblog)

原文出处及转载信息见文内详细说明,如有侵权,请联系 yunjia_community@tencent.com 删除。

原始发表时间:2019-06-21

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

我来说两句

0 条评论
登录 后参与评论

推荐阅读

  • 远程办公经验为0,如何将日常工作平滑过度到线上?

    我是一名创业者,我的公司(深圳市友浩达科技有限公司)在2018年8月8日开始运营,现在还属于微型公司。这个春节假期,我一直十分关注疫情动向,也非常关心其对公司带来的影响。

    TVP官方团队
    TAPD 敏捷项目管理腾讯乐享企业邮箱企业编程算法
  • 数据中台,概念炒作还是另有奇效? | TVP思享

    作者简介:史凯,花名凯哥,腾讯云最具价值专家TVP,ThoughtWorks数据智能业务总经理。投身于企业数字化转型工作近20年。2000年初,在IBM 研发企业级中间件,接着加入埃森哲,为大型企业提供信息化架构规划,设计,ERP,云平台,数据仓库构建等技术咨询实施服务,随后在EMC负责企业应用转型业务,为企业提供云迁移,应用现代化服务。现在专注于企业智能化转型领域,是数据驱动的数字化转型的行业布道者,数据中台的推广者,精益数据创新体系的创始人,2019年荣获全球Data IQ 100人的数据赋能者称号,创业邦卓越生态聚合赋能官TOP 5。2019年度数字化转型专家奖。打造了行业第一个数据创新的数字化转型卡牌和工作坊。创建了精益数据创新方法论体系构建数据驱动的智能企业,并在多个企业验证成功,正在向国内外推广。

    TVP官方团队
    大数据数据分析企业
  • 扩展 Kubernetes 之 CRI

    使用 cri-containerd 的调用流程更为简洁, 省去了上面的调用流程的 1,2 两步

    王磊-AI基础
    Kubernetes
  • 扩展 Kubernetes 之 Kubectl Plugin

    kubectl 功能非常强大, 常见的命令使用方式可以参考 kubectl --help,或者这篇文章

    王磊-AI基础
    Kubernetes
  • 多种登录方式定量性能测试方案

    最近接到到一个测试任务,某服务提供了两种登录方式:1、账号密码登录;2、手机号+验证码登录。要对这两种登录按照一定的比例进行压测。

    八音弦
    测试服务 WeTest
  • 线程安全类在性能测试中应用

    首先验证接口参数签名是否正确,然后加锁去判断订单信息和状态,处理用户增添VIP时间事务,成功之后释放锁。锁是针对用户和订单的分布式锁,使用方案是用的redis。

    八音弦
    安全编程算法
  • 使用CDN(jsdelivr) 优化博客访问速度

    PS: 此篇文章适用于 使用 Github pages 或者 coding pages 的朋友,其他博客也类似.

    IFONLY@CUIT
    CDNGitGitHub开源
  • 扩展 Kubernetes 之 CNI

    Network Configuration 是 CNI 输入参数中最重要当部分, 可以存储在磁盘上

    王磊-AI基础
    Kubernetes
  • 聚焦【技术应变力】云加社区沙龙online重磅上线!

    云加社区结合特殊时期热点,挑选备受关注的音视频流量暴增、线下业务快速转线上、紧急上线防疫IoT应用等话题,邀请众多业界专家,为大家提供连续十一天的干货分享。从视野、预判、应对等多角度,帮助大家全面提升「技术应变力」!

    腾小云
  • 京东购物小程序购物车性能优化实践

    它是小程序开发工具内置的一个可视化监控工具,能够在 OS 级别上实时记录系统资源的使用情况。

    WecTeam
    渲染JavaScripthttps网络安全缓存

扫码关注云+社区

领取腾讯云代金券