@@ -20,13 +20,21 @@ const MAX_BACKOFF: Duration = Duration::from_secs(60);
2020/// Initial backoff delay.
2121const INITIAL_BACKOFF : Duration = Duration :: from_secs ( 1 ) ;
2222
23+ #[ derive( Clone , Debug , Default , Deserialize ) ]
24+ pub struct EtcdTlsConfig {
25+ pub ca_pem : Option < String > ,
26+ pub cert_pem : Option < String > ,
27+ pub key_pem : Option < String > ,
28+ }
29+
2330#[ derive( Clone , Debug , Deserialize ) ]
2431pub struct Config {
2532 pub host : Vec < String > ,
2633 pub prefix : String ,
2734 pub timeout : u32 ,
2835 pub user : Option < String > ,
2936 pub password : Option < String > ,
37+ pub tls : Option < EtcdTlsConfig > ,
3038}
3139
3240impl Default for Config {
@@ -37,6 +45,7 @@ impl Default for Config {
3745 timeout : 5 ,
3846 user : None ,
3947 password : None ,
48+ tls : None ,
4049 }
4150 }
4251}
@@ -89,6 +98,64 @@ impl EtcdConfigProvider {
8998 opts = opts. with_user ( user, password) ;
9099 }
91100
101+ // Enable TLS when any host uses the https:// scheme.
102+ //
103+ // We use the openssl-tls backend (not rustls) because the control-plane
104+ // etcd certificate may have a misconfigured SAN (e.g. only an empty DNS
105+ // name instead of the server IP). With OpenSSL we can skip hostname
106+ // verification while still validating the certificate chain against the
107+ // supplied CA, which is equivalent to `curl --cacert` without `-k`.
108+ let use_tls = config. host . iter ( ) . any ( |h| h. starts_with ( "https://" ) ) ;
109+ if use_tls {
110+ use openssl:: ssl:: SslVerifyMode ;
111+
112+ let mut ssl_cfg = etcd_client:: OpenSslClientConfig :: default ( ) ;
113+
114+ if let Some ( tls_cfg) = & config. tls {
115+ if let Some ( ca_pem) = & tls_cfg. ca_pem {
116+ // ca_cert_pem() only loads the first certificate in a PEM
117+ // bundle. Use stack_from_pem so that multi-cert bundles
118+ // (e.g. intermediate + root) are all added to the store.
119+ let ca_bytes = ca_pem. as_bytes ( ) . to_vec ( ) ;
120+ ssl_cfg = ssl_cfg. manually ( move |b| {
121+ for cert in openssl:: x509:: X509 :: stack_from_pem ( & ca_bytes) ? {
122+ b. cert_store_mut ( ) . add_cert ( cert) ?;
123+ }
124+ Ok ( ( ) )
125+ } ) ;
126+ }
127+
128+ if let ( Some ( cert_pem) , Some ( key_pem) ) =
129+ ( & tls_cfg. cert_pem , & tls_cfg. key_pem )
130+ {
131+ ssl_cfg =
132+ ssl_cfg. client_cert_pem_and_key ( cert_pem. as_bytes ( ) , key_pem. as_bytes ( ) ) ;
133+ }
134+ }
135+
136+ // Skip hostname/IP verification: the CP certificate SAN may not match
137+ // the endpoint IP/hostname. We still validate the certificate chain
138+ // (preverify_ok covers chain errors). Error code 64 is
139+ // X509_V_ERR_IP_ADDRESS_MISMATCH; we allow it explicitly so that
140+ // IP-addressed endpoints with a non-matching SAN still connect.
141+ ssl_cfg = ssl_cfg. manually ( |b| {
142+ b. set_verify_callback ( SslVerifyMode :: PEER , |preverify_ok, ctx| {
143+ if preverify_ok {
144+ return true ;
145+ }
146+ // Allow IP address mismatch and hostname mismatch errors.
147+ matches ! (
148+ ctx. error( ) . as_raw( ) ,
149+ 64 // X509_V_ERR_IP_ADDRESS_MISMATCH
150+ | 62 // X509_V_ERR_HOSTNAME_MISMATCH
151+ )
152+ } ) ;
153+ Ok ( ( ) )
154+ } ) ;
155+
156+ opts = opts. with_openssl_tls ( ssl_cfg) ;
157+ }
158+
92159 let mut client = etcd_client:: Client :: connect (
93160 config
94161 . host
@@ -99,7 +166,10 @@ impl EtcdConfigProvider {
99166 )
100167 . await ?;
101168
102- client. status ( ) . await . map ( |_| Ok ( client) ) ?
169+ // Use a KV.Get as the connectivity probe instead of Maintenance.Status.
170+ // The API7 dp-manager only implements KV/Watch/Lease; calling Status
171+ // returns HTTP 404 which tonic misparses as a gRPC framing error.
172+ client. get ( "__probe__" , None ) . await . map ( |_| Ok ( client) ) ?
103173 }
104174
105175 /// Spawn the long-running supervisor task that manages the watch stream
0 commit comments