Hello,I'm Shendi.

这天心血来潮,决定做一个内网穿透的软件.

用过花生壳等软件的就知道内网穿透是个啥,干嘛用的了.

我们如果有服务器(比如tomcat),实际上我们在电脑上开启了服务器别人是访问不到我们的,除非你有公网ip,目前 IPv4已经几近被占完,所以我们想让别人访问到我们的服务器,使用内网穿透是不错的选择

目录

前言

想法

NAT

开始之前(需要的知识)

制作服务端

设计

编写服务端

启动类

创建接收端

实现TCP协议类

整理思路(用户与客户端之间通信图)

实现协议工具类

客户端

启动类代码如下

NATInfo 隧道信息类(从服务端复制更改)

View是与用户交互的接口

Controller是隧道的控制器

ClientProtocol 是客户端协议类(与服务端通信的socket)

实现打开隧道

实现控制台输出视图 ConsoleView

实现TCP协议类

结尾


前言

  • 此项目是我边写边做的,所以避免不了有一些小BUG,包含客户端和服务端.

    • 项目源码: https://github.com/1711680493/Application/tree/master/ShendiNAT
  • 如果想要了解更多可以关注一下,并进入我的实战专栏
    • https://blog.csdn.net/qq_41806966/category_9656338.html
  • 使用了自己写的工具包,ConfigurationFactory(配置文件)和Log(日志)
  • 仅用于学习.

想法

做事情之前都是先思考,后行动.

利用现有知识,我如何实现内网穿透呢?

我刚开始的想法是,搞一台电脑作为服务器,上面运行一个程序.

程序内容是开启我想要被别人访问的端口(一台电脑有65535个端口,我们能用的基本上有6w多个?).

使用TCP或者UDP,将端口一个个用线程打开...

然后我们在写一个软件,软件启动就连接我们之前写的软件,并绑定对应端口.

内容为(模拟访问指定ip的指定端口),当用户访问我们的服务器,我们根据内容(域名不同什么的)去访问对应服务器

服务器获取到客户端返回的内容返回给用户.(中间夹杂了服务器的内容,这个肯定会慢很多,速度会受到我们服务器的限制等影响)

画张图大概就是这样的(这个只是我的想法,具体实现应该是没问题的,或许效率会更低一点?)

我也不知道我的想法是否能行,所以并没有直接动手,而是先查阅资料.

NAT

因为公网 IP 地址逐渐不够用了,NAT的本质是让一群机器公用一个IP
    可以简单理解为别人开了一个端口给你用,当别人访问那个端口就是访问你的服务器
优缺点
    增加了内网的安全性
    提高了网络安全性
    降低了发送数据的效率
NAT实现方式
    静态 NAT: 一个公网IP对应一个私有IP(一对一)
    NAPT: 端口多路复用技术,与静态NAT不同在于NAPT不仅要转换IP,还要转换端口.
        一个公网ip可以对应多个私有ip主机,只不过端口不同.
        (目前用的最多的是这种,什么花生壳,ngrok...)
        例如 私有ip/公网ip
        192.168.0.6:80        111.111.111.111:10000
        192.168.0.7:80        111.111.111.111:10001
        192.168.0.8:321        111.111.111.111:10002
NAT类型
    主要分为两大类: 锥型NAT和对称型NAT
    锥形NAT分为: 完全锥型,受限锥型,端口受限锥型
    
    完全锥型NAT(Full Cone NAT): IP和端口都不受限
    受限锥型NAT(Restricted Cone NAT): IP受限,端口不受限
    端口受限NAT(Port Restricted Cone NAT): IP和端口都受限
    对称型NAT(Symmetric NAT): 对每个外部主机或端口的会话都会映射为不同的端口

通过搜查众多资料,好吧,我的想法应该是正确的(具体咱也不知道呀)...

猜测: 我们内网能访问到外界网络是因为与某台服务器(公网ip)连接,内网通过那台服务器做了我们接下来要写的软件的操作...

-----------------------------------

上面的基本上是我百度到的总结的废话...

通过万能的网友和百度,突然意识到我的猜测是正确的.

NAT(Network Address Translation,网络地址转换)是一种技术.

内网穿透又称NAT穿透,替换一下意思,NAT=内网?,或许我们内网使用的技术就是NAT

我们访问网络上的资源都会经过交换机,路由器,最后到达服务器(拥有公网IP的),服务器的作用就是获取你的请求,然后去请求另一台服务器,获取到数据返回给你(可能吧).

对于什么是内网,什么是外网,很简单,每一台电脑都有ip,内网就等于一个范围内的ip基本上都是这个范围,比如192.168.0.1,通过路由器分发.

在 cmd 使用 ipconfig 命令和在百度上搜索ip就能看出差别

好了,废话不多说,开始直接实现我们的功能

开始之前(需要的知识)


  • 熟悉 Java(如果看个原理,熟悉另一门语言的话可以不用)
  • 网络编程,TCP
    • 如果不会网络编程的可以看下我的爬虫教程
    • Socket制作爬虫1 https://blog.csdn.net/qq_41806966/article/details/102903174
    • Socket制作爬虫2 https://blog.csdn.net/qq_41806966/article/details/102966105
  • Java反射
    • 不会的可以看下我密码学教程(顺带熟悉Java的ClassLoader)
    • 密码学1,反射 https://blog.csdn.net/qq_41806966/article/details/103394127
    • 密码学2,简单加密解密 https://blog.csdn.net/qq_41806966/article/details/103888049
    • 密码学3,加密class文件并调用 https://blog.csdn.net/qq_41806966/article/details/103913682
  • 设计模式(策略模式用到较多,为什么?扩展性好啊)
  • 对 Java 不熟的小白可以进入我的 Java教程专栏


制作服务端


设计

首先我们需要内网穿透作用于啥...(比如你是做嵌入式的,你可以用此方法写服务端到路由器限制别人操作...)

通过上面某些废话,我们有几种方式

  • 静态NAT,一个ip对应一个ip,可能是为了安全性?
  • NAPT,有6w多个端口可以复用.

我们这里目的很简单(为了学习),使用NAPT方式,使得装了我们客户端软件的电脑都通过访问服务器的方式来访问内部.

(如果你有一个外网ip,那么就可以让外部电脑访问你使用了客户端软件的电脑)

一个端口对应一个私有IP,目前写好扩展,只实现TCP的功能(HTTP是应用层协议,所以写好TCP,也等于写好了http)

编写服务端

这里使用 Java 完成这个程序.

我们新建Java项目,新建一个类,类里面有main方法,我们需要提供一个界面让用户添加内网主机和对应的端口.

然后我们应该提供这样一个独立的id给予此用户

为了扩展,需要先分析哪一部分是会变的.哪一部分是相对固定的

  • 用户添加内网主机ip端口对应的协议是会变的,比如新增http协议,udp等
  • 添加内网主机端口和设置协议是不会变的

所以我们在 main 方法里处理好添加内网主机的操作,并且通过用户选择,获取到字符串(或者其他的,反正要标识此类型),通过配置文件反射调用对应的类,添加完后我们需要分配唯一id,和一个端口(我们端口功能也扩展一下)

所以实际上我们最少需要三个服务端,处理NAT的至少一个(用户添加的时候打开),接收选择的一个(这两个是同一个项目),以及与用户操作,供用户选择服务器的服务端一个

