标签归档:HTTP

绕过代理服务器获取客户端IP

一般来说,Web应用和客户端之间隔着很多个代理服务器,无论正向代理还是反向代理。这样就给本地资讯类应用(新闻、天气等)或者统计(审计,追踪等)需求带来了麻烦,因为在应用通过request.getRemoteAddr()方法(以Java为例)往往只能拿到与自己直接通信的设备IP。因此,如果用户通过直接或者间接的正向代理(ISP提供的缓存服务器等)上网,Web应用只会取到正向代理服务器地址;如果Web应用前端部署着Nginx或者Apache之类的反向代理,Web应用只会取到反向代理的地址。

面对这样的限制,代理厂商利用HTTP自定义Header规避了问题。比如Nginx,可以添加以下配置:

proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

它向后端应用传递了两个HTTP自定义Header,X-Real-IP和X-Forwarded-For。这两个Header作用类似,都是向后端透传客户端源IP,但是又有一些不同,主要区别在于:

1. X-Forwarded-For已有标准RFC文档(RFC 7239)定义,而X-Real-IP没有。
2. X-Forwarded-For Header格式为X-Forwarded-For: client1, proxy1, proxy2,每级代理都会将与自己直接通信的对端IP追加在X-Forwarded-For中,因此应用还需要额外解析IP列表以获取所需IP。而X-Real-IP只会记录与自己直接通信的对端IP。

由此,反向代理后面的应用服务器可以通过X-Real-IP或者X-Forwarded-For请求头来获取客户端IP:

String xRealIp = request.getHeader("X-Real-IP");
String xForwardIps = request.getHeader("X-Forwarded-For");

另外需要注意的是,HTTP Header是很容易被伪造的,因此,X-Forwarded-For Header中的首个IP也不意味着就一定是客户端源IP。从安全性方面来说,如果应用前不加反向代理,则request.getRemoteAddr()拿到的IP便是可信的,无法伪造;如果加了反向代理,那么X-Real-IP和X-Forwarded-For IP的最后一个IP是可信的,无法伪造。

--EOF--

WebSocket via nginx

WebSocket的握手流程[1]如下:
浏览器请求:

1
2
3
4
5
6
7
8
GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw==
Sec-WebSocket-Protocol: chat, superchat
Sec-WebSocket-Version: 13
Origin: http://example.com

服务器响应:

1
2
3
4
5
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: HSmrc0sMlYUkAGmm5OPpG2HaGWk=
Sec-WebSocket-Protocol: chat

细节不展开了,只需关注浏览器和服务器之间如何通过Upgrade和Connection header进行协商,将一个HTTP连接升级为WebSocket。

nginx 1.3版本以后开始支持WebSocket的反向代理。因为WebSocket的握手主要是通过标准HTTP请求和响应来实现的,因此对于反向代理服务器,nginx在7层只要正确处理好WebSocket协议的握手流程,使得本来在tcp连接上跑HTTP协议的,现在用来跑WebSocket,只要握手完成剩下的交互就是tcp层面的事,跟HTTP协议没有关系了。注意,如果nginx版本小于1.3,对WebSocket进行反向代理时会出现“WebSocket is closed before the connection is established.”的警告。

nginx支持WebSocket需进行以下配置(官方文档):

1
2
3
4
5
6
location /chat/ {
    proxy_pass http://backend;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
}

HTTP 1.1的长连接是WebSocket协商的基础,另外因为Upgrade和Connection header在HTTP协议中都是hop-by-hop header,因此在反向代理的时候需要把这两个header回填,发送到服务器端。从全局来看,就是客户端(浏览器)同nginx之间建立一条tcp长连接,nginx和后端服务器之间建立一条(或多条,视upstream的server节点个数)tcp长连接,这些长连接都是用来传递WebSocket数据帧。

