开发专栏
使用 Razor Pages 打造查看真实 IP、IPv6 检测网站 —— 实现篇
3 年前
14696
服务器端开发 快速入门 Nginx ASP.NET IPv6 Razor Pages

本文将介绍 nginx 自定义头以及使用ASP.NET Core Razor Pages开发一个查看真实IP地址、检查请求者 IPv6 可用性的网站!

使用 Razor Pages 打造查看真实 IP、IPv6 检测网站系列文章:

欢迎大家阅读 ASP.NET Core Razor Pages 打造查看真实 IP、IPv6 系列文章,本篇是系列的第二篇,详细介绍代码的编写。如果错过了《使用 Razor Pages 打造查看真实 IP、IPv6 检测网站 —— 思路篇》,可以点击链接阅读。

本篇文章对应的代码可以在 https://github.com/huhubun/BunIp/tree/simple_ver​ 查看到。

完整实现可以访问 https://ip.bun.plus/ 体验。

开始之前

在上一篇我们已经创建好了一个空的 Razor 页面项目。

修改运行服务器

但开始编码之前,我们修改一下 Visual Studio 的配置。在 Visual Studio 的调试按钮附近找到 IIS Express 的字样,点击旁边的下拉菜单,修改为和项目一致的选项,表示使用 ASP.NET Core 的 Kestrel 服务器进行调试。

当你运行项目时,弹出一个控制台窗口而非启动 IIS Express 时,说明配置成功。

获取真实 IP

Pages 文件夹下找到 Index.cshtml,它就是我们的首页。Index.cshtml 是 Razor 页面,它还有一个以 .cs 后缀结尾的 IndexModel 类。在 Visual Studio 的“解决方案资源管理器”中,展开 Index.cshtml 文件前面的小三角,或者在 Index.cshtml 文件内容的空白处点击右键,选择 转到 PageModel,都可以进入页面对应的 Page Model 类中。在 Razor 页面里可以使用 @Model.XXX 这样的方式访问到 Page Model 里的属性。

为了能让页面显示请求者的 IP 地址,按照我们的设计,只需要读取 X-Real-IP 头即可。不过为了兼顾通过 nginx 访问(上线后)和直接访问站点(开发调试时),我们需要对 X-Real-IP 的值进行判断。因为开发中访问的时候是没有这个头的,就需要显示 Kestrel 获取到的请求者的地址。我们添加一个帮助类完成这个操作,新建 Helpers 文件夹,并在其中新建 IpHelper.cs 文件:

using Microsoft.AspNetCore.Http;
using System;
using System.Net;

namespace IpTest.Helpers
{
    public static class IpHelper
    {
        public static IPAddress GetRealIp(HttpContext httpContext)
        {
            // 如果存在 X-Real-IP header,并且值合法,就是用 X-Real-IP 的值
            var headers = httpContext.Request.Headers;
            var realIpHeader = headers["X-Real-IP"];

            if (!String.IsNullOrEmpty(realIpHeader) && IPAddress.TryParse(realIpHeader, out var ipAddress))
            {
                return ipAddress;
            }

            return httpContext.Connection.RemoteIpAddress;
        }
    }
}

然后在 IndexModel 里增加一个只读属性 DisplayIp,它的值来自于上面的帮助方法获取的 IP 地址:

    public IPAddress DisplayIp => IpHelper.GetRealIp(HttpContext);

接着修改 Index.cshtml,把原有的内容替换成这样:

@page
@model IndexModel
@{
    ViewData["Title"] = "Home page";
}

<div class="text-center">
    <h1 class="display-4">@Model.DisplayIp</h1>
</div>

可以看到我们使用了 @Model.DisplayIp 获取 Model 的内容。按下 Ctrl + F5 运行项目,因为是通过 localhost 访问的,所以我们看见的地址是 IPv6 的 ::1(也有可能是 127.0.0.1,取决于操作系统是优先使用 IPv6 还是 IPv4):

首次运行

向 appsettings.json 增加配置

根据设计,需要准备三个地址,我们把这三个地址添加到配置中,因为要想发起 ajax 请求需要知道请求的地址是什么。