这里只做前两个,让用户添加选择服务器的我们可以写一个类来模拟.

让用户添加的我们也使用TCP协议,并且为了防止改变什么的,也写入配置文件,通过反射的形式调用


我这里用到了我自己写的一个工具集(读取properties配置文件的,你们可以使用Properties直接读取,如果需要,可以下载源码获取)

启动类

在启动类里的main方法中只需要开启服务端,并监听就ok了.

我们需要定义一下数据格式(不复杂化,我们需要三个参数,[ip,端口,协议]) 数据格式为 ip;端口;协议(以英文分号分隔)

如果不是这个数据格式就不做操作.

第一句是读取配置文件进行操作,第二句是创建此类对象,并调用此对象的 onCreate() 方法

创建接收端

创建ServerReceive接口

创建TCP接收器,实现此接口.

/**
* TCP 接收端.<br>
* 监听
* @author Shendi <a href='tencent://AddContact/?fromId=45&fromSubId=1&subcmd=all&uin=1711680493'>QQ</a>
* @version 1.0
*/
public class TCPReceive implements ServerReceive { @Override public void onCreate() { } }

接下来就是在 onCreate 方法中创建TCP服务器,Socket.

并且接收到数据处理,如果正确我们就将信息存起来,等客户端使用了此信息,就打开对应端口

在此之前我们需要先创建一个表示穿透信息的 bean 类.

package shendi.nat.bean;

import shendi.nat.protocol.ServerProtocol;

/**
* 穿透信息,包含需要访问的ip和端口,状态等.
* @author Shendi <a href='tencent://AddContact/?fromId=45&fromSubId=1&subcmd=all&uin=1711680493'>QQ</a>
* @version 1.0
*/
public class NATInfo {
/**
* 内网电脑的 ip.
*/
private String ip; /**
* 内网电脑的端口.
*/
private int port; /**
* 服务端对应的端口(经济原因,这里应是域名)
*/
private int serverPort; /**
* 使用的什么协议.
*/
private String protocol; /**
* 当前穿透(隧道)状态.
*/
private NATState state = NATState.离线; /**
* 协议对应的服务端.
*/
private ServerProtocol server; /**
* 创建穿透信息.
* @param ip 内网电脑的ip
* @param port 内网电脑的端口
* @param serverPort 对应的服务器的端口
* @param protocol 使用的什么协议
*/
public NATInfo(String ip, int port, int serverPort,String protocol) {
this.ip = ip;
this.port = port;
this.serverPort = serverPort;
this.protocol = protocol;
} public String getIp() { return ip; } public int getPort() { return port; } public int getServerPort() { return serverPort; } public String getProtocol() { return protocol; } public NATState getState() { return state; }
public void setState(NATState state) { this.state = state; } public ServerProtocol getServer() { return server; }
public void setServer(ServerProtocol server) { this.server = server;} }

NATState是一个枚举类(改变了一下字体(仿宋),感觉瞬间敲代码都有精神了)

注: 上面说的数据格式要稍微改变一下,增加一项(因为我的疏忽,漏掉了这个服务端接收到数据有两种,一种是添加穿透,另一种是操作(打开/关闭)穿透)

  • 添加操作

    • add;ip;端口;服务器上的端口;协议
  • 打开操作
    • open;隧道id
  • 关闭操作
    • close;隧道id
  • 添加操作的返回数据
    • ok;id;端口或者域名

      • 这里需要加ok是为了表示我们的数据是正确的
    • 出错则返回 error;错误信息.
  • 打开操作的返回
    • ok;内网ip;内网端口;端口/域名;协议

我先把当前模型画一个图,整理一下,大致就是这样的

我们的 TCPReceive 需要保存穿透(隧道)信息(实际操作应该是存在数据库,但这里为了不麻烦,不使用数据库,直接存内存)

我们创建隧道后应该给其分配某个端口(避免别人使用此端口,这里因为条件原因只用端口,一般为域名.),

而且隧道也需要有id,这里为了简便,id就从0开始依次自增...

有了以上基础,就可以轻松完成添加隧道操作了

// 添加隧道,增加一个id进入map里然后返回数据就ok了.
case "add":
// 格式为 add;ip;端口;协议
// 这里的 ip 和端口都是客户端的.
String ip = datas[0];
int port = Integer.parseInt(datas[1]);
int serverPort = Integer.parseInt(datas[2]);
String protocol = datas[3]; // 判断端口是否占用,占用则提醒,
try {
new ServerSocket(serverPort).close();
// 返回信息,返回格式为 ok;id;端口
NAT_INFOS.put(String.valueOf(id), new NATInfo(ip, port, serverPort, protocol)); StringBuilder b = new StringBuilder();
b.append("ok;"); b.append(id); b.append(';'); b.append(serverPort);
output.write(b.toString().getBytes()); output.flush(); id++;
} catch (IOException e) {
Log.printAlarm("TCP接收器新增隧道失败,端口被占用,客户请求 ip: " + requestIp);
output.write("error;端口被占用,请更换端口".getBytes()); output.flush();
}
break;

在编写开启和关闭操作之前我们需要编写对应的协议类.

新建一个抽象类叫做 ServerProtocol 用于定义协议

package shendi.nat.protocol;

import shendi.nat.bean.NATInfo;
import shendi.nat.bean.NATState; /**
* 隧道协议抽象类,继承此类来创建一个隧道协议.<br>
* 创建的隧道协议需要在 protocol.properties 中进行配置.<br>
* 配置形式为 协议名=类全路径.<br>
* 如果要实现构造方法,请确保线程安全,请勿执行耗时操作.
* @author Shendi <a href='tencent://AddContact/?fromId=45&fromSubId=1&subcmd=all&uin=1711680493'>QQ</a>
* @version 1.0
*/
public abstract class ServerProtocol { /**
* 死循环的标志位,true运行,false关闭.
*/
private boolean state = true; /**
* 隧道信息.
*/
private NATInfo info; /**
* 开启此协议.
* @author Shendi <a href='tencent://AddContact/?fromId=45&fromSubId=1&subcmd=all&uin=1711680493'>QQ</a>
* @param info 隧道的信息.
*/
public final void start(NATInfo info) {
info.setState(NATState.在线);
this.info = info; new Thread(() ->{
onCreate(info);
while (state) { run(); }
onStop();
}).start();
} /**
* 关闭此协议,关闭后就代表此对象将被清理.
* @author Shendi <a href='tencent://AddContact/?fromId=45&fromSubId=1&subcmd=all&uin=1711680493'>QQ</a>
*/
public final void stop() { info.setState(NATState.离线); state = false; } /**
* 在被创建的时候调用.<br>
* 用于初始化.
* @author Shendi <a href='tencent://AddContact/?fromId=45&fromSubId=1&subcmd=all&uin=1711680493'>QQ</a>
* @param info 隧道的信息.
*/
protected abstract void onCreate(NATInfo info); /**
* 执行具体操作逻辑.<br>
* 此方法会被持续调用,请确保线程安全.
* @author Shendi <a href='tencent://AddContact/?fromId=45&fromSubId=1&subcmd=all&uin=1711680493'>QQ</a>
*/
protected abstract void run(); /**
* 被关闭时调用.<br>
* 用于关闭连接,恢复状态等.
* @author Shendi <a href='tencent://AddContact/?fromId=45&fromSubId=1&subcmd=all&uin=1711680493'>QQ</a>
*/
protected abstract void onStop(); /**
* 获取当前隧道信息.
* @author Shendi <a href='tencent://AddContact/?fromId=45&fromSubId=1&subcmd=all&uin=1711680493'>QQ</a>
* @return {@link NATInfo}
*/
public NATInfo getInfo() { return info; }
}