以下是一个真实例子,使用场景来为:
Openstack组件中有个nova-novncproxy服务,支持用户通过浏览器以VNC的方式登陆虚拟机。现在有一个类似域名为https://console.ustack.com的Web管理平台,在VNC登陆虚拟机页面嵌入noVNC地址。这里的问题是,Web管理平台是https的,如果noVNC地址是http,那么高版本浏览器为了安全考虑都会屏蔽noVNC页面,理由是https页面内嵌了http页面;如果给noVNC页面也加个https支持,那就有点浪费了,给IP地址买张证书不划算,给域名买张证书不划算的同时还浪费一个域名。所以最好的方式就是在Web管理平台的前端nginx机器上为noVNC做个反向代理,用户看到的都是Web管理平台的域名,这样在交互上也显得统一。结合上文描述的nginx支持WebSocket的配置,用法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
#web管理平台
upstream web {
  server 127.0.0.1:8080;
  keepalive 80;
}
 
#noVNC
upstream vnc {
  server 111.26.23.10:6080;
  keepalive 80;
}
 
 
server {
  listen 443;
  server_name console.ustack.com;
  ssl on;
  ssl_certificate /etc/nginx/ssl/server.crt;
  ssl_certificate_key /etc/nginx/ssl/server.key;
 
  location / {
    proxy_pass http://web;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header REMOTE-HOST $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_http_version 1.1;
  }
 
  location /vnc {
    rewrite ^/vnc/(.*) /$1 break;
    proxy_pass http://vnc;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header REMOTE-HOST $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_http_version 1.1;
  }
 
  location /websockify {
    proxy_pass http://vnc;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header REMOTE-HOST $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "Upgrade";
    proxy_read_timeout 86400;
  }
}

其中,/websockify location就是用来反向代理WebSocket请求,proxy_read_timeout参数是为了避免长时间没有数据收发时连接被nginx关闭。

[1] WebSocket. http://en.wikipedia.org/wiki/WebSocket.

--EOF--

利用iptables实现基于端口的网络流量统计

如何统计某个应用的网络流量(包括网络流入量和网络流出量)问题,可以转换成如何基于端口号进行网络流量统计的问题。大部分网络应用程序都是传输层及以上的协议,因此基于端口号(tcp, udp)统计网络流量基本能覆盖到此类需求。

利用iptables实现基于端口的流量统计是一种比较简单可行的方案。它可以对流经每一条规则的包数量和流量进行计数。例如要对常规的Web服务器进行流量统计,可以设置如下规则:

1
2
root@debian:~# iptables -A INPUT -p tcp --dport 80
root@debian:~# iptables -A OUTPUT -p tcp --sport 80

第一条规则表示,在INPUT链上添加一条规则,该条规则对所有来自外部网络的、与本机80端口通信的请求有效,即网络流入量,第二条规则则相反,它用于统计从本机80端口发出的网络流量,即网络流出量。因为我们的目的是统计流量,故此处可以省略ACCEPT或DROP之类的动作。

查看流量计数时,只要加上-nvx参数即可:

1
2
3
4
5
6
7
8
9
10
11
root@debian:~# iptables -nvx -L
Chain INPUT (policy ACCEPT 320 packets, 33045 bytes)
pkts     bytes target     prot opt in     out     source  destination         
0        785      tcp  --  *      *   0.0.0.0/0   0.0.0.0/0 tcp dpt:80
 
Chain FORWARD (policy ACCEPT 0 packets, 0 bytes)
pkts     bytes target     prot opt in     out     source  destination         
 
Chain OUTPUT (policy ACCEPT 311 packets, 33711 bytes)
pkts     bytes target     prot opt in     out     source  destination         
0        1252      tcp  --  *      *   0.0.0.0/0   0.0.0.0/0 tcp spt:80

iptables的-vx参数表示详细信息,可以以字节为单位显示包数量和网络流量,-n参数表示以数字方式表示ip地址和端口号等,不加的话iptables会默认将ip反解为主机名,例如127.0.0.1用localhost表示,端口号显示为协议号,例如80显示成http, 5672显示成amqp。

