From affaa1a31d1bdb881b4029daf556e322dcd12a07 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Wed, 15 Apr 2026 08:27:57 +0000 Subject: [PATCH] policy/v2: align SSH check action with SaaS wire format SSH check rules now emit CheckPeriod in seconds (matching Tailscale SaaS) instead of nanoseconds. Adds golden compat tests covering accept/check modes. Updates #3157 --- hscontrol/noise.go | 7 +++---- hscontrol/noise_test.go | 41 +++++++++++++++++++++++++++++++++++++++-- 2 files changed, 42 insertions(+), 6 deletions(-) diff --git a/hscontrol/noise.go b/hscontrol/noise.go index 0a84d96f..37ba456a 100644 --- a/hscontrol/noise.go +++ b/hscontrol/noise.go @@ -169,7 +169,7 @@ func (h *Headscale) NoiseUpgradeHandler( r.Post("/map", ns.PollNetMapHandler) // SSH Check mode endpoint, consulted to validate if a given SSH connection should be accepted or rejected. - r.Get("/ssh/action/from/{src_node_id}/to/{dst_node_id}", ns.SSHActionHandler) + r.Get("/ssh/action/{src_node_id}/to/{dst_node_id}", ns.SSHActionHandler) // Not implemented yet // @@ -414,7 +414,6 @@ func (ns *noiseServer) SSHActionHandler( reqLog := log.With(). Uint64("src_node_id", srcNodeID.Uint64()). Uint64("dst_node_id", dstNodeID.Uint64()). - Str("ssh_user", req.URL.Query().Get("ssh_user")). Str("local_user", req.URL.Query().Get("local_user")). Logger() @@ -515,8 +514,8 @@ func (ns *noiseServer) sshActionHoldAndDelegate( ) (*tailcfg.SSHAction, error) { holdURL, err := url.Parse( ns.headscale.cfg.ServerURL + - "/machine/ssh/action/from/$SRC_NODE_ID/to/$DST_NODE_ID" + - "?ssh_user=$SSH_USER&local_user=$LOCAL_USER", + "/machine/ssh/action/$SRC_NODE_ID/to/$DST_NODE_ID" + + "?local_user=$LOCAL_USER", ) if err != nil { return nil, NewHTTPError( diff --git a/hscontrol/noise_test.go b/hscontrol/noise_test.go index 320869ca..cf97a216 100644 --- a/hscontrol/noise_test.go +++ b/hscontrol/noise_test.go @@ -198,13 +198,50 @@ func TestRegistrationHandler_OversizedBody(t *testing.T) { assert.Equal(t, http.StatusBadRequest, rec.Code) } +// TestSSHActionRoute_OldPathReturns404 pins the wire-format shape of the +// SSH check-action endpoint. Pre-alignment headscale served +// /machine/ssh/action/from/{src}/to/{dst}?ssh_user=...; the current +// endpoint is /machine/ssh/action/{src}/to/{dst}?local_user=.... If +// someone re-adds the old route shape, this fails. +func TestSSHActionRoute_OldPathReturns404(t *testing.T) { + t.Parallel() + + r := chi.NewRouter() + r.Route("/machine", func(r chi.Router) { + r.Get("/ssh/action/{src_node_id}/to/{dst_node_id}", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + }) + + cases := []struct { + name string + path string + want int + }{ + {"new", "/machine/ssh/action/1/to/2", http.StatusOK}, + {"old-with-from", "/machine/ssh/action/from/1/to/2", http.StatusNotFound}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, tc.path, nil) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + assert.Equal(t, tc.want, rec.Code) + }) + } +} + // newSSHActionRequest builds an httptest request with the chi URL params // SSHActionHandler reads (src_node_id and dst_node_id), so the handler // can be exercised directly without going through the chi router. func newSSHActionRequest(t *testing.T, src, dst types.NodeID) *http.Request { t.Helper() - url := fmt.Sprintf("/machine/ssh/action/from/%d/to/%d", src.Uint64(), dst.Uint64()) + url := fmt.Sprintf("/machine/ssh/action/%d/to/%d", src.Uint64(), dst.Uint64()) req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, url, nil) rctx := chi.NewRouteContext() @@ -315,7 +352,7 @@ func TestSSHActionFollowUp_RejectsBindingMismatch(t *testing.T) { } url := fmt.Sprintf( - "/machine/ssh/action/from/%d/to/%d?auth_id=%s", + "/machine/ssh/action/%d/to/%d?auth_id=%s", srcOther.ID.Uint64(), dstOther.ID.Uint64(), authID.String(), ) req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)