This commit is contained in:
Face
2025-08-22 17:31:54 +03:00
parent 0a38af1b66
commit 00309149d4
39 changed files with 3001 additions and 84 deletions

45
dns/src/gurt_server/ca.rs Normal file
View File

@@ -0,0 +1,45 @@
use crate::crypto;
use anyhow::Result;
use sqlx::PgPool;
pub struct CaCertificate {
pub ca_cert_pem: String,
pub ca_key_pem: String,
}
pub async fn get_or_create_ca(db: &PgPool) -> Result<CaCertificate> {
if let Some(ca_cert) = get_active_ca(db).await? {
return Ok(ca_cert);
}
log::info!("Generating new CA certificate...");
let (ca_key_pem, ca_cert_pem) = crypto::generate_ca_cert()?;
sqlx::query(
"INSERT INTO ca_certificates (ca_cert_pem, ca_key_pem, is_active) VALUES ($1, $2, TRUE)"
)
.bind(&ca_cert_pem)
.bind(&ca_key_pem)
.execute(db)
.await?;
log::info!("CA certificate generated and stored");
Ok(CaCertificate {
ca_cert_pem,
ca_key_pem,
})
}
async fn get_active_ca(db: &PgPool) -> Result<Option<CaCertificate>> {
let result: Option<(String, String)> = sqlx::query_as(
"SELECT ca_cert_pem, ca_key_pem FROM ca_certificates WHERE is_active = TRUE ORDER BY created_at DESC LIMIT 1"
)
.fetch_optional(db)
.await?;
Ok(result.map(|(ca_cert_pem, ca_key_pem)| CaCertificate {
ca_cert_pem,
ca_key_pem,
}))
}

View File

@@ -82,7 +82,7 @@ pub(crate) struct ResponseDnsRecord {
pub(crate) record_type: String,
pub(crate) name: String,
pub(crate) value: String,
pub(crate) ttl: i32,
pub(crate) ttl: Option<i32>,
pub(crate) priority: Option<i32>,
}

View File

