mirror of
https://github.com/house-of-vanity/OutFleet.git
synced 2025-10-23 16:59:08 +00:00
Letsencrypt works
This commit is contained in:
890
Cargo.lock
generated
890
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
@@ -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)]
|
||||
|
156
src/database/entities/dns_provider.rs
Normal file
156
src/database/entities/dns_provider.rs
Normal 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]
|
||||
}
|
||||
}
|
@@ -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;
|
||||
|
@@ -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,
|
||||
}
|
@@ -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),
|
||||
]
|
||||
}
|
||||
}
|
@@ -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?)
|
||||
}
|
||||
}
|
132
src/database/repository/dns_provider.rs
Normal file
132
src/database/repository/dns_provider.rs
Normal 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)
|
||||
}
|
||||
}
|
@@ -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;
|
||||
|
@@ -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))
|
||||
|
@@ -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
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
|
||||
|
@@ -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)
|
||||
}))))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
102
src/web/handlers/dns_providers.rs
Normal file
102
src/web/handlers/dns_providers.rs
Normal 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),
|
||||
}
|
||||
}
|
@@ -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
135
src/web/handlers/tasks.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
@@ -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))
|
||||
}
|
@@ -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>
|
||||
|
Reference in New Issue
Block a user