From b92aef33ed30f898b748d9ab0bf6c332a4516080 Mon Sep 17 00:00:00 2001 From: Mike Bonnet Date: Oct 28 2019 15:06:41 +0000 Subject: krb5: deploy, authenticate with, and manage a Kerberos 5 KDC The krb5 variable supports deploying a new containerized Kerberos 5 KDC (using the krb5-fedora image from https://pagure.io/krb5-fedora and quay.io/factory2/krb5-fedora). It provides methods for running admin commands (adding user and service principals), authenticating to the KDC, and running commands in an environment with a valid Kerberos ccache. It can be used to configure services for Kerberos authentication and validate their correct operation. --- diff --git a/resources/openshift/templates/krb5.yaml b/resources/openshift/templates/krb5.yaml new file mode 100644 index 0000000..1b12c65 --- /dev/null +++ b/resources/openshift/templates/krb5.yaml @@ -0,0 +1,262 @@ +--- +apiVersion: v1 +kind: Template +metadata: + name: krb5-test-template +labels: + template: krb5-test-template +parameters: +- name: TEST_ID + displayName: Test id + description: Short unique identifier for this test run (e.g. Jenkins job number) + required: true +- name: NAME + displayName: The name for this deployment config. + required: true + value: krb5 +- name: REALM + displayName: The Kerberos realm to manage. + required: true + value: CLUSTER.LOCAL +- name: DOMAIN + displayName: The DNS domain associated with the realm. + required: true + value: cluster.local +- name: KDC_DB_PASSWORD + displayName: The master password for the Kerberos database. + generate: expression + from: "[\\w]{16}" +- name: ADMIN_PASSWORD + displayName: The password for the kadmin/admin principal. + generate: expression + from: "[\\w]{16}" +- name: INIT_USERS + displayName: A comma-separated list of initial users to define, in username:password format. + required: false +- name: IMAGE + displayName: Location of the image to deploy. + required: true + value: quay.io/factory2/krb5-fedora:latest +objects: +- apiVersion: v1 + kind: Secret + metadata: + name: ${NAME}-${TEST_ID}-secret + labels: + app: ${NAME} + service: kerberos + environment: test-${TEST_ID} + stringData: + KDC_DB_PASSWORD: ${KDC_DB_PASSWORD} + ADMIN_PASSWORD: ${ADMIN_PASSWORD} +- apiVersion: v1 + kind: ConfigMap + metadata: + name: ${NAME}-${TEST_ID}-config + labels: + app: ${NAME} + service: kerberos + environment: test-${TEST_ID} + data: + krb5.conf: | + includedir /etc/krb5.conf.d/ + + [logging] + default = STDERR + kdc = STDERR + admin_server = STDERR + debug = true + + [libdefaults] + dns_lookup_kdc = false + dns_lookup_realm = false + dns_canonicalize_hostname = false + ticket_lifetime = 24h + renew_lifetime = 7d + forwardable = true + pkinit_anchors = FILE:/etc/pki/tls/certs/ca-bundle.crt + spake_preauth_groups = edwards25519 + default_realm = ${REALM} + default_ccache_name = FILE:/tmp/%{uid}-ccache + + [realms] + ${REALM} = { + kdc = kerberos-${TEST_ID}:8088 + admin_server = kerberos-${TEST_ID}:8749 + kpasswd_server = kerberos-${TEST_ID}:8464 + kdc_listen = 8088 + kdc_tcp_listen = 8088 + kadmind_listen = 8749 + kpasswd_listen = 8464 + acl_file = /etc/kadm5.acl + } + + [domain_realm] + .${DOMAIN} = ${REALM} + ${DOMAIN} = ${REALM} + kadm5.acl: | + */admin@${REALM} * +- apiVersion: v1 + kind: DeploymentConfig + metadata: + name: ${NAME}-${TEST_ID} + labels: + app: ${NAME} + service: kerberos + environment: test-${TEST_ID} + spec: + replicas: 1 + strategy: + type: Recreate + selector: + app: ${NAME} + service: kerberos + environment: test-${TEST_ID} + template: + metadata: + labels: + app: ${NAME} + service: kerberos + environment: test-${TEST_ID} + spec: + initContainers: + - name: init-kdc-db + image: ${IMAGE} + imagePullPolicy: Always + command: + - /usr/local/bin/init-kdc-db + env: + - name: REALM + value: ${REALM} + - name: INIT_USERS + value: ${INIT_USERS} + envFrom: + - secretRef: + name: ${NAME}-${TEST_ID}-secret + volumeMounts: + - name: config-vol + mountPath: /etc/krb5.conf + subPath: krb5.conf + - name: config-vol + mountPath: /etc/kadm5.acl + subPath: kadm5.acl + - name: data-vol + mountPath: /var/kerberos/krb5kdc + resources: + requests: + memory: "384Mi" + cpu: "300m" + limits: + memory: "512Mi" + cpu: "500m" + containers: + - name: kdc + image: ${IMAGE} + imagePullPolicy: Always + command: + - /usr/sbin/krb5kdc + - -n + volumeMounts: + - name: config-vol + subPath: krb5.conf + mountPath: /etc/krb5.conf + - name: config-vol + subPath: kadm5.acl + mountPath: /etc/kadm5.acl + - name: data-vol + mountPath: /var/kerberos/krb5kdc + ports: + - name: kdc + containerPort: 8088 + - name: kdc-udp + containerPort: 8088 + protocol: UDP + resources: + requests: + memory: "384Mi" + cpu: "300m" + limits: + memory: "512Mi" + cpu: "500m" + livenessProbe: + tcpSocket: + port: 8088 + readinessProbe: + tcpSocket: + port: 8088 + - name: kadmind + image: ${IMAGE} + imagePullPolicy: Always + command: + - /usr/sbin/kadmind + - -nofork + volumeMounts: + - name: config-vol + subPath: krb5.conf + mountPath: /etc/krb5.conf + - name: config-vol + subPath: kadm5.acl + mountPath: /etc/kadm5.acl + - name: data-vol + mountPath: /var/kerberos/krb5kdc + ports: + - name: admin + containerPort: 8749 + - name: kpasswd + containerPort: 8464 + - name: kpasswd-udp + containerPort: 8464 + protocol: UDP + resources: + requests: + memory: "384Mi" + cpu: "300m" + limits: + memory: "512Mi" + cpu: "500m" + livenessProbe: + tcpSocket: + port: 8749 + readinessProbe: + tcpSocket: + port: 8749 + volumes: + - name: config-vol + configMap: + name: ${NAME}-${TEST_ID}-config + - name: data-vol + emptyDir: {} + triggers: + - type: ConfigChange +- apiVersion: v1 + kind: Service + metadata: + name: kerberos-${TEST_ID} + labels: + app: ${NAME} + service: kerberos + environment: test-${TEST_ID} + spec: + type: NodePort + ports: + - name: kdc + port: 8088 + targetPort: kdc + - name: kdc-udp + port: 8088 + protocol: UDP + targetPort: kdc-udp + - name: admin + port: 8749 + targetPort: admin + - name: kpasswd + port: 8464 + targetPort: kpasswd + - name: kpasswd-udp + port: 8464 + protocol: UDP + targetPort: kpasswd-udp + selector: + app: ${NAME} + service: kerberos + environment: test-${TEST_ID} diff --git a/src/com/redhat/c3i/util/Krb5Client.groovy b/src/com/redhat/c3i/util/Krb5Client.groovy new file mode 100644 index 0000000..9192ad8 --- /dev/null +++ b/src/com/redhat/c3i/util/Krb5Client.groovy @@ -0,0 +1,121 @@ +// Interact with a Kerberos 5 KDC. +// Mike Bonnet (mikeb@redhat.com), 2019-10-22 + +package com.redhat.c3i.util + +class Krb5Client implements Serializable { + String realm + String domain + String kdc_host + String admin_host + String kpasswd_host + String principal + String password + String keytab + String confDir + Boolean kinit + def steps + + def init() { + for (param in ['realm', 'domain', 'kdc_host', 'admin_host', 'kpasswd_host', 'principal']) { + if (!this."${param}") { + steps.error "The ${param} must be specified" + } + } + if (kinit == null) { + kinit = true + } + if (!confDir) { + confDir = "${steps.pwd(tmp: true)}/krb5/${principal.replace('/', '_')}" + } + steps.dir(confDir) { + if (!steps.fileExists('krb5.conf')) { + steps.writeFile file: 'krb5.conf', text: """\ + [libdefaults] + dns_lookup_kdc = false + dns_lookup_realm = false + dns_canonicalize_hostname = false + ticket_lifetime = 24h + renew_lifetime = 7d + forwardable = true + pkinit_anchors = FILE:/etc/pki/tls/certs/ca-bundle.crt + spake_preauth_groups = edwards25519 + default_realm = ${realm} + default_ccache_name = FILE:${confDir}/ccache + + [realms] + ${realm} = { + kdc = ${kdc_host} + admin_server = ${admin_host} + kpasswd_server = ${kpasswd_host} + } + + [domain_realm] + .${domain} = ${realm} + ${domain} = ${realm} + """.stripIndent() + if (kinit) { + if (keytab) { + steps.writeFile file: 'keytab', text: keytab, encoding: 'Base64' + run("kinit -V -k -t keytab -c ccache ${principal}") + } else if (password) { + run("kinit -V -c ccache ${principal} <<<'${password}'") + } else { + steps.error "Either a password or a keytab must be specified" + } + } + } + } + } + + def run(Closure body) { + init() + steps.withEnv(["KRB5_CONFIG=${confDir}/krb5.conf"]) { + return body() + } + } + + def run(Map args=[:], String cmd) { + return run({ steps.sh script: cmd, + returnStdout: args.returnStdout ?: false, + returnStatus: args.returnStatus ?: false }) + } + + def runAdmin(String cmd) { + if (!password) { + steps.error "The admin password must be specified" + } + run("${cmd} <<<'${password}'") + } + + def addPrincipal(String princ, String password) { + runAdmin("kadmin -p ${principal} add_principal -pw '${password}' ${princ}") + } + + def addService(String svc) { + runAdmin("kadmin -p ${principal} add_principal -randkey ${svc}") + } + + def getKeytab(String svc) { + steps.dir(steps.pwd(tmp: true)) { + def ktfile = "${svc.replace('/', '_')}.kt" + if (!steps.fileExists(ktfile)) { + runAdmin("kadmin -p ${principal} ktadd -k ${ktfile} ${svc}") + } + return steps.readFile(file: ktfile, encoding: 'Base64') + } + } + + def changePassword(String newpass) { + if (!password) { + steps.error "The current password must be specified" + } + run("""\ + kpasswd < + krb5.env.remove(key) + def client = krb5.client(kinit: false) + def exc = shouldFail { + client.init() + } + assertEquals("The ${key.replace('KRB5_', '').toLowerCase()} must be specified" as String, exc.message) + krb5.env.put(key, value) + } + } + + @Test + void testInitPasswd() { + def first = true + helper.registerAllowedMethod('fileExists', [String.class], { if (first) { first = false; false } else { true } }) + def client = krb5.client( + principal: 'testprinc', + password: 'testpass', + ) + client.init() + assertEquals(true, client.kinit) + assertTrue(helper.callStack.any { call -> + call.methodName == 'pwd' && + call.args[0].tmp == true + }) + assertTrue(helper.callStack.any { call -> + call.methodName == 'dir' && + call.args[0] == '/tmp/dir/krb5/testprinc' + }) + assertTrue(helper.callStack.any { call -> + call.methodName == 'writeFile' && + call.args[0].file == 'krb5.conf' + }) + assertTrue(helper.callStack.any { call -> + call.methodName == 'sh' && + call.args[0].script == "kinit -V -c ccache testprinc <<<'testpass'" + }) + } + + @Test + void testInitKeytab() { + def first = true + helper.registerAllowedMethod('fileExists', [String.class], { if (first) { first = false; false } else { true } }) + def client = krb5.client( + principal: 'testprinc', + keytab: 'a1b2c3', + ) + client.init() + assertEquals(true, client.kinit) + assertTrue(helper.callStack.any { call -> + call.methodName == 'pwd' && + call.args[0].tmp == true + }) + assertTrue(helper.callStack.any { call -> + call.methodName == 'dir' && + call.args[0] == '/tmp/dir/krb5/testprinc' + }) + assertTrue(helper.callStack.any { call -> + call.methodName == 'writeFile' && + call.args[0].file == 'krb5.conf' + }) + assertTrue(helper.callStack.any { call -> + call.methodName == 'writeFile' && + call.args[0].file == 'keytab' && + call.args[0].encoding == 'Base64' + }) + assertTrue(helper.callStack.any { call -> + call.methodName == 'sh' && + call.args[0].script == 'kinit -V -k -t keytab -c ccache testprinc' + }) + } + + @Test + void testRun() { + def first = true + helper.registerAllowedMethod('fileExists', [String.class], { if (first) { first = false; false } else { true } }) + def client = krb5.client( + principal: 'testprinc', + password: 'testpass' + ) + client.run('ls') + assertTrue(helper.callStack.any { call -> + call.methodName == 'sh' && + call.args[0].script == "kinit -V -c ccache testprinc <<<'testpass'" + }) + assertTrue(helper.callStack.any { call -> + call.methodName == 'withEnv' && + call.args[0][0].startsWith('KRB5_CONFIG=') + }) + assertTrue(helper.callStack.any { call -> + call.methodName == 'sh' && + call.args[0].script == 'ls' && + call.args[0].returnStdout == false && + call.args[0].returnStatus == false + }) + } + + @Test + void testRunReturnStdout() { + def first = true + helper.registerAllowedMethod('fileExists', [String.class], { if (first) { first = false; false } else { true } }) + def client = krb5.client( + principal: 'testprinc', + password: 'testpass' + ) + client.run('ls', returnStdout: true) + assertTrue(helper.callStack.any { call -> + call.methodName == 'sh' && + call.args[0].script == 'ls' && + call.args[0].returnStdout == true && + call.args[0].returnStatus == false + }) + } + + @Test + void testRunReturnStatus() { + def first = true + helper.registerAllowedMethod('fileExists', [String.class], { if (first) { first = false; false } else { true } }) + def client = krb5.client( + principal: 'testprinc', + password: 'testpass' + ) + client.run('ls', returnStatus: true) + assertTrue(helper.callStack.any { call -> + call.methodName == 'sh' && + call.args[0].script == 'ls' && + call.args[0].returnStdout == false && + call.args[0].returnStatus == true + }) + } + + @Test + void testChangePassword() { + def first = true + helper.registerAllowedMethod('fileExists', [String.class], { if (first) { first = false; false } else { true } }) + def client = krb5.client( + principal: 'testprinc', + password: 'testpass' + ) + client.changePassword('newpass') + assertTrue(helper.callStack.any { call -> + call.methodName == 'sh' && + call.args[0].script.startsWith('kpasswd < + call.methodName == 'sh' && + call.args[0].script == "ls <<<'testpass'" + }) + assertFalse(helper.callStack.any { call -> + call.methodName == 'sh' && + call.args[0].script.startsWith('kinit') + }) + } + + @Test + void testAddPrincipal() { + def client = krb5.adminClient(password: 'testpass') + client.addPrincipal('newprinc', 'newpass') + assertTrue(helper.callStack.any { call -> + call.methodName == 'sh' && + call.args[0].script == "kadmin -p kadmin/admin add_principal -pw 'newpass' newprinc <<<'testpass'" + }) + } + + @Test + void testAddService() { + def client = krb5.adminClient(password: 'testpass') + client.addService('new/svc') + assertTrue(helper.callStack.any { call -> + call.methodName == 'sh' && + call.args[0].script == "kadmin -p kadmin/admin add_principal -randkey new/svc <<<'testpass'" + }) + } + + @Test + void testGetKeytab() { + helper.registerAllowedMethod('readFile', [Map.class], { 'ktdata' }) + def client = krb5.adminClient(password: 'testpass') + def result = client.getKeytab('some/svc') + assertEquals('ktdata', result) + assertEquals(2, helper.methodCallCount('pwd')) + assertEquals(2, helper.methodCallCount('dir')) + assertTrue(helper.callStack.any { call -> + call.methodName == 'sh' && + call.args[0].script.startsWith('kadmin -p kadmin/admin ktadd') && + call.args[0].script.endsWith("some/svc <<<'testpass'") + }) + assertTrue(helper.callStack.any { call -> + call.methodName == 'readFile' && + call.args[0].file == 'some_svc.kt' && + call.args[0].encoding == 'Base64' + }) + } + + @Test + void testGetKeytabExists() { + helper.registerAllowedMethod('fileExists', [String.class], { true }) + helper.registerAllowedMethod('readFile', [Map.class], { 'ktdata' }) + def client = krb5.adminClient(password: 'testpass') + def result = client.getKeytab('some/svc') + assertEquals('ktdata', result) + assertEquals(1, helper.methodCallCount('pwd')) + assertEquals(1, helper.methodCallCount('dir')) + assertEquals(0, helper.methodCallCount('sh')) + assertTrue(helper.callStack.any { call -> + call.methodName == 'readFile' && + call.args[0].file == 'some_svc.kt' && + call.args[0].encoding == 'Base64' + }) + } + + @Test + void testWithKrbArgs() { + def first = true + helper.registerAllowedMethod('fileExists', [String.class], { if (first) { first = false; false } else { true } }) + def result = krb5.withKrb(principal: 'testprinc', password: 'testpass') { + return 'output' + } + assertEquals('output', result) + assertTrue(helper.callStack.any { call -> + call.methodName == 'sh' && + call.args[0].script == "kinit -V -c ccache testprinc <<<'testpass'" + }) + assertTrue(helper.callStack.any { call -> + call.methodName == 'withEnv' && + call.args[0][0].startsWith('KRB5_CONFIG=') + }) + } + + @Test + void testWithKrbEnv() { + def first = true + helper.registerAllowedMethod('fileExists', [String.class], { if (first) { first = false; false } else { true } }) + krb5.env.KRB5_PRINCIPAL = 'testprinc' + krb5.env.KRB5_PASSWORD = 'testpass' + def result = krb5.withKrb() { + return 'output' + } + assertEquals('output', result) + assertTrue(helper.callStack.any { call -> + call.methodName == 'sh' && + call.args[0].script == "kinit -V -c ccache testprinc <<<'testpass'" + }) + assertTrue(helper.callStack.any { call -> + call.methodName == 'withEnv' && + call.args[0][0].startsWith('KRB5_CONFIG=') + }) + } + +} diff --git a/vars/krb5.groovy b/vars/krb5.groovy new file mode 100644 index 0000000..6e2188c --- /dev/null +++ b/vars/krb5.groovy @@ -0,0 +1,90 @@ +// Functions to deploy a containerized Kerberos KDC. +// Mike Bonnet (mikeb@redhat.com), 2019-10-22 + +/** + * Deploy a Kerberos 5 KDC suitable for testing. + * @param args.script The script calling the method. + * @param args.test_id A unique {@code String} used to identify this instance. + * @param args.realm The Kerberos realm to manage. + * @param args.domain The DNS domain to associate with the Kerberos realm. + * @param args.admin_password The password for the admin user. + * @param args.init_users A comma-separated list of initial users to define, in username:password format. + * @param args.image The pull spec of the Kerberos container image to use. + * @return An OpenShift selector representing the DeploymentConfigs rolled out. + */ +def deploy(Map args) { + if (!args.image) { + args.image = 'quay.io/factory2/krb5-fedora:latest' + } + def yaml = libraryResource "openshift/templates/krb5.yaml" + def template = readYaml text: yaml + def models = args.script.openshift.process(template, + '-p', "TEST_ID=${args.test_id}", + '-p', "REALM=${args.realm ?: 'CLUSTER.LOCAL'}", + '-p', "DOMAIN=${args.domain ?: 'cluster.local'}", + '-p', "ADMIN_PASSWORD=${args.admin_password}", + '-p', "INIT_USERS=${args.init_users ?: ''}", + '-p', "IMAGE=${args.image}", + '-l', 'c3i.redhat.com/app=krb5', + '-l', "c3i.redhat.com/test=${args.test_id}", + ) + return c3i.deploy(script: args.script, objs: models) +} + +/** + * Return a client that can be used for interacting with the KDC. + * @param args.principal The Kerberos principal to use to contact the KDC. If not specified, + * it will be retrieved from {@code env.KRB5_PRINCIPAL}. + * @param args.password The password for the Kerberos principal. If not specified, + * it will be retrieved from {@code env.KRB5_PASSWORD}. + * @param args.keytab The Base64-encoded keytab for the Kerberos principal. If not specified, + * it will be retrieved from {@code env.KRB5_KEYTAB}. + * @param args.realm The Kerberos realm. If not specified, + * it will be retrieved from the {@code env.KRB5_REALM} variable. + * @param args.domain The domain associated with the realm. If not specified, + * it will be retrieved from the {@code env.KRB5_DOMAIN} variable. + * @param args.kdc_host The hostname:port for the KDC. If not specified, + * it will be retrieved from the {@code env.KRB5_KDC_HOST} variable. + * @param args.admin_host The hostname:port for the admin server. If not specified, + * it will be retrieved from the {@code env.KRB5_ADMIN_HOST} variable. + * @param args.kpasswd_host The hostname:port for the kpasswd server. If not specified, + * it will be retrieved from the {@code env.KRB5_KPASSWD_HOST} variable. + * @return A {@code Krb5Client} instance. + */ +def client(Map args=[:]) { + args.principal = args.principal ?: env.KRB5_PRINCIPAL + args.password = args.password ?: env.KRB5_PASSWORD + args.keytab = args.keytab ?: env.KRB5_KEYTAB + args.realm = args.realm ?: env.KRB5_REALM + args.domain = args.domain ?: env.KRB5_DOMAIN + args.kdc_host = args.kdc_host ?: env.KRB5_KDC_HOST + args.admin_host = args.admin_host ?: env.KRB5_ADMIN_HOST + args.kpasswd_host = args.kpasswd_host ?: env.KRB5_KPASSWD_HOST + args.steps = steps + return new com.redhat.c3i.util.Krb5Client(args) +} + +/** + * Return a client that can be used for issuing admin commands to the KDC. + * @param args.password The admin password for the KDC. If not specified, + * it will be retrieved from {@code env.KRB5_ADMIN_PASSWORD} if + * defined, otherwise from {@code env.KRB5_PASSWORD}. + * @return A {@code Krb5Client} instance. + */ +def adminClient(Map args=[:]) { + args.principal = 'kadmin/admin' + args.password = args.password ?: env.KRB5_ADMIN_PASSWORD + args.kinit = false + return client(args) +} + +/** + * Run a block of code with Kerberos authentication configured. + * @params args The same arguments accepted by {@code client()}. + * @params body The {@code Closure} to execute. + * @return The return value of the {@code Closure}. + */ +def withKrb(Map args=[:], Closure body) { + def client = client(args) + return client.run(body) +}