Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: infer krb5 ccache from within the PAM module #914

Merged
merged 9 commits into from
Feb 21, 2024
1 change: 1 addition & 0 deletions .github/workflows/qa.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ jobs:
with:
golangci-lint-configfile: ".golangci-ci.yaml"
tools-directory: "tools"
generate-diff-paths-to-ignore: po/* docs/**/*.md README.md
- name: C code formatting
uses: jidicula/[email protected]
with:
Expand Down
71 changes: 71 additions & 0 deletions cmd/adsysd/client/policy.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"io"
"os"
"os/user"
"strconv"
"strings"

"github.com/fatih/color"
Expand All @@ -18,6 +19,8 @@ import (
"github.com/ubuntu/adsys/internal/cmdhandler"
"github.com/ubuntu/adsys/internal/consts"
log "github.com/ubuntu/adsys/internal/grpc/logstreamer"
"github.com/ubuntu/decorate"
"golang.org/x/sys/unix"
)

func (a *App) installPolicy() {
Expand Down Expand Up @@ -95,6 +98,22 @@ func (a *App) installPolicy() {
RunE: func(cmd *cobra.Command, args []string) error { return a.dumpCertEnrollScript() },
}
debugCmd.AddCommand(certEnrollCmd)
ticketPathCmd := &cobra.Command{
Use: "ticket-path",
Short: gotext.Get("Print the path of the current (or given) user's Kerberos ticket"),
Long: gotext.Get(`Infer and print the path of the current user's Kerberos ticket, leveraging the krb5 API.
The command is a no-op if the ticket is not present on disk or the detect_cached_ticket setting is not true.`),
Args: cmdhandler.ZeroOrNArgs(1),
ValidArgsFunction: cmdhandler.NoValidArgs,
RunE: func(cmd *cobra.Command, args []string) error {
var username string
if len(args) > 0 {
username = args[0]
}
return a.printTicketPath(username)
},
}
debugCmd.AddCommand(ticketPathCmd)

var updateMachine, updateAll *bool
updateCmd := &cobra.Command{
Expand Down Expand Up @@ -300,6 +319,58 @@ func (a *App) dumpCertEnrollScript() error {
return os.WriteFile("cert-autoenroll", []byte(script), 0600)
}

// printTicketPath prints the path to the Kerberos ccache of the given (or current) user to stdout.
// The function is a no-op if the detect_cached_ticket setting is not enabled.
// No error is raised if the inferred ticket is not present on disk.
func (a *App) printTicketPath(username string) (err error) {
didrocks marked this conversation as resolved.
Show resolved Hide resolved
defer decorate.OnError(&err, gotext.Get("error getting ticket path"))

// Do not print anything if the required setting is not enabled
if !a.config.DetectCachedTicket {
log.Debugf(a.ctx, "The detect_cached_ticket setting needs to be enabled to use this command")
return nil
}

// Default to current user
if username == "" {
u, err := user.Current()
if err != nil {
return fmt.Errorf("failed to retrieve current user: %w", err)
}
username = u.Username
}

user, err := user.Lookup(username)
if err != nil {
return err
}
uid, err := strconv.Atoi(user.Uid)
if err != nil {
return err
}

// This effectively deescalates the current user's privileges with no
// possibility of turning back. We're doing this on purpose right before the
// code path that requires this, with the program exiting immediately after.
if err := unix.Setuid(uid); err != nil {
return fmt.Errorf(gotext.Get("failed to set privileges to UID %d: %v", uid, err))
}

krb5ccPath, err := ad.TicketPath()
if errors.Is(err, ad.ErrTicketNotPresent) {
log.Debugf(a.ctx, "No ticket found for user %s: %s", username, err)
return nil
}

if err != nil {
return err
}

fmt.Println(krb5ccPath)

return nil
}

func colorizePolicies(policies string) (string, error) {
first := true
var out stringsBuilderWithError
Expand Down
71 changes: 71 additions & 0 deletions cmd/adsysd/integration_tests/adsysctl_policy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1248,6 +1248,77 @@ func TestPolicyDebugScriptDump(t *testing.T) {
}
}

func TestPolicyDebugTicketPath(t *testing.T) {
tests := map[string]struct {
username string

configDisabled bool
pathNotPresent bool
pathIsDir bool

wantOut string
wantErr bool
}{
"Return path for current explicit user": {},
"Return path for current implicit user": {username: "-"},

// No-op cases (return no error and no output)
"No-op when path not present on disk": {pathNotPresent: true},
"No-op when detect_cached_ticket is not set": {configDisabled: true},

// Error cases
"Error when passed an invalid user": {username: "invaliduser", wantErr: true},
"Error if ticket path is a directory": {pathIsDir: true, wantErr: true},
}
for name, tc := range tests {
tc := tc
t.Run(name, func(t *testing.T) {
// Empty username means current user
if tc.username == "" {
u, err := user.Current()
require.NoError(t, err, "Setup: could not get current user")
tc.username = u.Username
}
// "-" username means empty argument, current user is inferred
if tc.username == "-" {
tc.username = ""
}

// Ensure we start and finish the test with a clean slate on disk
uid := os.Getuid()
krb5ccname := filepath.Join(os.TempDir(), fmt.Sprintf("krb5cc_%d", uid))
err := os.RemoveAll(krb5ccname)
require.NoError(t, err, "Setup: could not remove ticket path")
t.Cleanup(func() {
err := os.RemoveAll(krb5ccname)
require.NoError(t, err, "Teardown: could not remove ticket path")
})

if tc.pathIsDir {
err := os.MkdirAll(krb5ccname, 0700)
require.NoError(t, err, "Setup: could not create ticket directory")
} else if !tc.pathNotPresent {
err := os.WriteFile(krb5ccname, []byte("Some ticket content"), 0600)
require.NoError(t, err, "Setup: could not write ticket content")
tc.wantOut = krb5ccname + "\n"
}

if tc.configDisabled {
tc.wantOut = ""
}

conf := createConf(t, confDetectCachedTicket(!tc.configDisabled))
out, err := runClient(t, conf, "policy", "debug", "ticket-path", tc.username)
if tc.wantErr {
require.Error(t, err, "command should exit with an error")
return
}
require.NoError(t, err, "command should exit with no error")
require.Equal(t, tc.wantOut, out, "command output should match")
})
}
}

func modifyAndAddUsers(t *testing.T, new string, users ...string) (passwd string) {
t.Helper()
dest := filepath.Join(t.TempDir(), "passwd")
Expand Down
108 changes: 108 additions & 0 deletions e2e/cmd/run_tests/05_test_pam_krb5cc/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
// Package main provides a script that runs PAM krb5cc-related tests on the
// provisioned Ubuntu client.
package main

import (
"context"
"fmt"
"os"
"path/filepath"

log "github.com/sirupsen/logrus"
"github.com/ubuntu/adsys/e2e/internal/command"
"github.com/ubuntu/adsys/e2e/internal/inventory"
"github.com/ubuntu/adsys/e2e/internal/remote"
)

var sshKey string

func main() {
os.Exit(run())
}

func run() int {
cmd := command.New(action,
command.WithValidateFunc(validate),
command.WithRequiredState(inventory.ADProvisioned),
)
cmd.Usage = fmt.Sprintf(`go run ./%s [options]

Perform PAM krb5cc-related tests on the provisioned Ubuntu client.

The runner must be connected to the ADSys E2E tests VPN.`, filepath.Base(os.Args[0]))

return cmd.Execute(context.Background())
}

func validate(_ context.Context, cmd *command.Command) (err error) {
sshKey, err = command.ValidateAndExpandPath(cmd.Inventory.SSHKeyPath, command.DefaultSSHKeyPath)
if err != nil {
return err
}

return nil
}

func action(ctx context.Context, cmd *command.Command) error {
rootClient, err := remote.NewClient(cmd.Inventory.IP, "root", sshKey)
if err != nil {
return fmt.Errorf("failed to connect to VM: %w", err)
}

defer func() {
if _, err := rootClient.Run(ctx, "rm -f /etc/adsys.yaml"); err != nil {
log.Errorf("Teardown: Failed to remove adsys configuration file: %v", err)
}
}()

// Install krb5-user to be able to interact with kinit
if _, err := rootClient.Run(ctx, "apt-get update && apt-get install -y krb5-user"); err != nil {
return fmt.Errorf("failed to install krb5-user: %w", err)
}

/// detect_cached_ticket unset (disabled)
// Connect with pubkey to bypass pam_sss setting KRB5CCNAME
client, err := remote.NewClient(cmd.Inventory.IP, fmt.Sprintf("%[email protected]", cmd.Inventory.Hostname), sshKey)
if err != nil {
return fmt.Errorf("failed to connect to VM as user with pubkey: %w", err)
}
if err := client.RequireEmpty(ctx, "echo $KRB5CCNAME"); err != nil {
return fmt.Errorf("KRB5CCNAME not empty: %w", err)
}

// Create a ccache
if _, err := client.Run(ctx, fmt.Sprintf("kinit %[email protected] <<<'%s'", cmd.Inventory.Hostname, remote.DomainUserPassword)); err != nil {
return fmt.Errorf("failed to create ccache: %w", err)
}

// Set detect_cached_ticket to true
if _, err := rootClient.Run(ctx, "echo 'detect_cached_ticket: true' > /etc/adsys.yaml"); err != nil {
return fmt.Errorf("failed to set detect_cached_ticket to true: %w", err)
}

/// detect_cached_ticket enabled
// Reconnect as user
client, err = remote.NewClient(cmd.Inventory.IP, fmt.Sprintf("%[email protected]", cmd.Inventory.Hostname), sshKey)
if err != nil {
return fmt.Errorf("failed to connect to VM as user with pubkey: %w", err)
}
if err := client.RequireNotEmpty(ctx, "echo $KRB5CCNAME"); err != nil {
return fmt.Errorf("KRB5CCNAME empty: %w", err)
}

// Remove ticket cache
if _, err := rootClient.Run(ctx, "rm -f /tmp/krb5cc_*"); err != nil {
return fmt.Errorf("failed to remove ticket cache: %w", err)
}

// Reconnect as user, KRB5CCNAME should be left unset
client, err = remote.NewClient(cmd.Inventory.IP, fmt.Sprintf("%[email protected]", cmd.Inventory.Hostname), sshKey)
if err != nil {
return fmt.Errorf("failed to connect to VM as user with pubkey: %w", err)
}
if err := client.RequireEmpty(ctx, "echo $KRB5CCNAME"); err != nil {
return fmt.Errorf("KRB5CCNAME not empty: %w", err)
}

return nil
}
24 changes: 24 additions & 0 deletions e2e/internal/remote/require.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package remote

import (
"context"
"errors"
"fmt"
"strings"
)
Expand Down Expand Up @@ -34,6 +35,29 @@ func (c Client) RequireContains(ctx context.Context, cmd string, expected string
return nil
}

// RequireEmpty runs the given command and returns an error if the output is not empty.
func (c Client) RequireEmpty(ctx context.Context, cmd string) error {
out, err := c.Run(ctx, cmd)
if err != nil {
return err
}

if strings.TrimSpace(string(out)) != "" {
return fmt.Errorf("expected empty output, got %q", string(out))
}

return nil
}

// RequireNotEmpty runs the given command and returns an error if the output is empty.
func (c Client) RequireNotEmpty(ctx context.Context, cmd string) error {
if err := c.RequireEmpty(ctx, cmd); err == nil {
return errors.New("expected non-empty output")
}

return nil
}

// RequireFileExists returns an error if the given file does not exist.
func (c Client) RequireFileExists(ctx context.Context, filepath string) error {
_, err := c.Run(ctx, fmt.Sprintf("test -f %q", filepath))
Expand Down
7 changes: 7 additions & 0 deletions e2e/scripts/first-run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,10 @@ hostnamectl set-hostname "$hostname"

echo "Adding hostname to hosts file..."
echo "127.0.0.1 $hostname" >> /etc/hosts

# These overrides disable password authentication which we explicitly enabled
# during provisioning. Since they take precedence over the main sshd_config we
# have to remove them.
echo "Removing cloud-init ssh configuration overrides..."
rm -rf /etc/ssh/sshd_config.d
systemctl restart ssh
6 changes: 3 additions & 3 deletions e2e/scripts/patches/focal.patch
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
diff --git a/debian/control b/debian/control
index d6fb19f2..f9f9fe0c 100644
index ccf213e0..40a2aa9f 100644
--- a/debian/control
+++ b/debian/control
@@ -2,10 +2,10 @@ Source: adsys
Expand All @@ -12,9 +12,9 @@ index d6fb19f2..f9f9fe0c 100644
dh-golang,
- golang-go (>= 2:1.21~),
+ golang-1.21-go,
libsmbclient-dev,
apparmor,
dbus,
libdbus-1-dev,
libglib2.0-dev,
diff --git a/debian/rules b/debian/rules
index 43646c6a..0708aa3d 100755
--- a/debian/rules
Expand Down
6 changes: 3 additions & 3 deletions e2e/scripts/patches/jammy.patch
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
diff --git a/debian/control b/debian/control
index d6fb19f2..93764223 100644
index ccf213e0..2d34a328 100644
--- a/debian/control
+++ b/debian/control
@@ -5,7 +5,7 @@ Maintainer: Ubuntu Developers <[email protected]>
Expand All @@ -8,9 +8,9 @@ index d6fb19f2..93764223 100644
dh-golang,
- golang-go (>= 2:1.21~),
+ golang-1.21-go,
didrocks marked this conversation as resolved.
Show resolved Hide resolved
libsmbclient-dev,
apparmor,
dbus,
libdbus-1-dev,
libglib2.0-dev,
diff --git a/debian/rules b/debian/rules
index 43646c6a..403e7bb9 100755
--- a/debian/rules
Expand Down
1 change: 0 additions & 1 deletion e2e/scripts/provision.sh
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ echo "Configure PAM to register user sessions in the systemd control group hiera
pam-auth-update --enable systemd

echo "Enabling keyboard-interactive authentication for domain users..."
rm -rf /etc/ssh/sshd_config.d # this contains overrides that conflict with our changes
didrocks marked this conversation as resolved.
Show resolved Hide resolved
sed -iE 's/^#\?PasswordAuthentication.*/PasswordAuthentication yes/' /etc/ssh/sshd_config
sed -iE 's/^#\?KbdInteractiveAuthentication.*/KbdInteractiveAuthentication yes/' /etc/ssh/sshd_config

Expand Down
Loading
Loading