用Netty实现Ngrok Client【原创】

什么是Ngrok


有时候我们需要临时将本地运行的web项目发布到公网,但没有公网ip,或者需要在家访问公司内网上的某台电脑的某个端口。这个时候就需要借助Ngrok来实现上述目的,Ngrok是一个内网穿透工具。

如何使用Ngrok


一套完整的Ngrok包含两个部分:Ngrok Server和Ngrok Client

Ngrok Server 需要部署在有公网ip的服务器上,Ngrok Client则可以部署在任意能够访问外网的电脑上。

当客户端启动且与服务端连接交换信息后,服务端会分配一个端口给客户端(例如52228),与客户端建立一条新的tcp连接,此后,通过访问服务端的52228端口,就相当于访问客户端中配置的需要被发布到公网的端口。

有人可能会问,“我本来就没有公网ip,如何部署Ngrok Server?”,对此,可以去Ngrok官网注册用户,使用Ngrok官网提供的Ngrok Server。

由于本片主要讲解Ngrok Client的Netty实现,具体部署过程不做详解。

Ngrok网络协议


Ngrok官方客户端采用C编写,也有网友提供了Python的实现,以下网络协议通过分析Python版的源码得到。
Ngrok网络协议的数据交换过程如下图所示:

image

上图不包含client和server之间的心跳包数据(client端口1和server端口1之间通过心跳维持连接)

各个端口含义:

server端口1 : server启动时配置的监听端口,默认是4443

client端口1 : client与server端口1建立连接时的端口,由操作系统分配。

server端口2 : client 发送ReqTunnel请求中携带的要求server暴露的端口,若client不指定端口,则是server随机分配的一个端口。

client端口2: client与server建立的另一个用来转发代理数据的端口,由操作系统分配。

client端口3:client与本地服务建立连接时的端口,由操作系统分配。

协议的具体数据内容:

Auth:

{ "Type": "Auth", "Payload": { "ClientId": "", "OS": "darwin", "Arch": "amd64", "Version": "2", "MmVersion": "1.7", "User": "user", "Password": "" }}

AuthResp:

{"Type":"AuthResp","Payload":{"Version":"2","ClientId":"d720a2bcb084f5669d7ef7af7fd8ad9c","Error":"","MmVersion":"1.7"}}

ReqTunnel:

{"Type": "ReqTunnel", "Payload": {"ReqId": "jhnl8GF3", "Protocol": "tcp", "Hostname": "", "Subdomain": "www", "HttpAuth": "", "RemotePort": 55499}}

ReqProxy:

{"Type":"ReqProxy","Payload":{}}

RegProxy:

{"Type": "RegProxy", "Payload": {"ClientId": "d720a2bcb084f5669d7ef7af7fd8ad9c"}}

NewTunnel:

{"Type":"NewTunnel","Payload":{"Error":"","ReqId":"jhnl8GF3","Protocol":"tcp","Url":"tcp://codewjy.top:55499"}}

Ping:

{"Type":"Ping","Payload":{}}

Pong:

{"Type":"Pong","Payload":{}}

通过Netty实现


了解了ngrok的网络协议,下面通过netty实现这一协议

按照协议的先后顺序,一步一步实现,

首先是client与server的控制连接的建立(上图中client端口1和server端口1的连接),同时也是客户端的启动入口NgrokClient:

