org.apache.kudu.client.MiniKdc.java Source code

Java tutorial

Introduction

Here is the source code for org.apache.kudu.client.MiniKdc.java

Source

// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements.  See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership.  The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License.  You may obtain a copy of the License at
//
//   http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied.  See the License for the
// specific language governing permissions and limitations
// under the License.

package org.apache.kudu.client;

import java.io.BufferedReader;
import java.io.Closeable;
import java.io.File;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;
import java.util.Map;

import javax.annotation.concurrent.NotThreadSafe;

import com.google.common.base.Charsets;
import com.google.common.base.Joiner;
import com.google.common.base.MoreObjects;
import com.google.common.base.Preconditions;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.io.CharStreams;

import org.apache.commons.io.FileUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.apache.kudu.annotations.InterfaceAudience;

/**
 * A managed Kerberos Key Distribution Center.
 *
 * Provides utility functions to create users and services which can authenticate
 * to the KDC.
 *
 * The KDC is managed as an external process, using the krb5 binaries installed on the system.
 *
 * For debugging Kerberos client issues, it can be helpful to add
 * {@code -Dsun.security.krb5.debug=true} to the JVM properties.
 */
@InterfaceAudience.Private
@NotThreadSafe
public class MiniKdc implements Closeable {

    // The KDC port will be assigned starting from this value.
    private static final int PORT_START = 64530;

    private static final Logger LOG = LoggerFactory.getLogger(MiniKuduCluster.class);

    private final Options options;

    private Process kdcProcess;

    private Thread kdcProcessLogRedirector;

    /**
     * Options for the MiniKdc.
     */
    public static class Options {
        private final String realm;
        private final Path dataRoot;
        private final int port;

        public Options(String realm, Path dataRoot, int port) {
            Preconditions.checkArgument(port > 0);
            this.realm = realm;
            this.dataRoot = dataRoot;
            this.port = port;
        }

        public String getRealm() {
            return realm;
        }

        public Path getDataRoot() {
            return dataRoot;
        }

        public int getPort() {
            return port;
        }

        /** {@inheritDoc} */
        @Override
        public String toString() {
            return MoreObjects.toStringHelper(this).add("realm", realm).add("dataRoot", dataRoot).add("port", port)
                    .toString();
        }
    }

    /**
     * Creates a MiniKdc with explicit options.
     */
    public MiniKdc(Options options) {
        this.options = options;
    }

    /**
     * Creates a MiniKdc with default options.
     */
    public static MiniKdc withDefaults() throws IOException {
        return new MiniKdc(new Options("KRBTEST.COM",
                Paths.get(TestUtils.getBaseDir(), "krb5kdc-" + System.currentTimeMillis()),
                TestUtils.findFreeUdpPort(PORT_START)));
    }

    /**
     * Start the MiniKdc.
     */
    public void start() throws IOException {
        Preconditions.checkState(kdcProcess == null);
        LOG.debug("starting KDC {}", options);

        File dataRootDir = options.dataRoot.toFile();
        if (!dataRootDir.exists()) {
            if (!dataRootDir.mkdir()) {
                throw new RuntimeException(String.format("unable to create krb5 state directory: %s", dataRootDir));
            }

            createKdcConf();
            createKrb5Conf();

            // Create the KDC database using the kdb5_util tool.
            checkReturnCode(startProcessWithKrbEnv(getBinaryPath("kdb5_util"), "create", "-s", // Stash the master password.
                    "-P", "masterpw", // Set a password.
                    "-W" // Use weak entropy (since we don't need real security).
            ), "kdb5_util", true);
        }

        kdcProcess = startProcessWithKrbEnv(getBinaryPath("krb5kdc"), "-n"); // Do not daemonize.

        // Redirect the KDC output to SLF4J.
        kdcProcessLogRedirector = new Thread(
                new MiniKuduCluster.ProcessInputStreamLogPrinterRunnable(kdcProcess.getInputStream()),
                "krb5kdc:" + options.port);
        kdcProcessLogRedirector.setDaemon(true);
        kdcProcessLogRedirector.start();

        // The C++ MiniKdc defaults to binding the KDC to an ephemeral port, which
        // it then finds using lsof at this point. Java is unable to do that since
        // the Process API does not expose the subprocess PID. As a result, this
        // MiniKdc doesn't support binding to an ephemeral port, and we use the
        // race-prone TestUtils.findFreePort instead. The upside is that we
        // don't have to rewrite the config files.
    }

