基于 AspNetCore 的 Git HTTP 服务器

on under developer
15 minute read

关于

Git HTTP 服务器是代码托管服务最重要的组件之一,Git HTTP 服务器将 HTTP 请求的数据写入到 git-upload-pack/git-receive-pack 的标准输入,然后读取 git-upload-pack/git-receive-pack 的输出,写入 HTTP 响应包体,然后传输给客户端。原理非常简单。

DotNet Core 安装

首先需要下载 .NET Core .NET Downloads ,此站点为正式释放版本, 如果要体验新的版本可以去 Github 项目主页下载最新的:Github: dotnet/cli

包括 Windows Ubuntu Debain 等都有安装指南

DotNet 命令

通常来说,dotnet 创建一个项目非常简单,进入到一个目录,运行

dotnet new

还原依赖

dotnet restore

编译

dotnet build

运行

dotnet run args…

发布

dotnet publish

项目依赖

AspNetCore 的项目依赖一般都需要添加 Microsoft.AspNetCore 此类库依赖 HTTP 服务器类库 Kestrel: https://github.com/aspnet/KestrelHttpServer,网络库底层使用 libuv

<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>netcoreapp1.1</TargetFramework>
    <RuntimeIdentifiers>win10-x64;osx.10.11-x64;ubuntu.16.04-x64</RuntimeIdentifiers>
  </PropertyGroup>

  <ItemGroup>
    <Folder Include="wwwroot\" />
  </ItemGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore" Version="1.1.2" />
  </ItemGroup>

</Project>

Angelo 服务器入口

运行一个 HTTP 服务器,在 Kestrel 的眼中就是一个 WebHostBuilder,WebHostBuilder 绑定好参数就可以运行了,ASP.NET 团队做了很多事情, 比如多线程,TCP 设置 NoDelay ,开启 HTTPS , 开启 HTTPS 要额外的证书,设置网站根目录,UseStartup 是一种模板类, 实现的类必须拥有 Configure 方法。关于 Unix domain socket,笔者并未测试。

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Hosting;

namespace Angelo
{
    public class Program
    {
        public static void Main(string[] args)
        {
            var host = new WebHostBuilder()
                .UseKestrel()
                .UseContentRoot(Directory.GetCurrentDirectory())
                .UseIISIntegration()
                .UseStartup<Startup>()
                .Build();

            host.Run();
        }
    }
}

服务器配置与初始化

熟悉 .NET 的都知道,以前读取配置文件有 System.Configuration ,通过读取 XML 格式后缀名为 .config 的配置文件,而 ASP.NET Core 支持 INI JSON XML 类型的配置,目前我使用的是 JSON, 即 appsettings.json

以下面的配置文件(appsettings.json)内容:

{
    "Logging": {
        "IncludeScopes": false,
        "LogLevel": {
            "Default": "Warning"
        }
    },
    "Appconfig": {
        "Root": "F:\\GitHub",
        "PathConvert": false,
        "Realm": "git.io",
        "Gitbin": "C:\\Program Files\\Git\\bin\\git.exe"
    }
}

初始化解析分别如下:

Appconfig.cs:

using System;
using System.Text;
using System.Linq;

namespace Angelo
{
    public class AuthResult
    {
        public int Result { get; set; }
        public int Userid { get; set; }
    }
    public class Appconfig
    {
        public string Root { get; set; }
        public bool PathConvert { get; set; } = false;
        public string AuthorizeURL { get; set; }
        public string Realm { get; set; }
        public string Gitbin { get; set; }
    }
    public class AppconfigProvider
    {
        public static Appconfig config { get; set; }
        public static string PathcombineRoot(string repodir)
        {
            var sb = new StringBuilder(config.Root);
            if (config.PathConvert)
            {
                if (repodir.Length < 3)
                    return null;
                sb.Append('/');
                if (repodir.First() == '/')
                {
                    sb.Append(repodir.ElementAt(1));
                    sb.Append(repodir.ElementAt(2));
                }
                else
                {
                    sb.Append(repodir.ElementAt(0));
                    sb.Append(repodir.ElementAt(1));
                    sb.Append('/');
                }
                sb.Append(repodir);
                return System.IO.Path.GetFullPath(sb.ToString());
            }
            if (repodir.First() != '/')
            {
                sb.Append('/');
            }
            sb.Append(repodir);
            return System.IO.Path.GetFullPath(sb.ToString());
        }
        public static AuthResult Authorize(string authtext, string pwn)
        {
            /// NOT IMPL
            var result = new AuthResult
            {
                Result = 0,
                Userid = 1
            };
            return result;
        }
    }
}