打开 appsettings.Development.json 文件(如果没有可以手动创建一个),在原有内容同一层级增加 IpTest 节点用于存放我们的配置:

{
  // 原有的内容省略
  // ...

  "IpTest": {
    // 部署站点信息,通过请求头中的 Host 进行识别
    "DeploySite": {
      // 混合部署,同时支持 IPv4 和 IPv6 访问
      "Hybrid": {
        // 域名(不需要填写 HTTP 协议,例如 ip.bun.plus)
        "Domain": "localhost",
        // 协议(http、https)
        "Scheme": "http",
        // 端口号
        "Port": "5000"
      },
      // IPv4 Only
      "IPv4": {
        "Domain": "127.0.0.1",
        "Scheme": "http",
        "Port": "14444"
      },
      // IPv6 Only
      "IPv6": {
        "Domain": "[::1]",
        "Scheme": "http",
        "Port": "16666"
    }
  }
}

可以看到,我们有了一个名为 DeploySite 部署站点信息的节点,里面包含了三个地址的域名、协议以及端口号信息。

接着为这个配置创建一个类,以便在程序中直接访问到它们的值。在项目根目录下创建文件夹 /Configs/DeploySites,并在其中创建 SiteInfo.cs

namespace IpTest.Configs.DeploySites
{
    public class SiteInfo
    {
        /// <summary>
        /// 域名
        /// </summary>
        public string Domain { get; set; }

        /// <summary>
        /// 访问协议(http、https)
        /// </summary>
        public string Scheme { get; set; }

        /// <summary>
        /// 端口号
        /// </summary>
        public int? Port { get; set; }

        public Uri Uri
        {
            get
            {
                if (Port.HasValue)
                {
                    return (new UriBuilder(Scheme, Domain, Port.Value)).Uri;
                }

                return (new UriBuilder(Scheme, Domain)).Uri;
            }
        }
    }
}

最后的 Uri 只读属性用于根据站点的信息生成 Uri,这样就不用我们自己拼字符串了,看起来舒服一点,后面会用到。

然后到 Configs 文件夹下创建 DeploySite.cs

using IpTest.Configs.DeploySites;

namespace IpTest.Configs
{
    public class DeploySite
    {
        /// <summary>
        /// 混合部署,同时支持 IPv4 和 IPv6 访问
        /// </summary>
        public SiteInfo Hybrid { get; set; }

        /// <summary>
        /// IPv4 Only
        /// </summary>
        public SiteInfo IPv4 { get; set; }

        /// <summary>
        /// IPv6 Only
        /// </summary>
        public SiteInfo IPv6 { get; set; }
    }
}

为了方便的访问和标识站点的部署模式,再在 Configs 文件夹下创建一个枚举 DeployMode

namespace IpTest.Configs
{
    public enum DeployMode
    {
        Hybrid,
        IPv4,
        IPv6
    }
}

最后来到 Startup.cs,把配置的读取加入到 ConfigureServices() 中,以便后续可以通过依赖注入使用它:

public void ConfigureServices(IServiceCollection services)
{
    services.AddRazorPages();

    services.Configure<DeploySite>(Configuration.GetSection("IpTest:DeploySite"));
}

注:IpTest:DeploySite 表示 IpTest 节点下的 DeploySite 节点。关于详细的配置文档请参阅 ASP.NET Core 中的配置

显示当前站点的部署模式

接着在 Index.cshtml.cs 做一些操作:

// using 部分略

public class IndexModel : PageModel
{
    // 增加 IOptions<DeploySite> 的注入
    private readonly IOptions<DeploySite> _deploySites;

    public IndexModel(IOptions<DeploySite> deploySites)
    {
        _deploySites = deploySites;
    }

    // 中间部分没有修改,略

    // 当前站点的部署模式,根据 host 头自动判断
    public DeployMode CurrentDeployMode
    {
        get
        {
            var host = Request.Headers["Host"];

            // 获取枚举的名称并用于循环
            foreach (var name in Enum.GetNames<DeployMode>())
            {
                // 使用枚举的名称,结合反射,获取配置的值
                // (如果这里不这样做,就需要写三个 if 分别判断 Hybrid、IPv4 和 IPv6 的配置)
                var siteInfo = typeof(DeploySite).GetProperty(name).GetValue(_deploySites.Value) as SiteInfo;

                // 拼接配置中的 domain 和 port,并和请求头中的 host 进行比较
                // 如果使用的是 80 和 443 端口,host 是没有端口号的,所以需要排除一下
                var domainAndPort = siteInfo.Domain;

                if (siteInfo.Port.HasValue && siteInfo.Port is not (80 or 443))
                {
                    domainAndPort += $":{siteInfo.Port}";
                }

                if (host == domainAndPort)
                {
                    // 如果 host 头与配置匹配,表示找到了当前站点的部署模式,返回相关枚举
                    return Enum.Parse<DeployMode>(name);
                }
            }

            throw new Exception($"Host 的值 {host} 与 DeploySite 的配置没有匹配项");
        }
    }
}

我们看看效果,看效果之前还要做两件事,首先在 Index.cshtml 中输出一下 CurrentDeployMode

@page
@model IndexModel
@{
    ViewData["Title"] = "Home page";
}

<div class="text-center">
    <h1 class="display-4">@Model.DisplayIp</h1>
</div>

当前请求的是 @Model.CurrentDeployMode 的站点

接着找到 /Properties/launchSettings.json 文件的 IpTest 节点中的 applicationUrl 节点,修改为:

    "applicationUrl": "http://localhost:5000;http://127.0.0.1:14444;http://[::1]:16666",

注意,这三个地址的需要和 appsettings.Development.json 文件里配置的三个地址对应。这样才能在本地调试的时候一次绑定多个地址和端口,方便我们测试。修改好后 Ctrl + F5 看看效果:

查看部署模式

哇,工作的非常好~注意观察服务器启动时绑定的端口以及浏览器的地址栏。

实现只返回 JSON 的 API

根据我们的设计,还需要有一个接收 ajax 请求并返回请求者 IP 地址的接口。但现在有个棘手的问题,Razor Page 不是 Page 吗,怎么返回一段 json 便于 ajax 之后处理呢?

这都不是事,我们在 Pages 文件夹下新建一个名为 IP.cshtmlIP两个字母必须大写)的“空 Razor 页面”。直接找到它的 IP.cshtml.cs 文件中的 OnGet() 方法。如果你有做过 MVC 或者 Web API 的开发,那么熟悉的东西来了,把 OnGet() 方法的 void 改为 JsonResult 😂,上代码:

