Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions constant/proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ const (
TypeOpenVPNServer = "openvpn-server"
TypeTailscale = "tailscale"
TypeCloudflared = "cloudflared"
TypeGeph = "geph"
TypeDERP = "derp"
TypeResolved = "resolved"
TypeSSMAPI = "ssm-api"
Expand Down Expand Up @@ -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:
Expand Down
47 changes: 47 additions & 0 deletions docs/configuration/endpoint/geph.md
Original file line number Diff line number Diff line change
@@ -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`.
47 changes: 47 additions & 0 deletions docs/configuration/endpoint/geph.zh.md
Original file line number Diff line number Diff line change
@@ -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`。
1 change: 1 addition & 0 deletions docs/configuration/endpoint/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/) |
Expand Down
1 change: 1 addition & 0 deletions docs/configuration/endpoint/index.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
| 类型 | 格式 |
|------------------|-----------------------------------------|
| `wireguard` | [WireGuard](./wireguard/) |
| `geph` | [Geph5](./geph/) |
| `tailscale` | [Tailscale](./tailscale/) |
| `openconnect` | [OpenConnect 客户端](./openconnect/) |
| `openvpn-client` | [OpenVPN 客户端](./openvpn-client/) |
Expand Down
33 changes: 33 additions & 0 deletions docs/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
10 changes: 10 additions & 0 deletions include/geph.go
Original file line number Diff line number Diff line change
@@ -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)
}
1 change: 1 addition & 0 deletions include/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ func EndpointRegistry() *endpoint.Registry {
registry := endpoint.NewRegistry()

registerWireGuardEndpoint(registry)
registerGephEndpoint(registry)
registerOpenConnectEndpoint(registry)
registerOpenVPNEndpoints(registry)
registerTailscaleEndpoint(registry)
Expand Down
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions option/geph.go
Original file line number Diff line number Diff line change
@@ -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"`
}
144 changes: 144 additions & 0 deletions protocol/geph/endpoint.go
Original file line number Diff line number Diff line change
@@ -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)
24 changes: 24 additions & 0 deletions protocol/geph/endpoint_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Loading