STF [STF] 二次开发之办公机接入 STF 服务

lucasluo · 2018年01月29日 · 最后由 lucasluo 回复于 2018年02月11日 · 最后更新自管理员 Lihuazhang · 2524 次阅读
本帖已被设为精华帖!

被多个姑娘拒绝是一种什么体验?很荣幸,我体会到了。这种心路历程,对于我这么一个大龄青年,一开始心酸,现在是坦然的接受,同时煮几碗心灵鸡汤,告诉自己不管这特么看上去是多么糟糕,你也应该积极拥抱这个世界。

1. 前言

STF部署之后,如何让北京、上海的设备进行共享成为了一个必然的需求。
然而这其中有很多不确定性:

  1. 怎样才能将设备接入呢?
  2. 接入后是否能够正常使用呢?
  3. 会不会因为接入的后某些不确定的因素导致二次开发失败呢? 带着这些疑问,接入需求进入了探索及研究阶段。

2. 原理

其实,我在之前的一篇文章[STF] 二次开发之批量安装做过STF拓扑结构的介绍,其中介绍过Provider这个概念。
现在回顾一下:

provider用来连接adb,然后为每个设备启动一个worker process。之后接受并发送从这些processors传过了的命令。

provider也表示你的宿主机,这个服务你部署在哪台机器上就代表是哪台机器接入,也即设备连在哪台主机上。另外,注意几个关键词连接adb,这说明provider只要给定ADB的IP地址和端口信息,他就能进行设备管理。然而,provider这个服务有个问题,底层调用的是adbkit的createClient方法,这个只能连接一台主机。聪明的同学肯定立刻意识到,启动多个provider不就可以了么?没错,当前阶段正式采用这种方案。
那么,provider服务应该在哪里呢?

  1. 服务端创建?
  2. 办公室的主机创建?

关于答案,我认为两种都可以。理由是STF的模块(服务)架构相对比较独立,就想deployment.md文档中描述的一样,可分布式部署。然而,目前我采用的是方案一,在于相对简便。方案二的问题在于windows安装STF会有各种问题,不是很顺利。不符合快速迭代的原则,想让用户能用起来。

3. Provider原理解释

首先要知道provider是一个可以独立工作的模块,当然有依赖,具体依赖什么自行翻看deployment.md文档。
接下来看看provider被启动时的代码,在lib/cli/local/index.js的266行。

  // provider
, procutil.fork(path.resolve(__dirname, '..'), [
      'provider'
    , '--name', argv.provider
    , '--min-port', argv.providerMinPort
    , '--max-port', argv.providerMaxPort
    , '--connect-sub', argv.bindDevPub
    , '--connect-push', argv.bindDevPull
    , '--group-timeout', argv.groupTimeout
    , '--public-ip', argv.publicIp
    , '--storage-url'
    , util.format('http://localhost:%d/', argv.port)
    , '--adb-host', argv.adbHost
    , '--adb-port', argv.adbPort
    , '--vnc-initial-size', argv.vncInitialSize.join('x')
    , '--mute-master', argv.muteMaster
    ]
    .concat(argv.allowRemote ? ['--allow-remote'] : [])
    .concat(argv.lockRotation ? ['--lock-rotation'] : [])
    .concat(!argv.cleanup ? ['--no-cleanup'] : [])
    .concat(argv.serial))

provider启动时,会传递各个命令行参数,这些参数是支持分布式部署的外部接口。
挑几个我用到的说一下。

  1. --name:这个参数就是设备所在机器的名称,可以自己定义。如果部署在用户的机器上,默认获取的是用户的计算机名称。目前的方案是创建一个客户端,通过InetAddress获取计算机名。
  2. --connect-sub:这个是丙丁设备的Pub端口,用来接收数据。(可以通过ps命令获取local provider的参数信息,拷贝帖进去就行了。)
  3. --connect-push:这个是设备的Pull端口发送数据。
  4. --public-ip:这个就是主机的公共IP了,既然在服务上,那就是服务器的ip地址。
  5. --storage-url:这个要注意,不能填http://localhost,这个是在浏览器端进行跳转到服务端的。所以要填服务器的ip地址,http://192.168.1.8/
  6. --adb-host:这个是adb的主机,这里没有填主机,填的是127.0.0.1,后面会说到,因为服务器不能访问办公及本机,需要网络穿透。不过,如果采用方案二,以我对provider的理解,我认为可以是真正主机的ip地址。
  7. --adb-port:这个就是adb所在机器的adb端口了,当然这里面也是通过网络穿透,并非是实际的5037端口
  8. --allow-remote:很容易理解,是否允许远程接入。

