feat: Decide upstream based on user's unix group membership (#536)

* feat: Decide upstream based on user's unix group membership

If a username is not defined, groupname is parsed. Check if the
user is part of that group and route them to the associated host
defined in the config file for the yaml plugin

* style: fix formatting with gofmt

* feat: Look up user groups only when groupname defined in yaml config

* fix: inefficient assignment because of unused var

* feat: fallback to next rule on group lookup failure

Instead of failing on group lookup errors, the matcher now skips the
groupname rule and proceeds to the next, eventually failing through to
the catchall rule.

* feat: test cases for group based routing in yaml plugin

* Revert "feat: fallback to next rule on group lookup failure"

This reverts commit 622ee9f1eb3157d04f57c068179bb74de8db3a1f.

Handles the error returned by getUserGroups instead of ignoring it,
to prevent potential runtime issues when user lookup fail

* feat: Check if a user is known to the system before group lookup

This will let the rule matching logic skip to the next pipe in the yaml
config when a user is not found on the system.
Note the variable name change from user to username to avoid ambiguity
dur to name collision with os/user package.

* feat: Avoid redundant user lookup

* feat: Improve error handling for user and group lookup failures

* feat: Use appropriate test user name for group routing
This commit is contained in:
eesaanatluri 2025-04-08 02:53:16 -04:00 committed by GitHub
parent 4ddeee8048
commit 4fce1dcb45
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 151 additions and 22 deletions

View file

@ -4,10 +4,13 @@ set -x
# use entrypoint.sh to generate the ssh_host_ed25519_key
PLUGIN="dummy_badname/" bash /sshpiperd/entrypoint.sh 2>/dev/null
groupadd -f testgroup && \
useradd -m -G testgroup testgroupuser
if [ "${SSHPIPERD_DEBUG}" == "1" ]; then
echo "enter debug on hold mode"
echo "run [docker exec -ti e2e_testrunner_1 bash] to run to attach"
sleep infinity;
else
go test -v;
fi
fi

View file

@ -48,19 +48,33 @@ pipes:
private_key: {{ .PrivateKey }}
known_hosts_data: {{ .KnownHostsKey }}
- from:
- username: ".*"
username_regex_match: true
authorized_keys:
- {{ .AuthorizedKeys_Simple }}
- {{ .AuthorizedKeys_Catchall }}
- username: "cert"
trusted_user_ca_keys: {{ .TrustedUserCAKeys }}
to:
host: host-publickey:2222
username: "user"
ignore_hostkey: true
private_key: {{ .PrivateKey }}
- from:
- username: "cert"
trusted_user_ca_keys: {{ .TrustedUserCAKeys }}
- groupname: "testgroup"
authorized_keys: {{ .AuthorizedKeys_Simple }}
to:
host: host-publickey:2222
username: "user"
private_key: {{ .PrivateKey }}
known_hosts_data: {{ .KnownHostsKey }}
- from:
- groupname: "testgroup"
to:
host: host-password:2222
username: "user"
ignore_hostkey: true
- from:
- username: ".*"
username_regex_match: true
authorized_keys:
- {{ .AuthorizedKeys_Simple }}
- {{ .AuthorizedKeys_Catchall }}
to:
host: host-publickey:2222
username: "user"
@ -479,4 +493,68 @@ func TestYaml(t *testing.T) {
checkSharedFileContent(t, targetfie, randtext)
})
t.Run("group_routing_key", func(t *testing.T) {
randtext := uuid.New().String()
targetfie := uuid.New().String()
c, _, _, err := runCmd(
"ssh",
"-v",
"-o",
"StrictHostKeyChecking=no",
"-o",
"UserKnownHostsFile=/dev/null",
"-p",
piperport,
"-l",
"testuser",
"-i",
path.Join(yamldir, "id_rsa_simple"),
"127.0.0.1",
fmt.Sprintf(`sh -c "echo -n %v > /shared/%v"`, randtext, targetfie),
)
if err != nil {
t.Errorf("failed to ssh to piper, %v", err)
}
defer killCmd(c)
time.Sleep(time.Second) // wait for file flush
checkSharedFileContent(t, targetfie, randtext)
})
t.Run("group_routing_password", func(t *testing.T) {
randtext := uuid.New().String()
targetfie := uuid.New().String()
c, stdin, stdout, err := runCmd(
"ssh",
"-v",
"-o",
"StrictHostKeyChecking=no",
"-o",
"UserKnownHostsFile=/dev/null",
"-p",
piperport,
"-l",
"testgroupuser",
"127.0.0.1",
fmt.Sprintf(`sh -c "echo -n %v > /shared/%v"`, randtext, targetfie),
)
if err != nil {
t.Errorf("failed to ssh to piper, %v", err)
}
defer killCmd(c)
enterPassword(stdin, stdout, "pass")
time.Sleep(time.Second) // wait for file flush
checkSharedFileContent(t, targetfie, randtext)
})
}

