diff --git a/constant/proxy.go b/constant/proxy.go index d42e5d92b9..bc8b451bcc 100644 --- a/constant/proxy.go +++ b/constant/proxy.go @@ -31,6 +31,7 @@ const ( TypeOpenVPNServer = "openvpn-server" TypeTailscale = "tailscale" TypeCloudflared = "cloudflared" + TypeGeph = "geph" TypeDERP = "derp" TypeResolved = "resolved" TypeSSMAPI = "ssm-api" @@ -112,6 +113,8 @@ func ProxyDisplayName(proxyType string) string { return "Tailscale" case TypeCloudflared: return "Cloudflared" + case TypeGeph: + return "Geph" case TypeSelector: return "Selector" case TypeURLTest: diff --git a/docs/configuration/endpoint/geph.md b/docs/configuration/endpoint/geph.md new file mode 100644 index 0000000000..da40090022 --- /dev/null +++ b/docs/configuration/endpoint/geph.md @@ -0,0 +1,47 @@ +`geph` endpoint launches Geph5 in packet/VPN mode and connects it to Sing-box through stdin/stdout. + +Geph5's stdio protocol is a sequence of raw IPv4/IPv6 packets, each prefixed by a 16-bit big-endian packet length. Sing-box provides the userspace IP stack that translates normal TCP and UDP endpoint operations into those packets. + +### Structure + +```json +{ + "type": "geph", + "tag": "geph5", + "executable_path": "/usr/bin/geph5-client", + "config_path": "/etc/geph5/client.yaml", + "control_address": "127.0.0.1:9913", + "startup_timeout": "15s", + "extra_args": [] +} +``` + +### Fields + +#### executable_path + +Path to the Geph5 client executable. + +Default: `geph5-client`. + +#### config_path + +Required path to the YAML configuration consumed by Geph5's `--config` option. + +#### control_address + +Address where Geph5 exposes its control RPC listener. + +Required. Must be a loopback address (for example, `127.0.0.1:9913`). + +Geph5 YAML must set `control_listen` to the same value. The address must be unused when the endpoint starts; Sing-box rejects an occupied listener so readiness cannot be reported by another Geph process. Geph5's TCP control RPC is unauthenticated, so keep it on loopback and protect local access to the host. + +#### extra_args + +Additional Geph5 command-line arguments. Sing-box always supplies `--config` and `--stdio-vpn`; those managed arguments must not be repeated here. + +#### startup_timeout + +Timeout for launching Geph5 and waiting for its control RPC `conn_info` state to become `Connected`. The endpoint is unavailable until Geph has established at least one authenticated tunnel session. + +Default: `15s`. diff --git a/docs/configuration/endpoint/geph.zh.md b/docs/configuration/endpoint/geph.zh.md new file mode 100644 index 0000000000..f5a9bce2ed --- /dev/null +++ b/docs/configuration/endpoint/geph.zh.md @@ -0,0 +1,47 @@ +`geph` 端点以数据包/VPN 模式启动 Geph5,并通过标准输入输出连接到 Sing-box。 + +Geph5 的 stdio 协议由原始 IPv4/IPv6 数据包组成,每个数据包前面带有一个 16 位大端长度。Sing-box 提供用户态 IP 协议栈,将普通 TCP 和 UDP 端点操作转换为这些数据包。 + +### 结构 + +```json +{ + "type": "geph", + "tag": "geph5", + "executable_path": "/usr/bin/geph5-client", + "config_path": "/etc/geph5/client.yaml", + "control_address": "127.0.0.1:9913", + "startup_timeout": "15s", + "extra_args": [] +} +``` + +### 字段 + +#### executable_path + +Geph5 客户端可执行文件路径。 + +默认值:`geph5-client`。 + +#### config_path + +必填。传递给 Geph5 `--config` 选项的 YAML 配置文件路径。 + +#### control_address + +Geph5 控制 RPC 监听地址。 + +必填,且必须是回环地址(例如 `127.0.0.1:9913`)。 + +Geph5 YAML 里需将 `control_listen` 设置为同一地址。端点启动时该地址必须未被占用;Sing-box 会拒绝已被占用的监听地址,避免误用其他 Geph 进程的就绪状态。Geph5 的 TCP 控制 RPC 不提供身份验证,因此必须使用回环地址,并限制其他本地用户对主机的访问。 + +#### extra_args + +额外的 Geph5 命令行参数。Sing-box 始终提供 `--config` 和 `--stdio-vpn`,不要在此重复这些托管参数。 + +#### startup_timeout + +启动 Geph5 并等待控制 RPC 的 `conn_info` 状态变为 `Connected` 的超时时间。Geph 建立至少一个经过身份验证的隧道会话后,端点才可用。 + +默认值:`15s`。 diff --git a/docs/configuration/endpoint/index.md b/docs/configuration/endpoint/index.md index 6e7614b057..c164341a59 100644 --- a/docs/configuration/endpoint/index.md +++ b/docs/configuration/endpoint/index.md @@ -22,6 +22,7 @@ An endpoint is a protocol with inbound and outbound behavior. | Type | Format | |------------------|-----------------------------------------| | `wireguard` | [WireGuard](./wireguard/) | +| `geph` | [Geph5](./geph/) | | `tailscale` | [Tailscale](./tailscale/) | | `openconnect` | [OpenConnect Client](./openconnect/) | | `openvpn-client` | [OpenVPN Client](./openvpn-client/) | diff --git a/docs/configuration/endpoint/index.zh.md b/docs/configuration/endpoint/index.zh.md index a75bc63162..625cbd634d 100644 --- a/docs/configuration/endpoint/index.zh.md +++ b/docs/configuration/endpoint/index.zh.md @@ -22,6 +22,7 @@ | 类型 | 格式 | |------------------|-----------------------------------------| | `wireguard` | [WireGuard](./wireguard/) | +| `geph` | [Geph5](./geph/) | | `tailscale` | [Tailscale](./tailscale/) | | `openconnect` | [OpenConnect 客户端](./openconnect/) | | `openvpn-client` | [OpenVPN 客户端](./openvpn-client/) | diff --git a/docs/schema.json b/docs/schema.json index 21e60632fb..60e217e05e 100644 --- a/docs/schema.json +++ b/docs/schema.json @@ -3390,6 +3390,39 @@ }, "Endpoint": { "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "const": "geph" + }, + "tag": { + "type": "string" + }, + "executable_path": { + "type": "string" + }, + "control_address": { + "type": "string" + }, + "config_path": { + "type": "string" + }, + "extra_args": { + "type": "array", + "items": { + "type": "string" + } + }, + "startup_timeout": { + "$ref": "#/$defs/Duration" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, { "type": "object", "properties": { diff --git a/include/geph.go b/include/geph.go new file mode 100644 index 0000000000..18cc1bccc7 --- /dev/null +++ b/include/geph.go @@ -0,0 +1,10 @@ +package include + +import ( + "github.com/sagernet/sing-box/adapter/endpoint" + "github.com/sagernet/sing-box/protocol/geph" +) + +func registerGephEndpoint(registry *endpoint.Registry) { + geph.RegisterEndpoint(registry) +} diff --git a/include/registry.go b/include/registry.go index 9bb9220c18..c0b795cc2b 100644 --- a/include/registry.go +++ b/include/registry.go @@ -111,6 +111,7 @@ func EndpointRegistry() *endpoint.Registry { registry := endpoint.NewRegistry() registerWireGuardEndpoint(registry) + registerGephEndpoint(registry) registerOpenConnectEndpoint(registry) registerOpenVPNEndpoints(registry) registerTailscaleEndpoint(registry) diff --git a/mkdocs.yml b/mkdocs.yml index 87faa50d47..a2d6e6192e 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -154,6 +154,7 @@ nav: - Endpoint: - configuration/endpoint/index.md - WireGuard: configuration/endpoint/wireguard.md + - Geph5: configuration/endpoint/geph.md - Tailscale: configuration/endpoint/tailscale.md - OpenConnect Client: configuration/endpoint/openconnect.md - OpenVPN Client: configuration/endpoint/openvpn-client.md diff --git a/option/geph.go b/option/geph.go new file mode 100644 index 0000000000..ca7927dc6c --- /dev/null +++ b/option/geph.go @@ -0,0 +1,11 @@ +package option + +import "github.com/sagernet/sing/common/json/badoption" + +type GephEndpointOptions struct { + ExecutablePath string `json:"executable_path,omitempty"` + ControlAddress string `json:"control_address"` + ConfigPath string `json:"config_path"` + ExtraArgs []string `json:"extra_args,omitempty"` + StartupTimeout badoption.Duration `json:"startup_timeout,omitempty"` +} diff --git a/protocol/geph/endpoint.go b/protocol/geph/endpoint.go new file mode 100644 index 0000000000..cc08daacc9 --- /dev/null +++ b/protocol/geph/endpoint.go @@ -0,0 +1,144 @@ +package geph + +import ( + "context" + "net" + "net/netip" + "sync" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/adapter/endpoint" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" +) + +const defaultExecutable = "geph5-client" + +func RegisterEndpoint(registry *endpoint.Registry) { + endpoint.Register[option.GephEndpointOptions](registry, C.TypeGeph, NewEndpoint) +} + +type Endpoint struct { + endpoint.Adapter + logger log.ContextLogger + stack packetStack + proc *gephProcess + mu sync.Mutex +} + +func NewEndpoint(ctx context.Context, _ adapter.Router, logger log.ContextLogger, tag string, options option.GephEndpointOptions) (adapter.Endpoint, error) { + if options.ConfigPath == "" { + return nil, E.New("missing Geph `config_path`") + } + if options.ControlAddress == "" { + return nil, E.New("missing Geph `control_address`") + } + if err := validateGephControlAddress(options.ControlAddress); err != nil { + return nil, err + } + executable := options.ExecutablePath + if executable == "" { + executable = defaultExecutable + } + if err := validateExtraArgs(options.ExtraArgs); err != nil { + return nil, err + } + p := newGephProcess(ctx, executable, options.ConfigPath, options.ControlAddress, options.ExtraArgs, time.Duration(options.StartupTimeout)) + return &Endpoint{ + Adapter: endpoint.NewAdapter(C.TypeGeph, tag, []string{N.NetworkTCP, N.NetworkUDP}, nil), + logger: logger, + proc: p, + }, nil +} + +func validateGephControlAddress(address string) error { + addressPort, err := netip.ParseAddrPort(address) + if err != nil { + return E.New("invalid Geph `control_address`: ", err) + } + if !addressPort.Addr().IsLoopback() { + return E.New("`control_address` must be loopback IP: ", address) + } + if addressPort.Port() == 0 { + return E.New("invalid Geph `control_address` port: ", address) + } + return nil +} + +func validateExtraArgs(args []string) error { + for _, arg := range args { + if arg == "--config" || arg == "--stdio-vpn" || arg == "--vpn-fd" || + len(arg) > len("--config=") && arg[:len("--config=")] == "--config=" { + return E.New("Geph manages ", arg, "; remove it from `extra_args`") + } + } + return nil +} + +func (e *Endpoint) Start(stage adapter.StartStage) error { + if stage != adapter.StartStateStart { + return nil + } + e.mu.Lock() + defer e.mu.Unlock() + if e.stack != nil { + return nil + } + if err := e.proc.Start(); err != nil { + return err + } + stack, err := newPacketStack(e.proc.incoming, e.proc.sendPacket) + if err != nil { + _ = e.proc.Close() + return E.Cause(err, "create Geph packet stack") + } + e.stack = stack + return nil +} + +func (e *Endpoint) Close() error { + e.mu.Lock() + stack := e.stack + e.stack = nil + e.mu.Unlock() + if stack != nil { + _ = stack.Close() + } + if e.proc != nil { + return e.proc.Close() + } + return nil +} + +func (e *Endpoint) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + if destination.IsDomain() || !destination.Addr.IsValid() { + return nil, E.New("Geph endpoint requires an IP destination") + } + e.mu.Lock() + stack := e.stack + e.mu.Unlock() + if stack == nil { + return nil, E.New("Geph endpoint is not started") + } + return stack.DialContext(ctx, network, destination) +} + +func (e *Endpoint) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { + if destination.IsDomain() || !destination.Addr.IsValid() { + return nil, E.New("Geph endpoint requires an IP destination") + } + e.mu.Lock() + stack := e.stack + e.mu.Unlock() + if stack == nil { + return nil, E.New("Geph endpoint is not started") + } + return stack.ListenPacket(ctx, destination) +} + +var _ adapter.Endpoint = (*Endpoint)(nil) diff --git a/protocol/geph/endpoint_test.go b/protocol/geph/endpoint_test.go new file mode 100644 index 0000000000..de27991db0 --- /dev/null +++ b/protocol/geph/endpoint_test.go @@ -0,0 +1,24 @@ +package geph + +import "testing" + +func TestValidateGephControlAddress(t *testing.T) { + for _, address := range []string{"127.0.0.1:9913", "[::1]:9913"} { + if err := validateGephControlAddress(address); err != nil { + t.Fatalf("expected %s to be valid: %v", address, err) + } + } + for _, address := range []string{ + "localhost:9913", + "0.0.0.0:9913", + "192.0.2.1:9913", + "127.0.0.1:0", + "127.0.0.1:65536", + "127.0.0.1:-1", + "127.0.0.1", + } { + if err := validateGephControlAddress(address); err == nil { + t.Fatalf("expected %s to be invalid", address) + } + } +} diff --git a/protocol/geph/packet_stack.go b/protocol/geph/packet_stack.go new file mode 100644 index 0000000000..f47ac90c02 --- /dev/null +++ b/protocol/geph/packet_stack.go @@ -0,0 +1,172 @@ +//go:build with_gvisor + +package geph + +import ( + "context" + "net" + "net/netip" + "os" + "sync" + + "github.com/sagernet/gvisor/pkg/buffer" + "github.com/sagernet/gvisor/pkg/tcpip" + "github.com/sagernet/gvisor/pkg/tcpip/adapters/gonet" + "github.com/sagernet/gvisor/pkg/tcpip/header" + "github.com/sagernet/gvisor/pkg/tcpip/network/ipv4" + "github.com/sagernet/gvisor/pkg/tcpip/network/ipv6" + "github.com/sagernet/gvisor/pkg/tcpip/stack" + "github.com/sagernet/sing-tun" + E "github.com/sagernet/sing/common/exceptions" + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" +) + +type packetStack interface { + DialContext(context.Context, string, M.Socksaddr) (net.Conn, error) + ListenPacket(context.Context, M.Socksaddr) (net.PacketConn, error) + Close() error +} + +type gephStack struct { + stack *stack.Stack + mtu uint32 + incoming <-chan []byte + send func([]byte) error + done chan struct{} + closeOnce sync.Once + dispatcher stack.NetworkDispatcher + inet4, inet6 netip.Addr +} + +func newPacketStack(incoming <-chan []byte, send func([]byte) error) (packetStack, error) { + g := &gephStack{mtu: 16384, incoming: incoming, send: send, done: make(chan struct{})} + ipStack, err := tun.NewGVisorStackWithOptions((*gephLink)(g), stack.NICOptions{}, true) + if err != nil { + return nil, err + } + for _, prefix := range []netip.Prefix{netip.MustParsePrefix("100.64.0.1/10"), netip.MustParsePrefix("fd00:6765::1/64")} { + address := tun.AddressFromAddr(prefix.Addr()) + protocol := ipv4.ProtocolNumber + if prefix.Addr().Is6() { + protocol = ipv6.ProtocolNumber + g.inet6 = prefix.Addr() + } else { + g.inet4 = prefix.Addr() + } + gErr := ipStack.AddProtocolAddress(tun.DefaultNIC, tcpip.ProtocolAddress{Protocol: protocol, AddressWithPrefix: tcpip.AddressWithPrefix{Address: address, PrefixLen: prefix.Bits()}}, stack.AddressProperties{}) + if gErr != nil { + ipStack.Close() + return nil, E.New("add Geph stack address: ", gErr) + } + } + g.stack = ipStack + go g.readLoop() + return g, nil +} + +func (g *gephStack) readLoop() { + for packet := range g.incoming { + if len(packet) == 0 { + continue + } + var protocol tcpip.NetworkProtocolNumber + switch header.IPVersion(packet) { + case header.IPv4Version: + protocol = header.IPv4ProtocolNumber + case header.IPv6Version: + protocol = header.IPv6ProtocolNumber + default: + continue + } + pb := stack.NewPacketBuffer(stack.PacketBufferOptions{Payload: buffer.MakeWithData(packet)}) + if g.dispatcher != nil { + g.dispatcher.DeliverNetworkPacket(protocol, pb) + } + pb.DecRef() + } +} + +func (g *gephStack) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { + var local netip.Addr + var protocol tcpip.NetworkProtocolNumber + if destination.IsIPv4() { + local, protocol = g.inet4, header.IPv4ProtocolNumber + } else { + local, protocol = g.inet6, header.IPv6ProtocolNumber + } + if !local.IsValid() { + return nil, E.New("Geph stack has no address for destination family") + } + bind := tcpip.FullAddress{NIC: tun.DefaultNIC, Addr: tun.AddressFromAddr(local)} + remote := tcpip.FullAddress{NIC: tun.DefaultNIC, Addr: tun.AddressFromAddr(destination.Addr), Port: destination.Port} + switch N.NetworkName(network) { + case N.NetworkTCP: + return gonet.DialTCPWithBind(ctx, g.stack, bind, remote, protocol) + case N.NetworkUDP: + return gonet.DialUDP(g.stack, &bind, &remote, protocol) + default: + return nil, E.Extend(N.ErrUnknownNetwork, network) + } +} + +func (g *gephStack) ListenPacket(_ context.Context, destination M.Socksaddr) (net.PacketConn, error) { + var local netip.Addr + var protocol tcpip.NetworkProtocolNumber + if destination.IsIPv4() { + local, protocol = g.inet4, header.IPv4ProtocolNumber + } else { + local, protocol = g.inet6, header.IPv6ProtocolNumber + } + if !local.IsValid() { + return nil, E.New("Geph stack has no address for destination family") + } + bind := tcpip.FullAddress{NIC: tun.DefaultNIC, Addr: tun.AddressFromAddr(local)} + return gonet.DialUDP(g.stack, &bind, nil, protocol) +} + +func (g *gephStack) Close() error { + g.closeOnce.Do(func() { + close(g.done) + g.stack.Close() + for _, ep := range g.stack.CleanupEndpoints() { + ep.Abort() + } + g.stack.Wait() + }) + return nil +} + +type gephLink gephStack + +func (e *gephLink) MTU() uint32 { return e.mtu } +func (e *gephLink) SetMTU(uint32) {} +func (e *gephLink) MaxHeaderLength() uint16 { return 0 } +func (e *gephLink) LinkAddress() tcpip.LinkAddress { return "" } +func (e *gephLink) SetLinkAddress(tcpip.LinkAddress) {} +func (e *gephLink) Capabilities() stack.LinkEndpointCapabilities { + return stack.CapabilityRXChecksumOffload +} +func (e *gephLink) Attach(d stack.NetworkDispatcher) { e.dispatcher = d } +func (e *gephLink) IsAttached() bool { return e.dispatcher != nil } +func (e *gephLink) Wait() {} +func (e *gephLink) ARPHardwareType() header.ARPHardwareType { return header.ARPHardwareNone } +func (e *gephLink) AddHeader(*stack.PacketBuffer) {} +func (e *gephLink) ParseHeader(*stack.PacketBuffer) bool { return true } +func (e *gephLink) Close() {} +func (e *gephLink) SetOnCloseAction(func()) {} +func (e *gephLink) WritePackets(list stack.PacketBufferList) (int, tcpip.Error) { + for _, packet := range list.AsSlice() { + var data []byte + for _, view := range packet.AsSlices() { + data = append(data, view...) + } + if err := e.send(data); err != nil { + return 0, &tcpip.ErrClosedForSend{} + } + } + return list.Len(), nil +} + +var _ stack.LinkEndpoint = (*gephLink)(nil) +var _ = os.ErrClosed diff --git a/protocol/geph/packet_stack_stub.go b/protocol/geph/packet_stack_stub.go new file mode 100644 index 0000000000..d708de784b --- /dev/null +++ b/protocol/geph/packet_stack_stub.go @@ -0,0 +1,26 @@ +//go:build !with_gvisor + +package geph + +import ( + "context" + "net" + + M "github.com/sagernet/sing/common/metadata" +) + +type packetStack interface { + DialContext(context.Context, string, M.Socksaddr) (net.Conn, error) + ListenPacket(context.Context, M.Socksaddr) (net.PacketConn, error) + Close() error +} + +func newPacketStack(<-chan []byte, func([]byte) error) (packetStack, error) { + return nil, errGVisorRequired +} + +var errGVisorRequired = unsupportedError("Geph endpoint requires a build with -tags with_gvisor") + +type unsupportedError string + +func (e unsupportedError) Error() string { return string(e) } diff --git a/protocol/geph/packet_stack_test.go b/protocol/geph/packet_stack_test.go new file mode 100644 index 0000000000..0028756d1e --- /dev/null +++ b/protocol/geph/packet_stack_test.go @@ -0,0 +1,46 @@ +//go:build with_gvisor + +package geph + +import ( + "context" + "net/netip" + "testing" + "time" + + M "github.com/sagernet/sing/common/metadata" + N "github.com/sagernet/sing/common/network" +) + +func TestPacketStackForwardsTCPIntoGephTransport(t *testing.T) { + incoming := make(chan []byte, 4) + outgoing := make(chan []byte, 4) + stack, err := newPacketStack(incoming, func(packet []byte) error { + outgoing <- append([]byte(nil), packet...) + return nil + }) + if err != nil { + t.Fatal(err) + } + defer stack.Close() + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + connCh := make(chan error, 1) + go func() { + conn, err := stack.DialContext(ctx, N.NetworkTCP, M.SocksaddrFrom(netip.MustParseAddr("198.18.0.1"), 443)) + if err == nil && conn != nil { + _ = conn.Close() + } + connCh <- err + }() + + select { + case packet := <-outgoing: + if len(packet) < 20 || packet[0]>>4 != 4 { + t.Fatalf("expected IPv4 TCP packet, got %x", packet) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for packet sent to Geph") + } +} diff --git a/protocol/geph/process.go b/protocol/geph/process.go new file mode 100644 index 0000000000..1c8cc30815 --- /dev/null +++ b/protocol/geph/process.go @@ -0,0 +1,491 @@ +package geph + +import ( + "bufio" + "context" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "os/exec" + "strings" + "sync" + "time" +) + +const ( + defaultGephStartupTimeout = 15 * time.Second + controlRPCPollInterval = 100 * time.Millisecond + controlRPCTimeout = 500 * time.Millisecond + maxControlRPCResponseSize = 64 * 1024 + maxStderrTailSize = 8192 +) + +const gephReadinessRequestID = "sing-box-geph-readiness" + +type gephProcess struct { + ctx context.Context + executable, config, controlAddress string + extraArgs []string + timeout time.Duration + incoming chan []byte + outgoing chan []byte + done, waitDone chan struct{} + closeOnce sync.Once + stateMu sync.Mutex + closed bool + cmd *exec.Cmd + stdin io.WriteCloser + waitErr error + stderrTail boundedStringBuffer +} + +type gephConnInfoRequest struct { + JSONRPC string `json:"jsonrpc"` + Method string `json:"method"` + Params []string `json:"params"` + ID string `json:"id"` +} + +type gephConnInfoResponse struct { + JSONRPC string `json:"jsonrpc"` + ID string `json:"id"` + Result *struct { + State string `json:"state"` + } `json:"result"` + Error *struct { + Code int `json:"code"` + Message string `json:"message"` + } `json:"error"` +} + +type gephControlProtocolError struct { + err error +} + +func (e *gephControlProtocolError) Error() string { + return e.err.Error() +} + +func (e *gephControlProtocolError) Unwrap() error { + return e.err +} + +func newGephControlProtocolError(format string, args ...any) error { + return &gephControlProtocolError{err: fmt.Errorf(format, args...)} +} + +type boundedStringBuffer struct { + mu sync.Mutex + content []byte + limit int +} + +func newBoundedStringBuffer(limit int) boundedStringBuffer { + return boundedStringBuffer{limit: limit} +} + +func (b *boundedStringBuffer) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + + if b.limit > 0 && len(p) >= b.limit { + b.content = append(b.content[:0], p[len(p)-b.limit:]...) + return len(p), nil + } + if b.limit > 0 { + excess := len(b.content) + len(p) - b.limit + if excess > 0 { + b.content = b.content[excess:] + } + } + b.content = append(b.content, p...) + return len(p), nil +} + +func (b *boundedStringBuffer) String() string { + b.mu.Lock() + defer b.mu.Unlock() + return strings.TrimSpace(string(b.content)) +} + +func newGephProcess(ctx context.Context, executable, config, controlAddress string, extraArgs []string, timeout time.Duration) *gephProcess { + return &gephProcess{ + ctx: ctx, + executable: executable, + config: config, + controlAddress: controlAddress, + extraArgs: append([]string(nil), extraArgs...), + timeout: timeout, + incoming: make(chan []byte, 256), + outgoing: make(chan []byte, 256), + done: make(chan struct{}), + waitDone: make(chan struct{}), + stderrTail: newBoundedStringBuffer(maxStderrTailSize), + } +} + +func (p *gephProcess) args() []string { + return append([]string{"--config", p.config, "--stdio-vpn"}, p.extraArgs...) +} + +func (p *gephProcess) Start() error { + if p.timeout <= 0 { + p.timeout = defaultGephStartupTimeout + } + startupCtx, cancel := context.WithTimeout(p.ctx, p.timeout) + defer cancel() + + if err := p.ensureControlAddressAvailable(startupCtx); err != nil { + if startupCtx.Err() != nil { + return p.startupContextError(startupCtx, nil) + } + return err + } + + cmd := exec.CommandContext(p.ctx, p.executable, p.args()...) + cmd.Stderr = &p.stderrTail + stdin, err := cmd.StdinPipe() + if err != nil { + return fmt.Errorf("create Geph stdin: %w", err) + } + stdout, err := cmd.StdoutPipe() + if err != nil { + _ = stdin.Close() + return fmt.Errorf("create Geph stdout: %w", err) + } + + startResult := make(chan error, 1) + go func() { + startResult <- cmd.Start() + }() + + select { + case err = <-startResult: + case <-startupCtx.Done(): + _ = stdin.Close() + _ = stdout.Close() + go reapLateGephStart(cmd, startResult) + return p.startupContextError(startupCtx, nil) + } + if err != nil { + _ = stdin.Close() + _ = stdout.Close() + return fmt.Errorf("start Geph: %w", err) + } + + p.cmd, p.stdin = cmd, stdin + go p.readLoop(stdout) + go p.writeLoop() + + go func() { + err := cmd.Wait() + p.stateMu.Lock() + p.waitErr = err + p.closed = true + p.stateMu.Unlock() + close(p.waitDone) + close(p.done) + }() + + if err = p.waitUntilReady(startupCtx); err != nil { + _ = p.Close() + return err + } + return nil +} + +func reapLateGephStart(cmd *exec.Cmd, startResult <-chan error) { + if err := <-startResult; err == nil && cmd.Process != nil { + _ = cmd.Process.Kill() + _ = cmd.Wait() + } +} + +func (p *gephProcess) ensureControlAddressAvailable(ctx context.Context) error { + if p.controlAddress == "" { + return nil + } + var listenConfig net.ListenConfig + listener, err := listenConfig.Listen(ctx, "tcp", p.controlAddress) + if err != nil { + return fmt.Errorf("Geph control address %s is already in use or unavailable: %w", p.controlAddress, err) + } + if err := listener.Close(); err != nil { + return fmt.Errorf("release Geph control address %s after availability check: %w", p.controlAddress, err) + } + return nil +} + +func (p *gephProcess) waitUntilReady(startupCtx context.Context) error { + if p.controlAddress == "" { + return nil + } + + ticker := time.NewTicker(controlRPCPollInterval) + defer ticker.Stop() + + var lastErr error + for { + select { + case <-startupCtx.Done(): + return p.startupContextError(startupCtx, lastErr) + case <-p.waitDone: + if p.ctx.Err() != nil { + return p.startupContextError(startupCtx, lastErr) + } + return p.startupExitedError() + default: + } + + ready, state, err := p.queryConnectionState(startupCtx) + if ready { + select { + case <-p.waitDone: + if p.ctx.Err() != nil { + return p.startupContextError(startupCtx, lastErr) + } + return p.startupExitedError() + default: + return nil + } + } + if err != nil { + var protocolErr *gephControlProtocolError + if errors.As(err, &protocolErr) { + return fmt.Errorf("invalid Geph control RPC response: %w", err) + } + lastErr = err + } else if state != "" { + switch state { + case "Connecting", "Disconnected": + lastErr = fmt.Errorf("control rpc state: %s", state) + default: + return fmt.Errorf("unexpected Geph control state: %s", state) + } + } + + select { + case <-startupCtx.Done(): + return p.startupContextError(startupCtx, lastErr) + case <-p.waitDone: + if p.ctx.Err() != nil { + return p.startupContextError(startupCtx, lastErr) + } + return p.startupExitedError() + case <-ticker.C: + } + } +} + +func (p *gephProcess) queryConnectionState(ctx context.Context) (bool, string, error) { + attemptCtx, cancel := context.WithTimeout(ctx, controlRPCTimeout) + defer cancel() + + dialer := &net.Dialer{} + conn, err := dialer.DialContext(attemptCtx, "tcp", p.controlAddress) + if err != nil { + return false, "", err + } + defer conn.Close() + + if deadline, ok := attemptCtx.Deadline(); ok { + if err := conn.SetDeadline(deadline); err != nil { + return false, "", fmt.Errorf("set conn_info deadline: %w", err) + } + } + + request := gephConnInfoRequest{ + JSONRPC: "2.0", + Method: "conn_info", + Params: []string{}, + ID: gephReadinessRequestID, + } + requestBytes, err := json.Marshal(request) + if err != nil { + return false, "", fmt.Errorf("marshal conn_info request: %w", err) + } + requestBytes = append(requestBytes, '\n') + if err := writeFull(conn, requestBytes); err != nil { + return false, "", fmt.Errorf("send conn_info request: %w", err) + } + + reader := bufio.NewReader(io.LimitReader(conn, maxControlRPCResponseSize+1)) + responseLine, err := reader.ReadString('\n') + if len(responseLine) > maxControlRPCResponseSize { + return false, "", newGephControlProtocolError("conn_info response exceeds %d bytes", maxControlRPCResponseSize) + } + if err != nil { + return false, "", fmt.Errorf("read conn_info response: %w", err) + } + var response gephConnInfoResponse + if err := json.Unmarshal([]byte(responseLine), &response); err != nil { + return false, "", newGephControlProtocolError("parse conn_info response: %w", err) + } + if response.JSONRPC != "2.0" { + return false, "", newGephControlProtocolError("conn_info rpc returned JSON-RPC version %q", response.JSONRPC) + } + if response.ID != gephReadinessRequestID { + return false, "", newGephControlProtocolError("conn_info rpc returned mismatched id %q", response.ID) + } + if response.Error != nil { + if response.Error.Message != "" { + return false, "", newGephControlProtocolError("conn_info rpc error: %s (code=%d)", response.Error.Message, response.Error.Code) + } + return false, "", newGephControlProtocolError("conn_info rpc error: code=%d", response.Error.Code) + } + if response.Result == nil { + return false, "", newGephControlProtocolError("conn_info rpc missing result") + } + state := strings.TrimSpace(response.Result.State) + if state == "" { + return false, "", newGephControlProtocolError("conn_info rpc missing state") + } + return state == "Connected", state, nil +} + +func writeFull(writer io.Writer, payload []byte) error { + for len(payload) > 0 { + n, err := writer.Write(payload) + if err != nil { + return err + } + if n == 0 { + return io.ErrShortWrite + } + payload = payload[n:] + } + return nil +} + +func (p *gephProcess) startupContextError(startupCtx context.Context, lastErr error) error { + if err := p.ctx.Err(); err != nil { + if lastErr != nil { + return fmt.Errorf("start Geph: %w (last control RPC error: %v)", err, lastErr) + } + return fmt.Errorf("start Geph: %w", err) + } + if errors.Is(startupCtx.Err(), context.DeadlineExceeded) { + return p.startupTimeoutError(lastErr) + } + if err := startupCtx.Err(); err != nil { + return fmt.Errorf("start Geph: %w", err) + } + return fmt.Errorf("start Geph: startup canceled") +} + +func (p *gephProcess) startupTimeoutError(lastErr error) error { + reason := strings.TrimSpace(p.stderrTail.String()) + if reason == "" { + if lastErr != nil { + return fmt.Errorf("start Geph: timeout waiting for control RPC after %s: %w", p.timeout, lastErr) + } + return fmt.Errorf("start Geph: timeout waiting for control RPC after %s", p.timeout) + } + if lastErr != nil { + return fmt.Errorf("start Geph: timeout waiting for control RPC after %s: %w (%s)", p.timeout, lastErr, reason) + } + return fmt.Errorf("start Geph: timeout waiting for control RPC after %s (%s)", p.timeout, reason) +} + +func (p *gephProcess) startupExitedError() error { + p.stateMu.Lock() + waitErr := p.waitErr + p.stateMu.Unlock() + + reason := strings.TrimSpace(p.stderrTail.String()) + if waitErr == nil { + if reason == "" { + return fmt.Errorf("start Geph: process exited during startup") + } + return fmt.Errorf("start Geph: process exited during startup: %s", reason) + } + if reason == "" { + return fmt.Errorf("start Geph: process exited during startup: %w", waitErr) + } + return fmt.Errorf("start Geph: process exited during startup: %w (%s)", waitErr, reason) +} + +func (p *gephProcess) sendPacket(packet []byte) error { + if len(packet) == 0 || len(packet) > 65535 { + return fmt.Errorf("invalid Geph packet length: %d", len(packet)) + } + p.stateMu.Lock() + closed := p.closed + p.stateMu.Unlock() + if closed { + return io.ErrClosedPipe + } + copyPacket := append([]byte(nil), packet...) + select { + case <-p.done: + return io.ErrClosedPipe + default: + } + select { + case p.outgoing <- copyPacket: + return nil + case <-p.done: + return io.ErrClosedPipe + } +} + +func (p *gephProcess) readLoop(r io.Reader) { + defer close(p.incoming) + var length [2]byte + for { + if _, err := io.ReadFull(r, length[:]); err != nil { + return + } + n := int(binary.BigEndian.Uint16(length[:])) + if n == 0 { + continue + } + packet := make([]byte, n) + if _, err := io.ReadFull(r, packet); err != nil { + return + } + select { + case p.incoming <- packet: + case <-p.done: + return + } + } +} + +func (p *gephProcess) writeLoop() { + for { + var packet []byte + select { + case packet = <-p.outgoing: + case <-p.done: + return + } + var length [2]byte + binary.BigEndian.PutUint16(length[:], uint16(len(packet))) + if _, err := p.stdin.Write(length[:]); err != nil { + return + } + if _, err := p.stdin.Write(packet); err != nil { + return + } + } +} + +func (p *gephProcess) Close() error { + p.closeOnce.Do(func() { + p.stateMu.Lock() + p.closed = true + p.stateMu.Unlock() + if p.stdin != nil { + _ = p.stdin.Close() + } + if p.cmd != nil && p.cmd.Process != nil { + _ = p.cmd.Process.Kill() + <-p.waitDone + } + }) + return nil +} diff --git a/protocol/geph/process_test.go b/protocol/geph/process_test.go new file mode 100644 index 0000000000..851e570b4b --- /dev/null +++ b/protocol/geph/process_test.go @@ -0,0 +1,307 @@ +//go:build !windows + +package geph + +import ( + "bufio" + "context" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "net" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestGephProcessArgsAndFraming(t *testing.T) { + controlAddress := freeControlAddress(t) + requestLog := filepath.Join(t.TempDir(), "control-requests.jsonl") + helper := writeGephHelper(t, controlAddress, []string{"Connected"}, requestLog) + p := newGephProcess(context.Background(), helper, "/tmp/geph5.yaml", controlAddress, []string{"--extra-test-arg"}, time.Second) + if got := p.args(); len(got) != 4 || got[0] != "--config" || got[1] != "/tmp/geph5.yaml" || got[2] != "--stdio-vpn" || got[3] != "--extra-test-arg" { + t.Fatalf("unexpected arguments: %#v", got) + } + if err := p.Start(); err != nil { + t.Fatal(err) + } + defer p.Close() + + requestLines, err := os.ReadFile(requestLog) + if err != nil { + t.Fatal(err) + } + request := strings.Split(strings.TrimSpace(string(requestLines)), "\n")[0] + var requestBody struct { + JSONRPC string `json:"jsonrpc"` + Method string `json:"method"` + ID string `json:"id"` + Params []string `json:"params"` + } + if err := json.Unmarshal([]byte(request), &requestBody); err != nil { + t.Fatalf("invalid conn_info request: %v", err) + } + if requestBody.JSONRPC != "2.0" || requestBody.Method != "conn_info" || requestBody.ID != gephReadinessRequestID || len(requestBody.Params) != 0 { + t.Fatalf("unexpected conn_info request: %#v", requestBody) + } + + packet := []byte{0x45, 0, 1, 2, 3} + if err := p.sendPacket(packet); err != nil { + t.Fatal(err) + } + select { + case got := <-p.incoming: + if string(got) != string(packet) { + t.Fatalf("packet mismatch: %x", got) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for framed packet") + } +} + +func TestGephProcessWaitsForConnectedState(t *testing.T) { + controlAddress := freeControlAddress(t) + requestLog := filepath.Join(t.TempDir(), "control-requests.jsonl") + helper := writeGephHelper(t, controlAddress, []string{"Connecting", "Connected"}, requestLog) + p := newGephProcess(context.Background(), helper, "/tmp/geph5.yaml", controlAddress, nil, 2*time.Second) + if err := p.Start(); err != nil { + t.Fatal(err) + } + defer p.Close() + + requestLines, err := os.ReadFile(requestLog) + if err != nil { + t.Fatal(err) + } + if requests := strings.Count(strings.TrimSpace(string(requestLines)), "\n") + 1; requests < 2 { + t.Fatalf("expected at least two control requests, got %d", requests) + } +} + +func TestGephProcessStartupTimeout(t *testing.T) { + controlAddress := freeControlAddress(t) + helper := writeGephHelper(t, controlAddress, []string{"Connecting"}, "") + p := newGephProcess(context.Background(), helper, "/tmp/geph5.yaml", controlAddress, nil, 400*time.Millisecond) + err := p.Start() + if err == nil { + defer p.Close() + t.Fatal("expected startup timeout") + } + if !strings.Contains(err.Error(), "timeout") { + t.Fatalf("expected timeout error, got: %v", err) + } + select { + case <-p.waitDone: + default: + t.Fatal("Geph child was not reaped after startup timeout") + } +} + +func TestGephProcessContextCancellation(t *testing.T) { + controlAddress := freeControlAddress(t) + helper := writeGephHelper(t, controlAddress, []string{"Connecting"}, "") + ctx, cancel := context.WithCancel(context.Background()) + p := newGephProcess(ctx, helper, "/tmp/geph5.yaml", controlAddress, nil, 5*time.Second) + time.AfterFunc(150*time.Millisecond, cancel) + err := p.Start() + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected context cancellation, got: %v", err) + } + select { + case <-p.waitDone: + default: + t.Fatal("Geph child was not reaped after context cancellation") + } +} + +func TestGephProcessRejectsOccupiedControlAddress(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer listener.Close() + p := newGephProcess(context.Background(), "/bin/false", "/tmp/geph5.yaml", listener.Addr().String(), nil, time.Second) + err = p.Start() + if err == nil || !strings.Contains(err.Error(), "already in use or unavailable") { + t.Fatalf("expected occupied-address error, got: %v", err) + } +} + +func TestGephProcessRejectsInvalidRPCResponse(t *testing.T) { + for name, response := range map[string]string{ + "version": `{"jsonrpc":"1.0","id":"sing-box-geph-readiness","result":{"state":"Connected"}}` + "\n", + "id": `{"jsonrpc":"2.0","id":"somebody-else","result":{"state":"Connected"}}` + "\n", + "malformed": "{not-json}\n", + "rpc error": `{"jsonrpc":"2.0","id":"sing-box-geph-readiness","error":{"code":-32000,"message":"not ready"}}` + "\n", + "missing result": `{"jsonrpc":"2.0","id":"sing-box-geph-readiness"}` + "\n", + "oversized": strings.Repeat("x", maxControlRPCResponseSize+1) + "\n", + } { + t.Run(name, func(t *testing.T) { + controlAddress, waitServer := startOneShotControlServer(t, response) + p := newGephProcess(context.Background(), "/bin/false", "/tmp/geph5.yaml", controlAddress, nil, time.Second) + ready, _, err := p.queryConnectionState(context.Background()) + waitServer() + if ready || err == nil { + t.Fatalf("expected invalid response error, ready=%v err=%v", ready, err) + } + var protocolErr *gephControlProtocolError + if !errors.As(err, &protocolErr) { + t.Fatalf("expected protocol error, got: %v", err) + } + }) + } +} + +func TestGephProcessErrorOnExitedProcess(t *testing.T) { + dir := t.TempDir() + helper := filepath.Join(dir, "geph5-helper.py") + script := "#!/usr/bin/env python3\nimport sys\nsys.stderr.write('geph bootstrap failed')\nsys.exit(3)\n" + if err := os.WriteFile(helper, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + + p := newGephProcess(context.Background(), helper, "/tmp/geph5.yaml", freeControlAddress(t), nil, 500*time.Millisecond) + err := p.Start() + if err == nil { + t.Fatal("expected startup failure") + } + if !strings.Contains(err.Error(), "process exited during startup") { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(err.Error(), "geph bootstrap failed") { + t.Fatalf("missing stderr diagnostics: %v", err) + } +} + +func TestGephProcessRejectsOversizedPacket(t *testing.T) { + p := newGephProcess(context.Background(), "/bin/false", "/tmp/geph5.yaml", "127.0.0.1:1080", nil, time.Second) + if err := p.sendPacket(make([]byte, 65536)); err == nil { + t.Fatal("expected oversized packet error") + } +} + +func TestGephFramingIsBigEndian(t *testing.T) { + var header [2]byte + binary.BigEndian.PutUint16(header[:], 0x1234) + if header != [2]byte{0x12, 0x34} { + t.Fatalf("unexpected framing: %x", header) + } +} + +func freeControlAddress(t *testing.T) string { + t.Helper() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + address := listener.Addr().String() + if err := listener.Close(); err != nil { + t.Fatal(err) + } + return address +} + +func writeGephHelper(t *testing.T, controlAddress string, states []string, requestLog string) string { + t.Helper() + host, port, err := net.SplitHostPort(controlAddress) + if err != nil { + t.Fatal(err) + } + statesJSON, err := json.Marshal(states) + if err != nil { + t.Fatal(err) + } + dir := t.TempDir() + helper := filepath.Join(dir, "geph5-helper.py") + script := fmt.Sprintf(`#!/usr/bin/env python3 +import json +import socket +import struct +import sys +import threading + +CONTROL_HOST = %q +CONTROL_PORT = %s +STATES = %s +REQUEST_LOG = %q + +def serve_control(): + listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + listener.bind((CONTROL_HOST, CONTROL_PORT)) + listener.listen() + index = 0 + while True: + conn, _ = listener.accept() + with conn: + reader = conn.makefile("rb") + request_line = reader.readline() + if not request_line: + continue + request = json.loads(request_line) + if REQUEST_LOG: + with open(REQUEST_LOG, "ab") as request_file: + request_file.write(request_line) + state = STATES[min(index, len(STATES) - 1)] + index += 1 + response = { + "jsonrpc": "2.0", + "id": request.get("id"), + "result": {"state": state}, + } + conn.sendall(json.dumps(response).encode() + b"\n") + +threading.Thread(target=serve_control, daemon=True).start() + +while True: + header = sys.stdin.buffer.read(2) + if len(header) != 2: + break + length = struct.unpack(">H", header)[0] + packet = sys.stdin.buffer.read(length) + if len(packet) != length: + break + sys.stdout.buffer.write(header + packet) + sys.stdout.buffer.flush() +`, host, port, statesJSON, requestLog) + if err := os.WriteFile(helper, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + return helper +} + +func startOneShotControlServer(t *testing.T, response string) (string, func()) { + t.Helper() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { + defer listener.Close() + conn, err := listener.Accept() + if err != nil { + done <- err + return + } + defer conn.Close() + if _, err = bufio.NewReader(conn).ReadString('\n'); err == nil { + _, err = conn.Write([]byte(response)) + } + done <- err + }() + return listener.Addr().String(), func() { + t.Helper() + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for control server") + } + } +}