通过这些参数,能获得什么信息?
没错,只要知道个参数的地址,provider就会自动将设备接入整个系统,它根据你传进的sub, pub, adb-host, port寻找相应的zmq及adb-server。
所以,一台主机接入就启动一个provider服务,一个provider可以支持多个设备连接,每个provider为每个设备启一个worker process。(不理解的,反复阅读文档吧,读书百遍,其义自现)

4. 关于网络穿透

4.1 为什么需要网络穿透?

前文已述,因为服务器不能访问办公室机器,需要中间层做一个代理,即所谓的网络穿透,可以将访问通过代理连入办公机。

4.2 网络穿透的原理

  1. 网络穿透用的是frp,这个可以自己在github上找到
  2. frp分为服务端和客户端。
  3. 服务端bind端口即可,这是暴露给客户端链接用的。服务端的程序是frps,服务端的配置文件是frps.ini,修改frps.ini。
# frps.ini
[common]
bind_port = 7000
  1. 客户端连入服务端,指定服务端的ip地址,端口,以及tcp的设置。客户端程序是frpc,frpc.ini是配置文件。
[ssh]
type = tcp
local_ip = 127.0.0.1
local_port = 5037
remote_port = 6668
  1. type:连接类型,标识tcp,我们通过tcp与服务端通信。
  2. local_ip:这是本机的ip地址。
  3. local_port:实际需要监听的端口,因为adb的端口是5037,所以我们监听这个端口。
  4. remote_port:这个是STF服务端要调用的端口,通过这个端口连接,区分是机器的连入。 通过以上分析,思路基本就有了。

5. 接入方案

通过FRP做为网络中转,客户端通过FRP连入服务器,服务器接受到请求后自动创建provider。

6. 改造

6.1 客户端

  1. 保存修改网路穿透,API等配置信息。
  2. 启动本地frp服务。
  3. 连接远程服务器。

6.2 STF服务端

  1. 提供一个api借口。
  2. 接受客户端请求
  3. 为客户端创建provider。

7. 改造细节

7.1 客户端

7.1.1 客户端界面展示

7.1.2 客户端架构,用javaFX开发,包含了三个平台的FRP。

7.1.3 界面布局代码。

