Skip to main content

licenz_core/
container.rs

1//! Container and cloud-aware licensing
2//!
3//! This module provides licensing modes that work in containerized
4//! and cloud environments where traditional hardware binding fails.
5
6use crate::error::{LicenseError, Result};
7use serde::{Deserialize, Serialize};
8use std::path::PathBuf;
9#[cfg(feature = "cloud-metadata")]
10use std::time::Duration;
11
12/// Detected runtime environment
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
14pub enum RuntimeEnvironment {
15    /// Traditional bare-metal or VM with stable hardware
16    Standalone,
17    /// Docker container
18    Docker,
19    /// Kubernetes pod
20    Kubernetes,
21    /// AWS EC2 instance
22    AwsEc2,
23    /// Google Cloud Compute Engine
24    GcpCompute,
25    /// Microsoft Azure VM
26    AzureVm,
27    /// Generic cloud/container (unknown provider)
28    GenericCloud,
29}
30
31impl RuntimeEnvironment {
32    /// Detect the current runtime environment
33    pub fn detect() -> Self {
34        // Check for Kubernetes
35        if std::env::var("KUBERNETES_SERVICE_HOST").is_ok() {
36            return Self::Kubernetes;
37        }
38
39        // Check for Docker
40        if std::path::Path::new("/.dockerenv").exists() {
41            return Self::Docker;
42        }
43
44        // Check cgroup for container indicators
45        if let Ok(cgroup) = std::fs::read_to_string("/proc/self/cgroup") {
46            if cgroup.contains("docker")
47                || cgroup.contains("kubepods")
48                || cgroup.contains("containerd")
49            {
50                return Self::Docker;
51            }
52        }
53
54        // Cloud provider detection would require network calls
55        // For now, check environment variables that cloud providers set
56        if std::env::var("AWS_EXECUTION_ENV").is_ok() || std::env::var("AWS_REGION").is_ok() {
57            return Self::AwsEc2;
58        }
59
60        if std::env::var("GOOGLE_CLOUD_PROJECT").is_ok() || std::env::var("GCP_PROJECT").is_ok() {
61            return Self::GcpCompute;
62        }
63
64        if std::env::var("AZURE_CLIENT_ID").is_ok() || std::env::var("WEBSITE_SITE_NAME").is_ok() {
65            return Self::AzureVm;
66        }
67
68        Self::Standalone
69    }
70
71    /// Check if this environment has stable hardware identifiers
72    pub fn has_stable_hardware(&self) -> bool {
73        matches!(
74            self,
75            Self::Standalone | Self::AwsEc2 | Self::GcpCompute | Self::AzureVm
76        )
77    }
78
79    /// Get the recommended instance ID source for this environment
80    pub fn recommended_id_source(&self) -> Option<InstanceIdSource> {
81        match self {
82            Self::Standalone => None, // Use hardware binding
83            Self::Docker => Some(InstanceIdSource::DockerContainerId),
84            Self::Kubernetes => Some(InstanceIdSource::KubernetesPodUid),
85            Self::AwsEc2 => Some(InstanceIdSource::AwsInstanceId),
86            Self::GcpCompute => Some(InstanceIdSource::GcpInstanceId),
87            Self::AzureVm => Some(InstanceIdSource::AzureInstanceId),
88            Self::GenericCloud => Some(InstanceIdSource::CustomEnvVar("INSTANCE_ID".to_string())),
89        }
90    }
91}
92
93/// Source for obtaining a stable instance identifier in cloud/container environments
94#[derive(Debug, Clone, Serialize, Deserialize)]
95pub enum InstanceIdSource {
96    /// AWS EC2 instance ID from IMDS
97    AwsInstanceId,
98    /// GCP instance ID from metadata server
99    GcpInstanceId,
100    /// Azure instance ID from IMDS
101    AzureInstanceId,
102    /// Kubernetes pod UID from downward API
103    KubernetesPodUid,
104    /// Docker container ID from cgroup
105    DockerContainerId,
106    /// Read from a file (e.g., mounted secret or config)
107    CustomFile(PathBuf),
108    /// Read from an environment variable
109    CustomEnvVar(String),
110}
111
112impl InstanceIdSource {
113    /// Get the instance ID from this source
114    pub fn get_id(&self) -> Option<String> {
115        match self {
116            Self::AwsInstanceId => get_aws_instance_id(),
117            Self::GcpInstanceId => get_gcp_instance_id(),
118            Self::AzureInstanceId => get_azure_instance_id(),
119            Self::KubernetesPodUid => get_kubernetes_pod_uid(),
120            Self::DockerContainerId => get_docker_container_id(),
121            Self::CustomFile(path) => std::fs::read_to_string(path)
122                .ok()
123                .map(|s| s.trim().to_string())
124                .filter(|s| !s.is_empty()),
125            Self::CustomEnvVar(var) => std::env::var(var).ok().filter(|s| !s.is_empty()),
126        }
127    }
128}
129
130/// Get AWS EC2 instance ID from Instance Metadata Service
131fn get_aws_instance_id() -> Option<String> {
132    // IMDSv2 with token
133    let token = ureq_get_with_header(
134        "http://169.254.169.254/latest/api/token",
135        "X-aws-ec2-metadata-token-ttl-seconds",
136        "21600",
137    )
138    .ok()?;
139
140    ureq_get_with_header(
141        "http://169.254.169.254/latest/meta-data/instance-id",
142        "X-aws-ec2-metadata-token",
143        &token,
144    )
145    .ok()
146}
147
148/// Get GCP instance ID from metadata server
149fn get_gcp_instance_id() -> Option<String> {
150    ureq_get_with_header(
151        "http://metadata.google.internal/computeMetadata/v1/instance/id",
152        "Metadata-Flavor",
153        "Google",
154    )
155    .ok()
156}
157
158/// Get Azure instance ID from IMDS
159fn get_azure_instance_id() -> Option<String> {
160    ureq_get_with_header(
161        "http://169.254.169.254/metadata/instance/compute/vmId?api-version=2021-02-01&format=text",
162        "Metadata",
163        "true",
164    )
165    .ok()
166}
167
168/// Get Kubernetes pod UID from downward API
169fn get_kubernetes_pod_uid() -> Option<String> {
170    // Standard location when using downward API
171    let paths = ["/etc/podinfo/uid", "/var/run/secrets/kubernetes.io/poduid"];
172
173    for path in paths {
174        if let Ok(uid) = std::fs::read_to_string(path) {
175            let uid = uid.trim().to_string();
176            if !uid.is_empty() {
177                return Some(uid);
178            }
179        }
180    }
181
182    // Fallback: try environment variable
183    std::env::var("POD_UID").ok()
184}
185
186/// Get Docker container ID from cgroup
187fn get_docker_container_id() -> Option<String> {
188    // Read cgroup file
189    let cgroup = std::fs::read_to_string("/proc/self/cgroup").ok()?;
190
191    for line in cgroup.lines() {
192        // Look for docker or containerd paths
193        if let Some(pos) = line.rfind('/') {
194            let id = &line[pos + 1..];
195            // Container IDs are typically 64 hex characters
196            if id.len() >= 12 && id.chars().all(|c| c.is_ascii_hexdigit()) {
197                return Some(id[..12].to_string()); // Short form
198            }
199        }
200    }
201
202    // Fallback: check hostname (often set to container ID in Docker)
203    std::env::var("HOSTNAME")
204        .ok()
205        .filter(|h| h.len() == 12 && h.chars().all(|c| c.is_ascii_hexdigit()))
206}
207
208/// Helper function for HTTP GET with a custom header
209#[allow(unused_variables)]
210fn ureq_get_with_header(url: &str, header_name: &str, header_value: &str) -> Result<String> {
211    // Use a simple blocking HTTP client
212    // In production, you'd want proper async with timeouts
213
214    #[cfg(feature = "cloud-metadata")]
215    {
216        use std::io::Read;
217        use std::io::Write;
218        use std::net::TcpStream;
219
220        let url = url::Url::parse(url).map_err(|e| {
221            LicenseError::IoError(std::io::Error::new(std::io::ErrorKind::InvalidInput, e))
222        })?;
223
224        let host = url.host_str().ok_or_else(|| {
225            LicenseError::IoError(std::io::Error::new(
226                std::io::ErrorKind::InvalidInput,
227                "No host",
228            ))
229        })?;
230        let port = url.port().unwrap_or(80);
231
232        let mut stream = TcpStream::connect_timeout(
233            &format!("{}:{}", host, port).parse().unwrap(),
234            Duration::from_secs(2),
235        )?;
236        stream.set_read_timeout(Some(Duration::from_secs(2)))?;
237
238        let request = format!(
239            "GET {} HTTP/1.1\r\nHost: {}\r\n{}: {}\r\nConnection: close\r\n\r\n",
240            url.path(),
241            host,
242            header_name,
243            header_value
244        );
245
246        stream.write_all(request.as_bytes())?;
247
248        let mut response = String::new();
249        stream.read_to_string(&mut response)?;
250
251        // Parse HTTP response (very basic)
252        if let Some(body_start) = response.find("\r\n\r\n") {
253            return Ok(response[body_start + 4..].trim().to_string());
254        }
255    }
256
257    Err(LicenseError::IoError(std::io::Error::other(
258        "Cloud metadata feature not enabled",
259    )))
260}
261
262/// Container-aware license binding
263#[derive(Debug, Clone, Serialize, Deserialize)]
264pub struct ContainerBinding {
265    /// The detected environment
266    pub environment: RuntimeEnvironment,
267    /// The instance ID source used
268    pub id_source: Option<InstanceIdSource>,
269    /// The bound instance ID (hashed)
270    pub instance_id_hash: Option<String>,
271}
272
273impl ContainerBinding {
274    /// Create a new container binding for the current environment
275    pub fn detect() -> Self {
276        let environment = RuntimeEnvironment::detect();
277        let id_source = environment.recommended_id_source();
278
279        let instance_id_hash = id_source
280            .as_ref()
281            .and_then(|src| src.get_id())
282            .map(|id| sha256_short(&id));
283
284        Self {
285            environment,
286            id_source,
287            instance_id_hash,
288        }
289    }
290
291    /// Check if the current environment matches this binding
292    pub fn matches_current(&self) -> bool {
293        if self.instance_id_hash.is_none() {
294            // No binding set
295            return true;
296        }
297
298        let current = Self::detect();
299        self.instance_id_hash == current.instance_id_hash
300    }
301
302    /// Check if hardware binding should be used instead
303    pub fn should_use_hardware_binding(&self) -> bool {
304        self.environment.has_stable_hardware() && self.instance_id_hash.is_none()
305    }
306}
307
308fn sha256_short(input: &str) -> String {
309    use sha2::{Digest, Sha256};
310    let mut hasher = Sha256::new();
311    hasher.update(input.as_bytes());
312    hex::encode(&hasher.finalize()[..16])
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318
319    #[test]
320    fn test_environment_detection() {
321        let env = RuntimeEnvironment::detect();
322        // In a test environment, should be Standalone or Docker depending on CI
323        println!("Detected environment: {:?}", env);
324    }
325
326    #[test]
327    fn test_custom_env_var_source() {
328        std::env::set_var("TEST_INSTANCE_ID", "test-id-12345");
329
330        let source = InstanceIdSource::CustomEnvVar("TEST_INSTANCE_ID".to_string());
331        let id = source.get_id();
332
333        assert_eq!(id, Some("test-id-12345".to_string()));
334
335        std::env::remove_var("TEST_INSTANCE_ID");
336    }
337
338    #[test]
339    fn test_container_binding() {
340        let binding = ContainerBinding::detect();
341
342        // Should match itself
343        assert!(binding.matches_current());
344
345        println!("Container binding: {:?}", binding);
346    }
347}