小坑,大跟头。如何正确在 Golang 中在处理 Http Request 之前修改 Host 字段内容

19,975 阅读8分钟

我是 LEE,老李,一个在 IT 行业摸爬滚打 17 年的技术老兵。

事件背景

前几天公司内部的 Serverless 项目技术定审核和启动会刚开完,上游的一个事业群的负责人煞有其事的找到我说:老李,你上次开启动会说,Request 的 Header 头中的 Host 字段因为路由会被修改,然后你们使用了 X-Forward-Host 来代替。这几天我跟其他几个事业群的小伙伴沟通的时候发现,业务侧很多人都依赖 Request 中的 Host 字段,而且需要多一步判断动作,太多项目要修改了。你看你们有没有办法完善下。一者你们做底层架构,统一修改远比我们这么多项目都改成本小。二者你们提供的解决方案,我们直接使用也比较放心啊。

在通过一番了解后,发现小伙伴所言不虚。为了业务线全面迁移到 Serverless 系统上,要对这么多的业务系统代码都需要改造,确实不太合理。而且每一个事业群下的不同业务研发都有自己的想法,难以一时统一。纠结一段时间后,决定在我们部门成立一个小组专攻这个问题。

最终的目标:业务线在迁移到 Serverless 系统中,跟使用原有 Kubernetes 系统中 Ingress 逻辑一样。

心智负担

在往下谈的之前,我先说说为什么小伙伴们的“心智负担”是什么?这样让大家能够了解,我们碰到这个问题为什么要这么解决。

此前我们先看看,最早一版本 结构简图:

最初流量模型

通过上图,我们可以得知,Knative Ingress 需要转发流量到 Istio IngressGateway 上需要修改 Host 头部字段,才能满足 Istio IngressGateway 将 Http 请求转发到后端的 Kubernetes 的 Pod 上。所以会导致后端的 Kubernetes 的 Pod 中读取 Http Request 的 Header 中 Host 字段并不是真正客户 HTTP 请求端传入的 Host 字段

为了能够将 Http Request 的原始 Host 往后传,我们决定使用 X-Forwarded-Host 作为“载体”,将 Host 原始内容传入到后端 Pod。这样应用就能够让 Pod 收到真实 Host 内容。

效果如下图:

改进流量模型

TIPS: 实际就是在 Knative Ingress 上开发了一个插件来实现效果。

当然在进入 Pod 之前,也要用另外一个插件来解决将 X-Forwarded-Host 转换回 Host 字段,要不然所有小伙伴在处理业务请求的时候,需要额外做如下的动作。

获得 Host 内容流程

虽然在实际效果上一定程度上解决了问题,但是从某种意义上说,我们只是迂回解决了问题,没有从本质上解决。这才导致之前事业群的负责人找到我们,希望我这边提供解决方案,并完美的解决问题。

显然其他事业群的小伙伴不愿意对原有系统和业务代码做过多的流程改造。所以只能我们另辟蹊径,在 Pod 前的 Sidecar 中将 Host 转换回来。

真正的问题

我们在修补 Sidecar 的代码还算顺利,最重要的工作主要放在了 Http Request 中 Header 字段处理上。一段时间过去了以后,我去追进度,一个研发小伙伴反馈,X-Forwarded-Host 转换回 Host 没办法实现,而且代码正确,debug 看了就是没有办法回写。 我觉得很奇怪,怎么会没有办法让 X-Forwarded-Host 转换回 Host 呢?

问题定位

经过一段时间代码排查,咋一看小伙伴写的代码确实没有问题,而且也符合逻辑。为什么就是不能实现效果呢?

xfh := request.Header.Get("X-Forwarded-Host")
request.Header.Set("Host", strings.Trimspace(xfh))

我自己独立拉去了代码,仔细往下看,才发现问题所在。并不是这个小伙伴开发有问题,而是对 golang 的 net/http 包的内部处理流程不熟悉,想当然的开发。

最后我就改了他一行代码就实现了效果。

// request.Header.Set("Host", strings.Trimspace(xfh))
request.Host = strings.Trimspace(xfh)

再对测试模型发起 Http 请求,观察模拟业务 Pod 收到的 Http Request 中 Host 是否与最初请求的一致。

修复 Host 字段

结果如我所料,符合整个预期。

问题模拟与再现

说到这里,虽然我已经把问题解决了,让项目继续往前走,而且在周五下班前的第一版的内测,反馈效果良好。但是我觉还是非常有必要讲清楚这个问题,因为我想这个小问题非常容易“踩坑”,而且会长时间的照不到解决方案

这里我用 Demo 代码做演示,重要的是说明问题所在。 模拟一个 Http 请求,在发送请求的前,修改 Request Host 字段。

func DoRequest(url string) (*http.Response, error) {
	client := &http.Client{}
	request, err := http.NewRequest(http.MethodGet, url, nil)
	if err != nil {
		return nil, err
	}
	// request.Header.Set("Host", "specific-host") //错误方式
	request.Host = "specific-host"
	rsp, err := client.Do(request)
	if err != nil {
		return nil, err
	}
	return rsp, nil
}

通过实际测试,会发现两种写法有截然不同的两种结果。

request.Header.Set("Host", "specific-host")
// 响应结果:get request 127.0.0.1:40280 to 127.0.0.1:11010
// 不符合预期

