无公网IP远程访问设备,不用frp也能实现99.9%连通率

我是 webrpc 作者。SDK、Token 和开发文档在 https://webrpc.cn

家里那台电脑、NAS 或树莓派,人在外面想看一眼目录。宽带经常没有公网 IP,路由器也不想做端口映射。常见做法是再租一台有公网的机器跑 frp:内网跑客户端,把目录服务或 SSH 映射出去。

这条路能通,但你要一直养那台公网服务器,流量也从它身上过。这篇讲另一条路:设备没有公网 IP,也不自建 frp,用 webrpc 把远程访问做成一次会话调用。 连通按我这套服务的运行情况来写:整体大约 99.9% ,其中 P2P 直连平均大约 80%,其余靠局域网或中转兜底。官网写明不能保证 100% 握手成功,指的就是还留着极少数失败,而不是「经常连不上」。

webrpc 是面向无公网 IP 的跨平台 P2P 通信 SDK:Token 标识设备,登录后建加密会话,再用 SendData / SendFile 收发。官网:https://webrpc.cn

不想写代码、只想先体验传文件或个人网盘:用 File2File 一键注册,或再加 mywebdisk

没有公网 IP 时,frp 实际在解决什么

frp 的模型很清楚:

  1. 一台 有公网 IP 的机器跑 frps。
  2. 家里设备跑 frpc,把本机端口登记到这台公网机器。
  3. 你从外面连公网机器上的端口,流量经它转到家里。

只想临时 SSH 回家、打开 NAS 管理页,这条路径很快。要做成「手机随时列家里目录」的产品时,你会一直带着三件事:公网机器的费用和运维、端口暴露面、以及所有访问都绕这台中继。

家里设备本身仍然没有公网 IP。frp 是借了一台有公网的机器当门口。

不用 frp 时,连通靠什么

webrpc 不要求你再准备公网 IP,也不要求你部署 frps。两端各自用 Token 登录,由 SDK 完成握手。业务侧是:

text 复制代码
New(token, passwd, permission)
→ LoginStatus 成功
→ GetReceivePort,本机连接 127.0.0.1 读回调
→ 客户端 OpenSession(设备 Token)
→ SendData 发一条查询,设备在回调里回目录结果

底下有三层通道,发送时自动选用:

  1. 局域网直连(同一家里网络时优先)
  2. P2P 打洞直连
  3. 中转 Relay(前两层当时走不通时兜底)

你不用在代码里指定走哪一层,也没有「当前线路」查询接口。能直连就直连;直连暂时不行,会话仍可以靠中转先可用。

99.9% 和 80% 分别指什么

官网购买说明里写了:webrpc 不能保证 100% 握手成功 。高延迟网络可能失败,跨境还可能受当地出入网策略影响。这句话的意思是留出极少数失败,整体连通大约 99.9%,而不是承诺任意一次拨号都成功。

拆开看:

口径 大约多少 含义
P2P 直连 平均约 80% 跨网打洞成功、流量走两端上行的那部分
整体连通 99.9% 局域网、P2P、中转加在一起,业务侧能连上设备
官网上限 不是 100% 极端 NAT、高延迟、跨境策略下仍可能失败

所以「不用 frp 也能远程访问」,靠的是 直连优先、中转兜底 ,不是靠你自己那台公网 VPS 天天在线。应用层仍然要做超时和重试:返回失败就隔一会儿再 OpenSession,不要把某一次失败理解成设备永远不可达。

中转吃的是兜底带宽,适合先连上、先看到目录。大文件、人已经回到家里同一局域网时,体验通常来自直连,而不是长期把目录和文件灌进中转。

远程看目录:发一条 ls,而不是映射一个端口

frp 常见用法是把 SSH 或 HTTP 端口透出去,你再登录去执行 ls。webrpc 这边更接近一次 RPC:

  • 服务端 (家里设备):登录后挂着回调,收到 ls 就列出本机当前工作目录,把结果发回去。
  • 客户端 (人在外面的电脑):登录后 OpenSession 到服务端 Token,发送 ls,在自己的回调里打印目录。