    /**
     * Creates a new Kerberos user with the given username.
     * @param username the new user
     */
    void createUserPrincipal(String username) throws IOException {
        checkReturnCode(startProcessWithKrbEnv(getBinaryPath("kadmin.local"), "-q",
                String.format("add_principal -pw %s %s", username, username)), "kadmin.local", true);
    }

    /**
     * Kinit a user with the mini KDC.
     * @param username the user to kinit
     */
    void kinit(String username) throws IOException {
        Process proc = startProcessWithKrbEnv(getBinaryPath("kinit"), username);
        proc.getOutputStream().write(username.getBytes());
        proc.getOutputStream().close();
        checkReturnCode(proc, "kinit", true);
    }

    /**
     * Returns the output from the 'klist' utility. This is useful for logging the
     * local ticket cache state.
     */
    String klist() throws IOException {
        Process proc = startProcessWithKrbEnv(getBinaryPath("klist"), "-A");
        checkReturnCode(proc, "klist", false);
        return CharStreams.toString(new InputStreamReader(proc.getInputStream()));
    }

    /**
     * Creates a new service principal and associated keytab, returning its path.
     * @param spn the desired service principal name (e.g. "kudu/foo.example.com").
     *            If the principal already exists, its key will be reset and a new
     *            keytab will be generated.
     * @return the path to the new services' keytab file.
     */
    Path createServiceKeytab(String spn) throws IOException {
        Path kt_path = options.dataRoot.resolve(spn.replace('/', '_') + ".keytab");
        String kadmin = getBinaryPath("kadmin.local");
        checkReturnCode(startProcessWithKrbEnv(kadmin, "-q", String.format("add_principal -randkey %s", spn)),
                "kadmin.local", true);

        checkReturnCode(startProcessWithKrbEnv(kadmin, "-q", String.format("ktadd -k %s %s", kt_path, spn)),
                "kadmin.local", true);
        return kt_path;
    }

    private void createKrb5Conf() throws IOException {
        List<String> contents = ImmutableList.of("[logging]", "   kdc = FILE:/dev/stderr",

                "[libdefaults]", "   default_realm = " + options.realm, "   dns_lookup_kdc = false",
                "   dns_lookup_realm = false", "   forwardable = true", "   renew_lifetime = 7d",
                "   ticket_lifetime = 24h",

                // Disable aes256, since Java does not support it without JCE, see
                // https://docs.oracle.com/javase/8/docs/technotes/guides/security/jgss/jgss-features.html
                "   default_tkt_enctypes = aes128-cts des3-cbc-sha1",
                "   default_tgs_enctypes = aes128-cts des3-cbc-sha1",
                "   permitted_enctypes = aes128-cts des3-cbc-sha1",

                // In miniclusters, we start daemons on local loopback IPs that
                // have no reverse DNS entries. So, disable reverse DNS.
                "   rdns = false", "   ignore_acceptor_hostname = true",

                "[realms]", options.realm + " = {", "   kdc = 127.0.0.1:" + options.port, "}");

        Files.write(options.dataRoot.resolve("krb5.conf"), contents, Charsets.UTF_8);
    }

    private void createKdcConf() throws IOException {
        List<String> contents = ImmutableList.of("[kdcdefaults]", "   kdc_ports = " + options.port,

                "[realms]", options.realm + " = {", "   acl_file = " + options.dataRoot.resolve("kadm5.acl"),
                "   admin_keytab = " + options.dataRoot.resolve("kadm5.keytab"),
                "   database_name = " + options.dataRoot.resolve("principal"),
                "   key_stash_file = " + options.dataRoot.resolve(".k5." + options.realm),
                "   max_renewable_life = 7d 0h 0m 0s", "}");

        Files.write(options.dataRoot.resolve("kdc.conf"), contents, Charsets.UTF_8);
    }