/**
 * HOST: ngrok服务端域名
 * PORT: ngrok服务端控制端口
 * REMORTE_PORT: ngrok服务端代理端口
 * LOCAL_PORT: 本地需要被暴露出来的端口
 */
    static final String HOST = "codewjy.top";
    static final int PORT = 4454;
    static final int REMORTE_PORT = 55499;
    static final int LOCAL_PORT = 8080;

    public static void main(String[] args) {
        new NgrokClient().start();
    }

    private void start() {
        NioEventLoopGroup group = new NioEventLoopGroup(1);
        Bootstrap b = new Bootstrap();
        try {
            b.group(group)
                    .channel(NioSocketChannel.class)
                    .option(ChannelOption.TCP_NODELAY, true)
                    .handler(new ChannelInitializer<SocketChannel>() {
                        protected void initChannel(SocketChannel ch) throws SSLException {
                            SSLEngine engine = SslContextBuilder.forClient().trustManager(InsecureTrustManagerFactory.INSTANCE).build().newEngine(ch.alloc());
                            ChannelPipeline p = ch.pipeline();
                            //ssl处理器
                            p.addFirst(new SslHandler(engine,false));
                            //以下两个处理器组成心跳处理器
                            p.addLast(new IdleStateHandler(5, 20, 0, TimeUnit.SECONDS));
                            p.addLast(new HeartBeatHandler());
                            //主控制处理器
                            p.addLast(new ControlHandler());
                        }
                    });
            ChannelFuture f = b.connect(NgrokClient.HOST, NgrokClient.PORT).sync();
            f.channel().closeFuture().sync();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }


ControlHandler部分代码

    @Override
    public void channelActive(ChannelHandlerContext ctx) {
        //channel激活的时候发送Auth
        ctx.channel().writeAndFlush(GenericUtil.getByteBuf(AUTH));
    }

    @Override
    protected void channelRead0(ChannelHandlerContext ctx, ByteBuf byteBuf) throws Exception {
        if (byteBuf.isReadable()) {
            int rb = byteBuf.readableBytes();
            if (rb > 8) {
                CharSequence charSequence = byteBuf.readCharSequence(rb, Charset.defaultCharset());
                JSONObject jsonObject = JSON.parseObject(charSequence.toString());
                if ("AuthResp".equals(jsonObject.get("Type"))) {
                    //收到AuthResp响应
                    clientId = jsonObject.getJSONObject("Payload").getString("ClientId");
                    ctx.channel().writeAndFlush(GenericUtil.getByteBuf(PING));
                    //发送ReqTunnel
                    ctx.channel().writeAndFlush(GenericUtil.getByteBuf(REQ_TUNNEL));
                }else if ("ReqProxy".equals(jsonObject.get("Type"))) {
                    //收到ReqProxy响应
                    Bootstrap b = new Bootstrap();
                    try {
                        b.group(group)
                                .channel(NioSocketChannel.class)
                                .option(ChannelOption.TCP_NODELAY, true)
                                .handler(new ChannelInitializer<SocketChannel>() {
                                    protected void initChannel(SocketChannel ch) throws SSLException {
                                        SSLEngine engine = SslContextBuilder.forClient()
                                                .trustManager(InsecureTrustManagerFactory.INSTANCE)
                                                .build()
                                                .newEngine(ch.alloc());
                                        ChannelPipeline p = ch.pipeline();
                                        //ssl处理器
                                        p.addFirst(new SslHandler(engine,false));
                                        //代理处理器
                                        p.addLast(new ProxyHandler(clientId));
                                    }
                                });
                        ChannelFuture f = b.connect(NgrokClient.HOST, NgrokClient.PORT).sync();
                        logger.info("connect to remote address "+f.channel().remoteAddress());
                        f.channel().closeFuture().addListener((ChannelFutureListener) channelFuture -> logger.info("disconnect to remote address "+f.channel().remoteAddress()));
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }else if ("NewTunnel".equals(jsonObject.get("Type"))) {
                    logger.info(jsonObject.toJSONString());
                }
            }
        }

    }

以上代码完成了client和server的握手
下面处理server发起开始代理部分的协议,也就是ProxyHandler的内容
ProxyHandler部分代码

    //持有连接到本地服务的channel,用于将数据转发给本地服务
    private ChannelFuture f;

    @Override
    public void channelActive(ChannelHandlerContext ctx) {
        //channel激活后,发送RegProxy
        ctx.channel().writeAndFlush(GenericUtil.getByteBuf(REG_PROXY));
    }

    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) throws InterruptedException {
        ByteBuf byteBuf = (ByteBuf) msg;
        if (byteBuf.isReadable()) {
            int rb = byteBuf.readableBytes();
            if (rb > 8) {
                if (!init){
                    CharSequence charSequence = byteBuf.readCharSequence(rb, Charset.defaultCharset());
                    JSONObject jsonObject = JSON.parseObject(charSequence.toString());
                    if ("StartProxy".equals(jsonObject.get("Type"))) {
                        logger.info("=====StartProxy=====");
                        Bootstrap b = new Bootstrap();
                        b.group(group)
                                .channel(NioSocketChannel.class)
                                .option(ChannelOption.TCP_NODELAY, true)
                                .handler(new ChannelInitializer<SocketChannel>() {
                                    protected void initChannel(SocketChannel ch) {
                                        ChannelPipeline p = ch.pipeline();
                                        //传入当前channel,用于将数据写回给ngrok server
                                        p.addLast(new FetchDataHandler(ctx.channel()));
                                    }
                                });
                        //连接本地服务
                        f = b.connect("127.0.0.1", NgrokClient.LOCAL_PORT).sync();
                        logger.info("connect local port:"+f.channel().localAddress());
                        f.channel().closeFuture().addListener((ChannelFutureListener) t -> {
                            logger.info("disconnect local port:"+f.channel().localAddress());
                            init = false;
                        });
                        init = true;
                    }
                }else {
                    //将用户请求数据转发给本地服务
                    logger.info("ProxyHandler write message to local port "+f.channel().localAddress()+":"+byteBuf.toString((CharsetUtil.UTF_8)));
                    f.channel().writeAndFlush(byteBuf.copy());

                }
            }
        }
    }

最后一步是ngrok连接本地服务后,完成的工作:

    private Channel channel;
    //传入连接到 ngrok server的channel
    FetchDataHandler(Channel channel) {
        this.channel=channel;
    }

    @Override
    protected void channelRead0(ChannelHandlerContext channelHandlerContext, ByteBuf byteBuf) throws Exception {
        //将本地服务的数据写回给ngrok server的channel
        logger.info("FatchDataHandler write message to remote address " +channel.remoteAddress()+":"+ byteBuf.toString(CharsetUtil.UTF_8));
        channel.writeAndFlush(byteBuf.copy());
    }

以上便是ngrok client 的netty实现过程。
源码可前往我的github查看:Ngrok Client Java.

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,921评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,635评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,393评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,836评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,833评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,685评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,043评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,694评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 42,671评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,670评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,779评论 1 332
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,424评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,027评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,984评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,214评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,108评论 2 351
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,517评论 2 343

推荐阅读更多精彩内容