前言:
最近准备使用树莓派搭建一个内网监控系统,然后在外网访问。因此选择了钉钉内网穿透的方式,因为这种方式最为简单,但是由于树莓派的架构是ARM指令集,所以无法运行成功,钉钉内网穿透只能在我的X86笔记本上面运行了。但是我倒是对内网穿透这个概念特别感兴趣了,所以就想着能不能利用自己所学习的知识,自己来模拟实现一个内网穿透工具。
1. 内网穿透简介
从黑盒的角度理解: 通常个人电脑无论是连接WIFI上网还是用网线上网,都是属于局域网里边的,外网无法直接访问到你的电脑,内网穿透可以让你的局域网中的电脑实现外网访问功能。举一个例子: 你在本地运行了一个Web服务,占用端口是8080,那么你本地进行测试就是:http://localhost:8080。但是如果你想给一个好朋友分享你的服务,那怎么办呢?是的,就是采用内网穿透的方式。
实际上,内网穿透是很复杂的一个操作,百度百科上面的解释为:
内网穿透,也即 NAT 穿透,进行 NAT 穿透是为了使具有某一个特定源 IP 地址和源端口号的数据包不被 NAT 设备屏蔽而正确路由到内网主机。
这里,我显然是做不了的。所以我的题目是叫做 《黑盒方式模拟实现内网穿透》。我需要的只是从外网访问到内网的服务,至于具体的过程我不关心,只需要达到这个目的即可了。
2. 具体想法和实现细节
2.1 具体想法
无论是哪种方式实现内网穿透都是需要一个公网IP地址的,我这里使用的一台阿里云的服务器。下面是整个模拟的示意图:
注:
1.内网穿透服务端部署在具有公网IP的机器上。
2.内网服务和内网穿透客户端部署在内网机器上。
说明:
我的想法很简单,即用户访问内网穿透服务器,然后内网穿透服务器将用户的请求报文转发给内网穿透客户端,接着内网穿透客户端将请求报文转发给内网服务,然后接收内网服务的响应报文,将其转发给内网穿透服务端,最后由内网穿透服务端将其转发给用户。大致流程是这样的,对于外部的用户来说它只会认为它访问了一个外网服务,因为用户面对的是一个黑盒系统。
2.2 实现细节
为了实现上面那个目标,其中最为关键的就是维持内网穿透客户端和内网穿透服务端的一个长连接,我需要使用这个长连接来交换双方的报文信息。因此,这个长连接需要在系统启动后就建立好,当有用户的请求进来的时候,内网穿透服务端首先接收这个请求,然后使用长连接将其转给内网穿透客户端,内网穿透客户端使用该报文作为请求访问内网服务,然后接收内网服务的响应,将其转发给内网穿透服务端,最后将其转发给用户。
3. 代码实现
3.1 目录结构
说明:
这个是内网穿透的服务端和客户端代码,我是放在一起了,没有分开写,因为双方需要使用到一些公用的类。但是建议还是分开成两个工程,因为需要分开部署。或者导出成jar包的时候,分别选择不同的主类即可。
客户端代码文件:Client.java、Connection.java、Msg.java、ProxyConnection.java。
服务端代码文件:Server.java、Connection.java、Msg.java、ProxyConnection.java。
3.2 Client 类
package org.dragon;
import java.io.IOException;
import java.net.Socket;
import java.net.UnknownHostException;
/**
* 用于双向通信的客户端
* */
public class Client {
private static final String REMOTE_HOST = "公网IP";
private static final String LOCAL_HOST = "127.0.0.1";
public static void main(String[] args) {
try {
Socket proxy = new Socket(REMOTE_HOST, 10000);
System.out.println("Connect Server Successfully!");
ProxyConnection proxyConnection = new ProxyConnection(proxy); // 维持和内网穿透服务端的长连接
// 可以实现同一个人多次访问
while (true) {
Msg msg = proxyConnection.receiveMsg();
Connection connection = new Connection(new Socket(LOCAL_HOST, 8080));
connection.sendMsg(msg); // 将请求报文发送给内网服务器,即模拟发送请求报文
msg = connection.receiveMsg(); // 接收内网服务器的响应报文
proxyConnection.sendMsg(msg); // 将内网服务器的响应报文转发给公网服务器(内网穿透服务端)
}
} catch (UnknownHostException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
}
}
3.3 Connection 类
package org.dragon;
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.Socket;
/**
* 维持用户和服务器的连接
* */
public class Connection {
private InputStream input;
private OutputStream output;
public Connection(Socket client) throws IOException {
this.input = new BufferedInputStream(client.getInputStream());
this.output = new BufferedOutputStream(client.getOutputStream());
}
public Msg receiveMsg() throws IOException {
byte[] msg = new byte[2*1024];
int len = input.read(msg);
return new Msg(len, msg);
}
public void sendMsg(Msg msg) throws IOException {
output.write(msg.getMsg(), 0, msg.getLen());
output.flush(); // 每一次写入都要刷新,防止阻塞。
}
}
3.4 Msg 类
package org.dragon;
public class Msg {
private int len;
private byte[] msg;
public Msg(int len, byte[] msg) {
this.len = len;
this.msg = msg;
}
public int getLen() {
return len;
}
public byte[] getMsg() {
return msg;
}
@Override
public String toString() {
return "msg: " + len + " --> " + new String(msg, 0, len);
}
}
3.5 ProxyConnection 类
package org.dragon;
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.net.Socket;
import java.net.UnknownHostException;
/**
* @author Alfred
*
* 代理服务器和代理客户端是用于维持两者之间通信的一个长连接Socket,
* 主要的目的是因为双方之间的通信方式是全双工的,它们的作用是为了传递报文。
* */
public class ProxyConnection {
private Socket proxySocket;
private DataInputStream input;
private DataOutputStream output;
public ProxyConnection(final Socket socket) throws UnknownHostException, IOException {
proxySocket = socket;
input = new DataInputStream(new BufferedInputStream(proxySocket.getInputStream()));
output = new DataOutputStream(new BufferedOutputStream(proxySocket.getOutputStream()));
}
/**
* 接收报文
* @throws IOException
* */
public Msg receiveMsg() throws IOException {
int len = input.readInt();
if (len <= 0) {
throw new IOException("异常接收数据,长度为:" + len);
}
byte[] msg = new byte[len];
int size = input.read(msg); // 这里到底会不会读取到这么多,我也有点迷惑!
return new Msg(size, msg); // 为了防止出错,还是使用一个记录实际读取值size
}
/**
* 转发报文
* @throws IOException
* */
public void sendMsg(Msg msg) throws IOException {
output.writeInt(msg.getLen());
output.write(msg.getMsg(), 0, msg.getLen());
output.flush(); // 每一次写入都需要手动刷新,防止阻塞。
}
}
3.6 Server 类
package org.dragon;
import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;
/**
* 用于双向通信的服务器
* */
public class Server {
public static void main(String[] args) {
try (ServerSocket server = new ServerSocket(10000)) {
// 用于交换控制信息的Socket
Socket proxy = server.accept();
ProxyConnection proxySocket = new ProxyConnection(proxy);
// 用于正常通讯的socket
while (true) {
Socket client = server.accept();
Connection connection = new Connection(client);
Msg msg = connection.receiveMsg(); // 接收用户的请求报文
proxySocket.sendMsg(msg); // 转发用户的请求报文给内网服务器
msg = proxySocket.receiveMsg(); // 接收内网服务器的响应报文
connection.sendMsg(msg); // 转发内网服务器的响应报文给用户
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
4. 内网服务
内网服务是一个web服务,这里我使用的是一个简单的SpringBoot项目,它只有三个请求方法。
package org.dragon.controller;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class Controller {
@GetMapping("/loveEN")
public String testEN() {
return "I love you yesterday and today!";
}
@GetMapping("/loveZH")
public String loveZH() {
return "有一美人兮,见之不忘。一日不见兮,思之如狂。凤飞翱翔兮,四海求凰。无奈佳人兮,不在东墙。";
}
@GetMapping("/loveJson")
public Map<String, String> loveJson() {
HashMap<String, String> map = new LinkedHashMap<>();
map.put("english", "I love you yesterday and today!");
map.put("chinese", "有一美人兮,见之不忘。一日不见兮,思之如狂。"
+ "凤飞翱翔兮,四海求凰。无奈佳人兮,不在东墙。");
return map;
}
}
5. 测试
5.1 内网测试
启动内网服务,在浏览器输入以下三条URL进行测试,功能正常。
5.2 外网测试
先后启动内网穿透服务端和内网穿透客户端,然后在浏览器访问一下三条URL即可。
注意:
1.如果你自己测试,切换成你运行内网穿透服务器的ip地址或者使用域名也行。
2.我这里外网机器和内网机器使用的是不同的端口(随便使用,只要不和自己机器上的服务端口冲突就行了),实际上可以在外网使用80端口,这样对普通用户比较友好。
3.第三条测试实际上是失败的,可以看到上面那个加载动画,一直在加载。按理说这个应该很快就停止了,但是似乎无法停下来。这是系统的bug了,但是由于我掌握的知识有限,就不去解决了。
6. 注意事项
- 这里的代码是一种模拟,它只能模拟这个功能,但是基本上不具备实际的作用,哈哈。因为我这里只有一个长连接,所以只能支持串行的通信,最好就是一个人简单的调用,似乎调用速度也不能太快了。我想了一种方式,在客户端和服务器之间维持一个连接池,这样就可以实现多线程访问了。
- 这里没有处理TCP的粘包和分包(我理解了这个概念,但是我不太会处理它),所以我默认请求报文和响应报文都是2KB以内大小。如果超过这个长度会导致问题,尽管可以调大这个参数,但是如果多数报文的都是很小的话,也会导致效率低下。
- 这个内网穿透是可以支持TCP之上的各种协议的,不一定是HTTP,至少理论上是可以的。
7. 总结
从这个想法的萌生到实现这个功能也是花了我好几天时间的,这本身就是一个学习的过程。学习使用自己的网络和编程知识解决问题,我认为这是一种很好的学习方式——学以致用。书到用时方恨少,在这个过程中体现的淋漓尽致,想要达到某个目的,但是由于自己知识的不足,没有什么特别好的解决办法,只能采用一些不优雅的实现方式了。不过,辩证的看,这也是一件好事,至少它指明了下一步学习的方向。