Skip to content
Merged
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
86 changes: 46 additions & 40 deletions adapter/outbound/openvpn.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,28 +41,31 @@ type OpenVPN struct {

type OpenVPNOption struct {
BasicOption
Name string `proxy:"name"`
Server string `proxy:"server"`
Port int `proxy:"port"`
Proto string `proxy:"proto,omitempty"`
Dev string `proxy:"dev,omitempty"`
Cipher string `proxy:"cipher,omitempty"`
Auth string `proxy:"auth,omitempty"`
CompLZO string `proxy:"comp-lzo,omitempty"`
CA string `proxy:"ca"`
Cert string `proxy:"cert,omitempty"`
Key string `proxy:"key,omitempty"`
TLSAuth string `proxy:"tls-auth,omitempty"`
KeyDirection string `proxy:"key-direction,omitempty"`
TLSCrypt string `proxy:"tls-crypt,omitempty"`
Username string `proxy:"username,omitempty"`
Password string `proxy:"password,omitempty"`
PeerInfo map[string]string `proxy:"peer-info,omitempty"`
Ping int `proxy:"ping,omitempty"`
PingRestart int `proxy:"ping-restart,omitempty"`
HandshakeTimeout int `proxy:"handshake-timeout,omitempty"`
MTU int `proxy:"mtu,omitempty"`
UDP bool `proxy:"udp,omitempty"`
Name string `proxy:"name"`
Server string `proxy:"server"`
Port int `proxy:"port"`
Proto string `proxy:"proto,omitempty"`
Dev string `proxy:"dev,omitempty"`
Cipher string `proxy:"cipher,omitempty"`
DataCiphers []string `proxy:"data-ciphers,omitempty"`
DataCipherFallback string `proxy:"data-ciphers-fallback,omitempty"`
Auth string `proxy:"auth,omitempty"`
CompLZO string `proxy:"comp-lzo,omitempty"`
CA string `proxy:"ca"`
Cert string `proxy:"cert,omitempty"`
Key string `proxy:"key,omitempty"`
TLSAuth string `proxy:"tls-auth,omitempty"`
KeyDirection string `proxy:"key-direction,omitempty"`
TLSCrypt string `proxy:"tls-crypt,omitempty"`
TLSCryptV2 string `proxy:"tls-crypt-v2,omitempty"`
Username string `proxy:"username,omitempty"`
Password string `proxy:"password,omitempty"`
PeerInfo map[string]string `proxy:"peer-info,omitempty"`
Ping int `proxy:"ping,omitempty"`
PingRestart int `proxy:"ping-restart,omitempty"`
HandshakeTimeout int `proxy:"handshake-timeout,omitempty"`
MTU int `proxy:"mtu,omitempty"`
UDP bool `proxy:"udp,omitempty"`

RemoteDnsResolve bool `proxy:"remote-dns-resolve,omitempty"`
Dns []string `proxy:"dns,omitempty"`
Expand All @@ -73,24 +76,27 @@ func NewOpenVPN(option OpenVPNOption) (*OpenVPN, error) {
return nil, errors.New("openvpn handshake timeout must be non-negative")
}
cfg := &ovpn.ClientConfig{
RemoteHost: option.Server,
RemotePort: uint16(option.Port),
Proto: option.Proto,
Dev: option.Dev,
Cipher: option.Cipher,
Auth: option.Auth,
CompLZO: option.CompLZO,
CA: []byte(option.CA),
Cert: []byte(option.Cert),
Key: []byte(option.Key),
TLSAuth: []byte(option.TLSAuth),
KeyDirection: option.KeyDirection,
TLSCrypt: []byte(option.TLSCrypt),
Username: option.Username,
Password: option.Password,
PeerInfo: option.PeerInfo,
PingInterval: time.Duration(option.Ping) * time.Second,
PingRestart: time.Duration(option.PingRestart) * time.Second,
RemoteHost: option.Server,
RemotePort: uint16(option.Port),
Proto: option.Proto,
Dev: option.Dev,
Cipher: option.Cipher,
DataCiphers: option.DataCiphers,
FallbackCipher: option.DataCipherFallback,
Auth: option.Auth,
CompLZO: option.CompLZO,
CA: []byte(option.CA),
Cert: []byte(option.Cert),
Key: []byte(option.Key),
TLSAuth: []byte(option.TLSAuth),
KeyDirection: option.KeyDirection,
TLSCrypt: []byte(option.TLSCrypt),
TLSCryptV2: []byte(option.TLSCryptV2),
Username: option.Username,
Password: option.Password,
PeerInfo: option.PeerInfo,
PingInterval: time.Duration(option.Ping) * time.Second,
PingRestart: time.Duration(option.PingRestart) * time.Second,
}
if err := cfg.Prepare(); err != nil {
return nil, err
Expand Down
9 changes: 8 additions & 1 deletion docs/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1359,6 +1359,8 @@ proxies: # socks5
proto: udp # udp/tcp,默认 udp
# dev: tun # 目前仅支持 tun,默认 tun
# cipher: AES-128-GCM # 支持 AES-128-GCM / AES-192-GCM / AES-256-GCM / AES-128-CBC / AES-192-CBC / AES-256-CBC / CHACHA20-POLY1305,默认 AES-128-GCM;AES-CBC 会按 AES-128-CBC 处理
# data-ciphers: [AES-256-GCM, AES-128-GCM] # 数据通道 cipher 协商列表,发送 IV_CIPHERS 给服务端;服务端 push 的 cipher 列表与本地列表取交集,取第一个匹配项
# data-ciphers-fallback: AES-128-CBC # 协商失败时的回退 cipher(对应 --data-ciphers-fallback)
# auth: SHA256 # 支持 MD5 / SHA1 / SHA256 / SHA384 / SHA512,默认 SHA256;AEAD cipher 会忽略 auth
# comp-lzo: "no" # 可选值:"yes", "no", "adaptive"
# username / password: auth-user-pass 模式(与下方 cert+key 二选一,不能都不填)
Expand Down Expand Up @@ -1386,12 +1388,17 @@ proxies: # socks5
# ...
# -----END OpenVPN Static key V1-----
# key-direction: "1" # tls-auth 使用,支持 "1" 或 "0";如果不填或为空字符串,则为双向模式(bidirectional)
# 从 .ovpn 中复制 <tls-crypt></tls-crypt> 内的内容,不需要保留 <tls-crypt> 标签;没有 tls-crypt 的配置可省略
# 从 .ovpn 中复制 <tls-crypt></tls-crypt> 内的内容,不需要保留 <tls-crypt> 标签;没有 tls-crypt 的配置可省略;与 tls-auth / tls-crypt-v2 互斥
# tls-crypt: |
# -----BEGIN OpenVPN Static key V1-----
# 00000000000000000000000000000000
# ...
# -----END OpenVPN Static key V1-----
# 从 .ovpn 中复制 <tls-crypt-v2></tls-crypt-v2> 内的内容;tls-crypt-v2 使用独立客户端密钥(PEM 内含 256 字节密钥 + wrapped key);与 tls-auth / tls-crypt 互斥
# tls-crypt-v2: |
# -----BEGIN OpenVPN tls-crypt-v2 client key-----
# AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8...
# -----END OpenVPN tls-crypt-v2 client key-----
# peer-info: # 透传给服务端的 peer-info 键值对,追加在内置 IV_VER/IV_PROTO/IV_CIPHERS 之后;用于服务端基于 peer-info 做准入决策
# IV_HWADDR: "52:54:00:ff:72:87"
# UV_DEVICE_ID: "laptop-001"
Expand Down
154 changes: 134 additions & 20 deletions transport/openvpn/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"io"
"net"
"strings"
"sync"
"sync/atomic"
"time"

Expand All @@ -18,6 +19,11 @@ import (

const (
ControlRetransmitDelay = time.Second

// renegotiateTimeout is the maximum time allowed for a TLS renegotiation
// (rekey) cycle. OpenVPN servers typically rekey every hour; the
// renegotiation itself should complete in seconds.
renegotiateTimeout = 30 * time.Second
)

type Client struct {
Expand All @@ -29,6 +35,14 @@ type Client struct {
data *DataChannel
push *PushReply

// negotiatedCipher is the data channel cipher selected during the most
// recent key exchange.
negotiatedCipher string

// dataLock protects c.data during TLS renegotiation (rekey), where the
// DataChannel is atomically replaced.
dataLock sync.RWMutex

runCtx context.Context
cancel context.CancelFunc

Expand All @@ -46,7 +60,13 @@ func NewClient(config *ClientConfig, io PacketIO) (*Client, error) {
return nil, errors.New("nil openvpn packet io")
}
var crypt ControlCryptor
if len(config.TLSCryptKey) > 0 {
if len(config.TLSCryptV2Key) > 0 || len(config.TLSCryptV2WrappedKey) > 0 {
var err error
crypt, err = NewTLSCryptV2(config.TLSCryptV2Key, config.TLSCryptV2WrappedKey)
if err != nil {
return nil, err
}
} else if len(config.TLSCryptKey) > 0 {
var err error
crypt, err = NewTLSCrypt(config.TLSCryptKey, true)
if err != nil {
Expand Down Expand Up @@ -103,9 +123,28 @@ func (c *Client) Handshake(ctx context.Context) (*PushReply, error) {
return nil, fmt.Errorf("openvpn tls handshake: %w", err)
}

push, err := c.doKeyExchange(ctx)
if err != nil {
return nil, err
}
_ = c.tlsConn.SetDeadline(time.Time{})
go c.watchControl()
return push, nil
}

// doKeyExchange performs the OpenVPN key method 2 exchange over the TLS
// control channel and creates a fresh data channel. It is used both for the
// initial handshake and for subsequent TLS renegotiations (rekeys).
// On success, c.data is atomically replaced with the new DataChannel.
func (c *Client) doKeyExchange(ctx context.Context) (*PushReply, error) {
primaryCipher := c.config.Cipher
if len(c.config.DataCiphers) > 0 {
primaryCipher = normalizeCipher(c.config.DataCiphers[0])
}

clientRecord, err := NewClientKeyMethod2Record(
InstallScriptOptionsString(c.config.Proto, c.config.Cipher, c.config.Auth, c.config.CompLZO),
InstallScriptPeerInfo(c.config.Cipher, c.config.CompLZO, c.config.PeerInfo),
InstallScriptOptionsString(c.config.Proto, primaryCipher, c.config.Auth, c.config.CompLZO),
InstallScriptPeerInfo(primaryCipher, c.config.DataCiphers, c.config.CompLZO, c.config.PeerInfo),
strings.TrimSpace(c.config.Username),
c.config.Password,
)
Expand All @@ -124,9 +163,12 @@ func (c *Client) Handshake(ctx context.Context) (*PushReply, error) {
return nil, err
}

// Derive keys using the maximum cipher key length (32 bytes). The actual
// cipher is determined after the push reply, and keys are sliced to the
// correct length at that point.
sources := clientRecord.Sources
sources.Server = serverRecord.Sources.Server
keys, err := DeriveClientKeyMaterial(sources, c.control.LocalSessionID(), c.control.RemoteSessionID(), c.config.DataCipherKeyLength())
keys, err := DeriveClientKeyMaterial(sources, c.control.LocalSessionID(), c.control.RemoteSessionID(), 32)
if err != nil {
return nil, fmt.Errorf("derive data channel keys: %w", err)
}
Expand All @@ -139,14 +181,30 @@ func (c *Client) Handshake(ctx context.Context) (*PushReply, error) {
return nil, err
}
c.push = push
c.data, err = NewDataChannel(keys, c.config.Cipher, c.config.Auth, push.PeerID)

// Negotiate the data channel cipher based on the push reply.
negotiatedCipher, err := c.config.NegotiateCipher(push.DataCiphers, push.Cipher)
if err != nil {
return nil, fmt.Errorf("negotiate data cipher: %w", err)
}
c.negotiatedCipher = negotiatedCipher

// Slice the derived keys to the negotiated cipher's key length.
cipherKeyLen := CipherKeyLength(negotiatedCipher)
keys.SendCipherKey = keys.SendCipherKey[:cipherKeyLen]
keys.RecvCipherKey = keys.RecvCipherKey[:cipherKeyLen]

newData, err := NewDataChannel(keys, negotiatedCipher, c.config.Auth, push.PeerID)
if err != nil {
return nil, err
}
c.dataLock.Lock()
oldData := c.data
c.data = newData
c.dataLock.Unlock()
_ = oldData
c.markSend()
c.markReceive()
_ = c.tlsConn.SetDeadline(time.Time{})
go c.watchControl()
return push, nil
}

Expand All @@ -159,21 +217,26 @@ func (c *Client) WritePing(ctx context.Context) error {
}

func (c *Client) writeDataPacket(ctx context.Context, packet []byte, compress bool) error {
if c.data == nil {
return errors.New("openvpn data channel is not ready")
}
if err := c.writeSem.Acquire(ctx, 1); err != nil {
return err
}
defer c.writeSem.Release(1)
// Acquire the data channel after securing the write semaphore, since a
// rekey may swap c.data while Acquire is blocked.
c.dataLock.RLock()
data := c.data
c.dataLock.RUnlock()
if data == nil {
return errors.New("openvpn data channel is not ready")
}
if compress && c.config.CompLZO == CompLzoYes {
compressed, err := lzo1xCompressSafe(packet)
if err != nil {
return err
}
packet = compressed
}
encrypted, err := c.data.Encrypt(packet)
encrypted, err := data.Encrypt(packet)
if err != nil {
return err
}
Expand All @@ -186,15 +249,20 @@ func (c *Client) writeDataPacket(ctx context.Context, packet []byte, compress bo
}

func (c *Client) ReadIPPacket(ctx context.Context) ([]byte, error) {
if c.data == nil {
return nil, errors.New("openvpn data channel is not ready")
}
for {
packet, err := c.mux.ReadDataPacket(ctx)
if err != nil {
return nil, err
}
plain, err := c.data.Decrypt(packet)
// Re-acquire the data channel after reading, since a rekey may have
// swapped c.data while ReadDataPacket was blocked.
c.dataLock.RLock()
data := c.data
c.dataLock.RUnlock()
if data == nil {
return nil, errors.New("openvpn data channel is not ready")
}
plain, err := data.Decrypt(packet)
if err != nil {
continue
}
Expand All @@ -209,12 +277,55 @@ func (c *Client) ReadIPPacket(ctx context.Context) ([]byte, error) {
}
}

// watchControl terminates the client when the established control channel
// starts a new key epoch or otherwise stops.
// watchControl monitors the control channel for TLS renegotiation requests
// (soft resets / rekeys). When the server initiates a rekey, the client
// performs a full TLS renegotiation followed by a new key method 2 exchange,
// then atomically swaps in a fresh DataChannel. If renegotiation fails or
// the control channel stops, the client is terminated.
func (c *Client) watchControl() {
_ = c.control.waitForSoftReset(c.runCtx)
c.cancel()
_ = c.mux.Close()
for {
err := c.control.waitForSoftReset(c.runCtx)
if err != nil {
c.cancel()
_ = c.mux.Close()
return
}
if err := c.renegotiate(); err != nil {
c.cancel()
_ = c.mux.Close()
return
}
}
}

// errRenegotiateNoTLS is returned when renegotiate() is called before a TLS
// connection has been established.
var errRenegotiateNoTLS = errors.New("cannot renegotiate: tls connection not established")

// renegotiate performs a single TLS renegotiation cycle:
// 1. Send our own soft reset to acknowledge the server's rekey request
// 2. Renegotiate the TLS session on the existing tlsConn
// 3. Exchange fresh key method 2 records and derive new data channel keys
// 4. Atomically replace c.data with the new DataChannel
func (c *Client) renegotiate() error {
if c.tlsConn == nil {
return errRenegotiateNoTLS
}
renegCtx, cancel := context.WithTimeout(c.runCtx, renegotiateTimeout)
defer cancel()

if err := c.control.SendSoftReset(renegCtx); err != nil {
return fmt.Errorf("send soft reset: %w", err)
}

if err := c.tlsConn.HandshakeContext(renegCtx); err != nil {
return fmt.Errorf("tls renegotiation: %w", err)
}

if _, err := c.doKeyExchange(renegCtx); err != nil {
return fmt.Errorf("rekey exchange: %w", err)
}
return nil
}

func (c *Client) SinceSend() time.Duration {
Expand Down Expand Up @@ -353,6 +464,9 @@ func (c *Client) tlsConfig() (*tls.Config, error) {
cfg := &tls.Config{
InsecureSkipVerify: true,
VerifyConnection: verify,
// Allow the server to initiate TLS renegotiation (rekey). OpenVPN
// servers rekey the control channel at regular intervals (default 1h).
Renegotiation: tls.RenegotiateFreelyAsClient,
}
certPEM := bytes.TrimSpace(c.config.Cert)
keyPEM := bytes.TrimSpace(c.config.Key)
Expand Down
Loading