在上一篇文章中,我们使用PCAP建立了本地的软件集线器(Hub)。考虑到较远距离的跨车间调试,有必要使用Tcp连接构造一个以太网的隧道,使得两个车间之间的调试设备可以虚拟的连接在一个Hub上。当然,我们可以使用QTcpSocket实现连接,或者尝试一下QSslSocket安全套接字,恰好使用前序文章中生成的证书,来玩一玩Ssl连接。
完整工程参考上一篇文章中的Git链接。
具有远程Ssl隧道的调试工作站的原理图如下:
1. 点对点TCP连接
我们希望两个调试工作站上的PCAPHub进程可以通过一个点对点的信道连接起来。工作站A 处于监听模式,工作站B处于Client模式。
这样,一对TCP连接相当于一根逻辑的网线,与各个本地网口是对等的关系。上图中,所有远程的MAC地址都会出现在“TCP隧道对端连接的MAC栏”中。
2. 搭建SSL服务器
在Qt6中,设置了QSslServer类,可以直接构造Ssl服务器。但在Qt5中,并没有这个类。为了兼容Qt5 ,要重载QTcpServer,构造一个SslServer
//"sslserver.h"
class SSLServer : public QTcpServer
{
Q_OBJECT
public:
explicit SSLServer(QObject *parent = nullptr);
protected:
void incomingConnection(qintptr socketDescriptor) override;
signals:
void sig_newClient(qintptr socketDescriptor);
};
//CPP
void SSLServer::incomingConnection(qintptr socketDescriptor)
{
emit sig_newClient(socketDescriptor);
}
而后,在QObject派生的TcpTunnel类中进行响应,sig_newClient会把套接字描述符泵给tcpTunnel 。在源码中,您会发现这些信号与曹都是在独立的线程中运行的,而不是主UI线程。tcpTunnel 类就是用来实例化工作在QThread上的对象,从而使用QThread独立的消息循环驱动信号、事件的流转。
class tcpTunnel : public QObject
{
//...
//Tunnel
protected:
SSLServer * m_svr = nullptr;
QTcpSocket * m_sock = nullptr;
protected slots:
void slot_new_connection(qintptr socketDescriptor);
};
//New Connections
void tcpTunnel::slot_new_connection(qintptr socketDescriptor)
{
QTcpSocket * sock = m_bSSL? new QSslSocket(this):new QTcpSocket(this);
if (sock->setSocketDescriptor(socketDescriptor)) {
connect(sock,&QTcpSocket::readyRead,this,&tcpTunnel::slot_read_sock);
//SSL Handshake
QSslSocket * sslsock = qobject_cast<QSslSocket*>(sock);
if (sslsock)
{
QString strCerPath = ":/certs/svr_cert.pem";
QString strPkPath = ":/certs/svr_privkey.pem";
sslsock->setLocalCertificate(strCerPath);
sslsock->setPrivateKey(strPkPath);
sslsock->startServerEncryption();
}
}
}
这里需要解释的有以下几点:
- QSslSocket与QTcpSocket具备高度一致的状态机,也就是state()函数的行为高度一致。正因为如此,界面上通过一个简单的标记,可以开启或者关闭Ssl功能。
- 作为服务器套接字,要在握手前指定证书。这里使用范例证书直接嵌入在资源里,是有问题的。因为范例证书的服务器地址是127.0.0.1,和真实环境不同,会导致客户端连接失败。本例子里,客户端会忽略地址不一致的错误,这是一种不好的行为。在生产环境下,还是要根据具体情况准备合适的证书。
有了上述逻辑,tcpTunnel 就能够在客户端到来时,响应消息并接受连接开始握手。
3 客户端发起连接
在tcpTunnel类的槽函数里,会发起指向服务器的连接. 这里需要注意的是,要及时响应QSslSocket::sslErrors信号,并处理几类证书错误,以便在含有错误的证书情况下,依旧可以建立连接。相关的错误是:
- QSslError::CertificateUntrusted 不信任的证书
- QSslError::CertificateNotYetValid 尚未生效的证书(未来的时刻)
- QSslError::CertificateExpired 过期的证书
- QSslError::HostNameMismatch 证书的服务器地址和当前连接的HostAddress不同。
此外,对于Qt5,由于信号QSslSocket::sslErrors存在重载,导致必须要进行显式的类型约束,才能进行functional 样式的connect。Qt6里已经避免了这个问题。
//H
class tcpTunnel : public QObject
{
public slots:
void startWork(QString address, QString port, bool listen,bool ssl);
};
//CPP
void tcpTunnel::startWork(QString address, QString port, bool ssl)
{
if (m_bSSL)
{
QSslSocket * sslsock = new QSslSocket(this);
connect(sslsock,static_cast<void (QSslSocket::*)(const QList <QSslError> &)>(&QSslSocket::sslErrors),
[this,sslsock](const QList <QSslError> & err)->void
{
QList<QSslError> errIgnore;
foreach (QSslError e, err)
{
emit sig_message(tr("SSL Error %1:%2 .").arg((int)e.error()).arg(e.errorString()));
if (e.error()==QSslError::HostNameMismatch) errIgnore<<e;
else if (e.error()==QSslError::CertificateUntrusted) errIgnore<<e;
else if (e.error()==QSslError::CertificateNotYetValid) errIgnore<<e;
else if (e.error()==QSslError::CertificateExpired) errIgnore<<e;
}
sslsock->ignoreSslErrors(errIgnore);
});
m_sock = sslsock;
sslsock->connectToHostEncrypted(m_str_addr,m_n_port);
}
else
{
m_sock = new QTcpSocket(this);
m_sock->connectToHost(QHostAddress(m_str_addr),m_n_port);
}
connect(m_sock,&QTcpSocket::readyRead,this,&tcpTunnel::slot_read_sock);
emit sig_message(tr("connecting to: %1:%2").arg(m_str_addr).arg(m_n_port));
}
4. Ethernet on TCP 协议
在上一篇文章里,对于抓取的以太网数据,存储在一个环状高速缓存中。如果通过tcp传输以太网数据,需要设计一种切割包的协议。我们用最简单的方法来做。
Magic | 长度 | 数据 |
---|---|---|
4Bytes | 2Bytes UShort | N1 Bytes |
4Bytes | 2Bytes UShort | N2 Bytes |
4Bytes | 2Bytes UShort | N3 Bytes |
…… |
这样,只需要在接收时,检测并取数据即可完成分包。发包的代码如下:
quint64 rp = PCAPIO::pcap_recv_pos;
while (m_nTPos < rp) {
const int nPOS = m_nTPos % PCAPIO_BUFCNT;
const int fromID = PCAPIO::global_buffer[nPOS].from_id;
if (fromID !=TCPTUNID
&& PCAPIO::global_buffer[nPOS].len < 65536
&& PCAPIO::global_buffer[nPOS].len >0)
{
const unsigned char hd[] = {0x18u,0x24u,0x7eu,0x69u};
const unsigned short len = PCAPIO::global_buffer[nPOS].len;
m_sock->write((const char *)hd,4);
m_sock->write((const char *)&len,2);
m_sock->write(PCAPIO::global_buffer[nPOS].data.constData(),len);
}
++m_nTPos;
}
收包的代码如下:
class tcpTunnel : public QObject
{
void dealPack(const char * pack, const int len);
//Tunnel
private:
quint64 m_nTPos = 0;
QByteArray m_package_array;
protected slots:
void slot_read_sock();
};
void tcpTunnel::slot_read_sock()
{
QByteArray arrData = m_sock->readAll();
m_package_array.append(arrData);
while (static_cast<size_t>(m_package_array.size())>=6)
{
//检查Magic
int goodoff = 0;
while (!(m_package_array[0+goodoff]==(char)0x18 && m_package_array[1+goodoff]==(char)0x24
&&m_package_array[2+goodoff]==(char)0x7E &&m_package_array[3+goodoff]==(char)0x69 ))
{
++goodoff;
if (goodoff+3>= m_package_array.size())
break;
}
if (goodoff)
m_package_array.remove(0,goodoff);
if (m_package_array.size()< 6 )
break;
const unsigned short * ptrlen = (const unsigned short *)
(m_package_array.constData() + 4);
const unsigned short datalen = *ptrlen;
if (m_package_array.size()<datalen+6)
break;
const char * dptr = m_package_array.constData()+6;
//Enqueue入队
dealPack(dptr,datalen);
//清除当前包
m_package_array.remove(0,6+datalen);
}
}
5 避免嵌套风暴
通过上述操作,理论上可以把远程的以太网包带到本地。但是,由于TCP隧道本身也运行在以太网协议上,如果PCAP在抓取的时候,把TCP隧道的内容也给抓了,那样的话会出现流量风暴。
如何避免流量风暴呢?只要在抓取前,把 “not tcp port 12345” 这样的过滤条件追加在用户的条件之后,即可避免风暴生成。
//2. Run Cap Thread on interface.
void recv_loop(QString itstr, QString filterStr, int id, int tcp_exlude,std::function<void (QString) > msg)
{
while (!pcap_stop)
{
pcap_t *handle = NULL;
//...
struct bpf_program filter;
//Combine Filter
QString ExFlt ;
if (tcp_exlude > 0 && tcp_exlude < 65536)
{
if (filterStr.trimmed().length())
ExFlt = "(" + filterStr.trimmed() + QString(") and not tcp port %1" ).arg(tcp_exlude);
else
ExFlt = QString("not tcp port %1" ).arg(tcp_exlude);
}
else
ExFlt = filterStr.trimmed();
//Compile Filter
if (ExFlt.size())
{
std::string filter_app = ExFlt.toStdString();
pcap_compile(handle, &filter, filter_app.c_str(), 0, net);
pcap_setfilter(handle, &filter);
}
}
}
}
当然,有时候这样做还不够,还要结合各个网口的抓包条件,共同进行约束。原则上,所有和隧道相关的流量都要排除在PCAP条件之外。
比如,如果这个TCP隧道是通过ssh的代理进行 -L或者-R接续的,则连带SSH也要排除。同时,由于HUB是低效的广播,要把类似远程桌面3389这种持续的流量全给关了。否则,可能会和设备抢夺带宽。
6 在HUB和交换机模式间灵活切换
我们只需要在像网卡回放时,通过一个开关来控制是否回放属于其他所有网口的内容,即可使得本网口工作于 集线器 或者 交换机模式下了。
struct tag_packages{
int from_id;//From which port
int to_id;//To which port
int len;
QByteArray data;
};
void recv_loop(QString itstr, QString filterStr, int id, int tcp_exlude,std::function<void (QString) > msg)
{
//...Dst Mac
const bool newDstMac = pcap_ports.contains(mac_dst);
if (newDstMac)
{
if(pcap_ports[mac_dst].dtmLastAck.msecsTo(dtm) <=CAP_FADE_MSECS)
dst_id = pcap_ports[mac_dst].curr_id;
}
}
//3. Run Send Thread on interface
void send_loop(QString itstr,int id, bool bSwitchMod,std::function<void (QString) > msg)
{
if (global_buffer[pos].from_id!=id &&
((!bSwitchMod)||(global_buffer[pos].to_id==id || global_buffer[pos].to_id==-1))
)
{
}
}
使用这个策略,在网口很多时,就能灵活的控制PCAPHub,使他工作在效率与范围兼顾的混合模式。