cli: classify Serve errors with operator hints

A *ListenerBindError that wraps syscall.EADDRINUSE now ends with a
"sudo ss -tlnp 'sport = :PORT'" pointer, and one wrapping
syscall.EACCES with a CAP_NET_BIND_SERVICE / setcap pointer. The
underlying chain is preserved via fmt.Errorf("%w"), so errors.Is /
errors.As continue to walk to the typed bind error and the syscall
errno.

Drop the "headscale ran into an error and had to shut down" wrap,
which only restated the symptom.

Export types.PortFromAddr so the classifier can render the port
number in the hint.

Updates #3227
This commit is contained in:
Kristoffer Dalby
2026-04-30 08:44:38 +00:00
parent 61021c739a
commit 2114ced0d5
4 changed files with 265 additions and 67 deletions
+41 -3
View File
@@ -4,7 +4,9 @@ import (
"errors"
"fmt"
"net/http"
"syscall"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/spf13/cobra"
"github.com/tailscale/squibble"
)
@@ -28,10 +30,46 @@ var serveCmd = &cobra.Command{
}
err = app.Serve()
if err != nil && !errors.Is(err, http.ErrServerClosed) {
return fmt.Errorf("headscale ran into an error and had to shut down: %w", err)
if err == nil || errors.Is(err, http.ErrServerClosed) {
return nil
}
return nil
return classifyServeError(err)
},
}
// classifyServeError augments specific error classes with operator
// hints. The underlying chain is left intact so errors.Is / errors.As
// continue to walk to ListenerBindError, syscall.EADDRINUSE, etc.
func classifyServeError(err error) error {
var bindErr *types.ListenerBindError
if !errors.As(err, &bindErr) {
return err
}
switch {
case errors.Is(err, syscall.EADDRINUSE):
port, perr := types.PortFromAddr(bindErr.Addr)
if perr != nil {
return fmt.Errorf(
"%w\n\nHint: another process on this host is bound to the same address. "+
"Find it with: sudo ss -tlnp",
err)
}
return fmt.Errorf(
"%w\n\nHint: another process on this host is bound to the same address. "+
"Find it with: sudo ss -tlnp 'sport = :%d'",
err, port)
case errors.Is(err, syscall.EACCES):
return fmt.Errorf(
"%w\n\nHint: binding to a privileged port (<1024) requires root or "+
"CAP_NET_BIND_SERVICE. The shipped systemd unit grants this capability; "+
"if running manually, use sudo or "+
"`setcap cap_net_bind_service=+ep ./headscale`",
err)
}
return err
}
+122
View File
@@ -0,0 +1,122 @@
package cli
import (
"errors"
"fmt"
"net"
"syscall"
"testing"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
var errClassifierUnrelated = errors.New("not a bind error")
func TestClassifyServeError(t *testing.T) {
tests := []struct {
name string
err error
wantHintSubstr []string
wantHintNotSubstr []string
wantUnchanged bool
}{
{
name: "eaddrinuse-numeric-port",
err: &types.ListenerBindError{
Listener: "main HTTP",
YAMLKey: "listen_addr",
Addr: "0.0.0.0:443",
Err: &net.OpError{Op: "listen", Net: "tcp", Err: syscall.EADDRINUSE},
},
wantHintSubstr: []string{
"another process on this host is bound to the same address",
"sudo ss -tlnp 'sport = :443'",
},
},
{
name: "eaddrinuse-named-port",
err: &types.ListenerBindError{
Listener: "ACME HTTP-01 challenge",
YAMLKey: "tls_letsencrypt_listen",
Addr: ":http",
Err: &net.OpError{Op: "listen", Net: "tcp", Err: syscall.EADDRINUSE},
},
wantHintSubstr: []string{"sudo ss -tlnp 'sport = :80'"},
},
{
name: "eaccess-privileged-port",
err: &types.ListenerBindError{
Listener: "main HTTP",
YAMLKey: "listen_addr",
Addr: "0.0.0.0:80",
Err: &net.OpError{Op: "listen", Net: "tcp", Err: syscall.EACCES},
},
wantHintSubstr: []string{
"privileged port",
"CAP_NET_BIND_SERVICE",
"setcap cap_net_bind_service=+ep ./headscale`",
},
},
{
name: "non-bind-error-passes-through",
err: errClassifierUnrelated,
wantUnchanged: true,
},
{
name: "bind-error-without-known-syscall",
err: &types.ListenerBindError{
Listener: "main HTTP",
YAMLKey: "listen_addr",
Addr: "0.0.0.0:80",
Err: errClassifierUnrelated,
},
wantUnchanged: true,
},
{
name: "wrapped-eaddrinuse-still-classified",
err: fmt.Errorf("serve: %w", &types.ListenerBindError{
Listener: "gRPC",
YAMLKey: "grpc_listen_addr",
Addr: "0.0.0.0:50443",
Err: &net.OpError{Op: "listen", Net: "tcp", Err: syscall.EADDRINUSE},
}),
wantHintSubstr: []string{"sudo ss -tlnp 'sport = :50443'"},
},
{
name: "eaddrinuse-unparseable-addr-omits-port",
err: &types.ListenerBindError{
Listener: "main HTTP",
YAMLKey: "listen_addr",
Addr: "garbage",
Err: &net.OpError{Op: "listen", Net: "tcp", Err: syscall.EADDRINUSE},
},
wantHintSubstr: []string{"sudo ss -tlnp"},
wantHintNotSubstr: []string{":0", "sport ="},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := classifyServeError(tt.err)
if tt.wantUnchanged {
require.ErrorIs(t, got, tt.err)
assert.Equal(t, tt.err.Error(), got.Error())
return
}
require.ErrorIs(t, got, tt.err)
for _, want := range tt.wantHintSubstr {
assert.Contains(t, got.Error(), want)
}
for _, unwanted := range tt.wantHintNotSubstr {
assert.NotContains(t, got.Error(), unwanted)
}
})
}
}