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"
+)