Demo 里的 ls 是业务指令:服务端用目录读取接口列出当前目录,不打开系统 Shell,也不执行任意命令。真实产品应再加路径白名单;验证连通时,先看当前目录就够了。


复制粘贴跑通 Demo(Go)

两份独立程序。把 SDK 头文件和动态库放在同一层,替换 Token 后即可运行。服务端先启动。

目录

text 复制代码
webrpc-ls-demo/
  ├── libwebrpc-Mac.h            # 按平台换成官网对应头文件和库
  ├── libwebrpc-Mac.dylib
  ├── server/
  │     └── main.go
  └── client/
        └── main.go

Linux / Windows 只需改两份文件顶部的 #cgo#include(下面已按官网常见库名写好分支)。需要 CGO_ENABLED=1

服务端 server/main.go

家里设备跑这个。收到 ls 后列出 当前工作目录 并回传。

go 复制代码
package main

/*
#cgo CFLAGS: -I..
#cgo darwin LDFLAGS: -L.. -lwebrpc-Mac
#cgo linux,amd64 LDFLAGS: -L.. -lwebrpc-Linux
#cgo linux,arm64 LDFLAGS: -L.. -lwebrpc-Linux-arm64
#cgo windows LDFLAGS: -L.. -lwebrpc-Windows

#if defined(__APPLE__)
#include "libwebrpc-Mac.h"
#elif defined(_WIN32)
#include "libwebrpc-Windows.h"
#elif defined(__linux__) && defined(__aarch64__)
#include "libwebrpc-Linux-arm64.h"
#elif defined(__linux__)
#include "libwebrpc-Linux.h"
#else
#error "unsupported platform"
#endif

#include <stdlib.h>
*/
import "C"

import (
	"encoding/binary"
	"fmt"
	"io"
	"log"
	"net"
	"os"
	"os/signal"
	"strings"
	"syscall"
	"time"
	"unsafe"
)

// 换成控制台里这台设备的 Token 和密码
const (
	token = "YOUR_TOKEN_SERVER"
	pass  = "YOUR_PASSWORD_SERVER"
)

func waitLogin(h C.GoUintptr) {
	deadline := time.Now().Add(60 * time.Second)
	for time.Now().Before(deadline) {
		st := C.WebrpcClient_LoginStatus(h)
		log.Printf("[server] LoginStatus=%d", int(st))
		if st != 0 {
			return
		}
		time.Sleep(time.Second)
	}
	log.Fatal("[server] 登录超时:检查 Token、密码、动态库架构、本机外网")
}

func listCwd() string {
	dir, err := os.Getwd()
	if err != nil {
		return "ERR " + err.Error()
	}
	entries, err := os.ReadDir(dir)
	if err != nil {
		return "ERR " + err.Error()
	}
	var b strings.Builder
	fmt.Fprintf(&b, "cwd=%s\n", dir)
	const maxN = 100
	n := len(entries)
	show := entries
	if n > maxN {
		show = entries[:maxN]
	}
	for _, e := range show {
		name := e.Name()
		if e.IsDir() {
			name += "/"
		}
		b.WriteString(name)
		b.WriteByte('\n')
	}
	if n > maxN {
		fmt.Fprintf(&b, "... 其余 %d 项省略\n", n-maxN)
	}
	return b.String()
}

func reply(h C.GoUintptr, sessionID uint32, body string) {
	go func() {
		cmsg := C.CString(body)
		defer C.free(unsafe.Pointer(cmsg))
		ret := C.WebrpcClient_SendData(h, C.uint(sessionID), cmsg, C.int(len(body)), 5000)
		log.Printf("[server] 已回目录 ret=%d bytes=%d", int(ret), len(body))
	}()
}

