HH-803: add stable network exit orchestration (#19)
This commit is contained in:
@@ -0,0 +1,362 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const browserProxyHost = "docker-gateway"
|
||||
|
||||
type memoryProxyRegistry struct {
|
||||
mu sync.Mutex
|
||||
proxies map[string]*memoryProxy
|
||||
}
|
||||
|
||||
type memoryProxy struct {
|
||||
mu sync.RWMutex
|
||||
exit gatewayProxyExit
|
||||
bindHost string
|
||||
listener net.Listener
|
||||
server *http.Server
|
||||
url string
|
||||
}
|
||||
|
||||
func newMemoryProxyRegistry() *memoryProxyRegistry {
|
||||
return &memoryProxyRegistry{proxies: map[string]*memoryProxy{}}
|
||||
}
|
||||
|
||||
func (registry *memoryProxyRegistry) configure(alias, bindHost string, port int, exit gatewayProxyExit) (string, func(), error) {
|
||||
registry.mu.Lock()
|
||||
defer registry.mu.Unlock()
|
||||
if proxy := registry.proxies[alias]; proxy != nil {
|
||||
if proxy.bindHost == bindHost && (port == 0 || proxy.listener.Addr().(*net.TCPAddr).Port == port) && proxy.exit == exit {
|
||||
return proxy.url, func() {}, nil
|
||||
}
|
||||
delete(registry.proxies, alias)
|
||||
_ = proxy.server.Close()
|
||||
}
|
||||
listener, err := net.Listen("tcp4", net.JoinHostPort(bindHost, strconv.Itoa(port)))
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
actualPort := listener.Addr().(*net.TCPAddr).Port
|
||||
proxy := &memoryProxy{exit: exit, bindHost: bindHost, listener: listener, url: "http://" + net.JoinHostPort(browserProxyHost, strconv.Itoa(actualPort))}
|
||||
proxy.server = &http.Server{Handler: proxy, ReadHeaderTimeout: 10 * time.Second, IdleTimeout: 60 * time.Second}
|
||||
registry.proxies[alias] = proxy
|
||||
go func() { _ = proxy.server.Serve(listener) }()
|
||||
undo := func() {
|
||||
registry.mu.Lock()
|
||||
defer registry.mu.Unlock()
|
||||
if registry.proxies[alias] == proxy {
|
||||
delete(registry.proxies, alias)
|
||||
_ = proxy.server.Close()
|
||||
}
|
||||
}
|
||||
return proxy.url, undo, nil
|
||||
}
|
||||
|
||||
func (registry *memoryProxyRegistry) ready(alias string, port int) bool {
|
||||
registry.mu.Lock()
|
||||
defer registry.mu.Unlock()
|
||||
proxy := registry.proxies[alias]
|
||||
return proxy != nil && proxy.listener.Addr().(*net.TCPAddr).Port == port
|
||||
}
|
||||
|
||||
func (registry *memoryProxyRegistry) remove(alias string) {
|
||||
registry.mu.Lock()
|
||||
proxy := registry.proxies[alias]
|
||||
delete(registry.proxies, alias)
|
||||
registry.mu.Unlock()
|
||||
if proxy != nil {
|
||||
_ = proxy.server.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func (proxy *memoryProxy) ServeHTTP(response http.ResponseWriter, request *http.Request) {
|
||||
if request.Method == http.MethodConnect {
|
||||
proxy.tunnel(response, request)
|
||||
return
|
||||
}
|
||||
proxy.mu.RLock()
|
||||
exit := proxy.exit
|
||||
proxy.mu.RUnlock()
|
||||
transport := &http.Transport{DisableKeepAlives: true}
|
||||
if exit.Protocol == "http" || exit.Protocol == "https" {
|
||||
upstream := &url.URL{Scheme: exit.Protocol, Host: net.JoinHostPort(exit.Host, strconv.Itoa(exit.Port))}
|
||||
if exit.Username != "" {
|
||||
upstream.User = url.UserPassword(exit.Username, exit.Password)
|
||||
}
|
||||
transport.Proxy = http.ProxyURL(upstream)
|
||||
} else {
|
||||
transport.DialContext = proxy.dialContext
|
||||
}
|
||||
defer transport.CloseIdleConnections()
|
||||
outbound := request.Clone(request.Context())
|
||||
outbound.RequestURI = ""
|
||||
outbound.Header.Del("Proxy-Authorization")
|
||||
result, err := transport.RoundTrip(outbound)
|
||||
if err != nil {
|
||||
http.Error(response, "proxy connection failed", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
defer result.Body.Close()
|
||||
for key, values := range result.Header {
|
||||
for _, value := range values {
|
||||
response.Header().Add(key, value)
|
||||
}
|
||||
}
|
||||
response.WriteHeader(result.StatusCode)
|
||||
_, _ = io.Copy(response, result.Body)
|
||||
}
|
||||
|
||||
func (proxy *memoryProxy) tunnel(response http.ResponseWriter, request *http.Request) {
|
||||
upstream, err := proxy.dialContext(request.Context(), "tcp", request.Host)
|
||||
if err != nil {
|
||||
http.Error(response, "proxy connection failed", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
client, buffered, err := http.NewResponseController(response).Hijack()
|
||||
if err != nil {
|
||||
_ = upstream.Close()
|
||||
http.Error(response, "proxy tunnel unavailable", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if _, err := buffered.WriteString("HTTP/1.1 200 Connection Established\r\n\r\n"); err != nil || buffered.Flush() != nil {
|
||||
_ = client.Close()
|
||||
_ = upstream.Close()
|
||||
return
|
||||
}
|
||||
done := make(chan struct{}, 2)
|
||||
go func() { _, _ = io.Copy(upstream, client); done <- struct{}{} }()
|
||||
go func() { _, _ = io.Copy(client, upstream); done <- struct{}{} }()
|
||||
<-done
|
||||
_ = client.Close()
|
||||
_ = upstream.Close()
|
||||
}
|
||||
|
||||
func (proxy *memoryProxy) dialContext(ctx context.Context, _, target string) (net.Conn, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 20*time.Second)
|
||||
defer cancel()
|
||||
proxy.mu.RLock()
|
||||
exit := proxy.exit
|
||||
proxy.mu.RUnlock()
|
||||
switch exit.Protocol {
|
||||
case "http", "https":
|
||||
return dialHTTPProxy(ctx, exit, target)
|
||||
case "socks4":
|
||||
return dialSOCKS4Proxy(ctx, exit, target)
|
||||
case "socks5":
|
||||
return dialSOCKS5Proxy(ctx, exit, target)
|
||||
default:
|
||||
return nil, errors.New("unsupported proxy protocol")
|
||||
}
|
||||
}
|
||||
|
||||
func dialHTTPProxy(ctx context.Context, exit gatewayProxyExit, target string) (net.Conn, error) {
|
||||
address := net.JoinHostPort(exit.Host, strconv.Itoa(exit.Port))
|
||||
connection, err := (&net.Dialer{}).DialContext(ctx, "tcp", address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if exit.Protocol == "https" {
|
||||
tlsConnection := tls.Client(connection, &tls.Config{ServerName: exit.Host, MinVersion: tls.VersionTLS12})
|
||||
if err := tlsConnection.HandshakeContext(ctx); err != nil {
|
||||
_ = connection.Close()
|
||||
return nil, err
|
||||
}
|
||||
connection = tlsConnection
|
||||
}
|
||||
request := &http.Request{Method: http.MethodConnect, URL: &url.URL{Opaque: target}, Host: target, Header: make(http.Header)}
|
||||
if exit.Username != "" {
|
||||
request.Header.Set("Proxy-Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(exit.Username+":"+exit.Password)))
|
||||
}
|
||||
if deadline, ok := ctx.Deadline(); ok {
|
||||
_ = connection.SetDeadline(deadline)
|
||||
}
|
||||
if err := request.Write(connection); err != nil {
|
||||
_ = connection.Close()
|
||||
return nil, err
|
||||
}
|
||||
result, err := http.ReadResponse(bufio.NewReader(connection), request)
|
||||
if err != nil {
|
||||
_ = connection.Close()
|
||||
return nil, err
|
||||
}
|
||||
if result.StatusCode != http.StatusOK {
|
||||
_ = result.Body.Close()
|
||||
_ = connection.Close()
|
||||
return nil, fmt.Errorf("upstream proxy returned %s", result.Status)
|
||||
}
|
||||
_ = connection.SetDeadline(time.Time{})
|
||||
return connection, nil
|
||||
}
|
||||
|
||||
func dialSOCKS4Proxy(ctx context.Context, exit gatewayProxyExit, target string) (net.Conn, error) {
|
||||
connection, err := (&net.Dialer{}).DialContext(ctx, "tcp", net.JoinHostPort(exit.Host, strconv.Itoa(exit.Port)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
host, portText, err := net.SplitHostPort(target)
|
||||
if err != nil {
|
||||
_ = connection.Close()
|
||||
return nil, err
|
||||
}
|
||||
port, err := strconv.Atoi(portText)
|
||||
if err != nil || port < 1 || port > 65535 {
|
||||
_ = connection.Close()
|
||||
return nil, errors.New("invalid SOCKS4 target")
|
||||
}
|
||||
payload := []byte{4, 1, byte(port >> 8), byte(port), 0, 0, 0, 1}
|
||||
if ip := net.ParseIP(host).To4(); ip != nil {
|
||||
copy(payload[4:8], ip)
|
||||
}
|
||||
payload = append(payload, exit.Username...)
|
||||
payload = append(payload, 0)
|
||||
if net.ParseIP(host).To4() == nil {
|
||||
payload = append(payload, host...)
|
||||
payload = append(payload, 0)
|
||||
}
|
||||
if err := exchangeSOCKS(ctx, connection, payload, 8); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return connection, nil
|
||||
}
|
||||
|
||||
func dialSOCKS5Proxy(ctx context.Context, exit gatewayProxyExit, target string) (net.Conn, error) {
|
||||
connection, err := (&net.Dialer{}).DialContext(ctx, "tcp", net.JoinHostPort(exit.Host, strconv.Itoa(exit.Port)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
methods := []byte{5, 1, 0}
|
||||
if exit.Username != "" {
|
||||
methods = []byte{5, 1, 2}
|
||||
}
|
||||
if deadline, ok := ctx.Deadline(); ok {
|
||||
_ = connection.SetDeadline(deadline)
|
||||
}
|
||||
if _, err := connection.Write(methods); err != nil {
|
||||
_ = connection.Close()
|
||||
return nil, err
|
||||
}
|
||||
selection := make([]byte, 2)
|
||||
if _, err := io.ReadFull(connection, selection); err != nil || selection[0] != 5 || selection[1] == 0xff {
|
||||
_ = connection.Close()
|
||||
return nil, errors.New("SOCKS5 authentication method rejected")
|
||||
}
|
||||
if selection[1] == 2 {
|
||||
if len(exit.Username) > 255 || len(exit.Password) > 255 {
|
||||
_ = connection.Close()
|
||||
return nil, errors.New("SOCKS5 credentials too long")
|
||||
}
|
||||
auth := append([]byte{1, byte(len(exit.Username))}, exit.Username...)
|
||||
auth = append(auth, byte(len(exit.Password)))
|
||||
auth = append(auth, exit.Password...)
|
||||
if _, err := connection.Write(auth); err != nil {
|
||||
_ = connection.Close()
|
||||
return nil, err
|
||||
}
|
||||
result := make([]byte, 2)
|
||||
if _, err := io.ReadFull(connection, result); err != nil || result[1] != 0 {
|
||||
_ = connection.Close()
|
||||
return nil, errors.New("SOCKS5 authentication rejected")
|
||||
}
|
||||
} else if exit.Username != "" {
|
||||
_ = connection.Close()
|
||||
return nil, errors.New("SOCKS5 proxy skipped required authentication")
|
||||
}
|
||||
host, portText, err := net.SplitHostPort(target)
|
||||
if err != nil {
|
||||
_ = connection.Close()
|
||||
return nil, err
|
||||
}
|
||||
port, err := strconv.Atoi(portText)
|
||||
if err != nil || port < 1 || port > 65535 {
|
||||
_ = connection.Close()
|
||||
return nil, errors.New("invalid SOCKS5 target")
|
||||
}
|
||||
request := []byte{5, 1, 0}
|
||||
if ip := net.ParseIP(host); ip != nil && ip.To4() != nil {
|
||||
request = append(request, 1)
|
||||
request = append(request, ip.To4()...)
|
||||
} else if ip != nil {
|
||||
request = append(request, 4)
|
||||
request = append(request, ip.To16()...)
|
||||
} else {
|
||||
if len(host) > 255 {
|
||||
_ = connection.Close()
|
||||
return nil, errors.New("SOCKS5 target too long")
|
||||
}
|
||||
request = append(request, 3, byte(len(host)))
|
||||
request = append(request, host...)
|
||||
}
|
||||
portBytes := make([]byte, 2)
|
||||
binary.BigEndian.PutUint16(portBytes, uint16(port))
|
||||
request = append(request, portBytes...)
|
||||
if _, err := connection.Write(request); err != nil {
|
||||
_ = connection.Close()
|
||||
return nil, err
|
||||
}
|
||||
header := make([]byte, 4)
|
||||
if _, err := io.ReadFull(connection, header); err != nil || header[0] != 5 || header[1] != 0 {
|
||||
_ = connection.Close()
|
||||
return nil, errors.New("SOCKS5 proxy rejected connection")
|
||||
}
|
||||
addressLength := 0
|
||||
switch header[3] {
|
||||
case 1:
|
||||
addressLength = 4
|
||||
case 4:
|
||||
addressLength = 16
|
||||
case 3:
|
||||
var length [1]byte
|
||||
if _, err := io.ReadFull(connection, length[:]); err != nil {
|
||||
_ = connection.Close()
|
||||
return nil, err
|
||||
}
|
||||
addressLength = int(length[0])
|
||||
default:
|
||||
_ = connection.Close()
|
||||
return nil, errors.New("invalid SOCKS5 response")
|
||||
}
|
||||
if _, err := io.CopyN(io.Discard, connection, int64(addressLength+2)); err != nil {
|
||||
_ = connection.Close()
|
||||
return nil, err
|
||||
}
|
||||
_ = connection.SetDeadline(time.Time{})
|
||||
return connection, nil
|
||||
}
|
||||
|
||||
func exchangeSOCKS(ctx context.Context, connection net.Conn, request []byte, responseBytes int) error {
|
||||
if deadline, ok := ctx.Deadline(); ok {
|
||||
_ = connection.SetDeadline(deadline)
|
||||
}
|
||||
if _, err := connection.Write(request); err != nil {
|
||||
_ = connection.Close()
|
||||
return err
|
||||
}
|
||||
if responseBytes > 0 {
|
||||
response := make([]byte, responseBytes)
|
||||
if _, err := io.ReadFull(connection, response); err != nil {
|
||||
_ = connection.Close()
|
||||
return err
|
||||
}
|
||||
if responseBytes == 8 && response[1] != 90 {
|
||||
_ = connection.Close()
|
||||
return errors.New("SOCKS4 proxy rejected connection")
|
||||
}
|
||||
}
|
||||
_ = connection.SetDeadline(time.Time{})
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user