飞牛OS会给大家提供一个FN ID,这个ID会根据网络情况自动切换使用内网、外网或者中继转发,我一直好奇是怎么实现的,于是看了看飞牛FN Connect的前端代码。

获取相关信息

FnOS会通过/api/v1/fn/con请求获取你设备上传的信息,比如:内网ip,公网ip,ddns域名,飞牛提供的域名,设备的端口等等。

返回信息大概长这样:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
{
    "msg": "",
    "code": 0,
    "data": {
        "ddns": null,
        "ipv4": [
            "192.168.2.xxx"
        ],
        "ipv6": [],
        "publicIpv4": [
            "39.xx.xx.xx",
        ],
        "publicIpv6": [],
        "fn": [
            "xxxx.5ddd.com:443"
        ],
        "port": {
            "httpsPort": 8001,
            "httpPort": 8002
        },
        "checkSum": "24860",
        "ver": "1.6.0"
    }
}

可以通过前端抓包获取,也可以通过下面这段代码获取

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import hashlib
import time
import random
import json
import requests

PREFIX = "NDzZTVxnRKP8Z0jXg1VAMonaG8akvh" 
API_KEY = "zIGtkc3dqZnJpd29qZXJqa2w7c"
FN_ID = "xxxx" # 改成你的FN ID
TARGET_URL = "/api/v1/fn/con"
FULL_URL = "https://5ddd.com/api/v1/fn/con"

def get_md5(s):
    return hashlib.md5(s.encode('utf-8')).hexdigest()

def get_sha256(s):
    return hashlib.sha256(s.encode('utf-8')).hexdigest()

def get_authx_headers(json_payload, fn_id):
    nonce = str(random.randint(100000, 999999))
    timestamp = str(int(time.time() * 1000))
    payload_str = json.dumps(json_payload, separators=(',', ':'))
    payload_md5 = get_md5(payload_str)
    sign_source = "_".join([PREFIX, TARGET_URL, nonce, timestamp, payload_md5, API_KEY])
    sign = get_md5(sign_source)
    authx_value = f"nonce={nonce}&timestamp={timestamp}&sign={sign}"
    fn_sign_source = f"trim_connect`{fn_id}`{timestamp}`anna"
    fn_sign_value = get_sha256(fn_sign_source)

    return {
        'authx': authx_value,
        'fn-sign': fn_sign_value,
        'Content-Type': 'application/json'
    }

data = {
    'fnId': FN_ID,
}

custom_headers = get_authx_headers(data, FN_ID)
response = requests.post(FULL_URL, headers=custom_headers, data=json.dumps(data, separators=(',', ':')))
print(response.text)

连通性测试

获取到收集到的IP之后,需要进行连通性测试,才能进行跳转。飞牛以版本1.4.0为分界线,大于等于1.4.0的会使用STUN方法进行检测,小于1.4.0使用Iframe Ping方法进行检测。

STUN

image-20251123135114823

飞牛会以你的所有内网IP + 系统设置中的httpsPort作为STUN服务器,并行尝试进行连接,通过promiseAny获取第一个成功响应的连接,并且跳转到 IP + httpPort 。(这里应该是考虑内网环境下,不需要加密)

image-20251123135500820

具体来说,就是通过 WebRTC 连接到你的IP+https端口,判断通过 STUN 服务器反射得到的地址的端口是否为发出的端口,如果是则说明是通的。

image-20251123134950855

这时候就好奇了,我的https端口不是监听着web服务吗,怎么能作为STUN服务器呢?

其实飞牛这里将TCP和UDP分开监听了,TCP作为web服务,UDP作为STUN服务器了,并且由一个叫pxy的程序监听。

image-20251123141032918

而对于json中提供的所有公网IP,在测试内网ip不通后,也会进行STUN测试,不过最后并不会直接跳转,而是展示出来,让你选择一个。

image-20251123191059161

Iframe Ping

这个方法的思路就很简单,通过创建一个iframe去访问nas上的/static/bridge.html?t=${Date.now()},在这个iframe中请求/trimfn获取飞牛ID,将自己的IP和飞牛ID一并通过parent.postMessage发送给外层的页面,来达到判断通信成功。

image-20251123143003512

image-20251123143418046

接口测试

对于DDNS和官方提供的Relay,会尝试访问/trimcon,判断返回的状态码是否为200或204,如果成功,则说明是通的。

image-20251123191416930

不过这里有一个很奇怪的点,在系统中设置DDNS之后,使用api并没有把DDNS的域名加载出来,始终为null,不知道是我设置错了还是怎么样。

总结

在浏览器中由于跨域的存在,我们需要不在另一端进行配置跨域就检测某个网页是否正常运行,直接请求肯定是不行的。飞牛提供了两个思路,一个需要额外运行STUN服务器程序并开放UDP,一个通过iframe绕过了跨域。但是飞牛为什么不能直接使用一个api,配置仅允许飞牛的域名进行跨域,直接进行检测呢?目前没有想到这两个方案的优势在哪。

同时通过分析我们可以发现,我们如果想要成功使用FNID连接上我们的设备,需要开放HTTP端口的TCP通信,HTTPS端口的TCP和UDP通信,否则就会跳转到飞牛官方的最终兜底策略中继转发。与此同时,飞牛的手机APP使用的策略似乎与网页不同,手机APP的HTTPS端口只需要开放TCP,就可以使用公网进行连接。