View file

@ -50,6 +50,9 @@
"username_regex_match": {
"type": "boolean"
},
"groupname": {
"type": "string"
},
"authorized_keys": {
"oneOf": [
{
@ -161,4 +164,4 @@
]
}
}
}
}

View file

@ -3,7 +3,11 @@
package main
import (
"errors"
log "github.com/sirupsen/logrus"
"os/user"
"regexp"
"slices"
"github.com/tg123/sshpiper/libplugin"
)
@ -84,26 +88,46 @@ func (s *skelpipeToWrapper) KnownHosts(conn libplugin.ConnMetadata) ([]byte, err
}
func (s *skelpipeFromWrapper) MatchConn(conn libplugin.ConnMetadata) (libplugin.SkelPipeTo, error) {
user := conn.User()
username := conn.User()
matched := s.from.Username == user
targetuser := s.to.Username
if targetuser == "" {
targetuser = user
}
var matched bool
if s.from.Username != "" {
matched = s.from.Username == username
if s.from.UsernameRegexMatch {
re, err := regexp.Compile(s.from.Username)
if err != nil {
return nil, err
}
if s.from.UsernameRegexMatch {
re, err := regexp.Compile(s.from.Username)
matched = re.MatchString(username)
if matched {
targetuser = re.ReplaceAllString(username, s.to.Username)
}
}
} else if s.from.Groupname != "" {
// check user is known to the system before grouplookup
usr, err := user.Lookup(username)
if err != nil {
var unknownUser user.UnknownUserError
if errors.As(err, &unknownUser) {
return nil, nil
}
log.Errorf("[ERROR] Matchconn(): Failure looking up user %q: %T - %v", username, err, err)
return nil, err
}
userGroups, err := getUserGroups(usr)
if err != nil {
return nil, err
}
fromPipeGroup := s.from.Groupname
matched = slices.Contains(userGroups, fromPipeGroup)
}
matched = re.MatchString(user)
if matched {
targetuser = re.ReplaceAllString(user, s.to.Username)
}
if targetuser == "" {
targetuser = username
}
if matched {
@ -183,3 +207,23 @@ func (p *plugin) listPipe(_ libplugin.ConnMetadata) ([]libplugin.SkelPipe, error
return pipes, nil
}
func getUserGroups(usr *user.User) ([]string, error) {
groupIds, err := usr.GroupIds()
if err != nil {
log.Errorf("[ERROR] getUserGroups(): Failure retrieving group IDs for %q: %T - %v", usr.Username, err, err)
return nil, err
}
var groups []string
for _, groupId := range groupIds {
grp, err := user.LookupGroupId(groupId)
if err != nil {
log.Errorf("[ERROR] getUserGroups(): Failure retrieving group name for %q: %T - %v", usr.Username, err, err)
return nil, err
}
groups = append(groups, grp.Name)
}
return groups, nil
}

View file

@ -14,7 +14,8 @@ import (
)
type yamlPipeFrom struct {
Username string `yaml:"username"`
Username string `yaml:"username,omitempty"`
Groupname string `yaml:"groupname,omitempty"`
UsernameRegexMatch bool `yaml:"username_regex_match,omitempty"`
AuthorizedKeys listOrString `yaml:"authorized_keys,omitempty"`
AuthorizedKeysData listOrString `yaml:"authorized_keys_data,omitempty"`