除了定义子类的接口之外,还提供了调用方使用的接口.看得出,start()  方法,线程里面执行操作,然后run方法是在死循环内...

这样的目的是为了方便停止死循环.

有了此类后,我们将之前写的 NATInfo 类里的服务端的 Object类型改为此类型.

TCPReceive的代码为

package shendi.nat.server.receive;

import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;
import java.net.UnknownHostException;
import java.util.HashMap; import shendi.kit.config.ConfigurationFactory;
import shendi.kit.log.Log;
import shendi.nat.bean.NATInfo;
import shendi.nat.bean.NATState;
import shendi.nat.protocol.ServerProtocol; /**
* TCP 接收端.<br>
* 接收到消息后进行处理,如果消息是正确的则会创建指定服务端.
* @author Shendi <a href='tencent://AddContact/?fromId=45&fromSubId=1&subcmd=all&uin=1711680493'>QQ</a>
* @version 1.0
*/
public class TCPReceive implements ServerReceive { /**
* TCP 服务端套接字.
*/
private ServerSocket server; /**
* 当前用户开启的端口.<br>
* 隧道id=隧道信息的形式.
*/
private static final HashMap<String,NATInfo> NAT_INFOS = new HashMap<>(); /**
* 分配的,隧道id
*/
private static int id = 0; public TCPReceive() throws IOException {
// 创建服务端,绑定指定端口,端口从配置文件获取
String port = ConfigurationFactory.getConfig("config").getProperty("server.port");
server = new ServerSocket(Integer.parseInt(port));
} @Override public void onCreate() {
while (true) {
try {
Socket socket = server.accept();
// 开启线程
new Thread(() -> {
try (BufferedInputStream input = new BufferedInputStream(socket.getInputStream());
BufferedOutputStream output = new BufferedOutputStream(socket.getOutputStream())) {
// 接收的数据不会大于 1024 字节
byte[] data = new byte[1024];
int len = input.read(data); // 处理数据
if (len > 0) {
String sData = new String(data,0,len);
String[] datas = sData.split(";"); // 获取到请求 IP 地址
String requestIp = socket.getInetAddress().getHostAddress(); // 加锁,避免高并发出问题
synchronized (NAT_INFOS) {
// 获取到数据类型 按照正常思路来,数组越界也就等于数据是错误的
switch (datas[0]) {
// 添加隧道,增加一个id进入map里然后返回数据就ok了.
case "add":
// 格式为 add;ip;端口;协议
// 这里的 ip 和端口都是客户端的.
String ip = datas[1];
int port = Integer.parseInt(datas[2]);
int serverPort = Integer.parseInt(datas[3]);
String protocol = datas[4]; // 判断端口是否占用,占用则提醒,
try {
new ServerSocket(serverPort).close();
// 返回信息,返回格式为 ok;id;端口
NAT_INFOS.put(String.valueOf(id), new NATInfo(ip, port, serverPort, protocol)); StringBuilder b = new StringBuilder();
b.append("ok;"); b.append(id); b.append(';'); b.append(serverPort);
output.write(b.toString().getBytes()); output.flush(); id++;
} catch (IOException e) {
Log.printAlarm("TCP接收器新增隧道失败,端口被占用,客户请求 ip: " + requestIp);
output.write("error;端口被占用,请更换端口".getBytes()); output.flush();
}
break;
// 开启隧道,先判断有无隧道,有则创建服务端改变隧道信息.
case "open":
String id = datas[1];
if (NAT_INFOS.containsKey(id)) {
// 如果已经在线则提示ok,否则创建指定隧道类并启动.
NATInfo info = NAT_INFOS.get(id); StringBuilder b = new StringBuilder();
b.append("ok;"); b.append(info.getIp()); b.append(';'); b.append(info.getPort());
b.append(';'); b.append(info.getServerPort()); b.append(';'); b.append(info.getProtocol()); if (info.getState() == NATState.在线) {
output.write(b.toString().getBytes()); output.flush();
Log.printAlarm("TCP接收器开启隧道,隧道已经是开启的,客户请求 ip: " + requestIp);
} else {
// 通过配置文件获取到指定协议类,如果没有则出错
// 这里最好是将用户拉黑... 因为一般都是同过不正当渠道...
String pClass = ConfigurationFactory.getConfig("protocol").getProperty(info.getProtocol());
if (pClass == null) { Log.printAlarm("TCP接收器开启隧道,没有指定协议!协议为: " + info.getProtocol() + ",客户请求 ip: " + requestIp); return; }
try {
ServerProtocol server = (ServerProtocol) Class.forName(pClass).getConstructor().newInstance();
// 设置隧道服务器
info.setServer(server); output.write(b.toString().getBytes()); output.flush(); server.start(info);
} catch (ClassNotFoundException e) { Log.printErr("TCP接收器开启隧道,类找不到: " + pClass + ",客户请求 ip: " + requestIp);
} catch (Exception e) { Log.printErr("TCP接收器开启隧道,出大问题了!!!客户请求 ip: " + requestIp + "\n错误信息: " + e.getMessage()); }
}
} else {
output.write("error;没有此id".getBytes()); output.flush();
Log.printAlarm("TCP接收器开启隧道没有此id,客户请求 ip: " + requestIp);
}
break;
// 关闭隧道,无任何返回.执行完后调用一次 GC.
case "close":
id = datas[1];
if (NAT_INFOS.containsKey(id)) {
NATInfo info = NAT_INFOS.get(id);
if (info.getState() == NATState.离线) { Log.printAlarm("TCP接收器关闭隧道已经是关闭的,客户请求 ip: " + requestIp); }
else if (info.getServer() == null) { Log.printAlarm("TCP接收器关闭隧道没有此id,客户请求 ip: " + requestIp); }
else { info.getServer().stop(); info.setServer(null); }
} else { Log.printAlarm("TCP接收器关闭隧道没有此id,客户请求 ip: " + requestIp); }
System.gc();
break;
default: Log.printAlarm("TCP 接收器接收到不合法数据: " + new String(data));
}
}
}
} catch (IOException e) {
e.printStackTrace();
Log.printErr("接收到数据,执行解析操作失败: " + e.getMessage());
} finally {
try {
if (socket != null) socket.close();
} catch (IOException e) { Log.printErr("Socket关闭失败: " + e.getMessage()); }
}
}).start();
} catch (IOException e) { Log.printErr("接收到数据,获取Socket失败: " + e.getMessage()); }
}
} public static void main(String[] args) throws UnknownHostException, IOException {
Socket socket = new Socket("127.0.0.1",9999);
BufferedOutputStream output = new BufferedOutputStream(socket.getOutputStream());
BufferedInputStream input = new BufferedInputStream(socket.getInputStream());
byte[] b = new byte[1024];
// 添加
output.write("add;8888;10000;tcp".getBytes());
output.flush();
// 获取到隧道id
int len = input.read(b);
String addReceive = new String(b,0,len);
String id = addReceive.split(";")[1];
System.out.println(addReceive);
// 将之前的关闭,因为一个请求只能做一次操作
output.close();
input.close();
socket.close(); socket = new Socket("127.0.0.1",9999);
output = new BufferedOutputStream(socket.getOutputStream());
input = new BufferedInputStream(socket.getInputStream());
// 打开
output.write(("open;"+id).getBytes());
output.flush();
len = input.read(b);
System.out.println(new String(b,0,len));
socket.close(); // 将之前的关闭,因为一个请求只能做一次操作
output.close();
input.close();
socket.close(); socket = new Socket("127.0.0.1",9999);
output = new BufferedOutputStream(socket.getOutputStream());
// 关闭
output.write(("close;"+id).getBytes());
output.flush();
output.close();
socket.close();
} }

