github.com/adevinta/lava@v0.7.2/internal/engine/targetserver.go (about)

     1  // Copyright 2023 Adevinta
     2  
     3  package engine
     4  
     5  import (
     6  	"errors"
     7  	"fmt"
     8  	"io/fs"
     9  	"net"
    10  	"net/url"
    11  	"os"
    12  	"path"
    13  	"strconv"
    14  	"strings"
    15  	"sync"
    16  	"syscall"
    17  
    18  	types "github.com/adevinta/vulcan-types"
    19  	"github.com/jroimartin/proxy"
    20  
    21  	"github.com/adevinta/lava/internal/assettypes"
    22  	"github.com/adevinta/lava/internal/config"
    23  	"github.com/adevinta/lava/internal/containers"
    24  	"github.com/adevinta/lava/internal/gitserver"
    25  )
    26  
    27  // targetMap maps a target identifier with its updated value.
    28  type targetMap struct {
    29  	// OldIdentifier is the original target identifier.
    30  	OldIdentifier string
    31  
    32  	// OldAssetType is the original asset type of the target.
    33  	OldAssetType types.AssetType
    34  
    35  	// NewIdentifier is the updated target identifier.
    36  	NewIdentifier string
    37  
    38  	// NewAssetType is the updated asset type of the target.
    39  	NewAssetType types.AssetType
    40  }
    41  
    42  // IsZero reports whether tm is the zero value.
    43  func (tm targetMap) IsZero() bool {
    44  	return tm == targetMap{}
    45  }
    46  
    47  // Addrs returns a [targetMap] with the addresses of the targets. If
    48  // it is not possible to get the address of a target, then the target
    49  // is used.
    50  func (tm targetMap) Addrs() targetMap {
    51  	oldAddr, err := getTargetAddr(config.Target{Identifier: tm.OldIdentifier, AssetType: tm.OldAssetType})
    52  	if err != nil {
    53  		oldAddr = tm.OldIdentifier
    54  	}
    55  
    56  	newAddr, err := getTargetAddr(config.Target{Identifier: tm.NewIdentifier, AssetType: tm.NewAssetType})
    57  	if err != nil {
    58  		newAddr = tm.NewIdentifier
    59  	}
    60  
    61  	tmAddrs := targetMap{
    62  		OldIdentifier: oldAddr,
    63  		OldAssetType:  tm.OldAssetType,
    64  		NewIdentifier: newAddr,
    65  		NewAssetType:  tm.NewAssetType,
    66  	}
    67  	return tmAddrs
    68  }
    69  
    70  // targetServer represents Lava's internal target server. It is used
    71  // to serve local Git repositories and services.
    72  type targetServer struct {
    73  	cli     containers.DockerdClient
    74  	gs      *gitserver.Server
    75  	gitAddr string
    76  	pg      *proxy.Group
    77  
    78  	mu   sync.Mutex
    79  	maps map[string]targetMap
    80  }
    81  
    82  // newTargetServer returns a new [targetServer].
    83  func newTargetServer(rt containers.Runtime) (srv *targetServer, err error) {
    84  	cli, err := containers.NewDockerdClient(rt)
    85  	if err != nil {
    86  		return nil, fmt.Errorf("new dockerd client: %w", err)
    87  	}
    88  
    89  	gs, err := gitserver.New()
    90  	if err != nil {
    91  		return nil, fmt.Errorf("new GitServer: %w", err)
    92  	}
    93  
    94  	listenHost, err := cli.HostGatewayInterfaceAddr()
    95  	if err != nil {
    96  		return nil, fmt.Errorf("get bridge host: %w", err)
    97  	}
    98  
    99  	ln, err := net.Listen("tcp", net.JoinHostPort(listenHost, "0"))
   100  	if err != nil {
   101  		return nil, fmt.Errorf("GitServer listener: %w", err)
   102  	}
   103  
   104  	_, gitPort, err := net.SplitHostPort(ln.Addr().String())
   105  	if err != nil {
   106  		return nil, fmt.Errorf("split Git server host port: %w", err)
   107  	}
   108  
   109  	go gs.Serve(ln) //nolint:errcheck
   110  
   111  	srv = &targetServer{
   112  		cli:     cli,
   113  		gs:      gs,
   114  		gitAddr: net.JoinHostPort(cli.HostGatewayHostname(), gitPort),
   115  		pg:      proxy.NewGroup(),
   116  		maps:    make(map[string]targetMap),
   117  	}
   118  	return srv, nil
   119  }
   120  
   121  // Handle handles the provided target. If the target is a local Git
   122  // repository (i.e. a directory in the Host), it is served using
   123  // Lava's internal Git server. If the target is a local service, it is
   124  // served through an internal proxy, so Vulcan checks can access the
   125  // service. The specified key should be unique and it is used to index
   126  // the generated target maps. If the key is known, the cached
   127  // [targetMap] is returned. The returned [targetMap] is the zero value
   128  // if it is not necessary to map the target.
   129  func (srv *targetServer) Handle(key string, target config.Target) (targetMap, error) {
   130  	srv.mu.Lock()
   131  	defer srv.mu.Unlock()
   132  
   133  	if tm, ok := srv.maps[key]; ok {
   134  		return tm, nil
   135  	}
   136  
   137  	var (
   138  		tm  targetMap
   139  		err error
   140  	)
   141  	switch target.AssetType {
   142  	case types.GitRepository:
   143  		tm, err = srv.handleGitRepo(target)
   144  	case assettypes.Path:
   145  		tm, err = srv.handlePath(target)
   146  	case types.IP, types.Hostname, types.WebAddress:
   147  		tm, err = srv.handle(target)
   148  	case types.AWSAccount, types.DockerImage, types.IPRange, types.DomainName:
   149  		// These asset types are not handled by the target
   150  		// server.
   151  	default:
   152  		return targetMap{}, fmt.Errorf("unsupported asset type: %v", target.AssetType)
   153  	}
   154  	if err != nil {
   155  		return targetMap{}, err
   156  	}
   157  
   158  	if !tm.IsZero() {
   159  		srv.maps[key] = tm
   160  	}
   161  	return tm, err
   162  }
   163  
   164  // handle serves the specified target through an internal proxy, so
   165  // Vulcan checks can access the service.
   166  func (srv *targetServer) handle(target config.Target) (targetMap, error) {
   167  	stream, loopback, err := srv.mkStream(target)
   168  	if err != nil {
   169  		return targetMap{}, fmt.Errorf("generate stream: %w", err)
   170  	}
   171  
   172  	// If the target is not a loopback address, ignore it.
   173  	if !loopback {
   174  		return targetMap{}, nil
   175  	}
   176  
   177  	batch := srv.pg.ListenAndServe(stream)
   178  	defer func() {
   179  		// Discard remaining events and errors. So
   180  		// *proxy.Group.Close can free resources.
   181  		go batch.Flush()
   182  	}()
   183  
   184  loop:
   185  	for {
   186  		select {
   187  		case err, ok := <-batch.Errors():
   188  			// No listeners.
   189  			if !ok {
   190  				break loop
   191  			}
   192  
   193  			// If there is a service already listening on
   194  			// that address, then assume that it is the
   195  			// target service and ignore the error.
   196  			if errors.Is(err, syscall.EADDRINUSE) {
   197  				break loop
   198  			}
   199  
   200  			// An unexpected error happened in one of the
   201  			// proxies.
   202  			return targetMap{}, fmt.Errorf("proxy group: %w", err)
   203  		case ev := <-batch.Events():
   204  			if ev.Kind == proxy.KindBeforeAccept {
   205  				// The proxy is listening.
   206  				break loop
   207  			}
   208  		}
   209  	}
   210  
   211  	intIdentifier, err := srv.mkIntIdentifier(target)
   212  	if err != nil {
   213  		return targetMap{}, fmt.Errorf("generate internal identifier: %w", err)
   214  	}
   215  
   216  	tm := targetMap{
   217  		OldIdentifier: target.Identifier,
   218  		OldAssetType:  target.AssetType,
   219  		NewIdentifier: intIdentifier,
   220  		NewAssetType:  target.AssetType,
   221  	}
   222  	return tm, nil
   223  }
   224  
   225  // handleGitRepo serves the provided Git repository using Lava's
   226  // internal Git server.
   227  func (srv *targetServer) handleGitRepo(target config.Target) (targetMap, error) {
   228  	if _, err := os.Stat(target.Identifier); err != nil {
   229  		// If the path does not exist, assume that the target
   230  		// is a remote Git repository and ignore it.
   231  		if errors.Is(err, fs.ErrNotExist) {
   232  			return targetMap{}, nil
   233  		}
   234  		return targetMap{}, err
   235  	}
   236  
   237  	repo, err := srv.gs.AddRepository(target.Identifier)
   238  	if err != nil {
   239  		return targetMap{}, fmt.Errorf("add Git repository: %w", err)
   240  	}
   241  
   242  	tm := targetMap{
   243  		OldIdentifier: target.Identifier,
   244  		OldAssetType:  target.AssetType,
   245  		NewIdentifier: fmt.Sprintf("http://%v/%v", srv.gitAddr, repo),
   246  		NewAssetType:  target.AssetType,
   247  	}
   248  	return tm, nil
   249  }
   250  
   251  // handlePath serves the provided path as a Git repository with a
   252  // single commit.
   253  func (srv *targetServer) handlePath(target config.Target) (targetMap, error) {
   254  	repo, err := srv.gs.AddPath(target.Identifier)
   255  	if err != nil {
   256  		return targetMap{}, fmt.Errorf("add path: %w", err)
   257  	}
   258  
   259  	tm := targetMap{
   260  		OldIdentifier: target.Identifier,
   261  		OldAssetType:  target.AssetType,
   262  		NewIdentifier: fmt.Sprintf("http://%v/%v", srv.gitAddr, repo),
   263  		NewAssetType:  assettypes.ToVulcan(target.AssetType),
   264  	}
   265  	return tm, nil
   266  }
   267  
   268  // TargetMap returns the target map corresponding to the specified
   269  // key. If the target map cannot be found, the returned [targetMap] is
   270  // the zero value and the boolean is false.
   271  func (srv *targetServer) TargetMap(key string) (tm targetMap, ok bool) {
   272  	srv.mu.Lock()
   273  	defer srv.mu.Unlock()
   274  
   275  	tm, ok = srv.maps[key]
   276  	return
   277  }
   278  
   279  // Close closes the internal Git server and proxy.
   280  func (srv *targetServer) Close() error {
   281  	if err := srv.cli.Close(); err != nil {
   282  		return fmt.Errorf("close dockerd client: %w", err)
   283  	}
   284  
   285  	if err := srv.gs.Close(); err != nil {
   286  		return fmt.Errorf("close Git server: %w", err)
   287  	}
   288  
   289  	if err := srv.pg.Close(); err != nil {
   290  		return fmt.Errorf("close proxy group: %w", err)
   291  	}
   292  
   293  	return nil
   294  }
   295  
   296  // mkStream generates a [proxy.Stream] between the Docker bridge
   297  // network and the provided target. It uses the same port as the
   298  // address, so if the target is host:port, the returned stream will be
   299  // "bridgehost:port,host:port". The returned bool reports whether the
   300  // target is a loopback address.
   301  func (srv *targetServer) mkStream(target config.Target) (stream proxy.Stream, loopback bool, err error) {
   302  	addr, err := getTargetAddr(target)
   303  	if err != nil {
   304  		return proxy.Stream{}, false, fmt.Errorf("get target addr: %w", err)
   305  	}
   306  
   307  	host, port, err := net.SplitHostPort(addr)
   308  	if err != nil {
   309  		return proxy.Stream{}, false, fmt.Errorf("split host port: %w", err)
   310  	}
   311  
   312  	listenHost, err := srv.cli.HostGatewayInterfaceAddr()
   313  	if err != nil {
   314  		return proxy.Stream{}, false, fmt.Errorf("get listen host: %w", err)
   315  	}
   316  
   317  	listenAddr := net.JoinHostPort(listenHost, port)
   318  	dialAddr := net.JoinHostPort(host, port)
   319  	s := fmt.Sprintf("tcp:%v,tcp:%v", listenAddr, dialAddr)
   320  	stream, err = proxy.ParseStream(s)
   321  	if err != nil {
   322  		return proxy.Stream{}, false, fmt.Errorf("parse stream: %w", err)
   323  	}
   324  
   325  	return stream, isLoopback(host), nil
   326  }
   327  
   328  // getTargetAddr returns the network address pointed by a given
   329  // target.
   330  //
   331  // If the target is a [types.IP] or a [types.Hostname], its identifier
   332  // is returned straightaway.
   333  //
   334  // If the target is a [types.WebAddress], the identifier is parsed as
   335  // URL. If it is a valid URL, the corresponding host[:port] is
   336  // returned. Otherwise, the function returns error.
   337  //
   338  // If the target is a [types.GitRepository], the identifier is parsed
   339  // as a Git URL. If it is a valid Git URL, the corresponding
   340  // host[:port] is returned. Otherwise, the function returns error.
   341  //
   342  // [git-fetch documentation] points out that remote Git URLs may use
   343  // any of the following syntaxes:
   344  //
   345  //   - ssh://[user@]host.xz[:port]/~[user]/path/to/repo.git/
   346  //   - git://host.xz[:port]/~[user]/path/to/repo.git/
   347  //   - http[s]://host.xz[:port]/path/to/repo.git/
   348  //   - ftp[s]://host.xz[:port]/path/to/repo.git/
   349  //   - [user@]host.xz:/~[user]/path/to/repo.git/
   350  //
   351  // For any other asset type, the function returns an error.
   352  //
   353  // [git-fetch documentation]: https://git-scm.com/docs/git-fetch#URLS
   354  func getTargetAddr(target config.Target) (string, error) {
   355  	switch target.AssetType {
   356  	case types.IP, types.Hostname:
   357  		return target.Identifier, nil
   358  	case types.WebAddress:
   359  		u, err := url.Parse(target.Identifier)
   360  		if err != nil {
   361  			return "", fmt.Errorf("parse URL: %w", err)
   362  		}
   363  		if u.Host == "" {
   364  			return "", fmt.Errorf("empty URL host: %v", u)
   365  		}
   366  		return guessHostPort(u), nil
   367  	case types.GitRepository:
   368  		u, err := parseGitURL(target.Identifier)
   369  		if err != nil {
   370  			return "", fmt.Errorf("parse Git URL: %w", err)
   371  		}
   372  		if u.Host == "" {
   373  			return "", fmt.Errorf("empty Git URL host: %v", u)
   374  		}
   375  		return guessHostPort(u), nil
   376  	}
   377  	return "", fmt.Errorf("invalid asset type: %v", target.AssetType)
   378  }
   379  
   380  // guessHostPort tries to guess the port corresponding to the provided
   381  // URL and returns host:port. If the URL specifies a port, it is used.
   382  // Otherwise, if the URL specifies a scheme, the default port for that
   383  // scheme is used. Finally, if it is not possible to guess a port,
   384  // only the host is returned.
   385  func guessHostPort(u *url.URL) string {
   386  	if u.Port() != "" {
   387  		return u.Host
   388  	}
   389  
   390  	host := u.Hostname()
   391  	if port, err := net.LookupPort("tcp", u.Scheme); err == nil {
   392  		return net.JoinHostPort(host, strconv.Itoa(port))
   393  	}
   394  	return host
   395  }
   396  
   397  // mkIntIdentifier returns the identifier of the provided target after
   398  // replacing the host with the Docker internal host. If it is not
   399  // possible to generate an internal target from the provided asset
   400  // type the function returns an error.
   401  func (srv *targetServer) mkIntIdentifier(target config.Target) (string, error) {
   402  	switch target.AssetType {
   403  	case types.IP, types.Hostname:
   404  		return srv.cli.HostGatewayHostname(), nil
   405  	case types.WebAddress:
   406  		u, err := url.Parse(target.Identifier)
   407  		if err != nil {
   408  			return "", fmt.Errorf("parse URL: %w", err)
   409  		}
   410  		return srv.mkIntURL(u), nil
   411  	case types.GitRepository:
   412  		u, err := parseGitURL(target.Identifier)
   413  		if err != nil {
   414  			return "", fmt.Errorf("parse Git URL: %w", err)
   415  		}
   416  		return srv.mkIntURL(u), nil
   417  	}
   418  	return "", fmt.Errorf("invalid asset type: %v", target.AssetType)
   419  }
   420  
   421  // mkIntURL returns the string representation of the provided URL
   422  // after replacing its host with the Docker internal host.
   423  func (srv *targetServer) mkIntURL(u *url.URL) string {
   424  	host := srv.cli.HostGatewayHostname()
   425  	if port := u.Port(); port != "" {
   426  		host = net.JoinHostPort(host, port)
   427  	}
   428  	u.Host = host
   429  	return u.String()
   430  }
   431  
   432  // isLoopback returns whether host is a loopback address.
   433  func isLoopback(host string) bool {
   434  	ips, err := net.LookupIP(host)
   435  	if err != nil {
   436  		return false
   437  	}
   438  
   439  	for _, ip := range ips {
   440  		if ip.IsLoopback() {
   441  			return true
   442  		}
   443  	}
   444  	return false
   445  }
   446  
   447  // parseGitURL parses a Git URL. If gitURL is a scp-like Git URL, it
   448  // is first converted into a SSH URL.
   449  func parseGitURL(gitURL string) (*url.URL, error) {
   450  	rawURL := gitURL
   451  	if !strings.Contains(gitURL, "://") {
   452  		// scp-like syntax is only recognized if there are no
   453  		// slashes before the first colon.
   454  		cidx := strings.Index(gitURL, ":")
   455  		sidx := strings.Index(gitURL, "/")
   456  		if cidx >= 0 && (sidx < 0 || cidx < sidx) {
   457  			rawURL = "ssh://" + gitURL[:cidx] + path.Join("/", gitURL[cidx+1:])
   458  		}
   459  	}
   460  	return url.Parse(rawURL)
   461  }