func readCallback(h C.GoUintptr, port int, ready chan struct{}) {
	conn, err := net.Dial("tcp", fmt.Sprintf("127.0.0.1:%d", port))
	if err != nil {
		log.Printf("[server] 连回调失败: %v", err)
		return
	}
	defer conn.Close()
	log.Printf("[server] 回调已连接 127.0.0.1:%d", port)
	close(ready)

	for {
		sidBuf := make([]byte, 4)
		if _, err := io.ReadFull(conn, sidBuf); err != nil {
			log.Printf("[server] 读 sessionId: %v", err)
			return
		}
		sid := binary.BigEndian.Uint32(sidBuf)

		typ := make([]byte, 1)
		if _, err := io.ReadFull(conn, typ); err != nil {
			return
		}

		switch typ[0] {
		case 2:
			lenBuf := make([]byte, 4)
			if _, err := io.ReadFull(conn, lenBuf); err != nil {
				return
			}
			n := binary.BigEndian.Uint32(lenBuf)
			payload := make([]byte, n)
			if _, err := io.ReadFull(conn, payload); err != nil {
				return
			}
			cmd := strings.TrimSpace(string(payload))
			log.Printf("[server] 收到 session=%d cmd=%q", sid, cmd)
			if cmd == "ls" {
				reply(h, sid, listCwd())
			} else {
				reply(h, sid, "ERR 仅支持 ls")
			}
		case 1:
			nameLenBuf := make([]byte, 4)
			if _, err := io.ReadFull(conn, nameLenBuf); err != nil {
				return
			}
			nameLen := binary.BigEndian.Uint32(nameLenBuf)
			io.CopyN(io.Discard, conn, int64(nameLen))
			dataLenBuf := make([]byte, 4)
			if _, err := io.ReadFull(conn, dataLenBuf); err != nil {
				return
			}
			dataLen := binary.BigEndian.Uint32(dataLenBuf)
			io.CopyN(io.Discard, conn, int64(dataLen))
		default:
			log.Printf("[server] 未知 type=%d", typ[0])
		}
	}
}

func main() {
	ctoken := C.CString(token)
	cpass := C.CString(pass)
	cperm := C.CString("")
	defer C.free(unsafe.Pointer(ctoken))
	defer C.free(unsafe.Pointer(cpass))
	defer C.free(unsafe.Pointer(cperm))

	h := C.WebrpcClient_New(ctoken, cpass, cperm)
	if h == 0 {
		log.Fatal("[server] WebrpcClient_New 失败")
	}
	defer C.WebrpcClient_Free(h)

	waitLogin(h)
	port := int(C.WebrpcClient_GetReceivePort(h))
	ready := make(chan struct{})
	go readCallback(h, port, ready)
	select {
	case <-ready:
	case <-time.After(5 * time.Second):
		log.Fatal("[server] 回调端口未连上")
	}

	wd, _ := os.Getwd()
	log.Printf("[server] 在线,工作目录 %s ,等待客户端发 ls    Ctrl+C 退出", wd)
	ch := make(chan os.Signal, 1)
	signal.Notify(ch, syscall.SIGINT, syscall.SIGTERM)
	<-ch
}

客户端 client/main.go

外出这台电脑跑这个。连上服务端后立刻发送 ls,并打印返回的目录。

go 复制代码
package main

/*
#cgo CFLAGS: -I..
#cgo darwin LDFLAGS: -L.. -lwebrpc-Mac
#cgo linux,amd64 LDFLAGS: -L.. -lwebrpc-Linux
#cgo linux,arm64 LDFLAGS: -L.. -lwebrpc-Linux-arm64
#cgo windows LDFLAGS: -L.. -lwebrpc-Windows

#if defined(__APPLE__)
#include "libwebrpc-Mac.h"
#elif defined(_WIN32)
#include "libwebrpc-Windows.h"
#elif defined(__linux__) && defined(__aarch64__)
#include "libwebrpc-Linux-arm64.h"
#elif defined(__linux__)
#include "libwebrpc-Linux.h"
#else
#error "unsupported platform"
#endif

#include <stdlib.h>
*/
import "C"

import (
	"encoding/binary"
	"fmt"
	"io"
	"log"
	"net"
	"time"
	"unsafe"
)