利用iptables规则统计网络流量可以保证应用在关闭重启后统计数据不丢失,但是对于主机重启的情况,它就无能为力了,因为默认情况下主机重启iptables规则会清空,即使使用开机任务等方式重建iptables规则,流量计数器也会清零。解决此类问题一个变通的方法是定期将网络流量值保存到文件,并清0重新计数,定期更新的时间越短,机器重启造成的流量计数丢失问题影响越小(特别涉及计费相关的业务)。

创建一个crontab任务,定期将流量统计追加至文件,例如:

1
root@debian:~# iptables -vxn -L >> file

也可以选择直接将流量值追加到文件:

1
root@debian:~# iptables -vxn -L | awk '{print $2}' >> file

当写入文件成功后,记得将流量值清0:

1
root@debian:~# iptables -Z

-Z参数支持指定链名称和规则索引号,例如下列命令表示清空INPUT链的第一条规则的流量计数器。

1
root@debian:~# iptables -Z INPUT 1

Reference:
[1] iptables man page: http://ipset.netfilter.org/iptables.man.html.

--EOF--

如何检测RabbitMQ工作状态

RabbitMQ的management插件提供了一个基于HTTP的接口GET /api/aliveness-test/%2F来检测RabbitMQ工作状态,aliveness-test是声明在%2F(默认vhost '/')下的一个测试队列,如果返回响应码200 OK,响应体{"status":"ok"},就可以认为当前RabbitMQ节点处在正常工作状态。

大部分情况下,这个接口屡试不爽。然而工作中碰到过一个场景,此接口出现过非预期的结果,具体原因尚不明确,只有初步结论:通过HTTP接口测试RabbitMQ工作状态不一定可靠。

出现非预期结果的场景如下:
有两台通过Openstack nova api创建的KVM虚拟机,在虚拟机里分别部署RabbitMQ(称为rabbit1@192.168.1.100, rabbit2@192.168.1.101),rabbit1@192.168.1.100和 rabbit2@192.168.1.101通过join_cluster命令建好集群模式。另外,在两个虚拟机内分别运行心跳程序,心跳程序每5s运行一次,它先调用GET /api/aliveness-test/%2F获取RabbitMQ工作状态,如果运行正常,则向管理服务器发送一个心跳包。

假如这时候通过nova的POST v2/{tenant_id}/servers/action接口关闭或者重启虚拟机192.168.1.100:

1
2
3
4
5
6
7
8
9
10
11
POST /${tenant_id}/servers/${server_id}/action
 
HOST: nova.server.xxx.org
Content-Type: application/json
Accept: applicaton/json
 
{
    "reboot" : {
        "type" : "HARD"
    }
}
1
2
3
4
5
6
7
8
9
POST /${tenant_id}/servers/${server_id}/action
 
HOST: nova.server.xxx.org
Content-Type: application/json
Accept: applicaton/json
 
{
    "os-stop": null
}

理论上,此时另一个RabbitMQ节点的心跳程序不应受到影响才对,但实际上,调用http://192.168.1.101:15672/api/aliveness-test/%2F会发现连接被阻塞住,无法返回。直到较长时间后接口才会返回。在这个HTTP接口被阻塞的过程中,RabbitMQ端口5672、HTTP API端口15672均正常开启,服务正常,但是因为心跳检测异常,管理服务器早已判定这两个节点离线,这就造成了误报。

到目前为止,这种诡异的情况只出现在这么一个场景下。其他情况比如ssh到其中一台虚拟机,通过reboot(8), shutdown(8)重启或者关闭,另一个RabbitMQ节点的HTTP接口不会被阻塞。此外,我了解到通过nova api关闭虚拟机,就相当于按了这台机器的电源键(强制关机、断电),于是尝试过两台物理机组成一个RabbitMQ节点集群,环境分别为OS X 10.8.2 + Erlang R15B03 + RabbitMQ 3.1.3和Win7 + Erlang R15B03-1 + RabbitMQ 3.1.5,在两个节点正常运行的情况下,强制关闭其中一台机器,也未出现上述问题。