@@ -390,7 +390,7 @@ pub(crate) async fn get_domain_records(ctx: &ServerContext, app_state: AppState,
record_type: record.record_type,
name: record.name,
value: record.value,
ttl: record.ttl.unwrap_or(3600),
ttl: record.ttl,
priority: record.priority,
}
}).collect();
@@ -445,13 +445,13 @@ pub(crate) async fn create_domain_record(ctx: &ServerContext, app_state: AppStat
return Ok(GurtResponse::bad_request().with_string_body("Record type is required"));
}
let valid_types = ["A", "AAAA", "CNAME", "TXT", "NS"];
let valid_types = ["A", "AAAA", "CNAME", "TXT"];
if !valid_types.contains(&record_data.record_type.as_str()) {
return Ok(GurtResponse::bad_request().with_string_body("Invalid record type. Only A, AAAA, CNAME, TXT, and NS records are supported."));
return Ok(GurtResponse::bad_request().with_string_body("Invalid record type. Only A, AAAA, CNAME, and TXT records are supported."));
}
let record_name = record_data.name.unwrap_or_else(|| "@".to_string());
let ttl = record_data.ttl.unwrap_or(3600);
let ttl = record_data.ttl.filter(|t| *t > 0);
match record_data.record_type.as_str() {
"A" => {
@@ -464,9 +464,9 @@ pub(crate) async fn create_domain_record(ctx: &ServerContext, app_state: AppStat
return Ok(GurtResponse::bad_request().with_string_body("Invalid IPv6 address for AAAA record"));
}
},
"CNAME" | "NS" => {
"CNAME" => {
if record_data.value.is_empty() || !record_data.value.contains('.') {
return Ok(GurtResponse::bad_request().with_string_body("CNAME and NS records must contain a valid domain name"));
return Ok(GurtResponse::bad_request().with_string_body("CNAME records must contain a valid domain name"));
}
},
"TXT" => {
@@ -498,7 +498,7 @@ pub(crate) async fn create_domain_record(ctx: &ServerContext, app_state: AppStat
record_type: record_data.record_type,
name: record_name,
value: record_data.value,
ttl,
ttl: Some(ttl.unwrap_or(3600)),
priority: record_data.priority,
};
@@ -637,7 +637,7 @@ async fn try_exact_match(query_name: &str, tld: &str, app_state: &AppState) -> R
record_type: record.record_type,
name: record.name,
value: record.value,
ttl: record.ttl.unwrap_or(3600),
ttl: record.ttl,
priority: record.priority,
}
}).collect();
@@ -718,7 +718,7 @@ async fn try_delegation_match(query_name: &str, tld: &str, app_state: &AppState)
record_type: record.record_type,
name: record.name,
value: record.value,
ttl: record.ttl.unwrap_or(3600),
ttl: record.ttl,
priority: record.priority,
}
}).collect();
@@ -761,6 +761,221 @@ pub(crate) async fn resolve_full_domain(ctx: &ServerContext, app_state: AppState
}
}
// Certificate Authority endpoints
pub(crate) async fn verify_domain_ownership(ctx: &ServerContext, app_state: AppState) -> Result<GurtResponse> {
let path_parts: Vec<&str> = ctx.path().split('/').collect();
if path_parts.len() < 3 {
return Ok(GurtResponse::bad_request().with_string_body("Invalid path format"));
}
let domain = path_parts[2];
let domain_parts: Vec<&str> = domain.split('.').collect();
if domain_parts.len() < 2 {
return Ok(GurtResponse::bad_request().with_string_body("Invalid domain format"));
}
let name = domain_parts[0];
let tld = domain_parts[1];
let domain_record: Option<Domain> = sqlx::query_as::<_, Domain>(
"SELECT id, name, tld, ip, user_id, status, denial_reason, created_at FROM domains WHERE name = $1 AND tld = $2 AND status = 'approved'"
)
.bind(name)
.bind(tld)
.fetch_optional(&app_state.db)
.await
.map_err(|_| GurtError::invalid_message("Database error"))?;
let exists = domain_record.is_some();
Ok(GurtResponse::ok().with_json_body(&serde_json::json!({
"domain": domain,
"exists": exists
}))?)
}
pub(crate) async fn request_certificate(ctx: &ServerContext, app_state: AppState) -> Result<GurtResponse> {
#[derive(serde::Deserialize)]
struct CertRequest {
domain: String,
csr: String,
}
let cert_request: CertRequest = serde_json::from_slice(ctx.body())
.map_err(|_| GurtError::invalid_message("Invalid JSON"))?;
let domain_parts: Vec<&str> = cert_request.domain.split('.').collect();
if domain_parts.len() < 2 {
return Ok(GurtResponse::bad_request().with_string_body("Invalid domain format"));
}
let name = domain_parts[0];
let tld = domain_parts[1];
let domain_record: Option<Domain> = sqlx::query_as::<_, Domain>(
"SELECT id, name, tld, ip, user_id, status, denial_reason, created_at FROM domains WHERE name = $1 AND tld = $2 AND status = 'approved'"
)
.bind(name)
.bind(tld)
.fetch_optional(&app_state.db)
.await
.map_err(|_| GurtError::invalid_message("Database error"))?;
if domain_record.is_none() {
return Ok(GurtResponse::bad_request().with_string_body("Domain does not exist or is not approved"));
}
let token = uuid::Uuid::new_v4().to_string();
let verification_data = generate_challenge_data(&cert_request.domain, &token)?;
sqlx::query(
"INSERT INTO certificate_challenges (token, domain, challenge_type, verification_data, csr_pem, expires_at) VALUES ($1, $2, $3, $4, $5, $6)"
)
.bind(&token)
.bind(&cert_request.domain)
.bind("dns") // Only DNS challenges
.bind(&verification_data)
.bind(&cert_request.csr)
.bind(chrono::Utc::now() + chrono::Duration::hours(1))
.execute(&app_state.db)
.await
.map_err(|_| GurtError::invalid_message("Failed to store challenge"))?;
let challenge = serde_json::json!({
"token": token,
"challenge_type": "dns",
"domain": cert_request.domain,
"verification_data": verification_data
});
Ok(GurtResponse::ok().with_json_body(&challenge)?)
}
pub(crate) async fn get_certificate(ctx: &ServerContext, app_state: AppState) -> Result<GurtResponse> {
let path_parts: Vec<&str> = ctx.path().split('/').collect();
if path_parts.len() < 4 {
return Ok(GurtResponse::bad_request().with_string_body("Invalid path format"));
}
let token = path_parts[3];
let challenge: Option<(String, String, String, Option<String>, chrono::DateTime<chrono::Utc>)> = sqlx::query_as(
"SELECT domain, challenge_type, verification_data, csr_pem, expires_at FROM certificate_challenges WHERE token = $1"
)
.bind(token)
.fetch_optional(&app_state.db)
.await
.map_err(|_| GurtError::invalid_message("Database error"))?;
let (domain, _challenge_type, verification_data, csr_pem, expires_at) = match challenge {
Some(c) => c,
None => return Ok(GurtResponse::not_found().with_string_body("Challenge not found"))
};
let csr_pem = match csr_pem {
Some(csr) => csr,
None => return Ok(GurtResponse::bad_request().with_string_body("CSR not found for this challenge"))
};
if chrono::Utc::now() > expires_at {
return Ok(GurtResponse::bad_request().with_string_body("Challenge expired"));
}
let challenge_domain = format!("_gurtca-challenge.{}", domain);
let domain_parts: Vec<&str> = challenge_domain.split('.').collect();
if domain_parts.len() < 3 {
return Ok(GurtResponse::bad_request().with_string_body("Invalid domain format"));
}
let record_name = "_gurtca-challenge";
let base_domain_name = domain_parts[domain_parts.len() - 2];
let tld = domain_parts[domain_parts.len() - 1];
let domain_record: Option<Domain> = sqlx::query_as::<_, Domain>(
"SELECT id, name, tld, ip, user_id, status, denial_reason, created_at FROM domains WHERE name = $1 AND tld = $2 AND status = 'approved'"
)
.bind(base_domain_name)
.bind(tld)
.fetch_optional(&app_state.db)
.await
.map_err(|_| GurtError::invalid_message("Database error"))?;
let domain_record = match domain_record {
Some(d) => d,
None => return Ok(GurtResponse::bad_request().with_string_body("Domain not found or not approved"))
};
let txt_records: Vec<DnsRecord> = sqlx::query_as::<_, DnsRecord>(
"SELECT id, domain_id, record_type, name, value, ttl, priority, created_at FROM dns_records WHERE domain_id = $1 AND record_type = 'TXT' AND name = $2 AND value = $3"
)
.bind(domain_record.id.unwrap())
.bind(record_name)
.bind(&verification_data)
.fetch_all(&app_state.db)
.await
.map_err(|_| GurtError::invalid_message("Database error"))?;
if txt_records.is_empty() {
return Ok(GurtResponse::new(gurt::GurtStatusCode::Accepted).with_string_body("Challenge not completed yet"));
}
let ca_cert = super::ca::get_or_create_ca(&app_state.db).await
.map_err(|e| {
log::error!("Failed to get CA certificate: {}", e);
GurtError::invalid_message("CA certificate error")
})?;
let cert_pem = crate::crypto::sign_csr_with_ca(
&csr_pem,
&ca_cert.ca_cert_pem,
&ca_cert.ca_key_pem,
&domain
).map_err(|e| {
log::error!("Failed to sign certificate: {}", e);
GurtError::invalid_message("Certificate signing failed")
})?;
let certificate = serde_json::json!({
"cert_pem": cert_pem,
"chain_pem": ca_cert.ca_cert_pem,
"expires_at": (chrono::Utc::now() + chrono::Duration::days(90)).to_rfc3339()
});
// Delete the challenge as it's completed
sqlx::query("DELETE FROM certificate_challenges WHERE token = $1")
.bind(token)
.execute(&app_state.db)
.await
.map_err(|_| GurtError::invalid_message("Failed to cleanup challenge"))?;
Ok(GurtResponse::ok().with_json_body(&certificate)?)
}
pub(crate) async fn get_ca_certificate(_ctx: &ServerContext, app_state: AppState) -> Result<GurtResponse> {
let ca_cert = super::ca::get_or_create_ca(&app_state.db).await
.map_err(|e| {
log::error!("Failed to get CA certificate: {}", e);
GurtError::invalid_message("CA certificate error")
})?;
Ok(GurtResponse::ok()
.with_header("Content-Type", "application/x-pem-file")
.with_header("Content-Disposition", "attachment; filename=\"gurted-ca.crt\"")
.with_string_body(ca_cert.ca_cert_pem))
}
fn generate_challenge_data(domain: &str, token: &str) -> Result<String> {
use sha2::{Sha256, Digest};
let data = format!("{}:{}", domain, token);
let mut hasher = Sha256::new();
hasher.update(data.as_bytes());
let hash = hasher.finalize();
Ok(base64::encode(hash))
}
#[derive(serde::Serialize)]
struct Error {
msg: &'static str,