我是 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 的模型很清楚:
- 一台 有公网 IP 的机器跑 frps。
- 家里设备跑 frpc,把本机端口登记到这台公网机器。
- 你从外面连公网机器上的端口,流量经它转到家里。
只想临时 SSH 回家、打开 NAS 管理页,这条路径很快。要做成「手机随时列家里目录」的产品时,你会一直带着三件事:公网机器的费用和运维、端口暴露面、以及所有访问都绕这台中继。
家里设备本身仍然没有公网 IP。frp 是借了一台有公网的机器当门口。
不用 frp 时,连通靠什么
webrpc 不要求你再准备公网 IP,也不要求你部署 frps。两端各自用 Token 登录,由 SDK 完成握手。业务侧是:
text
New(token, passwd, permission)
→ LoginStatus 成功
→ GetReceivePort,本机连接 127.0.0.1 读回调
→ 客户端 OpenSession(设备 Token)
→ SendData 发一条查询,设备在回调里回目录结果
底下有三层通道,发送时自动选用:
- 局域网直连(同一家里网络时优先)
- P2P 打洞直连
- 中转 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 秒内没有收到目录,请看服务端日志")
}
}
怎么跑
- 打开 webrpc.cn,准备 两枚 Token(服务端一枚、客户端一枚)及各自密码。个人档常见一次两枚,以控制台为准。
- 下载与当前系统、CPU 架构一致的动态库,放到
webrpc-ls-demo/根目录。 - 服务端
main.go填YOUR_TOKEN_SERVER/YOUR_PASSWORD_SERVER。 - 客户端再填自己的 Token、密码,以及 同一枚服务端 Token 到
peerToken。 - 两端都能上网即可,不需要公网 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 跑出目录,再决定要不要叠你自己的文件和管理界面。