Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/sdk.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ jobs:
# This additional target is needed for wasm32 compatibility check.
targets: wasm32-unknown-unknown, thumbv6m-none-eabi

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'

- name: SDK tests
run: cd sdk && ./run-tests.sh

Expand Down
31 changes: 21 additions & 10 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ rsa = "0.9"
ring = "0.17.14"
rustls = "0.23.23"
rustls-pki-types = "1.13.1"
rustls-webpki = "0.103.10"
rustls-webpki = "0.103.16"
schnorrkel = "0.11.4"
sha2 = { version = "0.10.8", default-features = false }
sha3 = "0.10.8"
Expand Down
1 change: 1 addition & 0 deletions dstack-util/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ ez-hash.workspace = true
cc-eventlog.workspace = true
safe-write.workspace = true
errify.workspace = true
base64.workspace = true

[dev-dependencies]
rand.workspace = true
82 changes: 75 additions & 7 deletions dstack-util/src/system_setup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use std::{
collections::{BTreeMap, BTreeSet},
fmt::Display,
net::{Ipv4Addr, Ipv6Addr},
ops::Deref,
path::{Path, PathBuf},
process::Command,
Expand Down Expand Up @@ -370,6 +371,43 @@ impl HostShared {
}
}

// The gateway's WireGuard fields are rendered into a wg-quick config, and
// wg-quick runs its `PreUp`/`PostUp` hooks as root. Accept only values that
// cannot carry config syntax, so the gateway cannot inject lines.
fn parse_wg_ip(ip: &str) -> Result<Ipv4Addr> {
ip.parse()
.with_context(|| format!("invalid WireGuard address: {ip:?}"))
}

fn parse_wg_public_key(key: &str) -> Result<String> {
use base64::{engine::general_purpose::STANDARD, Engine as _};
let decoded = STANDARD
.decode(key)
.ok()
.filter(|decoded| decoded.len() == 32)
.with_context(|| format!("invalid WireGuard public key: {key:?}"))?;
Ok(STANDARD.encode(decoded))
}

fn validate_wg_endpoint(endpoint: &str) -> Result<&str> {
let valid = endpoint.rsplit_once(':').is_some_and(|(host, port)| {
let host_ok = match host.strip_prefix('[').and_then(|h| h.strip_suffix(']')) {
Some(v6) => v6.parse::<Ipv6Addr>().is_ok(),
None => {
!host.is_empty()
&& host
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-')
}
};
host_ok && port.parse::<u16>().is_ok()
});
if !valid {
bail!("invalid WireGuard endpoint: {endpoint:?}");
}
Ok(endpoint)
}

const GATEWAY_CACHE_PATH: &str = "/run/dstack/gateway-cache.json";
const WG_CONFIG_PATH: &str = "/etc/wireguard/dstack-wg0.conf";
/// Certificate validity period in seconds (10 days)
Expand Down Expand Up @@ -628,7 +666,7 @@ impl<'a> GatewayContext<'a> {
};
let mut wg_info = response.wg.context("Missing wg info")?;

let client_ip = &wg_info.client_ip;
let client_ip = parse_wg_ip(&wg_info.client_ip)?;

// Sort peers by public key for consistent config generation
wg_info.servers.sort_by(|a, b| a.pk.cmp(&b.pk));
Expand All @@ -643,7 +681,9 @@ impl<'a> GatewayContext<'a> {
key_store.wg_sk
);
for WireGuardPeer { pk, ip, endpoint } in &wg_info.servers {
let ip = ip.split('/').next().unwrap_or_default();
let pk = parse_wg_public_key(pk)?;
let ip = parse_wg_ip(ip.split('/').next().unwrap_or_default())?;
let endpoint = validate_wg_endpoint(endpoint)?;
new_config.push_str(&format!(
"[Peer]\n\
PublicKey = {pk}\n\
Expand Down Expand Up @@ -1449,12 +1489,11 @@ impl Stage1<'_> {
ciphertext: &[u8],
allowed: &BTreeSet<String>,
) -> Result<BTreeMap<String, String>> {
let vars = if !key.is_empty() && !ciphertext.is_empty() {
let vars = if !ciphertext.is_empty() {
info!("Processing encrypted env");
let env_crypt_key: [u8; 32] = key
.try_into()
.ok()
.context("Invalid env crypt key length")?;
let env_crypt_key: [u8; 32] = key.try_into().ok().context(
"encrypted env is present but the key provider gave no valid env crypt key",
)?;
let decrypted_json =
dh_decrypt(env_crypt_key, ciphertext).context("Failed to decrypt env file")?;
crate::parse_env_file::parse_env(&decrypted_json, allowed)?
Expand Down Expand Up @@ -1872,3 +1911,32 @@ fn test_validate_luks2_header_rejects_keyslot_area_that_overflows() {
"{error:#}"
);
}

#[test]
fn wireguard_fields_reject_config_syntax() {
for endpoint in [
"gateway.example.com:51820",
"192.0.2.1:51821",
"[2001:db8::1]:51822",
] {
assert_eq!(validate_wg_endpoint(endpoint).unwrap(), endpoint);
}
for endpoint in [
"",
"192.0.2.1",
"192.0.2.1:99999",
"0.0.0.0/0:1",
"192.0.2.1:51820\n[Interface]\nPostUp = id",
"[not-v6]:51820",
] {
assert!(validate_wg_endpoint(endpoint).is_err(), "{endpoint:?}");
}

let key = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
assert_eq!(parse_wg_public_key(key).unwrap(), key);
assert!(parse_wg_public_key(&format!("{key}\nPostUp = id")).is_err());
assert!(parse_wg_public_key("AAAA").is_err());

assert!(parse_wg_ip("10.0.0.2").is_ok());
assert!(parse_wg_ip("10.0.0.2/32\n[Interface]").is_err());
}
19 changes: 16 additions & 3 deletions guest-agent/src/http_routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -203,9 +203,9 @@ mod docker_logs {

let seconds = match unit {
"s" => value,
"m" => value * 60,
"h" => value * 3600,
"d" => value * 24 * 3600,
"m" => value.saturating_mul(60),
"h" => value.saturating_mul(3600),
"d" => value.saturating_mul(24 * 3600),
_ => {
anyhow::bail!("Invalid time unit. Use s, m, h, or d");
}
Expand Down Expand Up @@ -358,6 +358,19 @@ mod docker_logs {
);
}

#[test]
fn an_oversized_duration_reaches_back_to_the_epoch() {
let now = SystemTime::now();
for unit in ["s", "m", "h", "d"] {
let duration = format!("{}{unit}", u64::MAX);
assert_eq!(
parse_duration_inner(&duration, now).unwrap(),
0,
"{duration}"
);
}
}

#[test]
fn test_parse_duration_errors() {
// Invalid unit
Expand Down
7 changes: 6 additions & 1 deletion ra-rpc/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,14 @@ serde.workspace = true
x509-parser.workspace = true
prost-types = { workspace = true, optional = true }
or-panic.workspace = true
rustls = { workspace = true, optional = true }

[dev-dependencies]
tokio-rustls.workspace = true
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "net", "io-util"] }

[features]
default = ["rocket", "client"]
rocket = ["dep:rocket", "dep:rocket-vsock-listener"]
client = ["reqwest"]
client = ["reqwest", "dep:rustls"]
openapi = ["dep:prost-types"]
Loading
Loading