MiniKdc.java

/**
 * 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.hadoop.minikdc;
import org.apache.kerby.kerberos.kerb.KrbException;
import org.apache.kerby.kerberos.kerb.server.KdcConfigKey;
import org.apache.kerby.kerberos.kerb.server.SimpleKdcServer;
import org.apache.kerby.util.IOUtil;
import org.apache.kerby.util.NetworkUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.File;
import java.io.FileInputStream;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Locale;
import java.util.Map;
import java.util.Properties;
import java.util.Set;

/**
 * Mini KDC based on Apache Directory Server that can be embedded in testcases
 * or used from command line as a standalone KDC.
 * <p>
 * <b>From within testcases:</b>
 * <p>
 * MiniKdc sets one System property when started and un-set when stopped:
 * <ul>
 *   <li>sun.security.krb5.debug: set to the debug value provided in the
 *   configuration</li>
 * </ul>
 * Because of this, multiple MiniKdc instances cannot be started in parallel.
 * For example, running testcases in parallel that start a KDC each. To
 * accomplish this a single MiniKdc should be used for all testcases running
 * in parallel.
 * <p>
 * MiniKdc default configuration values are:
 * <ul>
 *   <li>org.name=EXAMPLE (used to create the REALM)</li>
 *   <li>org.domain=COM (used to create the REALM)</li>
 *   <li>kdc.bind.address=localhost</li>
 *   <li>kdc.port=0 (ephemeral port)</li>
 *   <li>instance=DefaultKrbServer</li>
 *   <li>max.ticket.lifetime=86400000 (1 day)</li>
 *   <li>max.renewable.lifetime=604800000 (7 days)</li>
 *   <li>transport=TCP</li>
 *   <li>debug=false</li>
 * </ul>
 * The generated krb5.conf forces TCP connections.
 */
public class MiniKdc {

  public static final String JAVA_SECURITY_KRB5_CONF =
      "java.security.krb5.conf";
  public static final String SUN_SECURITY_KRB5_DEBUG =
      "sun.security.krb5.debug";

  public static void main(String[] args) throws Exception {
    if (args.length < 4) {
      System.out.println("Arguments: <WORKDIR> <MINIKDCPROPERTIES> " +
              "<KEYTABFILE> [<PRINCIPALS>]+");
      System.exit(1);
    }
    File workDir = new File(args[0]);
    if (!workDir.exists()) {
      throw new RuntimeException("Specified work directory does not exists: "
              + workDir.getAbsolutePath());
    }
    Properties conf = createConf();
    File file = new File(args[1]);
    if (!file.exists()) {
      throw new RuntimeException("Specified configuration does not exists: "
              + file.getAbsolutePath());
    }
    Properties userConf = new Properties();
    InputStreamReader r = null;
    try {
      r = new InputStreamReader(new FileInputStream(file),
          StandardCharsets.UTF_8);
      userConf.load(r);
    } finally {
      if (r != null) {
        r.close();
      }
    }
    for (Map.Entry<?, ?> entry : userConf.entrySet()) {
      conf.put(entry.getKey(), entry.getValue());
    }
    final MiniKdc miniKdc = new MiniKdc(conf, workDir);
    miniKdc.start();
    File krb5conf = new File(workDir, "krb5.conf");
    if (miniKdc.getKrb5conf().renameTo(krb5conf)) {
      File keytabFile = new File(args[2]).getAbsoluteFile();
      String[] principals = new String[args.length - 3];
      System.arraycopy(args, 3, principals, 0, args.length - 3);
      miniKdc.createPrincipal(keytabFile, principals);
      System.out.println();
      System.out.println("Standalone MiniKdc Running");
      System.out.println("---------------------------------------------------");
      System.out.println("  Realm           : " + miniKdc.getRealm());
      System.out.println("  Running at      : " + miniKdc.getHost() + ":" +
              miniKdc.getHost());
      System.out.println("  krb5conf        : " + krb5conf);
      System.out.println();
      System.out.println("  created keytab  : " + keytabFile);
      System.out.println("  with principals : " + Arrays.asList(principals));
      System.out.println();
      System.out.println(" Do <CTRL-C> or kill <PID> to stop it");
      System.out.println("---------------------------------------------------");
      System.out.println();
      Runtime.getRuntime().addShutdownHook(new Thread() {
        @Override
        public void run() {
          miniKdc.stop();
        }
      });
    } else {
      throw new RuntimeException("Cannot rename KDC's krb5conf to "
              + krb5conf.getAbsolutePath());
    }
  }

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

  public static final String ORG_NAME = "org.name";
  public static final String ORG_DOMAIN = "org.domain";
  public static final String KDC_BIND_ADDRESS = "kdc.bind.address";
  public static final String KDC_PORT = "kdc.port";
  public static final String INSTANCE = "instance";
  public static final String MAX_TICKET_LIFETIME = "max.ticket.lifetime";
  public static final String MIN_TICKET_LIFETIME = "min.ticket.lifetime";
  public static final String MAX_RENEWABLE_LIFETIME = "max.renewable.lifetime";
  public static final String TRANSPORT = "transport";
  public static final String DEBUG = "debug";

