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 }