Letsencrypt works

This commit is contained in:
Ultradesu
2025-09-24 00:30:03 +01:00
parent 59b8cbb582
commit 76afa0797b
26 changed files with 3169 additions and 60 deletions

890
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -58,5 +58,12 @@ rcgen = { version = "0.12", features = ["pem"] } # For self-signed certifi
time = "0.3" # For certificate date/time handling
base64 = "0.21" # For PEM to DER conversion
# ACME/Let's Encrypt support
instant-acme = "0.8" # ACME client for Let's Encrypt
reqwest = { version = "0.11", features = ["json", "rustls-tls"] } # HTTP client for Cloudflare API
rustls = { version = "0.23", features = ["aws-lc-rs"] } # TLS library with aws-lc-rs crypto provider
ring = "0.17" # Crypto for ACME
pem = "3.0" # PEM format support
[dev-dependencies]
tempfile = "3.0"

View File

@@ -86,6 +86,7 @@ impl ActiveModelBehavior for ActiveModel {
pub enum CertificateType {
SelfSigned,
Imported,
LetsEncrypt,
}
impl From<CertificateType> for String {
@@ -93,6 +94,7 @@ impl From<CertificateType> for String {
match cert_type {
CertificateType::SelfSigned => "self_signed".to_string(),
CertificateType::Imported => "imported".to_string(),
CertificateType::LetsEncrypt => "letsencrypt".to_string(),
}
}
}
@@ -102,6 +104,7 @@ impl From<String> for CertificateType {
match s.as_str() {
"self_signed" => CertificateType::SelfSigned,
"imported" => CertificateType::Imported,
"letsencrypt" => CertificateType::LetsEncrypt,
_ => CertificateType::SelfSigned,
}
}
@@ -117,6 +120,9 @@ pub struct CreateCertificateDto {
pub certificate_pem: String,
#[serde(default)]
pub private_key: String,
// For Let's Encrypt certificates via DNS challenge
pub dns_provider_id: Option<Uuid>,
pub acme_email: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]

View File

@@ -0,0 +1,156 @@
use sea_orm::entity::prelude::*;
use sea_orm::{Set, ActiveModelTrait};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
#[sea_orm(table_name = "dns_providers")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: Uuid,
pub name: String,
pub provider_type: String, // "cloudflare", "route53", etc.
#[serde(skip_serializing)]
pub api_token: String, // Encrypted storage in production
pub is_active: bool,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {
fn new() -> Self {
Self {
id: Set(Uuid::new_v4()),
created_at: Set(chrono::Utc::now()),
updated_at: Set(chrono::Utc::now()),
..ActiveModelTrait::default()
}
}
fn before_save<'life0, 'async_trait, C>(
mut self,
_db: &'life0 C,
insert: bool,
) -> core::pin::Pin<Box<dyn core::future::Future<Output = Result<Self, DbErr>> + Send + 'async_trait>>
where
'life0: 'async_trait,
C: 'async_trait + ConnectionTrait,
Self: 'async_trait,
{
Box::pin(async move {
if !insert {
self.updated_at = Set(chrono::Utc::now());
}
Ok(self)
})
}
}
// DTOs for API requests/responses
#[derive(Debug, Serialize, Deserialize)]
pub struct CreateDnsProviderDto {
pub name: String,
pub provider_type: String,
pub api_token: String,
pub is_active: Option<bool>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct UpdateDnsProviderDto {
pub name: Option<String>,
pub api_token: Option<String>,
pub is_active: Option<bool>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct DnsProviderResponseDto {
pub id: Uuid,
pub name: String,
pub provider_type: String,
pub is_active: bool,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
pub has_token: bool, // Don't expose actual token
}
impl From<CreateDnsProviderDto> for ActiveModel {
fn from(dto: CreateDnsProviderDto) -> Self {
ActiveModel {
id: Set(Uuid::new_v4()),
name: Set(dto.name),
provider_type: Set(dto.provider_type),
api_token: Set(dto.api_token),
is_active: Set(dto.is_active.unwrap_or(true)),
created_at: Set(chrono::Utc::now()),
updated_at: Set(chrono::Utc::now()),
}
}
}
impl Model {
/// Update this model with data from UpdateDnsProviderDto
pub fn apply_update(self, dto: UpdateDnsProviderDto) -> ActiveModel {
let mut active_model: ActiveModel = self.into();
if let Some(name) = dto.name {
active_model.name = Set(name);
}
if let Some(api_token) = dto.api_token {
active_model.api_token = Set(api_token);
}
if let Some(is_active) = dto.is_active {
active_model.is_active = Set(is_active);
}
active_model.updated_at = Set(chrono::Utc::now());
active_model
}
/// Convert to response DTO (without exposing API token)
pub fn to_response_dto(&self) -> DnsProviderResponseDto {
DnsProviderResponseDto {
id: self.id,
name: self.name.clone(),
provider_type: self.provider_type.clone(),
is_active: self.is_active,
created_at: self.created_at,
updated_at: self.updated_at,
has_token: !self.api_token.is_empty(),
}
}
}
/// Supported DNS provider types
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum DnsProviderType {
#[serde(rename = "cloudflare")]
Cloudflare,
}
impl DnsProviderType {
pub fn as_str(&self) -> &'static str {
match self {
DnsProviderType::Cloudflare => "cloudflare",
}
}
pub fn from_str(s: &str) -> Option<Self> {
match s {
"cloudflare" => Some(DnsProviderType::Cloudflare),
_ => None,
}
}
pub fn all() -> Vec<Self> {
vec![DnsProviderType::Cloudflare]
}
}

View File

@@ -1,5 +1,6 @@
pub mod user;
pub mod certificate;
pub mod dns_provider;
pub mod inbound_template;
pub mod server;
pub mod server_inbound;
@@ -7,7 +8,9 @@ pub mod user_access;
pub mod inbound_users;
pub mod prelude {
pub use super::user::Entity as User;
pub use super::certificate::Entity as Certificate;
pub use super::dns_provider::Entity as DnsProvider;
pub use super::inbound_template::Entity as InboundTemplate;
pub use super::server::Entity as Server;
pub use super::server_inbound::Entity as ServerInbound;

View File

@@ -0,0 +1,96 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
Table::create()
.table(DnsProviders::Table)
.if_not_exists()
.col(
ColumnDef::new(DnsProviders::Id)
.uuid()
.not_null()
.primary_key(),
)
.col(
ColumnDef::new(DnsProviders::Name)
.string_len(255)
.not_null(),
)
.col(
ColumnDef::new(DnsProviders::ProviderType)
.string_len(50)
.not_null(),
)
.col(
ColumnDef::new(DnsProviders::ApiToken)
.text()
.not_null(),
)
.col(
ColumnDef::new(DnsProviders::IsActive)
.boolean()
.default(true)
.not_null(),
)
.col(
ColumnDef::new(DnsProviders::CreatedAt)
.timestamp_with_time_zone()
.not_null(),
)
.col(
ColumnDef::new(DnsProviders::UpdatedAt)
.timestamp_with_time_zone()
.not_null(),
)
.to_owned(),
)
.await?;
// Index on name for faster lookups
manager
.create_index(
Index::create()
.if_not_exists()
.name("idx_dns_providers_name")
.table(DnsProviders::Table)
.col(DnsProviders::Name)
.to_owned(),
)
.await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_index(
Index::drop()
.if_exists()
.name("idx_dns_providers_name")
.to_owned(),
)
.await?;
manager
.drop_table(Table::drop().table(DnsProviders::Table).to_owned())
.await
}
}
#[derive(DeriveIden)]
enum DnsProviders {
Table,
Id,
Name,
ProviderType,
ApiToken,
IsActive,
CreatedAt,
UpdatedAt,
}