request.Host = "specific-host"
// 响应结果:get request 127.0.0.1:40293 to specific-host
// 符合预期

看到这里,应该有小伙伴应该会有一愣,只怕是写很多年的 Golang 代码,实现了无数的处理 Http Request 逻辑,这里提到的问题没有怎么注意过吧? 哈哈哈,实际我这里碰到这样的问题也是少有的几次。

为了真正的能解释问题,我们继续往下追代码。

go/src/net/http/client.go

// 包装函数
func (c *Client) Do(req *Request) (*Response, error) {
	return c.do(req)
}

// 真正的处理函数
func (c *Client) do(req *Request) (retres *Response, reterr error) {

	...

	for {
		// For all but the first request, create the next
		// request hop and replace req.
		if len(reqs) > 0 {

			...

			// 处理 Http Host
			host := ""
			if req.Host != "" && req.Host != req.URL.Host {
				// If the caller specified a custom Host header and the
				// redirect location is relative, preserve the Host header
				// through the redirect. See issue #22233.
				if u, _ := url.Parse(loc); u != nil && !u.IsAbs() {
					host = req.Host
				}
			}

			...

			// 构造 Http Request
			req = &Request{
				Method:   redirectMethod,
				Response: resp,
				URL:      u,
				Header:   make(Header), // 构造 Header,request.Header.Set("Host", "specific-host") 在这里
				Host:     host,  // 传入 Host, request.Host = "specific-host" 在这里
				Cancel:   ireq.Cancel,
				ctx:      ireq.ctx,
			}

		...

		// 发送 Http 请求
		if resp, didTimeout, err = c.send(req, deadline); err != nil {
			// c.send() always closes req.Body
			reqBodyClosed = true
			if !deadline.IsZero() && didTimeout() {
				err = &httpError{
					err:     err.Error() + " (Client.Timeout exceeded while awaiting headers)",
					timeout: true,
				}
			}
			return nil, uerr(err)
		}

		...

		req.closeBody()
	}
}

看到这里就差不多明白了,要在处理 Http Request 的请求之前要对 Host 字段修改,就要使用 Request.Host 而不是 Header.Set("Host")。这个逻辑在 Http Server 中也是一样,gin 框架底层就是使用 Http Server,所以也是这个处理逻辑。

说了这么多,真的说明白了吗?就要使用 Request.Host 而不是 Header.Set("Host") 吗?如果你觉得是,那我得说,那不是我老李的风格,咱们要继续一定要找到官方说明。

下面是 golang 源代码中对 Http Request 的定义和说明,我们仔细看下面的注释

// A Request represents an HTTP request received by a server
// or to be sent by a client.
//
// The field semantics differ slightly between client and server
// usage. In addition to the notes on the fields below, see the
// documentation for Request.Write and RoundTripper.
type Request struct {
	...

	// Header contains the request header fields either received
	// by the server or to be sent by the client.
	//
	// If a server received a request with header lines,
	//
	//	Host: example.com
	//	accept-encoding: gzip, deflate
	//	Accept-Language: en-us
	//	fOO: Bar
	//	foo: two
	//
	// then
	//
	//	Header = map[string][]string{
	//		"Accept-Encoding": {"gzip, deflate"},
	//		"Accept-Language": {"en-us"},
	//		"Foo": {"Bar", "two"},
	//	}
	//
	// For incoming requests, the Host header is promoted to the
	// Request.Host field and removed from the Header map.
	//
	// HTTP defines that header names are case-insensitive. The
	// request parser implements this by using CanonicalHeaderKey,
	// making the first character and any characters following a
	// hyphen uppercase and the rest lowercase.
	//
	// For client requests, certain headers such as Content-Length
	// and Connection are automatically written when needed and
	// values in Header may be ignored. See the documentation
	// for the Request.Write method.
	Header Header

	...

	// For server requests, Host specifies the host on which the
	// URL is sought. For HTTP/1 (per RFC 7230, section 5.4), this
	// is either the value of the "Host" header or the host name
	// given in the URL itself. For HTTP/2, it is the value of the
	// ":authority" pseudo-header field.
	// It may be of the form "host:port". For international domain
	// names, Host may be in Punycode or Unicode form. Use
	// golang.org/x/net/idna to convert it to either format if
	// needed.
	// To prevent DNS rebinding attacks, server Handlers should
	// validate that the Host header has a value for which the
	// Handler considers itself authoritative. The included
	// ServeMux supports patterns registered to particular host
	// names and thus protects its registered Handlers.
	//
	// For client requests, Host optionally overrides the Host
	// header to send. If empty, the Request.Write method uses
	// the value of URL.Host. Host may contain an international
	// domain name.
	Host string

	...

}

golang 官方就特意把 Host 字段单独从 Request Header 中拿出来处理。

我想其中一个比较重要的原因可能是这个:

For client requests, Host optionally overrides the Host header to send. If empty, the Request.Write method uses the value of URL.Host. Host may contain an international domain name.

说到最后

虽然修改 Http Request 中 Host 字段内容不是什么了不起的方法,正因为是这样导致我们在这块储备不足,导致在实际工作中碰到这个卡脖子的问题,导致长时间没有解决。而且我 Google 了下,没有什么人对这个问题做了详细解释,所以最终决定将这个问题“解法”成文,并放在网络上,供大家参考,能够帮助大家及时“避坑”。