kftray-portforward 0.30.0

KFtray library with port forwarding logic
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
use std::future::Future;
use std::pin::Pin;
use std::sync::LazyLock;
use std::time::{
    Duration,
    Instant,
};

use hyper_openssl::client::legacy::HttpsConnector;
use hyper_util::client::legacy::connect::HttpConnector;
use hyper_util::rt::TokioExecutor;
use kube::Client;
use kube::client::ConfigExt;
use kube::config::Config;
use log::{
    error,
    info,
    warn,
};
use openssl::ssl::{
    SslConnector,
    SslMethod,
    SslSessionCacheMode,
    SslVerifyMode,
};
use tower::ServiceBuilder;

use super::config::ConfigExtClone;
use super::error::{
    KubeClientError,
    KubeResult,
};
use super::proxy::{
    create_insecure_with_proxy,
    create_openssl_with_proxy,
    create_rustls_with_proxy,
};

type StrategyFuture<'a> = Pin<Box<dyn Future<Output = KubeResult<Client>> + Send + 'a>>;
type Strategy<'a> = (&'static str, StrategyFuture<'a>);

const POOL_MAX_IDLE_PER_HOST: usize = 5;

/// Idle keep-alive connections are only reaped when the pool has a timer
/// (`pool_timer`, not `timer`); without one they stay open until the client
/// itself is dropped.
const POOL_IDLE_TIMEOUT: Duration = Duration::from_secs(30);

/// Total budget for connecting to and probing the API server. Independent
/// of `Config::read_timeout`, which governs already-established requests
/// (e.g. watches) rather than how long a connection attempt may take.
const CONNECTION_BUDGET: Duration = Duration::from_secs(30);

/// Minimum time reserved for each strategy still to come, so a slow first
/// strategy cannot exhaust the whole budget before later strategies get a
/// chance to run.
const MIN_STRATEGY_SLICE: Duration = Duration::from_secs(5);

static HTTP_CONNECTOR: LazyLock<HttpConnector> = LazyLock::new(|| {
    let mut connector = HttpConnector::new();
    connector.set_nodelay(true);
    connector.enforce_http(false);
    connector
});

pub async fn create_client_with_config(config: &Config) -> KubeResult<Client> {
    let config = config.clone();

    let strategies = if config.accept_invalid_certs {
        info!("Creating insecure connection strategies for skip-tls-verify=true");
        create_insecure_connection_strategies(&config)
    } else {
        create_connection_strategies(&config)
    };

    execute_strategies(strategies, CONNECTION_BUDGET).await
}

