Made subs works
All checks were successful
Rust Docker Build / docker (push) Successful in 1h11m56s

This commit is contained in:
AB from home.homenet
2025-10-19 15:23:17 +03:00
parent dae787657c
commit c6892b1a73
11 changed files with 87 additions and 49 deletions

7
Cargo.lock generated
View File

@@ -3397,6 +3397,12 @@ dependencies = [
"digest", "digest",
] ]
[[package]]
name = "sha1_smol"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d"
[[package]] [[package]]
name = "sha2" name = "sha2"
version = "0.10.9" version = "0.10.9"
@@ -4500,6 +4506,7 @@ dependencies = [
"getrandom 0.3.3", "getrandom 0.3.3",
"js-sys", "js-sys",
"serde", "serde",
"sha1_smol",
"wasm-bindgen", "wasm-bindgen",
] ]

View File

@@ -37,7 +37,7 @@ sea-orm = { version = "1.0", features = ["sqlx-postgres", "runtime-tokio-rustls"
sea-orm-migration = "1.0" sea-orm-migration = "1.0"
# Additional utilities # Additional utilities
uuid = { version = "1.0", features = ["v4", "serde"] } uuid = { version = "1.0", features = ["v4", "v5", "serde"] }
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] }
async-trait = "0.1" async-trait = "0.1"
log = "0.4" log = "0.4"

View File