<?import javafx.scene.layout.GridPane?>
<?import javafx.scene.control.Button?>
<?import javafx.scene.control.Label?>
<?import javafx.scene.control.TextField?>
<?import javafx.scene.layout.HBox?>
<?import javafx.scene.text.Text?>
<?import javafx.scene.layout.VBox?>
<?import javafx.scene.control.Separator?>
<?import javafx.scene.control.TextArea?>
<GridPane fx:controller="sample.Controller"
          xmlns:fx="http://javafx.com/fxml" alignment="center" hgap="10" vgap="10">
    <Text text="Configuration" GridPane.columnIndex="0" GridPane.rowIndex="0" GridPane.columnSpan="2" />

    <Label text="Access Token:"  GridPane.rowIndex="1" GridPane.columnIndex="0" />
    <TextField fx:id="accessTokenTextField" GridPane.columnIndex="1" GridPane.rowIndex="1" />

    <Label text="API:" GridPane.columnIndex="0" GridPane.rowIndex="2" />
    <TextField GridPane.columnIndex="1" GridPane.rowIndex="2" fx:id="apiTextField"/>

    <Label text="Server Address:" GridPane.columnIndex="0" GridPane.rowIndex="3" />
    <TextField GridPane.columnIndex="1" GridPane.rowIndex="3" fx:id="serverAddrTextField"/>

    <Label text="Server Port:" GridPane.columnIndex="0" GridPane.rowIndex="4" />
    <TextField GridPane.columnIndex="1" GridPane.rowIndex="4" fx:id="serverPortTextField"/>

    <Label text="Type:" GridPane.columnIndex="0" GridPane.rowIndex="5" />
    <TextField fx:id="typeTextField" GridPane.columnIndex="1" GridPane.rowIndex="5" text="tcp" editable="false" disable="true"/>

    <Label text="Local IP:" GridPane.columnIndex="0" GridPane.rowIndex="6" />
    <TextField GridPane.columnIndex="1" GridPane.rowIndex="6" fx:id="localIpTextField"/>

    <Label text="Local ADB Port:" GridPane.columnIndex="0" GridPane.rowIndex="7" />
    <TextField GridPane.columnIndex="1" GridPane.rowIndex="7" fx:id="localAdbPortTextField"/>

    <Label text="Remote Port:" GridPane.columnIndex="0" GridPane.rowIndex="8" />
    <TextField GridPane.columnIndex="1" GridPane.rowIndex="8" fx:id="remotePortTextField"/>

    <HBox spacing="10" alignment="BOTTOM_RIGHT" GridPane.columnIndex="1" GridPane.rowIndex="9">
        <Button text="#1 Save Configuration" onAction="#handleSaveConfigAction" />
        <Button text="#2 Start Frp Client" onAction="#handleStartFrpClientAction" />
        <Button text="#3 Connect to Server" onAction="#handleConnectAction" />
    </HBox>

    <Label text="Config Status: " GridPane.columnIndex="0" GridPane.rowIndex="10" />
    <Text fx:id="configStatusText" GridPane.columnIndex="1" GridPane.columnSpan="2" GridPane.halignment="RIGHT" GridPane.rowIndex="10" />

    <Label text="Frp Status: " GridPane.columnIndex="0" GridPane.rowIndex="11" />
    <Text fx:id="frpStatusText" GridPane.columnIndex="1" GridPane.columnSpan="2" GridPane.halignment="RIGHT" GridPane.rowIndex="11" />

    <Label text="Server Status: " GridPane.columnIndex="0" GridPane.rowIndex="12" />
    <TextField fx:id="serverStatusTextField" GridPane.columnIndex="1" GridPane.columnSpan="2" GridPane.halignment="RIGHT" GridPane.rowIndex="12" />
</GridPane>

7.1.4 控制器代码(其他代码略)

package sample;

import frp.Common;
import frp.FRP;
import frp.Ssh;
import javafx.event.ActionEvent;
import javafx.fxml.FXML;
import javafx.fxml.Initializable;
import javafx.scene.control.TextField;
import javafx.scene.text.Text;
import stf.STF;
import util.PlatformUtil;
import util.PropertyUtil;

import java.net.URL;
import java.util.ResourceBundle;

public class Controller implements Initializable {
    // UI Fields
    @FXML
    private TextField accessTokenTextField;
    @FXML
    private TextField apiTextField;
    @FXML
    private TextField serverAddrTextField;
    @FXML
    private TextField serverPortTextField;
    @FXML
    private TextField typeTextField;
    @FXML
    private TextField localIpTextField;
    @FXML
    private TextField localAdbPortTextField;
    @FXML
    private TextField remotePortTextField;
    @FXML
    private Text configStatusText;
    @FXML
    private Text frpStatusText;
    @FXML
    private TextField serverStatusTextField;
    // Settings
    private PropertyUtil propUtil;
    private FRP frp;
    private Common commonSettings;
    private Ssh sshSettings;
    // STF
    private STF stf;

    public Controller() {
    }

    public void handleConnectAction(ActionEvent actionEvent) {
        try {
            saveAccessToken();
            saveApi();

            String api = propUtil.getApi();
            String json = "{\n"
                    + "\"computerName\": \"${computerName}\",\n"
                    + "\"userName\": \"${userName}\",\n"
                    + "\"remotePort\": ${remotePort}\n"
                    + "}";

            json = json.replace("${computerName}", PlatformUtil.getComputerName() == null ? "unknown" : PlatformUtil.getComputerName())
                    .replace("${userName}", PlatformUtil.getUserName() == null ? "unknwon" : PlatformUtil.getUserName())
                    .replace("${remotePort}", sshSettings.getRemotePort());

            String tmp = stf.connect(propUtil.getAccessToken(), api, json);
            serverStatusTextField.setText(tmp);
        } catch (Exception e) {
            serverStatusTextField.setText(e.getMessage());
        }
    }