/// Runs every strategy against ONE overall deadline instead of giving each
/// strategy its own full timeout: with 3-4 strategies a per-strategy budget
/// would let a hung API server block the caller for 3-4x the intended
/// timeout. Each strategy (connection + version probe) gets whatever budget
/// remains from the overall deadline; a strategy is skipped once the budget
/// is exhausted.
async fn execute_strategies(
    strategies: Vec<Strategy<'_>>, total_budget: Duration,
) -> KubeResult<Client> {
    let mut failed_attempts = Vec::new();
    let mut skipped_attempts = Vec::new();
    let mut last_error: Option<KubeClientError> = None;
    let deadline = Instant::now() + total_budget;
    let strategy_count = strategies.len();

    for (index, (description, strategy)) in strategies.into_iter().enumerate() {
        let remaining = deadline.saturating_duration_since(Instant::now());
        if remaining.is_zero() {
            warn!("Strategy '{description}' skipped: overall connection budget exhausted");
            skipped_attempts.push(description.to_string());
            last_error.get_or_insert_with(|| {
                KubeClientError::connection_error(
                    "Timed out connecting to the Kubernetes API server",
                )
            });
            continue;
        }

        let strategies_after = (strategy_count - index - 1) as u32;
        let reserved_for_rest = MIN_STRATEGY_SLICE.saturating_mul(strategies_after);
        let after_reservation = remaining.saturating_sub(reserved_for_rest);
        // With strategies still to come, giving this one the whole remaining
        // budget (instead of skipping it) would exhaust the time reserved
        // for them, letting one slow strategy starve every later one.
        if after_reservation.is_zero() && strategies_after > 0 {
            warn!(
                "Strategy '{description}' skipped: no budget remains after reserving time for \
                 {strategies_after} more strategies"
            );
            skipped_attempts.push(description.to_string());
            last_error.get_or_insert_with(|| {
                KubeClientError::connection_error(
                    "Timed out connecting to the Kubernetes API server",
                )
            });
            continue;
        }
        let strategy_budget = if after_reservation.is_zero() {
            remaining
        } else {
            after_reservation
        };
        let strategy_deadline = Instant::now() + strategy_budget;

        info!("Attempting strategy: {description}");

        let result = match tokio::time::timeout(strategy_budget, strategy).await {
            Ok(Ok(client)) => {
                let probe_remaining = strategy_deadline.saturating_duration_since(Instant::now());
                if probe_remaining.is_zero() {
                    Err(KubeClientError::connection_error(
                        "Timed out connecting to the Kubernetes API server",
                    ))
                } else {
                    test_client_connection(&client, probe_remaining)
                        .await
                        .map(|_| client)
                }
            }
            Ok(Err(e)) => Err(e),
            Err(_) => Err(KubeClientError::connection_error(
                "Timed out connecting to the Kubernetes API server",
            )),
        };

        match result {
            Ok(client) => {
                info!("Successfully connected using: {description}");
                return Ok(client);
            }
            Err(e) => {
                warn!("Strategy '{description}' failed: {e}");
                failed_attempts.push(description.to_string());
                last_error = Some(e);
            }
        }
    }

    log_connection_failure(&failed_attempts, &skipped_attempts, last_error.as_ref());

    Err(last_error
        .unwrap_or_else(|| KubeClientError::connection_error("No connection strategies available")))
}

fn create_connection_strategies(config: &Config) -> Vec<Strategy<'_>> {
    let original = config.clone();
    let without_invalid_certs = config.clone_with_invalid_certs(false);

    vec![
        create_rustls_strategy("Rustls (original settings)", original.clone()),
        create_openssl_strategy(
            "OpenSSL (original settings)",
            original.clone(),
            original.accept_invalid_certs,
        ),
        create_openssl_strategy(
            "OpenSSL (with cert verification)",
            without_invalid_certs,
            false,
        ),
    ]
}

fn create_insecure_connection_strategies(config: &Config) -> Vec<Strategy<'_>> {
    let original = config.clone();
    let with_invalid_certs = config.clone_with_invalid_certs(true);

    vec![
        create_openssl_strategy(
            "OpenSSL (skip cert verification)",
            with_invalid_certs.clone(),
            true,
        ),
        create_openssl_strategy(
            "OpenSSL (original settings)",
            original.clone(),
            original.accept_invalid_certs,
        ),
        create_rustls_strategy("Rustls (original settings)", original),
        create_insecure_strategy("Insecure HTTP", with_invalid_certs),
    ]
}

fn create_rustls_strategy(description: &'static str, config: Config) -> Strategy<'static> {
    (
        description,
        Box::pin(async move { create_rustls_client(config).await }),
    )
}

fn create_openssl_strategy(
    description: &'static str, config: Config, skip_verify: bool,
) -> Strategy<'static> {
    (
        description,
        Box::pin(async move { create_openssl_client(config, skip_verify).await }),
    )
}

