podman/pkg/machine/ports.go

214 lines
5.1 KiB
Go

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()
}