diff --git a/go.mod b/go.mod index 0dc543d6..2345d357 100644 --- a/go.mod +++ b/go.mod @@ -48,9 +48,8 @@ require ( github.com/hashicorp/golang-lru v0.5.4 github.com/mitchellh/panicwrap v1.0.0 github.com/op/go-logging v0.0.0-20160315200505-970db520ece7 - github.com/oschwald/geoip2-golang v1.8.0 - github.com/prometheus/client_golang v1.19.1 github.com/refraction-networking/utls v1.3.3 + github.com/refraction-networking/water v0.7.0-alpha github.com/siddontang/go v0.0.0-20180604090527-bdc77568d726 github.com/spaolacci/murmur3 v1.1.0 github.com/stretchr/testify v1.8.4 @@ -80,6 +79,7 @@ require ( github.com/aead/ecdh v0.2.0 // indirect github.com/andybalholm/brotli v1.0.5 // indirect github.com/beorn7/perks v1.0.1 // indirect + github.com/blang/vfs v1.0.0 // indirect github.com/cenkalti/backoff/v4 v4.2.1 // indirect github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect @@ -89,6 +89,7 @@ require ( github.com/dvyukov/go-fuzz v0.0.0-20210429054444-fca39067bc72 // indirect github.com/felixge/httpsnoop v1.0.3 // indirect github.com/gaukas/godicttls v0.0.4 // indirect + github.com/gaukas/wazerofs v0.1.0 // indirect github.com/getlantern/algeneva v0.0.0-20240222191137-2b4e88234f59 // indirect github.com/getlantern/bufconn v0.0.0-20190625204133-a08544339f8d // indirect github.com/getlantern/byteexec v0.0.0-20220903142956-e6ed20032cfd // indirect @@ -131,6 +132,7 @@ require ( github.com/mmcloughlin/avo v0.0.0-20200803215136-443f81d77104 // indirect github.com/nwaples/rardecode v1.1.2 // indirect github.com/onsi/ginkgo/v2 v2.12.0 // indirect + github.com/oschwald/geoip2-golang v1.8.0 // indirect github.com/oschwald/maxminddb-golang v1.10.0 // indirect github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c // indirect github.com/pierrec/lz4/v4 v4.1.12 // indirect @@ -152,6 +154,7 @@ require ( github.com/pion/webrtc/v3 v3.2.6 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/prometheus/client_golang v1.19.1 // indirect github.com/prometheus/client_model v0.5.0 // indirect github.com/prometheus/common v0.48.0 // indirect github.com/prometheus/procfs v0.12.0 // indirect @@ -162,6 +165,7 @@ require ( github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8 // indirect github.com/templexxx/cpu v0.0.8 // indirect github.com/templexxx/xorsimd v0.4.1 // indirect + github.com/tetratelabs/wazero v1.7.1 // indirect github.com/ti-mo/conntrack v0.3.0 // indirect github.com/ti-mo/netfilter v0.3.1 // indirect github.com/tjfoc/gmsm v1.3.2 // indirect @@ -180,13 +184,13 @@ require ( go.uber.org/multierr v1.8.0 // indirect go.uber.org/zap v1.21.0 // indirect golang.org/x/crypto v0.18.0 // indirect - golang.org/x/exp v0.0.0-20240110193028-0dcbfd608b1e // indirect + golang.org/x/exp v0.0.0-20240119083558-1b970713d09a // indirect golang.org/x/mod v0.14.0 // indirect golang.org/x/oauth2 v0.16.0 // indirect golang.org/x/sync v0.6.0 // indirect golang.org/x/sys v0.17.0 // indirect golang.org/x/text v0.14.0 // indirect - golang.org/x/tools v0.16.0 // indirect + golang.org/x/tools v0.17.0 // indirect google.golang.org/appengine v1.6.7 // indirect google.golang.org/genproto v0.0.0-20231002182017-d307bd883b97 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20231002182017-d307bd883b97 // indirect @@ -199,3 +203,5 @@ require ( // Waiting on https://github.com/mitchellh/panicwrap/pull/27 to be merged upstream replace github.com/mitchellh/panicwrap v1.0.0 => github.com/getlantern/panicwrap v0.0.0-20200707191944-9ba45baf8e51 + +replace github.com/tetratelabs/wazero => github.com/refraction-networking/wazero v1.7.1-w diff --git a/go.sum b/go.sum index b08e88d1..be452b44 100644 --- a/go.sum +++ b/go.sum @@ -40,6 +40,8 @@ github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLj github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/blang/vfs v1.0.0 h1:AUZUgulCDzbaNjTRWEP45X7m/J10brAptZpSRKRZBZc= +github.com/blang/vfs v1.0.0/go.mod h1:jjuNUc/IKcRNNWC9NUCvz4fR9PZLPIKxEygtPs/4tSI= github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= @@ -74,6 +76,8 @@ github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4 github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= github.com/gaukas/godicttls v0.0.4 h1:NlRaXb3J6hAnTmWdsEKb9bcSBD6BvcIjdGdeb0zfXbk= github.com/gaukas/godicttls v0.0.4/go.mod h1:l6EenT4TLWgTdwslVb4sEMOCf7Bv0JAK67deKr9/NCI= +github.com/gaukas/wazerofs v0.1.0 h1:wIkW1bAxSnpaaVkQ5LOb1tm1BXdVap3eKjJpVWIqt2E= +github.com/gaukas/wazerofs v0.1.0/go.mod h1:+JECB9Fwt0taPqSgHckG9lmT3tcoVK+9VJozTsq9UlI= github.com/getlantern/algeneva v0.0.0-20240222191137-2b4e88234f59 h1:uWNy0b1Wtpsd4n64Kat+fRjvPCBwM2Nykwt71LupJAQ= github.com/getlantern/algeneva v0.0.0-20240222191137-2b4e88234f59/go.mod h1:PrNR8tMXO26YNs8K9653XCUH7u2Kv4OdfFC3Ke1GsX0= github.com/getlantern/broflake v0.0.0-20231117182649-7d46643a6f87 h1:9nUdYJp3TCKE/jTwI0mymz8FPT7jXHBRvRy2rXlKFDo= @@ -423,6 +427,10 @@ github.com/quic-go/quic-go v0.40.0 h1:GYd1iznlKm7dpHD7pOVpUvItgMPo/jrMgDWZhMCecq github.com/quic-go/quic-go v0.40.0/go.mod h1:PeN7kuVJ4xZbxSv/4OX6S1USOX8MJvydwpTx31vx60c= github.com/refraction-networking/utls v1.3.3 h1:f/TBLX7KBciRyFH3bwupp+CE4fzoYKCirhdRcC490sw= github.com/refraction-networking/utls v1.3.3/go.mod h1:DlecWW1LMlMJu+9qpzzQqdHDT/C2LAe03EdpLUz/RL8= +github.com/refraction-networking/water v0.7.0-alpha h1:yLMAvIi0lHHD2mvwx63GLnptpjvnmBCWFvTHTWLykLk= +github.com/refraction-networking/water v0.7.0-alpha/go.mod h1:VPQENA6E5+2V5RlX/yaBlTCKeFQ25j49DUc/pmmwkXE= +github.com/refraction-networking/wazero v1.7.1-w h1:z7Ty5PsMkJEDBCsn3ELUjceQGBT0FMVGldOSpDK3giQ= +github.com/refraction-networking/wazero v1.7.1-w/go.mod h1:ytl6Zuh20R/eROuyDaGPkp82O9C/DJfXAwJfQ3X6/7Y= github.com/riobard/go-bloom v0.0.0-20200614022211-cdc8013cb5b3 h1:f/FNXud6gA3MNr8meMVVGxhp+QBTqY91tM8HjEuMjGg= github.com/riobard/go-bloom v0.0.0-20200614022211-cdc8013cb5b3/go.mod h1:HgjTstvQsPGkxUsCd2KWxErBblirPizecHcpD3ffK+s= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= @@ -550,8 +558,8 @@ golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0 golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc= golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20240110193028-0dcbfd608b1e h1:723BNChdd0c2Wk6WOE320qGBiPtYx0F0Bbm1kriShfE= -golang.org/x/exp v0.0.0-20240110193028-0dcbfd608b1e/go.mod h1:iRJReGqOEeBhDZGkGbynYwcHlctCvnjTYIamk7uXpHI= +golang.org/x/exp v0.0.0-20240119083558-1b970713d09a h1:Q8/wZp0KX97QFTc2ywcOE0YRjZPVIx+MXInMzdvQqcA= +golang.org/x/exp v0.0.0-20240119083558-1b970713d09a/go.mod h1:idGWGoKP1toJGkd5/ig9ZLuPcZBC3ewk7SzmH0uou08= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= @@ -677,8 +685,8 @@ golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4f golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.16.0 h1:GO788SKMRunPIBCXiQyo2AaexLstOrVhuAL5YwsckQM= -golang.org/x/tools v0.16.0/go.mod h1:kYVVN6I1mBNoB1OX+noeBjbRk4IUEPa7JJ+TJMEooJ0= +golang.org/x/tools v0.17.0 h1:FvmRgNOcs3kOa+T20R1uhfP9F6HgG2mfxDv1vrx1Htc= +golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/http-proxy/main.go b/http-proxy/main.go index 1293dbeb..770a097a 100644 --- a/http-proxy/main.go +++ b/http-proxy/main.go @@ -186,6 +186,10 @@ var ( algenevaAddr = flag.String("algeneva-addr", "", "Address at which to listen for algenAddr connections.") + waterAddr = flag.String("water-addr", "", "Address at which to listen for WATER connections.") + waterWASM = flag.String("water-wasm", "", "Base64 encoded WASM for WATER") + waterTransport = flag.String("water-transport", "", "WATER based transport name") + track = flag.String("track", "", "The track this proxy is running on") ) @@ -469,6 +473,9 @@ func main() { BroflakeCert: os.Getenv("BROFLAKE_CERT"), BroflakeKey: os.Getenv("BROFLAKE_KEY"), AlgenevaAddr: *algenevaAddr, + WaterAddr: *waterAddr, + WaterWASM: *waterWASM, + WaterTransport: *waterTransport, } if *maxmindLicenseKey != "" { log.Debug("Will use Maxmind for geolocating clients") diff --git a/http_proxy.go b/http_proxy.go index 4903fa66..25463e91 100644 --- a/http_proxy.go +++ b/http_proxy.go @@ -65,6 +65,7 @@ import ( "github.com/getlantern/http-proxy-lantern/v2/tlslistener" "github.com/getlantern/http-proxy-lantern/v2/tlsmasq" "github.com/getlantern/http-proxy-lantern/v2/tokenfilter" + "github.com/getlantern/http-proxy-lantern/v2/water" "github.com/getlantern/http-proxy-lantern/v2/wss" algeneva "github.com/getlantern/lantern-algeneva" @@ -184,6 +185,10 @@ type Proxy struct { AlgenevaAddr string + WaterAddr string + WaterWASM string + WaterTransport string + throttleConfig throttle.Config instrument instrument.Instrument } @@ -649,6 +654,8 @@ func (p *Proxy) buildOTELOpts(endpoint string, includeProxyName bool) *otel.Opts opts.Addr = p.BroflakeAddr } else if p.AlgenevaAddr != "" { opts.Addr = p.AlgenevaAddr + } else if p.WaterAddr != "" { + opts.Addr = p.WaterAddr } if includeProxyName { opts.ProxyName = proxyName @@ -960,6 +967,19 @@ func (p *Proxy) listenAlgeneva(baseListen func(string) (net.Listener, error)) li } } +// listenWATER start a WATER listener and return it +// Currently water doesn't support customized TCP connections and we need to listen and receive requests directly from the WATER listener +func (p *Proxy) listenWATER(addr string) (net.Listener, error) { + ctx := context.Background() + waterListener, err := water.NewWATERListener(ctx, p.WaterTransport, addr, p.WaterWASM) + if err != nil { + return nil, err + } + + log.Debugf("Listening for water at %v", waterListener.Addr()) + return waterListener, nil +} + func (p *Proxy) setupPacketForward() error { if runtime.GOOS != "linux" { log.Debugf("Ignoring packet forward on %v", runtime.GOOS) diff --git a/protoListeners.go b/protoListeners.go index 6174fac6..5b0ff2d6 100644 --- a/protoListeners.go +++ b/protoListeners.go @@ -29,5 +29,6 @@ func getProtoListenersArgs(p *Proxy) []protoListenerArgs { p.ShadowsocksMultiplexAddr, p.wrapMultiplexing(p.listenShadowsocks), }, + {"water", p.WaterAddr, p.listenWATER}, } } diff --git a/water/listener.go b/water/listener.go new file mode 100644 index 00000000..3d6b86f7 --- /dev/null +++ b/water/listener.go @@ -0,0 +1,38 @@ +package water + +import ( + "context" + "encoding/base64" + "log/slog" + "net" + + "github.com/getlantern/golog" + "github.com/refraction-networking/water" + _ "github.com/refraction-networking/water/transport/v1" +) + +var log = golog.LoggerFor("water") + +// NewWATERListener creates a WATER listener +// Currently water doesn't support customized TCP connections and we need to listen and receive requests directly from the WATER listener +func NewWATERListener(ctx context.Context, transport, address, wasm string) (net.Listener, error) { + decodedWASM, err := base64.StdEncoding.DecodeString(wasm) + if err != nil { + log.Errorf("failed to decode WASM base64: %v", err) + return nil, err + } + + cfg := &water.Config{ + TransportModuleBin: decodedWASM, + //NetworkListener: baseListener, + OverrideLogger: slog.New(newLogHandler(log, transport)), + } + + waterListener, err := cfg.ListenContext(ctx, "tcp", address) + if err != nil { + log.Errorf("error creating water listener: %v", err) + return nil, err + } + + return waterListener, nil +} diff --git a/water/listener_test.go b/water/listener_test.go new file mode 100644 index 00000000..26a997e1 --- /dev/null +++ b/water/listener_test.go @@ -0,0 +1,89 @@ +package water + +import ( + "bytes" + "context" + "embed" + "encoding/base64" + "io" + "net" + "testing" + + "github.com/refraction-networking/water" + _ "github.com/refraction-networking/water/transport/v1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +//go:embed testdata/* +var testData embed.FS + +func TestWATERListener(t *testing.T) { + f, err := testData.Open("testdata/reverse_v1.wasm") + require.Nil(t, err) + + wasm, err := io.ReadAll(f) + require.Nil(t, err) + + b64WASM := base64.StdEncoding.EncodeToString(wasm) + + ctx := context.Background() + + cfg := &water.Config{ + TransportModuleBin: wasm, + } + + ll, err := NewWATERListener(ctx, "reverse_v0", "127.0.0.1:3000", b64WASM) + require.Nil(t, err) + + messageRequest := "hello" + expectedResponse := "world" + // running listener + go func() { + for { + var conn net.Conn + conn, err = ll.Accept() + if err != nil { + t.Error(err) + return + } + + go func() { + if conn == nil { + log.Error("nil connection") + return + } + buf := make([]byte, 2*len(messageRequest)) + n, err := conn.Read(buf) + if err != nil { + log.Errorf("error reading: %v", err) + return + } + + buf = buf[:n] + if !bytes.Equal(buf, []byte(messageRequest)) { + log.Errorf("unexpected request %v %v", buf, messageRequest) + return + } + conn.Write([]byte(expectedResponse)) + }() + } + }() + + dialer, err := water.NewDialerWithContext(ctx, cfg) + require.Nil(t, err) + + conn, err := dialer.DialContext(ctx, "tcp", ll.Addr().String()) + require.Nil(t, err) + defer conn.Close() + + n, err := conn.Write([]byte(messageRequest)) + assert.Nil(t, err) + assert.Equal(t, len(messageRequest), n) + + buf := make([]byte, 1024) + n, err = conn.Read(buf) + assert.Nil(t, err) + assert.Equal(t, len(expectedResponse), n) + assert.Equal(t, expectedResponse, string(buf[:n])) +} diff --git a/water/logger.go b/water/logger.go new file mode 100644 index 00000000..ca398f07 --- /dev/null +++ b/water/logger.go @@ -0,0 +1,162 @@ +package water + +import ( + "context" + "fmt" + "log/slog" + "strings" + + "github.com/getlantern/golog" +) + +// slogHandler is a Handler that implements the slog.Handler interface +// and writes log records to a golog.Logger. +type slogHandler struct { + logger golog.Logger + transport string + minLevel slog.Level + opts slog.HandlerOptions + attrs string + groups []string +} + +func newLogHandler(logger golog.Logger, transport string) *slogHandler { + return &slogHandler{logger: logger, transport: transport} +} + +// Enabled reports whether the handler handles records at the given level. +// The handler ignores records whose level is lower. +// It is called early, before any arguments are processed, +// to save effort if the log event should be discarded. +// If called from a Logger method, the first argument is the context +// passed to that method, or context.Background() if nil was passed +// or the method does not take a context. +// The context is passed so Enabled can use its values +// to make a decision. +func (h *slogHandler) Enabled(_ context.Context, level slog.Level) bool { + minLevel := slog.LevelDebug + if h.opts.Level != nil { + minLevel = h.opts.Level.Level() + } + return level >= minLevel +} + +// Handle handles the Record. +// It will only be called when Enabled returns true. +// The Context argument is as for Enabled. +// It is present solely to provide Handlers access to the context's values. +// Canceling the context should not affect record processing. +// (Among other things, log messages may be necessary to debug a +// cancellation-related problem.) +// +// Handle methods that produce output should observe the following rules: +// - If r.Time is the zero time, ignore the time. +// - If r.PC is zero, ignore it. +// - Attr's values should be resolved. +// - If an Attr's key and value are both the zero value, ignore the Attr. +// This can be tested with attr.Equal(Attr{}). +// - If a group's key is empty, inline the group's Attrs. +// - If a group has no Attrs (even if it has a non-empty key), +// ignore it. +func (h *slogHandler) Handle(ctx context.Context, record slog.Record) error { + if !h.Enabled(ctx, record.Level) { + return nil + } + + messageBuilder := new(strings.Builder) + messageBuilder.WriteString(record.Time.String()) + messageBuilder.WriteString("-") + messageBuilder.WriteString(record.Level.String()) + messageBuilder.WriteString(" ") + messageBuilder.WriteString(h.transport) + messageBuilder.WriteString(": ") + messageBuilder.WriteString(record.Message) + messageBuilder.WriteString(" ") + record.Attrs(func(attr slog.Attr) bool { + messageBuilder.WriteString(attr.Key) + messageBuilder.WriteString("=") + messageBuilder.WriteString(attr.Value.String()) + messageBuilder.WriteString(" ") + return true + }) + + messageBuilder.WriteString(h.attrs) + message := messageBuilder.String() + + switch record.Level { + case slog.LevelDebug, slog.LevelInfo, slog.LevelWarn: + h.logger.Debug(message) + case slog.LevelError: + err := h.logger.Error(message) + if err != nil { + return err + } + default: + return fmt.Errorf("unsupported log level: %v", record.Level) + } + return nil +} + +// WithAttrs returns a new Handler whose attributes consist of +// both the receiver's attributes and the arguments. +// The Handler owns the slice: it may retain, modify or discard it. +func (h *slogHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + h.attrs = parseAttrs(attrs) + return h +} + +func parseAttrs(attrs []slog.Attr) string { + attrsBuilder := new(strings.Builder) + for _, attr := range attrs { + attrsBuilder.WriteString(attr.Key) + attrsBuilder.WriteString(":") + switch attr.Value.Kind() { + case slog.KindString, slog.KindAny: + attrsBuilder.WriteString(attr.Value.String()) + case slog.KindInt64: + fmt.Fprintf(attrsBuilder, "%d", attr.Value.Int64()) + case slog.KindUint64: + fmt.Fprintf(attrsBuilder, "%d", attr.Value.Uint64()) + case slog.KindFloat64: + fmt.Fprintf(attrsBuilder, "%f", attr.Value.Float64()) + case slog.KindBool: + fmt.Fprintf(attrsBuilder, "%t", attr.Value.Bool()) + case slog.KindTime: + attrsBuilder.WriteString(attr.Value.Time().String()) + case slog.KindDuration: + attrsBuilder.WriteString(attr.Value.Duration().String()) + case slog.KindLogValuer: + attrsBuilder.WriteString(attr.Value.LogValuer().LogValue().String()) + case slog.KindGroup: + attrsBuilder.WriteString("{") + attrsBuilder.WriteString(parseAttrs(attr.Value.Group())) + attrsBuilder.WriteString("}") + } + attrsBuilder.WriteString(" ") + } + return attrsBuilder.String() +} + +// WithGroup returns a new Handler with the given group appended to +// the receiver's existing groups. +// The keys of all subsequent attributes, whether added by With or in a +// Record, should be qualified by the sequence of group names. +// +// How this qualification happens is up to the Handler, so long as +// this Handler's attribute keys differ from those of another Handler +// with a different sequence of group names. +// +// A Handler should treat WithGroup as starting a Group of Attrs that ends +// at the end of the log event. That is, +// +// logger.WithGroup("s").LogAttrs(ctx, level, msg, slog.Int("a", 1), slog.Int("b", 2)) +// +// should behave like +// +// logger.LogAttrs(ctx, level, msg, slog.Group("s", slog.Int("a", 1), slog.Int("b", 2))) +// +// If the name is empty, WithGroup returns the receiver. +func (h *slogHandler) WithGroup(name string) slog.Handler { + // TODO: Implement WithGroup + return h +} diff --git a/water/testdata/reverse_v0.wasm b/water/testdata/reverse_v0.wasm new file mode 100644 index 00000000..223ac554 Binary files /dev/null and b/water/testdata/reverse_v0.wasm differ diff --git a/water/testdata/reverse_v1.wasm b/water/testdata/reverse_v1.wasm new file mode 100644 index 00000000..5927f054 Binary files /dev/null and b/water/testdata/reverse_v1.wasm differ