From 8fcc41eda24b013c8df240dd9117a90f4d0f1213 Mon Sep 17 00:00:00 2001 From: philoinovsky Date: Thu, 9 Apr 2026 18:49:23 +0800 Subject: [PATCH] add support for anytls --- .gitignore | 2 + feature.go | 1 + proxy/anytls/anytls.go | 191 +++++++++++++++++++ proxy/anytls/session/client.go | 196 +++++++++++++++++++ proxy/anytls/session/frame.go | 36 ++++ proxy/anytls/session/session.go | 325 ++++++++++++++++++++++++++++++++ proxy/anytls/session/stream.go | 88 +++++++++ 7 files changed, 839 insertions(+) create mode 100644 proxy/anytls/anytls.go create mode 100644 proxy/anytls/session/client.go create mode 100644 proxy/anytls/session/frame.go create mode 100644 proxy/anytls/session/session.go create mode 100644 proxy/anytls/session/stream.go diff --git a/.gitignore b/.gitignore index 17b3186..77ec1f7 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,5 @@ config/rules.d/*.list glider /bak/ /rules.d/ + +CLAUDE.md \ No newline at end of file diff --git a/feature.go b/feature.go index 21b49fc..9489a92 100644 --- a/feature.go +++ b/feature.go @@ -5,6 +5,7 @@ import ( // _ "github.com/nadoo/glider/service/xxx" // comment out the protocols you don't need to make the compiled binary smaller. + _ "github.com/nadoo/glider/proxy/anytls" _ "github.com/nadoo/glider/proxy/http" _ "github.com/nadoo/glider/proxy/kcp" _ "github.com/nadoo/glider/proxy/mixed" diff --git a/proxy/anytls/anytls.go b/proxy/anytls/anytls.go new file mode 100644 index 0000000..de8cb8c --- /dev/null +++ b/proxy/anytls/anytls.go @@ -0,0 +1,191 @@ +// Protocol spec: +// https://github.com/anytls/anytls-go/blob/main/docs/protocol.md + +package anytls + +import ( + "context" + "crypto/sha256" + "crypto/tls" + "crypto/x509" + "encoding/binary" + "errors" + "fmt" + "net" + "net/url" + "os" + "strconv" + "strings" + "time" + + "github.com/nadoo/glider/pkg/log" + "github.com/nadoo/glider/pkg/socks" + "github.com/nadoo/glider/proxy" + "github.com/nadoo/glider/proxy/anytls/session" +) + +func init() { + proxy.RegisterDialer("anytls", NewAnyTLSDialer) + proxy.AddUsage("anytls", ` +AnyTLS scheme: + anytls://password@host:port[?serverName=SERVERNAME][&skipVerify=true][&cert=PATH][&idleTimeout=SECONDS][&idleCheckInterval=SECONDS] +`) +} + +// AnyTLS implements the anytls protocol client. +type AnyTLS struct { + dialer proxy.Dialer + addr string + password []byte // sha256 hash + tlsConfig *tls.Config + serverName string + skipVerify bool + certFile string + + idleCheckInterval time.Duration + idleTimeout time.Duration + + client *session.Client +} + +// NewAnyTLSDialer returns a new anytls dialer. +func NewAnyTLSDialer(s string, d proxy.Dialer) (proxy.Dialer, error) { + u, err := url.Parse(s) + if err != nil { + return nil, fmt.Errorf("[anytls] parse url err: %s", err) + } + + pass := u.User.Username() + if pass == "" { + return nil, errors.New("[anytls] password must be specified") + } + + query := u.Query() + + a := &AnyTLS{ + dialer: d, + addr: u.Host, + skipVerify: query.Get("skipVerify") == "true", + serverName: query.Get("serverName"), + certFile: query.Get("cert"), + } + + // default port + if a.addr != "" { + if _, port, _ := net.SplitHostPort(a.addr); port == "" { + a.addr = net.JoinHostPort(a.addr, "443") + } + if a.serverName == "" { + a.serverName = a.addr[:strings.LastIndex(a.addr, ":")] + } + } + + // password sha256 + hash := sha256.Sum256([]byte(pass)) + a.password = hash[:] + + // idle session config + a.idleCheckInterval = 30 * time.Second + a.idleTimeout = 60 * time.Second + if v := query.Get("idleCheckInterval"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + a.idleCheckInterval = time.Duration(n) * time.Second + } + } + if v := query.Get("idleTimeout"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + a.idleTimeout = time.Duration(n) * time.Second + } + } + + // tls config + a.tlsConfig = &tls.Config{ + ServerName: a.serverName, + InsecureSkipVerify: a.skipVerify, + MinVersion: tls.VersionTLS12, + } + + if a.certFile != "" { + certData, err := os.ReadFile(a.certFile) + if err != nil { + return nil, fmt.Errorf("[anytls] read cert file error: %s", err) + } + certPool := x509.NewCertPool() + if !certPool.AppendCertsFromPEM(certData) { + return nil, fmt.Errorf("[anytls] can not append cert file: %s", a.certFile) + } + a.tlsConfig.RootCAs = certPool + } + + // session pool client + a.client = session.NewClient( + context.Background(), + a.createOutboundConn, + a.idleCheckInterval, + a.idleTimeout, + ) + + return a, nil +} + +// createOutboundConn dials to the server, performs TLS handshake, and sends authentication. +func (a *AnyTLS) createOutboundConn(ctx context.Context) (net.Conn, error) { + rc, err := a.dialer.Dial("tcp", a.addr) + if err != nil { + return nil, fmt.Errorf("[anytls] dial to %s error: %s", a.addr, err) + } + + tlsConn := tls.Client(rc, a.tlsConfig) + if err := tlsConn.Handshake(); err != nil { + rc.Close() + return nil, fmt.Errorf("[anytls] tls handshake error: %s", err) + } + + // Send authentication: sha256(password) + padding_length(uint16) + padding + var buf [34]byte // 32 bytes hash + 2 bytes padding length (0) + copy(buf[:32], a.password) + binary.BigEndian.PutUint16(buf[32:34], 0) + + if _, err := tlsConn.Write(buf[:]); err != nil { + tlsConn.Close() + return nil, fmt.Errorf("[anytls] auth write error: %s", err) + } + + return tlsConn, nil +} + +// Addr returns the forwarder's address. +func (a *AnyTLS) Addr() string { + if a.addr == "" { + return a.dialer.Addr() + } + return a.addr +} + +// Dial connects to the address addr on the network net via the proxy. +func (a *AnyTLS) Dial(network, addr string) (net.Conn, error) { + stream, err := a.client.CreateStream(context.Background()) + if err != nil { + log.F("[anytls] create stream error: %s", err) + return nil, err + } + + // Write SOCKS address to indicate the destination + target := socks.ParseAddr(addr) + if target == nil { + stream.Close() + return nil, fmt.Errorf("[anytls] failed to parse target address: %s", addr) + } + + if _, err := stream.Write(target); err != nil { + stream.Close() + return nil, fmt.Errorf("[anytls] failed to write target address: %s", err) + } + + return stream, nil +} + +// DialUDP connects to the given address via the proxy. +func (a *AnyTLS) DialUDP(network, addr string) (net.PacketConn, error) { + return nil, proxy.ErrNotSupported +} diff --git a/proxy/anytls/session/client.go b/proxy/anytls/session/client.go new file mode 100644 index 0000000..85d56d3 --- /dev/null +++ b/proxy/anytls/session/client.go @@ -0,0 +1,196 @@ +package session + +import ( + "context" + "io" + "net" + "sync" + "sync/atomic" + "time" +) + +// DialFunc is a function that creates a new outbound connection. +type DialFunc func(ctx context.Context) (net.Conn, error) + +// Client manages a pool of sessions for stream multiplexing. +type Client struct { + ctx context.Context + cancel context.CancelFunc + + dialOut DialFunc + + sessionCounter atomic.Uint64 + + idleSessions []*Session + idleLock sync.Mutex + + sessions map[uint64]*Session + sessionsLock sync.Mutex + + idleTimeout time.Duration +} + +// NewClient creates a new session pool client. +func NewClient(ctx context.Context, dialOut DialFunc, idleCheckInterval, idleTimeout time.Duration) *Client { + if idleCheckInterval < 5*time.Second { + idleCheckInterval = 30 * time.Second + } + if idleTimeout < 5*time.Second { + idleTimeout = 30 * time.Second + } + + c := &Client{ + dialOut: dialOut, + sessions: make(map[uint64]*Session), + idleTimeout: idleTimeout, + } + c.ctx, c.cancel = context.WithCancel(ctx) + + go c.idleCleanupLoop(idleCheckInterval) + return c +} + +// CreateStream opens a new stream, reusing an idle session or creating a new one. +func (c *Client) CreateStream(ctx context.Context) (net.Conn, error) { + select { + case <-c.ctx.Done(): + return nil, io.ErrClosedPipe + default: + } + + sess := c.getIdleSession() + if sess == nil { + var err error + sess, err = c.createSession(ctx) + if err != nil { + return nil, err + } + } + + stream, err := sess.OpenStream() + if err != nil { + sess.Close() + return nil, err + } + + // When the stream closes, return the session to the idle pool. + stream.CloseFunc = func() error { + err := stream.CloseRemote() + if !sess.IsClosed() { + select { + case <-c.ctx.Done(): + go sess.Close() + default: + c.idleLock.Lock() + sess.IdleSince = time.Now() + c.idleSessions = append(c.idleSessions, sess) + c.idleLock.Unlock() + } + } + return err + } + + return stream, nil +} + +func (c *Client) getIdleSession() *Session { + c.idleLock.Lock() + defer c.idleLock.Unlock() + + // Reuse the newest idle session (last in slice). + for len(c.idleSessions) > 0 { + n := len(c.idleSessions) + sess := c.idleSessions[n-1] + c.idleSessions = c.idleSessions[:n-1] + if !sess.IsClosed() { + return sess + } + } + return nil +} + +func (c *Client) createSession(ctx context.Context) (*Session, error) { + conn, err := c.dialOut(ctx) + if err != nil { + return nil, err + } + + sess := NewClientSession(conn) + sess.Seq = c.sessionCounter.Add(1) + sess.DieHook = func() { + c.idleLock.Lock() + for i, s := range c.idleSessions { + if s == sess { + c.idleSessions = append(c.idleSessions[:i], c.idleSessions[i+1:]...) + break + } + } + c.idleLock.Unlock() + + c.sessionsLock.Lock() + delete(c.sessions, sess.Seq) + c.sessionsLock.Unlock() + } + + c.sessionsLock.Lock() + c.sessions[sess.Seq] = sess + c.sessionsLock.Unlock() + + sess.Run() + return sess, nil +} + +// Close shuts down the client and all sessions. +func (c *Client) Close() error { + c.cancel() + + c.sessionsLock.Lock() + toClose := make([]*Session, 0, len(c.sessions)) + for _, sess := range c.sessions { + toClose = append(toClose, sess) + } + c.sessions = make(map[uint64]*Session) + c.sessionsLock.Unlock() + + for _, sess := range toClose { + sess.Close() + } + return nil +} + +func (c *Client) idleCleanupLoop(interval time.Duration) { + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-c.ctx.Done(): + return + case <-ticker.C: + c.idleCleanup() + } + } +} + +func (c *Client) idleCleanup() { + expTime := time.Now().Add(-c.idleTimeout) + var toClose []*Session + + c.idleLock.Lock() + remaining := c.idleSessions[:0] + for _, sess := range c.idleSessions { + if sess.IsClosed() { + continue + } + if sess.IdleSince.Before(expTime) { + toClose = append(toClose, sess) + } else { + remaining = append(remaining, sess) + } + } + c.idleSessions = remaining + c.idleLock.Unlock() + + for _, sess := range toClose { + sess.Close() + } +} diff --git a/proxy/anytls/session/frame.go b/proxy/anytls/session/frame.go new file mode 100644 index 0000000..fe16ca7 --- /dev/null +++ b/proxy/anytls/session/frame.go @@ -0,0 +1,36 @@ +package session + +import "encoding/binary" + +// Command types for the anytls session protocol. +const ( + cmdWaste byte = 0 // padding, discarded + cmdSYN byte = 1 // stream open + cmdPSH byte = 2 // data push + cmdFIN byte = 3 // stream close (EOF) + cmdSettings byte = 4 // client -> server settings + cmdAlert byte = 5 // alert message + cmdUpdatePaddingScheme byte = 6 // update padding scheme + cmdSYNACK byte = 7 // server -> client stream opened (v2) + cmdHeartRequest byte = 8 // keepalive request (v2) + cmdHeartResponse byte = 9 // keepalive response (v2) + cmdServerSettings byte = 10 // server -> client settings (v2) +) + +const headerSize = 1 + 4 + 2 // cmd(1) + streamID(4) + length(2) + +type frame struct { + cmd byte + sid uint32 + data []byte +} + +func newFrame(cmd byte, sid uint32) frame { + return frame{cmd: cmd, sid: sid} +} + +type rawHeader [headerSize]byte + +func (h rawHeader) Cmd() byte { return h[0] } +func (h rawHeader) StreamID() uint32 { return binary.BigEndian.Uint32(h[1:]) } +func (h rawHeader) Length() uint16 { return binary.BigEndian.Uint16(h[5:]) } diff --git a/proxy/anytls/session/session.go b/proxy/anytls/session/session.go new file mode 100644 index 0000000..ee31300 --- /dev/null +++ b/proxy/anytls/session/session.go @@ -0,0 +1,325 @@ +package session + +import ( + "encoding/binary" + "fmt" + "io" + "net" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/nadoo/glider/pkg/log" +) + +const protocolVersion = "2" +const clientName = "anytls/0.0.11" + +// Session multiplexes streams over a single connection. +type Session struct { + conn net.Conn + connLock sync.Mutex + + streams map[uint32]*Stream + streamID atomic.Uint32 + streamLock sync.RWMutex + + dieOnce sync.Once + die chan struct{} + DieHook func() + + // pool fields + Seq uint64 + IdleSince time.Time + + peerVersion byte + + // buffering: buffer initial frames until first stream opens + buffering bool + buffer []byte +} + +// NewClientSession creates a new client-side session. +func NewClientSession(conn net.Conn) *Session { + s := &Session{ + conn: conn, + buffering: true, + die: make(chan struct{}), + streams: make(map[uint32]*Stream), + } + return s +} + +// Run starts the session. For clients, it sends settings then starts the recv loop. +func (s *Session) Run() { + settings := fmt.Sprintf("v=%s\nclient=%s\n", protocolVersion, clientName) + f := newFrame(cmdSettings, 0) + f.data = []byte(settings) + s.writeControlFrame(f) + + go s.recvLoop() +} + +// IsClosed returns true if the session is closed. +func (s *Session) IsClosed() bool { + select { + case <-s.die: + return true + default: + return false + } +} + +// Close closes the session and all its streams. +func (s *Session) Close() error { + var once bool + s.dieOnce.Do(func() { + close(s.die) + once = true + }) + if once { + if s.DieHook != nil { + s.DieHook() + s.DieHook = nil + } + s.streamLock.Lock() + for _, stream := range s.streams { + stream.closeLocally() + } + s.streams = make(map[uint32]*Stream) + s.streamLock.Unlock() + return s.conn.Close() + } + return io.ErrClosedPipe +} + +// OpenStream opens a new stream on the session. +func (s *Session) OpenStream() (*Stream, error) { + if s.IsClosed() { + return nil, io.ErrClosedPipe + } + + sid := s.streamID.Add(1) + stream := newStream(sid, s) + + if _, err := s.writeControlFrame(newFrame(cmdSYN, sid)); err != nil { + return nil, err + } + + s.buffering = false + + s.streamLock.Lock() + defer s.streamLock.Unlock() + select { + case <-s.die: + return nil, io.ErrClosedPipe + default: + s.streams[sid] = stream + return stream, nil + } +} + +func (s *Session) recvLoop() { + defer s.Close() + + var hdr rawHeader + for { + if s.IsClosed() { + return + } + + if _, err := io.ReadFull(s.conn, hdr[:]); err != nil { + return + } + + sid := hdr.StreamID() + dataLen := int(hdr.Length()) + + switch hdr.Cmd() { + case cmdPSH: + if dataLen > 0 { + buf := make([]byte, dataLen) + if _, err := io.ReadFull(s.conn, buf); err != nil { + return + } + s.streamLock.RLock() + stream, ok := s.streams[sid] + s.streamLock.RUnlock() + if ok { + stream.pw.Write(buf) + } + } + + case cmdFIN: + s.streamLock.Lock() + stream, ok := s.streams[sid] + delete(s.streams, sid) + s.streamLock.Unlock() + if ok { + stream.closeLocally() + } + + case cmdSYNACK: + if dataLen > 0 { + buf := make([]byte, dataLen) + if _, err := io.ReadFull(s.conn, buf); err != nil { + return + } + // non-empty SYNACK means handshake failure + s.streamLock.RLock() + stream, ok := s.streams[sid] + s.streamLock.RUnlock() + if ok { + stream.dieErr = fmt.Errorf("remote: %s", string(buf)) + stream.pr.CloseWithError(stream.dieErr) + } + } + + case cmdWaste: + if dataLen > 0 { + buf := make([]byte, dataLen) + if _, err := io.ReadFull(s.conn, buf); err != nil { + return + } + // discard + } + + case cmdAlert: + if dataLen > 0 { + buf := make([]byte, dataLen) + if _, err := io.ReadFull(s.conn, buf); err != nil { + return + } + log.F("[anytls] alert from server: %s", string(buf)) + return + } + + case cmdServerSettings: + if dataLen > 0 { + buf := make([]byte, dataLen) + if _, err := io.ReadFull(s.conn, buf); err != nil { + return + } + m := parseStringMap(string(buf)) + if v, err := strconv.Atoi(m["v"]); err == nil { + s.peerVersion = byte(v) + } + } + + case cmdUpdatePaddingScheme: + // We don't implement dynamic padding updates for simplicity; + // just consume the data. + if dataLen > 0 { + buf := make([]byte, dataLen) + if _, err := io.ReadFull(s.conn, buf); err != nil { + return + } + } + + case cmdHeartRequest: + s.writeControlFrame(newFrame(cmdHeartResponse, sid)) + + case cmdHeartResponse: + // no-op + + case cmdSettings: + // Server shouldn't send this to client, but consume anyway + if dataLen > 0 { + buf := make([]byte, dataLen) + if _, err := io.ReadFull(s.conn, buf); err != nil { + return + } + } + + default: + // Unknown command: consume data + if dataLen > 0 { + buf := make([]byte, dataLen) + if _, err := io.ReadFull(s.conn, buf); err != nil { + return + } + } + } + } +} + +func (s *Session) streamClosed(sid uint32) error { + if s.IsClosed() { + return io.ErrClosedPipe + } + _, err := s.writeControlFrame(newFrame(cmdFIN, sid)) + s.streamLock.Lock() + delete(s.streams, sid) + s.streamLock.Unlock() + return err +} + +func (s *Session) writeDataFrame(sid uint32, data []byte) (int, error) { + dataLen := len(data) + buf := make([]byte, headerSize+dataLen) + buf[0] = cmdPSH + binary.BigEndian.PutUint32(buf[1:5], sid) + binary.BigEndian.PutUint16(buf[5:7], uint16(dataLen)) + copy(buf[headerSize:], data) + + _, err := s.writeConn(buf) + if err != nil { + return 0, err + } + return dataLen, nil +} + +func (s *Session) writeControlFrame(f frame) (int, error) { + dataLen := len(f.data) + buf := make([]byte, headerSize+dataLen) + buf[0] = f.cmd + binary.BigEndian.PutUint32(buf[1:5], f.sid) + binary.BigEndian.PutUint16(buf[5:7], uint16(dataLen)) + copy(buf[headerSize:], f.data) + + s.conn.SetWriteDeadline(time.Now().Add(5 * time.Second)) + _, err := s.writeConn(buf) + if err != nil { + s.Close() + return 0, err + } + s.conn.SetWriteDeadline(time.Time{}) + return dataLen, nil +} + +func (s *Session) writeConn(b []byte) (int, error) { + s.connLock.Lock() + defer s.connLock.Unlock() + + if s.buffering { + s.buffer = append(s.buffer, b...) + return len(b), nil + } + + if len(s.buffer) > 0 { + b = append(s.buffer, b...) + s.buffer = nil + } + + return s.conn.Write(b) +} + +// NumStreams returns the number of active streams. +func (s *Session) NumStreams() int { + s.streamLock.RLock() + defer s.streamLock.RUnlock() + return len(s.streams) +} + +// parseStringMap parses newline-separated key=value pairs. +func parseStringMap(s string) map[string]string { + m := make(map[string]string) + for _, line := range strings.Split(s, "\n") { + if k, v, ok := strings.Cut(line, "="); ok { + m[strings.TrimSpace(k)] = strings.TrimSpace(v) + } + } + return m +} diff --git a/proxy/anytls/session/stream.go b/proxy/anytls/session/stream.go new file mode 100644 index 0000000..cde20d6 --- /dev/null +++ b/proxy/anytls/session/stream.go @@ -0,0 +1,88 @@ +package session + +import ( + "io" + "net" + "sync" + "time" +) + +// Stream implements net.Conn over a multiplexed session. +type Stream struct { + id uint32 + sess *Session + + pr *io.PipeReader + pw *io.PipeWriter + + dieOnce sync.Once + dieErr error + CloseFunc func() error // overridable close hook +} + +func newStream(id uint32, sess *Session) *Stream { + pr, pw := io.Pipe() + return &Stream{ + id: id, + sess: sess, + pr: pr, + pw: pw, + } +} + +func (s *Stream) Read(b []byte) (int, error) { + return s.pr.Read(b) +} + +func (s *Stream) Write(b []byte) (int, error) { + if s.dieErr != nil { + return 0, s.dieErr + } + return s.sess.writeDataFrame(s.id, b) +} + +func (s *Stream) Close() error { + if s.CloseFunc != nil { + return s.CloseFunc() + } + return s.CloseRemote() +} + +func (s *Stream) CloseRemote() error { + var once bool + s.dieOnce.Do(func() { + s.dieErr = io.ErrClosedPipe + s.pr.Close() + once = true + }) + if once { + return s.sess.streamClosed(s.id) + } + return s.dieErr +} + +// closeLocally closes the stream without notifying the remote peer. +func (s *Stream) closeLocally() { + s.dieOnce.Do(func() { + s.dieErr = net.ErrClosed + s.pr.Close() + }) +} + +func (s *Stream) LocalAddr() net.Addr { + if ts, ok := s.sess.conn.(interface{ LocalAddr() net.Addr }); ok { + return ts.LocalAddr() + } + return nil +} + +func (s *Stream) RemoteAddr() net.Addr { + if ts, ok := s.sess.conn.(interface{ RemoteAddr() net.Addr }); ok { + return ts.RemoteAddr() + } + return nil +} + +func (s *Stream) SetDeadline(t time.Time) error { return nil } +func (s *Stream) SetReadDeadline(t time.Time) error { return nil } +func (s *Stream) SetWriteDeadline(t time.Time) error { return nil }