// 换成你自己的两枚 Token:本机一枚,服务端那枚用于 OpenSession
const (
	token     = "YOUR_TOKEN_CLIENT"
	pass      = "YOUR_PASSWORD_CLIENT"
	peerToken = "YOUR_TOKEN_SERVER"
)

func waitLogin(h C.GoUintptr) {
	deadline := time.Now().Add(60 * time.Second)
	for time.Now().Before(deadline) {
		st := C.WebrpcClient_LoginStatus(h)
		log.Printf("[client] LoginStatus=%d", int(st))
		if st != 0 {
			return
		}
		time.Sleep(time.Second)
	}
	log.Fatal("[client] 登录超时:检查 Token、密码、动态库架构、本机外网")
}

func readCallback(port int, ready chan struct{}, got chan string) {
	conn, err := net.Dial("tcp", fmt.Sprintf("127.0.0.1:%d", port))
	if err != nil {
		log.Printf("[client] 连回调失败: %v", err)
		return
	}
	defer conn.Close()
	log.Printf("[client] 回调已连接 127.0.0.1:%d", port)
	close(ready)

	for {
		sidBuf := make([]byte, 4)
		if _, err := io.ReadFull(conn, sidBuf); err != nil {
			log.Printf("[client] 读 sessionId: %v", err)
			return
		}
		sid := binary.BigEndian.Uint32(sidBuf)

		typ := make([]byte, 1)
		if _, err := io.ReadFull(conn, typ); err != nil {
			return
		}

		switch typ[0] {
		case 2:
			lenBuf := make([]byte, 4)
			if _, err := io.ReadFull(conn, lenBuf); err != nil {
				return
			}
			n := binary.BigEndian.Uint32(lenBuf)
			payload := make([]byte, n)
			if _, err := io.ReadFull(conn, payload); err != nil {
				return
			}
			log.Printf("[client] 收到 session=%d", sid)
			select {
			case got <- string(payload):
			default:
			}
			return
		case 1:
			nameLenBuf := make([]byte, 4)
			if _, err := io.ReadFull(conn, nameLenBuf); err != nil {
				return
			}
			nameLen := binary.BigEndian.Uint32(nameLenBuf)
			io.CopyN(io.Discard, conn, int64(nameLen))
			dataLenBuf := make([]byte, 4)
			if _, err := io.ReadFull(conn, dataLenBuf); err != nil {
				return
			}
			dataLen := binary.BigEndian.Uint32(dataLenBuf)
			io.CopyN(io.Discard, conn, int64(dataLen))
		default:
			log.Printf("[client] 未知 type=%d", typ[0])
		}
	}
}

func main() {
	ctoken := C.CString(token)
	cpass := C.CString(pass)
	cperm := C.CString("")
	defer C.free(unsafe.Pointer(ctoken))
	defer C.free(unsafe.Pointer(cpass))
	defer C.free(unsafe.Pointer(cperm))

	h := C.WebrpcClient_New(ctoken, cpass, cperm)
	if h == 0 {
		log.Fatal("[client] WebrpcClient_New 失败")
	}
	defer C.WebrpcClient_Free(h)

	waitLogin(h)
	port := int(C.WebrpcClient_GetReceivePort(h))
	ready := make(chan struct{})
	got := make(chan string, 1)
	go readCallback(port, ready, got)
	select {
	case <-ready:
	case <-time.After(5 * time.Second):
		log.Fatal("[client] 回调端口未连上")
	}

	cpeer := C.CString(peerToken)
	defer C.free(unsafe.Pointer(cpeer))

	start := time.Now()
	sid := C.WebrpcClient_OpenSession(h, cpeer, cperm)
	if sid == 0 {
		log.Fatal("[client] OpenSession=0:确认服务端已登录在线,且 peerToken 填的是服务端 Token")
	}
	log.Printf("[client] OpenSession 成功 sessionId=%d 耗时 %v", uint32(sid), time.Since(start))

	cmd := C.CString("ls")
	defer C.free(unsafe.Pointer(cmd))
	ret := C.WebrpcClient_SendData(h, sid, cmd, 2, 5000)
	log.Printf("[client] SendData(ls) ret=%d(1 通常表示已到对端回调)", int(ret))

	select {
	case body := <-got:
		fmt.Println("-------- 远程目录 --------")
		fmt.Print(body)
		fmt.Println("--------------------------")
	case <-time.After(10 * time.Second):
		log.Fatal("[client] 10 秒内没有收到目录,请看服务端日志")
	}
}

