前言
最近在做业务时,接到了一个小需求: 后端服务记录下请求客户端的Ip地址,方便后续的问题定位和追踪。虽然请求的客户端有后台web服务,移动app和设备,这里想追踪的其实是设备。
在这种图中,工程网关和子设备-电表,子设备-开关属于同一个Zigbee自组网中 ,而工程网关与云端服务以MQTT协议进行交互。客户通过后台网页服务和移动App对电表和开关进行控制指令的下发,但是偶尔会发生子设备没发生响应的情况。项目经理认为是我没有将指令投递到网关上。
此时的我汗流浃背,满头大汗。问题的关键是找到关键的问题。
事情到了这里,我作为被告,我需要证明控制指令已经下发到了网关上。立马我问嵌入式同事是否有方法可以看到网关的运行日志。嵌入式同学说,你拿到设备的真实Ip地址,直接通过ssh命令即可登录到网关中看到网关的运行日志了。
此刻,我觉得离我沉冤昭雪的日子马上到来了。
愚昧之山
作为多年的CRUD工程师,我熟练掌握了Restful接口开发 和网络Socket编程。我写下了验证我清白的2行代码:
HTTP接口:
java
@RequestMapping("/")
@ResponseBody
public void requestIn(@PathVariable String entry,
HttpServletRequest httpServletRequest,
HttpServletResponse httpServletResponse) {
// 获取客户端 IP 地址
String clientIp = request.getRemoteAddr();
}
TCP网络编程逻辑:
java
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
// 获取客户端 IP 地址
log.info("address: {}", ctx.channel.remoteAddress();
......
}
当我急切的将其部署上去时,发现上述2个接口中获取的IP地址都是: 192.168.1.107。
难道那里出现问题了么。我恍然想起,我的后端服务都是经过代理过的,这个IP是envoy那台机器的地址。(备注: Envoy是一个类似Nginx的服务代理,但是可动态配置,性能很高且灵活性,在云原生时代的指定CNCF代理服务)
绝望之谷
此时的我拿出服务的部署架构图,我仔细端详:
在常见的代理配置中,TCP和HTTP的代理配置也是有所不同的,这里需要普及下知识。
OSI七层模型
要聊几层代理,需要先看一下网络分层,标准的七层网络分层,也就是OSI七层模型。TCP/IP五层模型和TCP/IP四层模型是从OSI七层优化而来。
从下往上看,第四层为传输层、第七层为应用层。再来看看每层对应的常见协议:
四层对应的是TCP/UDP协议,也就常说的IP+端口。七层已经是非常具体的应用层协议了。因此,所谓四层就是基于IP+端口的负载均衡;七层就是基于URL等应用层信息的负载均衡。
四层代理
四层代理主要工作于OSI模型中的传输层,传输层主要处理消息的传递,而不管消息的内容。TCP就是常见的四层协议。
四层负载均衡只针对由上游服务发送和接收的网络包,而并不检查包内的具体内容是什么。四层负载均衡可以通过检查TCP流中的前几个包,从而决定是否限制路由。
因此,四层负载均衡的核心就是IP+端口层面的负载均衡,不涉及具体的报文内容。
七层代理
七层代理主要工作于OSI模型的应用层,应用层主要用来处理消息内容的。比如,HTTP便是常见的七层协议。
七层负载均衡服务器起到了反向代理的作用,Client端要先与七层负载均衡设备三次握手建立TCP连接,把要访问的报文信息发送给七层负载均衡。
七层负载均衡器基于消息中内容( 比如URL或者cookie中的信息 )来做出负载均衡的决定。之后,七层负载均衡器建立一个新的TCP连接来选择上游服务并向这个服务发出请求。
此时我的心凉半截,如此复杂啊!
开悟之坡
经过 3 分钟的自我内耗后,并喝了一杯热水♨️后,拾起书📚来补充下知识。
Web服务七层代理处理
在http/https的协议中,我们可以通过 X-Forwarded-For
从 Header 信息中获取到离服务端最近的 client 端的 IP 地址, 如果请求经过了多级代理且每级代理都开启此特性, 就可以获得真实有效的用户 IP。
Envoy配置:
yaml
static_resources:
listeners:
# \u4e0d\u5e26psk\u8ba4\u8bc1\u7684\u8bbe\u5907\u7684atop\u5165\u53e3
- name: listener_http_8888
address:
socket_address:
address: 0.0.0.0
port_value: 8888
filter_chains:
- filters:
- name: envoy.filters.network.http_connection_manager
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
stat_prefix: ingress_http_8888
access_log:
......
http_filters:
- name: envoy.filters.http.router
use_remote_address: true
xff_num_trusted_hops: 1
route_config:
......
这里关键的配置主要有 2 行:
-
use_remote_address
: x-forwarded-for (XFF) 是一个标准的代理标头,它表示请求在从客户端到服务器的路上经过的 IP 地址。 在代理请求之前,兼容代理会将最近客户端的 IP 地址附加到 XFF 列表中。仅在 use_remote_address HTTP 连接管理选项设置为 true 时,Envoy 才会追加到 XFF 。 这意味着如果 use_remote_address 为 false(这是默认值),则连接管理器将以不修改 XFF 的透明模式运行。
-
xff_num_trusted_hops
: 如果 use_remote_address 为 true 并且 xff_num_trusted_hops 设置为大于零的值 N,则可信客户端地址是 XFF 右端的第 N 个地址。 (如果 XFF 包含的地址少于 N 个,Envoy 就会使用直接下行连接的源地址作为可信客户端地址。)
后端服务获取IP地址:
java
@RequestMapping("/")
@ResponseBody
public void requestIn(@PathVariable String entry,
HttpServletRequest httpServletRequest,
HttpServletResponse httpServletResponse) {
// 获取 X-Forwarded-For 头部值
String xForwardedForHeader = httpServletRequest.getHeader("X-Forwarded-For");
// 提取真实客户端 IP 地址
String clientIpAddress = extractClientIpAddress(xForwardedForHeader);
log.info("Client IP Address:{}", clientIpAddress);
}
TCP层代理处理
使用Proxy Protocol: proxy protocol 最早由 HAproxy 发明实现. 是一种类似 X-Forwarded-For
的基于应用层实现的方式. 由于是基于应用层, 所以需要客户端和服务器端同时支持此协议。
其如何工作的呢?proxy protocol 支持 v1
和 v2
两个版本. v1 版本以明文的字符串发送数据, v2 版本以二进制格式发送. 简单而言, proxy protocol
实现主要是在建立 TCP 连接后, 在发送应用数据之前先将用户的 IP 信息发送到服务端. 我们可以简单理解为 TCP 三次握手完成后由 proxy protocol
的客户端立即将用户的 IP 信息发送过来。
Envoy配置:
yaml
- name: mqtt_service
connect_timeout: 30s
per_connection_buffer_limit_bytes: 32768
health_checks:
......
type: STATIC
lb_policy: ROUND_ROBIN
transport_socket:
name: envoy.transport_sockets.upstream_proxy_protocol
typed_config:
"@type": type.googleapis.com/envoy.extensions.transport_sockets.proxy_protocol.v3.ProxyProtocolUpstreamTransport
config:
version: V2
transport_socket:
name: envoy.transport_sockets.raw_buffer
load_assignment:
cluster_name: mqtt_service
endpoints:
......
这里我使用的是V2版本的Proxy Protocol协议。
后端服务处理:
java
@Override
protected void initChannel(SocketChannel ch) {
ChannelPipeline pipeline = ch.pipeline();
if (context.isProxyEnabled()) {
pipeline.addLast("proxy", new HAProxyMessageDecoder());
pipeline.addLast("ipFilter", new ProxyIpFilter(context));
} else {
pipeline.addLast("ipFilter", new IpFilter(context));
}
......
}
在这段代码中,可以发现我是否经过代理做成配置项来分别处理了。为什么要这么做呢?
因为在MQTT协议中,客户端发起的第一个请求会是CONNECT报文。而经过Envoy代理后的服务,Envoy会在建立连接的时候发送且只发送一次Proxy Protocol报文。如果 2 者进行混用。会发现不代理的情况下,MQTT客户端连不上的情况。
此时,有的同学奇怪HAProxyMessageDecoder这个解码器放在最上面,对于正常请求有影响没?那让我们看下其逻辑:
bash
@Override
protected final void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception {
// determine the specification version
if (version == -1) {
if ((version = findVersion(in)) == -1) {
return;
}
}
ByteBuf decoded;
if (version == 1) {
decoded = decodeLine(ctx, in);
} else {
decoded = decodeStruct(ctx, in);
}
if (decoded != null) {
finished = true;
try {
if (version == 1) {
out.add(HAProxyMessage.decodeHeader(decoded.toString(CharsetUtil.US_ASCII)));
} else {
out.add(HAProxyMessage.decodeHeader(decoded));
}
} catch (HAProxyProtocolException e) {
fail(ctx, null, e);
}
}
}
留意注释里强调的:finished变量,接着往下看:
java
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
super.channelRead(ctx, msg);
if (finished) {
ctx.pipeline().remove(this);
}
}
解码成功后,后面就将它移除了,所以不用担心后面读取正常请求数据的时候会被这个decoder影响到。
效果演示
bash
Received msg: HAProxyMessage(protocolVersion: V2, command: PROXY, proxiedProtocol: TCP4, sourceAddress: 192.168.1.106, destinationAddress: 172.18.0.4, sourcePort: 60408, destinationPort: 5883, tlvs: [])
Received msg: HAProxyMessage(protocolVersion: V2, command: PROXY, proxiedProtocol: TCP4, sourceAddress: 192.168.1.109, destinationAddress: 172.18.0.4, sourcePort: 6106, destinationPort: 8883, tlvs: [])
拿到这个网关的IP地址后,我进到这个网关里查看其运行日志。和嵌入式进行疯狂的battle。最后证明我的程序是没问题的,是子设备信号指令查导致的。
开心一笑!深藏功与名。希望大家后续可以通过这个实战案例,学习如何获取这狡猾的IP地址。