using BunIp.Web.Helpers;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;

namespace BunIp.Web.Pages
{
    public class IPModel : PageModel
    {
        public JsonResult OnGet()
        {
            var ipAddress = IpHelper.GetRealIp(HttpContext);
            return new JsonResult(new { Ip = ipAddress.ToString() });
        }
    }
}

获取IP地址的API

启动项目,在地址后加上 /ip。铛铛,熟悉的配方熟悉的结果,接口搞定!

根据 ajax 请求的结果判断 IP 可用性

现在要准备发起 ajax 了!

通过之前增加的 CurrentDeployMode 属性,可以知道当前请求的站点是以哪种模式部署的,再结合请求者的 IP 地址(一开始增加的 DisplayIp 属性),这样我们才能知道 ajax 请求应该发给谁:

  • 如果当前是混合模式,并且 DisplayIp 为 IPv6 地址,说明请求者已经有 IPv6 地址,需要检测他有没有 IPv4 地址,所以应该发一个 ajax 请求给只能通过 IPv4 访问的地址
  • 如果当前是混合模式,并且 DisplayIp 为 IPv4 地址,说明请求者已经有 IPv4 地址,需要检测他有没有 IPv6 地址,所以应该发一个 ajax 请求给只能通过 IPv6 访问的地址
  • 如果当前是 IPv4 模式,说明请求者访问了只能通过 IPv4 访问的站点,不需要发 ajax,把当前请求的地址展示出来即可
  • 如果当前是 IPv6 模式,说明请求者访问了只能通过 IPv6 访问的站点,同上

更新 Model 和 Page