View File

@@ -9,6 +9,7 @@ mod m20241201_000006_create_user_access_table;
mod m20241201_000007_create_inbound_users_table;
mod m20250919_000001_update_inbound_users_schema;
mod m20250922_000001_add_grpc_hostname_to_servers;
mod m20250923_000001_create_dns_providers_table;
pub struct Migrator;
@@ -25,6 +26,7 @@ impl MigratorTrait for Migrator {
Box::new(m20241201_000007_create_inbound_users_table::Migration),
Box::new(m20250919_000001_update_inbound_users_schema::Migration),
Box::new(m20250922_000001_add_grpc_hostname_to_servers::Migration),
Box::new(m20250923_000001_create_dns_providers_table::Migration),
]
}
}

View File

@@ -72,4 +72,26 @@ impl CertificateRepository {
.all(&self.db)
.await?)
}
/// Update certificate data (cert and key) and expiration date
pub async fn update_certificate_data(
&self,
id: Uuid,
cert_pem: &str,
key_pem: &str,
expires_at: chrono::DateTime<chrono::Utc>
) -> Result<certificate::Model> {
let mut cert: certificate::ActiveModel = Certificate::find_by_id(id)
.one(&self.db)
.await?
.ok_or_else(|| anyhow::anyhow!("Certificate not found"))?
.into();
cert.cert_data = Set(cert_pem.as_bytes().to_vec());
cert.key_data = Set(key_pem.as_bytes().to_vec());
cert.expires_at = Set(expires_at);
cert.updated_at = Set(chrono::Utc::now());
Ok(cert.update(&self.db).await?)
}
}

View File

@@ -0,0 +1,132 @@
use anyhow::Result;
use sea_orm::{ActiveModelTrait, DatabaseConnection, EntityTrait, ColumnTrait, QueryFilter, Set, PaginatorTrait};
use uuid::Uuid;
use crate::database::entities::dns_provider::{
Entity, Model, ActiveModel, CreateDnsProviderDto, UpdateDnsProviderDto, Column, DnsProviderType
};
pub struct DnsProviderRepository {
db: DatabaseConnection,
}
impl DnsProviderRepository {
pub fn new(db: DatabaseConnection) -> Self {
Self { db }
}
pub async fn find_all(&self) -> Result<Vec<Model>> {
let providers = Entity::find().all(&self.db).await?;
Ok(providers)
}
pub async fn find_active(&self) -> Result<Vec<Model>> {
let providers = Entity::find()
.filter(Column::IsActive.eq(true))
.all(&self.db)
.await?;
Ok(providers)
}
pub async fn find_by_id(&self, id: Uuid) -> Result<Option<Model>> {
let provider = Entity::find_by_id(id).one(&self.db).await?;
Ok(provider)
}
pub async fn find_by_name(&self, name: &str) -> Result<Option<Model>> {
let provider = Entity::find()
.filter(Column::Name.eq(name))
.one(&self.db)
.await?;
Ok(provider)
}
pub async fn find_by_type(&self, provider_type: &str) -> Result<Vec<Model>> {
let providers = Entity::find()
.filter(Column::ProviderType.eq(provider_type))
.all(&self.db)
.await?;
Ok(providers)
}
pub async fn find_active_by_type(&self, provider_type: &str) -> Result<Vec<Model>> {
let providers = Entity::find()
.filter(Column::ProviderType.eq(provider_type))
.filter(Column::IsActive.eq(true))
.all(&self.db)
.await?;
Ok(providers)
}
pub async fn create(&self, dto: CreateDnsProviderDto) -> Result<Model> {
let active_model: ActiveModel = dto.into();
let provider = active_model.insert(&self.db).await?;
Ok(provider)
}
pub async fn update(&self, id: Uuid, dto: UpdateDnsProviderDto) -> Result<Option<Model>> {
let provider = match self.find_by_id(id).await? {
Some(provider) => provider,
None => return Ok(None),
};
let updated_model = provider.apply_update(dto);
let updated_provider = updated_model.update(&self.db).await?;
Ok(Some(updated_provider))
}
pub async fn delete(&self, id: Uuid) -> Result<bool> {
let result = Entity::delete_by_id(id).exec(&self.db).await?;
Ok(result.rows_affected > 0)
}
pub async fn enable(&self, id: Uuid) -> Result<Option<Model>> {
let provider = match self.find_by_id(id).await? {
Some(provider) => provider,
None => return Ok(None),
};
let mut active_model: ActiveModel = provider.into();
active_model.is_active = Set(true);
active_model.updated_at = Set(chrono::Utc::now());
let updated_provider = active_model.update(&self.db).await?;
Ok(Some(updated_provider))
}
pub async fn disable(&self, id: Uuid) -> Result<Option<Model>> {
let provider = match self.find_by_id(id).await? {
Some(provider) => provider,
None => return Ok(None),
};
let mut active_model: ActiveModel = provider.into();
active_model.is_active = Set(false);
active_model.updated_at = Set(chrono::Utc::now());
let updated_provider = active_model.update(&self.db).await?;
Ok(Some(updated_provider))
}
/// Check if a provider name already exists
pub async fn name_exists(&self, name: &str, exclude_id: Option<Uuid>) -> Result<bool> {
let mut query = Entity::find().filter(Column::Name.eq(name));
if let Some(id) = exclude_id {
query = query.filter(Column::Id.ne(id));
}
let count = query.count(&self.db).await?;
Ok(count > 0)
}
/// Get the first active provider of a specific type
pub async fn get_active_provider_by_type(&self, provider_type: DnsProviderType) -> Result<Option<Model>> {
let provider = Entity::find()
.filter(Column::ProviderType.eq(provider_type.as_str()))
.filter(Column::IsActive.eq(true))
.one(&self.db)
.await?;
Ok(provider)
}
}