Startup.cs

using System;
using System.Text;
using System.Linq;

namespace Angelo
{
    public class AuthResult
    {
        public int Result { get; set; }
        public int Userid { get; set; }
    }
    public class Appconfig
    {
        public string Root { get; set; }
        public bool PathConvert { get; set; } = false;
        public string AuthorizeURL { get; set; }
        public string Realm { get; set; }
        public string Gitbin { get; set; }
    }
    public class AppconfigProvider
    {
        public static Appconfig config { get; set; }
        public static string PathcombineRoot(string repodir)
        {
            var sb = new StringBuilder(config.Root);
            if (config.PathConvert)
            {
                if (repodir.Length < 3)
                    return null;
                sb.Append('/');
                if (repodir.First() == '/')
                {
                    sb.Append(repodir.ElementAt(1));
                    sb.Append(repodir.ElementAt(2));
                }
                else
                {
                    sb.Append(repodir.ElementAt(0));
                    sb.Append(repodir.ElementAt(1));
                    sb.Append('/');
                }
                sb.Append(repodir);
                return System.IO.Path.GetFullPath(sb.ToString());
            }
            if (repodir.First() != '/')
            {
                sb.Append('/');
            }
            sb.Append(repodir);
            return System.IO.Path.GetFullPath(sb.ToString());
        }
        public static AuthResult Authorize(string authtext, string pwn)
        {
            /// NOT IMPL
            var result = new AuthResult
            {
                Result = 0,
                Userid = 1
            };
            return result;
        }
    }
}

服务器请求与响应

在 Angelo 中,核心就是对请求进行处理后使用不同参数执行不同的 git 命令,讲 HTTP 包体写入到 git 命令输入,讲 git 命令输出作为响应体返回给客户端。

得益于 DotNet async/await 实现 Angelo 的过程颇为简单。

using System;
using System.IO;
using System.IO.Compression;
using System.Diagnostics;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;

namespace Angelo
{
    public class Session
    {
        private HttpContext Context { get; }
        public Session(HttpContext context) 
        {
            this.Context = context;
               
        }
        private async Task Unauthorized()
        {
            Context.Response.StatusCode = StatusCodes.Status401Unauthorized;
            Context.Response.Headers.Add("WWW-Authenticate", $"Basic realm=\"{AppconfigProvider.config.Realm}\"");
            await Context.Response.WriteAsync("Unauthorized");
        }
        private async Task NotFound()
        {
            Context.Response.StatusCode = StatusCodes.Status404NotFound;
            await Context.Response.WriteAsync("Not Found");
        }

        void HeadersFill()
        {
            Context.Response.Headers["Expires"] = "Fri, 01 Jan 1980 00:00:00 GMT";
            Context.Response.Headers["Pragma"] = "no-cache";
            Context.Response.Headers["Cache-Control"] = "no-cache, max-age=0, must-revalidate";
        }