fn create_insecure_strategy(description: &'static str, config: Config) -> Strategy<'static> {
    (
        description,
        Box::pin(async move { create_insecure_client(config).await }),
    )
}

async fn create_rustls_client(config: Config) -> KubeResult<Client> {
    if let Some(proxy_url) = config.proxy_url.clone() {
        return create_rustls_with_proxy(config, &proxy_url).await;
    }

    let connector = config.rustls_https_connector().map_err(|e| {
        KubeClientError::connection_error_with_source("Failed to create Rustls connector", e)
    })?;

    let hyper_client = create_hyper_client(connector);
    build_kube_client(config, hyper_client)
}

async fn create_openssl_client(config: Config, skip_verify: bool) -> KubeResult<Client> {
    if let Some(proxy_url) = config.proxy_url.clone() {
        let ssl_builder = config.openssl_ssl_connector_builder().map_err(|e| {
            KubeClientError::connection_error_with_source(
                "Failed to create SSL connector with client certificates",
                e,
            )
        })?;
        let http_connector = create_http_connector();
        return create_openssl_with_proxy(config, ssl_builder, http_connector, &proxy_url).await;
    }

    let ssl_connector = create_ssl_connector(skip_verify)?;
    let http_connector = create_http_connector();

    let https_connector =
        HttpsConnector::with_connector(http_connector, ssl_connector).map_err(|e| {
            KubeClientError::connection_error_with_source("Failed to create HTTPS connector", e)
        })?;

    let hyper_client = create_hyper_client(https_connector);
    build_kube_client(config, hyper_client)
}

async fn create_insecure_client(config: Config) -> KubeResult<Client> {
    let http_connector = create_http_connector();

    if let Some(proxy_url) = config.proxy_url.clone() {
        return create_insecure_with_proxy(config, http_connector, &proxy_url).await;
    }

    let hyper_client = create_hyper_client(http_connector);
    build_kube_client(config, hyper_client)
}

fn create_ssl_connector(skip_verify: bool) -> KubeResult<openssl::ssl::SslConnectorBuilder> {
    let mut builder = SslConnector::builder(SslMethod::tls()).map_err(|e| {
        KubeClientError::connection_error_with_source(
            "Failed to create SSL connector (ensure OpenSSL is properly configured)",
            e,
        )
    })?;

    builder.set_verify(if skip_verify {
        SslVerifyMode::NONE
    } else {
        SslVerifyMode::PEER
    });

    builder.set_session_cache_mode(SslSessionCacheMode::CLIENT);

    Ok(builder)
}

pub fn create_http_connector() -> HttpConnector {
    HTTP_CONNECTOR.clone()
}

pub fn create_hyper_client<C>(
    connector: C,
) -> hyper_util::client::legacy::Client<C, kube::client::Body>
where
    C: hyper_util::client::legacy::connect::Connect + Clone + Send + Sync + 'static,
{
    use hyper_util::rt::TokioTimer;

    hyper_util::client::legacy::Client::builder(TokioExecutor::new())
        .pool_max_idle_per_host(POOL_MAX_IDLE_PER_HOST)
        .pool_idle_timeout(POOL_IDLE_TIMEOUT)
        .retry_canceled_requests(true)
        .timer(TokioTimer::new())
        .pool_timer(TokioTimer::new())
        .build(connector)
}

/// Probes the API server under a deadline. A server that accepts the
/// connection but never answers would otherwise hang every caller that waits
/// on a client, including stop.
async fn test_client_connection(client: &Client, probe_timeout: Duration) -> KubeResult<()> {
    tokio::time::timeout(probe_timeout, client.apiserver_version())
        .await
        .map_err(|_| {
            KubeClientError::connection_error(
                "Timed out waiting for the Kubernetes API server version",
            )
        })?
        .map_err(|e| {
            KubeClientError::connection_error_with_source(
                "Failed to connect to Kubernetes API server",
                e,
            )
        })?;
    Ok(())
}

pub fn build_kube_client<C>(
    config: Config, hyper_client: hyper_util::client::legacy::Client<C, kube::client::Body>,
) -> KubeResult<Client>
where
    C: hyper_util::client::legacy::connect::Connect + Clone + Send + Sync + 'static,
{
    let auth_layer = config.auth_layer().map_err(|e| {
        let error_detail = format!("{e}");
        let error_debug = format!("{e:?}");

        if error_detail.contains("AuthExecStart")
            || error_detail.contains("unable to run auth exec")
        {
            error!(
                "Failed to start auth exec plugin. The command could not be executed. \
                 Error: {error_detail}. Debug: {error_debug}"
            );
        } else if error_detail.contains("AuthExecRun")
            || error_detail.contains("failed with status")
        {
            error!(
                "Auth exec plugin command failed. The plugin ran but returned an error. \
                 Error: {error_detail}. Debug: {error_debug}"
            );
        } else if error_detail.contains("MissingCommand") {
            error!(
                "Auth exec plugin has no command specified in kubeconfig. \
                 Error: {error_detail}"
            );
        } else {
            warn!("Failed to create auth layer: {error_detail}. Debug: {error_debug}");
        }

        KubeClientError::auth_error_with_source("Failed to create auth layer", e)
    })?;

    let service = ServiceBuilder::new()
        .layer(config.base_uri_layer())
        .option_layer(auth_layer)
        .map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { Box::new(e) })
        .service(hyper_client);

    Ok(Client::new(service, config.default_namespace))
}

