greentic-start-dev 1.1.27285499481

Greentic lifecycle runner for start/restart/stop orchestration
Documentation
use std::env;
use std::fmt::Display;
use std::io::{BufReader, Cursor};
use std::sync::Arc;

use anyhow::{Context, Result};
use http_body_util::{BodyExt, Full};
use hyper::body::{Body, Bytes};
use hyper::client::conn::http1;
use hyper::header::{AUTHORIZATION, CONTENT_TYPE, HOST};
use hyper::{Method, Request, Response, StatusCode};
use hyper_util::rt::TokioIo;
use rustls::pki_types::{CertificateDer, PrivateKeyDer, ServerName};
use tokio::net::TcpStream;
use tokio_rustls::TlsConnector;

use super::helpers::error_response;

pub(super) const ADMIN_RELAY_PREFIX: &str = "/admin-relay/v1";

#[derive(Clone)]
pub(super) struct AdminRelayConfig {
    pub(super) token: String,
    pub(super) admin_port: u16,
    pub(super) client_config: Arc<rustls::ClientConfig>,
}

pub(super) fn load_admin_relay_config_from_env() -> Result<Option<Arc<AdminRelayConfig>>> {
    let Some(token) = env_var_nonempty("GREENTIC_ADMIN_RELAY_TOKEN") else {
        return Ok(None);
    };

    let ca_pem = env_var_nonempty("GREENTIC_ADMIN_CA_PEM")
        .context("GREENTIC_ADMIN_RELAY_TOKEN is set but GREENTIC_ADMIN_CA_PEM is missing")?;
    let client_cert_pem = env_var_nonempty("GREENTIC_ADMIN_CLIENT_CERT_PEM").context(
        "GREENTIC_ADMIN_RELAY_TOKEN is set but GREENTIC_ADMIN_CLIENT_CERT_PEM is missing",
    )?;
    let client_key_pem = env_var_nonempty("GREENTIC_ADMIN_CLIENT_KEY_PEM").context(
        "GREENTIC_ADMIN_RELAY_TOKEN is set but GREENTIC_ADMIN_CLIENT_KEY_PEM is missing",
    )?;

    let admin_port = parse_admin_port_from_env().unwrap_or(8443);
    let client_config = build_client_config(&ca_pem, &client_cert_pem, &client_key_pem)?;

    Ok(Some(Arc::new(AdminRelayConfig {
        token,
        admin_port,
        client_config: Arc::new(client_config),
    })))
}

pub(super) fn relay_target_path(path: &str) -> Option<String> {
    let suffix = path.strip_prefix(ADMIN_RELAY_PREFIX)?;
    Some(format!("/admin/v1{suffix}"))
}

pub(super) async fn handle_admin_relay<B>(
    req: Request<B>,
    target_path: String,
    config: Arc<AdminRelayConfig>,
) -> Result<Response<Full<Bytes>>, Response<Full<Bytes>>>
where
    B: Body<Data = Bytes> + Unpin,
    B::Error: Display,
{
    if !authorized(req.headers().get(AUTHORIZATION), &config.token) {
        return Err(error_response(
            StatusCode::UNAUTHORIZED,
            "missing or invalid bearer token for admin relay",
        ));
    }

    let method = req.method().clone();
    let content_type = req.headers().get(CONTENT_TYPE).cloned();
    let query = req.uri().query().map(str::to_string);
    let target_uri = match query {
        Some(query) if !query.is_empty() => format!("{target_path}?{query}"),
        _ => target_path,
    };
    let body = req
        .into_body()
        .collect()
        .await
        .map_err(|err| {
            error_response(
                StatusCode::BAD_REQUEST,
                format!("failed to read admin relay request body: {err}"),
            )
        })?
        .to_bytes();

    forward_admin_request(&config, method, target_uri, content_type, body)
        .await
        .map_err(|err| {
            error_response(
                StatusCode::BAD_GATEWAY,
                format!("admin relay failed: {err:#}"),
            )
        })
}

fn env_var_nonempty(key: &str) -> Option<String> {
    env::var(key).ok().filter(|value| !value.trim().is_empty())
}

fn parse_admin_port_from_env() -> Option<u16> {
    let value = env::var("GREENTIC_ADMIN_LISTEN").ok()?;
    let port = value.rsplit(':').next()?;
    port.parse().ok()
}

fn authorized(header: Option<&hyper::header::HeaderValue>, expected_token: &str) -> bool {
    let Some(header) = header else {
        return false;
    };
    let Ok(header) = header.to_str() else {
        return false;
    };
    let Some(token) = header.strip_prefix("Bearer ") else {
        return false;
    };
    token == expected_token
}

