Skip to content

Commit 63a8926

Browse files
committed
Implement automatic port reassignment on Windows
While only leveraged by the WSL backend, this commit also adds core infrastructure for all other backends for future enhancement. - Adds a common port cross backend allocation registry to prevent duplicate assignment across multiple machine instances - Introduces logic in Start() that detects OS port conflicts and scans for a viable replacement port - Updates connection definitions and server configuration accordingly - Utilizes a coordinated file lock strategy to prevent racing overwrites of port and connection registries - WSL backend coordinates locking for containers.conf until a future common enhancement exists to replace it [NO NEW TESTS NEEDED] Signed-off-by: Jason T. Greene <[email protected]>
1 parent 938a3e1 commit 63a8926

File tree

7 files changed

+393
-3
lines changed

7 files changed

+393
-3
lines changed

pkg/machine/config.go

+11
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,17 @@ func GetDataDir(vmType VMType) (string, error) {
199199
return dataDir, mkdirErr
200200
}
201201

202+
// GetGLobalDataDir returns the root of all backends
203+
// for shared machine data.
204+
func GetGlobalDataDir() (string, error) {
205+
dataDir, err := DataDirPrefix()
206+
if err != nil {
207+
return "", err
208+
}
209+
210+
return dataDir, os.MkdirAll(dataDir, 0755)
211+
}
212+
202213
// DataDirPrefix returns the path prefix for all machine data files
203214
func DataDirPrefix() (string, error) {
204215
data, err := homedir.GetDataHome()

pkg/machine/connection.go

+15
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,21 @@ func AnyConnectionDefault(name ...string) (bool, error) {
5858
return false, nil
5959
}
6060

61+
func ChangeConnectionURI(name string, uri fmt.Stringer) error {
62+
cfg, err := config.ReadCustomConfig()
63+
if err != nil {
64+
return err
65+
}
66+
dst, ok := cfg.Engine.ServiceDestinations[name]
67+
if !ok {
68+
return errors.New("connection not found")
69+
}
70+
dst.URI = uri.String()
71+
cfg.Engine.ServiceDestinations[name] = dst
72+
73+
return cfg.Write()
74+
}
75+
6176
func ChangeDefault(name string) error {
6277
cfg, err := config.ReadCustomConfig()
6378
if err != nil {

pkg/machine/ports.go

+213
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
package machine
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"errors"
7+
"fmt"
8+
"io"
9+
"net"
10+
"os"
11+
"path/filepath"
12+
"strconv"
13+
14+
"github.com/containers/storage/pkg/ioutils"
15+
"github.com/containers/storage/pkg/lockfile"
16+
"github.com/sirupsen/logrus"
17+
)
18+
19+
const (
20+
portAllocFileName = "port-alloc.dat"
21+
portLockFileName = "port-alloc.lck"
22+
)
23+
24+
// Reserves a unique port for a machine instance in a global (user) scope across
25+
// all machines and backend types. On success the port is guaranteed to not be
26+
// allocated until released with a call to ReleaseMachinePort().
27+
//
28+
// The purpose of this method is to prevent collisions between machine
29+
// instances when ran at the same time. Note, that dynamic port reassignment
30+
// on its own is insufficient to resolve conflicts, since there is a narrow
31+
// window between port detection and actual service binding, allowing for the
32+
// possibility of a second racing machine to fail if its check is unlucky to
33+
// fall within that window. Additionally, there is the potential for a long
34+
// running reassignment dance over start/stop until all machine instances
35+
// eventually arrive at total conflict free state. By reserving ports using
36+
// mechanism these scenarios are prevented.
37+
func AllocateMachinePort() (int, error) {
38+
const maxRetries = 10000
39+
40+
handles := []io.Closer{}
41+
defer func() {
42+
for _, handle := range handles {
43+
handle.Close()
44+
}
45+
}()
46+
47+
lock, err := acquirePortLock()
48+
if err != nil {
49+
return 0, err
50+
}
51+
defer lock.Unlock()
52+
53+
ports, err := loadPortAllocations()
54+
if err != nil {
55+
return 0, err
56+
}
57+
58+
var port int
59+
for i := 0; ; i++ {
60+
var handle io.Closer
61+
62+
// Ports must be held temporarily to prevent repeat search results
63+
handle, port, err = getRandomPortHold()
64+
if err != nil {
65+
return 0, err
66+
}
67+
handles = append(handles, handle)
68+
69+
if _, exists := ports[port]; !exists {
70+
break
71+
}
72+
73+
if i > maxRetries {
74+
return 0, errors.New("maximum number of retries exceeded searching for available port")
75+
}
76+
}
77+
78+
ports[port] = struct{}{}
79+
if err := storePortAllocations(ports); err != nil {
80+
return 0, err
81+
}
82+
83+
return port, nil
84+
}
85+
86+
// Releases a reserved port for a machine when no longer required. Care should
87+
// be taken to ensure there are no conditions (e.g. failure paths) where the
88+
// port might unintentionally remain in use after releasing
89+
func ReleaseMachinePort(port int) error {
90+
lock, err := acquirePortLock()
91+
if err != nil {
92+
return err
93+
}
94+
defer lock.Unlock()
95+
ports, err := loadPortAllocations()
96+
if err != nil {
97+
return err
98+
}
99+
100+
delete(ports, port)
101+
return storePortAllocations(ports)
102+
}
103+
104+
func IsLocalPortAvailable(port int) bool {
105+
// Used to mark invalid / unassigned port
106+
if port <= 0 {
107+
return false
108+
}
109+
110+
lc := getPortCheckListenConfig()
111+
l, err := lc.Listen(context.Background(), "tcp", fmt.Sprintf("127.0.0.1:%d", port))
112+
if err != nil {
113+
return false
114+
}
115+
l.Close()
116+
return true
117+
}
118+
119+
func getRandomPortHold() (io.Closer, int, error) {
120+
l, err := net.Listen("tcp", "127.0.0.1:0")
121+
if err != nil {
122+
return nil, 0, fmt.Errorf("unable to get free machine port: %w", err)
123+
}
124+
_, portString, err := net.SplitHostPort(l.Addr().String())
125+
if err != nil {
126+
l.Close()
127+
return nil, 0, fmt.Errorf("unable to determine free machine port: %w", err)
128+
}
129+
port, err := strconv.Atoi(portString)
130+
if err != nil {
131+
l.Close()
132+
return nil, 0, fmt.Errorf("unable to convert port to int: %w", err)
133+
}
134+
return l, port, err
135+
}
136+
137+
func acquirePortLock() (*lockfile.LockFile, error) {
138+
lockDir, err := GetGlobalDataDir()
139+
if err != nil {
140+
return nil, err
141+
}
142+
143+
lock, err := lockfile.GetLockFile(filepath.Join(lockDir, portLockFileName))
144+
if err != nil {
145+
return nil, err
146+
}
147+
148+
lock.Lock()
149+
return lock, nil
150+
}
151+
152+
func loadPortAllocations() (map[int]struct{}, error) {
153+
portDir, err := GetGlobalDataDir()
154+
if err != nil {
155+
return nil, err
156+
}
157+
158+
var portData []int
159+
exists := true
160+
file, err := os.OpenFile(filepath.Join(portDir, portAllocFileName), 0, 0)
161+
if errors.Is(err, os.ErrNotExist) {
162+
exists = false
163+
} else if err != nil {
164+
return nil, err
165+
}
166+
defer file.Close()
167+
168+
// Non-existence of the file, or a corrupt file are not treated as hard
169+
// failures, since dynamic reassignment and continued use will eventually
170+
// rebuild the dataset. This also makes migration cases simpler, since
171+
// the state doesn't have to exist
172+
if exists {
173+
decoder := json.NewDecoder(file)
174+
if err := decoder.Decode(&portData); err != nil {
175+
logrus.Warnf("corrupt port allocation file, could not use state")
176+
}
177+
}
178+
179+
ports := make(map[int]struct{})
180+
placeholder := struct{}{}
181+
for _, port := range portData {
182+
ports[port] = placeholder
183+
}
184+
185+
return ports, nil
186+
}
187+
188+
func storePortAllocations(ports map[int]struct{}) error {
189+
portDir, err := GetGlobalDataDir()
190+
if err != nil {
191+
return err
192+
}
193+
194+
portData := make([]int, 0, len(ports))
195+
for port := range ports {
196+
portData = append(portData, port)
197+
}
198+
199+
opts := &ioutils.AtomicFileWriterOptions{ExplicitCommit: true}
200+
w, err := ioutils.NewAtomicFileWriterWithOpts(filepath.Join(portDir, portAllocFileName), 0644, opts)
201+
if err != nil {
202+
return err
203+
}
204+
defer w.Close()
205+
206+
enc := json.NewEncoder(w)
207+
if err := enc.Encode(portData); err != nil {
208+
return err
209+
}
210+
211+
// Commit the changes to disk if no errors
212+
return w.Commit()
213+
}

pkg/machine/ports_unix.go

+29
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
//go:build darwin || dragonfly || freebsd || linux || netbsd || openbsd
2+
// +build darwin dragonfly freebsd linux netbsd openbsd
3+
4+
package machine
5+
6+
import (
7+
"net"
8+
"syscall"
9+
)
10+
11+
func getPortCheckListenConfig() *net.ListenConfig {
12+
return &net.ListenConfig{
13+
Control: func(network, address string, c syscall.RawConn) (cerr error) {
14+
if err := c.Control(func(fd uintptr) {
15+
// Prevent listening socket from holding over in TIME_WAIT in the rare case a connection
16+
// attempt occurs in the short window the socket is listening. This ensures the registration
17+
// will be gone when close() completes, freeing it up for the real subsequent listen by another
18+
// process
19+
cerr = syscall.SetsockoptLinger(int(fd), syscall.SOL_SOCKET, syscall.SO_LINGER, &syscall.Linger{
20+
Onoff: 1,
21+
Linger: 0,
22+
})
23+
}); err != nil {
24+
cerr = err
25+
}
26+
return
27+
},
28+
}
29+
}

pkg/machine/ports_windows.go

+28
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package machine
2+
3+
import (
4+
"net"
5+
"syscall"
6+
)
7+
8+
// NOTE the reason for the code duplication between win and unix is that the syscall
9+
// implementations require a different cast (Handle on Windows, int on Unixes)
10+
func getPortCheckListenConfig() *net.ListenConfig {
11+
return &net.ListenConfig{
12+
Control: func(network, address string, c syscall.RawConn) (cerr error) {
13+
if err := c.Control(func(fd uintptr) {
14+
// Prevent listening socket from holding over in TIME_WAIT in the rare case a connection
15+
// attempt occurs in the short window the socket is listening. This ensures the registration
16+
// will be gone when close() completes, freeing it up for the real subsequent listen by another
17+
// process
18+
cerr = syscall.SetsockoptLinger(syscall.Handle(fd), syscall.SOL_SOCKET, syscall.SO_LINGER, &syscall.Linger{
19+
Onoff: 1,
20+
Linger: 0,
21+
})
22+
}); err != nil {
23+
cerr = err
24+
}
25+
return
26+
},
27+
}
28+
}

pkg/machine/wsl/config.go

+1-2
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import (
1010
"time"
1111

1212
"github.com/containers/podman/v4/pkg/machine"
13-
"github.com/containers/podman/v4/utils"
1413
"github.com/sirupsen/logrus"
1514
)
1615

@@ -55,7 +54,7 @@ func (p *WSLVirtualization) NewMachine(opts machine.InitOptions) (machine.VM, er
5554
}
5655

5756
// Add a random port for ssh
58-
port, err := utils.GetRandomPort()
57+
port, err := machine.AllocateMachinePort()
5958
if err != nil {
6059
return nil, err
6160
}

0 commit comments

Comments
 (0)