    public void handleSaveConfigAction(ActionEvent actionEvent) {
        String serverAddr = serverAddrTextField.getText();
        String serverPort = serverPortTextField.getText();
        String type = typeTextField.getText();
        String localIp = localIpTextField.getText();
        String localAdbPort = localAdbPortTextField.getText();
        String remotePort = remotePortTextField.getText();

        commonSettings.setServerAddr(serverAddr);
        commonSettings.setServerPort(serverPort);

        sshSettings.setType(type);
        sshSettings.setLocalIp(localIp);
        sshSettings.setLocalPort(localAdbPort);
        sshSettings.setRemotePort(remotePort);

        try {
            saveAccessToken();
            saveApi();
            frp.save();
            configStatusText.setText("Save config succeed!");
        } catch (Exception e) {
            configStatusText.setText("Save config failed!");
        }
    }

    @Override
    public void initialize(URL location, ResourceBundle resources) {
        stf = new STF();

        propUtil = new PropertyUtil();
        accessTokenTextField.setText(propUtil.getAccessToken());
        apiTextField.setText(propUtil.getApi());

        frp = new FRP();
        commonSettings = frp.getCommonSettings();
        sshSettings = frp.getSshSettings();

        serverAddrTextField.setText(commonSettings.getServerAddr());
        serverPortTextField.setText(commonSettings.getServerPort());

        localIpTextField.setText(sshSettings.getLocalIp());
        localAdbPortTextField.setText(sshSettings.getLocalPort());
        remotePortTextField.setText(sshSettings.getRemotePort());
    }

    public void handleStartFrpClientAction(ActionEvent actionEvent) {
        try {
            frp.connect();
            frpStatusText.setText("FRP Client is running!");
        } catch (Exception e) {
            frpStatusText.setText("failed! " + e.getMessage());
        }
    }

    private void saveApi() {
        String api = apiTextField.getText().trim();
        propUtil.setApi(api);
    }

    private void saveAccessToken() {
        String accessToken = accessTokenTextField.getText().trim();
        propUtil.setAccessToken(accessToken);
    }
}

8. 服务端改造

  1. 服务端主要通过改造swagger生成控制器。
  2. 同时修改stf的db api,创建providers表用来存储为用户创建provider的信息。

8.1 swagger的改造

8.1.1 api_v1.yaml的修改

/providers:
  x-swagger-router-controller: provider
  post:
    summary: Create Provider
    description: Create one provider for each connection from client
    operationId: createProvider
    tags:
      - providers
    parameters:
      - name: clientInfo
        in: body
        description: Provider for client to create
        required: true
        schema:
          $ref: '#/definitions/CreateProviderPayload'
    responses:
      "200":
        description: Provider Created
        schema:
          $ref: "#/definitions/ProviderResponse"
    security:
      - accessTokenAuth: []

8.1.2 控制器的改造

新建一个provider.js控制器,创建如下代码。

var _ = require('lodash')
var Promise = require('bluebird')
var proc = require('child_process')

var procutil = require('../../../util/procutil')
var path = require('path')
var util = require('util')
var dbapi = require('../../../db/api')
var logger = require('../../../util/logger')

var log = logger.createLogger('api:controllers:provider')

module.exports = {
  createProvider: createProvider
}

function createProvider(req, res) {
  var computerName = req.body.computerName
  var userName = req.body.userName
  var remotePort = req.body.remotePort

  dbapi.loadProvider(remotePort)
    .then(function(provider) {
      if(provider) {
        return res.status(200).json({
          code: 1
        , message: 'Remote port already in use!'
        , provider: provider
        })
      }

      dbapi.saveProvider({
        remotePort: remotePort
        , computerName: computerName
        , userName: userName
      })
        .then(function(provider) {
          procutil.fork(path.resolve(__dirname, '..'), [
            'provider'
            , '--name', computerName
            , '--min-port', '7400'
            , '--max-port', '7700'
            , '--connect-sub', 'tcp://127.0.0.1:7114'
            , '--connect-push', 'tcp://127.0.0.1:7116'
            , '--group-timeout', '900'
            , '--public-ip', '192.168.177.15'
            , '--storage-url'
            , util.format('http://192.168.177.15:%d/', '80')
            , '--adb-host', '127.0.0.1'
            , '--adb-port', remotePort
            , '--vnc-initial-size', '600x800'
            , '--mute-master', 'never'
            , '--allow-remote'
          ])

          return res.status(200).json({
            code: 0
            , message: 'Established connection!'
            , provider: provider
          })
        })
    })
}