async fn forward_admin_request(
    config: &AdminRelayConfig,
    method: Method,
    target_uri: String,
    content_type: Option<hyper::header::HeaderValue>,
    body: Bytes,
) -> Result<Response<Full<Bytes>>> {
    let tcp = TcpStream::connect(("127.0.0.1", config.admin_port))
        .await
        .with_context(|| {
            format!(
                "connect to local admin listener on 127.0.0.1:{}",
                config.admin_port
            )
        })?;

    let connector = TlsConnector::from(config.client_config.clone());
    let server_name = ServerName::try_from("localhost")
        .map_err(|_| anyhow::anyhow!("invalid admin relay server name"))?;
    let tls_stream = connector
        .connect(server_name, tcp)
        .await
        .context("TLS connect to local admin listener")?;

    let (mut sender, connection) = http1::handshake(TokioIo::new(tls_stream))
        .await
        .context("handshake admin relay HTTP/1 client")?;
    tokio::spawn(async move {
        let _ = connection.await;
    });

    let mut builder = Request::builder().method(method).uri(target_uri);
    if let Some(content_type) = content_type {
        builder = builder.header(CONTENT_TYPE, content_type);
    }
    builder = builder.header(HOST, "localhost");
    let request = builder
        .body(Full::new(body))
        .map_err(|err| anyhow::anyhow!("build admin relay request: {err}"))?;

    let response = sender
        .send_request(request)
        .await
        .context("send admin relay request")?;
    let status = response.status();
    let content_type = response.headers().get(CONTENT_TYPE).cloned();
    let body = response
        .into_body()
        .collect()
        .await
        .context("read admin relay response body")?
        .to_bytes();

    let mut builder = Response::builder().status(status);
    if let Some(content_type) = content_type {
        builder = builder.header(CONTENT_TYPE, content_type);
    }
    builder
        .body(Full::new(body))
        .map_err(|err| anyhow::anyhow!("build admin relay response: {err}"))
}

fn build_client_config(
    ca_pem: &str,
    client_cert_pem: &str,
    client_key_pem: &str,
) -> Result<rustls::ClientConfig> {
    let ca_certs = parse_certs(ca_pem).context("parse admin relay CA certs")?;
    let client_certs = parse_certs(client_cert_pem).context("parse admin relay client certs")?;
    let client_key = parse_private_key(client_key_pem).context("parse admin relay client key")?;

    let mut root_store = rustls::RootCertStore::empty();
    for cert in ca_certs {
        root_store
            .add(cert)
            .context("add admin relay CA cert to root store")?;
    }

    let provider = Arc::new(rustls::crypto::ring::default_provider());

    rustls::ClientConfig::builder_with_provider(provider)
        .with_safe_default_protocol_versions()
        .context("set admin relay TLS protocol versions")?
        .with_root_certificates(root_store)
        .with_client_auth_cert(client_certs, client_key)
        .context("build admin relay client config")
}

fn parse_certs(pem: &str) -> Result<Vec<CertificateDer<'static>>> {
    rustls_pemfile::certs(&mut BufReader::new(Cursor::new(pem.as_bytes())))
        .collect::<Result<Vec<_>, _>>()
        .context("parse PEM certificates")
}

fn parse_private_key(pem: &str) -> Result<PrivateKeyDer<'static>> {
    rustls_pemfile::private_key(&mut BufReader::new(Cursor::new(pem.as_bytes())))
        .context("parse PEM private key")?
        .ok_or_else(|| anyhow::anyhow!("no private key found in PEM"))
}

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

    #[test]
    fn relay_target_path_maps_public_prefix_to_admin_api() {
        assert_eq!(
            relay_target_path("/admin-relay/v1/health").as_deref(),
            Some("/admin/v1/health")
        );
        assert_eq!(
            relay_target_path("/admin-relay/v1/qa/session/abc").as_deref(),
            Some("/admin/v1/qa/session/abc")
        );
        assert_eq!(relay_target_path("/not-admin"), None);
    }

    #[test]
    fn authorized_requires_matching_bearer_token() {
        let ok = hyper::header::HeaderValue::from_static("Bearer secret-token");
        let wrong = hyper::header::HeaderValue::from_static("Bearer nope");
        let malformed = hyper::header::HeaderValue::from_static("Basic abc");

        assert!(authorized(Some(&ok), "secret-token"));
        assert!(!authorized(Some(&wrong), "secret-token"));
        assert!(!authorized(Some(&malformed), "secret-token"));
        assert!(!authorized(None, "secret-token"));
    }
}