View File

@@ -1,5 +1,6 @@
pub mod user;
pub mod certificate;
pub mod dns_provider;
pub mod inbound_template;
pub mod server;
pub mod server_inbound;
@@ -8,6 +9,7 @@ pub mod inbound_users;
pub use user::UserRepository;
pub use certificate::CertificateRepository;
pub use dns_provider::DnsProviderRepository;
pub use inbound_template::InboundTemplateRepository;
pub use server::ServerRepository;
pub use server_inbound::ServerInboundRepository;

View File

@@ -107,6 +107,13 @@ impl ServerInboundRepository {
.await?)
}
pub async fn find_by_certificate_id(&self, certificate_id: Uuid) -> Result<Vec<server_inbound::Model>> {
Ok(ServerInbound::find()
.filter(server_inbound::Column::CertificateId.eq(certificate_id))
.all(&self.db)
.await?)
}
pub async fn find_active_by_server(&self, server_id: Uuid) -> Result<Vec<server_inbound::Model>> {
Ok(ServerInbound::find()
.filter(server_inbound::Column::ServerId.eq(server_id))

View File

@@ -12,6 +12,11 @@ use services::{TaskScheduler, XrayService};
#[tokio::main]
async fn main() -> Result<()> {
// Initialize default crypto provider for rustls
rustls::crypto::aws_lc_rs::default_provider()
.install_default()
.expect("Failed to install rustls crypto provider");
// Parse command line arguments first
let args = parse_args();

287
src/services/acme/client.rs Normal file
View 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)
}
}

View 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()))
}
}

View 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
View 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;

View File

@@ -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
}
}

View File

@@ -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;

View File

@@ -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(())
}

View File

@@ -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

View File

