github.com/symfony-cli/symfony-cli@v0.0.0-20240514161054-ece2df437dfa/local/proxy/proxy.go (about)

     1  /*
     2   * Copyright (c) 2021-present Fabien Potencier <fabien@symfony.com>
     3   *
     4   * This file is part of Symfony CLI project
     5   *
     6   * This program is free software: you can redistribute it and/or modify
     7   * it under the terms of the GNU Affero General Public License as
     8   * published by the Free Software Foundation, either version 3 of the
     9   * License, or (at your option) any later version.
    10   *
    11   * This program is distributed in the hope that it will be useful,
    12   * but WITHOUT ANY WARRANTY; without even the implied warranty of
    13   * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
    14   * GNU Affero General Public License for more details.
    15   *
    16   * You should have received a copy of the GNU Affero General Public License
    17   * along with this program. If not, see <http://www.gnu.org/licenses/>.
    18   */
    19  
    20  package proxy
    21  
    22  import (
    23  	"crypto/tls"
    24  	"crypto/x509"
    25  	"fmt"
    26  	"io"
    27  	"log"
    28  	"net"
    29  	"net/http"
    30  	"sort"
    31  	"strconv"
    32  	"strings"
    33  	"sync"
    34  
    35  	"github.com/elazarl/goproxy"
    36  	"github.com/pkg/errors"
    37  	"github.com/symfony-cli/cert"
    38  	"github.com/symfony-cli/symfony-cli/local/html"
    39  	"github.com/symfony-cli/symfony-cli/local/pid"
    40  	"github.com/symfony-cli/symfony-cli/local/projects"
    41  )
    42  
    43  type Proxy struct {
    44  	*Config
    45  	proxy *goproxy.ProxyHttpServer
    46  }
    47  
    48  func tlsToLocalWebServer(proxy *goproxy.ProxyHttpServer, tlsConfig *tls.Config, localPort int) *goproxy.ConnectAction {
    49  	httpError := func(w io.WriteCloser, ctx *goproxy.ProxyCtx, err error) {
    50  		if _, err := io.WriteString(w, "HTTP/1.1 502 Bad Gateway\r\n\r\n"); err != nil {
    51  			ctx.Warnf("Error responding to client: %s", err)
    52  		}
    53  		if err := w.Close(); err != nil {
    54  			ctx.Warnf("Error closing client connection: %s", err)
    55  		}
    56  	}
    57  	connectDial := func(proxy *goproxy.ProxyHttpServer, network, addr string) (c net.Conn, err error) {
    58  		if proxy.ConnectDial != nil {
    59  			return proxy.ConnectDial(network, addr)
    60  		}
    61  		if proxy.Tr.Dial != nil {
    62  			return proxy.Tr.Dial(network, addr)
    63  		}
    64  		return net.Dial(network, addr)
    65  	}
    66  	// tlsRecordHeaderLooksLikeHTTP reports whether a TLS record header
    67  	// looks like it might've been a misdirected plaintext HTTP request.
    68  	tlsRecordHeaderLooksLikeHTTP := func(hdr [5]byte) bool {
    69  		switch string(hdr[:]) {
    70  		case "GET /", "HEAD ", "POST ", "PUT /", "OPTIO":
    71  			return true
    72  		}
    73  		return false
    74  	}
    75  	return &goproxy.ConnectAction{
    76  		Action: goproxy.ConnectHijack,
    77  		Hijack: func(req *http.Request, proxyClient net.Conn, ctx *goproxy.ProxyCtx) {
    78  			ctx.Logf("Hijacking CONNECT")
    79  			proxyClient.Write([]byte("HTTP/1.0 200 OK\r\n\r\n"))
    80  
    81  			proxyClientTls := tls.Server(proxyClient, tlsConfig)
    82  			if err := proxyClientTls.Handshake(); err != nil {
    83  				defer proxyClient.Close()
    84  				if re, ok := err.(tls.RecordHeaderError); ok && re.Conn != nil && tlsRecordHeaderLooksLikeHTTP(re.RecordHeader) {
    85  					io.WriteString(proxyClient, "HTTP/1.0 400 Bad Request\r\n\r\nClient sent an HTTP request to an HTTPS server.\n")
    86  					return
    87  				}
    88  
    89  				ctx.Logf("TLS handshake error from %s: %v", proxyClient.RemoteAddr(), err)
    90  				return
    91  			}
    92  
    93  			ctx.Logf("Assuming CONNECT is TLS, TLS proxying it")
    94  			targetSiteCon, err := connectDial(proxy, "tcp", fmt.Sprintf("127.0.0.1:%d", localPort))
    95  			if err != nil {
    96  				httpError(proxyClientTls, ctx, err)
    97  				if targetSiteCon != nil {
    98  					targetSiteCon.Close()
    99  				}
   100  				return
   101  			}
   102  
   103  			negotiatedProtocol := proxyClientTls.ConnectionState().NegotiatedProtocol
   104  			if negotiatedProtocol == "" {
   105  				negotiatedProtocol = "http/1.1"
   106  			}
   107  
   108  			targetTlsConfig := &tls.Config{
   109  				RootCAs:    tlsConfig.RootCAs,
   110  				ServerName: "localhost",
   111  				NextProtos: []string{negotiatedProtocol},
   112  			}
   113  
   114  			targetSiteTls := tls.Client(targetSiteCon, targetTlsConfig)
   115  			if err := targetSiteTls.Handshake(); err != nil {
   116  				ctx.Warnf("Cannot handshake target %v %v", req.Host, err)
   117  				httpError(proxyClientTls, ctx, err)
   118  				targetSiteTls.Close()
   119  				return
   120  			}
   121  
   122  			var wg sync.WaitGroup
   123  			wg.Add(2)
   124  			go func() {
   125  				if _, err := io.Copy(proxyClientTls, targetSiteTls); err != nil {
   126  					ctx.Warnf("Error copying to target: %s", err)
   127  					httpError(proxyClientTls, ctx, err)
   128  				}
   129  				proxyClientTls.CloseWrite()
   130  				wg.Done()
   131  			}()
   132  			go func() {
   133  				if _, err := io.Copy(targetSiteTls, proxyClientTls); err != nil {
   134  					ctx.Warnf("Error copying to client: %s", err)
   135  				}
   136  				targetSiteTls.CloseWrite()
   137  				wg.Done()
   138  			}()
   139  			wg.Wait()
   140  			proxyClientTls.Close()
   141  			targetSiteTls.Close()
   142  		},
   143  	}
   144  }
   145  
   146  func New(config *Config, ca *cert.CA, logger *log.Logger, debug bool) *Proxy {
   147  	proxy := goproxy.NewProxyHttpServer()
   148  	proxy.Verbose = debug
   149  	proxy.Logger = logger
   150  	p := &Proxy{
   151  		Config: config,
   152  		proxy:  proxy,
   153  	}
   154  
   155  	var proxyTLSConfig *tls.Config
   156  
   157  	if ca != nil {
   158  		goproxy.GoproxyCa = *ca.AsTLS()
   159  		getCertificate := p.newCertStore(ca).getCertificate
   160  		cert, err := x509.ParseCertificate(ca.AsTLS().Certificate[0])
   161  		if err != nil {
   162  			panic(err)
   163  		}
   164  		certpool := x509.NewCertPool()
   165  		certpool.AddCert(cert)
   166  		tlsConfig := &tls.Config{
   167  			RootCAs:        certpool,
   168  			GetCertificate: getCertificate,
   169  			NextProtos:     []string{"http/1.1", "http/1.0"},
   170  		}
   171  		proxyTLSConfig = &tls.Config{
   172  			RootCAs:        certpool,
   173  			GetCertificate: getCertificate,
   174  			NextProtos:     []string{"h2", "http/1.1", "http/1.0"},
   175  		}
   176  		goproxy.MitmConnect.TLSConfig = func(host string, ctx *goproxy.ProxyCtx) (*tls.Config, error) {
   177  			return tlsConfig, nil
   178  		}
   179  		// They don't use TLSConfig but let's keep them in sync
   180  		goproxy.OkConnect.TLSConfig = goproxy.MitmConnect.TLSConfig
   181  		goproxy.RejectConnect.TLSConfig = goproxy.MitmConnect.TLSConfig
   182  		goproxy.HTTPMitmConnect.TLSConfig = goproxy.MitmConnect.TLSConfig
   183  	}
   184  	proxy.NonproxyHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
   185  		if r.Host == "" {
   186  			fmt.Fprintln(w, "Cannot handle requests without a Host header, e.g. HTTP 1.0")
   187  			return
   188  		}
   189  		r.URL.Scheme = "http"
   190  		r.URL.Host = r.Host
   191  		if r.URL.Path == "/proxy.pac" {
   192  			p.servePacFile(w, r)
   193  			return
   194  		} else if r.URL.Path == "/" {
   195  			p.serveIndex(w, r)
   196  			return
   197  		}
   198  		http.Error(w, "Not Found", 404)
   199  	})
   200  	cond := proxy.OnRequest(config.tldMatches())
   201  	cond.HandleConnectFunc(func(host string, ctx *goproxy.ProxyCtx) (*goproxy.ConnectAction, string) {
   202  		hostName, hostPort, err := net.SplitHostPort(host)
   203  		if err != nil {
   204  			// probably because no port in the host (determine it via the scheme)
   205  			if ctx.Req.URL.Scheme == "https" {
   206  				hostPort = "443"
   207  			} else {
   208  				hostPort = "80"
   209  			}
   210  			hostName = ctx.Req.Host
   211  		}
   212  		// wrong port?
   213  		if ctx.Req.URL.Scheme == "https" && hostPort != "443" {
   214  			return goproxy.MitmConnect, host
   215  		} else if ctx.Req.URL.Scheme == "http" && hostPort != "80" {
   216  			return goproxy.MitmConnect, host
   217  		}
   218  		projectDir := p.GetDir(hostName)
   219  		if projectDir == "" {
   220  			return goproxy.MitmConnect, host
   221  		}
   222  
   223  		pid := pid.New(projectDir, nil)
   224  		if !pid.IsRunning() {
   225  			return goproxy.MitmConnect, host
   226  		}
   227  
   228  		backend := fmt.Sprintf("127.0.0.1:%d", pid.Port)
   229  
   230  		if hostPort != "443" {
   231  			// No TLS termination required, let's go through regular proxy
   232  			return goproxy.OkConnect, backend
   233  		}
   234  
   235  		if proxyTLSConfig != nil {
   236  			return tlsToLocalWebServer(proxy, proxyTLSConfig, pid.Port), backend
   237  		}
   238  
   239  		// We didn't manage to get a tls.Config, we can't fulfill this request hijacking TLS
   240  		return goproxy.RejectConnect, backend
   241  	})
   242  	cond.DoFunc(func(r *http.Request, ctx *goproxy.ProxyCtx) (*http.Request, *http.Response) {
   243  		hostName, hostPort, err := net.SplitHostPort(r.Host)
   244  		if err != nil {
   245  			// probably because no port in the host (determine it via the scheme)
   246  			if r.URL.Scheme == "https" {
   247  				hostPort = "443"
   248  			} else {
   249  				hostPort = "80"
   250  			}
   251  			hostName = r.Host
   252  		}
   253  		// wrong port?
   254  		if r.URL.Scheme == "https" && hostPort != "443" {
   255  			return r, goproxy.NewResponse(r,
   256  				goproxy.ContentTypeHtml, http.StatusNotFound,
   257  				html.WrapHTML(
   258  					"Proxy Error",
   259  					html.CreateErrorTerminal(`You must use port 443 for HTTPS requests (%s used)`, hostPort)+
   260  						html.CreateAction(fmt.Sprintf("https://%s/", hostName), "Go to port 443"), ""),
   261  			)
   262  		} else if r.URL.Scheme == "http" && hostPort != "80" {
   263  			return r, goproxy.NewResponse(r,
   264  				goproxy.ContentTypeHtml, http.StatusNotFound,
   265  				html.WrapHTML(
   266  					"Proxy Error",
   267  					html.CreateErrorTerminal(`You must use port 80 for HTTP requests (%s used)`, hostPort)+
   268  						html.CreateAction(fmt.Sprintf("http://%s/", hostName), "Go to port 80"), ""),
   269  			)
   270  		}
   271  		projectDir := p.GetDir(hostName)
   272  		if projectDir == "" {
   273  			hostNameWithoutTLD := strings.TrimSuffix(hostName, "."+p.TLD)
   274  			hostNameWithoutTLD = strings.TrimPrefix(hostNameWithoutTLD, "www.")
   275  
   276  			// the domain does not refer to any project
   277  			return r, goproxy.NewResponse(r,
   278  				goproxy.ContentTypeHtml, http.StatusNotFound,
   279  				html.WrapHTML("Proxy Error", html.CreateErrorTerminal(`# The "%s" hostname is not linked to a directory yet.
   280  # Link it via the following command:
   281  
   282  <code>symfony proxy:domain:attach %s --dir=/some/dir</code>`, hostName, hostNameWithoutTLD), ""))
   283  		}
   284  
   285  		pid := pid.New(projectDir, nil)
   286  		if !pid.IsRunning() {
   287  			return r, goproxy.NewResponse(r,
   288  				goproxy.ContentTypeHtml, http.StatusNotFound,
   289  				// colors from http://ethanschoonover.com/solarized
   290  				html.WrapHTML(
   291  					"Proxy Error",
   292  					html.CreateErrorTerminal(`# It looks like the web server associated with the "%s" hostname is not started yet.
   293  # Start it via the following command:
   294  
   295  $ symfony server:start --daemon --dir=%s`,
   296  						hostName, projectDir)+
   297  						html.CreateAction("", "Retry"), ""),
   298  			)
   299  		}
   300  
   301  		r.URL.Host = fmt.Sprintf("127.0.0.1:%d", pid.Port)
   302  
   303  		if r.Header.Get("X-Forwarded-Port") == "" {
   304  			r.Header.Set("X-Forwarded-Port", hostPort)
   305  		}
   306  
   307  		return r, nil
   308  	})
   309  	return p
   310  }
   311  
   312  func (p *Proxy) Start() error {
   313  	go p.Config.Watch()
   314  	return errors.WithStack(http.ListenAndServe(":"+strconv.Itoa(p.Port), p.proxy))
   315  }
   316  
   317  func (p *Proxy) servePacFile(w http.ResponseWriter, r *http.Request) {
   318  	// Use the current request hostname (r.Host) to generate the PAC file.
   319  	// This means that as soon as you are able to reach the proxy, the generated
   320  	// PAC file will expose an appropriate hostname or IP even if the proxy
   321  	// is running remotely, in a container or a VM.
   322  	// No need to fall back to p.Host and p.Port as r.Host is already checked
   323  	// upper in the stacktrace.
   324  	w.Header().Add("Content-Type", "application/x-ns-proxy-autoconfig")
   325  	w.Write([]byte(fmt.Sprintf(`// Only proxy *.%s requests
   326  // Configuration file in ~/.symfony5/proxy.json
   327  function FindProxyForURL (url, host) {
   328  	if (dnsDomainIs(host, '.%s')) {
   329  		if (isResolvable(host)) {
   330  			return 'DIRECT';
   331  		}
   332  
   333  		return 'PROXY %s';
   334  	}
   335  
   336  	return 'DIRECT';
   337  }
   338  `, p.TLD, p.TLD, r.Host)))
   339  }
   340  
   341  func (p *Proxy) serveIndex(w http.ResponseWriter, r *http.Request) {
   342  	content := ``
   343  
   344  	proxyProjects, err := ToConfiguredProjects()
   345  	if err != nil {
   346  		return
   347  	}
   348  	runningProjects, err := pid.ToConfiguredProjects(true)
   349  	if err != nil {
   350  		return
   351  	}
   352  	projects, err := projects.GetConfiguredAndRunning(proxyProjects, runningProjects)
   353  	if err != nil {
   354  		return
   355  	}
   356  	projectDirs := []string{}
   357  	for dir := range projects {
   358  		projectDirs = append(projectDirs, dir)
   359  	}
   360  	sort.Strings(projectDirs)
   361  
   362  	content += "<table><tr><th>Directory<th>Port<th>Domains"
   363  	for _, dir := range projectDirs {
   364  		project := projects[dir]
   365  		content += fmt.Sprintf("<tr><td>%s", dir)
   366  		if project.Port > 0 {
   367  			content += fmt.Sprintf(`<td><a href="http://127.0.0.1:%d/">%d</a>`, project.Port, project.Port)
   368  		} else {
   369  			content += `<td style="color: #b58900">Not running`
   370  		}
   371  		content += "<td>"
   372  		for _, domain := range project.Domains {
   373  			if strings.Contains(domain, "*") {
   374  				content += fmt.Sprintf(`%s://%s/`, project.Scheme, domain)
   375  			} else {
   376  				content += fmt.Sprintf(`<a href="%s://%s/">%s://%s/</a>`, project.Scheme, domain, project.Scheme, domain)
   377  			}
   378  			content += "<br>"
   379  		}
   380  	}
   381  	w.Write([]byte(html.WrapHTML("Proxy Index", html.CreateTerminal(content), "")))
   382  }