fn log_connection_failure(
    failed_attempts: &[String], skipped_attempts: &[String], last_error: Option<&KubeClientError>,
) {
    if failed_attempts.is_empty() && skipped_attempts.is_empty() {
        error!("No connection strategies available");
        return;
    }

    let mut summary = Vec::new();
    if !failed_attempts.is_empty() {
        summary.push(format!(
            "{} failed: {}",
            failed_attempts.len(),
            failed_attempts.join(", ")
        ));
    }
    if !skipped_attempts.is_empty() {
        summary.push(format!(
            "{} skipped (budget exhausted before they ran): {}",
            skipped_attempts.len(),
            skipped_attempts.join(", ")
        ));
    }
    let summary = summary.join("; ");

    match last_error {
        Some(err) => error!("All connection strategies failed. Last error: {err}. {summary}"),
        None => error!("All connection strategies failed. {summary}"),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_connection_strategies_count() {
        let config = Config::new("https://example.com".parse().unwrap());
        let strategies = create_connection_strategies(&config);
        assert_eq!(strategies.len(), 3);

        let mut insecure_config = Config::new("https://example.com".parse().unwrap());
        insecure_config.accept_invalid_certs = true;
        let insecure_strategies = create_insecure_connection_strategies(&insecure_config);
        assert_eq!(insecure_strategies.len(), 4);
    }

    #[test]
    fn test_http_connector_configuration() {
        let connector = create_http_connector();
        drop(connector);
    }

    #[test]
    fn test_ssl_connector_verify_modes() {
        let connector_verify = create_ssl_connector(false);
        assert!(connector_verify.is_ok());

        let connector_no_verify = create_ssl_connector(true);
        assert!(connector_no_verify.is_ok());
    }

    #[test]
    fn test_proxy_url_parsing() {
        let mut config = Config::new("https://example.com".parse().unwrap());
        config.proxy_url = Some("http://proxy.example.com:8080".parse().unwrap());

        assert!(config.proxy_url.is_some());
        let proxy_url = config.proxy_url.as_ref().unwrap();
        assert_eq!(proxy_url.scheme_str(), Some("http"));
        assert_eq!(proxy_url.host(), Some("proxy.example.com"));
        assert_eq!(proxy_url.port_u16(), Some(8080));
    }

    #[test]
    fn test_socks5_proxy_url() {
        let mut config = Config::new("https://example.com".parse().unwrap());
        config.proxy_url = Some("socks5://localhost:1080".parse().unwrap());

        assert!(config.proxy_url.is_some());
        let proxy_url = config.proxy_url.as_ref().unwrap();
        assert_eq!(proxy_url.scheme_str(), Some("socks5"));
        assert_eq!(proxy_url.host(), Some("localhost"));
        assert_eq!(proxy_url.port_u16(), Some(1080));
    }

    #[test]
    fn test_error_creation() {
        let error = KubeClientError::connection_error("Test connection error");
        assert!(matches!(error, KubeClientError::ConnectionError { .. }));
        assert_eq!(error.to_string(), "Connection error: Test connection error");
    }

    #[tokio::test]
    async fn execute_strategies_shares_one_budget_across_strategies() {
        fn slow_strategy(description: &'static str) -> Strategy<'static> {
            (
                description,
                Box::pin(async move {
                    tokio::time::sleep(Duration::from_millis(200)).await;
                    Err(KubeClientError::connection_error("unreachable"))
                }),
            )
        }

        let strategies = vec![
            slow_strategy("slow-1"),
            slow_strategy("slow-2"),
            slow_strategy("slow-3"),
        ];

        let start = Instant::now();
        let result = execute_strategies(strategies, Duration::from_millis(100)).await;
        let elapsed = start.elapsed();

        assert!(result.is_err(), "every strategy exceeds the shared budget");
        assert!(
            elapsed < Duration::from_millis(400),
            "elapsed {elapsed:?} implies each strategy got its own timeout instead of \
             sharing one overall budget (3 strategies x 200ms would be >= 600ms)"
        );
    }

    #[tokio::test]
    async fn execute_strategies_reserves_slice_for_later_strategies() {
        fn timeout_prone_strategy(description: &'static str, sleep_ms: u64) -> Strategy<'static> {
            (
                description,
                Box::pin(async move {
                    tokio::time::sleep(Duration::from_millis(sleep_ms)).await;
                    Err(KubeClientError::connection_error("unreachable"))
                }),
            )
        }

        // total_budget (5100ms) - MIN_STRATEGY_SLICE (5000ms) reserved for the
        // one remaining strategy leaves the first strategy only ~100ms. A
        // slow first strategy must be cut off there instead of eating into
        // the second strategy's reserved slice.
        let strategies = vec![
            timeout_prone_strategy("slow-first", 250),
            timeout_prone_strategy("fast-second", 1),
        ];

        let start = Instant::now();
        let result = execute_strategies(strategies, Duration::from_millis(5100)).await;
        let elapsed = start.elapsed();

        assert!(result.is_err(), "both strategies fail or time out");
        assert!(
            elapsed < Duration::from_millis(400),
            "elapsed {elapsed:?} implies the first strategy was not capped at its reserved \
             slice (~100ms); the previous `.max(remaining.min(MIN_STRATEGY_SLICE))` clamp undid \
             the reservation and let the first strategy run its full 250ms sleep instead of \
             timing out at ~100ms, leaving less than the intended slice for the second strategy"
        );
    }

    #[tokio::test]
    async fn execute_strategies_skips_a_strategy_with_no_budget_left_after_reservation() {
        fn timeout_prone_strategy(description: &'static str, sleep_ms: u64) -> Strategy<'static> {
            (
                description,
                Box::pin(async move {
                    tokio::time::sleep(Duration::from_millis(sleep_ms)).await;
                    Err(KubeClientError::connection_error("unreachable"))
                }),
            )
        }

        // After the first strategy's 300ms, ~4900ms remain: less than the
        // 5000ms `MIN_STRATEGY_SLICE` reserved for the still-pending third
        // strategy, so `after_reservation` is zero for the second strategy
        // while a strategy still follows it. The second strategy sleeps
        // 4000ms so a regression that gives it the whole remaining budget
        // instead of skipping it shows up as several extra seconds of
        // elapsed time rather than the near-instant skip this asserts.
        let strategies = vec![
            timeout_prone_strategy("first", 300),
            timeout_prone_strategy("would-exhaust-the-reservation", 4000),
            timeout_prone_strategy("third", 0),
        ];

        let start = Instant::now();
        let result = execute_strategies(strategies, Duration::from_millis(5200)).await;
        let elapsed = start.elapsed();

        assert!(result.is_err(), "every strategy fails or is skipped");
        assert!(
            elapsed < Duration::from_millis(1000),
            "elapsed {elapsed:?} implies the second strategy ran with the whole remaining \
             budget (~4900ms) instead of being skipped once no budget was left after \
             reserving time for the third strategy"
        );
    }
}