/**
* Copyright 2016 StreamSets Inc.
*
* Licensed under 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 com.streamsets.datacollector.vault;
import com.google.common.base.Splitter;
import com.google.common.hash.HashFunction;
import com.google.common.hash.Hasher;
import com.google.common.hash.Hashing;
import com.streamsets.datacollector.util.Configuration;
import com.streamsets.datacollector.vault.api.VaultException;
import com.streamsets.pipeline.api.impl.Utils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.net.InetAddress;
import java.net.NetworkInterface;
import java.util.ArrayList;
import java.util.Enumeration;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
public class Vault {
private static final Logger LOG = LoggerFactory.getLogger(Vault.class);
private static final HashFunction HASH_FUNCTION = Hashing.sha256();
private static final ConcurrentMap<String, Secret> SECRETS = new ConcurrentHashMap<>();
private static final ConcurrentMap<String, Long> LEASES = new ConcurrentHashMap<>();
private static final ScheduledExecutorService EXECUTOR = Executors.newSingleThreadScheduledExecutor();
private static final String VAULT_ADDR = "vault.addr";
private static final Splitter mapSplitter = Splitter.on('/').trimResults().omitEmptyStrings();
private String appId;
private VaultConfiguration config;
private long leaseExpirationBuffer;
private long authExpirationTime;
private long renewalInterval;
public Vault(Configuration sdcProperties) {
if (!sdcProperties.hasName(VAULT_ADDR) || sdcProperties.get(VAULT_ADDR, "").isEmpty()) {
// Vault is disabled.
return;
}
// Initial config that doesn't contain auth token
config = parseVaultConfigs(sdcProperties);
LOG.debug("Scheduling renewal every '{}' seconds.", renewalInterval);
EXECUTOR.scheduleWithFixedDelay(
new VaultRenewalTask(LEASES, SECRETS),
renewalInterval,
renewalInterval,
TimeUnit.SECONDS
);
}
private class VaultRenewalTask implements Runnable {
private final ConcurrentMap<String, Long> leases;
private final ConcurrentMap<String, Secret> secrets;
VaultRenewalTask(ConcurrentMap<String, Long> leases, ConcurrentMap<String, Secret> secrets) {
this.leases = leases;
this.secrets = secrets;
}
@Override
public void run() {
try {
// Remove any expired leases (non-renewable)
purgeExpiredLeases();
// Attempt to renew remaining leases
// Remove any leases that failed to renew successfully.
purgeFailedRenewals(renewLeases());
} catch (Throwable t) { // NOSONAR
LOG.error("Error in lease renewer: {}", t.toString(), t);
}
LOG.debug("Completed lease renewal.");
}
private void purgeFailedRenewals(List<String> failedRenewalLeases) {
// Remove any leases that failed to renew
for (String lease : failedRenewalLeases) {
LOG.debug("Removing lease '{}' as expired.", lease);
leases.remove(lease);
}
}
private List<String> renewLeases() {
List<String> failedRenewalLeases = new ArrayList<>();
for (Map.Entry<String, Long> lease : leases.entrySet()) {
LOG.debug("Attempting renewal for leaseId '{}'", lease.getKey());
if (!renewLease(secrets, lease.getKey())) {
failedRenewalLeases.add(lease.getKey());
}
}
return failedRenewalLeases;
}
private boolean renewLease(Map<String, Secret> secrets, String leaseId) {
VaultClient vault = new VaultClient(config);
try {
Secret renewal = vault.sys().lease().renew(leaseId);
LOG.debug("Renewed lease '{}' for '{}' seconds", renewal.getLeaseId(), renewal.getLeaseDuration());
leases.put(renewal.getLeaseId(), System.currentTimeMillis() + (renewal.getLeaseDuration() * 1000));
} catch (VaultException | RuntimeException e) {
// We catch IllegalStateException to make sure this lease is removed because there seems to be a bug in
// Vault that returns a 204 instead of a Secret when trying to renew STS credentials
// SDC doesn't support STS today, so it is also unlikely for someone to hit this error.
LOG.error("Failed to renew lease for '{}'", leaseId, e);
secrets.remove(getPath(leaseId));
return false;
}
return true;
}
/**
* Since we have no way to inform a running pipeline of new credentials, it is likely
* that the pipeline will simply fail at some point and will have to be restarted
* either manually or via the built-in retry feature.
*
* This means that as long as we simply evict the expired LEASES they should be
* renewed automatically when they are requested.
*/
private void purgeExpiredLeases() {
List<String> expiredLeases = new ArrayList<>(leases.size());
for (Map.Entry<String, Long> lease : leases.entrySet()) {
if (lease.getValue() - System.currentTimeMillis() <= leaseExpirationBuffer) {
expiredLeases.add(lease.getKey());
secrets.remove(getPath(lease.getKey()));
}
}
for (String lease : expiredLeases) {
leases.remove(lease);
LOG.debug("Removing lease '{}' as expired", lease);
}
}
/**
* Returns the path portion of the specified leaseId
*
* @param leaseId a leaseId
* @return path portion of leaseId
*/
private String getPath(String leaseId) {
return leaseId.substring(0, leaseId.lastIndexOf('/') - 1);
}
}
/**
* The user-id portion of Vault's app-auth should be machine specific and at least
* somewhat obfuscated to make it more difficult to derive. We use the sha256 hash of
* the MAC address of the first interface found by InetAddress.getLocalHost().
*
* This provides a way for administrators to compute the value itself during/after
* deployment and authorize the app-id, user-id pair out of band.
*
* @return String representation of the user-id portion of an auth token.
*/
static String calculateUserId() {
try {
// Try to hash based on default interface
InetAddress ip = InetAddress.getLocalHost();
NetworkInterface netIf = NetworkInterface.getByInetAddress(ip);
byte[] mac = netIf.getHardwareAddress();
if (mac == null) {
// In some cases the default interface may be a tap/tun device which has no MAC
// instead pick the first available interface.
Enumeration<NetworkInterface> netIfs = NetworkInterface.getNetworkInterfaces();
while (netIfs.hasMoreElements() && mac == null) {
netIf = netIfs.nextElement();
mac = netIf.getHardwareAddress();
}
}
if (mac == null) {
throw new IllegalStateException("Could not find network interface with MAC address.");
}
Hasher hasher = HASH_FUNCTION.newHasher(6); // MAC is 6 bytes.
return hasher.putBytes(mac).hash().toString();
} catch (IOException e) {
LOG.error("Could not compute Vault user-id: '{}'", e.toString(), e);
throw new VaultRuntimeException("Could not compute Vault user-id: " + e.toString());
}
}
/**
* Returns the current auth token.
* @return vault token
*/
public String token() {
return getConfig().getToken();
}
/**
* Reads a secret from the local cache if it hasn't expired and returns the value for the specified key.
* If the secret isn't cached or has expired, it requests it from Vault again.
*
* @param path path in Vault to read
* @param key key of the property of the secret represented by the path to return
* @return value of the specified key for the requested secret.
*/
public String read(String path, String key) {
return read(path, key, 0L);
}
/**
* Reads a secret from the local cache if it hasn't expired and returns the value for the specified key.
* If the secret isn't cached or has expired, it requests it from Vault again.
*
* This version of the method will also add the specified delay in milliseconds before returning, but only
* if the value wasn't already locally cached. This is because certain backends such as AWS will return
* a secret (access keys for example) before they have propagated to all AWS services. For AWS a delay of up to 5
* or 10 seconds may be necessary. If you receive a 403 from AWS services you probably need to increase the delay.
*
* @param path path in Vault to read
* @param key key of the property of the secret represented by the path to return
* @param delay delay in milliseconds to wait before returning the value if it wasn't already cached.
* @return value of the specified key for the requested secret.
*/
public String read(String path, String key, long delay) {
if (!SECRETS.containsKey(path)) {
VaultClient vault = new VaultClient(getConfig());
Secret secret;
try {
secret = vault.logical().read(path);
} catch (VaultException e) {
LOG.error(e.toString(), e);
throw new VaultRuntimeException(e.toString());
}
// Record the expiration date of this lease
String leaseId;
if (secret.isRenewable()) {
// Only renewable secrets seem to have a leaseId
leaseId = secret.getLeaseId();
} else {
// So for non-renewable secret's we'll store the path with an extra / so that we can purge them correctly.
leaseId = path + "/";
}
LEASES.put(leaseId, System.currentTimeMillis() + (secret.getLeaseDuration() * 1000));
SECRETS.put(path, secret);
try {
Thread.sleep(delay);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
Map<String, Object> data = SECRETS.get(path).getData();
String value = getSecretValue(data, key).orElseThrow(() -> new VaultRuntimeException("Value not found for key"));
LOG.trace("Retrieved value for key '{}'", key);
return value;
}
private Optional<String> getSecretValue(Object base, String key) {
Object data = base;
for (String part : mapSplitter.split(key)) {
if (data instanceof Map) {
data = ((Map) data).get(part);
} else {
throw new IllegalStateException(Utils.format("Unsupported data element type '{}'", data.getClass().getName()));
}
}
if (!(data instanceof String)) {
return Optional.empty();
} else {
return Optional.of(data.toString());
}
}
private VaultConfiguration getConfig() {
if (config == null) {
throw new VaultRuntimeException("Vault has not been configured for this Data Collector.");
}
if (authExpirationTime - System.currentTimeMillis() <= 1000) {
VaultClient vault = new VaultClient(config);
Secret auth;
try {
auth = vault.authenticate().appId(appId, calculateUserId());
} catch (VaultException e) {
LOG.error(e.toString(), e);
throw new VaultRuntimeException(e.toString());
}
authExpirationTime = System.currentTimeMillis() + (auth.getAuth().getLeaseDuration() * 1000);
config = VaultConfigurationBuilder.newVaultConfiguration()
.fromVaultConfiguration(config)
.withToken(auth.getAuth().getClientToken())
.build();
}
return config;
}
private VaultConfiguration parseVaultConfigs(Configuration sdcProperties) {
leaseExpirationBuffer = Long.parseLong(sdcProperties.get("vault.lease.expiration.buffer.sec", "120"));
renewalInterval = Long.parseLong(sdcProperties.get("vault.lease.renewal.interval.sec", "60"));
appId = sdcProperties.get("vault.app.id", "");
if (appId.isEmpty()) {
throw new VaultRuntimeException("vault.app.id must be specified in sdc.properties");
}
config = VaultConfigurationBuilder.newVaultConfiguration()
.withAddress(sdcProperties.get("vault.addr", VaultConfigurationBuilder.DEFAULT_ADDRESS))
.withOpenTimeout(Integer.parseInt(sdcProperties.get("vault.open.timeout", "0")))
.withProxyOptions(
ProxyOptionsBuilder.newProxyOptions()
.withProxyAddress(sdcProperties.get("vault.proxy.address", ""))
.withProxyPort(Integer.parseInt(sdcProperties.get("vault.proxy.port", "8080")))
.withProxyUsername(sdcProperties.get("vault.proxy.username", ""))
.withProxyPassword(sdcProperties.get("vault.proxy.password", ""))
.build()
)
.withReadTimeout(Integer.parseInt(sdcProperties.get("vault.read.timeout", "0")))
.withSslOptions(SslOptionsBuilder
.newSslOptions()
.withEnabledProtocols(
sdcProperties.get("vault.ssl.enabled.protocols", SslOptionsBuilder.DEFAULT_PROTOCOLS)
)
.withTrustStoreFile(sdcProperties.get("vault.ssl.truststore.file", ""))
.withTrustStorePassword(sdcProperties.get("vault.ssl.truststore.password", ""))
.withSslVerify(Boolean.parseBoolean(sdcProperties.get("vault.ssl.verify", "true")))
.withSslTimeout(Integer.parseInt(sdcProperties.get("vault.ssl.timeout", "0")))
.build()
)
.withTimeout(Integer.parseInt(sdcProperties.get("vault.timeout", "0")))
.build();
return config;
}
}