// Package tsnetbridge 是 tsnet 的最小 gomobile 桥接探针(M1 可行性验证)。 // 目标:证明 tsnet 能经 gomobile bind 编译到 iOS(retire R5:Go+C+++Swift 三运行时共存)。 // 真实的多节点/DialerRouter/fd 桥实现见设计文档,后续填充。 package tsnetbridge import ( "context" "encoding/json" "io" "net" "os" "strconv" "sync" "sync/atomic" "time" "golang.org/x/sys/unix" "tailscale.com/tsnet" ) // Node 包装一个嵌入式 tsnet 节点。gomobile 导出为引用型对象。 type Node struct { srv *tsnet.Server } // NewNode 创建(不启动)一个 tsnet 节点。stateDir 唯一、hostname 展示在管理台。 func NewNode(stateDir, hostname string) *Node { return &Node{srv: &tsnet.Server{ Dir: stateDir, Hostname: hostname, Ephemeral: false, }} } // UpWithAuthKey 用 auth key 启动并等待 Running(timeoutMs 到则返回 error)。 // gomobile 友好签名:基本类型入参、error 出参。 func (n *Node) UpWithAuthKey(authKey string, timeoutMs int) error { n.srv.AuthKey = authKey ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeoutMs)*time.Millisecond) defer cancel() _, err := n.srv.Up(ctx) return err } // SelfIP 返回本节点的 Tailscale IPv4(经 LocalClient().Status() 取;未就绪返回空串)。 func (n *Node) SelfIP() string { lc, err := n.srv.LocalClient() if err != nil { return "" } st, err := lc.Status(context.Background()) if err != nil || st.Self == nil { return "" } for _, ip := range st.Self.TailscaleIPs { if ip.Is4() { return ip.String() } } return "" } // DialTCPFD 经 tsnet 连接 tailnet 内 host:port,返回一个已连接的 socket fd 供 libssh2 使用。 // 实现:tsnet.Dial 得到 net.Conn(netstack 虚拟连接,无真实 fd)→ socketpair(AF_UNIX) → // 两条 goroutine 双向 io.Copy 泵 → 把 socketpair 的另一端 fd 返回给 Swift。 // Swift 侧 close(fd) 会触发泵结束并关闭 tsConn。返回 -1 + error 表示失败。 func (n *Node) DialTCPFD(hostOrIP string, port int, timeoutMs int) (int64, error) { ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeoutMs)*time.Millisecond) defer cancel() tsConn, err := n.srv.Dial(ctx, "tcp", net.JoinHostPort(hostOrIP, strconv.Itoa(port))) if err != nil { return -1, err } fds, err := unix.Socketpair(unix.AF_UNIX, unix.SOCK_STREAM, 0) if err != nil { tsConn.Close() return -1, err } goFile := os.NewFile(uintptr(fds[0]), "ts-pump") goConn, err := net.FileConn(goFile) goFile.Close() if err != nil { tsConn.Close() unix.Close(fds[0]) unix.Close(fds[1]) return -1, err } // 双向泵:任一方向结束即关闭两端。 go func() { io.Copy(goConn, tsConn) goConn.Close() tsConn.Close() }() go func() { io.Copy(tsConn, goConn) tsConn.Close() goConn.Close() }() return int64(fds[1]), nil } // MoshRelay 是一条 mosh-over-tsnet 的 UDP 中继: // mosh-client 在本地把 UDP 包发到 127.0.0.1:,本中继逐包经 tsnet 转发到 // tailnet 内 host:moshPort,回包再转回"最近一次上行的 client 源地址"(吸收 mosh 的 roaming/端口跳变)。 // 与 DialTCPFD 的差异:mosh 内部自建 UDP socket 并 connect(),无法注入外部 fd,故用 loopback relay。 type MoshRelay struct { mu sync.Mutex local *net.UDPConn // 当前 loopback socket;Rebind 在同端口替换(关旧 socket 令旧上行泵退出) localPort int // 固定本地端口(mosh 始终发往此端口,Rebind 保持不变) tsConn net.Conn // tsnet netstack UDP 虚拟连接(纯内存,进程冻结无损,无需重建) lastCli atomic.Pointer[net.UDPAddr] closed bool } // StartMoshRelay 建立中继并启动双向泵,返回句柄。Swift 用 LocalPort() 作为 mosh_main 的 port // 参数、ip 传 "127.0.0.1"。失败返回 nil + error。timeoutMs 限制 tsnet UDP Dial 的建连等待。 func (n *Node) StartMoshRelay(host string, moshPort int, timeoutMs int) (*MoshRelay, error) { ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeoutMs)*time.Millisecond) defer cancel() tsConn, err := n.srv.Dial(ctx, "udp", net.JoinHostPort(host, strconv.Itoa(moshPort))) if err != nil { return nil, err } local, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0}) if err != nil { tsConn.Close() return nil, err } r := &MoshRelay{ local: local, localPort: local.LocalAddr().(*net.UDPAddr).Port, tsConn: tsConn, } go r.pumpUp(local) // 上行泵(随具体 socket 生成/退出,Rebind 会换新泵) go r.pumpDown() // 下行泵(绑定稳定 tsConn,跨 Rebind 存活) return r, nil } // pumpUp:client(loopback) → tsnet(server)。绑定某个具体 socket;该 socket 关闭即退出 // (Rebind/Close 触发)。记录最近 client 源地址供下行回送(吸收 mosh 的 hop_port 换端口)。 func (r *MoshRelay) pumpUp(local *net.UDPConn) { buf := make([]byte, 65536) for { nr, cli, err := local.ReadFromUDP(buf) if nr > 0 { r.lastCli.Store(cli) if _, werr := r.tsConn.Write(buf[:nr]); werr != nil { return } } if err != nil { return } } } // pumpDown:tsnet(server) → client(最近 loopback 源地址)。绑定稳定 tsConn,跨 Rebind 存活; // 每次写入"当前"socket(Rebind 后自动跟上)。逐报文保边界。 func (r *MoshRelay) pumpDown() { buf := make([]byte, 65536) for { nr, err := r.tsConn.Read(buf) if nr > 0 { if cli := r.lastCli.Load(); cli != nil { r.mu.Lock() l := r.local r.mu.Unlock() if l != nil { l.WriteToUDP(buf[:nr], cli) } } } if err != nil { return } } } // LocalPort 返回固定的 loopback relay 端口(mosh_main 的 port 参数,跨 Rebind 不变)。 func (r *MoshRelay) LocalPort() int { return r.localPort } // Rebind 在同端口重开 loopback socket(iOS 前台恢复用:挂起后旧 socket 可能被系统回收为 defunct)。 // 关旧 socket → 旧上行泵退出 → 新泵接管;下行泵绑定稳定 tsConn 无需重启。端口被占等返回 error(由上层升级为全量重建)。 func (r *MoshRelay) Rebind() error { r.mu.Lock() defer r.mu.Unlock() if r.closed { return nil } if r.local != nil { r.local.Close() // 释放端口 + 令旧 pumpUp 退出(UDP 无 TIME_WAIT,同端口可立即重绑) } nl, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: r.localPort}) if err != nil { r.local = nil return err } r.local = nl go r.pumpUp(nl) return nil } // Close 关闭中继(两条泵随 conn 关闭而退出)。 func (r *MoshRelay) Close() error { r.mu.Lock() r.closed = true l := r.local r.local = nil r.mu.Unlock() if l != nil { l.Close() } return r.tsConn.Close() } // WakeUp 在 iOS 前台恢复时主动唤醒 tsnet 网络层:注入链路变化事件 + Rebind/重新 STUN, // 把"等首次写失败才发现 DERP/路径已死"变成"立刻重建",配合 mosh SSP 实现秒级恢复。 func (n *Node) WakeUp() { sys := n.srv.Sys() if mon, ok := sys.NetMon.GetOK(); ok { mon.InjectEvent() } if ms, ok := sys.MagicSock.GetOK(); ok { ms.Rebind() ms.ReSTUN("foreground") } } // PeersJSON 返回 tailnet 内其它节点列表(name/ip/online/os)的 JSON,用于选择连接目标。 func (n *Node) PeersJSON() string { lc, err := n.srv.LocalClient() if err != nil { return "[]" } st, err := lc.Status(context.Background()) if err != nil { return "[]" } type peer struct { Name string `json:"name"` IP string `json:"ip"` Online bool `json:"online"` OS string `json:"os"` } out := []peer{} for _, p := range st.Peer { ip := "" for _, a := range p.TailscaleIPs { if a.Is4() { ip = a.String() break } } out = append(out, peer{Name: p.DNSName, IP: ip, Online: p.Online, OS: p.OS}) } b, err := json.Marshal(out) if err != nil { return "[]" } return string(b) } // Close 释放节点。 func (n *Node) Close() error { return n.srv.Close() }