package client import ( "context" "crypto/tls" "errors" "fmt" "io" "net" "net/http" "net/http/httputil" "net/url" "strings" "time" "go.miragespace.co/specter/spec/tun" "go.miragespace.co/specter/util" "go.miragespace.co/specter/util/acceptor" "go.miragespace.co/specter/util/pipe" "go.uber.org/zap" "go.uber.org/zap/zapcore" ) type httpProxy struct { acceptor *acceptor.HTTP2Acceptor forwarder *http.Server } type httpReqCtxKey string const ( ctxStartTime = httpReqCtxKey("start-time") defaultProxyHeaderTimeout = time.Second * 15 ) func injectStartTime(proxy http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { start := time.Now() ctx := context.WithValue(r.Context(), ctxStartTime, start) r = r.WithContext(ctx) proxy.ServeHTTP(w, r) }) } func (f *forwarder) forwardStream(ctx context.Context, hostname string, remote net.Conn, r route) { var ( u *url.URL = r.parsed target string local net.Conn err error ) logger := f.logger.With(zap.String("hostname", hostname), zap.String("target", u.String())) switch u.Scheme { case "tcp": dialer := &net.Dialer{ Timeout: time.Second * 3, } target = u.Host local, err = dialer.DialContext(ctx, "tcp", u.Host) case "unix", "winio": target = u.Path local, err = pipe.DialPipe(ctx, u.Path) default: err = fmt.Errorf("unknown scheme: %s", u.Scheme) } if err != nil { logger.Error("Error dialing to target", zap.String("target", target), zap.Error(err)) tun.SendStatusProto(remote, err) remote.Close() return } tun.SendStatusProto(remote, nil) tun.Pipe(remote, local) } func (f *forwarder) getHTTPProxy(_ context.Context, hostname string, r route) *httpProxy { proxy, loaded := f.proxies.LoadOrStoreLazy(hostname, func() *httpProxy { var ( u *url.URL = r.parsed isPipe = false ) logger := f.logger.With(zap.String("hostname", hostname), zap.String("target", u.String())) logger.Info("Creating new proxy") readHeaderTimeout := defaultProxyHeaderTimeout if r.proxyHeaderReadTimeout > 0 { readHeaderTimeout = r.proxyHeaderReadTimeout } tp := &http.Transport{ TLSClientConfig: &tls.Config{ ServerName: u.Host, InsecureSkipVerify: r.insecure, }, MaxIdleConns: 10, IdleConnTimeout: time.Second * 30, ResponseHeaderTimeout: readHeaderTimeout, ForceAttemptHTTP2: true, } switch u.Scheme { case "unix", "winio": isPipe = true tp.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { return pipe.DialPipe(ctx, u.Path) } } // determine Host header behavior based on ProxyHeaderMode mode := strings.ToLower(r.proxyHeaderMode) root := f.rootDomain.Load() fqdn := hostname if !strings.Contains(fqdn, ".") && root != "" { fqdn = fmt.Sprintf("%s.%s", fqdn, root) } hostHeader := u.Host if isPipe { hostHeader = "pipe" } switch mode { case "": // legacy behavior: optional override only if len(r.proxyHeaderHost) > 0 { hostHeader = r.proxyHeaderHost } case "hostname": hostHeader = fqdn case "target": if !isPipe { hostHeader = u.Host } else { // validate() should have prevented this; fall back to hostname for safety hostHeader = fqdn } case "custom": if len(r.proxyHeaderHost) > 0 { hostHeader = r.proxyHeaderHost } } proxy := &httputil.ReverseProxy{} proxy.Rewrite = func(preq *httputil.ProxyRequest) { if isPipe { preq.Out.URL.Scheme = "http" preq.Out.URL.Host = "pipe" preq.Out.URL.Path = preq.In.URL.Path preq.Out.URL.RawPath = preq.In.URL.RawPath preq.Out.URL.RawQuery = preq.In.URL.RawQuery } else { preq.SetURL(u) } preq.Out.Host = hostHeader // This private proxy receives only authenticated server streams or // the in-process gateway. Restore the visitor IP, host, and scheme // sanitized by that gateway, without adding the tunnel peer. for _, header := range []string{"X-Forwarded-For", "X-Forwarded-Host", "X-Forwarded-Proto"} { if value := preq.In.Header.Get(header); value != "" { preq.Out.Header.Set(header, value) } } } proxy.Transport = tp proxy.ErrorHandler = func(rw http.ResponseWriter, r *http.Request, e error) { if errors.Is(e, context.Canceled) || errors.Is(e, io.EOF) { // this is expected return } logger.Error("Error forwarding http/https request", zap.Object("request", (*encRequest)(r)), zap.Error(e)) rw.WriteHeader(http.StatusBadGateway) fmt.Fprintf(rw, "Forwarding target returned error: %s", e.Error()) } proxy.ModifyResponse = func(r *http.Response) error { logger.Debug("Access Log", zap.Object("request", (*encRequest)(r.Request)), zap.Object("response", (*encResponse)(r))) return nil } proxy.ErrorLog = util.GetStdLogger(logger, "targetProxy") proxy.BufferPool = util.NewBufferPool(1024 * 16) return &httpProxy{ acceptor: acceptor.NewH2Acceptor(nil), forwarder: &http.Server{ Handler: injectStartTime(proxy), ErrorLog: zap.NewStdLog(f.logger), ReadHeaderTimeout: readHeaderTimeout, }, } }) if !loaded { go proxy.forwarder.Serve(proxy.acceptor) } return proxy } type encRequest http.Request var _ zapcore.ObjectMarshaler = (*encRequest)(nil) func (r *encRequest) MarshalLogObject(enc zapcore.ObjectEncoder) error { enc.AddString("method", r.Method) enc.AddString("path", r.RequestURI) proxied := r.Header.Get("x-forwarded-for") if len(proxied) > 0 { ips := strings.Split(proxied, ",") for i, ip := range ips { ips[i] = strings.TrimSpace(ip) } enc.AddString("client", ips[0]) if len(ips) > 1 { enc.AddString("via", ips[1]) } } return nil } type encResponse http.Response var _ zapcore.ObjectMarshaler = (*encResponse)(nil) func (r *encResponse) MarshalLogObject(enc zapcore.ObjectEncoder) error { ctx := r.Request.Context() start := ctx.Value(ctxStartTime).(time.Time) enc.AddInt("code", r.StatusCode) enc.AddInt64("bytes", r.ContentLength) enc.AddDuration("duration", time.Since(start)) return nil }