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 }