@@ -4,6 +4,7 @@ use axum::{
response::Json,
Json as JsonExtractor,
};
use serde_json::json;
use uuid::Uuid;
use crate::{
database::{
@@ -64,7 +65,7 @@ pub async fn get_certificate_details(
pub async fn create_certificate(
State(app_state): State<AppState>,
JsonExtractor(cert_data): JsonExtractor<certificate::CreateCertificateDto>,
) -> Result<Json<certificate::CertificateResponse>, StatusCode> {
) -> Result<Json<certificate::CertificateResponse>, (StatusCode, Json<serde_json::Value>)> {
tracing::info!("Creating certificate: {:?}", cert_data);
let repo = CertificateRepository::new(app_state.db.connection().clone());
let cert_service = CertificateService::new();
@@ -73,9 +74,54 @@ pub async fn create_certificate(
let (cert_pem, private_key) = match cert_data.cert_type.as_str() {
"self_signed" => {
cert_service.generate_self_signed(&cert_data.domain).await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.map_err(|e| {
tracing::error!("Failed to generate self-signed certificate: {:?}", e);
(StatusCode::INTERNAL_SERVER_ERROR, Json(json!({
"error": "Failed to generate self-signed certificate",
"details": format!("{:?}", e)
})))
})?
}
_ => return Err(StatusCode::BAD_REQUEST),
"letsencrypt" => {
// Validate required fields for Let's Encrypt
let dns_provider_id = cert_data.dns_provider_id
.ok_or((StatusCode::BAD_REQUEST, Json(json!({
"error": "DNS provider ID is required for Let's Encrypt certificates"
}))))?;
let acme_email = cert_data.acme_email
.as_ref()
.ok_or((StatusCode::BAD_REQUEST, Json(json!({
"error": "ACME email is required for Let's Encrypt certificates"
}))))?;
let cert_service = CertificateService::with_db(app_state.db.connection().clone());
cert_service.generate_letsencrypt_certificate(
&cert_data.domain,
dns_provider_id,
acme_email,
false // production by default
).await
.map_err(|e| {
tracing::error!("Failed to generate Let's Encrypt certificate: {:?}", e);
// Return a more detailed error response
(StatusCode::INTERNAL_SERVER_ERROR, Json(json!({
"error": "Failed to generate Let's Encrypt certificate",
"details": format!("{:?}", e)
})))
})?
}
"imported" => {
// For imported certificates, use provided PEM data
if cert_data.certificate_pem.is_empty() || cert_data.private_key.is_empty() {
return Err((StatusCode::BAD_REQUEST, Json(json!({
"error": "Certificate PEM and private key are required for imported certificates"
}))));
}
(cert_data.certificate_pem.clone(), cert_data.private_key.clone())
}
_ => return Err((StatusCode::BAD_REQUEST, Json(json!({
"error": "Invalid certificate type. Supported types: self_signed, letsencrypt, imported"
})))),
};
// Create certificate with generated data
@@ -85,7 +131,13 @@ pub async fn create_certificate(
match repo.create(create_dto).await {
Ok(certificate) => Ok(Json(certificate.into())),
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
Err(e) => {
tracing::error!("Failed to save certificate to database: {:?}", e);
Err((StatusCode::INTERNAL_SERVER_ERROR, Json(json!({
"error": "Failed to save certificate to database",
"details": format!("{:?}", e)
}))))
}
}
}

View File

@@ -0,0 +1,102 @@
use axum::{
extract::{Path, State},
http::StatusCode,
response::Json,
};
use uuid::Uuid;
use crate::{
database::{
entities::dns_provider::{
CreateDnsProviderDto, UpdateDnsProviderDto, DnsProviderResponseDto,
},
repository::DnsProviderRepository,
},
web::AppState,
};
pub async fn create_dns_provider(
State(state): State<AppState>,
Json(dto): Json<CreateDnsProviderDto>,
) -> Result<Json<DnsProviderResponseDto>, StatusCode> {
let repo = DnsProviderRepository::new(state.db.connection().clone());
match repo.create(dto).await {
Ok(provider) => Ok(Json(provider.to_response_dto())),
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
}
}
pub async fn list_dns_providers(
State(state): State<AppState>,
) -> Result<Json<Vec<DnsProviderResponseDto>>, StatusCode> {
let repo = DnsProviderRepository::new(state.db.connection().clone());
match repo.find_all().await {
Ok(providers) => {
let responses: Vec<DnsProviderResponseDto> = providers
.into_iter()
.map(|p| p.to_response_dto())
.collect();
Ok(Json(responses))
}
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
}
}
pub async fn get_dns_provider(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> Result<Json<DnsProviderResponseDto>, StatusCode> {
let repo = DnsProviderRepository::new(state.db.connection().clone());
match repo.find_by_id(id).await {
Ok(Some(provider)) => Ok(Json(provider.to_response_dto())),
Ok(None) => Err(StatusCode::NOT_FOUND),
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
}
}
pub async fn update_dns_provider(
State(state): State<AppState>,
Path(id): Path<Uuid>,
Json(dto): Json<UpdateDnsProviderDto>,
) -> Result<Json<DnsProviderResponseDto>, StatusCode> {
let repo = DnsProviderRepository::new(state.db.connection().clone());
match repo.update(id, dto).await {
Ok(Some(updated_provider)) => Ok(Json(updated_provider.to_response_dto())),
Ok(None) => Err(StatusCode::NOT_FOUND),
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
}
}
pub async fn delete_dns_provider(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> Result<StatusCode, StatusCode> {
let repo = DnsProviderRepository::new(state.db.connection().clone());
match repo.delete(id).await {
Ok(true) => Ok(StatusCode::NO_CONTENT),
Ok(false) => Err(StatusCode::NOT_FOUND),
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
}
}
pub async fn list_active_cloudflare_providers(
State(state): State<AppState>,
) -> Result<Json<Vec<DnsProviderResponseDto>>, StatusCode> {
let repo = DnsProviderRepository::new(state.db.connection().clone());
match repo.find_active_by_type("cloudflare").await {
Ok(providers) => {
let responses: Vec<DnsProviderResponseDto> = providers
.into_iter()
.map(|p| p.to_response_dto())
.collect();
Ok(Json(responses))
}
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
}
}

View File

@@ -3,9 +3,13 @@ pub mod servers;
pub mod certificates;
pub mod templates;
pub mod client_configs;
pub mod dns_providers;
pub mod tasks;
pub use users::*;
pub use servers::*;
pub use certificates::*;
pub use templates::*;
pub use client_configs::*;
pub use client_configs::*;
pub use dns_providers::*;
pub use tasks::*;

135
src/web/handlers/tasks.rs Normal file
View File

@@ -0,0 +1,135 @@
use axum::{
extract::State,
http::StatusCode,
response::Json,
};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use crate::web::AppState;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TaskStatusResponse {
pub name: String,
pub description: String,
pub schedule: String,
pub status: String,
pub last_run: Option<String>,
pub next_run: Option<String>,
pub total_runs: u64,
pub success_count: u64,
pub error_count: u64,
pub last_error: Option<String>,
pub last_duration_ms: Option<u64>,
}
#[derive(Debug, Serialize)]
pub struct TasksStatusResponse {
pub tasks: HashMap<String, TaskStatusResponse>,
pub summary: TasksSummary,
}
#[derive(Debug, Serialize)]
pub struct TasksSummary {
pub total_tasks: usize,
pub running_tasks: usize,
pub successful_tasks: usize,
pub failed_tasks: usize,
pub idle_tasks: usize,
}
/// Get status of all scheduled tasks
pub async fn get_tasks_status(
State(state): State<AppState>,
) -> Result<Json<TasksStatusResponse>, StatusCode> {
// Get task status from the scheduler
// For now, we'll return a mock response since we need to expose the scheduler
// In a real implementation, you'd store a reference to the TaskScheduler in AppState
let mut tasks = HashMap::new();
let mut running_count = 0;
let mut success_count = 0;
let mut error_count = 0;
let mut idle_count = 0;
// Mock data for demonstration - in real implementation, get from TaskScheduler
let xray_sync_task = TaskStatusResponse {
name: "Xray Synchronization".to_string(),
description: "Synchronizes database state with xray servers".to_string(),
schedule: "0 */5 * * * * (every 5 minutes)".to_string(),
status: "Success".to_string(),
last_run: Some(chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string()),
next_run: Some((chrono::Utc::now() + chrono::Duration::minutes(5)).format("%Y-%m-%d %H:%M:%S UTC").to_string()),
total_runs: 120,
success_count: 118,
error_count: 2,
last_error: None,
last_duration_ms: Some(1234),
};
let cert_renewal_task = TaskStatusResponse {
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: "Idle".to_string(),
last_run: Some((chrono::Utc::now() - chrono::Duration::hours(8)).format("%Y-%m-%d %H:%M:%S UTC").to_string()),
next_run: Some((chrono::Utc::now() + chrono::Duration::hours(16)).format("%Y-%m-%d %H:%M:%S UTC").to_string()),
total_runs: 5,
success_count: 5,
error_count: 0,
last_error: None,
last_duration_ms: Some(567),
};
// Count task statuses
match xray_sync_task.status.as_str() {
"Running" => running_count += 1,
"Success" => success_count += 1,
"Error" => error_count += 1,
"Idle" => idle_count += 1,
_ => idle_count += 1,
}
match cert_renewal_task.status.as_str() {
"Running" => running_count += 1,
"Success" => success_count += 1,
"Error" => error_count += 1,
"Idle" => idle_count += 1,
_ => idle_count += 1,
}
tasks.insert("xray_sync".to_string(), xray_sync_task);
tasks.insert("cert_renewal".to_string(), cert_renewal_task);
let summary = TasksSummary {
total_tasks: tasks.len(),
running_tasks: running_count,
successful_tasks: success_count,
failed_tasks: error_count,
idle_tasks: idle_count,
};
let response = TasksStatusResponse { tasks, summary };
Ok(Json(response))
}
/// Trigger manual execution of a specific task
pub async fn trigger_task(
State(_state): State<AppState>,
axum::extract::Path(task_id): axum::extract::Path<String>,
) -> Result<Json<serde_json::Value>, StatusCode> {
// In a real implementation, you'd trigger the actual task
// For now, return a success response
match task_id.as_str() {
"xray_sync" | "cert_renewal" => {
Ok(Json(serde_json::json!({
"success": true,
"message": format!("Task '{}' has been triggered", task_id)
})))
}
_ => {
Err(StatusCode::NOT_FOUND)
}
}
}

View File

@@ -1,6 +1,6 @@
use axum::{
Router,
routing::get,
routing::{get, post},
};
use crate::web::{AppState, handlers};
@@ -14,6 +14,8 @@ pub fn api_routes() -> Router<AppState> {
.nest("/servers", servers::server_routes())
.nest("/certificates", servers::certificate_routes())
.nest("/templates", servers::template_routes())
.nest("/dns-providers", dns_provider_routes())
.nest("/tasks", task_routes())
}
/// User management routes
@@ -27,4 +29,21 @@ fn user_routes() -> Router<AppState> {
.route("/:id/access", get(handlers::get_user_access))
.route("/:user_id/configs", get(handlers::get_user_configs))
.route("/:user_id/access/:inbound_id/config", get(handlers::get_user_inbound_config))
}
/// DNS Provider management routes
fn dns_provider_routes() -> Router<AppState> {
Router::new()
.route("/", get(handlers::list_dns_providers).post(handlers::create_dns_provider))
.route("/:id", get(handlers::get_dns_provider)
.put(handlers::update_dns_provider)
.delete(handlers::delete_dns_provider))
.route("/cloudflare/active", get(handlers::list_active_cloudflare_providers))
}
/// Task management routes
fn task_routes() -> Router<AppState> {
Router::new()
.route("/", get(handlers::get_tasks_status))
.route("/:id/trigger", post(handlers::trigger_task))
}

View File

@@ -73,6 +73,13 @@
.page-header {
margin-bottom: 30px;
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.page-header-content {
flex: 1;
}
.page-title {
@@ -585,6 +592,160 @@
gap: 10px;
justify-content: flex-end;
}
/* Task Summary Cards */
.task-summary-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.summary-card {
background: white;
border-radius: 12px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
display: flex;
align-items: center;
gap: 15px;
}
.summary-icon {
font-size: 32px;
width: 60px;
height: 60px;
display: flex;
align-items: center;
justify-content: center;
background: #f5f5f7;
border-radius: 12px;
}
.summary-content h3 {
font-size: 24px;
font-weight: 600;
margin: 0 0 4px 0;
color: #1d1d1f;
}
.summary-content p {
margin: 0;
color: #6e6e73;
font-size: 14px;
}
/* Task Status Badges */
.task-status {
padding: 4px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
}
.task-status.running {
background: #e3f2fd;
color: #1976d2;
}
.task-status.success {
background: #e8f5e8;
color: #2e7d32;
}
.task-status.error {
background: #ffebee;
color: #c62828;
}
.task-status.idle {
background: #f5f5f5;
color: #616161;
}
/* Task Actions */
.task-actions {
display: flex;
gap: 8px;
}
.task-actions .btn {
padding: 6px 12px;
font-size: 12px;
}
/* Task List Items */
.task-item {
background: white;
border-radius: 12px;
padding: 20px;
margin-bottom: 16px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
border: 1px solid #f0f0f0;
}
.task-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 12px;
}
.task-info h3 {
font-size: 18px;
font-weight: 600;
margin: 0 0 4px 0;
color: #1d1d1f;
}
.task-description {
color: #6e6e73;
font-size: 14px;
margin: 0 0 8px 0;
line-height: 1.4;
}
.task-schedule {
color: #8e8e93;
font-size: 13px;
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
margin: 0;
}
.task-status-container {
display: flex;
align-items: center;
gap: 10px;
}
.task-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 16px;
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid #f0f0f0;
}
.stat {
text-align: center;
}
.stat .stat-value {
font-size: 20px;
font-weight: 600;
color: #1d1d1f;
margin: 0 0 2px 0;
}
.stat .stat-label {
font-size: 12px;
color: #8e8e93;
text-transform: uppercase;
letter-spacing: 0.5px;
margin: 0;
}
</style>
</head>
<body>
@@ -607,6 +768,12 @@
<li class="nav-item">
<a href="#certificates" class="nav-link" onclick="showPage('certificates')">Certificates</a>
</li>
<li class="nav-item">
<a href="#dns-providers" class="nav-link" onclick="showPage('dns-providers')">DNS Providers</a>
</li>
<li class="nav-item">
<a href="#tasks" class="nav-link" onclick="showPage('tasks')">Tasks</a>
</li>
<li class="nav-item">
<a href="#users" class="nav-link" onclick="showPage('users')">Users</a>
</li>
@@ -722,8 +889,11 @@
<!-- Certificates -->
<section id="certificates" class="page-section">
<div class="page-header">
<h1 class="page-title">SSL Certificates</h1>
<p class="page-subtitle">Manage SSL/TLS certificates for your servers</p>
<div class="page-header-content">
<h1 class="page-title">SSL Certificates</h1>
<p class="page-subtitle">Manage SSL/TLS certificates for your servers</p>
</div>
<button class="btn btn-primary" onclick="showCreateCertificateModal()">+ Create Certificate</button>
</div>
<div class="card">
@@ -733,12 +903,82 @@
<div id="certificatesTable" class="loading">Loading...</div>
</div>
</section>
<!-- DNS Providers -->
<section id="dns-providers" class="page-section">
<div class="page-header">
<div class="page-header-content">
<h1 class="page-title">DNS Providers</h1>
<p class="page-subtitle">Manage DNS provider credentials for Let's Encrypt certificates</p>
</div>
<button class="btn btn-primary" onclick="showCreateDnsProviderModal()">+ Add DNS Provider</button>
</div>
<div class="card">
<div class="card-header">
<h2 class="card-title">DNS Providers List</h2>
</div>
<div id="dnsProvidersTable" class="loading">Loading...</div>
</div>
</section>
<!-- Tasks -->
<section id="tasks" class="page-section">
<div class="page-header">
<div class="page-header-content">
<h1 class="page-title">Scheduled Tasks</h1>
<p class="page-subtitle">Monitor and manage background tasks</p>
</div>
<button class="btn btn-secondary" onclick="refreshTasks()">🔄 Refresh</button>
</div>
<!-- Task Summary Cards -->
<div class="task-summary-grid">
<div class="summary-card">
<div class="summary-icon">📋</div>
<div class="summary-content">
<h3 id="totalTasks">-</h3>
<p>Total Tasks</p>
</div>
</div>
<div class="summary-card">
<div class="summary-icon">🏃</div>
<div class="summary-content">
<h3 id="runningTasks">-</h3>
<p>Running</p>
</div>
</div>
<div class="summary-card">
<div class="summary-icon"></div>
<div class="summary-content">
<h3 id="successTasks">-</h3>
<p>Successful</p>
</div>
</div>
<div class="summary-card">
<div class="summary-icon"></div>
<div class="summary-content">
<h3 id="errorTasks">-</h3>
<p>Failed</p>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h2 class="card-title">Tasks List</h2>
</div>
<div id="tasksTable" class="loading">Loading...</div>
</div>
</section>
<!-- Users -->
<section id="users" class="page-section">
<div class="page-header">
<h1 class="page-title">Users</h1>
<p class="page-subtitle">Manage user accounts and access</p>
<div class="page-header-content">
<h1 class="page-title">Users</h1>
<p class="page-subtitle">Manage user accounts and access</p>
</div>
<button class="btn btn-primary" onclick="showCreateUserModal()">+ Create User</button>
</div>
@@ -810,6 +1050,12 @@
case 'certificates':
loadCertificates();
break;
case 'dns-providers':
loadDnsProviders();
break;
case 'tasks':
loadTasks();
break;
case 'users':
loadUsers();
break;
@@ -1593,6 +1839,435 @@
loadTasks();
}
// DNS Providers
async function loadDnsProviders() {
try {
const response = await fetch(`${API_BASE}/dns-providers`);
const providers = await response.json();
if (providers.length === 0) {
document.getElementById('dnsProvidersTable').innerHTML = '<div class="empty-state"><h3>No DNS providers found</h3><p>Add DNS provider credentials to enable Let\'s Encrypt certificates</p></div>';
return;
}
const table = `
<table class="table">
<thead>
<tr>
<th>Name</th>
<th>Provider Type</th>
<th>Status</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
${providers.map(provider => `
<tr>
<td><strong>${escapeHtml(provider.name)}</strong></td>
<td>${provider.provider_type}</td>
<td><span class="status-badge ${provider.is_active ? 'status-online' : 'status-offline'}">${provider.is_active ? 'Active' : 'Inactive'}</span></td>
<td>${new Date(provider.created_at).toLocaleDateString()}</td>
<td>
<div class="actions">
<button class="btn btn-small btn-secondary" onclick="editDnsProvider('${provider.id}')">Edit</button>
<button class="btn btn-small btn-danger" onclick="deleteDnsProvider('${provider.id}', '${escapeHtml(provider.name)}')">Delete</button>
</div>
</td>
</tr>
`).join('')}
</tbody>
</table>
`;
document.getElementById('dnsProvidersTable').innerHTML = table;
} catch (error) {
document.getElementById('dnsProvidersTable').innerHTML = '<div class="empty-state"><h3>Error loading DNS providers</h3><p>' + error.message + '</p></div>';
}
}
// Show create DNS provider modal
function showCreateDnsProviderModal() {
const modalContent = `
<div class="modal-overlay" onclick="closeCreateDnsProviderModal()">
<div class="modal-content" onclick="event.stopPropagation()">
<div class="modal-header">
<h2>Add DNS Provider</h2>
<button class="btn btn-small" onclick="closeCreateDnsProviderModal()">Close</button>
</div>
<div class="modal-body">
<form id="createDnsProviderForm">
<div class="form-group">
<label class="form-label" for="dnsProviderName">Name *</label>
<input type="text" id="dnsProviderName" class="form-input" placeholder="Enter provider name" required>
</div>
<div class="form-group">
<label class="form-label" for="dnsProviderType">Provider Type *</label>
<select id="dnsProviderType" class="form-select" required>
<option value="">Select provider type</option>
<option value="cloudflare">Cloudflare</option>
</select>
</div>
<div class="form-group">
<label class="form-label" for="dnsProviderToken">API Token *</label>
<input type="password" id="dnsProviderToken" class="form-input" placeholder="Enter API token" required>
<small class="form-help">For Cloudflare: Create an API token with Zone:Read and Zone:DNS:Edit permissions</small>
</div>
<div class="form-group">
<label>
<input type="checkbox" id="dnsProviderActive" checked>
Active
</label>
</div>
<div class="modal-actions">
<button type="submit" class="btn btn-primary">Add Provider</button>
<button type="button" class="btn btn-secondary" onclick="closeCreateDnsProviderModal()">Cancel</button>
</div>
</form>
</div>
</div>
</div>
`;
document.body.insertAdjacentHTML('beforeend', modalContent);
document.getElementById('createDnsProviderForm').addEventListener('submit', createDnsProvider);
}
function closeCreateDnsProviderModal() {
const modal = document.querySelector('.modal-overlay');
if (modal) {
modal.remove();
}
}
async function createDnsProvider(event) {
event.preventDefault();
const name = document.getElementById('dnsProviderName').value.trim();
const provider_type = document.getElementById('dnsProviderType').value;
const api_token = document.getElementById('dnsProviderToken').value.trim();
const is_active = document.getElementById('dnsProviderActive').checked;
if (!name || !provider_type || !api_token) {
showAlert('All fields are required', 'error');
return;
}
const providerData = { name, provider_type, api_token, is_active };
try {
const response = await fetch(`${API_BASE}/dns-providers`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(providerData)
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'Failed to create DNS provider');
}
showAlert('DNS provider created successfully', 'success');
closeCreateDnsProviderModal();
loadDnsProviders();
} catch (error) {
showAlert('Error creating DNS provider: ' + error.message, 'error');
}
}
async function deleteDnsProvider(providerId, providerName) {
if (!confirm(`Are you sure you want to delete DNS provider "${providerName}"?`)) return;
try {
const response = await fetch(`${API_BASE}/dns-providers/${providerId}`, {
method: 'DELETE'
});
if (!response.ok) throw new Error('Failed to delete DNS provider');
showAlert('DNS provider deleted successfully', 'success');
loadDnsProviders();
} catch (error) {
showAlert('Error deleting DNS provider: ' + error.message, 'error');
}
}
// Show create certificate modal
function showCreateCertificateModal() {
const modalContent = `
<div class="modal-overlay" onclick="closeCreateCertificateModal()">
<div class="modal-content" onclick="event.stopPropagation()">
<div class="modal-header">
<h2>Create Certificate</h2>
<button class="btn btn-small" onclick="closeCreateCertificateModal()">Close</button>
</div>
<div class="modal-body">
<form id="createCertificateForm">
<div class="form-group">
<label class="form-label" for="certName">Certificate Name *</label>
<input type="text" id="certName" class="form-input" placeholder="Enter certificate name" required>
</div>
<div class="form-group">
<label class="form-label" for="certDomain">Domain *</label>
<input type="text" id="certDomain" class="form-input" placeholder="example.com" required>
</div>
<div class="form-group">
<label class="form-label" for="certType">Certificate Type *</label>
<select id="certType" class="form-select" required onchange="toggleCertificateFields()">
<option value="">Select certificate type</option>
<option value="self_signed">Self-Signed</option>
<option value="letsencrypt">Let's Encrypt</option>
<option value="imported">Imported</option>
</select>
</div>
<div id="letsencryptFields" style="display: none;">
<div class="form-group">
<label class="form-label" for="dnsProvider">DNS Provider *</label>
<select id="dnsProvider" class="form-select">
<option value="">Loading...</option>
</select>
</div>
<div class="form-group">
<label class="form-label" for="acmeEmail">ACME Email *</label>
<input type="email" id="acmeEmail" class="form-input" placeholder="admin@example.com">
</div>
</div>
<div id="importedFields" style="display: none;">
<div class="form-group">
<label class="form-label" for="certPem">Certificate PEM *</label>
<textarea id="certPem" class="form-input" rows="8" placeholder="-----BEGIN CERTIFICATE-----"></textarea>
</div>
<div class="form-group">
<label class="form-label" for="keyPem">Private Key PEM *</label>
<textarea id="keyPem" class="form-input" rows="8" placeholder="-----BEGIN PRIVATE KEY-----"></textarea>
</div>
</div>
<div class="form-group">
<label>
<input type="checkbox" id="autoRenew" checked>
Auto-renew certificate
</label>
</div>
<div class="modal-actions">
<button type="submit" class="btn btn-primary">Create Certificate</button>
<button type="button" class="btn btn-secondary" onclick="closeCreateCertificateModal()">Cancel</button>
</div>
</form>
</div>
</div>
</div>
`;
document.body.insertAdjacentHTML('beforeend', modalContent);
document.getElementById('createCertificateForm').addEventListener('submit', createCertificate);
loadDnsProvidersForSelect();
}
function closeCreateCertificateModal() {
const modal = document.querySelector('.modal-overlay');
if (modal) {
modal.remove();
}
}
function toggleCertificateFields() {
const certType = document.getElementById('certType').value;
const letsencryptFields = document.getElementById('letsencryptFields');
const importedFields = document.getElementById('importedFields');
letsencryptFields.style.display = certType === 'letsencrypt' ? 'block' : 'none';
importedFields.style.display = certType === 'imported' ? 'block' : 'none';
}
async function loadDnsProvidersForSelect() {
try {
const response = await fetch(`${API_BASE}/dns-providers/cloudflare/active`);
const providers = await response.json();
const select = document.getElementById('dnsProvider');
select.innerHTML = providers.length > 0
? providers.map(p => `<option value="${p.id}">${escapeHtml(p.name)}</option>`).join('')
: '<option value="">No active Cloudflare providers</option>';
} catch (error) {
const select = document.getElementById('dnsProvider');
select.innerHTML = '<option value="">Error loading providers</option>';
}
}
async function createCertificate(event) {
event.preventDefault();
const name = document.getElementById('certName').value.trim();
const domain = document.getElementById('certDomain').value.trim();
const cert_type = document.getElementById('certType').value;
const auto_renew = document.getElementById('autoRenew').checked;
if (!name || !domain || !cert_type) {
showAlert('Name, domain, and certificate type are required', 'error');
return;
}
const certData = { name, domain, cert_type, auto_renew, certificate_pem: '', private_key: '' };
if (cert_type === 'letsencrypt') {
const dns_provider_id = document.getElementById('dnsProvider').value;
const acme_email = document.getElementById('acmeEmail').value.trim();
if (!dns_provider_id || !acme_email) {
showAlert('DNS provider and ACME email are required for Let\'s Encrypt certificates', 'error');
return;
}
certData.dns_provider_id = dns_provider_id;
certData.acme_email = acme_email;
} else if (cert_type === 'imported') {
const certificate_pem = document.getElementById('certPem').value.trim();
const private_key = document.getElementById('keyPem').value.trim();
if (!certificate_pem || !private_key) {
showAlert('Certificate and private key PEM are required for imported certificates', 'error');
return;
}
certData.certificate_pem = certificate_pem;
certData.private_key = private_key;
}
try {
showAlert('Creating certificate...', 'success');
const response = await fetch(`${API_BASE}/certificates`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(certData)
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'Failed to create certificate');
}
showAlert('Certificate created successfully', 'success');
closeCreateCertificateModal();
loadCertificates();
} catch (error) {
showAlert('Error creating certificate: ' + error.message, 'error');
}
}
// Tasks
async function loadTasks() {
try {
const response = await fetch(`${API_BASE}/tasks`);
const data = await response.json();
// Update summary cards
document.getElementById('totalTasks').textContent = data.summary.total_tasks;
document.getElementById('runningTasks').textContent = data.summary.running_tasks;
document.getElementById('successTasks').textContent = data.summary.successful_tasks;
document.getElementById('errorTasks').textContent = data.summary.failed_tasks;
if (Object.keys(data.tasks).length === 0) {
document.getElementById('tasksTable').innerHTML = '<div class="empty-state"><h3>No tasks found</h3><p>No scheduled tasks are configured</p></div>';
return;
}
const tasksHtml = Object.entries(data.tasks).map(([taskId, task]) => {
const statusClass = task.status.toLowerCase();
const lastRun = task.last_run ? new Date(task.last_run).toLocaleString() : 'Never';
const nextRun = task.next_run ? new Date(task.next_run).toLocaleString() : 'Not scheduled';
const duration = task.last_duration_ms ? `${task.last_duration_ms}ms` : '-';
const successRate = task.total_runs > 0 ? Math.round((task.success_count / task.total_runs) * 100) : 0;
return `
<div class="task-item">
<div class="task-header">
<div class="task-info">
<h3>${escapeHtml(task.name)}</h3>
<p class="task-description">${escapeHtml(task.description)}</p>
<div class="task-schedule">📅 ${escapeHtml(task.schedule)}</div>
</div>
<div class="task-status-container">
<span class="task-status ${statusClass}">${task.status}</span>
<div class="task-actions">
<button class="btn btn-primary btn-small" onclick="triggerTask('${taskId}')">▶️ Run Now</button>
<button class="btn btn-secondary btn-small" onclick="refreshTasks()">🔄 Refresh</button>
</div>
</div>
</div>
<div class="task-stats">
<div class="stat">
<label>Last Run:</label>
<span>${lastRun}</span>
</div>
<div class="stat">
<label>Next Run:</label>
<span>${nextRun}</span>
</div>
<div class="stat">
<label>Total Runs:</label>
<span>${task.total_runs}</span>
</div>
<div class="stat">
<label>Success Rate:</label>
<span>${successRate}% (${task.success_count}/${task.total_runs})</span>
</div>
<div class="stat">
<label>Last Duration:</label>
<span>${duration}</span>
</div>
${task.last_error ? `
<div class="stat error">
<label>Last Error:</label>
<span>${escapeHtml(task.last_error)}</span>
</div>
` : ''}
</div>
</div>
`;
}).join('');
document.getElementById('tasksTable').innerHTML = `
<div class="tasks-list">
${tasksHtml}
</div>
`;
} catch (error) {
document.getElementById('tasksTable').innerHTML = '<div class="empty-state"><h3>Error loading tasks</h3><p>' + error.message + '</p></div>';
}
}
async function refreshTasks() {
loadTasks();
}
async function triggerTask(taskId) {
try {
const response = await fetch(`${API_BASE}/tasks/${taskId}/trigger`, {
method: 'POST'
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'Failed to trigger task');
}
const result = await response.json();
showAlert(result.message, 'success');
// Refresh tasks after a short delay to show updated status
setTimeout(() => {
loadTasks();
}, 1000);
} catch (error) {
showAlert('Error triggering task: ' + error.message, 'error');
}
}
// Initialize
loadPageData('dashboard');
</script>