diff --git a/libpod/networking_machine.go b/libpod/networking_machine.go index de45e299ea..1212b03ed1 100644 --- a/libpod/networking_machine.go +++ b/libpod/networking_machine.go @@ -75,7 +75,7 @@ func requestMachinePorts(expose bool, ports []types.PortMapping) error { } return err } - if err := makeMachineRequest(ctx, client, url, buf); err != nil { + if err := makeMachineRequest(ctx, client, url, buf, machinePort); err != nil { if expose { // in case of an error make sure to unexpose the other ports if cerr := requestMachinePorts(false, ports[:num]); cerr != nil { @@ -91,7 +91,7 @@ func requestMachinePorts(expose bool, ports []types.PortMapping) error { return nil } -func makeMachineRequest(ctx context.Context, client *http.Client, url string, buf io.Reader) error { +func makeMachineRequest(ctx context.Context, client *http.Client, url string, buf io.Reader, port machineExpose) error { req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, buf) if err != nil { return err @@ -104,19 +104,41 @@ func makeMachineRequest(ctx context.Context, client *http.Client, url string, bu } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return annotateGvproxyResponseError(resp.Body) + return annotateGvproxyResponseError(resp.Body, port) } return nil } -func annotateGvproxyResponseError(r io.Reader) error { +func annotateGvproxyResponseError(r io.Reader, port machineExpose) error { b, err := io.ReadAll(r) if err == nil && len(b) > 0 { - return fmt.Errorf("something went wrong with the request: %q", string(b)) + body := string(b) + // gvproxy runs unprivileged on the host and binds the published port + // there, not inside the VM. On macOS, binding a privileged port + // (< 1024) to a specific IP address is rejected for a normal user even + // though binding to all interfaces is allowed, so give a more + // actionable error in that case instead of the raw gvproxy message. + // See https://github.com/containers/podman/issues/28009 + if host, portStr, perr := net.SplitHostPort(port.Local); perr == nil && strings.Contains(body, "permission denied") { + if p, cerr := strconv.Atoi(portStr); cerr == nil && p < 1024 && !unspecifiedHostIP(host) { + return fmt.Errorf("cannot bind port %s: %q: with podman machine the published port is bound on the host by gvproxy, which runs unprivileged; macOS does not permit binding a privileged port (< 1024) to a specific IP address as a normal user. Publish the port without a host IP to bind all interfaces, or use a port >= 1024", port.Local, body) + } + } + return fmt.Errorf("something went wrong with the request: %q", body) } return errors.New("something went wrong with the request, could not read response") } +// unspecifiedHostIP reports whether the host part of a published port maps to +// "all interfaces": an empty host or the unspecified address (0.0.0.0 / ::). +func unspecifiedHostIP(host string) bool { + if host == "" { + return true + } + ip := net.ParseIP(host) + return ip != nil && ip.IsUnspecified() +} + // exposeMachinePorts exposes the ports for podman machine via gvproxy func (r *Runtime) exposeMachinePorts(ports []types.PortMapping) error { if !machine.IsGvProxyBased() { diff --git a/libpod/networking_machine_test.go b/libpod/networking_machine_test.go new file mode 100644 index 0000000000..24aad2027b --- /dev/null +++ b/libpod/networking_machine_test.go @@ -0,0 +1,52 @@ +//go:build !remote && (linux || freebsd) + +package libpod + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAnnotateGvproxyResponseError(t *testing.T) { + const permDenied = "bind: permission denied" + + tests := []struct { + name string + body string + // local is the host-side "ip:port" gvproxy was asked to bind. + local string + // wantHint is true when the macOS/gvproxy privileged-port hint is expected. + wantHint bool + }{ + {"privileged port on a specific IP returns the gvproxy hint", permDenied, "192.168.1.5:80", true}, + {"privileged port on an IPv6 address returns the gvproxy hint", permDenied, "[fe80::1]:443", true}, + {"privileged port without a host IP stays generic", permDenied, ":80", false}, + {"privileged port on 0.0.0.0 stays generic", permDenied, "0.0.0.0:80", false}, + {"privileged port on the IPv6 unspecified address stays generic", permDenied, "[::]:80", false}, + {"unprivileged port on a specific IP stays generic", permDenied, "192.168.1.5:8080", false}, + {"unrelated error on a specific privileged port stays generic", "some other failure", "192.168.1.5:80", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := annotateGvproxyResponseError(strings.NewReader(tt.body), machineExpose{Local: tt.local}) + require.Error(t, err) + assert.Equal(t, tt.wantHint, strings.Contains(err.Error(), "gvproxy")) + if tt.wantHint { + // the hint must carry the actionable guidance, not just the word "gvproxy" + assert.Contains(t, err.Error(), "without a host IP") + } + // The raw gvproxy response body is always preserved for debugging. + assert.Contains(t, err.Error(), tt.body) + }) + } +} + +func TestAnnotateGvproxyResponseErrorEmptyBody(t *testing.T) { + err := annotateGvproxyResponseError(strings.NewReader(""), machineExpose{Local: "192.168.1.5:80"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "could not read response") +}