-
Notifications
You must be signed in to change notification settings - Fork 55
/
mobileproxy.go
240 lines (214 loc) · 8.72 KB
/
mobileproxy.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
// Copyright 2023 The Outline Authors
//
// 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"
"errors"
"fmt"
"io"
"log"
"net"
"net/http"
"os"
"strconv"
"strings"
"time"
"github.com/Jigsaw-Code/outline-sdk/transport"
"github.com/Jigsaw-Code/outline-sdk/x/configurl"
"github.com/Jigsaw-Code/outline-sdk/x/httpproxy"
"github.com/Jigsaw-Code/outline-sdk/x/smart"
)
// Proxy enables you to get the actual address bound by the server and stop the service when no longer needed.
type Proxy struct {
host string
port int
proxyHandler *httpproxy.ProxyHandler
server *http.Server
}
// Address returns the IP and port the server is bound to.
func (p *Proxy) Address() string {
return net.JoinHostPort(p.host, strconv.Itoa(p.port))
}
// Host returns the IP the server is bound to.
func (p *Proxy) Host() string {
return p.host
}
// Port returns the port the server is bound to.
func (p *Proxy) Port() int {
return p.port
}
// AddURLProxy sets up a URL-based proxy handler that activates when an incoming HTTP request matches
// the specified path prefix. The pattern must represent a path segment, which is checked against
// the path of the incoming request.
//
// This function is particularly useful for libraries or components that accept URLs but do not support proxy
// configuration directly. By leveraging AddURLProxy, such components can route requests through a proxy by
// constructing URLs in the format "http://${HOST}:${PORT}/${PATH}/${URL}", where "${URL}" is the target resource.
// For instance, using "http://localhost:8080/proxy/https://example.com" routes the request for "https://example.com"
// through a proxy at "http://localhost:8080/proxy".
//
// The path should start with a forward slash ('/') for clarity, but one will be added if missing.
//
// The function associates the given 'dialer' with the specified 'path', allowing different dialers to be used for
// different path-based proxies within the same application in the future. currently we only support one URL proxy.
func (p *Proxy) AddURLProxy(path string, dialer *StreamDialer) {
if p.proxyHandler == nil {
// Called after Stop. Warn and ignore.
log.Println("Called Proxy.AddURLProxy after Stop")
return
}
if len(path) == 0 || path[0] != '/' {
path = "/" + path
}
// TODO(fortuna): Add support for multiple paths. I tried http.ServeMux, but it does request sanitization,
// which breaks the URL extraction: https://pkg.go.dev/net/http#hdr-Request_sanitizing.
// We can consider forking http.StripPrefix to provide a fallback instead of NotFound, and chaing them.
p.proxyHandler.FallbackHandler = http.StripPrefix(path, httpproxy.NewPathHandler(dialer.StreamDialer))
}
// 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()
}
// Allow garbage collection in case the user keeps holding a reference to the Proxy.
p.proxyHandler = nil
p.server = nil
}
// RunProxy runs a local web proxy that listens on localAddress, and handles proxy requests by
// establishing connections to requested destination using the [StreamDialer].
func RunProxy(localAddress string, dialer *StreamDialer) (*Proxy, error) {
listener, err := net.Listen("tcp", localAddress)
if err != nil {
return nil, fmt.Errorf("could not listen on address %v: %v", localAddress, err)
}
if dialer == nil {
return nil, errors.New("dialer must not be nil. Please create and pass a valid StreamDialer")
}
// The default http.Server doesn't close hijacked connections or cancel in-flight request contexts during
// shutdown. This can lead to lingering connections. We'll create a base context, propagated to requests,
// that is cancelled on shutdown. This enables handlers to gracefully terminate requests and close connections.
serverCtx, cancelCtx := context.WithCancelCause(context.Background())
proxyHandler := httpproxy.NewProxyHandler(dialer)
proxyHandler.FallbackHandler = http.NotFoundHandler()
server := &http.Server{
Handler: proxyHandler,
BaseContext: func(l net.Listener) context.Context {
return serverCtx
},
}
server.RegisterOnShutdown(func() {
cancelCtx(errors.New("server stopped"))
})
go server.Serve(listener)
host, portStr, err := net.SplitHostPort(listener.Addr().String())
if err != nil {
return nil, fmt.Errorf("could not parse proxy address '%v': %v", listener.Addr().String(), err)
}
port, err := strconv.Atoi(portStr)
if err != nil {
return nil, fmt.Errorf("could not parse proxy port '%v': %v", portStr, err)
}
return &Proxy{
host: host,
port: port,
server: server,
proxyHandler: proxyHandler,
}, nil
}
// StreamDialer encapsulates the logic to create stream connections (like TCP).
type StreamDialer struct {
transport.StreamDialer
}
var configModule = configurl.NewDefaultProviders()
// NewStreamDialerFromConfig creates a [StreamDialer] based on the given config.
// The config format is specified in https://pkg.go.dev/github.com/Jigsaw-Code/outline-sdk/x/config#hdr-Config_Format.
func NewStreamDialerFromConfig(transportConfig string) (*StreamDialer, error) {
dialer, err := configModule.NewStreamDialer(context.Background(), transportConfig)
if err != nil {
return nil, err
}
return &StreamDialer{dialer}, nil
}
// LogWriter is used as a sink for logging.
type LogWriter io.StringWriter
// Adaptor to convert an [io.StringWriter] to a [io.Writer].
type stringToBytesWriter struct {
w io.Writer
}
// WriteString implements [io.StringWriter].
func (w *stringToBytesWriter) WriteString(logText string) (int, error) {
return io.WriteString(w.w, logText)
}
// NewStderrLogWriter creates a [LogWriter] that writes to the standard error output.
func NewStderrLogWriter() LogWriter {
return &stringToBytesWriter{os.Stderr}
}
// Adaptor to convert an [io.Writer] to a [io.StringWriter].
type bytestoStringWriter struct {
sw io.StringWriter
}
// Write implements [io.Writer].
func (w *bytestoStringWriter) Write(b []byte) (int, error) {
return w.sw.WriteString(string(b))
}
func toWriter(logWriter LogWriter) io.Writer {
if logWriter == nil {
return nil
}
if w, ok := logWriter.(io.Writer); ok {
return w
}
return &bytestoStringWriter{logWriter}
}
// NewSmartStreamDialer automatically selects a DNS and TLS strategy to use, and returns a [StreamDialer]
// that will use the selected strategy.
// It uses testDomains to find a strategy that works when accessing those domains.
// The strategies to search are given in the searchConfig. An example can be found in
// https://github.com/Jigsaw-Code/outline-sdk/x/examples/smart-proxy/config.json
func NewSmartStreamDialer(testDomains *StringList, searchConfig string, logWriter LogWriter) (*StreamDialer, error) {
logBytesWriter := toWriter(logWriter)
// TODO: inject the base dialer for tests.
finder := smart.StrategyFinder{
LogWriter: logBytesWriter,
TestTimeout: 5 * time.Second,
StreamDialer: &transport.TCPDialer{},
PacketDialer: &transport.UDPDialer{},
}
dialer, err := finder.NewDialer(context.Background(), testDomains.list, []byte(searchConfig))
if err != nil {
return nil, fmt.Errorf("failed to find dialer: %w", err)
}
return &StreamDialer{dialer}, nil
}
// StringList allows us to pass a list of strings to the Go Mobile functions, since Go Mobile doesn't
// support slices as parameters.
type StringList struct {
list []string
}
// Append adds the string value to the end of the list.
func (l *StringList) Append(value string) {
l.list = append(l.list, value)
}
// NewListFromLines creates a StringList by splitting the input string on new lines.
func NewListFromLines(lines string) *StringList {
return &StringList{list: strings.Split(lines, "\n")}
}