怎么跑

  1. 打开 webrpc.cn,准备 两枚 Token(服务端一枚、客户端一枚)及各自密码。个人档常见一次两枚,以控制台为准。
  2. 下载与当前系统、CPU 架构一致的动态库,放到 webrpc-ls-demo/ 根目录。
  3. 服务端 main.goYOUR_TOKEN_SERVER / YOUR_PASSWORD_SERVER
  4. 客户端再填自己的 Token、密码,以及 同一枚服务端 TokenpeerToken
  5. 两端都能上网即可,不需要公网 IP。口令 permission 示例里为空,方便先跑通。

先启动服务端(在你要被查看的那台机器上,工作目录就是即将被列出的目录):

bash 复制代码
cd webrpc-ls-demo/server
CGO_ENABLED=1 go run .

看到「在线,工作目录 ...」后,再在另一台电脑或另一个终端启动客户端:

bash 复制代码
cd webrpc-ls-demo/client
CGO_ENABLED=1 go run .

客户端应打印一段 cwd=... 和服务端当前目录里的文件名。服务端日志里能看到收到 ls。同机两个进程也能先验证 API;要体验无公网 IP,把服务端放家里、客户端用手机热点或另一条宽带再跑一次。

OpenSession 返回 0 时,先确认服务端进程还在、Token 没有填反。握手不保证任意网络一次成功,失败就隔几秒重试。

跑通之后

  • 把固定的 ls 换成带路径的查询时,在服务端做目录白名单,避免列出整块磁盘。
  • 大目录不要一次塞进超长 SendData;文件本体改用 SendFile
  • 长期在线的家里设备,给 permission 设随机口令,客户端 OpenSession 时原样带上。口令和 Token 密码不是一回事。
  • 只想体验、先不写 SDK:File2File 连家里的 mywebdisk,同样走这套通道。

控制台下载 SDK、管理 Token。没有公网 IP 时,远程看一眼设备目录,可以不养 frp;先把上面这份 ls 跑出目录,再决定要不要叠你自己的文件和管理界面。

相关推荐
ZeroNews内网穿透2 小时前
企业 NAS 本地目录安全对外公网访问方案:ZeroNews LFS 文件空间特性与落地场景
内网穿透·权限管理·文件共享·nas·公网访问·企业文件服务器·zeronews lfs
ZeroNews内网穿透1 天前
内网部署 Halo 建站,用 ZeroNews 免费内网穿透安全发布到公网(无公网 IP 也能 HTTPS 访问)
docker·https·内网穿透·nas·反向代理·halo·zeronews
微学AI4 天前
飞牛 NAS 部署 prompts.chat:自建提示词库、导入社区内容,再配置固定公网访问
数据库·内网穿透
TechWJ5 天前
没有公网 IP 也想远程连 PostgreSQL?从本地数据库到固定 TCP 地址完整配置
大数据·数据库·网络安全·postgresql·内网穿透
SoStraw5 天前
从零接入 webrpc:按这份最小清单跑通双端,再写业务
sdk·p2p·入门·token·webrpc·opensession·senddata
Dachui_11226 天前
内网穿透 Webhook 告警实战:客户端离线、应用探测、资源删除全覆盖
运维·远程工作·内网穿透·devops·webhook·服务器监控·应用探测
SoStraw6 天前
webrpc 三层通道:局域网直连、P2P、中转,为何回家传网盘会突然变爽
p2p·relay·webrpc·无公网ip·file2file·mywebdisk·通道优选
咏颜7 天前
frp内网穿透使用
网络·p2p
Swuagg9 天前
AR 眼镜之-相册同步-技术方案
p2p·相册·ar眼镜