Skip to content

Commit aec5e35

Browse files
committed
wg-checker: Add periodic gateway refresh every 3 minutes
Previously only refreshed when WireGuard handshake was stale. Now also performs periodic refresh to pick up gateway configuration changes.
1 parent 8443c5f commit aec5e35

3 files changed

Lines changed: 120 additions & 54 deletions

File tree

basefiles/wg-checker.service

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ Type=simple
88
ExecStart=/bin/wg-checker.sh
99
Restart=always
1010
RestartSec=10
11-
StandardOutput=journal+console
11+
StandardOutput=journal
1212
StandardError=journal+console
1313

1414
[Install]

basefiles/wg-checker.sh

Lines changed: 24 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
# SPDX-License-Identifier: Apache-2.0
66

77
HANDSHAKE_TIMEOUT=180
8+
REFRESH_INTERVAL=180
89
LAST_REFRESH=0
910
STALE_SINCE=0
1011
DSTACK_WORK_DIR=${DSTACK_WORK_DIR:-/dstack}
@@ -14,61 +15,69 @@ get_latest_handshake() {
1415
wg show $IFNAME latest-handshakes 2>/dev/null | awk 'BEGIN { max = 0 } NF >= 2 { if ($2 > max) max = $2 } END { print max }'
1516
}
1617