  private static final Set<String> PROPERTIES = new HashSet<String>();
  private static final Properties DEFAULT_CONFIG = new Properties();

  static {
    PROPERTIES.add(ORG_NAME);
    PROPERTIES.add(ORG_DOMAIN);
    PROPERTIES.add(KDC_BIND_ADDRESS);
    PROPERTIES.add(KDC_BIND_ADDRESS);
    PROPERTIES.add(KDC_PORT);
    PROPERTIES.add(INSTANCE);
    PROPERTIES.add(TRANSPORT);
    PROPERTIES.add(MAX_TICKET_LIFETIME);
    PROPERTIES.add(MAX_RENEWABLE_LIFETIME);

    DEFAULT_CONFIG.setProperty(KDC_BIND_ADDRESS, "localhost");
    DEFAULT_CONFIG.setProperty(KDC_PORT, "0");
    DEFAULT_CONFIG.setProperty(INSTANCE, "DefaultKrbServer");
    DEFAULT_CONFIG.setProperty(ORG_NAME, "EXAMPLE");
    DEFAULT_CONFIG.setProperty(ORG_DOMAIN, "COM");
    DEFAULT_CONFIG.setProperty(TRANSPORT, "TCP");
    DEFAULT_CONFIG.setProperty(MAX_TICKET_LIFETIME, "86400000");
    DEFAULT_CONFIG.setProperty(MAX_RENEWABLE_LIFETIME, "604800000");
    DEFAULT_CONFIG.setProperty(DEBUG, "false");
  }

  /**
   * Convenience method that returns MiniKdc default configuration.
   * <p>
   * The returned configuration is a copy, it can be customized before using
   * it to create a MiniKdc.
   * @return a MiniKdc default configuration.
   */
  public static Properties createConf() {
    return (Properties) DEFAULT_CONFIG.clone();
  }

  private Properties conf;
  private SimpleKdcServer simpleKdc;
  private int port;
  private String realm;
  private File workDir;
  private File krb5conf;
  private String transport;
  private boolean krb5Debug;

  public void setTransport(String transport) {
    this.transport = transport;
  }
  /**
   * Creates a MiniKdc.
   *
   * @param conf MiniKdc configuration.
   * @param workDir working directory, it should be the build directory. Under
   * this directory an ApacheDS working directory will be created, this
   * directory will be deleted when the MiniKdc stops.
   * @throws Exception thrown if the MiniKdc could not be created.
   */
  public MiniKdc(Properties conf, File workDir) throws Exception {
    if (!conf.keySet().containsAll(PROPERTIES)) {
      Set<String> missingProperties = new HashSet<String>(PROPERTIES);
      missingProperties.removeAll(conf.keySet());
      throw new IllegalArgumentException("Missing configuration properties: "
              + missingProperties);
    }
    this.workDir = new File(workDir, Long.toString(System.currentTimeMillis()));
    if (!this.workDir.exists()
            && !this.workDir.mkdirs()) {
      throw new RuntimeException("Cannot create directory " + this.workDir);
    }
    LOG.info("Configuration:");
    LOG.info("---------------------------------------------------------------");
    for (Map.Entry<?, ?> entry : conf.entrySet()) {
      LOG.info("  {}: {}", entry.getKey(), entry.getValue());
    }
    LOG.info("---------------------------------------------------------------");
    this.conf = conf;
    port = Integer.parseInt(conf.getProperty(KDC_PORT));
    String orgName= conf.getProperty(ORG_NAME);
    String orgDomain = conf.getProperty(ORG_DOMAIN);
    realm = orgName.toUpperCase(Locale.ENGLISH) + "."
            + orgDomain.toUpperCase(Locale.ENGLISH);
  }

  /**
   * Returns the port of the MiniKdc.
   *
   * @return the port of the MiniKdc.
   */
  public int getPort() {
    return port;
  }

  /**
   * Returns the host of the MiniKdc.
   *
   * @return the host of the MiniKdc.
   */
  public String getHost() {
    return conf.getProperty(KDC_BIND_ADDRESS);
  }

  /**
   * Returns the realm of the MiniKdc.
   *
   * @return the realm of the MiniKdc.
   */
  public String getRealm() {
    return realm;
  }

  public File getKrb5conf() {
    krb5conf = new File(System.getProperty(JAVA_SECURITY_KRB5_CONF));
    return krb5conf;
  }

  /**
   * Starts the MiniKdc.
   *
   * @throws Exception thrown if the MiniKdc could not be started.
   */
  public synchronized void start() throws Exception {
    if (simpleKdc != null) {
      throw new RuntimeException("Already started");
    }
    simpleKdc = new SimpleKdcServer();
    prepareKdcServer();
    simpleKdc.init();
    resetDefaultRealm();
    simpleKdc.start();
    LOG.info("MiniKdc started.");
  }

