diff --git a/Android/app/src/go/intra/packet_proxy.go b/Android/app/src/go/intra/packet_proxy.go deleted file mode 100644 index e55e10ac..00000000 --- a/Android/app/src/go/intra/packet_proxy.go +++ /dev/null @@ -1,168 +0,0 @@ -// Copyright 2023 Jigsaw Operations LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package intra - -import ( - "context" - "errors" - "fmt" - "net" - "net/netip" - "sync/atomic" - "time" - - "localhost/Intra/Android/app/src/go/doh" - "localhost/Intra/Android/app/src/go/intra/protect" - - "golang.getoutline.org/sdk/network" - "golang.getoutline.org/sdk/transport" -) - -type intraPacketProxy struct { - fakeDNSAddr netip.AddrPort - dns atomic.Pointer[doh.Resolver] - proxy network.PacketProxy - listener UDPListener - ctx context.Context -} - -var _ network.PacketProxy = (*intraPacketProxy)(nil) - -func newIntraPacketProxy( - ctx context.Context, fakeDNS netip.AddrPort, dns doh.Resolver, protector protect.Protector, listener UDPListener, -) (*intraPacketProxy, error) { - if dns == nil { - return nil, errors.New("dns is required") - } - - pl := &transport.UDPListener{ - ListenConfig: *protect.MakeListenConfig(protector), - } - - // RFC 4787 REQ-5 requires a timeout no shorter than 5 minutes. - pp, err := network.NewPacketProxyFromPacketListener(pl, network.WithPacketListenerWriteIdleTimeout(5*time.Minute)) - if err != nil { - return nil, fmt.Errorf("failed to create packet proxy from listener: %w", err) - } - - dohpp := &intraPacketProxy{ - fakeDNSAddr: fakeDNS, - proxy: pp, - listener: listener, - ctx: ctx, - } - dohpp.dns.Store(&dns) - - return dohpp, nil -} - -// NewSession implements PacketProxy.NewSession. -func (p *intraPacketProxy) NewSession(resp network.PacketResponseReceiver) (network.PacketRequestSender, error) { - dohResp := &dohPacketRespReceiver{ - PacketResponseReceiver: resp, - stats: makeTracker(), - listener: p.listener, - } - req, err := p.proxy.NewSession(dohResp) - if err != nil { - return nil, fmt.Errorf("failed to create new session: %w", err) - } - - return &dohPacketReqSender{ - PacketRequestSender: req, - proxy: p, - response: dohResp, - stats: dohResp.stats, - }, nil -} - -func (p *intraPacketProxy) SetDNS(dns doh.Resolver) error { - if dns == nil { - return errors.New("dns is required") - } - p.dns.Store(&dns) - return nil -} - -// DoH PacketRequestSender wrapper -type dohPacketReqSender struct { - network.PacketRequestSender - - response *dohPacketRespReceiver - proxy *intraPacketProxy - stats *tracker -} - -// DoH PacketResponseReceiver wrapper -type dohPacketRespReceiver struct { - network.PacketResponseReceiver - - stats *tracker - listener UDPListener -} - -var _ network.PacketRequestSender = (*dohPacketReqSender)(nil) -var _ network.PacketResponseReceiver = (*dohPacketRespReceiver)(nil) - -// WriteTo implements PacketRequestSender.WriteTo. It will query the DoH server if the packet a DNS packet. -func (req *dohPacketReqSender) WriteTo(p []byte, destination netip.AddrPort) (int, error) { - if isEquivalentAddrPort(destination, req.proxy.fakeDNSAddr) { - defer func() { - // conn was only used for this DNS query, so it's unlikely to be used again - if req.stats.download.Load() == 0 && req.stats.upload.Load() == 0 { - req.Close() - } - }() - - resp, err := (*req.proxy.dns.Load()).Query(req.proxy.ctx, p) - if err != nil { - return 0, fmt.Errorf("DoH request error: %w", err) - } - if len(resp) == 0 { - return 0, errors.New("empty DoH response") - } - - return req.response.writeFrom(resp, net.UDPAddrFromAddrPort(req.proxy.fakeDNSAddr), false) - } - - req.stats.upload.Add(int64(len(p))) - return req.PacketRequestSender.WriteTo(p, destination) -} - -// Close terminates the UDP session, and reports session stats to the listener. -func (resp *dohPacketRespReceiver) Close() error { - if resp.listener != nil { - resp.listener.OnUDPSocketClosed(&UDPSocketSummary{ - Duration: int32(time.Since(resp.stats.start)), - UploadBytes: resp.stats.upload.Load(), - DownloadBytes: resp.stats.download.Load(), - }) - } - return resp.PacketResponseReceiver.Close() -} - -// WriteFrom implements PacketResponseReceiver.WriteFrom. -func (resp *dohPacketRespReceiver) WriteFrom(p []byte, source net.Addr) (int, error) { - return resp.writeFrom(p, source, true) -} - -// writeFrom writes to the underlying PacketResponseReceiver. -// It will also add len(p) to downloadBytes if doStat is true. -func (resp *dohPacketRespReceiver) writeFrom(p []byte, source net.Addr, doStat bool) (int, error) { - if doStat { - resp.stats.download.Add(int64(len(p))) - } - return resp.PacketResponseReceiver.WriteFrom(p, source) -} diff --git a/Android/app/src/go/intra/packet_relay.go b/Android/app/src/go/intra/packet_relay.go new file mode 100644 index 00000000..9b210f40 --- /dev/null +++ b/Android/app/src/go/intra/packet_relay.go @@ -0,0 +1,132 @@ +// Copyright 2023 Jigsaw Operations LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package intra + +import ( + "fmt" + "net/netip" + "sync" + "time" + + "localhost/Intra/Android/app/src/go/intra/protect" + + "golang.getoutline.org/sdk/network/packetrelay" + "golang.getoutline.org/sdk/transport" +) + +// statsPacketRelay is a [packetrelay.PacketRelay] that relays UDP packets to their destination +// and reports the traffic of each association to a [UDPListener] once it concludes. +// +// DNS is not handled here. Queries addressed to the fake DNS server are intercepted before they +// reach this relay (see [NewTunnel]), so they are structurally excluded from the reported traffic. +type statsPacketRelay struct { + base packetrelay.PacketRelay + listener UDPListener +} + +var _ packetrelay.PacketRelay = (*statsPacketRelay)(nil) + +func newIntraPacketRelay(protector protect.Protector, listener UDPListener) (packetrelay.PacketRelay, error) { + pl := &transport.UDPListener{ + ListenConfig: *protect.MakeListenConfig(protector), + } + + // RFC 4787 REQ-5 requires a timeout no shorter than 5 minutes. The timeout is reset by + // outgoing packets only, matching the previous behavior. + base, err := packetrelay.NewPacketRelayFromPacketListener(pl, 5*time.Minute) + if err != nil { + return nil, fmt.Errorf("failed to create packet relay from listener: %w", err) + } + + return &statsPacketRelay{base: base, listener: listener}, nil +} + +// NewAssociation implements [packetrelay.PacketRelay].NewAssociation. +func (r *statsPacketRelay) NewAssociation() (packetrelay.PacketSender, packetrelay.PacketReceiver, error) { + sender, receiver, err := r.base.NewAssociation() + if err != nil { + return nil, nil, fmt.Errorf("failed to create new association: %w", err) + } + + stats := makeTracker() + return &statsPacketSender{sender: sender, stats: stats}, + &statsPacketReceiver{receiver: receiver, stats: stats, listener: r.listener}, + nil +} + +// statsPacketSender counts the bytes sent by an association. +type statsPacketSender struct { + sender packetrelay.PacketSender + stats *tracker +} + +var _ packetrelay.PacketSender = (*statsPacketSender)(nil) + +// SendPacket implements [packetrelay.PacketSender].SendPacket. +func (s *statsPacketSender) SendPacket(p []byte, destination netip.AddrPort) error { + s.stats.upload.Add(int64(len(p))) + return s.sender.SendPacket(p, destination) +} + +// Close implements [packetrelay.PacketSender].Close. +func (s *statsPacketSender) Close() error { + return s.sender.Close() +} + +// statsPacketReceiver counts the bytes received by an association and reports its stats to the +// listener once the association concludes. +type statsPacketReceiver struct { + receiver packetrelay.PacketReceiver + stats *tracker + listener UDPListener + reportOnce sync.Once +} + +var _ packetrelay.PacketReceiver = (*statsPacketReceiver)(nil) + +// ReceivePackets implements [packetrelay.PacketReceiver].ReceivePackets. It returns when the +// association is closed, and reports the association stats to the listener. +func (r *statsPacketReceiver) ReceivePackets(handler packetrelay.PacketHandler) error { + defer r.report() + return r.receiver.ReceivePackets(&statsPacketHandler{stats: r.stats, handler: handler}) +} + +// report notifies the listener that the association has concluded. +func (r *statsPacketReceiver) report() { + r.reportOnce.Do(func() { + if r.listener == nil { + return + } + r.listener.OnUDPSocketClosed(&UDPSocketSummary{ + Duration: int32(time.Since(r.stats.start)), + UploadBytes: r.stats.upload.Load(), + DownloadBytes: r.stats.download.Load(), + }) + }) +} + +// statsPacketHandler counts the bytes received by the association before handing the packet +// over to the network stack. +type statsPacketHandler struct { + stats *tracker + handler packetrelay.PacketHandler +} + +var _ packetrelay.PacketHandler = (*statsPacketHandler)(nil) + +func (h *statsPacketHandler) HandlePacket(p []byte, source netip.AddrPort) error { + h.stats.download.Add(int64(len(p))) + return h.handler.HandlePacket(p, source) +} diff --git a/Android/app/src/go/intra/packet_relay_test.go b/Android/app/src/go/intra/packet_relay_test.go new file mode 100644 index 00000000..ee5be322 --- /dev/null +++ b/Android/app/src/go/intra/packet_relay_test.go @@ -0,0 +1,203 @@ +// Copyright 2026 Jigsaw Operations LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package intra + +import ( + "context" + "net" + "net/netip" + "testing" + "time" + + "golang.getoutline.org/sdk/dns" + "golang.getoutline.org/sdk/network/dnsintercept" + "golang.getoutline.org/sdk/network/packetrelay" + + "github.com/stretchr/testify/require" +) + +const testTimeout = 10 * time.Second + +var testFakeDNSAddr = netip.MustParseAddrPort("10.111.222.3:53") + +// capturedPacket is a packet delivered to the network stack. +type capturedPacket struct { + payload []byte + source netip.AddrPort +} + +// captureHandler is a [packetrelay.PacketHandler] that records the packets it receives. +type captureHandler struct { + packets chan capturedPacket +} + +var _ packetrelay.PacketHandler = (*captureHandler)(nil) + +func newCaptureHandler() *captureHandler { + return &captureHandler{packets: make(chan capturedPacket, 16)} +} + +func (h *captureHandler) HandlePacket(p []byte, source netip.AddrPort) error { + // p must not be retained, so make a copy. + h.packets <- capturedPacket{payload: append([]byte(nil), p...), source: source} + return nil +} + +func (h *captureHandler) next(t *testing.T) capturedPacket { + t.Helper() + select { + case p := <-h.packets: + return p + case <-time.After(testTimeout): + t.Fatal("timed out waiting for a packet") + return capturedPacket{} + } +} + +// udpListenerFunc adapts a function to the [UDPListener] interface. +type udpListenerFunc func(*UDPSocketSummary) + +func (f udpListenerFunc) OnUDPSocketClosed(s *UDPSocketSummary) { f(s) } + +// startAssociation creates one association on relay and runs its receive loop in the +// background. It returns the sender half, the captured packets, and the error returned by the +// receive loop. +func startAssociation(t *testing.T, relay packetrelay.PacketRelay) ( + packetrelay.PacketSender, *captureHandler, <-chan error, +) { + t.Helper() + + sender, receiver, err := relay.NewAssociation() + require.NoError(t, err) + t.Cleanup(func() { sender.Close() }) + + handler := newCaptureHandler() + received := make(chan error, 1) + go func() { received <- receiver.ReceivePackets(handler) }() + + return sender, handler, received +} + +// newTestPacketRelay creates the Intra packet relay, and the summaries it reports. +func newTestPacketRelay(t *testing.T) (packetrelay.PacketRelay, <-chan *UDPSocketSummary) { + t.Helper() + + summaries := make(chan *UDPSocketSummary, 1) + relay, err := newIntraPacketRelay(nil, udpListenerFunc(func(s *UDPSocketSummary) { summaries <- s })) + require.NoError(t, err) + + return relay, summaries +} + +// TestAssociationRelaysPackets verifies that packets reach their destination, that responses +// are delivered back to the network stack, and that both directions are counted. +func TestAssociationRelaysPackets(t *testing.T) { + echo := startEchoServer(t) + relay, summaries := newTestPacketRelay(t) + sender, handler, _ := startAssociation(t, relay) + + require.NoError(t, sender.SendPacket([]byte("ping"), echo)) + + packet := handler.next(t) + require.Equal(t, []byte("ping"), packet.payload) + // The source may be reported as a 4-in-6 address. + require.True(t, isEquivalentAddrPort(packet.source, echo), "got %v, want %v", packet.source, echo) + + require.NoError(t, sender.Close()) + summary := <-summaries + require.EqualValues(t, 4, summary.UploadBytes) + require.EqualValues(t, 4, summary.DownloadBytes) +} + +// TestAssociationCloseIsIdempotent verifies the [packetrelay.PacketSender] close semantics. +func TestAssociationCloseIsIdempotent(t *testing.T) { + echo := startEchoServer(t) + relay, _ := newTestPacketRelay(t) + sender, _, received := startAssociation(t, relay) + + require.NoError(t, sender.Close()) + require.ErrorIs(t, sender.Close(), packetrelay.ErrClosed) + require.ErrorIs(t, sender.SendPacket([]byte("ping"), echo), packetrelay.ErrClosed) + + // Closing the sender must terminate ReceivePackets. + select { + case <-received: + case <-time.After(testTimeout): + t.Fatal("ReceivePackets did not return after Close") + } +} + +// TestInterceptedDNSIsNotRelayed verifies the composition used by [NewTunnel]: queries to the +// fake DNS server are answered by the resolver, never reach the relay, and are therefore not +// counted nor reported. +func TestInterceptedDNSIsNotRelayed(t *testing.T) { + response := []byte("fake-dns-response") + queries := make(chan []byte, 1) + exchanger := dns.FuncExchanger(func(_ context.Context, q []byte) ([]byte, error) { + queries <- q + return response, nil + }) + + relay, summaries := newTestPacketRelay(t) + sender, handler, received := startAssociation( + t, dnsintercept.New(relay, testFakeDNSAddr, exchanger)) + + require.NoError(t, sender.SendPacket([]byte("dns-query"), testFakeDNSAddr)) + require.Equal(t, []byte("dns-query"), <-queries) + + packet := handler.next(t) + require.Equal(t, response, packet.payload) + require.Equal(t, testFakeDNSAddr, packet.source) + + // The association was only used for this DNS query, so it is closed right away. + select { + case <-received: + case <-time.After(testTimeout): + t.Fatal("the DNS-only association was not closed") + } + + // No UDP socket was ever opened, so there is nothing to report. + select { + case summary := <-summaries: + t.Fatalf("unexpected summary for a DNS-only association: %+v", summary) + default: + } +} + +// startEchoServer runs a UDP server that echoes back everything it receives. +func startEchoServer(t *testing.T) netip.AddrPort { + t.Helper() + + conn, err := net.ListenPacket("udp", "127.0.0.1:0") + require.NoError(t, err) + t.Cleanup(func() { conn.Close() }) + + go func() { + buf := make([]byte, 2048) + for { + n, addr, err := conn.ReadFrom(buf) + if err != nil { + return + } + if _, err := conn.WriteTo(buf[:n], addr); err != nil { + return + } + } + }() + + addr, err := netip.ParseAddrPort(conn.LocalAddr().String()) + require.NoError(t, err) + return addr +} diff --git a/Android/app/src/go/intra/tunnel.go b/Android/app/src/go/intra/tunnel.go index 25677171..8d4212e4 100644 --- a/Android/app/src/go/intra/tunnel.go +++ b/Android/app/src/go/intra/tunnel.go @@ -22,11 +22,15 @@ import ( "net" "os" "strings" + "sync/atomic" "localhost/Intra/Android/app/src/go/doh" "localhost/Intra/Android/app/src/go/intra/protect" + "localhost/Intra/Android/app/src/go/logging" + "golang.getoutline.org/sdk/dns" "golang.getoutline.org/sdk/network" + "golang.getoutline.org/sdk/network/dnsintercept" "golang.getoutline.org/sdk/network/lwip2transport" ) @@ -44,7 +48,7 @@ type Tunnel struct { ctx context.Context cancel context.CancelFunc sd *intraStreamDialer - pp *intraPacketProxy + dns atomic.Pointer[doh.Resolver] sni *tcpSNIReporter tun io.Closer } @@ -68,6 +72,9 @@ func NewTunnel( if eventListener == nil { return nil, errors.New("eventListener is required") } + if dohdns == nil { + return nil, errors.New("dohdns is required") + } fakeDNSAddr, err := net.ResolveUDPAddr("udp", fakedns) if err != nil { @@ -81,18 +88,26 @@ func NewTunnel( tun: tun, } t.ctx, t.cancel = context.WithCancel(context.Background()) + t.dns.Store(&dohdns) t.sd, err = newIntraStreamDialer(fakeDNSAddr.AddrPort(), dohdns, protector, eventListener, t.sni) if err != nil { return nil, fmt.Errorf("failed to create stream dialer: %w", err) } - t.pp, err = newIntraPacketProxy(t.ctx, fakeDNSAddr.AddrPort(), dohdns, protector, eventListener) + pr, err := newIntraPacketRelay(protector, eventListener) if err != nil { - return nil, fmt.Errorf("failed to create packet proxy: %w", err) + return nil, fmt.Errorf("failed to create packet relay: %w", err) } - if t.IPDevice, err = lwip2transport.ConfigureDevice(t.sd, t.pp); err != nil { + // Queries addressed to the fake DNS server are answered by the DoH resolver. Everything + // else is relayed to its destination, and is the only traffic reported to eventListener. + dnsRelay := dnsintercept.New(pr, fakeDNSAddr.AddrPort(), dns.FuncExchanger(t.queryDNS), + dnsintercept.WithErrorHandler(func(err error) { + logging.Warnf("Failed to answer DNS query: %v", err) + })) + + if t.IPDevice, err = lwip2transport.ConfigureDeviceWithRelay(t.sd, dnsRelay); err != nil { return nil, fmt.Errorf("failed to configure lwIP stack: %w", err) } @@ -100,13 +115,31 @@ func NewTunnel( return } +// queryDNS resolves a wire-format DNS query with the current DoH resolver. It is the +// [dns.Exchanger] that answers the intercepted queries. +func (t *Tunnel) queryDNS(ctx context.Context, query []byte) ([]byte, error) { + resolver := t.dns.Load() + if resolver == nil { + return nil, errors.New("no DNS resolver is configured") + } + + // ctx is canceled when the association is closed. Also cancel the query when the tunnel + // is disconnected. + ctx, cancel := context.WithCancel(ctx) + defer cancel() + stopOnDisconnect := context.AfterFunc(t.ctx, cancel) + defer stopOnDisconnect() + + return (*resolver).Query(ctx, query) +} + // Set the DNS Resolver. This method must be called before connecting the transport // to the TUN device. The transport can be changed at any time during operation, but // must not be nil. -func (t *Tunnel) SetDNS(dns doh.Resolver) { - t.sd.SetDNS(dns) - t.pp.SetDNS(dns) - t.sni.SetDNS(dns) +func (t *Tunnel) SetDNS(resolver doh.Resolver) { + t.sd.SetDNS(resolver) + t.dns.Store(&resolver) + t.sni.SetDNS(resolver) } // Enable reporting of SNIs that resulted in connection failures, using the diff --git a/Android/app/src/go/intra/tunnel_test.go b/Android/app/src/go/intra/tunnel_test.go new file mode 100644 index 00000000..16de028f --- /dev/null +++ b/Android/app/src/go/intra/tunnel_test.go @@ -0,0 +1,100 @@ +// Copyright 2026 Jigsaw Operations LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package intra + +import ( + "context" + "testing" + "time" + + "localhost/Intra/Android/app/src/go/doh" + + "github.com/stretchr/testify/require" +) + +// ctxResolver is a [doh.Resolver] that runs a context-aware function. +type ctxResolver struct { + doh.Resolver + query func(ctx context.Context, q []byte) ([]byte, error) +} + +func (r *ctxResolver) Query(ctx context.Context, q []byte) ([]byte, error) { + return r.query(ctx, q) +} + +// newTestTunnel creates the minimal [Tunnel] needed to exercise queryDNS. +func newTestTunnel(t *testing.T) *Tunnel { + t.Helper() + + tun := &Tunnel{} + tun.ctx, tun.cancel = context.WithCancel(context.Background()) + t.Cleanup(tun.cancel) + return tun +} + +// TestQueryDNSUsesCurrentResolver verifies that queryDNS always uses the resolver that is +// configured at the time of the query, so that SetDNS takes effect immediately. +func TestQueryDNSUsesCurrentResolver(t *testing.T) { + tun := newTestTunnel(t) + + _, err := tun.queryDNS(context.Background(), []byte("query")) + require.Error(t, err, "queryDNS must fail when there is no resolver") + + var first doh.Resolver = newFakeTransport(func([]byte) ([]byte, error) { + return []byte("first"), nil + }) + tun.dns.Store(&first) + response, err := tun.queryDNS(context.Background(), []byte("query")) + require.NoError(t, err) + require.Equal(t, []byte("first"), response) + + var second doh.Resolver = newFakeTransport(func([]byte) ([]byte, error) { + return []byte("second"), nil + }) + tun.dns.Store(&second) + response, err = tun.queryDNS(context.Background(), []byte("query")) + require.NoError(t, err) + require.Equal(t, []byte("second"), response) +} + +// TestQueryDNSIsCanceledOnDisconnect verifies that disconnecting the tunnel cancels the +// queries that are still in flight. +func TestQueryDNSIsCanceledOnDisconnect(t *testing.T) { + tun := newTestTunnel(t) + + started := make(chan struct{}) + var resolver doh.Resolver = &ctxResolver{query: func(ctx context.Context, _ []byte) ([]byte, error) { + close(started) + <-ctx.Done() + return nil, ctx.Err() + }} + tun.dns.Store(&resolver) + + errs := make(chan error, 1) + go func() { + _, err := tun.queryDNS(context.Background(), []byte("query")) + errs <- err + }() + + <-started + tun.cancel() + + select { + case err := <-errs: + require.ErrorIs(t, err, context.Canceled) + case <-time.After(testTimeout): + t.Fatal("the in-flight query was not canceled") + } +} diff --git a/go.mod b/go.mod index c34435fc..a296dc5f 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( github.com/Jigsaw-Code/choir v1.0.1 github.com/Jigsaw-Code/getsni v1.0.0 github.com/stretchr/testify v1.8.4 - golang.getoutline.org/sdk v0.1.0-rc1 + golang.getoutline.org/sdk v0.1.0-rc1.0.20260915234250-3f66d9c28049 golang.org/x/mobile v0.0.0-20260908204917-8b95e45f8d3e golang.org/x/net v0.59.0 golang.org/x/sys v0.48.0 diff --git a/go.sum b/go.sum index 81f7766b..60498ae1 100644 --- a/go.sum +++ b/go.sum @@ -15,6 +15,8 @@ github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcU github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= golang.getoutline.org/sdk v0.1.0-rc1 h1:soeTFOQCsR/2vIUFgbEGHbRRykpC3XOxL7+AlLCOdt0= golang.getoutline.org/sdk v0.1.0-rc1/go.mod h1:nKZlO//e/sRFk+rp8gm8EJ5RasDSyY+fGDNSd3I2iaA= +golang.getoutline.org/sdk v0.1.0-rc1.0.20260915234250-3f66d9c28049 h1:YZ0Q1cLmKNe4naQ6WzZedm2GpVAvlhiHYUS/VCYIne4= +golang.getoutline.org/sdk v0.1.0-rc1.0.20260915234250-3f66d9c28049/go.mod h1:nKZlO//e/sRFk+rp8gm8EJ5RasDSyY+fGDNSd3I2iaA= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20200604202706-70a84ac30bf9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=