17-
maybe_refresh() {
18+
do_refresh() {
1819
now=$1
19-
20-
if [ "$LAST_REFRESH" -ne 0 ] && [ $((now - LAST_REFRESH)) -lt $HANDSHAKE_TIMEOUT ]; then
21-
return
22-
fi
20+
reason=$2
21+
force=$3
2322

2423
if ! command -v dstack-util >/dev/null 2>&1; then
2524
printf 'dstack-util not found; cannot refresh gateway.\n' >&2
2625
LAST_REFRESH=$now
2726
return
2827
fi
2928

30-
printf 'WireGuard handshake stale; refreshing dstack gateway...\n'
31-
if dstack-util gateway-refresh --work-dir "$DSTACK_WORK_DIR"; then
29+
printf '%s; refreshing dstack gateway...\n' "$reason"
30+
if [ "$force" = "1" ]; then
31+
cmd="dstack-util gateway-refresh --work-dir $DSTACK_WORK_DIR --force"
32+
else
33+
cmd="dstack-util gateway-refresh --work-dir $DSTACK_WORK_DIR"
34+
fi
35+
if $cmd; then
3236
printf 'dstack gateway refresh succeeded.\n'
3337
else
3438
printf 'dstack gateway refresh failed.\n' >&2
3539
fi
3640

3741
LAST_REFRESH=$now
38-
STALE_SINCE=$now
42+
STALE_SINCE=0
3943
}
4044

41-
check_handshake() {
45+
check_and_refresh() {
4246
if ! command -v wg >/dev/null 2>&1; then
4347
return
4448
fi
4549

4650
now=$(date +%s)
47-
latest=$(get_latest_handshake)
4851

52+
# Periodic refresh every REFRESH_INTERVAL seconds (not forced)
53+
if [ "$LAST_REFRESH" -eq 0 ] || [ $((now - LAST_REFRESH)) -ge $REFRESH_INTERVAL ]; then
54+
do_refresh "$now" "Periodic refresh" 0
55+
return
56+
fi
57+
58+
# Check handshake staleness (forced refresh)
59+
latest=$(get_latest_handshake)
4960
if [ -z "$latest" ]; then
5061
latest=0
5162
fi
5263

5364
if [ "$latest" -gt 0 ]; then
5465
if [ $((now - latest)) -ge $HANDSHAKE_TIMEOUT ]; then
55-
maybe_refresh "$now"
56-
else
57-
STALE_SINCE=0
66+
do_refresh "$now" "WireGuard handshake stale" 1 >&2
5867
fi
5968
else
6069
if [ "$STALE_SINCE" -eq 0 ]; then
6170
STALE_SINCE=$now
6271
fi
6372
if [ $((now - STALE_SINCE)) -ge $HANDSHAKE_TIMEOUT ]; then
64-
maybe_refresh "$now"
73+
do_refresh "$now" "WireGuard handshake stale" 1 >&2
6574
fi
6675
fi
6776
}
6877

6978
while true; do
7079
if [ -f /etc/wireguard/$IFNAME.conf ]; then
71-
check_handshake
80+
check_and_refresh
7281
else
7382
STALE_SINCE=0
7483
fi

dstack-util/src/system_setup.rs

Lines changed: 95 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,9 @@ pub struct GatewayRefreshArgs {
7575
/// dstack work directory
7676
#[arg(long)]
7777
work_dir: PathBuf,
78+
/// Force reconfiguration even if config unchanged
79+
#[arg(long)]
80+
force: bool,
7881
}
7982

8083
#[derive(Deserialize, Serialize, Clone, Default)]
@@ -328,37 +331,44 @@ impl HostShared {
328331
}
329332
}
330333

331-
const GATEWAY_CERT_CACHE_PATH: &str = "/run/dstack/gateway-client-cert-cache.json";
334+
const GATEWAY_CACHE_PATH: &str = "/run/dstack/gateway-cache.json";
335+
const WG_CONFIG_PATH: &str = "/etc/wireguard/dstack-wg0.conf";
332336
/// Certificate validity period in seconds (10 days)
333337
const CERT_VALIDITY_SECS: u64 = 10 * 24 * 3600;
334338

335339
#[derive(Serialize, Deserialize)]
336-
struct CachedClientCert {
337-
cert: String,
338-
key: String,
340+
struct GatewayCache {
341+
/// Client certificate chain
342+
client_cert: String,
343+
/// Client private key
344+
client_key: String,
339345
/// Certificate expiry time as seconds since UNIX epoch
340-
not_after: u64,
346+
cert_not_after: u64,
347+
/// WireGuard private key
348+
wg_sk: String,
349+
/// WireGuard public key
350+
wg_pk: String,
341351
}
342352

343-
impl CachedClientCert {
353+
impl GatewayCache {
344354
fn load() -> Option<Self> {
345-
let content = fs::read_to_string(GATEWAY_CERT_CACHE_PATH).ok()?;
355+
let content = fs::read_to_string(GATEWAY_CACHE_PATH).ok()?;
346356
serde_json::from_str(&content).ok()
347357
}
348358

349359
fn save(&self) -> Result<()> {
350-
let content = serde_json::to_string(self).context("Failed to serialize cert cache")?;
351-
safe_write(GATEWAY_CERT_CACHE_PATH, &content).context("Failed to write cert cache")?;
360+
let content = serde_json::to_string(self).context("Failed to serialize gateway cache")?;
361+
safe_write(GATEWAY_CACHE_PATH, &content).context("Failed to write gateway cache")?;
352362
Ok(())
353363
}
354364

355-
fn is_valid(&self) -> bool {
365+
fn is_cert_valid(&self) -> bool {
356366
let now = std::time::SystemTime::now()
357367
.duration_since(std::time::UNIX_EPOCH)
358368
.map(|d| d.as_secs())
359369
.unwrap_or(0);
360370
// Valid if at least 10 minutes remaining
361-
now + 600 < self.not_after
371+
now + 600 < self.cert_not_after
362372
}
363373
}
364374

@@ -406,10 +416,25 @@ impl<'a> GatewayContext<'a> {
406416
.context("Failed to register CVM")
407417
}
408418

409-
async fn get_or_request_client_cert(&self) -> Result<(String, String)> {
410-
if let Some(cached) = CachedClientCert::load().filter(|c| c.is_valid()) {
419+
fn get_or_generate_wg_keys(cache: Option<&GatewayCache>) -> Result<(String, String)> {
420+
if let Some(cache) = cache {
421+
info!("Using cached WireGuard keys");
422+
return Ok((cache.wg_sk.clone(), cache.wg_pk.clone()));
423+
}
424+
425+
info!("Generating new WireGuard keys");
426+
let sk = cmd!(wg genkey)?;
427+
let pk = cmd!(echo $sk | wg pubkey).or(Err(anyhow!("Failed to generate public key")))?;
428+
Ok((sk, pk))
429+
}
430+
431+
async fn get_or_request_client_cert(
432+
&self,
433+
cache: Option<&GatewayCache>,
434+
) -> Result<(String, String)> {
435+
if let Some(cache) = cache.filter(|c| c.is_cert_valid()) {
411436
info!("Using cached client certificate");
412-
return Ok((cached.cert, cached.key));
437+
return Ok((cache.client_cert.clone(), cache.client_key.clone()));
413438
}
414439

415440
info!("Requesting new client certificate");
@@ -424,7 +449,8 @@ impl<'a> GatewayContext<'a> {
424449
subject_alt_names: vec![],
425450
usage_server_auth: false,
426451
usage_client_auth: true,
427-
ext_quote: false,
452+
// TODO: Disable this once pre-0.5.6 gateways are deprecated
453+
ext_quote: true,
428454
ext_app_info: true,
429455
not_before: None,
430456
not_after: Some(not_after),
@@ -445,20 +471,10 @@ impl<'a> GatewayContext<'a> {
445471
let cert = certs.join("\n");
446472
let key = key.serialize_pem();
447473

448-
// Cache the certificate for future use
449-
let cached = CachedClientCert {
450-
cert: cert.clone(),
451-
key: key.clone(),
452-
not_after,
453-
};
454-
if let Err(e) = cached.save() {
455-
warn!("Failed to cache client certificate: {e:?}");
456-
}
457-
458474
Ok((cert, key))
459475
}
460476

461-
async fn setup(&self) -> Result<()> {
477+
async fn setup(&self, force: bool) -> Result<()> {
462478
if !self.shared.app_compose.gateway_enabled() {
463479
info!("dstack-gateway is not enabled");
464480
return Ok(());
@@ -468,11 +484,28 @@ impl<'a> GatewayContext<'a> {
468484
}
469485

470486
info!("Setting up dstack-gateway");
471-
// Generate WireGuard keys
472-
let sk = cmd!(wg genkey)?;
473-
let pk = cmd!(echo $sk | wg pubkey).or(Err(anyhow!("Failed to generate public key")))?;
474487

475-
let (client_cert, client_key) = self.get_or_request_client_cert().await?;
488+
// Load cached data if available
489+
let cache = GatewayCache::load();
490+
491+
// Get or generate WireGuard keys
492+
let (wg_sk, wg_pk) = Self::get_or_generate_wg_keys(cache.as_ref())?;
493+
494+
// Get or request client certificate
495+
let (client_cert, client_key) = self.get_or_request_client_cert(cache.as_ref()).await?;
496+
497+
// Calculate cert expiry for cache
498+
let cert_not_after = cache
499+
.as_ref()
500+
.filter(|c| c.is_cert_valid())
501+
.map(|c| c.cert_not_after)
502+
.unwrap_or_else(|| {
503+
let now = std::time::SystemTime::now()
504+
.duration_since(std::time::UNIX_EPOCH)
505+
.map(|d| d.as_secs())
506+
.unwrap_or(0);
507+
now + CERT_VALIDITY_SECS
508+
});
476509

477510
if self.shared.sys_config.gateway_urls.is_empty() {
478511
bail!("Missing gateway urls");
@@ -482,7 +515,7 @@ impl<'a> GatewayContext<'a> {
482515
let mut error = anyhow!("unknown error");
483516
for (i, url) in self.shared.sys_config.gateway_urls.iter().enumerate() {
484517
let response = self
485-
.register_cvm(url, client_key.clone(), client_cert.clone(), pk.clone())
518+
.register_cvm(url, client_key.clone(), client_cert.clone(), wg_pk.clone())
486519
.await;
487520
match response {
488521
Ok(response) => {
@@ -498,21 +531,24 @@ impl<'a> GatewayContext<'a> {
498531
}
499532
return Err(error).context("Failed to register CVM, all dstack-gateway urls are down");
500533
};
501-
let wg_info = response.wg.context("Missing wg info")?;
534+
let mut wg_info = response.wg.context("Missing wg info")?;
502535

503536
let client_ip = &wg_info.client_ip;
504537

538+
// Sort peers by public key for consistent config generation
539+
wg_info.servers.sort_by(|a, b| a.pk.cmp(&b.pk));
540+
505541
// Create WireGuard config
506542
let wg_listen_port = "9182";
507-
let mut config = format!(
543+
let mut new_config = format!(
508544
"[Interface]\n\
509-
PrivateKey = {sk}\n\
545+
PrivateKey = {wg_sk}\n\
510546
ListenPort = {wg_listen_port}\n\
511547
Address = {client_ip}/32\n\n"
512548
);
513549
for WireGuardPeer { pk, ip, endpoint } in &wg_info.servers {
514550
let ip = ip.split('/').next().unwrap_or_default();
515-
config.push_str(&format!(
551+
new_config.push_str(&format!(
516552
"[Peer]\n\
517553
PublicKey = {pk}\n\
518554
AllowedIPs = {ip}/32\n\
@@ -521,9 +557,30 @@ impl<'a> GatewayContext<'a> {
521557
));
522558
}
523559

560+
// Save cache
561+
let new_cache = GatewayCache {
562+
client_cert,
563+
client_key,
564+
cert_not_after,
565+
wg_sk,
566+
wg_pk,
567+
};
568+
if let Err(e) = new_cache.save() {
569+
warn!("Failed to save gateway cache: {e:?}");
570+
}
571+
572+
// Check if config has changed (skip check if force is set)
573+
if !force {
574+
let current_config = fs::read_to_string(WG_CONFIG_PATH).ok();
575+
if current_config.as_ref() == Some(&new_config) {
576+
info!("WireGuard config unchanged, skipping reconfiguration");
577+
return Ok(());
578+
}
579+
}
580+
524581
let wg_dir = Path::new("/etc/wireguard");
525582
fs::create_dir_all(wg_dir)?;
526-
fs::write(wg_dir.join("dstack-wg0.conf"), config)?;
583+
fs::write(wg_dir.join("dstack-wg0.conf"), &new_config)?;
527584

528585
cmd! {
529586
chmod 600 $wg_dir/dstack-wg0.conf;
@@ -612,7 +669,7 @@ pub async fn cmd_gateway_refresh(args: GatewayRefreshArgs) -> Result<()> {
612669
let keys: AppKeys = deserialize_json_file(&keys_path)
613670
.with_context(|| format!("Failed to load app keys from {}", keys_path.display()))?;
614671

615-
GatewayContext::new(&shared, &keys).setup().await
672+
GatewayContext::new(&shared, &keys).setup(args.force).await
616673
}
617674

618675
struct AppIdValidator {
@@ -1352,7 +1409,7 @@ impl Stage1<'_> {
13521409
.notify_q("boot.progress", "setting up dstack-gateway")
13531410
.await;
13541411
GatewayContext::new(&self.shared, &self.keys)
1355-
.setup()
1412+
.setup(true)
13561413
.await?;
13571414
self.vmm
13581415
.notify_q("boot.progress", "setting up docker")

0 commit comments

Comments
 (0)