ss-libev 原始碼解析local篇(2):ss_local和socks5客戶端握手
上一篇說到ss-libev建立listen_ctx_t物件用於監聽客戶端連線,呼叫accept_cb處理來自客戶端的新連線,建立server_t物件用於處理和客戶端之間的互動。本篇分析來自客戶端的SOCK5連線的建立以及傳輸資料的過程。
首先,回憶一下使用new_server()函式建立server_t物件時,註冊了客戶端連線的讀寫事件的回撥:
ev_io_init(&server->recv_ctx->io, server_recv_cb, fd, EV_READ);
ev_io_init(&server->send_ctx->io, server_send_cb, fd, EV_WRITE );
即,對於客戶端和ss-local server之間的TCP連線,當local server的accepted fd上有可讀事件時,即客戶端傳送資料過來了,server_recv_cb被呼叫;當local server的accepted fd上有可寫事件時,即可以向客戶端傳送資料時(一般是準備傳送資料時start,當寫緩衝可用時觸發),server_send_cb被呼叫。這是libev非同步io的用法,具體可以查詢libev。注意這兩句只是註冊了事件回撥,還沒有啟用監聽。在accept_cb裡面,new_server之後,隨即呼叫了 ev_io_start(EV_A_ & server->recv_ctx->io); 啟用了server->recv_ctx->io上面註冊的事件即讀事件的監聽,當客戶端有資料發過來時,server_recv_cb即被呼叫。而server->send_ctx->io的監聽暫時還沒啟用,因為此刻還不可能有資料可寫,要等到遠端伺服器返回轉發的資料之後才會啟用監聽。好了,先分析server_recv_cb。
static void server_recv_cb(EV_P_ ev_io *w, int revents) 分析
幾個資料結構
- server_ctx_t
typedef struct server_ctx {
ev_io io;
int connected;
struct server *server;
} server_ctx_t;
- server_t
typedef struct server {
int fd;
int stage;
cipher_ctx_t *e_ctx;
cipher_ctx_t *d_ctx ;
struct server_ctx *recv_ctx;
struct server_ctx *send_ctx;
struct listen_ctx *listener;
struct remote *remote;
buffer_t *buf;
buffer_t *abuf;
ev_timer delayed_connect_watcher;
struct cork_dllist_item entries;
} server_t;
- remote_t
typedef struct remote {
int fd;
int direct;
int addr_len;
uint32_t counter;
buffer_t *buf;
struct remote_ctx *recv_ctx;
struct remote_ctx *send_ctx;
struct server *server;
struct sockaddr_storage addr;
} remote_t;
- buffer_t
typedef struct buffer {
size_t idx;
size_t len;
size_t capacity;
char *data;
} buffer_t;
其中 server_t 上篇已經說過,客戶端每來一個新的TCP請求,都會生成一個server_t,可以認為server_t描述了客戶端和ss-local server的互動狀態。server_t包含了server_ctx和buffer_t物件,並引用了remote物件。server_ctx_t用來具體處理客戶端和local server之間的資料讀寫,因此server中包含了兩個server_ctx:recv_ctx和send_ctx。server_ctx_t的結構上篇也說了,主要包含ev_io物件,以及connected標誌,並且還指向了他所屬於的server。remote是用於描述ss-local server和遠端ss-server之間的互動,暫且不說。buffer_t是一個緩衝區結構,server包含兩個緩衝區,分別是buf和abuf。另外server還有一個stage標誌,用於描述自身所處的狀態,在new_server裡面被初始化為STAGE_INIT。所有的狀態描述如下:
#define STAGE_ERROR -1 /* Error detected */
#define STAGE_INIT 0 /* Initial stage */
#define STAGE_HANDSHAKE 1 /* Handshake with client */
#define STAGE_PARSE 2 /* Parse the header */
#define STAGE_RESOLVE 4 /* Resolve the hostname */
#define STAGE_WAIT 5 /* Wait for more data */
#define STAGE_STREAM 6 /* Stream between client and server */
引數解析
libev的io回撥函式,會把io物件傳入。而server_recv_cb實際需要處理server等物件。在函式開頭,利用結構體裡面的指標位置,解出需要的指標:
server_ctx_t *server_recv_ctx = (server_ctx_t *)w;
server_t *server = server_recv_ctx->server;
remote_t *remote = server->remote;
隨後,根據是否已經建立remote,判斷使用哪個buf
if (remote == NULL) {
buf = server->buf;
} else {
buf = remote->buf;
}
如果已經建立了remote,則使用remote的buf,將資料讀入其中,後面會看到這是因為此時已經是要傳送具體的資料了,所以直接把資料讀取到remote的buf中。
讀取資料
既然server_recv_cb是可讀事件的回撥,所以被呼叫時就可以讀取資料了。但這兒要注意的是server的stage,如果是STAGE_WAIT則不讀取,這個wait後面再說。
r = recv(server->fd, buf->data + buf->len, BUF_SIZE - buf->len, 0);
r是成功讀取到的資料的位元組數,如果為0,表示讀取到了一個EOF,即連線已經close,沒有資料可讀了。因為這兒是讀取從客戶端傳送過來的資料,因此是客戶端主動斷開了連線。此時ss-local要做的是關閉和釋放server和remote,本次代理互動結束。
if (r == 0) {
// connection closed
close_and_free_remote(EV_A_ remote);
close_and_free_server(EV_A_ server);
return;
}
如果r==-1,則有可能是出錯了,也需要結束本次代理,但因為是非阻塞io,如果errno == EAGAIN 或 errno == EWOULDBLOCK,則表示現在沒有資料需要再試。如果r>0,則表示讀取到了r位元組資料,記錄在buf的len中,並繼續執行。
buf->len += r;
SOCKS5 方法選擇 (in STAGE_INIT)
上文提到,一個新的server_t,stage初始化為STAGE_INIT,因此在server_recv_cb中,會先處理這個狀態。在STAGE_INIT中,處理SOCK5握手的第一個請求,即method select請求,按照SOCK5規範,客戶端先向SOCK5服務端查詢支援的認證方式,最常用的是匿名和使用者名稱密碼認證,客戶端在請求頭裡面將他希望使用的認證方式都列出來,服務端選擇他要用的方式並返回響應。具體可參閱SOKC5 RFC1928。因為ss-local是在本地執行的,其實認證方式並沒有太大的意義,所以無論客戶端請求什麼樣的方式,ss-local都只返回匿名認證,也就是不認證直接通過,這樣就沒有後續的驗證環節了。
struct method_select_response response;
response.ver = SVERSION;
response.method = 0;
char *send_buf = (char *)&response;
send(server->fd, send_buf, sizeof(response), 0);
server->stage = STAGE_HANDSHAKE;
如這段程式碼所示,ss-local直接給客戶端傳送了一個0X0500,表示我這是匿名認證你來握手吧,然後將server的stage設定為STAGE_HANDSHAKE。
傳送method select reponse之後,有個tcp粘包處理,因為tcp是流協議,沒有訊息邊界,一次recv出來的資料可能超過了本條訊息的長度,因此ss-local在這兒做了個處理:
if (method->ver == SVERSION && method_len < (int)(buf->len)) {
memmove(buf->data, buf->data + method_len , buf->len - method_len);
buf->len -= method_len;
continue;
}
此處method_len為SOCKS5方法選擇請求的訊息長度,根據方法數而變化,如果方法數為1,則長度為3,方法數為2,則長度為4,即長度為方法數+2。如果讀取到的資料超過訊息長度,則把超出的部分資料移動到buf的前端,並把buf的len設定為剩餘資料的長度。這樣相當於清除掉了已經處理過的方法選擇訊息,保留了多讀取到的內容。不過我認為這種情況應該是不會發生的,因為SOCKS5握手階段的訊息都是一應一答,如果服務端不返回method select response,客戶端應該不會進一步傳送其他訊息。TCP粘包一般發生在連續傳送資料時。可以認為ss-local的處理比較嚴謹,可以看成是一種防禦性程式設計。上面沒提到的是,在處理方法選擇請求時,ss-local同樣處理了斷包的情況,即如果收到的資料長度不滿足訊息的長度則直接返回。因為上面列出的資料讀取程式碼,總是將資料新增到buf->data + buf->len處,所以斷包的情況自然能處理好。不過ss-local只是對請求的長度進行檢查,實際並不檢測其內容,所以客戶端只要發一個長度為3的任意包,也能通過這一階段,進入下面的握手。
SOCKS5握手(in STAGE_HANDSHAKE)
SOCK5匿名認證成功之後,客戶端就可以傳送具體的請求細節了,ss-local稱之為握手階段。具體就是客戶端傳送一個SOCKS5請求,粘一段RFC的內容:
+----+-----+-------+------+----------+----------+
|VER | CMD | RSV | ATYP | DST.ADDR | DST.PORT |
+----+-----+-------+------+----------+----------+
| 1 | 1 | X'00' | 1 | Variable | 2 |
+----+-----+-------+------+----------+----------+
Where:
o VER protocol version: X'05'
o CMD
o CONNECT X'01'
o BIND X'02'
o UDP ASSOCIATE X'03'
o RSV RESERVED
o ATYP address type of following address
o IP V4 address: X'01'
o DOMAINNAME: X'03'
o IP V6 address: X'04'
o DST.ADDR desired destination address
o DST.PORT desired destination port in network octet
order
其中cmd為1是tcp轉發請求,3是udp聯合轉發請求,ss-local只支援這兩個cmd。
先來看程式碼:
else if (server->stage == STAGE_HANDSHAKE || server->stage == STAGE_PARSE) {
struct socks5_request *request = (struct socks5_request *)buf->data;
size_t request_len = sizeof(struct socks5_request);
struct sockaddr_in sock_addr;
memset(&sock_addr, 0, sizeof(sock_addr));
if (buf->len < request_len) {
return;
}
在server的stage為STAGE_HANDSHAKE或STAGE_PARSE時,將讀入的資料視為socks5 request處理,STAGE_PARSE後面再說,此處會判斷讀取的buf是否滿足request的長度,如果不滿足就返回等待資料繼續讀入。注意這兒定義了一個sockaddr_in物件sock_addr並設定為0,這個sock_addr會在socks5_response中返回給客戶端。繼續往後看,如果request->cmd為3,則會在sock_addr中填充udp轉發的監聽地址和埠:
if (request->cmd == 3) {
udp_assc = 1;
socklen_t addr_len = sizeof(sock_addr);
getsockname(udp_fd, (struct sockaddr *)&sock_addr,
&addr_len);
if (verbose) {
LOGI("udp assc request accepted");
}
}
這個udp_fd,就是上一篇中說的init_udprelay返回的fd。如果cmd是1,則sock_addr則不會有任何處理,保持0。如果cmd不是3也不是1,則ss-local會返回一個表示cmd不支援的response,其rep欄位填0x07表示不支援cmd,並關閉這個代理連線。如果不是不支援,則程式碼繼續往下,走到Fake reply。
// Fake reply
if (server->stage == STAGE_HANDSHAKE) {
struct socks5_response response;
response.ver = SVERSION;
response.rep = 0;
response.rsv = 0;
response.atyp = 1;
buffer_t resp_to_send;
buffer_t *resp_buf = &resp_to_send;
balloc(resp_buf, BUF_SIZE);
memcpy(resp_buf->data, &response, sizeof(struct socks5_response));
memcpy(resp_buf->data + sizeof(struct socks5_response),
&sock_addr.sin_addr, sizeof(sock_addr.sin_addr));
memcpy(resp_buf->data + sizeof(struct socks5_response) +
sizeof(sock_addr.sin_addr),
&sock_addr.sin_port, sizeof(sock_addr.sin_port));
int reply_size = sizeof(struct socks5_response) +
sizeof(sock_addr.sin_addr) + sizeof(sock_addr.sin_port);
int s = send(server->fd, resp_buf->data, reply_size, 0);
bfree(resp_buf);
if (s < reply_size) {
LOGE("failed to send fake reply");
close_and_free_remote(EV_A_ remote);
close_and_free_server(EV_A_ server);
return;
}
if (udp_assc) {
close_and_free_remote(EV_A_ remote);
close_and_free_server(EV_A_ server);
return;
}
}
為啥叫fake reply呢,因為按照正常流程,客戶端發過來的請求包含了域名或者ip地址,socks5服務端需要去實際連線目的伺服器才知道是否能代理,如果不能代理,比如根本訪問不了,則會在socks5 response中的rep中填入相應的錯誤碼,如0x04 Host unreachable。而ss-local的處理是直接認為可以訪問,返回可成功代理的response,也就是0x0500 0001加後上sock_addr中的地址和埠號,sock_addr是socks5伺服器訪問目的地址使用的ip地址和埠。對於tcp的情況,這個地址和埠號對於客戶端沒什麼用,可以全為0,並且這兒是fake replay,此時連線沒有建立,所以只能填0。且通過程式碼看,sock_addr確實初始化為0了,所以這兒填的全是0。如果是udp,則不全為0了,最後兩位埠號是真實的埠號,即udp_fd對應的埠號。ss-local處理udp轉發的方式是建立一個全域性的udp監聽埠,在fake replay的時候直接把這個埠發給客戶端,客戶端就可以往此埠傳送資料了。注意在fake reply最後,如果是udp_assc的情況,直接把tcp連線斷開了,這點不符合SOCKS5規範,不過在最新版中已經改掉了。在SOCKS5規範中,如果udp assc的tcp斷開,客戶端會認為udp埠不再可用,需要重新請求。
正常來說,傳送fake reply之後,握手就結束了,不過STAGE_HANDSHAKE中還有一些處理,為避免本篇太長,留下篇再說。