From 1ff2bab1f0007a346b2073e5963065b15a5bb114 Mon Sep 17 00:00:00 2001 From: ginuerzh Date: Fri, 11 Nov 2022 22:23:36 +0800 Subject: [PATCH] add vhost for port forwarding --- chain/hop.go | 35 ++++++++- config/config.go | 2 + config/parsing/chain.go | 1 + config/parsing/service.go | 1 + go.mod | 12 +-- go.sum | 12 +++ handler/forward/internal/forward/forward.go | 84 +++++++++++++++++++++ handler/forward/local/handler.go | 27 +++++-- handler/forward/local/metadata.go | 3 + handler/forward/remote/handler.go | 23 ++++-- handler/forward/remote/metadata.go | 3 + internal/io/io.go | 15 ++++ 12 files changed, 195 insertions(+), 23 deletions(-) create mode 100644 handler/forward/internal/forward/forward.go create mode 100644 internal/io/io.go diff --git a/chain/hop.go b/chain/hop.go index 54c608c..70889c9 100644 --- a/chain/hop.go +++ b/chain/hop.go @@ -2,6 +2,7 @@ package chain import ( "context" + "net" "github.com/go-gost/core/bypass" "github.com/go-gost/core/chain" @@ -71,19 +72,47 @@ func (p *chainHop) Select(ctx context.Context, opts ...chain.SelectOption) *chai } // hop level bypass - if p.options.bypass != nil && p.options.bypass.Contains(options.Addr) { + if p.options.bypass != nil && + p.options.bypass.Contains(options.Addr) { return nil } + filters := p.nodes + if host := options.Host; host != "" { + filters = nil + if v, _, _ := net.SplitHostPort(host); v != "" { + host = v + } + var nodes []*chain.Node + for _, node := range p.nodes { + if node == nil { + continue + } + if node.Options().Host == "" { + nodes = append(nodes, node) + continue + } + if node.Options().Host == host { + filters = append(filters, node) + p.options.logger.Debugf("find node for host: %s", host) + } + } + if len(filters) == 0 { + filters = nodes + } + } + var nodes []*chain.Node - for _, node := range p.nodes { + for _, node := range filters { if node == nil { continue } // node level bypass - if node.Options().Bypass != nil && node.Options().Bypass.Contains(options.Addr) { + if node.Options().Bypass != nil && + node.Options().Bypass.Contains(options.Addr) { continue } + nodes = append(nodes, node) } if len(nodes) == 0 { diff --git a/config/config.go b/config/config.go index 0fb7b10..c74867f 100644 --- a/config/config.go +++ b/config/config.go @@ -260,6 +260,7 @@ type ForwarderConfig struct { type ForwardNodeConfig struct { Name string `yaml:",omitempty" json:"name,omitempty"` Addr string `yaml:",omitempty" json:"addr,omitempty"` + Host string `yaml:",omitempty" json:"host,omitempty"` Bypass string `yaml:",omitempty" json:"bypass,omitempty"` Bypasses []string `yaml:",omitempty" json:"bypasses,omitempty"` } @@ -333,6 +334,7 @@ type HopConfig struct { type NodeConfig struct { Name string `json:"name"` Addr string `yaml:",omitempty" json:"addr,omitempty"` + Host string `yaml:",omitempty" json:"host,omitempty"` Interface string `yaml:",omitempty" json:"interface,omitempty"` SockOpts *SockOptsConfig `yaml:"sockopts,omitempty" json:"sockopts,omitempty"` Bypass string `yaml:",omitempty" json:"bypass,omitempty"` diff --git a/config/parsing/chain.go b/config/parsing/chain.go index 445c1c9..bb5894d 100644 --- a/config/parsing/chain.go +++ b/config/parsing/chain.go @@ -202,6 +202,7 @@ func ParseHop(cfg *config.HopConfig) (chain.Hop, error) { chain.ResoloverNodeOption(registry.ResolverRegistry().Get(v.Resolver)), chain.HostMapperNodeOption(registry.HostsRegistry().Get(v.Hosts)), chain.MetadataNodeOption(nm), + chain.HostNodeOption(v.Host), ) nodes = append(nodes, node) } diff --git a/config/parsing/service.go b/config/parsing/service.go index 518b651..12c6365 100644 --- a/config/parsing/service.go +++ b/config/parsing/service.go @@ -235,6 +235,7 @@ func parseForwarder(cfg *config.ForwarderConfig) (chain.Hop, error) { &config.NodeConfig{ Name: node.Name, Addr: node.Addr, + Host: node.Host, Bypass: node.Bypass, Bypasses: node.Bypasses, }, diff --git a/go.mod b/go.mod index 29237bd..8f4818a 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ require ( github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d github.com/gin-contrib/cors v1.3.1 github.com/gin-gonic/gin v1.7.7 - github.com/go-gost/core v0.0.0-20221020130224-eb9d483127cc + github.com/go-gost/core v0.0.0-20221111142129-c2a1dd2a89cb github.com/go-gost/gosocks4 v0.0.1 github.com/go-gost/gosocks5 v0.3.1-0.20211109033403-d894d75b7f09 github.com/go-gost/relay v0.1.1-0.20211123134818-8ef7fd81ffd7 @@ -31,10 +31,10 @@ require ( github.com/xtaci/smux v1.5.16 github.com/xtaci/tcpraw v1.2.25 github.com/yl2chen/cidranger v1.0.2 - golang.org/x/crypto v0.0.0-20220817201139-bc19a97f63c8 - golang.org/x/net v0.0.0-20220812174116-3211cb980234 - golang.org/x/sys v0.0.0-20220817070843-5a390386f1f2 - golang.org/x/time v0.0.0-20220722155302-e5dcc9cfc0b9 + golang.org/x/crypto v0.2.0 + golang.org/x/net v0.2.0 + golang.org/x/sys v0.2.0 + golang.org/x/time v0.2.0 golang.zx2c4.com/wireguard v0.0.0-20220703234212-c31a7b1ab478 google.golang.org/grpc v1.49.0 google.golang.org/protobuf v1.28.0 @@ -91,7 +91,7 @@ require ( github.com/vishvananda/netns v0.0.0-20191106174202-0a2b9b5464df // indirect golang.org/x/exp v0.0.0-20220827204233-334a2380cb91 // indirect golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 // indirect - golang.org/x/text v0.3.7 // indirect + golang.org/x/text v0.4.0 // indirect golang.org/x/tools v0.1.12 // indirect golang.zx2c4.com/wintun v0.0.0-20211104114900-415007cec224 // indirect google.golang.org/genproto v0.0.0-20220126215142-9970aeb2e350 // indirect diff --git a/go.sum b/go.sum index 1f03a41..0750615 100644 --- a/go.sum +++ b/go.sum @@ -98,6 +98,8 @@ github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2 github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gost/core v0.0.0-20221020130224-eb9d483127cc h1:pS75VLwTkYLIC3n0QbfwE65N/1Zh8BnXfErNq9DGWd4= github.com/go-gost/core v0.0.0-20221020130224-eb9d483127cc/go.mod h1:bHVbCS9da6XtKNYMkMUVcck5UqDDUkyC37erVfs4GXQ= +github.com/go-gost/core v0.0.0-20221111142129-c2a1dd2a89cb h1:BkuYeTJYfN3nGHtnljjRBuBBXg2hTRBN9EmszZalyzg= +github.com/go-gost/core v0.0.0-20221111142129-c2a1dd2a89cb/go.mod h1:bHVbCS9da6XtKNYMkMUVcck5UqDDUkyC37erVfs4GXQ= github.com/go-gost/gosocks4 v0.0.1 h1:+k1sec8HlELuQV7rWftIkmy8UijzUt2I6t+iMPlGB2s= github.com/go-gost/gosocks4 v0.0.1/go.mod h1:3B6L47HbU/qugDg4JnoFPHgJXE43Inz8Bah1QaN9qCc= github.com/go-gost/gosocks5 v0.3.1-0.20211109033403-d894d75b7f09 h1:A95M6UWcfZgOuJkQ7QLfG0Hs5peWIUSysCDNz4pfe04= @@ -409,6 +411,8 @@ golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220817201139-bc19a97f63c8 h1:GIAS/yBem/gq2MUqgNIzUHW7cJMmx3TGZOrnyYaNQ6c= golang.org/x/crypto v0.0.0-20220817201139-bc19a97f63c8/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.2.0 h1:BRXPfhNivWL5Yq0BGQ39a2sW6t44aODpfxkWjYdzewE= +golang.org/x/crypto v0.2.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -486,6 +490,8 @@ golang.org/x/net v0.0.0-20210726213435-c6fcb2dbf985/go.mod h1:9nx3DQGgdP8bBQD5qx golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220812174116-3211cb980234 h1:RDqmgfe7SvlMWoqC3xwQ2blLO3fcWcxMa3eBLRdRW7E= golang.org/x/net v0.0.0-20220812174116-3211cb980234/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= +golang.org/x/net v0.2.0 h1:sZfSu1wtKLGlWI4ZZayP0ck9Y73K1ynO6gqzTdBVdPU= +golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -564,9 +570,12 @@ golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220817070843-5a390386f1f2 h1:fqTvyMIIj+HRzMmnzr9NtpHP6uVpvB5fkHcgPDC4nu8= golang.org/x/sys v0.0.0-20220817070843-5a390386f1f2/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.2.0 h1:ljd4t30dBnAvMZaQCevtY0xLLD0A+bRZXbgLMLU1F/A= +golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY= +golang.org/x/term v0.2.0 h1:z85xZCsEl7bi/KwbNADeBYoOP0++7W1ipu+aGnpwzRM= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -577,11 +586,14 @@ golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20220722155302-e5dcc9cfc0b9 h1:ftMN5LMiBFjbzleLqtoBZk7KdJwhuybIU+FckUHgoyQ= golang.org/x/time v0.0.0-20220722155302-e5dcc9cfc0b9/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.2.0 h1:52I/1L54xyEQAYdtcSuxtiT84KGYTBGXwayxmIpNJhE= +golang.org/x/time v0.2.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= diff --git a/handler/forward/internal/forward/forward.go b/handler/forward/internal/forward/forward.go new file mode 100644 index 0000000..9544b32 --- /dev/null +++ b/handler/forward/internal/forward/forward.go @@ -0,0 +1,84 @@ +package forward + +import ( + "bufio" + "bytes" + "context" + "crypto/tls" + "encoding/binary" + "io" + "net/http" + "strings" + + dissector "github.com/go-gost/tls-dissector" + xio "github.com/go-gost/x/internal/io" +) + +func SniffHost(ctx context.Context, rdw io.ReadWriter) (rw io.ReadWriter, host string, err error) { + rw = rdw + + // try to sniff TLS traffic + var hdr [dissector.RecordHeaderLen]byte + _, err = io.ReadFull(rw, hdr[:]) + rw = xio.NewReadWriter(io.MultiReader(bytes.NewReader(hdr[:]), rw), rw) + if err == nil && + hdr[0] == dissector.Handshake && + binary.BigEndian.Uint16(hdr[1:3]) == tls.VersionTLS10 { + return sniffSNI(ctx, rw) + } + + // try to sniff HTTP traffic + if isHTTP(string(hdr[:])) { + buf := new(bytes.Buffer) + var r *http.Request + r, err = http.ReadRequest(bufio.NewReader(io.TeeReader(rw, buf))) + rw = xio.NewReadWriter(io.MultiReader(buf, rw), rw) + if err == nil { + host = r.Host + return + } + } + + return +} + +func sniffSNI(ctx context.Context, rw io.ReadWriter) (io.ReadWriter, string, error) { + buf := new(bytes.Buffer) + host, err := getServerName(ctx, io.TeeReader(rw, buf)) + rw = xio.NewReadWriter(io.MultiReader(buf, rw), rw) + return rw, host, err +} + +func getServerName(ctx context.Context, r io.Reader) (host string, err error) { + record, err := dissector.ReadRecord(r) + if err != nil { + return + } + + clientHello := dissector.ClientHelloMsg{} + if err = clientHello.Decode(record.Opaque); err != nil { + return + } + + for _, ext := range clientHello.Extensions { + if ext.Type() == dissector.ExtServerName { + snExtension := ext.(*dissector.ServerNameExtension) + host = snExtension.Name + break + } + } + + return +} + +func isHTTP(s string) bool { + return strings.HasPrefix(http.MethodGet, s[:3]) || + strings.HasPrefix(http.MethodPost, s[:4]) || + strings.HasPrefix(http.MethodPut, s[:3]) || + strings.HasPrefix(http.MethodDelete, s) || + strings.HasPrefix(http.MethodOptions, s) || + strings.HasPrefix(http.MethodPatch, s) || + strings.HasPrefix(http.MethodHead, s[:4]) || + strings.HasPrefix(http.MethodConnect, s) || + strings.HasPrefix(http.MethodTrace, s) +} diff --git a/handler/forward/local/handler.go b/handler/forward/local/handler.go index 42f72dc..3e4c527 100644 --- a/handler/forward/local/handler.go +++ b/handler/forward/local/handler.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "io" "net" "time" @@ -11,6 +12,7 @@ import ( "github.com/go-gost/core/handler" md "github.com/go-gost/core/metadata" xchain "github.com/go-gost/x/chain" + "github.com/go-gost/x/handler/forward/internal/forward" netpkg "github.com/go-gost/x/internal/net" "github.com/go-gost/x/registry" ) @@ -84,18 +86,29 @@ func (h *forwardHandler) Handle(ctx context.Context, conn net.Conn, opts ...hand return nil } - target := h.hop.Select(ctx) + network := "tcp" + if _, ok := conn.(net.PacketConn); ok { + network = "udp" + } + + var rw io.ReadWriter + var host string + if h.md.sniffing { + if network == "tcp" { + rw, host, _ = forward.SniffHost(ctx, conn) + } + } + + var target *chain.Node + if h.hop != nil { + target = h.hop.Select(ctx, chain.HostSelectOption(host)) + } if target == nil { err := errors.New("target not available") log.Error(err) return err } - network := "tcp" - if _, ok := conn.(net.PacketConn); ok { - network = "udp" - } - log = log.WithFields(map[string]any{ "dst": fmt.Sprintf("%s/%s", target.Addr, network), }) @@ -119,7 +132,7 @@ func (h *forwardHandler) Handle(ctx context.Context, conn net.Conn, opts ...hand t := time.Now() log.Debugf("%s <-> %s", conn.RemoteAddr(), target.Addr) - netpkg.Transport(conn, cc) + netpkg.Transport(rw, cc) log.WithFields(map[string]any{ "duration": time.Since(t), }).Debugf("%s >-< %s", conn.RemoteAddr(), target.Addr) diff --git a/handler/forward/local/metadata.go b/handler/forward/local/metadata.go index 2e358c0..461585e 100644 --- a/handler/forward/local/metadata.go +++ b/handler/forward/local/metadata.go @@ -9,13 +9,16 @@ import ( type metadata struct { readTimeout time.Duration + sniffing bool } func (h *forwardHandler) parseMetadata(md mdata.Metadata) (err error) { const ( readTimeout = "readTimeout" + sniffing = "sniffing" ) h.md.readTimeout = mdutil.GetDuration(md, readTimeout) + h.md.sniffing = mdutil.GetBool(md, sniffing) return } diff --git a/handler/forward/remote/handler.go b/handler/forward/remote/handler.go index ec082eb..db35c5a 100644 --- a/handler/forward/remote/handler.go +++ b/handler/forward/remote/handler.go @@ -4,12 +4,14 @@ import ( "context" "errors" "fmt" + "io" "net" "time" "github.com/go-gost/core/chain" "github.com/go-gost/core/handler" md "github.com/go-gost/core/metadata" + "github.com/go-gost/x/handler/forward/internal/forward" netpkg "github.com/go-gost/x/internal/net" "github.com/go-gost/x/registry" ) @@ -75,9 +77,21 @@ func (h *forwardHandler) Handle(ctx context.Context, conn net.Conn, opts ...hand return nil } + network := "tcp" + if _, ok := conn.(net.PacketConn); ok { + network = "udp" + } + + var rw io.ReadWriter + var host string + if h.md.sniffing { + if network == "tcp" { + rw, host, _ = forward.SniffHost(ctx, conn) + } + } var target *chain.Node if h.hop != nil { - target = h.hop.Select(ctx) + target = h.hop.Select(ctx, chain.HostSelectOption(host)) } if target == nil { err := errors.New("target not available") @@ -85,11 +99,6 @@ func (h *forwardHandler) Handle(ctx context.Context, conn net.Conn, opts ...hand return err } - network := "tcp" - if _, ok := conn.(net.PacketConn); ok { - network = "udp" - } - log = log.WithFields(map[string]any{ "dst": fmt.Sprintf("%s/%s", target.Addr, network), }) @@ -113,7 +122,7 @@ func (h *forwardHandler) Handle(ctx context.Context, conn net.Conn, opts ...hand t := time.Now() log.Debugf("%s <-> %s", conn.RemoteAddr(), target.Addr) - netpkg.Transport(conn, cc) + netpkg.Transport(rw, cc) log.WithFields(map[string]any{ "duration": time.Since(t), }).Debugf("%s >-< %s", conn.RemoteAddr(), target.Addr) diff --git a/handler/forward/remote/metadata.go b/handler/forward/remote/metadata.go index c3dbe23..41ec68f 100644 --- a/handler/forward/remote/metadata.go +++ b/handler/forward/remote/metadata.go @@ -9,13 +9,16 @@ import ( type metadata struct { readTimeout time.Duration + sniffing bool } func (h *forwardHandler) parseMetadata(md mdata.Metadata) (err error) { const ( readTimeout = "readTimeout" + sniffing = "sniffing" ) h.md.readTimeout = mdutil.GetDuration(md, readTimeout) + h.md.sniffing = mdutil.GetBool(md, sniffing) return } diff --git a/internal/io/io.go b/internal/io/io.go new file mode 100644 index 0000000..d27bc0f --- /dev/null +++ b/internal/io/io.go @@ -0,0 +1,15 @@ +package io + +import "io" + +type readWriter struct { + io.Reader + io.Writer +} + +func NewReadWriter(r io.Reader, w io.Writer) io.ReadWriter { + return &readWriter{ + Reader: r, + Writer: w, + } +}