diff --git a/examples/default.yaml b/examples/default.yaml index 3d7d35dfe1c..c73aeefbf11 100644 --- a/examples/default.yaml +++ b/examples/default.yaml @@ -497,6 +497,11 @@ hostResolver: # - 1.1.1.1 # - 1.0.0.1 +# The host proxy implements a HTTP and HTTPS proxy that can cache downloads on the host. +hostProxy: + # 🟢 Builtin default: false + enabled: null + # Prefix to use for installing guest agent, and containerd with dependencies (if configured) # 🟢 Builtin default: /usr/local guestInstallPrefix: null diff --git a/go.mod b/go.mod index b543014d2dc..bdb792ca8f6 100644 --- a/go.mod +++ b/go.mod @@ -20,6 +20,7 @@ require ( github.com/diskfs/go-diskfs v1.4.1 github.com/docker/go-units v0.5.0 github.com/elastic/go-libaudit/v2 v2.5.0 + github.com/elazarl/goproxy v0.0.0-20240909085733-6741dbfc16a1 github.com/foxcpp/go-mockdns v1.1.0 github.com/goccy/go-yaml v1.12.0 github.com/google/go-cmp v0.6.0 diff --git a/go.sum b/go.sum index e73d47ec8c0..58d7c249f7a 100644 --- a/go.sum +++ b/go.sum @@ -67,6 +67,10 @@ github.com/elastic/go-libaudit/v2 v2.5.0 h1:5OK919QRnGtcjVBz3n/cs5F42im1mPlVTA9T github.com/elastic/go-libaudit/v2 v2.5.0/go.mod h1:AjlnhinP+kKQuUJoXLVrqxBM8uyhQmkzoV6jjsCFP4Q= github.com/elastic/go-licenser v0.4.1 h1:1xDURsc8pL5zYT9R29425J3vkHdt4RT5TNEMeRN48x4= github.com/elastic/go-licenser v0.4.1/go.mod h1:V56wHMpmdURfibNBggaSBfqgPxyT1Tldns1i87iTEvU= +github.com/elazarl/goproxy v0.0.0-20240909085733-6741dbfc16a1 h1:g7YUigN4dW2+zpdusdTTghZ+5Py3BaUMAStvL8Nk+FY= +github.com/elazarl/goproxy v0.0.0-20240909085733-6741dbfc16a1/go.mod h1:thX175TtLTzLj3p7N/Q9IiKZ7NF+p72cvL91emV0hzo= +github.com/elazarl/goproxy/ext v0.0.0-20190711103511-473e67f1d7d2 h1:dWB6v3RcOy03t/bUadywsbyrQwCqZeNIEX6M1OtSZOM= +github.com/elazarl/goproxy/ext v0.0.0-20190711103511-473e67f1d7d2/go.mod h1:gNh8nYJoAm43RfaxurUnxr+N1PwuFV3ZMl/efxlIlY8= github.com/elliotchance/orderedmap v1.6.0 h1:xjn+kbbKXeDq6v9RVE+WYwRbYfAZKvlWfcJNxM8pvEw= github.com/elliotchance/orderedmap v1.6.0/go.mod h1:wsDwEaX5jEoyhbs7x93zk2H/qv0zwuhg4inXhDkYqys= github.com/elliotwutingfeng/asciiset v0.0.0-20230602022725-51bbb787efab h1:h1UgjJdAAhj+uPL68n7XASS6bU+07ZX1WJvVS2eyoeY= diff --git a/pkg/cidata/cidata.TEMPLATE.d/lima.env b/pkg/cidata/cidata.TEMPLATE.d/lima.env index c0c8c7d11ee..9f51ef92ffb 100644 --- a/pkg/cidata/cidata.TEMPLATE.d/lima.env +++ b/pkg/cidata/cidata.TEMPLATE.d/lima.env @@ -32,6 +32,7 @@ LIMA_CIDATA_SLIRP_GATEWAY={{.SlirpGateway}} LIMA_CIDATA_SLIRP_IP_ADDRESS={{.SlirpIPAddress}} LIMA_CIDATA_UDP_DNS_LOCAL_PORT={{.UDPDNSLocalPort}} LIMA_CIDATA_TCP_DNS_LOCAL_PORT={{.TCPDNSLocalPort}} +LIMA_CIDATA_HTTP_PROXY_LOCAL_PORT={{.HTTPProxyLocalPort}} LIMA_CIDATA_ROSETTA_ENABLED={{.RosettaEnabled}} LIMA_CIDATA_ROSETTA_BINFMT={{.RosettaBinFmt}} {{- if .SkipDefaultDependencyResolution}} diff --git a/pkg/cidata/cidata.TEMPLATE.d/proxy.crt b/pkg/cidata/cidata.TEMPLATE.d/proxy.crt new file mode 100644 index 00000000000..abc5b014625 --- /dev/null +++ b/pkg/cidata/cidata.TEMPLATE.d/proxy.crt @@ -0,0 +1,3 @@ +{{ range $line := .HTTPProxyCACert.Lines -}} +{{ $line }} +{{ end -}} diff --git a/pkg/cidata/cidata.go b/pkg/cidata/cidata.go index 722c1a6c947..d8a84560106 100644 --- a/pkg/cidata/cidata.go +++ b/pkg/cidata/cidata.go @@ -111,7 +111,7 @@ func setupEnv(instConfigEnv map[string]string, propagateProxyEnv bool, slirpGate return env, nil } -func templateArgs(bootScripts bool, instDir, name string, instConfig *limayaml.LimaYAML, udpDNSLocalPort, tcpDNSLocalPort, vsockPort int, virtioPort string) (*TemplateArgs, error) { +func templateArgs(bootScripts bool, instDir, name string, instConfig *limayaml.LimaYAML, udpDNSLocalPort, tcpDNSLocalPort, vsockPort int, virtioPort string, proxyPort int, proxyCert string) (*TemplateArgs, error) { if err := limayaml.Validate(instConfig, false); err != nil { return nil, err } @@ -290,6 +290,11 @@ func templateArgs(bootScripts bool, instDir, name string, instConfig *limayaml.L } } + if *instConfig.HostProxy.Enabled { + args.HTTPProxyLocalPort = proxyPort + args.HTTPProxyCACert = getCert(proxyCert) + } + args.CACerts.RemoveDefaults = instConfig.CACertificates.RemoveDefaults for _, path := range instConfig.CACertificates.Files { @@ -330,7 +335,7 @@ func templateArgs(bootScripts bool, instDir, name string, instConfig *limayaml.L } func GenerateCloudConfig(instDir, name string, instConfig *limayaml.LimaYAML) error { - args, err := templateArgs(false, instDir, name, instConfig, 0, 0, 0, "") + args, err := templateArgs(false, instDir, name, instConfig, 0, 0, 0, "", 0, "") if err != nil { return err } @@ -352,8 +357,8 @@ func GenerateCloudConfig(instDir, name string, instConfig *limayaml.LimaYAML) er return os.WriteFile(filepath.Join(instDir, filenames.CloudConfig), config, 0o444) } -func GenerateISO9660(instDir, name string, instConfig *limayaml.LimaYAML, udpDNSLocalPort, tcpDNSLocalPort int, nerdctlArchive string, vsockPort int, virtioPort string) error { - args, err := templateArgs(true, instDir, name, instConfig, udpDNSLocalPort, tcpDNSLocalPort, vsockPort, virtioPort) +func GenerateISO9660(instDir, name string, instConfig *limayaml.LimaYAML, udpDNSLocalPort, tcpDNSLocalPort int, nerdctlArchive string, vsockPort int, virtioPort string, proxyPort int, proxyCert string) error { + args, err := templateArgs(true, instDir, name, instConfig, udpDNSLocalPort, tcpDNSLocalPort, vsockPort, virtioPort, proxyPort, proxyCert) if err != nil { return err } diff --git a/pkg/cidata/template.go b/pkg/cidata/template.go index 60abc16a961..453d2538e62 100644 --- a/pkg/cidata/template.go +++ b/pkg/cidata/template.go @@ -73,6 +73,8 @@ type TemplateArgs struct { SlirpIPAddress string UDPDNSLocalPort int TCPDNSLocalPort int + HTTPProxyLocalPort int + HTTPProxyCACert Cert Env map[string]string Param map[string]string BootScripts bool diff --git a/pkg/hostagent/hostagent.go b/pkg/hostagent/hostagent.go index f2f18debb5f..3d619775f98 100644 --- a/pkg/hostagent/hostagent.go +++ b/pkg/hostagent/hostagent.go @@ -27,6 +27,7 @@ import ( hostagentapi "github.com/lima-vm/lima/pkg/hostagent/api" "github.com/lima-vm/lima/pkg/hostagent/dns" "github.com/lima-vm/lima/pkg/hostagent/events" + "github.com/lima-vm/lima/pkg/hostagent/proxy" "github.com/lima-vm/lima/pkg/limayaml" "github.com/lima-vm/lima/pkg/networks" "github.com/lima-vm/lima/pkg/osutil" @@ -44,6 +45,7 @@ type HostAgent struct { sshLocalPort int udpDNSLocalPort int tcpDNSLocalPort int + proxyLocalPort int instDir string instName string instSSHAddress string @@ -117,6 +119,13 @@ func New(instName string, stdout io.Writer, signalCh chan os.Signal, opts ...Opt return nil, err } } + var proxyLocalPort int + if *inst.Config.HostProxy.Enabled { + proxyLocalPort, err = findFreeUDPLocalPort() + if err != nil { + return nil, err + } + } vSockPort := 0 virtioPort := "" @@ -136,7 +145,7 @@ func New(instName string, stdout io.Writer, signalCh chan os.Signal, opts ...Opt if err := cidata.GenerateCloudConfig(inst.Dir, instName, inst.Config); err != nil { return nil, err } - if err := cidata.GenerateISO9660(inst.Dir, instName, inst.Config, udpDNSLocalPort, tcpDNSLocalPort, o.nerdctlArchive, vSockPort, virtioPort); err != nil { + if err := cidata.GenerateISO9660(inst.Dir, instName, inst.Config, udpDNSLocalPort, tcpDNSLocalPort, o.nerdctlArchive, vSockPort, virtioPort, proxyLocalPort, proxy.CACert); err != nil { return nil, err } @@ -201,6 +210,7 @@ func New(instName string, stdout io.Writer, signalCh chan os.Signal, opts ...Opt sshLocalPort: sshLocalPort, udpDNSLocalPort: udpDNSLocalPort, tcpDNSLocalPort: tcpDNSLocalPort, + proxyLocalPort: proxyLocalPort, instDir: inst.Dir, instName: instName, instSSHAddress: inst.SSHAddress, @@ -348,6 +358,17 @@ func (a *HostAgent) Run(ctx context.Context) error { defer dnsServer.Shutdown() } + if *a.instConfig.HostProxy.Enabled { + srvOpts := proxy.ServerOptions{ + TCPPort: a.proxyLocalPort, + } + proxyServer, err := proxy.Start(srvOpts) + if err != nil { + return fmt.Errorf("cannot start proxy server: %w", err) + } + defer proxyServer.Shutdown() + } + errCh, err := a.driver.Start(ctx) if err != nil { return err diff --git a/pkg/hostagent/proxy/proxy.go b/pkg/hostagent/proxy/proxy.go new file mode 100644 index 00000000000..f68d00c5133 --- /dev/null +++ b/pkg/hostagent/proxy/proxy.go @@ -0,0 +1,166 @@ +// This file has been adapted from https://github.com/elazarl/goproxy/blob/6741dbfc16a1/examples/goproxy-eavesdropper/main.go + +package proxy + +import ( + "bufio" + "context" + "net" + "net/http" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" + + "github.com/lima-vm/lima/pkg/downloader" + + "github.com/elazarl/goproxy" + "github.com/sirupsen/logrus" +) + +// CACert has the CA certificate text. +var CACert = string(goproxy.CA_CERT) + +type ServerOptions struct { + Address string + TCPPort int +} + +type Server struct { + srv *http.Server +} + +func (s *Server) Shutdown() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if s.srv != nil { + _ = s.srv.Shutdown(ctx) + } +} + +func Start(opts ServerOptions) (*Server, error) { + server := &Server{} + if opts.TCPPort > 0 { + srv, err := listenAndServe(opts) + if err != nil { + return nil, err + } + server.srv = srv + } + return server, nil +} + +func sendFile(req *http.Request, path string, lastModified time.Time, contentType string) (*http.Response, error) { + resp := &http.Response{} + resp.Request = req + resp.TransferEncoding = req.TransferEncoding + resp.Header = make(http.Header) + status := http.StatusOK + resp.StatusCode = status + resp.Status = http.StatusText(status) + st, err := os.Stat(path) + if err != nil { + return nil, err + } + f, err := os.Open(path) + if err != nil { + return nil, err + } + resp.Body = f + if contentType == "" { + contentType = "application/octet-stream" + } + resp.Header.Set("Content-Type", contentType) + if !lastModified.IsZero() { + resp.Header.Set("Last-Modified", lastModified.Format(http.TimeFormat)) + } + resp.ContentLength = st.Size() + return resp, nil +} + +func listenAndServe(opts ServerOptions) (*http.Server, error) { + ucd, err := os.UserCacheDir() + if err != nil { + return nil, err + } + cacheDir := filepath.Join(ucd, "lima") + downloader.HideProgress = true + + addr := net.JoinHostPort(opts.Address, strconv.Itoa(opts.TCPPort)) + proxy := goproxy.NewProxyHttpServer() + proxy.OnRequest(goproxy.ReqHostMatches(regexp.MustCompile("^.*$"))). + HandleConnect(goproxy.AlwaysMitm) + proxy.OnRequest().DoFunc(func(req *http.Request, _ *goproxy.ProxyCtx) (*http.Request, *http.Response) { + u := req.URL + if strings.Contains(u.Host, ":") { + host, port, err := net.SplitHostPort(u.Host) + if err != nil { + return nil, nil + } + if u.Scheme == "http" && port == "80" || u.Scheme == "https" && port == "443" { + u.Host = host + } + } + url := u.String() + if res, err := downloader.Cached(url, downloader.WithCacheDir(cacheDir)); err == nil { + if resp, err := sendFile(req, res.CachePath, res.LastModified, res.ContentType); err == nil { + return nil, resp + } + } + if res, err := downloader.Download(context.Background(), "", url, downloader.WithCacheDir(cacheDir)); err == nil { + if resp, err := sendFile(req, res.CachePath, res.LastModified, res.ContentType); err == nil { + return nil, resp + } + } + return req, nil + }) + proxy.OnRequest(goproxy.ReqHostMatches(regexp.MustCompile("^.*:80$"))). + HijackConnect(func(req *http.Request, client net.Conn, ctx *goproxy.ProxyCtx) { + defer func() { + if e := recover(); e != nil { + ctx.Logf("error connecting to remote: %v", e) + _, _ = client.Write([]byte("HTTP/1.1 500 Cannot reach destination\r\n\r\n")) + } + client.Close() + }() + clientBuf := bufio.NewReadWriter(bufio.NewReader(client), bufio.NewWriter(client)) + remote, err := net.Dial("tcp", req.URL.Host) + if err != nil { + ctx.Logf("%v", err) + return + } + _, _ = client.Write([]byte("HTTP/1.1 200 Ok\r\n\r\n")) + remoteBuf := bufio.NewReadWriter(bufio.NewReader(remote), bufio.NewWriter(remote)) + for { + req, err := http.ReadRequest(clientBuf.Reader) + if err != nil { + ctx.Logf("%v", err) + return + } + _ = req.Write(remoteBuf) + _ = remoteBuf.Flush() + resp, err := http.ReadResponse(remoteBuf.Reader, req) + if err != nil { + ctx.Logf("%v", err) + return + } + _ = resp.Write(clientBuf.Writer) + _ = clientBuf.Flush() + resp.Body.Close() + } + }) + proxy.Verbose = true + s := &http.Server{Addr: addr, Handler: proxy} + go func() { + logrus.Debugf("Start HTTP proxy listening on: %v", addr) + if e := s.ListenAndServe(); e != nil { + if e != http.ErrServerClosed { + logrus.Fatal(e) + } + } + }() + + return s, nil +} diff --git a/pkg/limayaml/defaults.go b/pkg/limayaml/defaults.go index 32817cee55c..67447c5ce8c 100644 --- a/pkg/limayaml/defaults.go +++ b/pkg/limayaml/defaults.go @@ -514,6 +514,16 @@ func FillDefault(y, d, o *LimaYAML, filePath string) { y.HostResolver.IPv6 = ptr.Of(false) } + if y.HostProxy.Enabled == nil { + y.HostProxy.Enabled = d.HostProxy.Enabled + } + if o.HostProxy.Enabled != nil { + y.HostProxy.Enabled = o.HostProxy.Enabled + } + if y.HostProxy.Enabled == nil { + y.HostProxy.Enabled = ptr.Of(false) + } + if y.PropagateProxyEnv == nil { y.PropagateProxyEnv = d.PropagateProxyEnv } diff --git a/pkg/limayaml/defaults_test.go b/pkg/limayaml/defaults_test.go index f65d4594ec7..ddb72843cf6 100644 --- a/pkg/limayaml/defaults_test.go +++ b/pkg/limayaml/defaults_test.go @@ -102,6 +102,9 @@ func TestFillDefault(t *testing.T) { Enabled: ptr.Of(true), IPv6: ptr.Of(false), }, + HostProxy: HostProxy{ + Enabled: ptr.Of(false), + }, PropagateProxyEnv: ptr.Of(true), CACertificates: CACertificates{ RemoveDefaults: ptr.Of(false), @@ -365,6 +368,9 @@ func TestFillDefault(t *testing.T) { "default": "localhost", }, }, + HostProxy: HostProxy{ + Enabled: ptr.Of(true), + }, PropagateProxyEnv: ptr.Of(false), Mounts: []Mount{ @@ -564,6 +570,9 @@ func TestFillDefault(t *testing.T) { "override.": "underflow", }, }, + HostProxy: HostProxy{ + Enabled: ptr.Of(true), + }, PropagateProxyEnv: ptr.Of(false), Mounts: []Mount{ diff --git a/pkg/limayaml/limayaml.go b/pkg/limayaml/limayaml.go index 469cb7a3033..dfc70db828f 100644 --- a/pkg/limayaml/limayaml.go +++ b/pkg/limayaml/limayaml.go @@ -40,6 +40,7 @@ type LimaYAML struct { Param map[string]string `yaml:"param,omitempty" json:"param,omitempty"` DNS []net.IP `yaml:"dns,omitempty" json:"dns,omitempty"` HostResolver HostResolver `yaml:"hostResolver,omitempty" json:"hostResolver,omitempty"` + HostProxy HostProxy `yaml:"hostProxy,omitempty" json:"hostProxy,omitempty"` // `useHostResolver` was deprecated in Lima v0.8.1, removed in Lima v0.14.0. Use `hostResolver.enabled` instead. PropagateProxyEnv *bool `yaml:"propagateProxyEnv,omitempty" json:"propagateProxyEnv,omitempty"` CACertificates CACertificates `yaml:"caCerts,omitempty" json:"caCerts,omitempty"` @@ -269,6 +270,10 @@ type HostResolver struct { Hosts map[string]string `yaml:"hosts,omitempty" json:"hosts,omitempty"` } +type HostProxy struct { + Enabled *bool `yaml:"enabled,omitempty" json:"enabled,omitempty"` +} + type CACertificates struct { RemoveDefaults *bool `yaml:"removeDefaults,omitempty" json:"removeDefaults,omitempty"` // default: false Files []string `yaml:"files,omitempty" json:"files,omitempty"`