Files
kid 866df1a323 feat: M3 mosh 挂起→恢复<3s(唤醒脉冲 + 会话状态机三相)
- 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>
2026-07-24 17:04:33 +08:00

278 lines
8.0 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Package tsnetbridge 是 tsnet 的最小 gomobile 桥接探针M1 可行性验证)。
// 目标:证明 tsnet 能经 gomobile bind 编译到 iOSretire R5Go+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 启动并等待 RunningtimeoutMs 到则返回 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.Connnetstack 虚拟连接,无真实 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 socketRebind 在同端口替换(关旧 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
}
// pumpUpclient(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
}
}
}
// pumpDowntsnet(server) → client(最近 loopback 源地址)。绑定稳定 tsConn跨 Rebind 存活;
// 每次写入"当前"socketRebind 后自动跟上)。逐报文保边界。
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 socketiOS 前台恢复用:挂起后旧 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()
}