        private async Task ExchangeRefs()
        {
            var url = Context.Request.Path.ToString();
            if (!url.EndsWith("/info/refs") || !Context.Request.Query.ContainsKey("service"))
            {
                Context.Response.StatusCode = StatusCodes.Status400BadRequest;
                await Context.Response.WriteAsync("Bad Request !");
                return;
            }
            var path = url.Substring(0, url.Length - "/info/refs".Length);
            string authtxt = null;
            if (Context.Request.Headers.ContainsKey("Authorization"))
            {
                authtxt = Context.Request.Headers["Authorization"].ToString();
            }
            var result = AppconfigProvider.Authorize(authtxt, path);
            if (result == null)
            {
                await Unauthorized();
                return;
            }
            var repodir = AppconfigProvider.PathcombineRoot(path);
            if (!Directory.Exists(repodir))
            {
                await NotFound();
                return;
            }
            HeadersFill();
            Process process = new Process();
            process.StartInfo.FileName = AppconfigProvider.config.Gitbin;
            process.StartInfo.Environment.Add("GL_ID", $"user-{result.Userid}");
            process.StartInfo.RedirectStandardOutput = true;
            switch (Context.Request.Query["service"])
            {
                case "git-upload-pack":
                    {
                        process.StartInfo.Arguments = $"upload-pack --stateless-rpc --advertise-refs \"{repodir}\"";
                        Context.Response.ContentType = "application/x-git-upload-pack-advertisement";
                        var bytes = System.Text.Encoding.UTF8.GetBytes("001e# service=git-upload-pack\n0000");
                        await Context.Response.Body.WriteAsync(bytes, 0, bytes.Length);
                    }
                    break;
                case "git-receive-pack":
                    {
                        process.StartInfo.Arguments = $"receive-pack --stateless-rpc --advertise-refs \"{repodir}\"";
                        Context.Response.ContentType = "application/x-git-receive-pack-advertisement";
                        var bytes2 = System.Text.Encoding.UTF8.GetBytes("001f# service=git-receive-pack\n0000");
                        await Context.Response.Body.WriteAsync(bytes2, 0, bytes2.Length);
                    }
                    break;
                default:
                    {
                        Context.Response.StatusCode = StatusCodes.Status400BadRequest;
                        await Context.Response.WriteAsync("Invalid service !");
                    }
                    break;
            }
            if (!process.Start())
            {
                Context.Response.StatusCode = StatusCodes.Status404NotFound;
                await Context.Response.WriteAsync("Git Not Found");
            }
            await process.StandardOutput.BaseStream.CopyToAsync(Context.Response.Body);
        }
        private async Task ExchangePackets()
        {
            var url = Context.Request.Path.ToString();
            var index = url.LastIndexOf('/');
            if (index == -1)
            {
                Context.Response.StatusCode = StatusCodes.Status404NotFound;
                await Context.Response.WriteAsync("Not Found!");
                return;
            }
            var service = url.Substring(index + 1);
            var path = url.Substring(0, index);
            string authtxt = null;
            if (Context.Request.Headers.ContainsKey("Authorization"))
            {
                authtxt = Context.Request.Headers["Authorization"].ToString();
            }
            var result = AppconfigProvider.Authorize(authtxt, path);
            if (result == null)
            {
                await Unauthorized();
                return;
            }
            var repodir = AppconfigProvider.PathcombineRoot(path);
            if (!Directory.Exists(repodir))
            {
                await NotFound();
                return;
            }
            HeadersFill();
            Process process = new Process();
            process.StartInfo.FileName = AppconfigProvider.config.Gitbin;
            process.StartInfo.RedirectStandardInput = true;
            process.StartInfo.RedirectStandardOutput = true;
            process.StartInfo.Environment.Add("GL_ID", $"user-{result.Userid}");
            if (service == "git-upload-pack")
            {
                process.StartInfo.Arguments = "upload-pack  --stateless-rpc  \"" + repodir + "\"";
                Context.Response.ContentType = "application/x-git-upload-pack-result";
            }
            else if (service == "git-receive-pack")
            {
                process.StartInfo.Arguments = "receive-pack  --stateless-rpc \"" + repodir + "\"";
                Context.Response.ContentType = "application/x-git-receive-pack-result";

            }
            else
            {
                Context.Response.StatusCode = StatusCodes.Status400BadRequest;
                await Context.Response.WriteAsync("Invalid service !");
                return;
            }
            if (!process.Start())
            {
                Context.Response.StatusCode = StatusCodes.Status404NotFound;
                await Context.Response.WriteAsync("Git Not Found");
            }
            if (Context.Request.Headers.ContainsKey("Content-Encoding") && Context.Request.Headers["Content-Encoding"].Equals("gzip"))
            {
                var input = new GZipStream(Context.Request.Body, CompressionMode.Decompress);
                await input.CopyToAsync(process.StandardInput.BaseStream);
            }
            else
            {
                await Context.Request.Body.CopyToAsync(process.StandardInput.BaseStream);
                await process.StandardInput.WriteAsync('\0');
            }
            process.StandardInput.Dispose();
            await process.StandardOutput.BaseStream.CopyToAsync(Context.Response.Body);
        }
        public async Task Handle()
        {
            Context.Response.Headers["Server"] = "Angelo/1.0";
            if (Context.Request.Method == "GET")
            {
                await ExchangeRefs();
            }
            else if (Context.Request.Method == "POST")
            {
                await ExchangePackets();
            }
            else
            {
                Context.Response.StatusCode = StatusCodes.Status405MethodNotAllowed;
                await Context.Response.WriteAsync("Method Not Allowed");
            }
            //Context.Request.Path;
        }
    }
}

运行

dotnet run

在 Windows 10 上运行成功
在 Ubuntu 16.04 上运行成功

其他

源码使用 MIT 协议托管到 Github: https://github.com/fcharlie/Angelo,验证等其他细节未实现。