package machine import ( "context" "encoding/json" "errors" "fmt" "io" "net" "os" "path/filepath" "strconv" "github.com/containers/storage/pkg/ioutils" "github.com/containers/storage/pkg/lockfile" "github.com/sirupsen/logrus" ) const ( portAllocFileName = "port-alloc.dat" portLockFileName = "port-alloc.lck" ) // Reserves a unique port for a machine instance in a global (user) scope across // all machines and backend types. On success the port is guaranteed to not be // allocated until released with a call to ReleaseMachinePort(). // // The purpose of this method is to prevent collisions between machine // instances when ran at the same time. Note, that dynamic port reassignment // on its own is insufficient to resolve conflicts, since there is a narrow // window between port detection and actual service binding, allowing for the // possibility of a second racing machine to fail if its check is unlucky to // fall within that window. Additionally, there is the potential for a long // running reassignment dance over start/stop until all machine instances // eventually arrive at total conflict free state. By reserving ports using // mechanism these scenarios are prevented. func AllocateMachinePort() (int, error) { const maxRetries = 10000 handles := []io.Closer{} defer func() { for _, handle := range handles { handle.Close() } }() lock, err := acquirePortLock() if err != nil { return 0, err } defer lock.Unlock() ports, err := loadPortAllocations() if err != nil { return 0, err } var port int for i := 0; ; i++ { var handle io.Closer // Ports must be held temporarily to prevent repeat search results handle, port, err = getRandomPortHold() if err != nil { return 0, err } handles = append(handles, handle) if _, exists := ports[port]; !exists { break } if i > maxRetries { return 0, errors.New("maximum number of retries exceeded searching for available port") } } ports[port] = struct{}{} if err := storePortAllocations(ports); err != nil { return 0, err } return port, nil } // Releases a reserved port for a machine when no longer required. Care should // be taken to ensure there are no conditions (e.g. failure paths) where the // port might unintentionally remain in use after releasing func ReleaseMachinePort(port int) error { lock, err := acquirePortLock() if err != nil { return err } defer lock.Unlock() ports, err := loadPortAllocations() if err != nil { return err } delete(ports, port) return storePortAllocations(ports) } func IsLocalPortAvailable(port int) bool { // Used to mark invalid / unassigned port if port <= 0 { return false } lc := getPortCheckListenConfig() l, err := lc.Listen(context.Background(), "tcp", fmt.Sprintf("127.0.0.1:%d", port)) if err != nil { return false } l.Close() return true } func getRandomPortHold() (io.Closer, int, error) { l, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { return nil, 0, fmt.Errorf("unable to get free machine port: %w", err) } _, portString, err := net.SplitHostPort(l.Addr().String()) if err != nil { l.Close() return nil, 0, fmt.Errorf("unable to determine free machine port: %w", err) } port, err := strconv.Atoi(portString) if err != nil { l.Close() return nil, 0, fmt.Errorf("unable to convert port to int: %w", err) } return l, port, err } func acquirePortLock() (*lockfile.LockFile, error) { lockDir, err := GetGlobalDataDir() if err != nil { return nil, err } lock, err := lockfile.GetLockFile(filepath.Join(lockDir, portLockFileName)) if err != nil { return nil, err } lock.Lock() return lock, nil } func loadPortAllocations() (map[int]struct{}, error) { portDir, err := GetGlobalDataDir() if err != nil { return nil, err } var portData []int exists := true file, err := os.OpenFile(filepath.Join(portDir, portAllocFileName), 0, 0) if errors.Is(err, os.ErrNotExist) { exists = false } else if err != nil { return nil, err } defer file.Close() // Non-existence of the file, or a corrupt file are not treated as hard // failures, since dynamic reassignment and continued use will eventually // rebuild the dataset. This also makes migration cases simpler, since // the state doesn't have to exist if exists { decoder := json.NewDecoder(file) if err := decoder.Decode(&portData); err != nil { logrus.Warnf("corrupt port allocation file, could not use state") } } ports := make(map[int]struct{}) placeholder := struct{}{} for _, port := range portData { ports[port] = placeholder } return ports, nil } func storePortAllocations(ports map[int]struct{}) error { portDir, err := GetGlobalDataDir() if err != nil { return err } portData := make([]int, 0, len(ports)) for port := range ports { portData = append(portData, port) } opts := &ioutils.AtomicFileWriterOptions{ExplicitCommit: true} w, err := ioutils.NewAtomicFileWriterWithOpts(filepath.Join(portDir, portAllocFileName), 0644, opts) if err != nil { return err } defer w.Close() enc := json.NewEncoder(w) if err := enc.Encode(portData); err != nil { return err } // Commit the changes to disk if no errors return w.Commit() }