- SessionMachine 加 mosh 三相(moshActive/moshParked/moshResuming)+看门狗兜底, mosh 接管即拆 SSH、后台冻结不重建、前台唤醒脉冲等 SSP 续(+5 单测) - bridge.go: Node.WakeUp()(tsnet InjectEvent+MagicSock.Rebind/ReSTUN) + MoshRelay.Rebind()(同端口重开 loopback、泵按 socket 生成容错) - iosclient.cc: g_mosh_last_heard 探针(导出 mosh_last_heard_ms) + SIGCONT 全屏重绘 - MoshSession: nudge()(SIGCONT) + lastHeardMs();SSHTerminalModel 前台脉冲三连 + healthy 轮询 + beginBackgroundTask;URL scheme 供无头前台唤醒 - 验证(vohive-vm over tsnet):15s 冻结→前台 wake pulse→1.3s recovered,<3s Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
278 lines
8.0 KiB
Go
278 lines
8.0 KiB
Go
// 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:<LocalPort>,本中继逐包经 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()
|
||
}
|