對於網絡用戶來說,一定都經歷過出門在外無法直接在外網訪問內網、或是難以部署異地遠程桌面,因此心急如焚的情況;對於企業來說,無論是財務管理軟件難以將分店信息同步到總部進行統計彙總、還是員工出差在外或在家裏就不能訪問企業內部辦公系統,都極大地影響了公司整體效率;對於個人開發者來說,微信小程序或者在線支付系統等開發環境往往需要一個可以外部訪問的公網環境進行調試,而大多數的企業網絡都被運營商做了轉發設置,無法直接訪問,難以進行開發調試。
諸如此類的難題衆多,但解決方法其實很簡單,那就是使用內網穿透軟件或者自己手寫一個,將內網下應用映射到外網,從而實現這一系列的簡易操作。目前內網穿透軟件有花生殼和神卓互聯,花生殼限制流量而且收費,這裏就介紹神卓互聯內網穿透,接下來就介紹和分析這款軟件的用法和技術要點。
首先用法很簡單,下載客戶端一直點下一步安裝即可,註冊一個賬號登錄即可添加映射,全程自助一般人都會操作。
填寫自己要穿透的應用名稱和端口號,如果需要獲取原訪問者IP最好是選擇Web應用。提交提交就可以了。
例如我需要發佈一個Tomcat應用,訪問端口號是7070,那麼應用名稱填寫tomcat,內網主機填寫127.0.0.1,內網端口填7070點提交就可以。
首先新建一個web項目
新建login.jsp登陸文件,內容如下:
<%@ page language="java" contentType="text/html; charset=UTF-8"
pageEncoding="UTF-8"%>
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>登錄系統</title>
<style type="text/css">
table td{font: 14px/1.5 'Microsoft YaHei',arial,tahoma,\5b8b\4f53,sans-serif;}
</style>
</head>
<body>
<table>
<tr><td>用戶名</td><td><input type="text"></td></tr>
<tr><td>密碼</td><td><input type="text"></td></tr>
<tr><td> </td><td><input type="submit" value="登錄"></td></tr>
</table>
</body>
</html>
先在本地運行,看項目是否可以正常運行
本地運行沒有問題,可以正常打開,接下來就試一下外網訪問
打開神卓互聯軟件主界面,右鍵選擇外網訪問
如果需要綁定域名訪問的話也很簡單,這裏不多說。
接下來就分析是如何做到將請求轉發到內網因爲又返回給訪問客戶端的。
InetAddress
//獲取本機的InetAddress實例
InetAddress address =InetAddress.getLocalHost();
address.getHostName();//獲取計算機名
address.getHostAddress();//獲取IP地址
byte[] bytes = address.getAddress();//獲取字節數組形式的IP地址,以點分隔的四部分
//獲取其他主機的InetAddress實例
InetAddress address2 =InetAddress.getByName("其他主機名");
InetAddress address3 =InetAddress.getByName("IP地址");
URL類
//創建一個URL的實例
URL baidu =new URL("http://www.baidu.com");
URL url =new URL(baidu,"/index.html?username=tom#test");//?表示參數,#表示錨點
url.getProtocol();//獲取協議
url.getHost();//獲取主機
url.getPort();//如果沒有指定端口號,根據協議不同使用默認端口。此時getPort()方法的返回值爲 -1
url.getPath();//獲取文件路徑
url.getFile();//文件名,包括文件路徑+參數
url.getRef();//相對路徑,就是錨點,即#號後面的內容
url.getQuery();//查詢字符串,即參數
以下就是P2P打洞核心代碼(TCP)
假設現在有以下3臺機器:
外網機器,IP:121.56.21.85 , 以下簡稱“主機A”
處在內網1下的機器,外網IP:106.116.5.45 ,內網IP:192.168.1.10, 以下簡稱“主機1”
處在內網2下的機器,外網IP:104.128.52.6 ,內網IP:192.168.0.11,以下簡稱“主機2”
很顯然內網的兩臺機器不能直接連接,我們現在要實現的是藉助外網機器,讓兩臺內網機器進行tcp直連通訊。
實現過程如下:
1、主機A啓動服務端程序,監聽端口8888,接受TCP請求。
2、啓動主機1的客戶端程序,連接主機A的8888端口,建立TCP連接。
3、啓動主機2的客戶端程序,連接主機A的8888端口,建立TCP連接。
4、主機2發送一個命令告訴主機A,我要求與其他設備進行連接,請求協助進行穿透。
5、主機A接收到主機2的命令之後,會返回主機1的外網地址和端口給主機2,同時把主機2的外網地址和端口發送給主機1。
6、主機1和主機2在收到主機A的信息之後,同時異步發起對對方的連接。
7、在與對方發起連接之後,監聽本地與主機A連接的端口(也可以在發起連接之前),(由於不同的操作系統對tcp的實現不盡相同,有的操作系統會在連接發送之後,把對方的連接當作是迴應,即發出SYN之後,把對方發來的SYN當作是本次SYN的ACK,這種情況就不需要監聽也可建立連接,本文的代碼所在測試環境就不需要監聽,測試環境爲:服務器centos 7.3, 內網1 win10,內網2 win10和centos7.2都測試過)。
8、主機1和主機2成功連上,可以關閉主機A的服務,主機1和主機2的連接依然會持續生效,不關閉就形成了一個3方直連的拓撲網狀結構網絡。
服務器端代碼:
package org.inchain.p2p;
import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.ArrayList;
import java.util.List;
/**
* 外網端服務,穿透中繼
*
* @author ln
*
*/
public class Server {
public static List<ServerThread> connections = new ArrayList<ServerThread>();
public static void main(String[] args) {
try {
// 1.創建一個服務器端Socket,即ServerSocket,指定綁定的端口,並監聽此端口
ServerSocket serverSocket = new ServerSocket(8888);
Socket socket = null;
// 記錄客戶端的數量
int count = 0;
System.out.println("***服務器即將啓動,等待客戶端的連接***");
// 循環監聽等待客戶端的連接
while (true) {
// 調用accept()方法開始監聽,等待客戶端的連接
socket = serverSocket.accept();
// 創建一個新的線程
ServerThread serverThread = new ServerThread(socket);
// 啓動線程
serverThread.start();
connections.add(serverThread);
count++;// 統計客戶端的數量
System.out.println("客戶端的數量:" + count);
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
package org.inchain.p2p;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.InetAddress;
import java.net.Socket;
/**
* 外網端服務多線程處理內網端連接
*
* @author ln
*
*/
public class ServerThread extends Thread {
// 和本線程相關的Socket
private Socket socket = null;
private BufferedReader br = null;
private PrintWriter pw = null;
public ServerThread(Socket socket) throws IOException {
this.socket = socket;
this.br = new BufferedReader(new InputStreamReader(socket.getInputStream()));
this.pw = new PrintWriter(socket.getOutputStream());
}
// 線程執行的操作,響應客戶端的請求
public void run() {
InetAddress address = socket.getInetAddress();
System.out.println("新連接,客戶端的IP:" + address.getHostAddress() + " ,端口:" + socket.getPort());
try {
pw.write("已有客戶端列表:" + Server.connections + "\n");
// 獲取輸入流,並讀取客戶端信息
String info = null;
while ((info = br.readLine()) != null) {
// 循環讀取客戶端的信息
System.out.println("我是服務器,客戶端說:" + info);
if (info.startsWith("newConn_")) {
//接收到穿透消息,通知目標節點
String[] infos = info.split("_");
//目標節點的外網ip地址
String ip = infos[1];
//目標節點的外網端口
String port = infos[2];
System.out.println("打洞到 " + ip + ":" + port);
for (ServerThread server : Server.connections) {
if (server.socket.getInetAddress().getHostAddress().equals(ip)
&& server.socket.getPort() == Integer.parseInt(port)) {
//發送命令通知目標節點進行穿透連接
server.pw.write("autoConn_" + socket.getInetAddress().getHostAddress() + "_" + socket.getPort()
+ "\n");
server.pw.flush();
break;
}
}
} else {
// 獲取輸出流,響應客戶端的請求
pw.write("歡迎您!" + info + "\n");
// 調用flush()方法將緩衝輸出
pw.flush();
}
}
} catch (Exception e) {
e.printStackTrace();
} finally {
System.out.println("客戶端關閉:" + address.getHostAddress() + " ,端口:" + socket.getPort());
Server.connections.remove(this);
// 關閉資源
try {
if (pw != null) {
pw.close();
}
if (br != null) {
br.close();
}
if (socket != null) {
socket.close();
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
@Override
public String toString() {
return "ServerThread [socket=" + socket + "]";
}
}
最後附上測試方法和運行效果:
使用方法:
1、在服務器啓動Server。
2、在客戶端1啓動Client,輸入notwait命令,等待服務器通知打洞。
3、在客戶端2啓動Client,輸入conn命令,然後輸入服務器返回的客戶端1的外網ip和端口,接下來就會自動完成連接。
運行效果:
客戶端1運行結果 (穿透成功之後,客戶端會把穿透對方返回的內容發送給服務器,服務器再返回)
客戶端1使用netstat查看的網絡連接
客戶端2的運行結果
客戶端2使用netstat查看的網絡連接
可以看到客戶端2對應的端口不同,那是因爲電信NAT的問題,本地獲取的Ip是電信10開頭的內網地址,相當於在客戶端2的上層還進行了一次中繼。
s:由於沒有對稱型的NAT設備,無法做深入研究,對稱型設備的端口太難猜測,穿透成功概率很小。