Index.cshtml.cs 追加下面的内容:

    public bool shouldTryIpv4 => CurrentDeployMode == DeployMode.Hybrid && DisplayIp.AddressFamily == AddressFamily.InterNetworkV6;

    public bool shouldTryIpv6 => CurrentDeployMode == DeployMode.Hybrid && DisplayIp.AddressFamily == AddressFamily.InterNetwork;

    public Uri ipv4Url => _deploySites.Value.IPv4.Uri;

    public Uri ipv6Url => _deploySites.Value.IPv6.Uri;

接着修改 Index.cshtml,我们增加了两个 div 并增加了一段 js 代码。通过 jQuery ajax 向对应服务器发起请求,并将返回的 IP 地址呈现在新增加的 div 中:

@page
@model IndexModel

<div class="text-center">
    <p class="lead mb-0">当前地址</p>
    <h1 class="display-4">@Model.DisplayIp</h1>

    @if (Model.ShouldTryIpv4)
    {
        <div id="TryIpv4" class="d-none">
            <p class="lead">
                您也拥有 IPv4 地址 <span class="ip-address"></span>
            </p>
        </div>
    }

    @if (Model.ShouldTryIpv6)
    {
        <div id="TryIpv6" class="d-none">
            <p class="lead">
                您也拥有 IPv6 地址 <span class="ip-address"></span>
            </p>
        </div>
    }

</div>

@section Scripts
{
    <script>
        $(function () {
            var tryIpV4 = $('#TryIpv4')
            var tryIpV6 = $('#TryIpv6')

            if (tryIpV4.length > 0) {
                $.get('@Model.Ipv4Url' + 'ip', showAnotherIp(tryIpV4))
            }

            if (tryIpV6.length > 0) {
                $.get('@Model.Ipv6Url' + 'ip', showAnotherIp(tryIpV6))
            }

            function showAnotherIp(container) {
                return function (data) {
                    container.find('.ip-address').text(data.ip)
                    container.removeClass('d-none')
                }
            }
        });
    </script>
}

添加跨域规则

因为 ajax 访问受到跨域的限制,我们还需要到 Startup.cs 中添加跨域访问的规则:

// 前略

public class Startup
{
    // 增加一个常量用作我们 CORS 规则的名称
    private const string CORS_POLICY_NAME = "IP_TEST_CORS";

    // 略

    public void ConfigureServices(IServiceCollection services)
    {
        // 前略

        // 新增 CORS 配置
        services.AddCors(options =>
        {
            var deploySites = Configuration.GetSection("IpTest:DeploySite").Get<DeploySite>();

            var origins = new Uri[]
            {
                deploySites.Hybrid.Uri,
                deploySites.IPv4.Uri,
                deploySites.IPv6.Uri
            };

            options.AddPolicy(CORS_POLICY_NAME, builder =>
                builder.WithOrigins(origins.Select(o => o.ToString().TrimEnd('/')).ToArray())
                    .AllowAnyMethod()
                    .AllowAnyHeader()
            );
        });
    }

    public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
    {
        // 前略

        // !!! 注意,UseCors() 必须加在 UseEndpoints() 方法之前 !!!
        app.UseCors(CORS_POLICY_NAME);

        app.UseEndpoints(endpoints =>
        {
            endpoints.MapRazorPages();
        });
    }

ConfigureServices() 中,我们对配置的地址进行了一些处理,主要是因为 Origins 的格式为 http://localhost:5000,而我们的 Uri 属性返回的 Uri 类型转换为 string 之后末尾有一个斜杠 http://localhost:5000/ 会导致浏览器校验 Origin 失败,所以我们需要去除 Uri 后的 /

另外就是千万注意,UseCors() 方法必须加在 UseEndpoints() 方法之前。

最终效果

修改完成后重新运行项目,可以看到当访问 localhost:5000 也就是即可以通过 IPv4 访问又可以通过 IPv6 访问的站点时,能显示出我的两个地址了!

本地调试指南

首先发布我们的站点,在项目上点击右键 - 发布,目标选择“文件夹”即可。

在正式上云和 nginx 之前,我们肯定要在本地(或树莓派上)进行调试,有这么几个地方需要特别注意的:

