diff --git a/README.md b/README.md index 0218b151..e90a7351 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,8 @@ To integrate the SDK into a mobile app, follow these steps: > **Note**: You must use `gomobile bind` on a package you create, not directly on the SDK packages. +An easy way to integrate with the SDK in a mobile app is by using the [`x/mobileproxy` library](./x/mobileproxy/) +to run a local web proxy that you can use to configure your app's networking libraries. ### Side Service @@ -119,15 +121,15 @@ Beta features: - Transport client strategies - Proxyless strategies - [ ] Encrypted DNS - - [x] Packet splitting + - [x] Packet splitting ([reference](https://pkg.go.dev/github.com/Jigsaw-Code/outline-sdk/transport/split)) - Proxy-based strategies - [ ] HTTP Connect - - [x] SOCKS5 StreamDialer + - [x] SOCKS5 StreamDialer ([reference](https://pkg.go.dev/github.com/Jigsaw-Code/outline-sdk/transport/socks5)) - [ ] SOCKS5 PacketDialer - Integration resources - For Mobile apps - - [ ] Library to run a local SOCKS5 or HTTP-Connect proxy + - [x] Library to run a local SOCKS5 or HTTP-Connect proxy ([source](./x/mobileproxy/mobileproxy.go), [example Go usage](./x/examples/fetch-proxy/main.go), [example mobile usage](./x/examples/mobileproxy)). - [x] Documentation on how to integrate the SDK into mobile apps - [ ] Connectivity Test mobile app using [Capacitor](https://capacitorjs.com/) - For Go apps diff --git a/x/examples/fetch-proxy/main.go b/x/examples/fetch-proxy/main.go new file mode 100644 index 00000000..e9b8c5c5 --- /dev/null +++ b/x/examples/fetch-proxy/main.go @@ -0,0 +1,56 @@ +// 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 +// +// https://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 main + +import ( + "flag" + "io" + "log" + "net/http" + "net/url" + "os" + + "github.com/Jigsaw-Code/outline-sdk/x/mobileproxy" +) + +func main() { + transportFlag := flag.String("transport", "", "Transport config") + flag.Parse() + + urlToFetch := flag.Arg(0) + if urlToFetch == "" { + log.Fatal("Need to pass the URL to fetch in the command-line") + } + + proxy, err := mobileproxy.RunProxy("localhost:0", *transportFlag) + if err != nil { + log.Fatalf("Cmobileproxy start proxy: %v", err) + } + + httpClient := &http.Client{Transport: &http.Transport{Proxy: http.ProxyURL(&url.URL{Scheme: "http", Host: proxy.Address()})}} + + resp, err := httpClient.Get(urlToFetch) + if err != nil { + log.Fatalf("URL GET failed: %v", err) + } + defer resp.Body.Close() + + _, err = io.Copy(os.Stdout, resp.Body) + if err != nil { + log.Fatalf("Read of page body failed: %v", err) + } + + proxy.Stop(5) +} diff --git a/x/go.mod b/x/go.mod index 1b1058c9..c1aa3551 100644 --- a/x/go.mod +++ b/x/go.mod @@ -6,7 +6,8 @@ require ( github.com/Jigsaw-Code/outline-sdk v0.0.6 github.com/miekg/dns v1.1.54 github.com/stretchr/testify v1.8.2 - golang.org/x/sys v0.8.0 + golang.org/x/mobile v0.0.0-20230905140555-fbe1c053b6a9 + golang.org/x/sys v0.11.0 ) require ( @@ -14,9 +15,10 @@ require ( github.com/kr/text v0.2.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/shadowsocks/go-shadowsocks2 v0.1.5 // indirect - golang.org/x/crypto v0.9.0 // indirect - golang.org/x/mod v0.10.0 // indirect - golang.org/x/net v0.10.0 // indirect - golang.org/x/tools v0.9.1 // indirect + golang.org/x/crypto v0.12.0 // indirect + golang.org/x/mod v0.12.0 // indirect + golang.org/x/net v0.14.0 // indirect + golang.org/x/sync v0.3.0 // indirect + golang.org/x/tools v0.12.1-0.20230818130535-1517d1a3ba60 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/x/go.sum b/x/go.sum index 5f6e66a1..28b219ad 100644 --- a/x/go.sum +++ b/x/go.sum @@ -24,22 +24,25 @@ github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= -golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g= -golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0= -golang.org/x/mod v0.10.0 h1:lFO9qtOdlre5W1jxS3r/4szv2/6iXxScdzjoBMXNhYk= -golang.org/x/mod v0.10.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/crypto v0.12.0 h1:tFM/ta59kqch6LlvYnPa0yx5a83cL2nHflFhYKvv9Yk= +golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= +golang.org/x/mobile v0.0.0-20230905140555-fbe1c053b6a9 h1:LaLfQUz4L1tfuOlrtEouZLZ0qHDwKn87E1NKoiudP/o= +golang.org/x/mobile v0.0.0-20230905140555-fbe1c053b6a9/go.mod h1:2jxcxt/JNJik+N+QcB8q308+SyrE3bu43+sGZDmJ02M= +golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M= -golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= -golang.org/x/sync v0.2.0 h1:PUR+T4wwASmuSTYdKjYHI5TD22Wy5ogLU5qZCOLxBrI= +golang.org/x/net v0.14.0 h1:BONx9s002vGdD9umnlX1Po8vOZmrgH34qlHcD1MfK14= +golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI= +golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU= -golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/tools v0.9.1 h1:8WMNJAz3zrtPmnYC7ISf5dEn3MT0gY7jBJfw27yrrLo= -golang.org/x/tools v0.9.1/go.mod h1:owI94Op576fPu3cIGQeHs3joujW/2Oc6MtlxbF5dfNc= +golang.org/x/tools v0.12.1-0.20230818130535-1517d1a3ba60 h1:o4bs4seAAlSiZQAZbO6/RP5XBCZCooQS3Pgc0AUjWts= +golang.org/x/tools v0.12.1-0.20230818130535-1517d1a3ba60/go.mod h1:Sc0INKfu04TlqNoRA1hgpFZbhYXHPr4V5DzpSBTPqQM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/x/httpproxy/connect_handler.go b/x/httpproxy/connect_handler.go index db25d453..cafbf964 100644 --- a/x/httpproxy/connect_handler.go +++ b/x/httpproxy/connect_handler.go @@ -15,7 +15,6 @@ package httpproxy import ( - "fmt" "io" "net" "net/http" @@ -25,47 +24,84 @@ import ( type handler struct { dialer transport.StreamDialer + client http.Client } var _ http.Handler = (*handler)(nil) // ServeHTTP implements [http.Handler].ServeHTTP for CONNECT requests, using the internal [transport.StreamDialer]. -func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { +func (h *handler) ServeHTTP(proxyResp http.ResponseWriter, proxyReq *http.Request) { // TODO(fortuna): For public services (not local), we need authentication and drain on failures to avoid fingerprinting. - if r.Method != http.MethodConnect { - http.Error(w, fmt.Sprintf("Method %v is not supported", r.Method), http.StatusMethodNotAllowed) + if proxyReq.Method == http.MethodConnect { + h.handleConnect(proxyResp, proxyReq) return + } else if proxyReq.URL.Host != "" { + h.handleHTTPProxyRequest(proxyResp, proxyReq) + } else { + http.Error(proxyResp, "Not Found", http.StatusNotFound) } +} + +func (h *handler) handleHTTPProxyRequest(proxyResp http.ResponseWriter, proxyReq *http.Request) { + // We create a new request that uses a relative path + Host header, instead of the absolute URL in the proxy request. + targetReq, err := http.NewRequestWithContext(proxyReq.Context(), proxyReq.Method, proxyReq.URL.String(), proxyReq.Body) + if err != nil { + http.Error(proxyResp, "Error creating target request", http.StatusInternalServerError) + return + } + for key, values := range proxyReq.Header { + for _, value := range values { + targetReq.Header.Add(key, value) + } + } + targetResp, err := h.client.Do(targetReq) + if err != nil { + http.Error(proxyResp, "Failed to fetch destination", http.StatusServiceUnavailable) + return + } + defer targetResp.Body.Close() + for key, values := range targetResp.Header { + for _, value := range values { + proxyResp.Header().Add(key, value) + } + } + _, err = io.Copy(proxyResp, targetResp.Body) + if err != nil { + http.Error(proxyResp, "Failed write response", http.StatusServiceUnavailable) + return + } +} +func (h *handler) handleConnect(proxyResp http.ResponseWriter, proxyReq *http.Request) { // Validate the target address. - _, portStr, err := net.SplitHostPort(r.Host) + _, portStr, err := net.SplitHostPort(proxyReq.Host) if err != nil { - http.Error(w, "Authority is not a valid host:port", http.StatusBadRequest) + http.Error(proxyResp, "Authority is not a valid host:port", http.StatusBadRequest) return } if portStr == "" { // As per https://httpwg.org/specs/rfc9110.html#CONNECT. - http.Error(w, "Port number must be specified", http.StatusBadRequest) + http.Error(proxyResp, "Port number must be specified", http.StatusBadRequest) return } // Dial the target. - targetConn, err := h.dialer.Dial(r.Context(), r.Host) + targetConn, err := h.dialer.Dial(proxyReq.Context(), proxyReq.Host) if err != nil { - http.Error(w, "Failed to connect to target", http.StatusServiceUnavailable) + http.Error(proxyResp, "Failed to connect to target", http.StatusServiceUnavailable) return } defer targetConn.Close() - hijacker, ok := w.(http.Hijacker) + hijacker, ok := proxyResp.(http.Hijacker) if !ok { - http.Error(w, "Webserver doesn't support hijacking", http.StatusInternalServerError) + http.Error(proxyResp, "Webserver doesn't support hijacking", http.StatusInternalServerError) return } httpConn, _, err := hijacker.Hijack() if err != nil { - http.Error(w, "Failed to hijack connection", http.StatusInternalServerError) + http.Error(proxyResp, "Failed to hijack connection", http.StatusInternalServerError) return } defer httpConn.Close() @@ -88,5 +124,5 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // The resulting handler is currently vulnerable to probing attacks. It's ok as a localhost proxy // but it may be vulnerable if used as a public proxy. func NewConnectHandler(dialer transport.StreamDialer) http.Handler { - return &handler{dialer} + return &handler{dialer, *http.DefaultClient} } diff --git a/x/mobileproxy/README.md b/x/mobileproxy/README.md new file mode 100644 index 00000000..f9a73eb3 --- /dev/null +++ b/x/mobileproxy/README.md @@ -0,0 +1,191 @@ +# Mobileproxy: Local Proxy Library for Mobile Apps + +This package enables the use Go Mobile to generate a mobile library to run a local proxy and configure your app networking libraries. + +### Build the Go Mobile binaries with [`go build`](https://pkg.go.dev/cmd/go#hdr-Compile_packages_and_dependencies) + +From the `x/` directory: + +```bash +go build -o ./out/ golang.org/x/mobile/cmd/gomobile golang.org/x/mobile/cmd/gobind +``` + +### Build the iOS and Android libraries with [`gomobile bind`](https://pkg.go.dev/golang.org/x/mobile/cmd/gomobile#hdr-Build_a_library_for_Android_and_iOS) + +```bash +PATH="$(pwd)/out:$PATH" gomobile bind -target=ios -o "$(pwd)/out/mobileproxy.xcframework" github.com/Jigsaw-Code/outline-sdk/x/mobileproxy +PATH="$(pwd)/out:$PATH" gomobile bind -target=android -o "$(pwd)/out/mobileproxy.aar" github.com/Jigsaw-Code/outline-sdk/x/mobileproxy +``` + +Note: Gomobile expects gobind to be in the PATH, that's why we need to prebuild it, and set up the PATH accordingly. + +
+Sample iOS generated Code + +The header file below is an example of the Objective-C interface that Go Mobile generates. + +> **Warning**: this example may diverge from what is actually generated by the current code. Use the coed you generate instead. + +`Mobileproxy.objc.h`: + +```objc +// Objective-C API for talking to github.com/Jigsaw-Code/outline-sdk/x/mobileproxy Go package. +// gobind -lang=objc github.com/Jigsaw-Code/outline-sdk/x/mobileproxy +// +// File is generated by gobind. Do not edit. + +#ifndef __Mobileproxy_H__ +#define __Mobileproxy_H__ + +@import Foundation; +#include "ref.h" +#include "Universe.objc.h" + + +@class MobileproxyProxy; + +/** + * Proxy enables you to get the actual address bound by the server and stop the service when no longer needed. + */ +@interface MobileproxyProxy : NSObject { +} +@property(strong, readonly) _Nonnull id _ref; + +- (nonnull instancetype)initWithRef:(_Nonnull id)ref; +- (nonnull instancetype)init; +/** + * Address returns the actual IP and port the server is bound to. + */ +- (NSString* _Nonnull)address; +/** + * Stop gracefully stops the proxy service, waiting for at most timeout seconds before forcefully closing it. +The function takes a timeoutSeconds number instead of a [time.Duration] so it's compatible with Go Mobile. + */ +- (void)stop:(long)timeoutSeconds; +@end + +/** + * RunProxy runs a local web proxy that listens on localAddress, and uses the transportConfig to +create a [transport.StreamDialer] that is used to connect to the requested destination. + */ +FOUNDATION_EXPORT MobileproxyProxy* _Nullable MobileproxyRunProxy(NSString* _Nullable localAddress, NSString* _Nullable transportConfig, NSError* _Nullable* _Nullable error); + +#endif +``` + +
+ +
+ Sample Android generated Code + +The files below are examples of the Java interface that Go Mobile generates. + +> **Warning**: this example may diverge from what is actually generated by the current code. Use the coed you generate instead. + +`mobileproxy.java`: + +```java +// Code generated by gobind. DO NOT EDIT. + +// Java class mobileproxy.mobileproxy is a proxy for talking to a Go program. +// +// autogenerated by gobind -lang=java github.com/Jigsaw-Code/outline-sdk/x/mobileproxy +package mobileproxy; + +import go.Seq; + +public abstract class mobileproxy { + static { + Seq.touch(); // for loading the native library + _init(); + } + + private mobileproxy() {} // uninstantiable + + // touch is called from other bound packages to initialize this package + public static void touch() {} + + private static native void _init(); + + + + /** + * RunProxy runs a local web proxy that listens on localAddress, and uses the transportConfig to + create the [transport.StreamDialer] to use to connect to the destination from the proxy requests. + */ + public static native Proxy runProxy(String localAddress, String transportConfig) throws Exception; +} + +``` + +`Proxy.java`: + +```java +// Code generated by gobind. DO NOT EDIT. + +// Java class mobileproxy.Proxy is a proxy for talking to a Go program. +// +// autogenerated by gobind -lang=java github.com/Jigsaw-Code/outline-sdk/x/mobileproxy +package mobileproxy; + +import go.Seq; + +/** + * Proxy enables you to get the actual address bound by the server and stop the service when no longer needed. + */ +public final class Proxy implements Seq.Proxy { + static { mobileproxy.touch(); } + + private final int refnum; + + @Override public final int incRefnum() { + Seq.incGoRef(refnum, this); + return refnum; + } + + Proxy(int refnum) { this.refnum = refnum; Seq.trackGoRef(refnum, this); } + + public Proxy() { this.refnum = __New(); Seq.trackGoRef(refnum, this); } + + private static native int __New(); + + /** + * Address returns the actual IP and port the server is bound to. + */ + public native String address(); + /** + * Stops gracefully stops the proxy service, waiting for at most timeout seconds before forcefully closing it. + */ + public native void stop(long timeoutSeconds); + @Override public boolean equals(Object o) { + if (o == null || !(o instanceof Proxy)) { + return false; + } + Proxy that = (Proxy)o; + return true; + } + + @Override public int hashCode() { + return java.util.Arrays.hashCode(new Object[] {}); + } + + @Override public String toString() { + StringBuilder b = new StringBuilder(); + b.append("Proxy").append("{"); + return b.append("}").toString(); + } +} +``` + +
+ +### Integrate into your mobile project + +To add the library to your mobile project, see Go Mobile's [Building and deploying to iOS](https://github.com/golang/go/wiki/Mobile#building-and-deploying-to-ios-1) and [Building and deploying to Android](https://github.com/golang/go/wiki/Mobile#building-and-deploying-to-android-1). + + +### Clean up + +```bash +rm -rf ./out/ +``` diff --git a/x/mobileproxy/mobileproxy.go b/x/mobileproxy/mobileproxy.go new file mode 100644 index 00000000..f413dd15 --- /dev/null +++ b/x/mobileproxy/mobileproxy.go @@ -0,0 +1,72 @@ +// 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 +// +// https://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 mobileproxy provides convenience utilities to help applications run a local proxy +// and use that to configure their networking libraries. +// +// This package is suitable for use with Go Mobile, making it a convenient way to integrate with mobile apps. +package mobileproxy + +import ( + "context" + "fmt" + "log" + "net" + "net/http" + "time" + + "github.com/Jigsaw-Code/outline-sdk/x/config" + "github.com/Jigsaw-Code/outline-sdk/x/httpproxy" +) + +// Proxy enables you to get the actual address bound by the server and stop the service when no longer needed. +type Proxy struct { + address string + server *http.Server +} + +// Address returns the actual IP and port the server is bound to. +func (p *Proxy) Address() string { + return p.address +} + +// Stop gracefully stops the proxy service, waiting for at most timeout seconds before forcefully closing it. +// The function takes a timeoutSeconds number instead of a [time.Duration] so it's compatible with Go Mobile. +func (p *Proxy) Stop(timeoutSeconds int) { + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeoutSeconds)*time.Second) + defer cancel() + if err := p.server.Shutdown(ctx); err != nil { + log.Fatalf("Failed to shutdown gracefully: %v", err) + p.server.Close() + } +} + +// RunProxy runs a local web proxy that listens on localAddress, and uses the transportConfig to +// create a [transport.StreamDialer] that is used to connect to the requested destination. +func RunProxy(localAddress string, transportConfig string) (*Proxy, error) { + dialer, err := config.NewStreamDialer(transportConfig) + if err != nil { + return nil, fmt.Errorf("could not create dialer: %w", err) + } + + listener, err := net.Listen("tcp", localAddress) + if err != nil { + return nil, fmt.Errorf("could not listen on address %v: %v", localAddress, err) + } + + server := &http.Server{Handler: httpproxy.NewConnectHandler(dialer)} + go server.Serve(listener) + + return &Proxy{address: listener.Addr().String(), server: server}, nil +} diff --git a/x/mobileproxy/tools.go b/x/mobileproxy/tools.go new file mode 100644 index 00000000..4db3a2d0 --- /dev/null +++ b/x/mobileproxy/tools.go @@ -0,0 +1,27 @@ +// 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. + +//go:build tools +// +build tools + +// See https://github.com/golang/go/wiki/Modules#how-can-i-track-tool-dependencies-for-a-module +// and https://github.com/go-modules-by-example/index/blob/master/010_tools/README.md + +package tools + +import ( + _ "github.com/Jigsaw-Code/outline-sdk/x/mobileproxy" + _ "golang.org/x/mobile/cmd/gobind" + _ "golang.org/x/mobile/cmd/gomobile" +)