Skip to content

Commit

Permalink
Merge pull request #606 from yerenkow/support-kerberos-ticket
Browse files Browse the repository at this point in the history
feat: add support for sec=krb5 mounting
  • Loading branch information
k8s-ci-robot committed May 6, 2023
2 parents dbba312 + d9aa489 commit 81e2973
Show file tree
Hide file tree
Showing 5 changed files with 321 additions and 1 deletion.
31 changes: 31 additions & 0 deletions deploy/example/storageclass-smb-krb5.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: smb-krb5
provisioner: smb.csi.k8s.io
parameters:
# On Windows, "*.default.svc.cluster.local" could not be recognized by csi-proxy
source: "//smb-server.default.svc.cluster.local/share"
# if csi.storage.k8s.io/provisioner-secret is provided, will create a sub directory
# with PV name under source
csi.storage.k8s.io/provisioner-secret-name: "smbcreds-krb5"
csi.storage.k8s.io/provisioner-secret-namespace: "default"
csi.storage.k8s.io/node-stage-secret-name: "smbcreds-krb5"
csi.storage.k8s.io/node-stage-secret-namespace: "default"
volumeBindingMode: Immediate
mountOptions:
- sec=krb5
- cruid=1000
- seal
- vers=3.0
- nosuid
- noexec
- dir_mode=0777
- file_mode=0777
- uid=1001
- gid=1001
- noperm
- mfsymlinks
- cache=strict
- noserverino # required to prevent data corruption
39 changes: 39 additions & 0 deletions docs/driver-parameters.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,45 @@ nodeStageSecretRef.namespace | namespace where the secret is | k8s namespace |
kubectl create secret generic smbcreds --from-literal username=USERNAME --from-literal password="PASSWORD"
```

### Kerberos ticket support for Linux




#### These are the conditions that must be met:
- Kerberos support should be set up and cifs-utils must be installed on every node.
- The directory /var/lib/kubelet/kerberos/ needs to exist, and it will hold kerberos credential cache files for various users.
- This directory is shared between the host and the smb container.
- The admin is responsible for cleaning up the directory on each node as they deem appropriate. It's important to note that unmounting doesn't delete the cache file.
- Each node should know to look up in that directory, here's example script for that, expected to be run on node provision:
```console
mkdir -p /etc/krb5.conf.d/
echo "[libdefaults]
default_ccache_name = FILE:/var/lib/kubelet/kerberos/krb5cc_%{uid}" > /etc/krb5.conf.d/ccache.conf
```
- Mount flags should include **sec=krb5,cruid=1000**
- sec=krb5 enables using credential cache
- cruid=1000 provides information for what user credential cache will be looked up. This should match the secret entry.

#### Pass kerberos ticket in kubernetes secret
To pass a ticket through secret, it needs to be acquired. Here's example how it can be done:

```console
export KRB5CCNAME=/tmp/ccache # Use temporary file for the cache
kinit USERNAME # Log in into domain
kvno cifs/lowercase_server_name # Acquire ticket for the needed share, it'll be written to the cache file
CCACHE=$(base64 -w 0 $KRB5CCNAME) # Get Base64-encoded cache
```

And passing the actual ticket to the secret, instead of the password.
Note that key for the ticket has included credential id, that must match exactly `cruid=` mount flag.
In theory, nothing prevents from having more than single ticket cache in the same secret.
```console
kubectl create secret generic smbcreds-krb5 --from-literal krb5cc_1000=$CCACHE
```

> See example of the [StorageClass](../deploy/example/storageclass-smb-krb5.yaml)
### Tips
#### `subDir` parameter supports following pv/pvc metadata conversion
> if `subDir` value contains following string, it would be converted into corresponding pv/pvc name or namespace
Expand Down
93 changes: 92 additions & 1 deletion pkg/smb/nodeserver.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,12 @@ limitations under the License.
package smb

import (
"encoding/base64"
"fmt"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"time"

Expand Down Expand Up @@ -182,10 +184,14 @@ func (d *Driver) NodeStageVolume(ctx context.Context, req *csi.NodeStageVolumeRe
sensitiveMountOptions = []string{password}
}
} else {
var useKerberosCache, err = ensureKerberosCache(mountFlags, secrets)
if err != nil {
return nil, status.Error(codes.Internal, fmt.Sprintf("Error writing kerberos cache: %v", err))
}
if err := os.MkdirAll(targetPath, 0750); err != nil {
return nil, status.Error(codes.Internal, fmt.Sprintf("MkdirAll %s failed with error: %v", targetPath, err))
}
if requireUsernamePwdOption {
if requireUsernamePwdOption && !useKerberosCache {
sensitiveMountOptions = []string{fmt.Sprintf("%s=%s,%s=%s", usernameField, username, passwordField, password)}
}
mountOptions = mountFlags
Expand Down Expand Up @@ -422,3 +428,88 @@ func checkGidPresentInMountFlags(mountFlags []string) bool {
}
return false
}

func hasKerberosMountOption(mountFlags []string) bool {
for _, mountFlag := range mountFlags {
if strings.HasPrefix(mountFlag, "sec=krb5") {
return true
}
}
return false
}

func getCredUID(mountFlags []string) (int, error) {
var cruidPrefix = "cruid="
for _, mountFlag := range mountFlags {
if strings.HasPrefix(mountFlag, cruidPrefix) {
return strconv.Atoi(strings.TrimPrefix(mountFlag, cruidPrefix))
}
}
return -1, fmt.Errorf("Can't find credUid in mount flags")
}

func getKrb5CcacheName(credUID int) string {
return fmt.Sprintf("%s%d", krb5Prefix, credUID)
}

func getKrb5CacheFileName(credUID int) string {
return fmt.Sprintf("%s%s%d", krb5CacheDirectory, krb5Prefix, credUID)
}
func kerberosCacheDirectoryExists() (bool, error) {
_, err := os.Stat(krb5CacheDirectory)
if os.IsNotExist(err) {
return false, status.Error(codes.Internal, fmt.Sprintf("Directory for kerberos caches must exist, it will not be created: %s: %v", krb5CacheDirectory, err))
} else if err != nil {
return false, err
}
return true, nil
}

func getKerberosCache(credUID int, secrets map[string]string) (string, []byte, error) {
var krb5CcacheName = getKrb5CcacheName(credUID)
var krb5CcacheContent string
for k, v := range secrets {
switch strings.ToLower(k) {
case krb5CcacheName:
krb5CcacheContent = v
}
}
if krb5CcacheContent == "" {
return "", nil, status.Error(codes.InvalidArgument, fmt.Sprintf("Empty kerberos cache in key %s", krb5CcacheName))
}
content, err := base64.StdEncoding.DecodeString(krb5CcacheContent)
if err != nil {
return "", nil, status.Error(codes.InvalidArgument, fmt.Sprintf("Malformed kerberos cache in key %s, expected to be in base64 form: %v", krb5CcacheName, err))
}
var krb5CacheFileName = getKrb5CacheFileName(credUID)

return krb5CacheFileName, content, nil
}

func ensureKerberosCache(mountFlags []string, secrets map[string]string) (bool, error) {
var securityIsKerberos = hasKerberosMountOption(mountFlags)
if securityIsKerberos {
_, err := kerberosCacheDirectoryExists()
if err != nil {
return false, err
}
credUID, err := getCredUID(mountFlags)
if err != nil {
return false, err
}
krb5CacheFileName, content, err := getKerberosCache(credUID, secrets)
if err != nil {
return false, err
}
err = os.WriteFile(krb5CacheFileName, content, os.FileMode(0700))
if err != nil {
return false, status.Error(codes.Internal, fmt.Sprintf("Couldn't write kerberos cache to file %s: %v", krb5CacheFileName, err))
}
err = os.Chown(krb5CacheFileName, credUID, credUID)
if err != nil {
return false, status.Error(codes.Internal, fmt.Sprintf("Couldn't chown kerberos cache %s to user %d: %v", krb5CacheFileName, credUID, err))
}
return true, nil
}
return false, nil
}
157 changes: 157 additions & 0 deletions pkg/smb/nodeserver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,14 @@ package smb

import (
"context"
"encoding/base64"
"errors"
"fmt"
"os"
"path/filepath"
"reflect"
"runtime"
"strconv"
"strings"
"syscall"
"testing"
Expand Down Expand Up @@ -690,6 +692,161 @@ func TestCheckGidPresentInMountFlags(t *testing.T) {
}
}

func TestHasKerberosMountOption(t *testing.T) {
tests := []struct {
desc string
MountFlags []string
result bool
}{
{
desc: "[Success] Sec kerberos present in mount flags",
MountFlags: []string{"sec=krb5"},
result: true,
},
{
desc: "[Success] Sec kerberos present in mount flags",
MountFlags: []string{"sec=krb5i"},
result: true,
},
{
desc: "[Success] Sec kerberos not present in mount flags",
MountFlags: []string{},
result: false,
},
{
desc: "[Success] Sec kerberos not present in mount flags",
MountFlags: []string{"sec=ntlm"},
result: false,
},
}

for _, test := range tests {
securityIsKerberos := hasKerberosMountOption(test.MountFlags)
if securityIsKerberos != test.result {
t.Errorf("[%s]: Expected result : %t, Actual result: %t", test.desc, test.result, securityIsKerberos)
}
}
}

func TestGetCredUID(t *testing.T) {
_, convertErr := strconv.Atoi("foo")
tests := []struct {
desc string
MountFlags []string
result int
expectedErr error
}{
{
desc: "[Success] Got correct credUID",
MountFlags: []string{"cruid=1000"},
result: 1000,
expectedErr: nil,
},
{
desc: "[Success] Got correct credUID",
MountFlags: []string{"cruid=0"},
result: 0,
expectedErr: nil,
},
{
desc: "[Error] Got error when no CredUID",
MountFlags: []string{},
result: -1,
expectedErr: fmt.Errorf("Can't find credUid in mount flags"),
},
{
desc: "[Error] Got error when CredUID is not an int",
MountFlags: []string{"cruid=foo"},
result: 0,
expectedErr: convertErr,
},
}

for _, test := range tests {
credUID, err := getCredUID(test.MountFlags)
if credUID != test.result {
t.Errorf("[%s]: Expected result : %d, Actual result: %d", test.desc, test.result, credUID)
}
if !reflect.DeepEqual(err, test.expectedErr) {
t.Errorf("[%s]: Expected error : %v, Actual error: %v", test.desc, test.expectedErr, err)
}
}
}

func TestGetKerberosCache(t *testing.T) {
ticket := []byte{'G', 'O', 'L', 'A', 'N', 'G'}
base64Ticket := base64.StdEncoding.EncodeToString(ticket)
credUID := 1000
goodFileName := fmt.Sprintf("%s%s%d", krb5CacheDirectory, krb5Prefix, credUID)
krb5CcacheName := "krb5cc_1000"

_, base64DecError := base64.StdEncoding.DecodeString("123")
tests := []struct {
desc string
credUID int
secrets map[string]string
expectedFileName string
expectedContent []byte
expectedErr error
}{
{
desc: "[Success] Got correct filename and content",
credUID: 1000,
secrets: map[string]string{
krb5CcacheName: base64Ticket,
},
expectedFileName: goodFileName,
expectedContent: ticket,
expectedErr: nil,
},
{
desc: "[Error] Throw error if credUID mismatch",
credUID: 1001,
secrets: map[string]string{
krb5CcacheName: base64Ticket,
},
expectedFileName: "",
expectedContent: nil,
expectedErr: status.Error(codes.InvalidArgument, fmt.Sprintf("Empty kerberos cache in key %s", "krb5cc_1001")),
},
{
desc: "[Error] Throw error if ticket is empty in secret",
credUID: 1000,
secrets: map[string]string{
krb5CcacheName: "",
},
expectedFileName: "",
expectedContent: nil,
expectedErr: status.Error(codes.InvalidArgument, fmt.Sprintf("Empty kerberos cache in key %s", krb5CcacheName)),
},
{
desc: "[Error] Throw error if ticket is invalid base64",
credUID: 1000,
secrets: map[string]string{
krb5CcacheName: "123",
},
expectedFileName: "",
expectedContent: nil,
expectedErr: status.Error(codes.InvalidArgument, fmt.Sprintf("Malformed kerberos cache in key %s, expected to be in base64 form: %v", krb5CcacheName, base64DecError)),
},
}

for _, test := range tests {
fileName, content, err := getKerberosCache(test.credUID, test.secrets)
if !reflect.DeepEqual(err, test.expectedErr) {
t.Errorf("[%s]: Expected error : %v, Actual error: %v", test.desc, test.expectedErr, err)
} else {
if fileName != test.expectedFileName {
t.Errorf("[%s]: Expected filename : %s, Actual result: %s", test.desc, test.expectedFileName, fileName)
}
if !reflect.DeepEqual(content, test.expectedContent) {
t.Errorf("[%s]: Expected content : %s, Actual content: %s", test.desc, test.expectedContent, content)
}
}
}

}

func TestNodePublishVolumeIdempotentMount(t *testing.T) {
if runtime.GOOS == "windows" || os.Getuid() != 0 {
return
Expand Down
2 changes: 2 additions & 0 deletions pkg/smb/smb.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ const (
sourceField = "source"
subDirField = "subdir"
domainField = "domain"
krb5Prefix = "krb5cc_"
krb5CacheDirectory = "/var/lib/kubelet/kerberos/"
mountOptionsField = "mountoptions"
defaultDomainName = "AZURE"
pvcNameKey = "csi.storage.k8s.io/pvc/name"
Expand Down

0 comments on commit 81e2973

Please sign in to comment.