  private void resetDefaultRealm() throws IOException {
    InputStream templateResource = new FileInputStream(
            getKrb5conf().getAbsolutePath());
    String content = IOUtil.readInput(templateResource);
    content = content.replaceAll("default_realm = .*\n",
            "default_realm = " + getRealm() + "\n");
    IOUtil.writeFile(content, getKrb5conf());
  }

  private void prepareKdcServer() throws Exception {
    // transport
    simpleKdc.setWorkDir(workDir);
    simpleKdc.setKdcHost(getHost());
    simpleKdc.setKdcRealm(realm);
    if (transport == null) {
      transport = conf.getProperty(TRANSPORT);
    }
    if (port == 0) {
      port = NetworkUtil.getServerPort();
    }
    if (transport != null) {
      if (transport.trim().equals("TCP")) {
        simpleKdc.setKdcTcpPort(port);
        simpleKdc.setAllowUdp(false);
      } else if (transport.trim().equals("UDP")) {
        simpleKdc.setKdcUdpPort(port);
        simpleKdc.setAllowTcp(false);
      } else {
        throw new IllegalArgumentException("Invalid transport: " + transport);
      }
    } else {
      throw new IllegalArgumentException("Need to set transport!");
    }
    simpleKdc.getKdcConfig().setString(KdcConfigKey.KDC_SERVICE_NAME,
            conf.getProperty(INSTANCE));
    if (conf.getProperty(DEBUG) != null) {
      krb5Debug = getAndSet(SUN_SECURITY_KRB5_DEBUG, conf.getProperty(DEBUG));
    }
    if (conf.getProperty(MIN_TICKET_LIFETIME) != null) {
      simpleKdc.getKdcConfig().setLong(KdcConfigKey.MINIMUM_TICKET_LIFETIME,
          Long.parseLong(conf.getProperty(MIN_TICKET_LIFETIME)));
    }
    if (conf.getProperty(MAX_TICKET_LIFETIME) != null) {
      simpleKdc.getKdcConfig().setLong(KdcConfigKey.MAXIMUM_TICKET_LIFETIME,
          Long.parseLong(conf.getProperty(MiniKdc.MAX_TICKET_LIFETIME)));
    }
  }

  /**
   * Stops the MiniKdc
   */
  public synchronized void stop() {
    if (simpleKdc != null) {
      try {
        simpleKdc.stop();
      } catch (KrbException e) {
        e.printStackTrace();
      } finally {
        if(conf.getProperty(DEBUG) != null) {
          System.setProperty(SUN_SECURITY_KRB5_DEBUG,
                  Boolean.toString(krb5Debug));
        }
      }
    }
    delete(workDir);
    try {
      // Will be fixed in next Kerby version.
      Thread.sleep(1000);
    } catch (InterruptedException e) {
      e.printStackTrace();
    }
    LOG.info("MiniKdc stopped.");
  }

  private void delete(File f) {
    if (f.isFile()) {
      if (! f.delete()) {
        LOG.warn("WARNING: cannot delete file " + f.getAbsolutePath());
      }
    } else {
      File[] fileList = f.listFiles();
      if (fileList != null) {
        for (File c : fileList) {
          delete(c);
        }
      }
      if (! f.delete()) {
        LOG.warn("WARNING: cannot delete directory " + f.getAbsolutePath());
      }
    }
  }

  /**
   * Creates a principal in the KDC with the specified user and password.
   *
   * @param principal principal name, do not include the domain.
   * @param password password.
   * @throws Exception thrown if the principal could not be created.
   */
  public synchronized void createPrincipal(String principal, String password)
          throws Exception {
    simpleKdc.createPrincipal(principal, password);
  }

  /**
   * Creates multiple principals in the KDC and adds them to a keytab file.
   *
   * @param keytabFile keytab file to add the created principals.
   * @param principals principals to add to the KDC, do not include the domain.
   * @throws Exception thrown if the principals or the keytab file could not be
   * created.
   */
  public synchronized void createPrincipal(File keytabFile,
                                           String ... principals)
          throws Exception {
    simpleKdc.createPrincipals(principals);
    if (keytabFile.exists() && !keytabFile.delete()) {
      LOG.error("Failed to delete keytab file: " + keytabFile);
    }
    for (String principal : principals) {
      simpleKdc.getKadmin().exportKeytab(keytabFile, principal);
    }
  }

  /**
   * Set the System property; return the old value for caching.
   *
   * @param sysprop property
   * @param debug true or false
   * @return the previous value
   */
  private boolean getAndSet(String sysprop, String debug) {
    boolean old = Boolean.getBoolean(sysprop);
    System.setProperty(sysprop, debug);
    return old;
  }
}