mirror of
https://github.com/house-of-vanity/OutFleet.git
synced 2025-10-26 10:09:08 +00:00
Letsencrypt works
This commit is contained in:
287
src/services/acme/client.rs
Normal file
287
src/services/acme/client.rs
Normal file
@@ -0,0 +1,287 @@
|
||||
use instant_acme::{
|
||||
Account, AuthorizationStatus, ChallengeType, Identifier, NewAccount, NewOrder, OrderStatus,
|
||||
};
|
||||
use rcgen::{CertificateParams, DistinguishedName, KeyPair};
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::time::sleep;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use crate::services::acme::{CloudflareClient, AcmeError};
|
||||
|
||||
pub struct AcmeClient {
|
||||
cloudflare: CloudflareClient,
|
||||
account: Account,
|
||||
directory_url: String,
|
||||
}
|
||||
|
||||
impl AcmeClient {
|
||||
pub async fn new(
|
||||
cloudflare_token: String,
|
||||
email: &str,
|
||||
directory_url: String,
|
||||
) -> Result<Self, AcmeError> {
|
||||
info!("Creating ACME client for directory: {}", directory_url);
|
||||
|
||||
let cloudflare = CloudflareClient::new(cloudflare_token)?;
|
||||
|
||||
// Create Let's Encrypt account
|
||||
info!("Creating Let's Encrypt account for: {}", email);
|
||||
let (account, _credentials) = Account::builder()
|
||||
.map_err(|e| AcmeError::AccountCreation(e.to_string()))?
|
||||
.create(
|
||||
&NewAccount {
|
||||
contact: &[&format!("mailto:{}", email)],
|
||||
terms_of_service_agreed: true,
|
||||
only_return_existing: false,
|
||||
},
|
||||
directory_url.clone(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| AcmeError::AccountCreation(e.to_string()))?;
|
||||
|
||||
Ok(Self {
|
||||
cloudflare,
|
||||
account,
|
||||
directory_url,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get_certificate(&mut self, domain: &str, base_domain: &str) -> Result<(String, String), AcmeError> {
|
||||
info!("Starting certificate request for domain: {}", domain);
|
||||
|
||||
// Validate domain
|
||||
if domain.is_empty() || base_domain.is_empty() {
|
||||
return Err(AcmeError::InvalidDomain("Domain cannot be empty".to_string()));
|
||||
}
|
||||
|
||||
// Create a new order
|
||||
let identifiers = vec![Identifier::Dns(domain.to_string())];
|
||||
let mut order = self.account
|
||||
.new_order(&NewOrder::new(&identifiers))
|
||||
.await
|
||||
.map_err(|e| AcmeError::OrderCreation(e.to_string()))?;
|
||||
|
||||
debug!("Created order");
|
||||
|
||||
// Process authorizations
|
||||
let mut authorizations = order.authorizations();
|
||||
|
||||
while let Some(authz_result) = authorizations.next().await {
|
||||
let mut authz = authz_result
|
||||
.map_err(|e| AcmeError::Challenge(e.to_string()))?;
|
||||
|
||||
let identifier = format!("{:?}", authz.identifier());
|
||||
|
||||
if authz.status == AuthorizationStatus::Valid {
|
||||
info!("Authorization already valid for: {:?}", identifier);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get challenge value and record ID first
|
||||
let (challenge_value, record_id) = {
|
||||
// Find DNS challenge
|
||||
let mut challenge = authz
|
||||
.challenge(ChallengeType::Dns01)
|
||||
.ok_or_else(|| AcmeError::Challenge("No DNS challenge found".to_string()))?;
|
||||
|
||||
info!("Processing DNS challenge for: {:?}", identifier);
|
||||
|
||||
// Get challenge value - use key authorization from challenge
|
||||
let challenge_value = challenge.key_authorization().dns_value();
|
||||
debug!("Challenge value: {}", challenge_value);
|
||||
|
||||
// Create DNS record
|
||||
let challenge_domain = format!("_acme-challenge.{}", domain);
|
||||
let record_id = self.cloudflare
|
||||
.create_txt_record(base_domain, &challenge_domain, &challenge_value)
|
||||
.await?;
|
||||
|
||||
info!("Created DNS TXT record, waiting for propagation...");
|
||||
|
||||
// Wait for DNS propagation
|
||||
self.wait_for_dns_propagation(&challenge_domain, &challenge_value)
|
||||
.await?;
|
||||
|
||||
// Submit challenge
|
||||
info!("Submitting challenge...");
|
||||
challenge.set_ready().await
|
||||
.map_err(|e| AcmeError::Challenge(e.to_string()))?;
|
||||
|
||||
(challenge_value, record_id)
|
||||
};
|
||||
|
||||
// Wait for challenge completion
|
||||
info!("Waiting for challenge validation (5 seconds)...");
|
||||
sleep(Duration::from_secs(5)).await;
|
||||
|
||||
// Cleanup DNS record
|
||||
self.cleanup_dns_record(base_domain, &record_id).await;
|
||||
}
|
||||
|
||||
// Wait for order to be ready
|
||||
info!("Waiting for order to be ready...");
|
||||
let start = Instant::now();
|
||||
let timeout = Duration::from_secs(300);
|
||||
|
||||
loop {
|
||||
if start.elapsed() > timeout {
|
||||
return Err(AcmeError::Challenge("Order processing timeout".to_string()));
|
||||
}
|
||||
|
||||
order.refresh().await
|
||||
.map_err(|e| AcmeError::OrderCreation(e.to_string()))?;
|
||||
|
||||
match order.state().status {
|
||||
OrderStatus::Ready => {
|
||||
info!("Order is ready for finalization");
|
||||
break;
|
||||
}
|
||||
OrderStatus::Invalid => {
|
||||
return Err(AcmeError::Challenge("Order became invalid".to_string()));
|
||||
}
|
||||
OrderStatus::Pending => {
|
||||
debug!("Order still pending, waiting...");
|
||||
sleep(Duration::from_secs(5)).await;
|
||||
}
|
||||
_ => {
|
||||
debug!("Order status: {:?}", order.state().status);
|
||||
sleep(Duration::from_secs(5)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Generate CSR
|
||||
info!("Generating certificate signing request...");
|
||||
let mut params = CertificateParams::new(vec![domain.to_string()]);
|
||||
|
||||
params.distinguished_name = DistinguishedName::new();
|
||||
|
||||
let key_pair = KeyPair::generate(&rcgen::PKCS_ECDSA_P256_SHA256)
|
||||
.map_err(|e| AcmeError::CertificateGeneration(e.to_string()))?;
|
||||
|
||||
// Set the key pair for CSR generation
|
||||
params.key_pair = Some(key_pair);
|
||||
|
||||
// Generate CSR using rcgen certificate
|
||||
let cert = rcgen::Certificate::from_params(params)
|
||||
.map_err(|e| AcmeError::CertificateGeneration(e.to_string()))?;
|
||||
let csr_der = cert.serialize_request_der()
|
||||
.map_err(|e| AcmeError::CertificateGeneration(e.to_string()))?;
|
||||
|
||||
// Finalize order with CSR
|
||||
info!("Finalizing order with CSR...");
|
||||
order.finalize_csr(&csr_der).await
|
||||
.map_err(|e| AcmeError::CertificateGeneration(e.to_string()))?;
|
||||
|
||||
// Wait for certificate to be ready
|
||||
info!("Waiting for certificate to be generated...");
|
||||
let start = Instant::now();
|
||||
let timeout = Duration::from_secs(300); // 5 minutes
|
||||
|
||||
let cert_chain_pem = loop {
|
||||
if start.elapsed() > timeout {
|
||||
return Err(AcmeError::CertificateGeneration("Certificate generation timeout".to_string()));
|
||||
}
|
||||
|
||||
order.refresh().await
|
||||
.map_err(|e| AcmeError::CertificateGeneration(e.to_string()))?;
|
||||
|
||||
match order.state().status {
|
||||
OrderStatus::Valid => {
|
||||
info!("Certificate is ready!");
|
||||
break order.certificate().await
|
||||
.map_err(|e| AcmeError::CertificateGeneration(e.to_string()))?
|
||||
.ok_or_else(|| AcmeError::CertificateGeneration("Certificate not available".to_string()))?;
|
||||
}
|
||||
OrderStatus::Invalid => {
|
||||
return Err(AcmeError::CertificateGeneration("Order became invalid during certificate generation".to_string()));
|
||||
}
|
||||
OrderStatus::Processing => {
|
||||
debug!("Certificate still being processed, waiting...");
|
||||
sleep(Duration::from_secs(3)).await;
|
||||
}
|
||||
_ => {
|
||||
debug!("Waiting for certificate, order status: {:?}", order.state().status);
|
||||
sleep(Duration::from_secs(3)).await;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let private_key_pem = cert.serialize_private_key_pem();
|
||||
|
||||
info!("Certificate successfully obtained!");
|
||||
Ok((cert_chain_pem, private_key_pem))
|
||||
}
|
||||
|
||||
async fn wait_for_dns_propagation(&self, record_name: &str, expected_value: &str) -> Result<(), AcmeError> {
|
||||
info!("Checking DNS propagation for: {}", record_name);
|
||||
|
||||
let start = Instant::now();
|
||||
let timeout = Duration::from_secs(120); // 2 minutes
|
||||
|
||||
while start.elapsed() < timeout {
|
||||
match self.check_dns_txt_record(record_name, expected_value).await {
|
||||
Ok(true) => {
|
||||
info!("DNS propagation confirmed");
|
||||
return Ok(());
|
||||
}
|
||||
Ok(false) => {
|
||||
debug!("DNS not yet propagated, waiting...");
|
||||
}
|
||||
Err(e) => {
|
||||
debug!("DNS check failed: {:?}", e);
|
||||
}
|
||||
}
|
||||
|
||||
sleep(Duration::from_secs(10)).await;
|
||||
}
|
||||
|
||||
warn!("DNS propagation timeout, but continuing anyway");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn check_dns_txt_record(&self, record_name: &str, expected_value: &str) -> Result<bool, AcmeError> {
|
||||
use std::process::Command;
|
||||
|
||||
let output = Command::new("dig")
|
||||
.args(&["+short", "TXT", record_name])
|
||||
.output()
|
||||
.map_err(|e| AcmeError::Io(e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(AcmeError::Challenge("dig command failed".to_string()));
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8(output.stdout)
|
||||
.map_err(|_| AcmeError::Challenge("Invalid UTF-8 in dig output".to_string()))?;
|
||||
|
||||
// Parse TXT record (remove quotes)
|
||||
for line in stdout.lines() {
|
||||
let cleaned = line.trim().trim_matches('"');
|
||||
if cleaned == expected_value {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
async fn cleanup_dns_record(&self, base_domain: &str, record_id: &str) {
|
||||
if let Err(e) = self.cloudflare.delete_txt_record(base_domain, record_id).await {
|
||||
warn!("Failed to cleanup DNS record {}: {:?}", record_id, e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the base domain from a full domain (e.g., "api.example.com" -> "example.com")
|
||||
pub fn get_base_domain(domain: &str) -> Result<String, AcmeError> {
|
||||
let parts: Vec<&str> = domain.split('.').collect();
|
||||
if parts.len() < 2 {
|
||||
return Err(AcmeError::InvalidDomain("Domain must have at least 2 parts".to_string()));
|
||||
}
|
||||
|
||||
// Take the last two parts for base domain
|
||||
let base_domain = format!("{}.{}", parts[parts.len() - 2], parts[parts.len() - 1]);
|
||||
Ok(base_domain)
|
||||
}
|
||||
}
|
||||
199
src/services/acme/cloudflare.rs
Normal file
199
src/services/acme/cloudflare.rs
Normal file
@@ -0,0 +1,199 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Duration;
|
||||
use tracing::{debug, info};
|
||||
|
||||
use crate::services::acme::error::AcmeError;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct CloudflareZone {
|
||||
id: String,
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct CloudflareZonesResponse {
|
||||
result: Vec<CloudflareZone>,
|
||||
success: bool,
|
||||
errors: Option<Vec<CloudflareApiError>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct CloudflareDnsRecord {
|
||||
id: String,
|
||||
#[serde(rename = "type")]
|
||||
record_type: String,
|
||||
name: String,
|
||||
content: String,
|
||||
ttl: u32,
|
||||
proxied: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct CloudflareDnsRecordsResponse {
|
||||
result: Vec<CloudflareDnsRecord>,
|
||||
success: bool,
|
||||
errors: Option<Vec<CloudflareApiError>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct CreateDnsRecordRequest {
|
||||
#[serde(rename = "type")]
|
||||
record_type: String,
|
||||
name: String,
|
||||
content: String,
|
||||
ttl: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct CreateDnsRecordResponse {
|
||||
result: CloudflareDnsRecord,
|
||||
success: bool,
|
||||
errors: Option<Vec<CloudflareApiError>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct CloudflareApiError {
|
||||
code: u32,
|
||||
message: String,
|
||||
}
|
||||
|
||||
pub struct CloudflareClient {
|
||||
client: reqwest::Client,
|
||||
api_token: String,
|
||||
}
|
||||
|
||||
impl CloudflareClient {
|
||||
pub fn new(api_token: String) -> Result<Self, AcmeError> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.build()
|
||||
.map_err(|e| AcmeError::HttpRequest(e))?;
|
||||
|
||||
Ok(Self { client, api_token })
|
||||
}
|
||||
|
||||
async fn get_zone_id(&self, domain: &str) -> Result<String, AcmeError> {
|
||||
info!("Getting Cloudflare zone ID for domain: {}", domain);
|
||||
|
||||
let url = format!("https://api.cloudflare.com/client/v4/zones?name={}", domain);
|
||||
|
||||
let response = self.client
|
||||
.get(&url)
|
||||
.header("Authorization", format!("Bearer {}", self.api_token))
|
||||
.header("Content-Type", "application/json")
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(AcmeError::CloudflareApi(format!("HTTP {}: {}", status, body)));
|
||||
}
|
||||
|
||||
let zones: CloudflareZonesResponse = response.json().await?;
|
||||
|
||||
if !zones.success {
|
||||
let errors = zones.errors.unwrap_or_default();
|
||||
let error_messages: Vec<String> = errors.iter().map(|e| e.message.clone()).collect();
|
||||
return Err(AcmeError::CloudflareApi(format!("API errors: {}", error_messages.join(", "))));
|
||||
}
|
||||
|
||||
zones.result
|
||||
.into_iter()
|
||||
.find(|z| z.name == domain)
|
||||
.map(|z| z.id)
|
||||
.ok_or_else(|| AcmeError::CloudflareApi(format!("Zone not found for domain: {}", domain)))
|
||||
}
|
||||
|
||||
pub async fn create_txt_record(&self, domain: &str, record_name: &str, content: &str) -> Result<String, AcmeError> {
|
||||
let zone_id = self.get_zone_id(domain).await?;
|
||||
info!("Creating TXT record {} in zone {}", record_name, domain);
|
||||
|
||||
let request = CreateDnsRecordRequest {
|
||||
record_type: "TXT".to_string(),
|
||||
name: record_name.to_string(),
|
||||
content: content.to_string(),
|
||||
ttl: 120, // 2 minutes TTL for quick propagation
|
||||
};
|
||||
|
||||
let url = format!("https://api.cloudflare.com/client/v4/zones/{}/dns_records", zone_id);
|
||||
|
||||
let response = self.client
|
||||
.post(&url)
|
||||
.header("Authorization", format!("Bearer {}", self.api_token))
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&request)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(AcmeError::CloudflareApi(format!("Failed to create DNS record ({}): {}", status, body)));
|
||||
}
|
||||
|
||||
let result: CreateDnsRecordResponse = response.json().await?;
|
||||
|
||||
if !result.success {
|
||||
let errors = result.errors.unwrap_or_default();
|
||||
let error_messages: Vec<String> = errors.iter().map(|e| e.message.clone()).collect();
|
||||
return Err(AcmeError::CloudflareApi(format!("Failed to create record: {}", error_messages.join(", "))));
|
||||
}
|
||||
|
||||
debug!("Created DNS record with ID: {}", result.result.id);
|
||||
Ok(result.result.id)
|
||||
}
|
||||
|
||||
pub async fn delete_txt_record(&self, domain: &str, record_id: &str) -> Result<(), AcmeError> {
|
||||
let zone_id = self.get_zone_id(domain).await?;
|
||||
info!("Deleting TXT record {} from zone {}", record_id, domain);
|
||||
|
||||
let url = format!("https://api.cloudflare.com/client/v4/zones/{}/dns_records/{}", zone_id, record_id);
|
||||
|
||||
let response = self.client
|
||||
.delete(&url)
|
||||
.header("Authorization", format!("Bearer {}", self.api_token))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(AcmeError::CloudflareApi(format!("Failed to delete DNS record ({}): {}", status, body)));
|
||||
}
|
||||
|
||||
info!("Successfully deleted DNS record");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn find_txt_record(&self, domain: &str, record_name: &str) -> Result<Option<String>, AcmeError> {
|
||||
let zone_id = self.get_zone_id(domain).await?;
|
||||
|
||||
let url = format!(
|
||||
"https://api.cloudflare.com/client/v4/zones/{}/dns_records?type=TXT&name={}",
|
||||
zone_id, record_name
|
||||
);
|
||||
|
||||
let response = self.client
|
||||
.get(&url)
|
||||
.header("Authorization", format!("Bearer {}", self.api_token))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(AcmeError::CloudflareApi(format!("Failed to list DNS records ({}): {}", status, body)));
|
||||
}
|
||||
|
||||
let records: CloudflareDnsRecordsResponse = response.json().await?;
|
||||
|
||||
if !records.success {
|
||||
let errors = records.errors.unwrap_or_default();
|
||||
let error_messages: Vec<String> = errors.iter().map(|e| e.message.clone()).collect();
|
||||
return Err(AcmeError::CloudflareApi(format!("Failed to list records: {}", error_messages.join(", "))));
|
||||
}
|
||||
|
||||
Ok(records.result.first().map(|r| r.id.clone()))
|
||||
}
|
||||
}
|
||||
40
src/services/acme/error.rs
Normal file
40
src/services/acme/error.rs
Normal file
@@ -0,0 +1,40 @@
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum AcmeError {
|
||||
#[error("ACME account creation failed: {0}")]
|
||||
AccountCreation(String),
|
||||
|
||||
#[error("ACME order creation failed: {0}")]
|
||||
OrderCreation(String),
|
||||
|
||||
#[error("ACME challenge failed: {0}")]
|
||||
Challenge(String),
|
||||
|
||||
#[error("DNS propagation timeout")]
|
||||
DnsPropagationTimeout,
|
||||
|
||||
#[error("Certificate generation failed: {0}")]
|
||||
CertificateGeneration(String),
|
||||
|
||||
#[error("Cloudflare API error: {0}")]
|
||||
CloudflareApi(String),
|
||||
|
||||
#[error("DNS provider not found")]
|
||||
DnsProviderNotFound,
|
||||
|
||||
#[error("Invalid domain: {0}")]
|
||||
InvalidDomain(String),
|
||||
|
||||
#[error("HTTP request failed: {0}")]
|
||||
HttpRequest(#[from] reqwest::Error),
|
||||
|
||||
#[error("JSON parsing failed: {0}")]
|
||||
JsonParsing(#[from] serde_json::Error),
|
||||
|
||||
#[error("IO error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
#[error("Instant ACME error: {0}")]
|
||||
InstantAcme(String),
|
||||
}
|
||||
7
src/services/acme/mod.rs
Normal file
7
src/services/acme/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
pub mod client;
|
||||
pub mod cloudflare;
|
||||
pub mod error;
|
||||
|
||||
pub use client::AcmeClient;
|
||||
pub use cloudflare::CloudflareClient;
|
||||
pub use error::AcmeError;
|
||||
@@ -1,16 +1,27 @@
|
||||
use rcgen::{Certificate, CertificateParams, DistinguishedName, DnType, SanType, KeyPair, PKCS_ECDSA_P256_SHA256};
|
||||
use std::net::IpAddr;
|
||||
use time::{Duration, OffsetDateTime};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::database::repository::DnsProviderRepository;
|
||||
use crate::database::entities::dns_provider::DnsProviderType;
|
||||
use crate::services::acme::{AcmeClient, AcmeError};
|
||||
use sea_orm::DatabaseConnection;
|
||||
|
||||
/// Certificate management service
|
||||
#[derive(Clone)]
|
||||
pub struct CertificateService {
|
||||
db: Option<DatabaseConnection>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl CertificateService {
|
||||
pub fn new() -> Self {
|
||||
Self {}
|
||||
Self { db: None }
|
||||
}
|
||||
|
||||
pub fn with_db(db: DatabaseConnection) -> Self {
|
||||
Self { db: Some(db) }
|
||||
}
|
||||
|
||||
/// Generate self-signed certificate optimized for Xray
|
||||
@@ -87,11 +98,132 @@ impl CertificateService {
|
||||
}
|
||||
|
||||
|
||||
/// Renew certificate
|
||||
/// Generate Let's Encrypt certificate using DNS challenge
|
||||
pub async fn generate_letsencrypt_certificate(
|
||||
&self,
|
||||
domain: &str,
|
||||
dns_provider_id: Uuid,
|
||||
acme_email: &str,
|
||||
staging: bool,
|
||||
) -> Result<(String, String), AcmeError> {
|
||||
tracing::info!("Generating Let's Encrypt certificate for domain: {} using DNS challenge", domain);
|
||||
|
||||
// Get database connection
|
||||
let db = self.db.as_ref()
|
||||
.ok_or_else(|| AcmeError::DnsProviderNotFound)?;
|
||||
|
||||
// Get DNS provider
|
||||
let dns_repo = DnsProviderRepository::new(db.clone());
|
||||
let dns_provider = dns_repo.find_by_id(dns_provider_id)
|
||||
.await
|
||||
.map_err(|_| AcmeError::DnsProviderNotFound)?
|
||||
.ok_or_else(|| AcmeError::DnsProviderNotFound)?;
|
||||
|
||||
// Verify provider is Cloudflare (only supported provider for now)
|
||||
if dns_provider.provider_type != DnsProviderType::Cloudflare.as_str() {
|
||||
return Err(AcmeError::CloudflareApi("Only Cloudflare provider is supported".to_string()));
|
||||
}
|
||||
|
||||
if !dns_provider.is_active {
|
||||
return Err(AcmeError::DnsProviderNotFound);
|
||||
}
|
||||
|
||||
// Determine ACME directory URL
|
||||
let directory_url = if staging {
|
||||
"https://acme-staging-v02.api.letsencrypt.org/directory"
|
||||
} else {
|
||||
"https://acme-v02.api.letsencrypt.org/directory"
|
||||
};
|
||||
|
||||
// Create ACME client
|
||||
let mut acme_client = AcmeClient::new(
|
||||
dns_provider.api_token.clone(),
|
||||
acme_email,
|
||||
directory_url.to_string(),
|
||||
).await?;
|
||||
|
||||
// Get base domain for DNS operations
|
||||
let base_domain = AcmeClient::get_base_domain(domain)?;
|
||||
|
||||
// Generate certificate
|
||||
let (cert_pem, key_pem) = acme_client
|
||||
.get_certificate(domain, &base_domain)
|
||||
.await?;
|
||||
|
||||
tracing::info!("Successfully generated Let's Encrypt certificate for domain: {}", domain);
|
||||
Ok((cert_pem, key_pem))
|
||||
}
|
||||
|
||||
/// Renew certificate by ID (used for manual renewal)
|
||||
pub async fn renew_certificate_by_id(&self, cert_id: Uuid) -> anyhow::Result<(String, String)> {
|
||||
let db = self.db.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Database connection not available"))?;
|
||||
|
||||
// Get the certificate from database
|
||||
let cert_repo = crate::database::repository::CertificateRepository::new(db.clone());
|
||||
let certificate = cert_repo.find_by_id(cert_id)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow::anyhow!("Certificate not found"))?;
|
||||
|
||||
tracing::info!("Renewing certificate '{}' for domain: {}", certificate.name, certificate.domain);
|
||||
|
||||
match certificate.cert_type.as_str() {
|
||||
"letsencrypt" => {
|
||||
// For Let's Encrypt, we need to regenerate using ACME
|
||||
// Find an active Cloudflare DNS provider
|
||||
let dns_repo = crate::database::repository::DnsProviderRepository::new(db.clone());
|
||||
let providers = dns_repo.find_active_by_type("cloudflare").await?;
|
||||
|
||||
if providers.is_empty() {
|
||||
return Err(anyhow::anyhow!("No active Cloudflare DNS provider found for Let's Encrypt renewal"));
|
||||
}
|
||||
|
||||
let dns_provider = &providers[0];
|
||||
let acme_email = "admin@example.com"; // TODO: Store this with certificate
|
||||
|
||||
// Generate new certificate
|
||||
let (cert_pem, key_pem) = self.generate_letsencrypt_certificate(
|
||||
&certificate.domain,
|
||||
dns_provider.id,
|
||||
acme_email,
|
||||
false, // Production
|
||||
).await?;
|
||||
|
||||
// Update in database
|
||||
cert_repo.update_certificate_data(
|
||||
cert_id,
|
||||
&cert_pem,
|
||||
&key_pem,
|
||||
chrono::Utc::now() + chrono::Duration::days(90),
|
||||
).await?;
|
||||
|
||||
Ok((cert_pem, key_pem))
|
||||
}
|
||||
"self_signed" => {
|
||||
// For self-signed, generate a new one
|
||||
let (cert_pem, key_pem) = self.generate_self_signed(&certificate.domain).await?;
|
||||
|
||||
// Update in database
|
||||
cert_repo.update_certificate_data(
|
||||
cert_id,
|
||||
&cert_pem,
|
||||
&key_pem,
|
||||
chrono::Utc::now() + chrono::Duration::days(365),
|
||||
).await?;
|
||||
|
||||
Ok((cert_pem, key_pem))
|
||||
}
|
||||
_ => {
|
||||
Err(anyhow::anyhow!("Cannot renew imported certificates automatically"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Renew certificate (legacy method for backward compatibility)
|
||||
pub async fn renew_certificate(&self, domain: &str) -> anyhow::Result<(String, String)> {
|
||||
tracing::info!("Renewing certificate for domain: {}", domain);
|
||||
|
||||
// For now, just generate a new self-signed certificate
|
||||
// For backward compatibility, just generate a new self-signed certificate
|
||||
self.generate_self_signed(domain).await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod xray;
|
||||
pub mod acme;
|
||||
pub mod certificates;
|
||||
pub mod events;
|
||||
pub mod tasks;
|
||||
@@ -6,4 +7,5 @@ pub mod uri_generator;
|
||||
|
||||
pub use xray::XrayService;
|
||||
pub use tasks::TaskScheduler;
|
||||
pub use uri_generator::UriGeneratorService;
|
||||
pub use uri_generator::UriGeneratorService;
|
||||
pub use certificates::CertificateService;
|
||||
@@ -1,6 +1,6 @@
|
||||
use anyhow::Result;
|
||||
use tokio_cron_scheduler::{JobScheduler, Job};
|
||||
use tracing::{info, error, warn};
|
||||
use tracing::{info, error, warn, debug};
|
||||
use crate::database::DatabaseManager;
|
||||
use crate::database::repository::{ServerRepository, ServerInboundRepository, InboundTemplateRepository, InboundUsersRepository, CertificateRepository, UserRepository};
|
||||
use crate::database::entities::inbound_users;
|
||||
@@ -161,6 +161,80 @@ impl TaskScheduler {
|
||||
|
||||
self.scheduler.add(sync_job).await?;
|
||||
|
||||
// Add certificate renewal task that runs once a day at 2 AM
|
||||
let db_clone_cert = db.clone();
|
||||
let task_status_cert = self.task_status.clone();
|
||||
|
||||
// Initialize certificate renewal task status
|
||||
{
|
||||
let mut status = self.task_status.write().unwrap();
|
||||
status.insert("cert_renewal".to_string(), TaskStatus {
|
||||
name: "Certificate Renewal".to_string(),
|
||||
description: "Renews Let's Encrypt certificates that expire within 15 days".to_string(),
|
||||
schedule: "0 0 2 * * * (daily at 2 AM)".to_string(),
|
||||
status: TaskState::Idle,
|
||||
last_run: None,
|
||||
next_run: Some(Utc::now() + chrono::Duration::days(1)),
|
||||
total_runs: 0,
|
||||
success_count: 0,
|
||||
error_count: 0,
|
||||
last_error: None,
|
||||
last_duration_ms: None,
|
||||
});
|
||||
}
|
||||
|
||||
let cert_renewal_job = Job::new_async("0 0 2 * * *", move |_uuid, _l| {
|
||||
let db = db_clone_cert.clone();
|
||||
let task_status = task_status_cert.clone();
|
||||
|
||||
Box::pin(async move {
|
||||
let start_time = Utc::now();
|
||||
|
||||
// Update task status to running
|
||||
{
|
||||
let mut status = task_status.write().unwrap();
|
||||
if let Some(task) = status.get_mut("cert_renewal") {
|
||||
task.status = TaskState::Running;
|
||||
task.last_run = Some(Utc::now());
|
||||
task.total_runs += 1;
|
||||
}
|
||||
}
|
||||
|
||||
match check_and_renew_certificates(&db).await {
|
||||
Ok(_) => {
|
||||
let duration = (Utc::now() - start_time).num_milliseconds() as u64;
|
||||
let mut status = task_status.write().unwrap();
|
||||
if let Some(task) = status.get_mut("cert_renewal") {
|
||||
task.status = TaskState::Success;
|
||||
task.success_count += 1;
|
||||
task.last_duration_ms = Some(duration);
|
||||
task.last_error = None;
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
let duration = (Utc::now() - start_time).num_milliseconds() as u64;
|
||||
let mut status = task_status.write().unwrap();
|
||||
if let Some(task) = status.get_mut("cert_renewal") {
|
||||
task.status = TaskState::Error;
|
||||
task.error_count += 1;
|
||||
task.last_duration_ms = Some(duration);
|
||||
task.last_error = Some(e.to_string());
|
||||
}
|
||||
error!("Certificate renewal task failed: {}", e);
|
||||
}
|
||||
}
|
||||
})
|
||||
})?;
|
||||
|
||||
self.scheduler.add(cert_renewal_job).await?;
|
||||
|
||||
// Also run certificate check on startup
|
||||
info!("Running initial certificate renewal check...");
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = check_and_renew_certificates(&db).await {
|
||||
error!("Initial certificate renewal check failed: {}", e);
|
||||
}
|
||||
});
|
||||
|
||||
self.scheduler.start().await?;
|
||||
Ok(())
|
||||
@@ -285,15 +359,20 @@ async fn get_desired_inbounds_from_db(
|
||||
let port = inbound.port_override.unwrap_or(template.default_port);
|
||||
|
||||
// Get certificate if specified
|
||||
let (cert_pem, key_pem) = if let Some(_cert_id) = inbound.certificate_id {
|
||||
let (cert_pem, key_pem) = if let Some(cert_id) = inbound.certificate_id {
|
||||
match load_certificate_from_db(db, inbound.certificate_id).await {
|
||||
Ok((cert, key)) => (cert, key),
|
||||
Ok((cert, key)) => {
|
||||
info!("Loaded certificate {} for inbound {}, has_cert={}, has_key={}",
|
||||
cert_id, inbound.tag, cert.is_some(), key.is_some());
|
||||
(cert, key)
|
||||
},
|
||||
Err(e) => {
|
||||
warn!("Failed to load certificate for inbound {}: {}", inbound.tag, e);
|
||||
warn!("Failed to load certificate {} for inbound {}: {}", cert_id, inbound.tag, e);
|
||||
(None, None)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
debug!("No certificate configured for inbound {}", inbound.tag);
|
||||
(None, None)
|
||||
};
|
||||
|
||||
@@ -422,4 +501,129 @@ pub struct XrayUser {
|
||||
pub id: String,
|
||||
pub email: String,
|
||||
pub level: i32,
|
||||
}
|
||||
|
||||
/// Check and renew certificates that expire within 15 days
|
||||
async fn check_and_renew_certificates(db: &DatabaseManager) -> Result<()> {
|
||||
use crate::services::certificates::CertificateService;
|
||||
use crate::database::repository::DnsProviderRepository;
|
||||
|
||||
info!("Starting certificate renewal check...");
|
||||
|
||||
let cert_repo = CertificateRepository::new(db.connection().clone());
|
||||
let dns_repo = DnsProviderRepository::new(db.connection().clone());
|
||||
let cert_service = CertificateService::with_db(db.connection().clone());
|
||||
|
||||
// Get all certificates
|
||||
let certificates = cert_repo.find_all().await?;
|
||||
let mut renewed_count = 0;
|
||||
let mut checked_count = 0;
|
||||
|
||||
for cert in certificates {
|
||||
// Only check Let's Encrypt certificates with auto_renew enabled
|
||||
if cert.cert_type != "letsencrypt" || !cert.auto_renew {
|
||||
continue;
|
||||
}
|
||||
|
||||
checked_count += 1;
|
||||
|
||||
// Check if certificate expires within 15 days
|
||||
if cert.expires_soon(15) {
|
||||
info!(
|
||||
"Certificate '{}' (ID: {}) expires at {} - renewing...",
|
||||
cert.name, cert.id, cert.expires_at
|
||||
);
|
||||
|
||||
// Find the DNS provider used for this certificate
|
||||
// For now, we'll use the first active Cloudflare provider
|
||||
// In production, you might want to store the provider ID with the certificate
|
||||
let providers = dns_repo.find_active_by_type("cloudflare").await?;
|
||||
|
||||
if providers.is_empty() {
|
||||
error!(
|
||||
"Cannot renew certificate '{}': No active Cloudflare DNS provider found",
|
||||
cert.name
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
let dns_provider = &providers[0];
|
||||
|
||||
// Need to get the ACME email - for now using a default
|
||||
// In production, this should be stored with the certificate
|
||||
let acme_email = "admin@example.com"; // TODO: Store this with certificate
|
||||
|
||||
// Attempt to renew the certificate
|
||||
match cert_service.generate_letsencrypt_certificate(
|
||||
&cert.domain,
|
||||
dns_provider.id,
|
||||
acme_email,
|
||||
false, // Use production Let's Encrypt
|
||||
).await {
|
||||
Ok((new_cert_pem, new_key_pem)) => {
|
||||
// Update the certificate in database
|
||||
match cert_repo.update_certificate_data(
|
||||
cert.id,
|
||||
&new_cert_pem,
|
||||
&new_key_pem,
|
||||
chrono::Utc::now() + chrono::Duration::days(90), // Let's Encrypt certs are valid for 90 days
|
||||
).await {
|
||||
Ok(_) => {
|
||||
info!("Successfully renewed certificate '{}'", cert.name);
|
||||
renewed_count += 1;
|
||||
|
||||
// Trigger sync for all servers using this certificate
|
||||
// This will be done via the event system
|
||||
if let Err(e) = trigger_cert_renewal_sync(db, cert.id).await {
|
||||
error!("Failed to trigger sync after certificate renewal: {}", e);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to save renewed certificate '{}' to database: {}", cert.name, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to renew certificate '{}': {:?}", cert.name, e);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
debug!(
|
||||
"Certificate '{}' expires at {} - no renewal needed yet",
|
||||
cert.name, cert.expires_at
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
info!(
|
||||
"Certificate renewal check completed: checked {}, renewed {}",
|
||||
checked_count, renewed_count
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Trigger sync for all servers that use a specific certificate
|
||||
async fn trigger_cert_renewal_sync(db: &DatabaseManager, cert_id: Uuid) -> Result<()> {
|
||||
use crate::services::events::send_sync_event;
|
||||
use crate::services::events::SyncEvent;
|
||||
|
||||
let inbound_repo = ServerInboundRepository::new(db.connection().clone());
|
||||
|
||||
// Find all server inbounds that use this certificate
|
||||
let inbounds = inbound_repo.find_by_certificate_id(cert_id).await?;
|
||||
|
||||
// Collect unique server IDs
|
||||
let mut server_ids = std::collections::HashSet::new();
|
||||
for inbound in inbounds {
|
||||
server_ids.insert(inbound.server_id);
|
||||
}
|
||||
|
||||
// Trigger sync for each server
|
||||
for server_id in server_ids {
|
||||
info!("Triggering sync for server {} after certificate renewal", server_id);
|
||||
send_sync_event(SyncEvent::InboundChanged(server_id));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -60,7 +60,12 @@ impl<'a> InboundClient<'a> {
|
||||
let tag = inbound["tag"].as_str().unwrap_or("").to_string();
|
||||
let port = inbound["port"].as_u64().unwrap_or(8080) as u32;
|
||||
let protocol = inbound["protocol"].as_str().unwrap_or("vless");
|
||||
let user_count = users.map_or(0, |u| u.len());
|
||||
let _user_count = users.map_or(0, |u| u.len());
|
||||
|
||||
tracing::info!(
|
||||
"Adding inbound '{}' with protocol={}, port={}, has_cert={}, has_key={}",
|
||||
tag, protocol, port, cert_pem.is_some(), key_pem.is_some()
|
||||
);
|
||||
|
||||
|
||||
// Create receiver configuration (port binding) - use simple port number
|
||||
|
||||
Reference in New Issue
Block a user