@@ -26,6 +26,9 @@ pub struct Args {
#[arg(long, default_value = "info")] #[arg(long, default_value = "info")]
pub log_level: Option<String>, pub log_level: Option<String>,
/// Base URL for the application (used in subscription links and Telegram messages)
#[arg(long, env = "BASE_URL")]
pub base_url: Option<String>,
/// Validate configuration and exit /// Validate configuration and exit
#[arg(long)] #[arg(long)]

View File

@@ -37,6 +37,10 @@ pub struct WebConfig {
pub jwt_secret: String, pub jwt_secret: String,
#[validate(range(min = 3600))] #[validate(range(min = 3600))]
pub jwt_expiry: u64, pub jwt_expiry: u64,
/// Base URL for the application (used in subscription links and Telegram messages)
/// Example: "https://vpn.hexor.cy"
#[validate(url)]
pub base_url: String,
} }
#[derive(Debug, Clone, Serialize, Deserialize, Validate)] #[derive(Debug, Clone, Serialize, Deserialize, Validate)]
@@ -84,6 +88,7 @@ impl Default for WebConfig {
cors_origins: vec!["http://localhost:3000".to_string()], cors_origins: vec!["http://localhost:3000".to_string()],
jwt_secret: "your-secret-key-change-in-production".to_string(), jwt_secret: "your-secret-key-change-in-production".to_string(),
jwt_expiry: 86400, // 24 hours jwt_expiry: 86400, // 24 hours
base_url: "http://localhost:8080".to_string(),
} }
} }
} }
@@ -174,6 +179,9 @@ impl AppConfig {
if let Some(log_level) = &args.log_level { if let Some(log_level) = &args.log_level {
builder = builder.set_override("logging.level", log_level.as_str())?; builder = builder.set_override("logging.level", log_level.as_str())?;
} }
if let Some(base_url) = &args.base_url {
builder = builder.set_override("web.base_url", base_url.as_str())?;
}
let config: AppConfig = builder.build()?.try_deserialize()?; let config: AppConfig = builder.build()?.try_deserialize()?;

View File

@@ -91,7 +91,7 @@ async fn main() -> Result<()> {
TaskScheduler::start_event_handler(db.clone(), event_receiver).await; TaskScheduler::start_event_handler(db.clone(), event_receiver).await;
// Initialize Telegram service if needed // Initialize Telegram service if needed
let telegram_service = Arc::new(TelegramService::new(db.clone())); let telegram_service = Arc::new(TelegramService::new(db.clone(), config.clone()));
if let Err(e) = telegram_service.initialize().await { if let Err(e) = telegram_service.initialize().await {
tracing::warn!("Failed to initialize Telegram service: {}", e); tracing::warn!("Failed to initialize Telegram service: {}", e);
} }
@@ -99,7 +99,7 @@ async fn main() -> Result<()> {
// Start web server with task scheduler // Start web server with task scheduler
tokio::select! { tokio::select! {
result = web::start_server(db, config.web.clone(), Some(telegram_service.clone())) => { result = web::start_server(db, config.clone(), Some(telegram_service.clone())) => {
match result { match result {
Err(e) => tracing::error!("Web server error: {}", e), Err(e) => tracing::error!("Web server error: {}", e),
_ => {} _ => {}

View File

@@ -2,12 +2,14 @@ use teloxide::{Bot, prelude::*};
use tokio::sync::oneshot; use tokio::sync::oneshot;
use crate::database::DatabaseManager; use crate::database::DatabaseManager;
use crate::config::AppConfig;
use super::handlers::{self, Command}; use super::handlers::{self, Command};
/// Run the bot polling loop /// Run the bot polling loop
pub async fn run_polling( pub async fn run_polling(
bot: Bot, bot: Bot,
db: DatabaseManager, db: DatabaseManager,
app_config: AppConfig,
mut shutdown_rx: oneshot::Receiver<()>, mut shutdown_rx: oneshot::Receiver<()>,
) { ) {
tracing::info!("Starting Telegram bot polling..."); tracing::info!("Starting Telegram bot polling...");
@@ -30,7 +32,7 @@ pub async fn run_polling(
); );
let mut dispatcher = Dispatcher::builder(bot.clone(), handler) let mut dispatcher = Dispatcher::builder(bot.clone(), handler)
.dependencies(dptree::deps![db]) .dependencies(dptree::deps![db, app_config])
.enable_ctrlc_handler() .enable_ctrlc_handler()
.build(); .build();

View File

@@ -9,6 +9,7 @@ pub use types::*;
use teloxide::{prelude::*, types::CallbackQuery}; use teloxide::{prelude::*, types::CallbackQuery};
use crate::database::DatabaseManager; use crate::database::DatabaseManager;
use crate::config::AppConfig;
/// Handle bot commands /// Handle bot commands
pub async fn handle_command( pub async fn handle_command(
@@ -16,6 +17,7 @@ pub async fn handle_command(
msg: Message, msg: Message,
cmd: Command, cmd: Command,
db: DatabaseManager, db: DatabaseManager,
app_config: AppConfig,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let chat_id = msg.chat.id; let chat_id = msg.chat.id;
let from = &msg.from.ok_or("No user info")?; let from = &msg.from.ok_or("No user info")?;
@@ -78,6 +80,7 @@ pub async fn handle_message(
bot: Bot, bot: Bot,
msg: Message, msg: Message,
db: DatabaseManager, db: DatabaseManager,
_app_config: AppConfig,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let chat_id = msg.chat.id; let chat_id = msg.chat.id;
let from = msg.from.as_ref().ok_or("No user info")?; let from = msg.from.as_ref().ok_or("No user info")?;
@@ -95,6 +98,7 @@ pub async fn handle_callback_query(
bot: Bot, bot: Bot,
q: CallbackQuery, q: CallbackQuery,
db: DatabaseManager, db: DatabaseManager,
app_config: AppConfig,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
if let Some(data) = &q.data { if let Some(data) = &q.data {
if let Some(callback_data) = CallbackData::parse(data) { if let Some(callback_data) = CallbackData::parse(data) {
@@ -106,7 +110,7 @@ pub async fn handle_callback_query(
handle_my_configs_edit(bot, &q, &db).await?; handle_my_configs_edit(bot, &q, &db).await?;
} }
CallbackData::SubscriptionLink => { CallbackData::SubscriptionLink => {
handle_subscription_link(bot, &q, &db).await?; handle_subscription_link(bot, &q, &db, &app_config).await?;
} }
CallbackData::Support => { CallbackData::Support => {
handle_support(bot, &q).await?; handle_support(bot, &q).await?;

View File

@@ -673,6 +673,7 @@ pub async fn handle_subscription_link(
bot: Bot, bot: Bot,
q: &CallbackQuery, q: &CallbackQuery,
db: &DatabaseManager, db: &DatabaseManager,
app_config: &crate::config::AppConfig,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let from = q.from.clone(); let from = q.from.clone();
let telegram_id = from.id.0 as i64; let telegram_id = from.id.0 as i64;
@@ -683,8 +684,7 @@ pub async fn handle_subscription_link(
let user_repo = UserRepository::new(db.connection()); let user_repo = UserRepository::new(db.connection());
if let Ok(Some(user)) = user_repo.get_by_telegram_id(telegram_id).await { if let Ok(Some(user)) = user_repo.get_by_telegram_id(telegram_id).await {
// Generate subscription URL // Generate subscription URL
let base_url = std::env::var("BASE_URL").unwrap_or_else(|_| "http://localhost:8080".to_string()); let subscription_url = format!("{}/sub/{}", app_config.web.base_url, user.id);
let subscription_url = format!("{}/sub/{}", base_url, user.id);
let message = match lang { let message = match lang {
Language::Russian => { Language::Russian => {

View File

@@ -7,6 +7,7 @@ use uuid::Uuid;
use crate::database::DatabaseManager; use crate::database::DatabaseManager;
use crate::database::repository::TelegramConfigRepository; use crate::database::repository::TelegramConfigRepository;
use crate::database::entities::telegram_config::Model as TelegramConfig; use crate::database::entities::telegram_config::Model as TelegramConfig;
use crate::config::AppConfig;
pub mod bot; pub mod bot;
pub mod handlers; pub mod handlers;
@@ -18,6 +19,7 @@ pub use error::TelegramError;
/// Main Telegram service that manages the bot lifecycle /// Main Telegram service that manages the bot lifecycle
pub struct TelegramService { pub struct TelegramService {
db: DatabaseManager, db: DatabaseManager,
app_config: AppConfig,
bot: Arc<RwLock<Option<Bot>>>, bot: Arc<RwLock<Option<Bot>>>,
config: Arc<RwLock<Option<TelegramConfig>>>, config: Arc<RwLock<Option<TelegramConfig>>>,
shutdown_signal: Arc<RwLock<Option<tokio::sync::oneshot::Sender<()>>>>, shutdown_signal: Arc<RwLock<Option<tokio::sync::oneshot::Sender<()>>>>,
@@ -25,9 +27,10 @@ pub struct TelegramService {
impl TelegramService { impl TelegramService {
/// Create a new Telegram service /// Create a new Telegram service
pub fn new(db: DatabaseManager) -> Self { pub fn new(db: DatabaseManager, app_config: AppConfig) -> Self {
Self { Self {
db, db,
app_config,
bot: Arc::new(RwLock::new(None)), bot: Arc::new(RwLock::new(None)),
config: Arc::new(RwLock::new(None)), config: Arc::new(RwLock::new(None)),
shutdown_signal: Arc::new(RwLock::new(None)), shutdown_signal: Arc::new(RwLock::new(None)),
@@ -83,10 +86,11 @@ impl TelegramService {
*self.shutdown_signal.write().await = Some(tx); *self.shutdown_signal.write().await = Some(tx);
let db = self.db.clone(); let db = self.db.clone();
let app_config = self.app_config.clone();
// Spawn polling task // Spawn polling task
tokio::spawn(async move { tokio::spawn(async move {
bot::run_polling(bot, db, rx).await; bot::run_polling(bot, db, app_config, rx).await;
}); });
Ok(()) Ok(())

View File

@@ -1,6 +1,6 @@
use axum::{ use axum::{
extract::{Path, State}, extract::{Path, State},
http::StatusCode, http::{StatusCode, HeaderMap, HeaderValue},
response::{IntoResponse, Response}, response::{IntoResponse, Response},
}; };
use base64::{Engine, engine::general_purpose}; use base64::{Engine, engine::general_purpose};
@@ -14,13 +14,13 @@ use crate::{
/// Get subscription links for a user by their ID /// Get subscription links for a user by their ID
/// Returns all configuration links for the user, one per line /// Returns all configuration links for the user, one per line
/// Based on Django implementation for compatibility
pub async fn get_user_subscription( pub async fn get_user_subscription(
State(state): State<AppState>, State(state): State<AppState>,
Path(user_id): Path<Uuid>, Path(user_id): Path<Uuid>,
) -> Result<Response, StatusCode> { ) -> Result<Response, StatusCode> {
let user_repo = UserRepository::new(state.db.connection()); let user_repo = UserRepository::new(state.db.connection());
let inbound_users_repo = InboundUsersRepository::new(state.db.connection().clone()); let inbound_users_repo = InboundUsersRepository::new(state.db.connection().clone());
let uri_generator = UriGeneratorService::new();
// Check if user exists // Check if user exists
let user = match user_repo.get_by_id(user_id).await { let user = match user_repo.get_by_id(user_id).await {
@@ -29,13 +29,16 @@ pub async fn get_user_subscription(
Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR), Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
}; };
// Get all inbound accesses for this user // Get all client config data for the user (this gets all active inbound accesses)
let user_inbounds = match inbound_users_repo.find_by_user_id(user_id).await { let all_configs = match inbound_users_repo.get_all_client_configs_for_user(user_id).await {
Ok(inbounds) => inbounds, Ok(configs) => configs,
Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR), Err(e) => {
tracing::error!("Failed to get client configs for user {}: {}", user_id, e);
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
}; };
if user_inbounds.is_empty() { if all_configs.is_empty() {
let response_text = "# No configurations available\n".to_string(); let response_text = "# No configurations available\n".to_string();
let response_base64 = general_purpose::STANDARD.encode(response_text); let response_base64 = general_purpose::STANDARD.encode(response_text);
return Ok(( return Ok((
@@ -47,28 +50,19 @@ pub async fn get_user_subscription(
let mut config_lines = Vec::new(); let mut config_lines = Vec::new();
// Generate URI for each inbound access // Generate connection strings for each config using existing UriGeneratorService
for user_inbound in user_inbounds { let uri_generator = UriGeneratorService::new();
// Get client configuration data using the existing repository method
match inbound_users_repo.get_client_config_data(user_id, user_inbound.server_inbound_id).await { for config_data in all_configs {
Ok(Some(config_data)) => {
// Generate URI
match uri_generator.generate_client_config(user_id, &config_data) { match uri_generator.generate_client_config(user_id, &config_data) {
Ok(client_config) => { Ok(client_config) => {
config_lines.push(client_config.uri); config_lines.push(client_config.uri);
tracing::debug!("Generated {} config for user {}: {}",
config_data.protocol.to_uppercase(), user.name, config_data.template_name);
} }
Err(e) => { Err(e) => {
tracing::warn!("Failed to generate URI for user {} inbound {}: {}", user_id, user_inbound.server_inbound_id, e); tracing::warn!("Failed to generate connection string for user {} template {}: {}",
continue; user.name, config_data.template_name, e);
}
}
}
Ok(None) => {
tracing::debug!("No config data found for user {} inbound {}", user_id, user_inbound.server_inbound_id);
continue;
}
Err(e) => {
tracing::warn!("Failed to get config data for user {} inbound {}: {}", user_id, user_inbound.server_inbound_id, e);
continue; continue;
} }
} }
@@ -84,15 +78,32 @@ pub async fn get_user_subscription(
).into_response()); ).into_response());
} }
// Join all URIs with newlines // Join all URIs with newlines (like Django implementation)
let response_text = config_lines.join("\n") + "\n"; let response_text = config_lines.join("\n") + "\n";
// Encode the entire response in base64 // Encode the entire response in base64 (like Django implementation)
let response_base64 = general_purpose::STANDARD.encode(response_text); let response_base64 = general_purpose::STANDARD.encode(response_text);
Ok(( // Build response with subscription headers (like Django)
StatusCode::OK, let mut headers = HeaderMap::new();
[("content-type", "text/plain; charset=utf-8")],
response_base64, // Add headers required by VPN clients
).into_response()) headers.insert("content-type", HeaderValue::from_static("text/plain; charset=utf-8"));
headers.insert("content-disposition", HeaderValue::from_str(&format!("attachment; filename=\"{}\"", user.name)).unwrap());
headers.insert("cache-control", HeaderValue::from_static("no-cache"));
// Profile information
let profile_title = general_purpose::STANDARD.encode("OutFleet VPN");
headers.insert("profile-title", HeaderValue::from_str(&format!("base64:{}", profile_title)).unwrap());
headers.insert("profile-update-interval", HeaderValue::from_static("24"));
headers.insert("profile-web-page-url", HeaderValue::from_str(&format!("{}/u/{}", state.config.web.base_url, user_id)).unwrap());
headers.insert("support-url", HeaderValue::from_str(&format!("{}/admin/", state.config.web.base_url)).unwrap());
// Subscription info (unlimited service)
let expire_timestamp = chrono::Utc::now().timestamp() + (365 * 24 * 60 * 60); // 1 year from now
headers.insert("subscription-userinfo",
HeaderValue::from_str(&format!("upload=0; download=0; total=1099511627776; expire={}", expire_timestamp)).unwrap());
Ok((StatusCode::OK, headers, response_base64).into_response())
} }

View File

@@ -14,7 +14,7 @@ use tower_http::services::ServeDir;
use tracing::info; use tracing::info;
use std::sync::Arc; use std::sync::Arc;
use crate::config::WebConfig; use crate::config::{WebConfig, AppConfig};
use crate::database::DatabaseManager; use crate::database::DatabaseManager;
use crate::services::{XrayService, TelegramService}; use crate::services::{XrayService, TelegramService};
@@ -27,14 +27,13 @@ use routes::api_routes;
#[derive(Clone)] #[derive(Clone)]
pub struct AppState { pub struct AppState {
pub db: DatabaseManager, pub db: DatabaseManager,
#[allow(dead_code)] pub config: AppConfig,
pub config: WebConfig,
pub xray_service: XrayService, pub xray_service: XrayService,
pub telegram_service: Option<Arc<TelegramService>>, pub telegram_service: Option<Arc<TelegramService>>,
} }
/// Start the web server /// Start the web server
pub async fn start_server(db: DatabaseManager, config: WebConfig, telegram_service: Option<Arc<TelegramService>>) -> Result<()> { pub async fn start_server(db: DatabaseManager, config: AppConfig, telegram_service: Option<Arc<TelegramService>>) -> Result<()> {
let xray_service = XrayService::new(); let xray_service = XrayService::new();
let app_state = AppState { let app_state = AppState {
@@ -55,7 +54,7 @@ pub async fn start_server(db: DatabaseManager, config: WebConfig, telegram_servi
.layer(CorsLayer::permissive()) .layer(CorsLayer::permissive())
.with_state(app_state); .with_state(app_state);
let addr: SocketAddr = format!("{}:{}", config.host, config.port).parse()?; let addr: SocketAddr = format!("{}:{}", config.web.host, config.web.port).parse()?;
info!("Starting web server on {}", addr); info!("Starting web server on {}", addr);
let listener = TcpListener::bind(&addr).await?; let listener = TcpListener::bind(&addr).await?;