Skip to content

Commit 8e9d7cc

Browse files
authored
Add support for specifying client TLS key/cert in json-based config (#41)
* Add support for specifying client TLS key/cert in json-based config Includes updates to EtcdClient/EtcdClusterConfig to ensure that cached/shared client instances are appropriately reference counted. * Fix PersistentLeaseKeyTest timing flake
1 parent caff22d commit 8e9d7cc

10 files changed

Lines changed: 508 additions & 54 deletions

File tree

etcd-json-schema.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,18 @@ Example JSON config doc:
99
"password": "password",
1010
"root_prefix": "aka-chroot-or-namespace",
1111
"certificate_file": "etcd-dev.pem",
12-
"override_authority": "etcd-development-01"
12+
"override_authority": "etcd-development-01",
13+
"client_key_file": "etcd-client-key.key",
14+
"client_certificate_file": "etcd-client-cert.crt"
1315
}
1416
```
1517

1618
- All attributes apart from `endpoints` are optional.
1719
- The `root_prefix` attribute currently has **no effect** on clients created via `EtcdClientConfig.getClient()`. It's included in the configuration for use by application code (to query via `EtcdClientConfig.getRootPrefix()`). In future full chroot-like functionality at the client level might be supported.
1820
- `certificate_file` is the name of a pem-format (public) cert to use for TLS server-auth, either an absolute path or a filename assumed to be in the same directory as the json config file itself.
1921
- A `certificate` attribute may be included _instead of_ `certificate_file`, whose value is an embedded string UTF-8 pem format certificate. This allows a single json doc to hold all of the necessary connection info.
20-
- The `override_authority` is optional and may be used to override the authority used for TLS hostname verification for _all_ endpoints.
22+
- `client_key_file` and `client_certificate_file` form an optional key/cert pair for TLS client-auth. Either may also be embedded in a similar way by instead including `client_key` and/or `client_certificate` string attributes.
23+
- The `override_authority` attribute is optional and may be used to override the authority used for TLS hostname verification for _all_ endpoints.
2124

2225
Example with embedded (trunctated) TLS cert:
2326

src/main/java/com/ibm/etcd/client/EtcdClient.java

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,9 @@
8989
import io.netty.channel.socket.nio.NioSocketChannel;
9090
import io.netty.handler.ssl.SslContext;
9191
import io.netty.handler.ssl.SslContextBuilder;
92+
import io.netty.util.AbstractReferenceCounted;
93+
import io.netty.util.IllegalReferenceCountException;
94+
import io.netty.util.ReferenceCounted;
9295
import io.netty.util.concurrent.FastThreadLocalThread;
9396

9497
public class EtcdClient implements KvStoreClient {
@@ -111,6 +114,8 @@ public class EtcdClient implements KvStoreClient {
111114
protected static final Pattern ADDR_PATT =
112115
Pattern.compile("(?:https?://|dns:///)?([a-zA-Z0-9\\-.]+)(?::(\\d+))?");
113116

117+
private final ReferenceCounted refCount; // may be null
118+
114119
private final int sessionTimeoutSecs;
115120

116121
private final ByteString name, password;
@@ -140,6 +145,7 @@ public static class Builder {
140145
private Executor executor; // for call-backs
141146
private boolean sendViaEventLoop = true; // default true
142147
private int sessTimeoutSecs = DEFAULT_SESSION_TIMEOUT_SECS;
148+
private boolean refCounted;
143149

144150
Builder(List<String> endpoints) {
145151
this.endpoints = Preconditions.checkNotNull(endpoints);
@@ -337,8 +343,8 @@ public EtcdClient build() {
337343
if (maxInboundMessageSize != 0) {
338344
ncb.maxInboundMessageSize(maxInboundMessageSize);
339345
}
340-
return new EtcdClient(ncb, defaultTimeoutMs, name, password,
341-
preemptAuth, threads, executor, sendViaEventLoop, sessTimeoutSecs);
346+
return new EtcdClient(ncb, defaultTimeoutMs, name, password, preemptAuth,
347+
threads, executor, sendViaEventLoop, sessTimeoutSecs, refCounted);
342348
}
343349
}
344350

@@ -391,7 +397,7 @@ public static Builder forEndpoints(String endpoints) {
391397
EtcdClient(NettyChannelBuilder chanBuilder, long defaultTimeoutMs,
392398
ByteString name, ByteString password, boolean initialAuth,
393399
int threads, Executor userExecutor, boolean sendViaEventLoop,
394-
int sessTimeoutSecs) {
400+
int sessTimeoutSecs, boolean refCounted) {
395401

396402
if (name == null && password != null) {
397403
throw new IllegalArgumentException("password without name");
@@ -400,6 +406,17 @@ public static Builder forEndpoints(String endpoints) {
400406
this.password = password;
401407
this.sessionTimeoutSecs = sessTimeoutSecs;
402408

409+
this.refCount = !refCounted ? null : new AbstractReferenceCounted() {
410+
@Override
411+
public ReferenceCounted touch(Object hint) {
412+
return null;
413+
}
414+
@Override
415+
protected void deallocate() {
416+
doClose();
417+
}
418+
};
419+
403420
chanBuilder.keepAliveTime(10L, SECONDS);
404421
chanBuilder.keepAliveTimeout(8L, SECONDS);
405422

@@ -462,6 +479,22 @@ public CallCredentials refreshCredentials() {
462479

463480
@Override
464481
public void close() {
482+
if (refCount != null) {
483+
refCount.release();
484+
} else {
485+
doClose();
486+
}
487+
}
488+
489+
boolean retain() {
490+
if (refCount != null) try {
491+
refCount.retain();
492+
return true;
493+
} catch (IllegalReferenceCountException irce) {}
494+
return false;
495+
}
496+
497+
void doClose() {
465498
if (leaseClient == CLOSED) {
466499
return;
467500
}
@@ -792,4 +825,22 @@ MultithreadEventLoopGroup getInternalExecutor() {
792825
return internalExecutor;
793826
}
794827

828+
// Public for access by EtcdClusterConfig from config package
829+
public static final class Internal {
830+
private Internal() {}
831+
832+
public static boolean retain(EtcdClient client) {
833+
return client != null && client.retain();
834+
}
835+
836+
public static void cleanup(EtcdClient client) {
837+
if (client != null) {
838+
client.doClose();
839+
}
840+
}
841+
842+
public static void makeRefCounted(Builder builder) {
843+
builder.refCounted = true;
844+
}
845+
}
795846
}

src/main/java/com/ibm/etcd/client/config/EtcdClusterConfig.java

Lines changed: 115 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,9 @@ public enum TlsMode { TLS, PLAINTEXT, AUTO }
7373
String composeDeployment;
7474
ByteSource certificate;
7575
String overrideAuthority;
76+
// either both or neither of these must be set
77+
ByteSource clientKey;
78+
ByteSource clientCertificate;
7679

7780
protected EtcdClusterConfig() {}
7881

@@ -93,23 +96,34 @@ private EtcdClient newClient() throws IOException, CertificateException {
9396
EtcdClient.Builder builder = EtcdClient.forEndpoints(endpointList)
9497
.withCredentials(user, password).withImmediateAuth()
9598
.withMaxInboundMessageSize(maxMessageSize);
99+
EtcdClient.Internal.makeRefCounted(builder);
96100
if (overrideAuthority != null) {
97101
builder.overrideAuthority(overrideAuthority);
98102
}
99103
TlsMode ssl = tlsMode;
100104
if (ssl == TlsMode.AUTO || ssl == null) {
101105
String ep = endpointList.get(0);
102-
ssl = ep.startsWith("https://")
103-
|| (!ep.startsWith("http://") && certificate != null)
104-
? TlsMode.TLS : TlsMode.PLAINTEXT;
106+
if (ep.startsWith("http://")) ssl = TlsMode.PLAINTEXT;
107+
else if (certificate != null || clientCertificate != null
108+
|| ep.startsWith("https://")) {
109+
ssl = TlsMode.TLS;
110+
}
105111
}
106112
if (ssl == TlsMode.PLAINTEXT) {
107113
builder.withPlainText();
108-
} else if (composeDeployment != null) {
109-
builder.withTrustManager(new ComposeTrustManagerFactory(composeDeployment,
110-
composeDeployment, certificate));
111-
} else if (certificate != null) {
112-
builder.withCaCert(certificate);
114+
} else {
115+
if (composeDeployment != null) {
116+
builder.withTrustManager(new ComposeTrustManagerFactory(composeDeployment,
117+
composeDeployment, certificate));
118+
} else if (certificate != null) {
119+
builder.withCaCert(certificate);
120+
}
121+
if (clientCertificate != null && clientKey != null) {
122+
try (InputStream keyStream = clientKey.openStream();
123+
InputStream certStream = clientCertificate.openStream()) {
124+
builder.withTlsConfig(b -> b.keyManager(certStream, keyStream));
125+
}
126+
}
113127
}
114128
if (isShutdown) {
115129
throw new IllegalStateException("shutdown");
@@ -141,19 +155,30 @@ public static EtcdClusterConfig fromProperties(ByteSource source) throws IOExcep
141155
config.composeDeployment = props.getProperty("compose_deployment");
142156
config.rootPrefix = bs(props.getProperty("root_prefix")); // a.k.a namespace
143157
String tlsMode = props.getProperty("tls_mode");
144-
if (tlsMode != null) {
158+
if (tlsMode != null) try {
145159
config.tlsMode = TlsMode.valueOf(tlsMode);
160+
} catch (IllegalArgumentException iae) {
161+
throw new IOException("Invalid value "
162+
+ tlsMode + " for etcd tls_mode config property");
146163
}
147-
String certPath = props.getProperty("certificate_file");
148-
if (certPath != null) {
149-
File certFile = new File(certPath);
150-
if (!certFile.exists()) {
151-
throw new IOException("cant find certificate file: " + certPath);
152-
}
153-
config.certificate = Files.asByteSource(certFile);
154-
}
164+
config.clientKey = certFromProperties("client_key", props);
165+
config.clientCertificate = certFromProperties("client_certificate", props);
166+
config.certificate = certFromProperties("certificate_file", props);
155167
config.overrideAuthority = props.getProperty("override_authority");
156-
return config;
168+
return validateConfig(config);
169+
}
170+
171+
private static ByteSource certFromProperties(String certFilePropName,
172+
Properties props) throws IOException {
173+
String certPath = props.getProperty(certFilePropName);
174+
if (certPath == null) {
175+
return null;
176+
}
177+
File certFile = new File(certPath);
178+
if (certFile.exists()) {
179+
return Files.asByteSource(certFile);
180+
}
181+
throw new IOException("cant find certificate file: " + certPath);
157182
}
158183

159184
public static EtcdClusterConfig fromJson(ByteSource source, File dir) throws IOException {
@@ -170,27 +195,53 @@ public static EtcdClusterConfig fromJson(ByteSource source, File dir) throws IOE
170195
config.password = bs(jsonConfig.password);
171196
config.composeDeployment = jsonConfig.composeDeployment;
172197
config.rootPrefix = bs(jsonConfig.rootPrefix);
173-
if (jsonConfig.certificateFile != null) {
174-
File certFile = new File(jsonConfig.certificateFile);
198+
if (jsonConfig.tlsMode != null) try {
199+
config.tlsMode = TlsMode.valueOf(jsonConfig.tlsMode);
200+
} catch (IllegalArgumentException iae) {
201+
throw new IOException("Invalid value "
202+
+ jsonConfig.tlsMode + " for etcd tls_mode config field");
203+
}
204+
config.clientKey = certFromJson(jsonConfig.clientKeyFile, jsonConfig.clientKey, dir);
205+
config.clientCertificate = certFromJson(
206+
jsonConfig.clientCertificateFile, jsonConfig.clientCertificate, dir);
207+
config.certificate = certFromJson(jsonConfig.certificateFile, jsonConfig.certificate, dir);
208+
config.overrideAuthority = jsonConfig.overrideAuthority;
209+
return validateConfig(config);
210+
}
211+
212+
private static ByteSource certFromJson(String certFileName, String literalCert,
213+
File dir) throws IOException {
214+
if (certFileName != null) {
215+
File certFile = new File(certFileName);
175216
if (dir != null && !certFile.exists()) {
176217
// try same dir as the config file
177-
certFile = new File(dir, jsonConfig.certificateFile);
218+
certFile = new File(dir, certFileName);
178219
}
179220
if (certFile.exists()) {
180-
config.certificate = Files.asByteSource(certFile);
181-
} else {
221+
if (literalCert != null) {
222+
logger.warn("Ignoring json-embedded cert because file "
223+
+ certFileName + " was also provided");
224+
}
225+
return Files.asByteSource(certFile);
226+
} else if (literalCert != null) {
182227
// will fall back to embedded if present
183-
logger.warn("Can't find certificate file: " + jsonConfig.certificateFile);
184-
}
185-
}
186-
if (jsonConfig.certificate != null) {
187-
if (config.certificate != null) {
188-
logger.warn("Ignoring json-embedded cert because file was also provided");
228+
logger.warn("Can't find certificate file: " + certFileName);
189229
} else {
190-
config.certificate = ByteSource.wrap(jsonConfig.certificate.getBytes(UTF_8));
230+
throw new IOException("Can't find certificate file: " + certFileName);
191231
}
192232
}
193-
config.overrideAuthority = jsonConfig.overrideAuthority;
233+
return literalCert != null ? ByteSource.wrap(literalCert.getBytes(UTF_8)) : null;
234+
}
235+
236+
private static EtcdClusterConfig validateConfig(EtcdClusterConfig config) throws IOException {
237+
if (config.composeDeployment != null) {
238+
logger.warn("compose_deployment config param is deprecated," +
239+
" use override_authority to set a specific name for TLS SNI");
240+
}
241+
if ((config.clientKey == null) != (config.clientCertificate == null)) {
242+
throw new IOException("Must specify either both or neither of TLS client_key "
243+
+ "and client_certificate attributes");
244+
}
194245
return config;
195246
}
196247

@@ -225,12 +276,26 @@ public static EtcdClusterConfig fromJsonFileOrSimple(String fileOrSimpleString)
225276
throw new FileNotFoundException("etcd config json file not found: " + f);
226277
}
227278

228-
private static final Cache<CacheKey,EtcdClient> clientCache = CacheBuilder.newBuilder().weakValues()
229-
.<CacheKey,EtcdClient>removalListener(rn -> rn.getValue().close()).build();
279+
private static final Cache<CacheKey, EtcdClient> clientCache = CacheBuilder.newBuilder().weakValues()
280+
.<CacheKey, EtcdClient>removalListener(rn -> EtcdClient.Internal.cleanup(rn.getValue())).build();
230281

231282
public static EtcdClient getClient(EtcdClusterConfig config) throws IOException, CertificateException {
232283
try {
233-
return clientCache.get(new CacheKey(config), config::newClient);
284+
// Share equivalent ref-counted client from cache if possible
285+
final CacheKey key = new CacheKey(config);
286+
while (true) {
287+
EtcdClient client = clientCache.getIfPresent(key);
288+
if (client == null) {
289+
EtcdClient[] maybeNew = new EtcdClient[1];
290+
client = clientCache.get(key, () -> maybeNew[0] = config.newClient());
291+
// if client != maybeNew[0] then we got a pre-existing client
292+
if (client != maybeNew[0] && !EtcdClient.Internal.retain(client)) {
293+
clientCache.invalidate(key);
294+
continue;
295+
}
296+
}
297+
return client;
298+
}
234299
} catch (ExecutionException ee) {
235300
Throwables.throwIfInstanceOf(ee.getCause(), IOException.class);
236301
Throwables.throwIfInstanceOf(ee.getCause(), CertificateException.class);
@@ -267,12 +332,16 @@ public boolean equals(Object obj) {
267332
return Objects.equals(config.endpoints, other.endpoints)
268333
&& Objects.equals(config.composeDeployment, other.composeDeployment)
269334
&& Objects.equals(config.user, other.user)
270-
&& Objects.equals(config.tlsMode, other.tlsMode);
335+
&& Objects.equals(config.tlsMode, other.tlsMode)
336+
&& (config.certificate == null) == (other.certificate == null)
337+
&& (config.clientKey == null) == (other.clientKey == null)
338+
&& (config.clientCertificate == null) == (other.clientCertificate == null);
271339
}
272340
@Override
273341
public int hashCode() {
274342
return Objects.hash(config.endpoints, config.user,
275-
config.composeDeployment, config.tlsMode);
343+
config.composeDeployment, config.tlsMode, config.certificate == null,
344+
config.clientKey == null, config.clientCertificate == null);
276345
}
277346
}
278347

@@ -296,11 +365,21 @@ static class JsonConfig {
296365
@Deprecated
297366
@SerializedName("compose_deployment")
298367
String composeDeployment;
368+
@SerializedName("tls_mode")
369+
String tlsMode;
299370
@SerializedName("certificate")
300371
String certificate;
301372
@SerializedName("certificate_file")
302373
String certificateFile;
303374
@SerializedName("override_authority")
304375
String overrideAuthority;
376+
@SerializedName("client_key")
377+
String clientKey;
378+
@SerializedName("client_key_file")
379+
String clientKeyFile;
380+
@SerializedName("client_certificate")
381+
String clientCertificate;
382+
@SerializedName("client_certificate_file")
383+
String clientCertificateFile;
305384
}
306385
}

0 commit comments

Comments
 (0)