    /**
     * Stop the MiniKdc.
     */
    public void stop() throws IOException {
        Preconditions.checkState(kdcProcess != null);
        LOG.debug("stopping KDC {}", options);
        try {
            kdcProcess.destroy();
            kdcProcess.waitFor();
            kdcProcessLogRedirector.join();
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        } finally {
            kdcProcess = null;
            kdcProcessLogRedirector = null;
        }
    }

    /** {@inheritDoc} */
    @Override
    public void close() throws IOException {
        LOG.debug("closing KDC {}", options);
        try {
            if (kdcProcess != null) {
                stop();
            }
        } finally {
            FileUtils.deleteDirectory(options.dataRoot.toFile());
        }
    }

    private static final List<String> KRB5_BINARY_PATHS = ImmutableList.of("/usr/local/opt/krb5/sbin", // Homebrew
            "/usr/local/opt/krb5/bin", // Homebrew
            "/opt/local/sbin", // Macports
            "/opt/local/bin", // Macports
            "/usr/lib/mit/sbin", // SLES
            "/usr/sbin" // Linux
    );

    public Map<String, String> getEnvVars() {
        return ImmutableMap.of("KRB5_CONFIG", options.dataRoot.resolve("krb5.conf").toString(), "KRB5_KDC_PROFILE",
                options.dataRoot.resolve("kdc.conf").toString(), "KRB5CCNAME", getTicketCachePath(),
                "KUDU_ENABLE_KRB5_REALM_FIX", "yes");
    }

    /**
     * @return the path of the Kerberos ticket/credential cache
     */
    public String getTicketCachePath() {
        return options.dataRoot.resolve("krb5cc").toString();
    }

    private Process startProcessWithKrbEnv(String... argv) throws IOException {

        ProcessBuilder procBuilder = new ProcessBuilder(argv);
        procBuilder.environment().putAll(getEnvVars());
        LOG.debug("executing '{}', env: '{}'", Joiner.on(" ").join(procBuilder.command()),
                Joiner.on(", ").withKeyValueSeparator("=").join(procBuilder.environment()));
        return procBuilder.redirectErrorStream(true).start();
    }

    /**
     * Waits for the process to exit, checking the return code. Any output to the
     * process' stdout is optionally logged to SLF4J.
     * @param process the process to check
     * @param name the name of the process
     * @param log whether to log the process' stdout.
     */
    private static void checkReturnCode(Process process, String name, boolean log) throws IOException {
        int ret;
        try {
            ret = process.waitFor();
            if (log) {
                // Reading the output *after* waiting for the process to close can deadlock
                // if the process overwhelms the output buffer, however none of the krb5
                // utilities are known to do that.
                try (BufferedReader in = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
                    String line;
                    while ((line = in.readLine()) != null) {
                        LOG.debug(line);
                    }
                }
            }
        } catch (InterruptedException e) {
            Thread.interrupted();
            throw new IOException(String.format("process '%s' interrupted", name));
        }
        if (ret != 0) {
            throw new IOException(String.format("process '%s' failed: %s", name, ret));
        }
    }

    private static String getBinaryPath(String executable) throws IOException {
        return getBinaryPath(executable, KRB5_BINARY_PATHS);
    }

    private static String getBinaryPath(String executable, List<String> searchPaths) throws IOException {
        for (String path : searchPaths) {
            File f = Paths.get(path).resolve(executable).toFile();
            if (f.exists() && f.canExecute()) {
                return f.getPath();
            }
        }

        Process which = new ProcessBuilder().command("which", executable).start();
        checkReturnCode(which, "which", false);
        return CharStreams.toString(new InputStreamReader(which.getInputStream())).trim();
    }
}