里面的 main 方法是测试用的,现在用一个类实现我们上面写的抽象类

package shendi.nat.protocol;

import shendi.nat.bean.NATInfo;

public class TCPServer extends ServerProtocol {

	@Override
protected void onCreate(NATInfo info) {
// TODO Auto-generated method stub
System.out.println("创建了");
} @Override
protected void run() {
// TODO Auto-generated method stub
System.out.println("运行了");
} @Override
protected void onStop() {
// TODO Auto-generated method stub
System.out.println("关闭了");
} }

讲一下配置文件, main.properties可忽略(因为我写的工具类要用到这个,

config.properties包含系统配置,现在的内容如下

protocol.properties是给实现协议抽象类的类用的,内容如下

缕一缕上面完成的功能

现在我们的服务端基本上完成一大半了!

只剩下实现协议类了

我们通过反射读取配置文件调用 接收类,也就是TCPReceive,我们也定义了接口,定义了传输协议...

以及将协议类架构写好了

接下来来测试一下(在TCPReceive里我写了个main方法)

启动NATServer

然后启动TCPReceive

结果如下(可能是我卡了,产生了间隔,所以运行中会出现)

TCPReceive输出

NATServer输出

再次重运行一次,结果如下(上面是因为我在启动的时候卡了一下,产生了间隔,因为我在TCPReceive里的main方法没有睡眠,所以执行的非常快,快到关闭的时候运行中还没有启动)

实现TCP协议类

写完这些后重新回顾上面的废话... 我们的应该是对称型NAT,

对称型NAT(Symmetric NAT): 对每个外部主机或端口的会话都会映射为不同的端口

记得我们在接收器部分使用了扩展(这次感觉做的非常对)

比如我们想在某台电脑上更改为xxx型NAT,只要制作这个实现和修改下配置文件就ok了,比如有台电脑想要一个端口对应多台私有主机(想象一下,是不是 锥型),有对应实现,我们只要将配置文件改一改就ok了.

每一个协议类都是一个单独的服务端(占用一个端口)

用户访问实际上访问的就是此服务端(因为我们在创建的时候设置了端口(有条件可以直接用域名,就是获取第一段来区分(www那一段)))

少了一个功能,没有修改隧道功能...问题不大,cv一下就ok了,这里主要是做NAT穿透(ngrok),修改功能并不重要

在协议类里的主要功能就是将用户请求转发到指定内网服务器

这里又大意了,因为客户端和用户都要与我们这个服务端进行通信,同一个端口,正常人第一反应是在搞一个服务端...

思考过后,我们可以获取到对应连接并进行长连接(很久不断)

并且我们的程序打开时,会有一个连接连过来,这个是客户端的连接,所以我们将第一个连接作为长连接存起来就ok了

后续连接都为用户.

接下来开始实现

协议类需要继承 ServerProtocol 类,并且实现对应方法(启动被调用,运行被调用,关闭被调用),上面已经将此操作完成了

接下来我们先在 onCreate 里进行初始化


整理思路(用户与客户端之间通信图)

用户访问->服务端->访问客户端->返回数据给服务端->返回数据给用户

不需要定义任何协议,因为只需要转发.

上面删除线是我第一想法,但是我又想到 服务端只有一个端口=客户端与服务端连接只有一个,但是用户有多个

所以需要改变一下.

整理一下思路(画张图-用户和客户端之间通信图(TCP))

需要协议是因为用户很多,我们要给每一个请求(一个请求对应一个用户)做一个标识.

并且我们 客户端发数据给服务端的只有一个,所以我们在 onCreate 中就开启线程读取客户度发来的数据进行处理

制定服务端和客户端通信的协议如下

数据用 ; 隔开,第一个为类型(可以自己制定更严格的协议,这里只是单纯为了教程)
注意: 一个有效数据的最后两个字节为 -2,-3,代表结束(不然难搞,两个字节是避免和用户的冲突)
发送给客户端数据格式
通知客户端有新用户连接
new;用户标识
发送数据给客户端
data;用户标识;数据
通知客户端用户关闭了连接
close;用户标识
发送给用户数据格式
发送数据给用户
data;用户标识;数据

更改 onCreate 中代码为如下(代码量有点大,只增加了一个读取客户端发送的数据并处理的线程)

@Override
protected void onCreate(NATInfo info) {
try {
server = new ServerSocket(info.getServerPort());
// 等待客户端连接(第一个连接),当然不避免客户端不连接的情况(恶意访问)
// 对于不连接的 就造成了DDOS攻击,因为 accept() 是异步阻塞的,所以需要设置阻塞超时时间.
// 超时会抛出 java.net.SocketTimeoutException.
server.setSoTimeout(1000);
client = server.accept();
cInput = new BufferedInputStream(client.getInputStream());
cOutput = new BufferedOutputStream(client.getOutputStream()); // 将时间改回
server.setSoTimeout(0); // 客户端只有一个,所以需要一个线程单独处理客户端发送数据.
// 设置名称方便以后出问题调试.
Thread t = new Thread(() -> {
// data为未处理的数据,index是现在有效数据的下标.
byte[] data = new byte[1024];
int index = 0; try {
// 记录上一次接收到的字节,用于判断结尾.
byte upData = 0; while (true) {
byte b = (byte) cInput.read();
// 如果读到的数据为 -1 我们需要发送一下心跳包判断客户端是否存活
if (b == -1) {
try { cOutput.write(0); } catch (IOException e) {
// 关闭此服务端
stop();
Log.printErr("客户端被关闭了,端口为: " + info.getServerPort() +",错误为: " + e.getMessage());
break;
}
} // 扩容
if (index >= data.length) {
byte[] temp = data;
data = new byte[temp.length << 1];
System.arraycopy(temp, 0, data, 0, temp.length);
} data[index++] = b; if (upData == -2 && b == -3) {
// 转成字符串,进行解析,最后两个数据位去除
String sData = new String(data,0,index-2);
String[] datas = sData.split(";"); switch (datas[0]) {
// 将数据发给指定用户
case "data":
if (datas.length < 3) { Log.printAlarm("TCP协议类数据格式data类型错误 端口为: " + info.getPort() + ",数据为: " + sData); break; }
BufferedOutputStream userOutput = new BufferedOutputStream(sockets.get(Integer.parseInt(datas[1])).getOutputStream());
userOutput.write(datas[2].getBytes());
userOutput.flush();
break;
// 默认,代表出错了
default: Log.printAlarm("TCP协议类数据格式错误 端口为: " + info.getPort() + ",数据为: " + sData);
}
// 此条数据被解析完成,开始下一条.
index = 0; upData = 0; data = new byte[data.length];
}
upData = b;
}
} catch (IOException e) { Log.printErr("TCP协议类接收客户端数据出错: " + e.getMessage()); }
});
t.setName("TCP服务端线程: " + info.getServerPort());
t.start();
} catch (SocketTimeoutException e) {
// 关闭隧道
stop();
} catch (IOException e) { Log.printErr("TCP协议类onCreate初始化出错: " + e.getMessage()); }
}


实现协议工具类

因为我们的协议用到的地方很多,所以写一个工具类

	/**
* 服务端穿透通信.
* @author Shendi <a href='tencent://AddContact/?fromId=45&fromSubId=1&subcmd=all&uin=1711680493'>QQ</a>
* @param type 类型
* @param info 后续数据信息,默认会转化成此对象toString形式,字节数组会做另外处理.
* @return 组装成的数据的字节表示形式
*/
public static final byte[] serverNAT(String type,Object... info) {
byte[] data = type.getBytes();
for (Object obj : info) {
byte[] temp = data;
byte[] bytes; if (obj instanceof byte[]) {
bytes = (byte[]) obj;
} else {
bytes = obj.toString().getBytes();
} data = new byte[temp.length + bytes.length + 1];
System.arraycopy(temp, 0, data, 0, temp.length);
data[temp.length] = ';';
System.arraycopy(bytes, 0, data, temp.length+1, bytes.length);
}
byte[] temp = data;
data = new byte[temp.length + 2];
System.arraycopy(temp, 0, data, 0, temp.length);
data[data.length - 1] = -3;
data[data.length - 2] = -2;
return data;
}


所以接下来实现 run() 方法

run 方法主要处理用户发送数据给客户端(并且给每一个用户一个唯一标识)

@Override
protected void run() {
try {
// 接收请求,将请求转发到客户端(客户端将请求转发到客户服务器).
Socket socket = server.accept();
// 新用户连接,
new Thread(() -> {
try (BufferedInputStream input = new BufferedInputStream(socket.getInputStream())) {
// 存储此用户
sockets.put(userId, socket); // 通知客户端有新用户
cOutput.write(ProtocolUtils.serverNAT("new", userId)); cOutput.flush(); // 当前id.
int id = userId; // 将用户发送的数据转发给客户端..
// 一方关闭就会出异常,就直接将socket关闭
while (true) {
byte[] data = new byte[0];
byte[] bytes = new byte[1024];
int len = -1;
while ((len = input.read(bytes)) != -1) {
byte[] temp = data;
data = new byte[temp.length + len];
System.arraycopy(temp, 0, data, 0, temp.length);
System.arraycopy(bytes, 0, data, temp.length, len);
} // 用户将 Socket 关闭了,这里也直接将对应 Socket 关闭
if (data.length == 0) {
if (socket != null) socket.close();
cOutput.write(ProtocolUtils.serverNAT("close", userId)); cOutput.flush();
cOutput.flush();
Log.print("用户关闭了连接,用户id: " + userId);
break;
} cOutput.write(ProtocolUtils.serverNAT("data",id,data));
cOutput.flush();
}
} catch (IOException e) {
e.printStackTrace();
Log.printErr("客户端关闭了连接/用户处理出错: " + e.getMessage());
}
userId++;
}).start();
} catch (IOException e) { Log.printErr("与用户打开连接出错: " + e.getMessage()); } }

onStop() 就是关闭一些流

@Override
protected void onStop() {
try { if (cInput != null) cInput.close(); } catch (IOException e) { Log.printErr("TCP协议类onStop 关闭input出错: " + e.getMessage()); }
try { if (cOutput != null) cOutput.close(); } catch (IOException e) { Log.printErr("TCP协议类onStop 关闭output出错: " + e.getMessage()); }
try { if (client != null) client.close(); } catch (IOException e) { Log.printErr("TCP协议类onStop 关闭client出错: " + e.getMessage()); }
try { if (server != null) server.close(); } catch (IOException e) { Log.printErr("TCP协议类onStop 关闭server出错: " + e.getMessage()); }
}

测试,在之前的 TCPReceive里,main为

测试太难搞了。。。但总算弄好了

	public static void main(String[] args) throws UnknownHostException, IOException {
Socket socket = new Socket("127.0.0.1",9999);
BufferedOutputStream output = new BufferedOutputStream(socket.getOutputStream());
BufferedInputStream input = new BufferedInputStream(socket.getInputStream());
byte[] b = new byte[1024];
// 添加
output.write("add;8888;10000;tcp".getBytes());
output.flush();
// 获取到隧道id
int len = input.read(b);
String addReceive = new String(b,0,len);
String id = addReceive.split(";")[1];
System.out.println(addReceive);
// 将之前的关闭,因为一个请求只能做一次操作
output.close();
input.close();
socket.close(); socket = new Socket("127.0.0.1",9999);
output = new BufferedOutputStream(socket.getOutputStream());
input = new BufferedInputStream(socket.getInputStream());
// 打开
output.write(("open;"+id).getBytes());
output.flush();
len = input.read(b);
System.out.println(new String(b,0,len));
socket.close(); // 将之前的关闭,因为一个请求只能做一次操作
output.close();
input.close();
socket.close(); /*
socket = new Socket("127.0.0.1",9999);
output = new BufferedOutputStream(socket.getOutputStream());
// 关闭
output.write(("close;"+id).getBytes());
output.flush();
output.close();
socket.close();
*/ // 测试 服务端 客户端 用户访问
// 8888是内网端口,我们要打开, 然后通过 10000 来访问到8888
// 这里是等于创建 内网的服务端
ServerSocket server = new ServerSocket(8888);
new Thread(() -> {
try (Socket s = server.accept();
OutputStream sOutput = s.getOutputStream()) {
// 服务端发送的数据不用什么协议
sOutput.write("我是内网的服务器,hello, world".getBytes());
sOutput.flush(); InputStream sInput = s.getInputStream();
byte[] sByte = new byte[1024];
int sLen = sInput.read(sByte);
System.out.println("内网服务端接收到数据: " + new String(sByte,0,sLen)); server.close();
} catch (IOException e) {
e.printStackTrace();
}
}).start(); // 客户端
// 10000端口是打开的服务端,我们这里创建连接 当做客户端
// 可以通过打开的时候获取到端口和ip
socket = new Socket("127.0.0.1", 10000);
input = new BufferedInputStream(socket.getInputStream());
output = new BufferedOutputStream(socket.getOutputStream()); // 用户端 开启后发送数据
new Thread(() -> {
try {
Socket userSocket = new Socket("127.0.0.1", 10000);
// 接收数据,开启后就创建了连接,连接被创建就会收到服务端发来的数据
InputStream userInput = userSocket.getInputStream();
byte[] userByte = new byte[1024];
int userLen = userInput.read(userByte);
System.out.println("用户接收到数据: " + new String(userByte,0,userLen)); OutputStream userOutput = userSocket.getOutputStream();
userOutput.write("我是用户,hello, world".getBytes());
userOutput.flush();
userSocket.close();
} catch (IOException e) {
e.printStackTrace();
}
}).start(); // 处理客户端的接收操作
b = new byte[1024];
len = input.read(b);
System.out.println("客户端接收到数据(新用户来了): " + new String(b,0,len));
// 上面这个数据应该是创建的数据格式,我们创建一个新Socket与内网服务端连接
// new;id,id应该为0,第一个用户
Socket serverUserSocket = new Socket("127.0.0.1",8888);
InputStream serverUserInput = serverUserSocket.getInputStream();
b = new byte[1024];
len = serverUserInput.read(b);
// 接收到内网服务端发的数据直接发给服务端->服务端发给用户]
System.out.println("客户端接收到内网服务端数据,并发送给服务端: " + new String(b,0,len));
output.write(ProtocolUtils.serverNAT("data", 0,new String(b,0,len)));
output.flush(); // 接收用户->服务端发来的数据
// 这里粘包了,没有问题,我们写客户端的时候会进行处理.
b = new byte[1024];
len = input.read(b);
String userData = new String(b,0,len);
System.out.println("客户端接收到数据,并发给内网服务端: " + userData);
OutputStream serverUserOutput = serverUserSocket.getOutputStream();
{
byte[] bytes = userData.split(";")[2].getBytes();
serverUserOutput.write(new String(bytes,0,bytes.length - 2).getBytes());
}
serverUserOutput.close();
serverUserInput.close();
serverUserSocket.close(); // while(true);
output.close();
input.close();
socket.close();
}

运行结果如下

服务端

测试端


客户端

我们已经写过测试代码了,客户端的话,只需要套协议就ok了

新建一个项目为客户端

将之前服务端可以用到的东西复制过来.

同样,为了扩展,我们用反射调用.在启动客户端的时候我们需要接收一个参数(隧道id),所以在运行的时候需要传递隧道id

客户端不能新增和删除隧道,只能打开和关闭.

所以我们客户类中有视图层(与用户交互),模型层(隧道信息),控制器层(控制隧道开关)

在启动类里启动视图层,通过控制器打开隧道,创建隧道信息,和通过隧道信息启动协议服务

启动类代码如下

NATInfo 隧道信息类(从服务端复制更改)

稍微改了一下,增加了个视图信息和单例

package shendi.nat.client.bean;

import shendi.nat.client.protocol.ClientProtocol;
import shendi.nat.client.view.View; /**
* 穿透信息,包含需要访问的ip和端口,状态等.
* @author Shendi <a href='tencent://AddContact/?fromId=45&fromSubId=1&subcmd=all&uin=1711680493'>QQ</a>
* @version 1.0
*/
public class NATInfo {
/**
* 单例模式
*/
private NATInfo() {}
private static final NATInfo INFO = new NATInfo(); /**
* 内网电脑的 ip.
*/
private String ip; /**
* 内网电脑的端口.
*/
private int port; /**
* 服务端对应的端口(经济原因,这里应是域名)
*/
private int serverPort; /**
* 使用的什么协议.
*/
private String protocol; /**
* 当前穿透(隧道)状态.
*/
private NATState state = NATState.离线; /**
* 协议对应的客户类.
*/
private ClientProtocol client; /**
* 当前视图
*/
private View view; /**
* 获取此类的单例对象.
* @author Shendi <a href='tencent://AddContact/?fromId=45&fromSubId=1&subcmd=all&uin=1711680493'>QQ</a>
* @return {@link NATInfo}
*/
public static NATInfo getInfo() { return INFO; } /**
* 打开隧道的时候获取的信息.
* @author Shendi <a href='tencent://AddContact/?fromId=45&fromSubId=1&subcmd=all&uin=1711680493'>QQ</a>
* @param ip 内网ip
* @param port 内网服务端口
* @param serverPort 服务器端口
* @param protocol 通信协议
*/
public void open(String ip,int port,int serverPort,String protocol) {
this.ip = ip;
this.port = port;
this.serverPort = serverPort;
this.protocol = protocol;
state = NATState.在线;
}
public String getIp() { return ip; } public int getPort() { return port; } public int getServerPort() { return serverPort; } public String getProtocol() { return protocol; } public NATState getState() { return state; }
public void setState(NATState state) { this.state = state; } public ClientProtocol getClient() { return client; }
public void setClient(ClientProtocol client) { this.client = client;} public View getView() { return view; }
public void setView(View view) { this.view = view; }
}

View是与用户交互的接口

代码如下

Controller是隧道的控制器

用于开关隧道

ClientProtocol 是客户端协议类(与服务端通信的socket)

基本上和服务端的协议类一模一样,不同的是有了创建此类对应对象的方法

我们打开了隧道后就应该创建对应连接的通信与服务端连接.所以此方法用于适应多客户端

/**
* 隧道协议抽象类,继承此类来创建一个隧道协议.<br>
* 创建的隧道协议需要在 protocol.properties 中进行配置.<br>
* 配置形式为 协议名=类全路径.<br>
* 如果要实现构造方法,请确保线程安全,请勿执行耗时操作.
* @author Shendi <a href='tencent://AddContact/?fromId=45&fromSubId=1&subcmd=all&uin=1711680493'>QQ</a>
* @version 1.0
*/
public abstract class ClientProtocol { /**
* 死循环的标志位,true运行,false关闭.
*/
private boolean state = true; /**
* 开启此协议.
* @author Shendi <a href='tencent://AddContact/?fromId=45&fromSubId=1&subcmd=all&uin=1711680493'>QQ</a>
* @param id 隧道id.
*/
public final void start(String id) {
NATInfo.getInfo().setState(NATState.在线); new Thread(() ->{
onCreate(id);
while (state) { run(); }
onStop(id);
}).start();
} /**
* 关闭此协议,关闭后就代表此对象将被清理.
* @author Shendi <a href='tencent://AddContact/?fromId=45&fromSubId=1&subcmd=all&uin=1711680493'>QQ</a>
*/
public final void stop() { NATInfo.getInfo().setState(NATState.离线); state = false; } /**
* 在被创建的时候调用.<br>
* 用于打开隧道和初始化.
* @author Shendi <a href='tencent://AddContact/?fromId=45&fromSubId=1&subcmd=all&uin=1711680493'>QQ</a>
*/
protected abstract void onCreate(); /**
* 执行具体操作逻辑.<br>
* 此方法会被持续调用,请确保线程安全.
* @author Shendi <a href='tencent://AddContact/?fromId=45&fromSubId=1&subcmd=all&uin=1711680493'>QQ</a>
*/
protected abstract void run(); /**
* 被关闭时调用.<br>
* 用于关闭隧道,关闭连接,恢复状态等.
* @author Shendi <a href='tencent://AddContact/?fromId=45&fromSubId=1&subcmd=all&uin=1711680493'>QQ</a>
*/
protected abstract void onStop(String id); /**
* 创建对应协议的客户端.
* @author Shendi <a href='tencent://AddContact/?fromId=45&fromSubId=1&subcmd=all&uin=1711680493'>QQ</a>
* @param protocol 客户端使用的协议.
* @return {@link ClientCodeException}
* @throws Exception 反射出异常时抛出.
*/
public static ClientProtocol create(String protocol) throws Exception {
return ((ClientProtocol)Class.forName(ConfigurationFactory.getConfig("protocol").getProperty(protocol)).getConstructor().newInstance());
}
}

实现打开隧道

在实现打开隧道之前,我们需要创建一个协议工具类(将之前服务端的协议工具类复制过来)

代码如下

/**
* 协议工具类,通过此类轻松组装数据.
* @author Shendi <a href='tencent://AddContact/?fromId=45&fromSubId=1&subcmd=all&uin=1711680493'>QQ</a>
* @version 1.0
*/
public class ProtocolUtils { /**
* 服务端穿透通信.
* @author Shendi <a href='tencent://AddContact/?fromId=45&fromSubId=1&subcmd=all&uin=1711680493'>QQ</a>
* @param type 类型
* @param info 后续数据信息,默认会转化成此对象toString形式,字节数组会做另外处理.
* @return 组装成的数据的字节表示形式
*/
public static final byte[] serverNAT(String type,Object... info) {
byte[] data = type.getBytes();
for (Object obj : info) {
byte[] temp = data;
byte[] bytes; if (obj instanceof byte[]) {
bytes = (byte[]) obj;
} else {
bytes = obj.toString().getBytes();
} data = new byte[temp.length + bytes.length + 1];
System.arraycopy(temp, 0, data, 0, temp.length);
data[temp.length] = ';';
System.arraycopy(bytes, 0, data, temp.length+1, bytes.length);
}
byte[] temp = data;
data = new byte[temp.length + 2];
System.arraycopy(temp, 0, data, 0, temp.length);
data[data.length - 1] = -3;
data[data.length - 2] = -2;
return data;
} /**
* 打开隧道的协议组装.
* @author Shendi <a href='tencent://AddContact/?fromId=45&fromSubId=1&subcmd=all&uin=1711680493'>QQ</a>
* @param id 隧道id
* @return 组装后的数据
*/
public static final byte[] open(String id) {
byte[] typeData = "open;".getBytes();
byte[] idData = id.getBytes();
byte[] data = new byte[typeData.length + idData.length];
System.arraycopy(typeData, 0, data, 0, typeData.length);
System.arraycopy(idData, 0, data, typeData.length, idData.length);
return data;
} /**
* 关闭隧道的协议组装.
* @author Shendi <a href='tencent://AddContact/?fromId=45&fromSubId=1&subcmd=all&uin=1711680493'>QQ</a>
* @param id 隧道id
* @return 组装后的数据
*/
public static final byte[] close(String id) {
byte[] typeData = "close;".getBytes();
byte[] idData = id.getBytes();
byte[] data = new byte[typeData.length + idData.length];
System.arraycopy(typeData, 0, data, 0, typeData.length);
System.arraycopy(idData, 0, data, typeData.length, idData.length);
return data;
} /**
* 解析开启隧道操作返回的数据.
* @author Shendi <a href='tencent://AddContact/?fromId=45&fromSubId=1&subcmd=all&uin=1711680493'>QQ</a>
* @param data 数据
* @return 操作是否成功.
*/
public static final boolean analysisOpen(String data) {
String[] datas = data.split(";");
// 一层层获取
if (datas.length > 0) {
String status = datas[0];
if ("ok".equals(status)) {
if (datas.length > 4) {
NATInfo.getInfo().open(datas[1], Integer.parseInt(datas[2]), Integer.parseInt(datas[3]), datas[4]);
return true;
} else {
NATInfo.getInfo().getView().sendInfo("开启隧道失败,网络不佳,请重试");
Log.printAlarm("开启隧道失败,数据不完整: " + data);
return false;
}
} else if ("error".equals(status)) {
NATInfo.getInfo().getView().sendInfo(datas[1]);
Log.print("开启隧道失败,信息为: " + datas[1]);
} else {
NATInfo.getInfo().getView().sendInfo("开启隧道失败,协议错误.");
Log.printAlarm("开启隧道失败,数据协议错误: " + data);
}
}
return false;
} }

上面代码较服务端不同的是,增加了开关协议,以及解析开启隧道的协议

打开隧道,打开成功后立马用对应协议来打开连接

/**
* 使用 TCP 协议的控制器,
* @author Shendi <a href='tencent://AddContact/?fromId=45&fromSubId=1&subcmd=all&uin=1711680493'>QQ</a>
* @version 1.0
*/
public class TCPController extends Controller { @Override
public boolean open(String id) {
String[] url = ConfigurationFactory.getConfig("config").getProperty("server.url").split(":");
try (Socket socket = new Socket(url[0],Integer.parseInt(url[1]));
InputStream input = socket.getInputStream();
OutputStream output = socket.getOutputStream()) {
output.write(ProtocolUtils.open(id));
output.flush(); // 接收服务端传来的数据并解析.
byte[] data = new byte[0];
byte[] bytes = new byte[1024];
int len = -1;
while ((len = input.read(bytes)) != -1) {
byte[] temp = data;
data = new byte[temp.length + len];
System.arraycopy(temp, 0, data, 0, temp.length);
System.arraycopy(bytes, 0, data, temp.length, len);
}
// 解析数据成功则打开与指定服务端的连接
if (ProtocolUtils.analysisOpen(new String(data))) {
try {
ClientProtocol.create(NATInfo.getInfo().getProtocol()).start(id);
return true;
} catch (Exception e) {
Log.printErr("打开隧道,创建客户端错误,请重新打开: " + e.getMessage());
NATInfo.getInfo().getView().sendInfo("打开隧道,创建客户端错误,请重新打开: " + e.getMessage());
}
}
} catch (IOException e) {
Log.printErr("打开隧道错误: " + e.getMessage());
NATInfo.getInfo().getView().sendInfo("打开隧道错误: " + e.getMessage());
}
return false;
} @Override
public void close(String id) {
String[] url = ConfigurationFactory.getConfig("config").getProperty("server.url").split(":");
try (Socket socket = new Socket(url[0],Integer.parseInt(url[1]));
OutputStream output = socket.getOutputStream()) {
output.write(ProtocolUtils.close(id));
output.flush();
} catch (IOException e) {
e.printStackTrace();
Log.printErr("关闭隧道错误: " + e.getMessage());
NATInfo.getInfo().getView().sendInfo("关闭隧道错误: " + e.getMessage());
}
} }

实现控制台输出视图 ConsoleView

先实现 open 方法

sc是Scanner,在onCreate进行初始化(onCreate先执行)

简单实现一下界面输出信息.

最后实现 show 方法,显示我们的交互界面

@Override
public void show() {
while (true) {
// 通过现在隧道状态来分界面
String input;
// 隧道在线
if (NATState.在线 == NATInfo.getInfo().getState()) {
sendInfo("* * * * * * 隧道在线 * * * * * *");
sendInfo("exit\t\t退出");
sendInfo("* * * * * * * * * * * * * * * *");
input = sc.nextLine();
// 过滤掉空字符
while ("".equals(input)) input = sc.nextLine();
String[] data = input.split(" ");
switch (data[0]) {
case "exit":
System.exit(0);
break;
} // 隧道离线
} else if (NATState.离线 == NATInfo.getInfo().getState()) {
sendInfo("* * * * * * 隧道离线 * * * * * *");
sendInfo("open [隧道id]\t打开隧道");
sendInfo("exit\t\t退出");
sendInfo("* * * * * * * * * * * * * * * *");
input = sc.nextLine();
// 过滤掉空字符
while ("".equals(input)) input = sc.nextLine(); String[] data = input.split(" ");
switch (data[0]) {
case "open":
if (data.length > 1 && !"".equals(data[1])) {
try {
if (!Controller.create().open(data[1])) {
String info = "隧道 [" + data[1] + "] 打开失败";
Log.printErr(info);
NATInfo.getInfo().getView().sendInfo(info);
}
} catch (Exception e) {
e.printStackTrace();
}
} else { System.out.println("使用open命令的方法为: open 隧道id"); }
break;
case "exit":
System.exit(0);
break;
}
} else {
System.err.println("控制台出现未知错误!");
// 等待用户输入后退出
try {
Runtime.getRuntime().exec("cmd /c pause");
} catch (IOException e) {
e.printStackTrace();
}
System.exit(0);
}
}
}

实现TCP协议类

完成这个类基本上就完工了.

此类处理与服务端通信,并且管理与内外服务端通信.

实现服务端到内网服务端之间数据的转发

项目量有点大,有很多地方有缺陷,比如在写服务端的时候可以把添加,打开等操作写协议类里,等...

在实现此类会扩展协议类(与服务端通信),为了扩展.

我们新建此类。实现协议类,并且将属定义好(Socket,流,存储用户的map)

实现 onStop

实现 onCreate

客户端的是一对一的,对应服务端和内网服务端,所以实现都在run里,onCreate中只进行初始化

实现 run方法

先直接把服务端onCreate下的循环代码拿过来稍作修改.

之前是这么写的,现在把这个判断结尾的操作封装到协议类里

现在变成了这样

稍作修改,增加服务端新增用户和关闭操作

@Override
protected void run() {
try {
byte b = (byte) input.read();
// 如果读到的数据为 -1 我们需要发送一下心跳包判断服务端是否存活
if (b == -1) {
try { output.write(0); } catch (IOException e) {
// 关闭此服务端
stop();
Log.printErr("与服务器的连接已断线!");
NATInfo.getInfo().getView().sendInfo("与服务器的连接已断线!");
return;
}
} // 扩容
if (index >= data.length) {
byte[] temp = data;
data = new byte[temp.length << 1];
System.arraycopy(temp, 0, data, 0, temp.length);
} data[index++] = b; if (ProtocolUtils.isData(data, index)) {
// 转成字符串,进行解析,最后两个数据位去除
String sData = new String(data,0,index-2);
System.out.println(sData);
String[] datas = sData.split(";"); // 这一部分解析可以封装到协议类里,但是太麻烦,直接硬写了.
switch (datas[0]) {
// 新增用户连接,直接创建一个与内网服务端的连接,并与id存入map.
case "new":
if (datas.length < 2) { Log.printAlarm("TCP协议类数据格式new类型错误: " + sData); NATInfo.getInfo().getView().sendInfo("TCP协议类数据格式new类型错误: " + sData); break; }
System.out.println(NATInfo.getInfo().getIp() + ":" + NATInfo.getInfo().getPort());
Socket socket = new Socket(NATInfo.getInfo().getIp(),NATInfo.getInfo().getPort());
sockets.put(datas[1], socket);
System.out.println(datas[1] + "-已打开和内网服务端的连接.");
// 开启线程执行接收内网服务端的数据
new Thread(() -> {
// 将 id 存起来,用于自己告诉客户端自己是谁
String id = datas[1];
try (BufferedInputStream input = new BufferedInputStream(socket.getInputStream())) {
while (true) {
byte[] data = new byte[0];
byte[] bytes = new byte[1024];
int len = -1;
while ((len = input.read(bytes)) != -1) {
byte[] temp = data;
data = new byte[temp.length + len];
System.arraycopy(temp, 0, data, 0, temp.length);
System.arraycopy(bytes, 0, data, temp.length, len);
} // 内网服务端将 Socket 关闭了,这里也直接将对应 Socket 关闭
if (data.length == 0) {
if (socket != null) socket.close();
Log.print("内网服务端断开指定用户,id: " + id);
NATInfo.getInfo().getView().sendInfo("内网服务端断开指定用户,id: " + id);
break;
} // 发送数据给服务端
System.out.println(new String(ProtocolUtils.serverNAT("data", data)));
output.write(ProtocolUtils.serverNAT("data", data));
output.flush();
}
// 我们关闭指定用户方式为直接关闭 socket,所以就会出此异常,格外处理.
} catch (SocketException e) {
Log.printErr("用户离开了 id=" + id);
NATInfo.getInfo().getView().sendInfo("用户离开了 id=" + id);
} catch (IOException e) {
e.printStackTrace();
}
}).start();
break;
// 将数据发给内网服务端
case "data":
if (datas.length < 3) { Log.printAlarm("TCP协议类数据格式data类型错误: " + sData); NATInfo.getInfo().getView().sendInfo("TCP协议类数据格式data类型错误: " + sData); break; }
BufferedOutputStream serverOutput = new BufferedOutputStream(sockets.get(datas[1]).getOutputStream());
System.out.println("数据发往内网服务端: " + datas[2]);
serverOutput.write(datas[2].getBytes());
serverOutput.flush();
break;
// 关闭对应用户与内网服务端的连接
case "close":
if (datas.length < 2) { Log.printAlarm("TCP协议类数据格式close类型错误: " + sData); NATInfo.getInfo().getView().sendInfo("TCP协议类数据格式close类型错误: " + sData); break; }
sockets.remove(datas[1]).close();
break;
// 默认,代表出错了
default: Log.printAlarm("TCP协议类数据格式错误: " + sData); NATInfo.getInfo().getView().sendInfo("TCP协议类数据格式错误: " + sData);
}
// 此条数据被解析完成,开始下一条.
index = 0; data = new byte[data.length];
}
} catch (IOException e) { stop(); Log.printErr("TCP协议类接收服务端数据出错: " + e.getMessage()); NATInfo.getInfo().getView().sendInfo("TCP协议类接收服务端数据出错: " + e.getMessage());}
}
 

这个程序基本上完成了(只不过有些bug)

我们判断数据结尾需要跳出循环条件,也就是input,.read读到的为-1,所以就代表了用户或者内网服务端通信发送数据完后都要使用flush,不然就会进入缓冲区(可以自行进行优化)

我新增了一些控制台输出方便寻找bug

测试一下 看看结果

在测试之前我们在输出视图做一个添加隧道的功能

我用以前写的一个文件传输的软件作为用户和内网服务端,拥有聊天功能,服务端口

将服务端和客户端启动.

先随便输入,跳过打开隧道,我们要添加隧道.(服务端为2333,用户连接的为10000端口)

打开隧道

接下来我们需要访问10000端口就可以访问到2333端口

我们可以在这个软件内发送消息(正如我之前说的,我这个软件没有用flush刷新数据过去,所以,只有当一方关闭(close方法也会调用一次flush)才能接收到数据)

断开连接.

结尾

最后画张图来梳理整个思路

  • NAT穿透已经实现,有一点点bug,稍做修改就没问题了(比如只有flush对方才能接收到数据,这个只要在循环里做判断就ok了)
  • 使用的 BIO,其实使用 NIO 做这个更有优势.
  • 实际应用的话,应该在传输过程中将数据加密.
  • 源码已上传到github,请看文章最上方.
  • 边学习,边实践