  • 可以直接通过 dotnet 命令启动程序
    • 通过添加 --urls 参数设置要绑定的 IP 地址和端口号,和在 launchSettings.json 里设置 applicationUrl 一样,例如:dotnet IpTest.dll --urls "http://10.0.0.20:15000;http://10.0.0.20:14444;http://[*]:16666"(注意:--urls 后面的地址要用 " 双引号包裹)
    • 上面例子最后的 [*] 表示绑定所有 IPv6 地址的 16666 端口,在懒得把完整 IPv6 地址写进来时很有用,懒呗😂
  • appsettings.json(或 appsettings.Development.json)中配置的三个站点信息是需要和跨域相对应的,不能写任何通配符在里面,必须写具体的 IP 地址
  • 发布之后,程序使用的是 Production 模式,不会使用 appsettings.Development.json 里的配置,所以需要把 appsettings.Development.json 改名成 appsettings.Production.json 或者把相关配置放在 appsettings.json

我在我的树莓派上进行调试时,使用的是如下配置(这个配置对应上面的 --urls 参数):

{
  // 前略

  "IpTest": {
    "DeploySite": {
      "Hybrid": {
        "Domain": "10.0.0.20",
        "Scheme": "http",
        "Port": "15000"
      },
      "IPv4": {
        "Domain": "10.0.0.20",
        "Scheme": "http",
        "Port": "14444"
      },
      "IPv6": {
        "Domain": "[fe80::30da:fd9b:c9e7:9ef9]",
        "Scheme": "http",
        "Port": "16666"
      }
    }
  }
}

树莓派内网 IP 是 10.0.0.20fe80::30da:fd9b:c9e7:9ef9

本地测试通过后,上云的话,将 Domain 全部换成对应域名即可,SchemePort 根据实际情况填写。

配置 nginx

部署的部分本文不详细展开,请参阅微软文档 使用 Nginx 在 Linux 上托管 ASP.NET Core,这里主要说一下 nginx 的配置。

绑定 IPv6 的地址和端口

和在浏览器中访问 IPv6 地址一样,在 nginx 中配置端口号时 IPv6 地址也需要用 [] 包裹。注意 listen 部分 [::]:443 就表示需要绑定所有 IPv6 地址的 443 端口:

server {
        listen          [::]:443 http2;

        server_name     ipv6.bun.plus;

# 后略        

如果要给一个域名同时绑定 IPv4 和 IPv6,只需要写两次 listen 即可:

server {
        listen          [::]:443 http2;
        listen          443 http2;

        server_name     ip.bun.plus;

第一个 listen 用于绑定 IPv6 的 443 端口,第二个 listen 用于绑定 IPv4 的 443 端口。

添加自定义 X-Real-IP 头

Nginx 必须知道他要把响应返回给谁,在 $remote_addr 中保存有请求者的真实地址,$ 开头的是 nginx 的一些变量,我们这里可以直接使用。

server {
        listen          [::]:443 http2;

        server_name     ipv6.bun.plus;
# 后略

        location / {
                proxy_pass              http://localhost:5000;
                proxy_http_version      1.1;
                # 后略

                proxy_set_header        X-Real-IP $remote_addr;
        }
}

通过 proxy_set_header 我们可以设置自定义的 header X-Real-IP,并将它的值为 $remote_addr 的值。

当 nginx 将请求转发给 proxy_pass 的地址时,就会带上这个请求头,我们程序中就能通过 Request.Headers["X-Real-IP"] 获取到请求者的 IP 了。

到这里我们的网站就完成啦,部署好后可以发给朋友们试试,看看他们支不支持 IPv6 以及真实 IP 是多少了,enjoy~

下集预告

网站的核心功能已经开发完成,下面需要整点儿花活了~

因为我的云服务器没有 IPv6 地址(腾讯出来挨打),IPv6 的部分我将部署在树莓派上。为了彰显树莓派的独特气质,我将使用 C# 获取树莓派的型号并将其在网页上输出,以及借此机会介绍 ASP.NET Core 中我很喜欢的功能:视图组件 View Component

敬请期待!