mirror of
https://github.com/juanfont/headscale.git
synced 2026-02-07 20:04:00 +01:00
{policy, noise}: initial SSH check poc
This is a rudimental version, it will call out to headscale to ask what to do over internal noise connection and log the request. For now we always return an accept, meaning that the test will pass ass we essentially have implemented "accept" with an extra step. Next is to actually "check something" Updates #1850 Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
This commit is contained in:
parent
badbb7550d
commit
0291fa8644
1
.github/workflows/test-integration.yaml
vendored
1
.github/workflows/test-integration.yaml
vendored
@ -253,6 +253,7 @@ jobs:
|
||||
- TestSSHIsBlockedInACL
|
||||
- TestSSHUserOnlyIsolation
|
||||
- TestSSHAutogroupSelf
|
||||
- TestSSHOneUserToOneCheckMode
|
||||
- TestTagsAuthKeyWithTagRequestDifferentTag
|
||||
- TestTagsAuthKeyWithTagNoAdvertiseFlag
|
||||
- TestTagsAuthKeyWithTagCannotAddViaCLI
|
||||
|
||||
@ -116,6 +116,8 @@ func (h *Headscale) NoiseUpgradeHandler(
|
||||
r.Post("/register", ns.RegistrationHandler)
|
||||
r.Post("/map", ns.PollNetMapHandler)
|
||||
|
||||
r.Get("/ssh/action/from/{src_node_id}/to/{dst_node_id}/ssh_user/{ssh_user}/local_user/{local_user}", ns.SSHAction)
|
||||
|
||||
// Not implemented yet
|
||||
//
|
||||
// /whoami is a debug endpoint to validate that the client can communicate over the connection,
|
||||
@ -147,12 +149,10 @@ func (h *Headscale) NoiseUpgradeHandler(
|
||||
r.Post("/update-health", ns.NotImplementedHandler)
|
||||
|
||||
r.Route("/webclient", func(r chi.Router) {})
|
||||
|
||||
r.Post("/c2n", ns.NotImplementedHandler)
|
||||
})
|
||||
|
||||
r.Post("/c2n", ns.NotImplementedHandler)
|
||||
|
||||
r.Get("/ssh-action", ns.SSHAction)
|
||||
|
||||
ns.httpBaseConfig = &http.Server{
|
||||
Handler: r,
|
||||
ReadHeaderTimeout: types.HTTPTimeout,
|
||||
@ -246,7 +246,39 @@ func (ns *noiseServer) NotImplementedHandler(writer http.ResponseWriter, req *ht
|
||||
// SSHAction handles the /ssh-action endpoint, it returns a [tailcfg.SSHAction]
|
||||
// to the client with the verdict of an SSH access request.
|
||||
func (ns *noiseServer) SSHAction(writer http.ResponseWriter, req *http.Request) {
|
||||
log.Trace().Caller().Str("path", req.URL.String()).Msg("got SSH action request")
|
||||
srcNodeID := chi.URLParam(req, "src_node_id")
|
||||
dstNodeID := chi.URLParam(req, "dst_node_id")
|
||||
sshUser := chi.URLParam(req, "ssh_user")
|
||||
localUser := chi.URLParam(req, "local_user")
|
||||
log.Trace().Caller().
|
||||
Str("path", req.URL.String()).
|
||||
Str("src_node_id", srcNodeID).
|
||||
Str("dst_node_id", dstNodeID).
|
||||
Str("ssh_user", sshUser).
|
||||
Str("local_user", localUser).
|
||||
Msg("got SSH action request")
|
||||
|
||||
accept := tailcfg.SSHAction{
|
||||
Reject: false,
|
||||
Accept: true,
|
||||
AllowAgentForwarding: true,
|
||||
AllowLocalPortForwarding: true,
|
||||
AllowRemotePortForwarding: true,
|
||||
}
|
||||
|
||||
writer.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
writer.WriteHeader(http.StatusOK)
|
||||
|
||||
err := json.NewEncoder(writer).Encode(accept)
|
||||
if err != nil {
|
||||
log.Error().Caller().Err(err).Msg("failed to encode SSH action response")
|
||||
return
|
||||
}
|
||||
|
||||
// Ensure response is flushed to client
|
||||
if flusher, ok := writer.(http.Flusher); ok {
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
// PollNetMapHandler takes care of /machine/:id/map using the Noise protocol
|
||||
|
||||
@ -12,6 +12,7 @@ import (
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/juanfont/headscale/hscontrol/util"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/spf13/viper"
|
||||
"go4.org/netipx"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/views"
|
||||
@ -319,11 +320,27 @@ func (pol *Policy) compileACLWithAutogroupSelf(
|
||||
return rules, nil
|
||||
}
|
||||
|
||||
func sshAction(accept bool, duration time.Duration) tailcfg.SSHAction {
|
||||
var sshAccept = tailcfg.SSHAction{
|
||||
Reject: false,
|
||||
Accept: true,
|
||||
AllowAgentForwarding: true,
|
||||
AllowLocalPortForwarding: true,
|
||||
AllowRemotePortForwarding: true,
|
||||
}
|
||||
|
||||
func sshCheck(baseURL string, duration time.Duration) tailcfg.SSHAction {
|
||||
return tailcfg.SSHAction{
|
||||
Reject: !accept,
|
||||
Accept: accept,
|
||||
SessionDuration: duration,
|
||||
Reject: false,
|
||||
Accept: false,
|
||||
SessionDuration: duration,
|
||||
// Replaced in the client:
|
||||
// * $SRC_NODE_IP (URL escaped)
|
||||
// * $SRC_NODE_ID (Node.ID as int64 string)
|
||||
// * $DST_NODE_IP (URL escaped)
|
||||
// * $DST_NODE_ID (Node.ID as int64 string)
|
||||
// * $SSH_USER (URL escaped, ssh user requested)
|
||||
// * $LOCAL_USER (URL escaped, local user mapped)
|
||||
HoldAndDelegate: fmt.Sprintf("%s/machine/ssh/action/from/$SRC_NODE_ID/to/$DST_NODE_ID/ssh_user/$SSH_USER/local_user/$LOCAL_USER", baseURL),
|
||||
AllowAgentForwarding: true,
|
||||
AllowLocalPortForwarding: true,
|
||||
AllowRemotePortForwarding: true,
|
||||
@ -375,11 +392,14 @@ func (pol *Policy) compileSSHPolicy(
|
||||
|
||||
var action tailcfg.SSHAction
|
||||
|
||||
// HACK HACK HACK
|
||||
serverURL := viper.GetString("server_url")
|
||||
|
||||
switch rule.Action {
|
||||
case SSHActionAccept:
|
||||
action = sshAction(true, 0)
|
||||
action = sshAccept
|
||||
case SSHActionCheck:
|
||||
action = sshAction(true, time.Duration(rule.CheckPeriod))
|
||||
action = sshCheck(serverURL, time.Duration(rule.CheckPeriod))
|
||||
default:
|
||||
return nil, fmt.Errorf("parsing SSH policy, unknown action %q, index: %d: %w", rule.Action, index, err)
|
||||
}
|
||||
|
||||
@ -579,3 +579,75 @@ func TestSSHAutogroupSelf(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSHOneUserToOneCheckMode(t *testing.T) {
|
||||
IntegrationSkip(t)
|
||||
|
||||
scenario := sshScenario(t,
|
||||
&policyv2.Policy{
|
||||
Groups: policyv2.Groups{
|
||||
policyv2.Group("group:integration-test"): []policyv2.Username{policyv2.Username("user1@")},
|
||||
},
|
||||
ACLs: []policyv2.ACL{
|
||||
{
|
||||
Action: "accept",
|
||||
Protocol: "tcp",
|
||||
Sources: []policyv2.Alias{wildcard()},
|
||||
Destinations: []policyv2.AliasWithPorts{
|
||||
aliasWithPorts(wildcard(), tailcfg.PortRangeAny),
|
||||
},
|
||||
},
|
||||
},
|
||||
SSHs: []policyv2.SSH{
|
||||
{
|
||||
Action: "check",
|
||||
Sources: policyv2.SSHSrcAliases{groupp("group:integration-test")},
|
||||
// Use autogroup:member and autogroup:tagged instead of wildcard
|
||||
// since wildcard (*) is no longer supported for SSH destinations
|
||||
Destinations: policyv2.SSHDstAliases{
|
||||
new(policyv2.AutoGroupMember),
|
||||
new(policyv2.AutoGroupTagged),
|
||||
},
|
||||
Users: []policyv2.SSHUser{policyv2.SSHUser("ssh-it-user")},
|
||||
},
|
||||
},
|
||||
},
|
||||
1,
|
||||
)
|
||||
// defer scenario.ShutdownAssertNoPanics(t)
|
||||
|
||||
allClients, err := scenario.ListTailscaleClients()
|
||||
requireNoErrListClients(t, err)
|
||||
|
||||
user1Clients, err := scenario.ListTailscaleClients("user1")
|
||||
requireNoErrListClients(t, err)
|
||||
|
||||
user2Clients, err := scenario.ListTailscaleClients("user2")
|
||||
requireNoErrListClients(t, err)
|
||||
|
||||
err = scenario.WaitForTailscaleSync()
|
||||
requireNoErrSync(t, err)
|
||||
|
||||
_, err = scenario.ListTailscaleClientsFQDNs()
|
||||
requireNoErrListFQDN(t, err)
|
||||
|
||||
for _, client := range user1Clients {
|
||||
for _, peer := range allClients {
|
||||
if client.Hostname() == peer.Hostname() {
|
||||
continue
|
||||
}
|
||||
|
||||
assertSSHHostname(t, client, peer)
|
||||
}
|
||||
}
|
||||
|
||||
for _, client := range user2Clients {
|
||||
for _, peer := range allClients {
|
||||
if client.Hostname() == peer.Hostname() {
|
||||
continue
|
||||
}
|
||||
|
||||
assertSSHPermissionDenied(t, client, peer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user