8.2 数据库的改造

数据库的改造比较简单

8.2.1 修改该tables.js代码,用来创建providers表。

, providers: {
    primaryKey: 'remotePort'
  }

8.2.2 修改该db/api.js代码,用来插入及查询provider信息。

dbapi.loadProvider = function(remotePort) {
  return db.run(r.table('providers').get(remotePort))
}

dbapi.saveProvider = function(provider) {
  return db.run(r.table('providers').insert({
    remotePort: provider.remotePort
  , computerName: provider.computerName
  , userName: provider.userName
  }))
}
  1. 这样,api改造就完毕了。客户端只要调用http://xxx.xxx.xxx/api/v1/providers就能创建provider,支持办公机接入。
  2. 由于接的是STF的security,所以访问前需要用户获取accessToken。
  3. 具体位置Settings->Keys里面。

后记

本文一开始说了两个方案,个人希望尝试一下方案二,这样就可以减轻服务端的压力,办公机器也撑得起这点进程开销。后续再改吧。
另,如果需要客户端完整代码的话,我会贴在评论区里面。

附言 1  ·  2018年01月29日
  1. 实测有bug,Provider如果reaper的心跳检测adb server超时自动退出。
  2. 所以数据库落库落客户端provider信息的这个逻辑去掉了。
  3. 因为之前担心多台机器用同一个端口会有问题,目前看上去FRP会报错。
  4. 同时,当provider断线以后,用户可以自己在创建。
  5. 最终的Swagger API改成只接受请求创建provider,其他啥也不做了。
如果觉得我的文章对您有用,请随意打赏。您的支持将鼓励我继续创作!
共收到 22 条回复 时间 点赞

mark一下。

Lihuazhang 将本帖设为了精华贴 01月29日 12:55

嗯,今年打算也做这个,到时候参考一下,mark一下。

STF本身是支持挂载远端机的,

比较好奇稳定性如何。

之前因为公司原因,主机和 provider 分开在两个大厦,provider 和主机之间掉线频率很高。

chenhengjie123 回复

你这个场景非常好。目前我也在想。初步想可能是reaper的心跳超时后重启provider,目前不敢定论,还没具体实施到这一步。或者修改provider的on exit事件。也不是很确定,也还是没有具体实践到这部分。恒洁老板如果有好的建议,欢迎指点。

carl 回复

对啊,在server那边输入一条命令就可以了,没必要搞这么麻烦

lucasluo 回复

建议可以先用原生的 provider 试试。provider 和 stf 主服务之间的网络稳定性还是有一定要求的,稳定性太差会导致掉线频繁,用起来很不爽。

我们其实也没有特意去解决,公司搬了之后大家都在一个办公楼,就没问题了。

我之前公司也是跨楼的,但网络也还好,主要还是看运维怎搭建的网吧。

0x88 回复

恩,你说的对。
做这么个包装,是为了让用户决定自己什么时候可连。
服务端也可以自动创建,不需要频繁登录服务器。
几百号人(可能拥有不止一台电脑)的管理成本也相对较大。
相当于自动化运维了。🤓

之前试过上海接入杭州,必须上行带宽给力,不然延迟的厉害,然并卵

mling 回复

恩,多谢前车之鉴。我还是走在了您这位巨人的肩膀上。

嗯,不同地区如果网络弄不好的话,会延迟很厉害

以前我也弄过类似的方案。当时自己手写了一个穿墙代理,穿墙代理的服务端收到请求后,自动启动一个provider。provider启动多了,有点耗资源。

codeskyblue 回复

好的,多谢,确实耗资源。目前您有什么好的方案么?不吝赐教。:-)

lucasluo 回复

后来我就把这个服务关掉了

codeskyblue 回复

多谢。

mark,有机会尝试一下方案二

wuhenyan 线下班第二期 Bash 教程 中提及了此贴 02月03日 21:06

楼主,jclinet 开源吗?

carl 回复

这个怎么挂内网手机?

lucasluo 回复

楼主,你这个公网挂载内网设备,运行效果如何?

menglinxi 回复

目前还可以。取决于网络带宽。目前还处于小规模运行阶段,还没有性能瓶颈问题。
provider在adb断了之后进程未退出,年后回来看看,现在休假了。
其他凑合。

需要 登录 后方可回复, 如果你还没有账号请点击这里 注册