既然通过HTTP的方式检测服务状态来发心跳的方法不靠谱,于是想到了通过检测TCP端口号的方式,以Python为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import socket
 
def check_aliveness(ip, port):
    sk = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    sk.settimeout(1)
    try:
        sk.connect((ip,port))
        print 'service is OK!'
        return True
    except Exception:
        print 'service is NOT OK!'
        return False
    finally:
        sk.close()
    return False
 
check_aliveness('127.0.0.1', 15672)

5672端口和15672端口,检测哪个端口更合理些?RabbitMQ默认会通过日志($RABBITMQ_HOME/var/log/rabbitmq/*.log)记录5672端口的客户端TCP连接/释放信息,如果长期运行,正常的AMQP客户端信息会被心跳连接信息淹没。所以,还是检测15672端口合理一些。

更简单的可以通过Bash脚本直接检查5672端口有没开启:

1
2
3
$ port=5672 
$ netstat -an | grep LISTEN | grep $port
tcp46      0      0  *.5672                 *.*                    LISTEN

--EOF--

RabbitMQ消息过期时间

RabbitMQ针对队列中的消息过期时间(Time To Live, TTL)有两种方法可以设置。第一种方法是通过队列属性设置,队列中所有消息都有相同的过期时间。第二种方法是对消息进行单独设置,每条消息TTL可以不同。如果上述两种方法同时使用,则消息的过期时间以两者之间TTL较小的那个数值为准。消息在队列的生存时间一旦超过设置的TTL值,就成为dead message,消费者将无法再收到该消息。

1. 设置队列属性。
通过队列属性设置消息TTL的方法是在queue.declare方法中加入x-message-ttl参数,单位为ms。
SDK设置如下:

1
2
3
Map<String, Object> args = new HashMap<String, Object>();
args.put("x-message-ttl", 60000);
channel.queueDeclare("myqueue", false, false, false, args);

HTTP接口调用如下:

$ curl -i -u guest:guest -H "content-type:application/json"  -XPUT 
-d'{"auto_delete":false,"durable":true,"arguments":{"x-message-ttl": 60000}}'
http://localhost:15672/api/queues/{vhost}/{queuename}

如果不设置TTL,则表示此消息不会过期。如果将TTL设置为0,则表示除非此时可以直接将消息投递到消费者,否则该消息会被立即丢弃,这个特性可以部分代替RabbitMQ 3.0.0以前支持的immediate参数,之所以说部分代替,是因为immediate参数在投递失败会有basic.return方法将消息体返回,详见『AMQP协议mandatory和immediate标志位区别』

2. 设置消息属性。
针对每条消息设置TTL的方法是在basic.publish方法中加入expiration的属性参数,单位为ms。
SDK设置如下:

1
2
3
4
byte[] messageBodyBytes = "Hello, world!".getBytes();
AMQP.BasicProperties properties = new AMQP.BasicProperties();
properties.setExpiration("60000");
channel.basicPublish("myexchange", "routingkey", properties, messageBodyBytes);

HTTP接口调用如下:

$ curl -i -u guest:guest -H "content-type:application/json"  -XPOST 
-d'{"properties":{"expiration":"60000"},"routing_key":"routingkey","payload":"my 
body","payload_encoding":"string"}' 
http://localhost:15672/api/exchanges/{vhost}/{exchangename}/publish

对于第一种设置队列TTL属性的方法,一旦消息过期,就会从队列中抹去,而第二种方法里,即使消息过期,也不会马上从队列中抹去,因为每条消息是否过期时在即将投递到消费者之前判定的,为什么两者得处理方法不一致?因为第一种方法里,队列中已过期的消息肯定在队列头部,RabbitMQ只要定期从队头开始扫描是否有过期消息即可,而第二种方法里,每条消息的过期时间不同,如果要删除所有过期消息,势必要扫描整个队列,所以不如等到此消息即将被消费时再判定是否过期,如果过期,再进行删除。

--EOF--