Files
amnezia-fellow/src/main.rs
T
ab 2dbbdb0252
Build and Publish / Build and Publish Docker Image (push) Successful in 5m9s
Fixed TG login
2026-07-06 16:30:16 +03:00

440 lines
16 KiB
Rust

mod admin;
mod api;
mod auth;
mod config;
mod i18n;
mod oidc;
mod sqlite_migration;
mod telegram;
mod user;
mod vpn;
use std::sync::Arc;
use async_trait::async_trait;
use cot::auth::PasswordVerificationResult;
use cot::cli::CliMetadata;
use cot::common_types::Password;
use cot::config::{
DatabaseConfig, MiddlewareConfig, ProjectConfig, SessionMiddlewareConfig, SessionStoreConfig,
SessionStoreTypeConfig,
};
use cot::db::Database;
use cot::form::{Form, FormResult};
use cot::html::Html;
use cot::middleware::SessionMiddleware;
use cot::project::{ProjectContext, RegisterAppsContext};
use cot::request::extractors::{RequestForm, UrlQuery};
use cot::response::IntoResponse;
use cot::router::method::get;
use cot::router::{Route, Router};
use cot::session::Session;
use cot::static_files::StaticFilesMiddleware;
use cot::{App, AppBuilder, Body, Project, Template};
use serde::Deserialize;
use crate::config::AppConfig;
use crate::i18n::{I18n, Translations};
use crate::user::User;
// ---------------------------------------------------------------------------
// Handlers
// ---------------------------------------------------------------------------
async fn index(session: Session, db: Database) -> cot::Result<cot::response::Response> {
if auth::get_session_user(&session, &db).await.is_none() {
return Ok(auth::redirect("/login"));
}
Ok(auth::redirect("/configs"))
}
#[derive(Debug, Template)]
#[template(path = "configs.html")]
struct ConfigsTemplate {
t: &'static Translations,
user_name: String,
user_role: String,
is_admin: bool,
app_version: &'static str,
}
#[derive(Debug, Template)]
#[template(path = "client_portal.html")]
struct ClientPortalTemplate {
t: &'static Translations,
user_name: String,
user_role: String,
app_version: &'static str,
}
#[derive(Debug, Template)]
#[template(path = "telegram_login.html")]
struct TelegramLoginTemplate {
t: &'static Translations,
}
async fn configs_page(
session: Session,
db: Database,
i18n: I18n,
) -> cot::Result<cot::response::Response> {
let user = match auth::get_session_user(&session, &db).await {
Some(user) => user,
None => {
return Html::new(TelegramLoginTemplate { t: i18n.t }.render()?).into_response();
}
};
let is_admin = user.role == auth::Role::Admin;
let user_name = user.name;
let user_role = user.role.code().to_owned();
if is_admin {
return Html::new(
ConfigsTemplate {
t: i18n.t,
user_name,
user_role,
is_admin,
app_version: env!("CARGO_PKG_VERSION"),
}
.render()?,
)
.into_response();
}
Html::new(
ClientPortalTemplate {
t: i18n.t,
user_name,
user_role,
app_version: env!("CARGO_PKG_VERSION"),
}
.render()?,
)
.into_response()
}
#[derive(Deserialize)]
struct SetLangQuery {
lang: String,
next: Option<String>,
}
async fn set_lang(
UrlQuery(query): UrlQuery<SetLangQuery>,
) -> cot::Result<cot::http::Response<Body>> {
let lang = i18n::Lang::from_code(&query.lang).unwrap_or(i18n::Lang::En);
let next = query.next.as_deref().unwrap_or("/");
let response = cot::http::Response::builder()
.status(cot::http::StatusCode::SEE_OTHER)
.header(cot::http::header::LOCATION, next)
.header(cot::http::header::SET_COOKIE, i18n::lang_cookie(lang))
.body(Body::fixed(""))
.expect("valid response");
Ok(response)
}
// ---------------------------------------------------------------------------
// Login page
// ---------------------------------------------------------------------------
#[derive(Debug, Template)]
#[template(path = "login.html")]
struct LoginTemplate {
t: &'static Translations,
auth_password_enabled: bool,
auth_sso_enabled: bool,
oidc_button_text: String,
message: String,
}
async fn login_page_handler(
i18n: I18n,
_startup_config: &AppConfig,
db: Database,
message: String,
) -> cot::Result<Html> {
let (config, _) = AppConfig::load_with_db(&db).await;
let template = LoginTemplate {
t: i18n.t,
auth_password_enabled: config.auth_password_enabled,
auth_sso_enabled: config.auth_sso_enabled,
oidc_button_text: config.oidc_button_text,
message,
};
Ok(Html::new(template.render()?))
}
#[derive(Debug, Form)]
struct LoginForm {
username: String,
password: String,
}
// ---------------------------------------------------------------------------
// Logout
// ---------------------------------------------------------------------------
async fn logout_handler(session: Session) -> cot::Result<cot::response::Response> {
auth::logout(&session).await?;
Ok(auth::redirect("/login"))
}
// ---------------------------------------------------------------------------
// App
// ---------------------------------------------------------------------------
struct AmneziaFellowApp {
config: Arc<AppConfig>,
}
#[async_trait]
impl App for AmneziaFellowApp {
fn name(&self) -> &'static str {
env!("CARGO_PKG_NAME")
}
async fn init(&self, context: &mut ProjectContext) -> cot::Result<()> {
if let Some(db) = context.try_database() {
sqlite_migration::run_if_requested(&self.config, db).await?;
}
Ok(())
}
fn router(&self) -> Router {
Router::with_urls([
Route::with_handler_and_name(
"/admin",
get(|| async { Ok::<_, cot::Error>(auth::redirect("/admin/")) }),
"admin_redirect",
),
Route::with_handler_and_name(
"/swagger",
get(|| async { Ok::<_, cot::Error>(auth::redirect("/swagger/")) }),
"swagger_redirect",
),
Route::with_handler_and_name("/", index, "index"),
Route::with_handler_and_name("/configs", get(configs_page), "configs"),
Route::with_handler_and_name(
"/login",
get({
let config = Arc::clone(&self.config);
move |i18n: I18n, db: Database| {
let config = Arc::clone(&config);
async move {
// No users at all → redirect to first-run setup
if User::count_all(&db).await.unwrap_or(0) == 0 {
return Ok(auth::redirect("/admin/setup"));
}
login_page_handler(i18n, &config, db, String::new())
.await?
.into_response()
}
}
})
.post({
let config = Arc::clone(&self.config);
move |i18n: I18n,
db: Database,
session: Session,
form: RequestForm<LoginForm>| {
let config = Arc::clone(&config);
async move {
let RequestForm(result) = form;
let data = match result {
FormResult::Ok(data) => data,
FormResult::ValidationError(_) => {
let msg = i18n.t.login_invalid.to_owned();
return login_page_handler(i18n, &config, db, msg)
.await?
.into_response();
}
};
// Try to authenticate
if let Ok(Some(user)) = User::get_by_username(&db, &data.username).await
{
if let Some(hash) = user.password_ref() {
let password = Password::new(&data.password);
match hash.verify(&password) {
PasswordVerificationResult::Ok
| PasswordVerificationResult::OkObsolete(_) => {
auth::login(&session, user.id_val()).await?;
return Ok(auth::redirect("/"));
}
PasswordVerificationResult::Invalid => {}
}
}
}
let msg = i18n.t.login_invalid.to_owned();
login_page_handler(i18n, &config, db, msg)
.await?
.into_response()
}
}
}),
"login",
),
Route::with_handler_and_name("/logout", get(logout_handler), "logout"),
Route::with_handler_and_name("/set-lang", set_lang, "set_lang"),
Route::with_handler_and_name(
"/auth/oidc/start",
get(oidc::oidc_start_handler),
"oidc_start",
),
Route::with_handler_and_name(
"/auth/oidc/callback",
get(oidc::oidc_callback_handler),
"oidc_callback",
),
])
}
}
// ---------------------------------------------------------------------------
// Project
// ---------------------------------------------------------------------------
struct AmneziaFellowProject {
app_config: Arc<AppConfig>,
}
impl Project for AmneziaFellowProject {
fn cli_metadata(&self) -> CliMetadata {
CliMetadata {
description: concat!(
env!("CARGO_PKG_DESCRIPTION"),
"\n\n",
"CONFIGURATION\n",
" All settings are available as AMNEZIA_FELLOW_-prefixed environment variables.\n",
" Priority: env var > DB override > compiled default.\n",
"\n",
" Database (required for most features):\n",
" AMNEZIA_FELLOW_DATABASE_URL SQLite or PostgreSQL connection URL\n",
" Example: sqlite:///data/amnezia-fellow.sqlite3?mode=rwc\n",
" Example: postgresql://user:pass@postgres:5432/amnezia_fellow\n",
" AMNEZIA_FELLOW_MIGRATE_SQLITE SQLite path/URL to import into PostgreSQL once\n",
" Example: /data/amnezia-fellow.sqlite3\n",
"\n",
" Server:\n",
" AMNEZIA_FELLOW_LOG_LEVEL Tracing filter (default: info)\n",
"\n",
" Authentication:\n",
" AMNEZIA_FELLOW_AUTH_PASSWORD_ENABLED Enable password login (default: true)\n",
" AMNEZIA_FELLOW_AUTH_SSO_ENABLED Enable SSO/OIDC login (default: false)\n",
" AMNEZIA_FELLOW_OIDC_ISSUER OIDC issuer URL\n",
" AMNEZIA_FELLOW_OIDC_CLIENT_ID OIDC client ID\n",
" AMNEZIA_FELLOW_OIDC_CLIENT_SECRET OIDC client secret\n",
" AMNEZIA_FELLOW_OIDC_BUTTON_TEXT SSO button label\n",
" AMNEZIA_FELLOW_OIDC_ADMIN_GROUPS OIDC groups that grant admin role\n",
" AMNEZIA_FELLOW_OIDC_CLIENT_GROUPS OIDC groups that grant client role\n",
"\n",
" Kubernetes:\n",
" AMNEZIA_FELLOW_K8S_NAMESPACE Namespace with Amnezia secrets\n",
" AMNEZIA_FELLOW_K8S_CLIENTS_SECRET Client peer Secret name\n",
" AMNEZIA_FELLOW_VPN_CLIENT_CIDR Client address pool\n",
"\n",
" API:\n",
" AMNEZIA_FELLOW_SWAGGER_ENABLED Enable Swagger UI at /swagger/ (default: false)\n",
"\n",
" Telegram:\n",
" AMNEZIA_FELLOW_TELEGRAM_BOT_ENABLED Enable Telegram bot/Web App integration (default: false)\n",
" AMNEZIA_FELLOW_TELEGRAM_BOT_USERNAME Telegram bot username, with or without @\n",
" AMNEZIA_FELLOW_TELEGRAM_BOT_TOKEN Telegram bot token for Web App initData verification\n",
"\n",
"QUICK START\n",
" export AMNEZIA_FELLOW_DATABASE_URL=sqlite://amnezia-fellow.sqlite3?mode=rwc\n",
" amnezia-fellow --listen 127.0.0.1:8000",
),
..cot::cli::metadata!()
}
}
fn config(&self, _config_name: &str) -> cot::Result<ProjectConfig> {
let mut builder = ProjectConfig::builder();
builder.debug(cfg!(debug_assertions));
if !self.app_config.database_url.is_empty() {
builder.database(
DatabaseConfig::builder()
.url(self.app_config.database_url.as_str())
.build(),
);
builder.middlewares(
MiddlewareConfig::builder()
.session(
SessionMiddlewareConfig::builder()
.store(
SessionStoreConfig::builder()
.store_type(SessionStoreTypeConfig::Database)
.build(),
)
.build(),
)
.build(),
);
}
Ok(builder.build())
}
fn middlewares(
&self,
handler: cot::project::RootHandlerBuilder,
context: &cot::project::MiddlewareContext,
) -> cot::project::RootHandler {
handler
.middleware(StaticFilesMiddleware::from_context(context))
.middleware(
SessionMiddleware::from_context(context).same_site(cot::config::SameSite::Lax),
)
.build()
}
fn register_apps(&self, apps: &mut AppBuilder, _context: &RegisterAppsContext) {
apps.register(cot::session::db::SessionApp::new());
apps.register_with_views(
AmneziaFellowApp {
config: Arc::clone(&self.app_config),
},
"",
);
apps.register_with_views(admin::AdminApp::new(), "/admin");
apps.register_with_views(api::ApiApp, "/api");
if self.app_config.swagger_enabled {
apps.register_with_views(cot::openapi::swagger_ui::SwaggerUi::new(), "/swagger");
}
}
}
// ---------------------------------------------------------------------------
// Entrypoint
// ---------------------------------------------------------------------------
#[cot::main]
fn main() -> impl Project {
let app_config = Arc::new(AppConfig::load());
// Initialise tracing subscriber with the configured log level.
// AMNEZIA_FELLOW_LOG_LEVEL (or the default "info") is parsed as an
// EnvFilter directive, so values like "debug" all work.
let filter =
tracing_subscriber::EnvFilter::try_new(&app_config.log_level).unwrap_or_else(|e| {
eprintln!(
"WARNING: invalid AMNEZIA_FELLOW_LOG_LEVEL {:?}: {e}; falling back to \"info\"",
app_config.log_level,
);
tracing_subscriber::EnvFilter::new("info")
});
tracing_subscriber::fmt().with_env_filter(filter).init();
tracing::info!("loaded config: {:?}", app_config);
telegram::spawn_bot_worker(Arc::clone(&app_config));
AmneziaFellowProject { app_config }
}