Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
/// The Docker engine creates this file at the root of every container.
|
||||
const DOCKER_ENV_FILE: &str = "/.dockerenv";
|
||||
|
||||
/// Detect whether we are running inside a Docker container.
|
||||
///
|
||||
/// The Docker runtime places a `/.dockerenv` marker file in the root filesystem of
|
||||
/// every container it creates. This is the standard heuristic used to detect Docker.
|
||||
pub fn is_in_docker() -> bool {
|
||||
std::fs::exists(DOCKER_ENV_FILE).unwrap_or(false)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::{IsolationPlatformError, WorkloadToken};
|
||||
|
||||
/// Issue a Docker sandbox workload token.
|
||||
/// Docker sandbox tokens do not have an expiration time.
|
||||
pub async fn issue_workload_token(
|
||||
_duration: Option<Duration>,
|
||||
) -> Result<WorkloadToken, IsolationPlatformError> {
|
||||
crate::read_generic_workload_token()
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/// Detect whether we are running inside a Kubernetes pod.
|
||||
///
|
||||
/// The kubelet unconditionally injects `KUBERNETES_SERVICE_HOST` into every pod.
|
||||
pub fn is_in_kubernetes() -> bool {
|
||||
std::env::var("KUBERNETES_SERVICE_HOST").is_ok_and(|v| !v.is_empty())
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
use std::{io, process::ExitStatus, sync::OnceLock, time::Duration};
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::Serialize;
|
||||
use warp_core::channel::{Channel, ChannelState};
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
mod docker;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
mod docker_sandbox;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
mod kubernetes;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
mod namespace;
|
||||
|
||||
/// Environment variable set by the server to identify the isolation platform.
|
||||
/// The value should match one of the `IsolationPlatformType` variants in snake_case.
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
const WARP_ISOLATION_PLATFORM_ENV: &str = "WARP_ISOLATION_PLATFORM";
|
||||
|
||||
/// Environment variable containing the generic Warp-managed workload token that we use
|
||||
/// for isolation platforms that don't issue their own tokens.
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
const WARP_WORKLOAD_TOKEN_ENV: &str = "WARP_WORKLOAD_TOKEN";
|
||||
|
||||
/// A kind of isolation platform. For our usage, isolation platforms are different ways where Warp
|
||||
/// can be sandboxed, such as VMs, containers, or cloud hosts. This may also include weaker forms
|
||||
/// of sandboxing such as Git worktrees.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum IsolationPlatformType {
|
||||
/// Warp is running within a Docker container. Note that this does *not* mean this is a Warp-hosted
|
||||
/// Docker Sandboxes environment. Instead, it's likely a self-hosted agent.
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
Docker,
|
||||
/// Warp is running within a Docker Sandbox, likely as a Warp-hosted agent.
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
DockerSandbox,
|
||||
/// Warp is running within a Kubernetes pod, likely as a self-hosted agent.
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
Kubernetes,
|
||||
/// Warp is running within a Namespace instance, likely as a Warp-hosted agent.
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
Namespace,
|
||||
}
|
||||
|
||||
/// A workload identity token issued by the isolation platform.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WorkloadToken {
|
||||
/// The token string.
|
||||
pub token: String,
|
||||
/// The expiration time of the token. On some platforms, workload tokens do not expire.
|
||||
pub expires_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// Detect the current isolation platform, if any.
|
||||
///
|
||||
/// Results are memoized for the lifetime of the process.
|
||||
pub fn detect() -> Option<IsolationPlatformType> {
|
||||
static DETECTED_PLATFORM: OnceLock<Option<IsolationPlatformType>> = OnceLock::new();
|
||||
|
||||
*DETECTED_PLATFORM.get_or_init(|| {
|
||||
// This never applies to integration tests.
|
||||
if ChannelState::channel() == Channel::Integration {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Use a closure so we can early-return.
|
||||
#[allow(clippy::redundant_closure_call)]
|
||||
let platform = (|| {
|
||||
// If the server explicitly told us which platform we're on, trust it.
|
||||
// This takes priority over all heuristic-based detection.
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
if let Some(platform) = platform_from_env() {
|
||||
return Some(platform);
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
if namespace::is_in_namespace_instance() {
|
||||
return Some(IsolationPlatformType::Namespace);
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
if kubernetes::is_in_kubernetes() {
|
||||
return Some(IsolationPlatformType::Kubernetes);
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
if docker::is_in_docker() {
|
||||
return Some(IsolationPlatformType::Docker);
|
||||
}
|
||||
|
||||
None
|
||||
})();
|
||||
|
||||
match platform {
|
||||
Some(platform) => {
|
||||
log::debug!("Detected isolation platform: {:?}", platform);
|
||||
}
|
||||
None => {
|
||||
log::info!("No isolation platform detected");
|
||||
}
|
||||
}
|
||||
|
||||
platform
|
||||
})
|
||||
}
|
||||
|
||||
/// Issue a workload identity token for the current isolation platform.
|
||||
///
|
||||
/// This will fail if no isolation platform is detected and no platform-agnostic workload token
|
||||
/// is available.
|
||||
#[cfg_attr(target_family = "wasm", allow(unused_variables))]
|
||||
pub async fn issue_workload_token(
|
||||
duration: Option<Duration>,
|
||||
) -> Result<WorkloadToken, IsolationPlatformError> {
|
||||
match detect() {
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
Some(IsolationPlatformType::DockerSandbox) => {
|
||||
docker_sandbox::issue_workload_token(duration).await
|
||||
}
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
Some(IsolationPlatformType::Namespace) => namespace::issue_workload_token(duration).await,
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
// Check for a platform-agnostic workload token if there's no
|
||||
// isolation platform or if the detected platform doesn't have
|
||||
// its own workload token mechanism.
|
||||
_ => read_generic_workload_token()
|
||||
.inspect_err(|err| log::debug!("No platform-agnostic workload token: {err}"))
|
||||
.map_err(|_| IsolationPlatformError::NoIsolationPlatformDetected),
|
||||
#[cfg(target_family = "wasm")]
|
||||
_ => Err(IsolationPlatformError::NoIsolationPlatformDetected),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a platform-agnostic workload token from the `WARP_WORKLOAD_TOKEN` environment variable.
|
||||
/// Returns a `WorkloadToken` with no expiration, or an error if the variable is missing/empty.
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
fn read_generic_workload_token() -> Result<WorkloadToken, IsolationPlatformError> {
|
||||
let token = std::env::var(WARP_WORKLOAD_TOKEN_ENV)
|
||||
.map_err(|_| IsolationPlatformError::GenericWorkloadTokenMissing)?;
|
||||
if token.is_empty() {
|
||||
return Err(IsolationPlatformError::GenericWorkloadTokenMissing);
|
||||
}
|
||||
Ok(WorkloadToken {
|
||||
token,
|
||||
expires_at: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse the `WARP_ISOLATION_PLATFORM` environment variable into a platform type.
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
fn platform_from_env() -> Option<IsolationPlatformType> {
|
||||
let value = std::env::var(WARP_ISOLATION_PLATFORM_ENV).ok()?;
|
||||
match value.as_str() {
|
||||
"docker" => Some(IsolationPlatformType::Docker),
|
||||
"docker_sandbox" => Some(IsolationPlatformType::DockerSandbox),
|
||||
"kubernetes" => Some(IsolationPlatformType::Kubernetes),
|
||||
"namespace" => Some(IsolationPlatformType::Namespace),
|
||||
other => {
|
||||
log::warn!("Unknown {WARP_ISOLATION_PLATFORM_ENV} value: {other}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum IsolationPlatformError {
|
||||
#[error("No isolation platform detected")]
|
||||
NoIsolationPlatformDetected,
|
||||
|
||||
#[error("Workload token is missing or empty")]
|
||||
GenericWorkloadTokenMissing,
|
||||
|
||||
#[error("Required command {command} is unavailable")]
|
||||
CommandUnavailable {
|
||||
command: String,
|
||||
#[source]
|
||||
source: io::Error,
|
||||
},
|
||||
|
||||
#[error("Command `{command}` exited with non-zero status: {status}")]
|
||||
CommandFailed { command: String, status: ExitStatus },
|
||||
|
||||
#[error(transparent)]
|
||||
Other(#[from] anyhow::Error),
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
use std::{env, fs, time::Duration};
|
||||
|
||||
use base64::prelude::{BASE64_URL_SAFE_NO_PAD, Engine as _};
|
||||
use chrono::{DateTime, Utc};
|
||||
use command::r#async::Command;
|
||||
use warp_core::channel::ChannelState;
|
||||
|
||||
use crate::{IsolationPlatformError, WorkloadToken};
|
||||
|
||||
/// Detect whether or not we are running in a Namespace instance.
|
||||
pub fn is_in_namespace_instance() -> bool {
|
||||
// For Namespace, match their CLI's logic for detecting a token:
|
||||
// https://github.com/namespacelabs/integrations/blob/08d0acd17ce05f8486ec8da329066dd6a12572a0/auth/token.go#L116-L131
|
||||
env::var("NSC_TOKEN_FILE").is_ok() || fs::exists("/var/run/nsc/token.json").is_ok_and(|v| v)
|
||||
}
|
||||
|
||||
/// Issue a Namespace workload identity token.
|
||||
pub async fn issue_workload_token(
|
||||
duration: Option<Duration>,
|
||||
) -> Result<WorkloadToken, IsolationPlatformError> {
|
||||
let mut nsc_command = Command::new("nsc");
|
||||
nsc_command
|
||||
.arg("auth")
|
||||
.arg("issue-id-token")
|
||||
.arg("--audience")
|
||||
.arg(&*ChannelState::workload_audience_url())
|
||||
.arg("--output")
|
||||
.arg("json");
|
||||
|
||||
if let Some(duration) = duration {
|
||||
nsc_command
|
||||
.arg("--duration")
|
||||
.arg(format!("{}ns", duration.as_nanos()));
|
||||
}
|
||||
|
||||
let output =
|
||||
nsc_command
|
||||
.output()
|
||||
.await
|
||||
.map_err(|err| IsolationPlatformError::CommandUnavailable {
|
||||
command: "nsc".to_owned(),
|
||||
source: err,
|
||||
})?;
|
||||
|
||||
if !output.status.success() {
|
||||
log::warn!(
|
||||
"`nsc` command failed with status {}: {}",
|
||||
output.status,
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
return Err(IsolationPlatformError::CommandFailed {
|
||||
command: "nsc".to_owned(),
|
||||
status: output.status,
|
||||
});
|
||||
}
|
||||
|
||||
/// JSON output from `nsc auth issue-id-token`.
|
||||
#[derive(serde::Deserialize)]
|
||||
struct NscTokenOutput {
|
||||
id_token: String,
|
||||
}
|
||||
|
||||
let token_output = serde_json::from_slice::<NscTokenOutput>(&output.stdout)
|
||||
.map_err(|_| anyhow::anyhow!("Unexpected output from `nsc auth issue-id-token`"))?;
|
||||
|
||||
// Namespace ID tokens are JWTs.
|
||||
let expires_at = parse_jwt_expiration(&token_output.id_token)?;
|
||||
|
||||
Ok(WorkloadToken {
|
||||
token: token_output.id_token,
|
||||
expires_at: Some(expires_at),
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse the expiration time from a JWT token.
|
||||
///
|
||||
/// JWTs have three base64url-encoded parts separated by dots: header.payload.signature.
|
||||
/// The payload contains an `exp` claim with the Unix timestamp of expiration.
|
||||
fn parse_jwt_expiration(token: &str) -> Result<DateTime<Utc>, IsolationPlatformError> {
|
||||
let parts: Vec<&str> = token.split('.').collect();
|
||||
if parts.len() != 3 {
|
||||
return Err(
|
||||
anyhow::anyhow!("Invalid JWT format: expected 3 parts, got {}", parts.len()).into(),
|
||||
);
|
||||
}
|
||||
|
||||
let payload_bytes = BASE64_URL_SAFE_NO_PAD
|
||||
.decode(parts[1])
|
||||
.map_err(|e| anyhow::anyhow!("Failed to decode JWT payload: {e}"))?;
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct JwtPayload {
|
||||
exp: i64,
|
||||
}
|
||||
|
||||
let payload: JwtPayload = serde_json::from_slice(&payload_bytes)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to parse JWT payload: {e}"))?;
|
||||
|
||||
DateTime::from_timestamp(payload.exp, 0)
|
||||
.ok_or_else(|| anyhow::anyhow!("Invalid exp timestamp in JWT: {}", payload.exp).into())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "namespace_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,51 @@
|
||||
use base64::prelude::{BASE64_URL_SAFE_NO_PAD, Engine as _};
|
||||
use chrono::{TimeZone, Utc};
|
||||
|
||||
use super::parse_jwt_expiration;
|
||||
|
||||
/// Helper to create a JWT token string for testing.
|
||||
fn make_jwt(payload_json: &str) -> String {
|
||||
let header = BASE64_URL_SAFE_NO_PAD.encode(r#"{"alg":"HS256","typ":"JWT"}"#);
|
||||
let payload = BASE64_URL_SAFE_NO_PAD.encode(payload_json);
|
||||
let signature = BASE64_URL_SAFE_NO_PAD.encode("fake_signature");
|
||||
format!("{header}.{payload}.{signature}")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_jwt_expiration_valid() {
|
||||
// Unix timestamp for 2024-01-15 12:00:00 UTC.
|
||||
let exp_timestamp: i64 = 1705320000;
|
||||
let token = make_jwt(&format!(r#"{{"exp":{exp_timestamp},"sub":"user123"}}"#));
|
||||
|
||||
let result = parse_jwt_expiration(&token).unwrap();
|
||||
let expected = Utc.timestamp_opt(exp_timestamp, 0).unwrap();
|
||||
|
||||
assert_eq!(result, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_jwt_expiration_invalid_format_too_few_parts() {
|
||||
let token = "header.payload";
|
||||
assert!(parse_jwt_expiration(token).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_jwt_expiration_invalid_format_too_many_parts() {
|
||||
let token = "a.b.c.d";
|
||||
assert!(parse_jwt_expiration(token).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_jwt_expiration_invalid_json() {
|
||||
let header = BASE64_URL_SAFE_NO_PAD.encode(r#"{"alg":"HS256"}"#);
|
||||
let payload = BASE64_URL_SAFE_NO_PAD.encode("not valid json");
|
||||
let token = format!("{header}.{payload}.signature");
|
||||
|
||||
assert!(parse_jwt_expiration(&token).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_jwt_expiration_missing_exp_field() {
|
||||
let token = make_jwt(r#"{"sub":"user123","iat":1234567890}"#);
|
||||
assert!(parse_jwt_expiration(token.as_str()).is_err());
|
||||
}
|
||||
Reference in New Issue
Block a user