diff --git a/Cargo.toml b/Cargo.toml index 5aab1b9..f6b5ca3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,16 +9,21 @@ license = "GPL3.0-or-later" [dependencies] anyhow = "1.0.71" +argparse = "0.2.2" async-std = { version = "1.12.0", features = ["attributes"] } base64 = "0.21.2" bcrypt = "0.14.0" colored = "2.0.0" +dialoguer = { version = "0.10.4", default-features = false, features = ["password"] } driftwood = "0.0.7" femme = "2.2.1" +futures = "0.3.28" json = "0.12.4" log = "0.4.19" +once_cell = "1.18.0" rand = "0.8.5" random-string = "1.0.0" +regex = "1.8.4" rsa = "0.9.2" serde = { version = "1.0.164", features = ["derive"] } serde_json = "1.0.97" @@ -27,3 +32,14 @@ sqlx = { version = "0.6.3", features = ["sqlite", "runtime-async-std-native-tls" tide = "0.16.0" time = "0.3.22" toml = "0.7.4" +uuid = { version = "1.3.4", features = ["v4", "fast-rng"] } + +# Server +[[bin]] +name = "yggdrasil" +path = "src/main.rs" + +# Database UI +[[bin]] +name = "dbtool" +path = "src/main_dbtool.rs" diff --git a/dbtool b/dbtool new file mode 100755 index 0000000..f813ac4 --- /dev/null +++ b/dbtool @@ -0,0 +1,14 @@ +#! /usr/bin/bash + +# +# Yggdrasil: Minecraft authentication server +# Copyright (C) 2023 0xf8.dev@proton.me +# +# This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with this program. If not, see . +# + +DATABASE_URL="sqlite:yggdrasil.db" cargo run --bin dbtool -- "$@" \ No newline at end of file diff --git a/src/dbtool/add_account.rs b/src/dbtool/add_account.rs new file mode 100644 index 0000000..2695657 --- /dev/null +++ b/src/dbtool/add_account.rs @@ -0,0 +1,52 @@ +/* + * Yggdrasil: Minecraft authentication server + * Copyright (C) 2023 0xf8.dev@proton.me + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with this program. If not, see . + */ + +use anyhow::{bail, Result}; +use log::info; + +use structs::account::Account; +use yggdrasil::*; + +use crate::dbtool::Args; + +pub struct AddAccount {} + +impl AddAccount { + pub async fn exec(args: Args, db: &Database) -> Result<()> { + if args.arguments.len() < 3 { bail!("Not enough arguments. add-account ") }; + + // Get args + let email = args.arguments.get(0).unwrap().to_lowercase(); + let lang = args.arguments.get(1).unwrap().to_lowercase(); + let country = args.arguments.get(2).unwrap().to_string(); + + // Validate args + if !Validate::email(&email) { bail!("Invalid email; ex: \"user@example\"") } + if !Validate::lang(&lang) { bail!("Invalid language; ex: \"en-us\"") } + if !Validate::country(&country) { bail!("Invalid country; ex: \"US\"") } + + // Get password + let password = Input::password().await?; + + info!("Email: {email}"); + info!("Lang: {lang}"); + info!("Country: {country}"); + info!("Password: ...{{{}}}", password.len()); + + // Create new account + let account = Account::new(db, email, lang, country, password).await?; + + info!("New account ID: {}", account.id); + + Ok(()) + } +} + diff --git a/src/dbtool/add_profile.rs b/src/dbtool/add_profile.rs new file mode 100644 index 0000000..37e3731 --- /dev/null +++ b/src/dbtool/add_profile.rs @@ -0,0 +1,53 @@ +/* + * Yggdrasil: Minecraft authentication server + * Copyright (C) 2023 0xf8.dev@proton.me + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with this program. If not, see . + */ + +use anyhow::{bail, Result}; +use log::info; + +use structs::{account::Account, profile::Profile}; +use yggdrasil::*; + +use crate::dbtool::Args; + +pub struct AddProfile {} + +impl AddProfile { + pub async fn exec(args: Args, db: &Database) -> Result<()> { + if args.arguments.len() < 2 { bail!("Not enough arguments. add-profile ") } + + // Get args + let email = args.arguments.get(0).unwrap().to_lowercase(); + let name = args.arguments.get(1).unwrap().to_string(); + + // Get account + let Some(account) = Account::from_email(db, email.to_owned()).await else { bail!("Account(email=\"{email}\") doesn't exist") }; + + // Attributes + let attributes = Input::attributes().await?; + + info!("Owner ID: {}", account.id); + + // Create new profile + let profile = Profile::new(db, account.to_owned(), name, attributes).await?; + + info!("New profile Name: \"{}\"", profile.name); + info!("New profile ID: {}", profile.id); + info!("New profile UUID: {}", profile.uuid); + + if account.selected_profile.is_none() { + info!("Setting new profile to be account's selected profile"); + account.set_selected_profile(db, &profile).await?; + } + + Ok(()) + } +} + diff --git a/src/dbtool/attach_profile.rs b/src/dbtool/attach_profile.rs new file mode 100644 index 0000000..8008ca1 --- /dev/null +++ b/src/dbtool/attach_profile.rs @@ -0,0 +1,46 @@ +/* + * Yggdrasil: Minecraft authentication server + * Copyright (C) 2023 0xf8.dev@proton.me + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with this program. If not, see . + */ + + +use std::str::FromStr; + +use anyhow::{bail, Result}; +use log::info; + +use structs::profile::Profile; +use yggdrasil::*; + +use crate::dbtool::Args; +use crate::util::structs::account::Account; + +pub struct AttachProfile {} + +impl AttachProfile { + pub async fn exec(args: Args, db: &Database) -> Result<()> { + if args.arguments.len() < 2 { bail!("Not enough arguments. attach-profile ") } + + // Get ids + let account_id = i64::from_str(args.arguments.get(0).unwrap())?; + let profile_id = i64::from_str(args.arguments.get(1).unwrap())?; + + // Get account + let Some(account) = Account::from_id(db, account_id).await else { + bail!("Account(id = {account_id}) doesn't exist") + }; + + // Get profile + let Some(profile) = Profile::from_id(db, profile_id).await else { + bail!("Profile(id = {profile_id}) doesn't exist") + }; + + account.set_selected_profile(db, &profile).await + } +} diff --git a/src/server/authserver/refresh.rs b/src/dbtool/del_account.rs similarity index 55% rename from src/server/authserver/refresh.rs rename to src/dbtool/del_account.rs index 568298b..1fe1135 100644 --- a/src/server/authserver/refresh.rs +++ b/src/dbtool/del_account.rs @@ -9,11 +9,30 @@ * You should have received a copy of the GNU General Public License along with this program. If not, see . */ -use anyhow::anyhow; -use tide::{prelude::*, Request, Result}; +use std::str::FromStr; -use yggdrasil::Database; +use anyhow::{bail, Result}; +use log::info; -pub async fn refresh(req: Request) -> Result { - Err(tide::Error::new(501, anyhow!("Not implemented yet")).into()) -} \ No newline at end of file +use structs::account::Account; +use yggdrasil::*; + +use crate::dbtool::Args; + +pub struct DelAccount {} + +impl DelAccount { + pub async fn exec(args: Args, db: &Database) -> Result<()> { + if args.arguments.len() < 1 { bail!("Not enough arguments. del-account ") } + + // Get id + let id = i64::from_str(args.arguments.get(0).unwrap())?; + + // Delete account + let email = Account::del(db, id).await?; + + info!("Deleted account(email = \"{email}\")"); + + Ok(()) + } +} diff --git a/src/server/authserver/invalidate.rs b/src/dbtool/del_profile.rs similarity index 55% rename from src/server/authserver/invalidate.rs rename to src/dbtool/del_profile.rs index e842e0f..59c32ca 100644 --- a/src/server/authserver/invalidate.rs +++ b/src/dbtool/del_profile.rs @@ -9,11 +9,30 @@ * You should have received a copy of the GNU General Public License along with this program. If not, see . */ -use anyhow::anyhow; -use tide::{prelude::*, Request, Result}; +use std::str::FromStr; -use yggdrasil::Database; +use anyhow::{bail, Result}; +use log::info; -pub async fn invalidate(req: Request) -> Result { - Err(tide::Error::new(501, anyhow!("Not implemented yet")).into()) -} \ No newline at end of file +use structs::profile::Profile; +use yggdrasil::*; + +use crate::dbtool::Args; + +pub struct DelProfile {} + +impl DelProfile { + pub async fn exec(args: Args, db: &Database) -> Result<()> { + if args.arguments.len() < 1 { bail!("Not enough arguments. del-profile ") } + + // Get id + let id = i64::from_str(args.arguments.get(0).unwrap())?; + + // Delete profile + let uuid = Profile::del(db, id).await?; + + info!("Deleted profile(uuid = \"{uuid}\")"); + + Ok(()) + } +} diff --git a/src/dbtool/dump.rs b/src/dbtool/dump.rs new file mode 100644 index 0000000..3d7edcc --- /dev/null +++ b/src/dbtool/dump.rs @@ -0,0 +1,87 @@ +/* + * Yggdrasil: Minecraft authentication server + * Copyright (C) 2023 0xf8.dev@proton.me + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with this program. If not, see . + */ + +use anyhow::{bail, Result}; +use log::info; + +use yggdrasil::*; +use yggdrasil::structs::*; + +use crate::dbtool::Args; + +pub struct Dump {} + +impl Dump { + async fn dump_accounts(db: &Database) -> Result<()> { + let r = sqlx::query_as!(account::AccountRaw, "SELECT * FROM accounts") + .fetch_all(&db.pool) + .await?; + + info!("[ Got {} records ]", r.len()); + + Ok(for a in r { + info!("{:#?}", a.complete(db).await) + }) + } + + async fn dump_profiles(db: &Database) -> Result<()> { + let r = sqlx::query_as!(profile::ProfileRaw, "SELECT * FROM profiles") + .fetch_all(&db.pool) + .await?; + + info!("[ Got {} records ]", r.len()); + + Ok(for p in r { + info!("{:#?}", p.complete(db).await.to_simple()) + }) + } + + async fn dump_sessions(db: &Database) -> Result<()> { + let r = sqlx::query_as!(session::SessionRaw, "SELECT * FROM sessions") + .fetch_all(&db.pool) + .await?; + + info!("[ Got {} records ]", r.len()); + + Ok(for s in r { + info!("{:#?}", s.complete(db).await) + }) + } + + async fn dump_tokens(db: &Database) -> Result<()> { + let r = sqlx::query_as!(token::TokenRaw, "SELECT * FROM tokens") + .fetch_all(&db.pool) + .await?; + + info!("[ Got {} records ]", r.len()); + + Ok(for t in r { + info!("{:#?}", t.complete(db).await) + }) + } + + pub async fn exec(args: Args, db: &Database) -> Result<()> { + if args.arguments.len() < 1 { bail!("Not enough arguments. dump ") } + + let table = args.arguments.get(0).unwrap().to_lowercase(); + + match table.as_str() { + "accounts" => Self::dump_accounts(db).await?, + "profiles" => Self::dump_profiles(db).await?, + "sessions" => Self::dump_sessions(db).await?, + "tokens" => Self::dump_tokens(db).await?, + _ => bail!("Invalid table \"{table}\". Tables: accounts, profiles, sessions, tokens") + } + + Ok(()) + } +} + diff --git a/src/dbtool/mod.rs b/src/dbtool/mod.rs new file mode 100644 index 0000000..8814d01 --- /dev/null +++ b/src/dbtool/mod.rs @@ -0,0 +1,76 @@ +/* + * Yggdrasil: Minecraft authentication server + * Copyright (C) 2023 0xf8.dev@proton.me + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with this program. If not, see . + */ + +use anyhow::{bail, Result}; +use argparse::{List, parser::ArgumentParser, Store, StoreTrue}; +use log::{debug, info}; + +use yggdrasil::*; + +mod dump; +mod search; +mod add_account; +mod add_profile; +mod del_account; +mod del_profile; +mod attach_profile; + +#[derive(Debug, Clone)] +pub struct Args { + pub command: String, + pub arguments: Vec, +} + +pub async fn start(db: &Database) -> Result<()> { + let mut args = Args { + command: String::new(), + arguments: vec![], + }; + + { + let mut parser = ArgumentParser::new(); + + parser.set_description("Database tool for Yggdrasil"); + parser.refer(&mut args.command) + .add_argument("command", Store, "Command to run") + .required(); + + parser.refer(&mut args.arguments) + .add_argument("arguments", List, "Arguments for command"); + + parser.parse_args_or_exit(); + } + + match args.command.to_lowercase().as_str() { + "dump" => dump::Dump::exec(args, &db).await?, + + "search" => search::Search::exec(args, &db).await?, + + "addaccount" | + "add-account" => add_account::AddAccount::exec(args, &db).await?, + + "addprofile" | + "add-profile" => add_profile::AddProfile::exec(args, &db).await?, + + "delaccount" | + "del-account" => del_account::DelAccount::exec(args, &db).await?, + + "delprofile" | + "del-profile" => del_profile::DelProfile::exec(args, &db).await?, + + "attachprofile" | + "attach-profile" => attach_profile::AttachProfile::exec(args, &db).await?, + + _ => bail!("Command doesn't exist") + } + + Ok(()) +} \ No newline at end of file diff --git a/src/dbtool/search.rs b/src/dbtool/search.rs new file mode 100644 index 0000000..036568d --- /dev/null +++ b/src/dbtool/search.rs @@ -0,0 +1,111 @@ +/* + * Yggdrasil: Minecraft authentication server + * Copyright (C) 2023 0xf8.dev@proton.me + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with this program. If not, see . + */ + +use std::str::FromStr; + +use anyhow::{bail, Result}; +use log::{info, warn}; + +use yggdrasil::*; +use yggdrasil::structs::account::Account; + +use crate::dbtool::Args; +use crate::util::structs::profile::Profile; + +pub struct Search {} + +impl Search { + // Account id + async fn search_accountid(db: &Database, query: Vec) -> Result<()> { + Ok(for q in query { + let id = match i64::from_str(&q) { + Ok(id) => id, + Err(_) => bail!("id({q}) isn't a valid i64") + }; + + match Account::from_id(db, id).await { + None => warn!("Account(id = {id}) doesn't exist"), + Some(a) => info!("{a:#?}") + } + }) + } + + // Profile id + async fn search_profileid(db: &Database, query: Vec) -> Result<()> { + Ok(for q in query { + let id = match i64::from_str(&q) { + Ok(id) => id, + Err(_) => bail!("id({q}) isn't a valid i64") + }; + + match Profile::from_id(db, id).await { + None => warn!("Profile(id = {id}) doesn't exist"), + Some(p) => info!("{:#?}", p.to_simple()) + } + }) + } + + // Account name + async fn search_email(db: &Database, query: Vec) -> Result<()> { + Ok(for q in query { + match Account::from_email(db, q.to_string()).await { + None => warn!("Account(email = \"{q}\") doesn't exist"), + Some(a) => info!("{a:#?}") + } + }) + } + + // Profile name + async fn search_name(db: &Database, query: Vec) -> Result<()> { + Ok(for q in query { + match Profile::from_name(db, q.to_string()).await { + None => warn!("Profile(name = \"{q}\") doesn't exist"), + Some(p) => info!("{:#?}", p.to_simple()) + } + }) + } + + // Profile uuid + async fn search_uuid(db: &Database, query: Vec) -> Result<()> { + Ok(for q in query { + match Profile::from_uuid(db, q.to_string()).await { + None => warn!("Profile(uuid = \"{q}\") doesn't exist"), + Some(p) => info!("{:#?}", p.to_simple()) + } + }) + } + + pub async fn exec(args: Args, db: &Database) -> Result<()> { + if args.arguments.len() < 2 { bail!("Not enough arguments. search [query..]\ntype: account-id | profile-id | email | name | uuid") } + + let query_type = args.arguments.get(0).unwrap().to_lowercase(); + let queries = args.arguments[1..args.arguments.len()].to_vec(); + + match query_type.as_str() { + "accountid" | + "account-id" => Self::search_accountid(db, queries).await?, + + "profileid" | + "profile-id" => Self::search_profileid(db, queries).await?, + + "email" => Self::search_email(db, queries).await?, + + "name" => Self::search_name(db, queries).await?, + + "uuid" => Self::search_uuid(db, queries).await?, + + _ => bail!("Invalid type \"{query_type}\"") + } + + Ok(()) + } +} + diff --git a/src/main.rs b/src/main.rs index d73ef12..55ac23e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,7 +12,8 @@ #![feature(fs_try_exists)] use anyhow::{bail, Result}; -use log::{debug, error, info, log, trace, warn}; +use log::{info, warn}; +use log::LevelFilter::Debug; use yggdrasil::*; @@ -26,7 +27,11 @@ async fn main() -> Result<()> { } // Start logger - femme::start(); + if std::env::var("DEBUG").unwrap_or(String::new()).to_lowercase() == "on" { + femme::with_level(Debug); + } else { + femme::start(); + } // Load config let config = Config::load()?; @@ -36,9 +41,10 @@ async fn main() -> Result<()> { let db = Database::init(config).await?; info!("Database URL: {}", std::env::var("DATABASE_URL")?); + let wrapper = DatabaseWrapper { db }; + // Start server - let server_thread = async_std::task::spawn(server::start(db)); - server_thread.await?; + server::start(&wrapper.db).await?; warn!("Server stopped!"); diff --git a/src/main_dbtool.rs b/src/main_dbtool.rs new file mode 100644 index 0000000..48cf605 --- /dev/null +++ b/src/main_dbtool.rs @@ -0,0 +1,50 @@ +/* + * Yggdrasil: Minecraft authentication server + * Copyright (C) 2023 0xf8.dev@proton.me + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with this program. If not, see . + */ + +#![feature(fs_try_exists)] + +use anyhow::{bail, Result}; +use log::{debug, error, info, log, trace, warn}; +use log::LevelFilter::{Debug, Info}; + +use yggdrasil::*; + +mod util; +mod dbtool; + +#[async_std::main] +async fn main() -> Result<()> { + // Early catch + if std::env::var("DATABASE_URL").is_err() { + bail!("DATABASE_URL needs to be set.") + } + + // Start logger + if std::env::var("DEBUG").unwrap_or(String::new()).to_lowercase() == "on" { + femme::with_level(Debug); + } else { + femme::with_level(Info); + } + + // Load config + let config = Config::load()?; + + // Load database + let db = Database::init(config).await?; + + match dbtool::start(&db).await { + Ok(_) => (), + Err(e) => error!("{e}") + } + + // Cleanup + Ok(log::logger().flush()) +} diff --git a/src/server/account/profiles.rs b/src/server/account/profiles.rs index 11944e3..f0f7a31 100644 --- a/src/server/account/profiles.rs +++ b/src/server/account/profiles.rs @@ -10,7 +10,7 @@ */ use anyhow::anyhow; -use tide::{prelude::*, Request, Result}; +use tide::{Request, Result}; use yggdrasil::Database; diff --git a/src/server/account/skin.rs b/src/server/account/skin.rs index 1af092f..a4bac06 100644 --- a/src/server/account/skin.rs +++ b/src/server/account/skin.rs @@ -10,7 +10,7 @@ */ use anyhow::anyhow; -use tide::{prelude::*, Request, Result}; +use tide::{Request, Result}; use yggdrasil::Database; diff --git a/src/server/authserver/authenticate.rs b/src/server/auth/authenticate.rs similarity index 66% rename from src/server/authserver/authenticate.rs rename to src/server/auth/authenticate.rs index 137c1d6..a544144 100644 --- a/src/server/authserver/authenticate.rs +++ b/src/server/auth/authenticate.rs @@ -9,32 +9,35 @@ * You should have received a copy of the GNU General Public License along with this program. If not, see . */ +use log::{debug, info}; use tide::{prelude::*, Request, Result}; use yggdrasil::*; use yggdrasil::errors::YggdrasilError; -use yggdrasil::structs::{Account::Account, Cape::Cape, Token::Token}; +use yggdrasil::structs::{account::Account, cape::Cape, token::Token}; #[derive(Deserialize, Debug)] struct Agent { pub name: String, - pub version: i64 + pub version: i64, } #[derive(Deserialize, Debug)] struct AuthenticateBody { pub agent: Agent, pub username: String, - pub password: String, // hashed? + pub password: String, + #[serde(rename = "clientToken")] pub client_token: Option, + #[serde(rename = "requestUser")] - pub request_user: Option + pub request_user: Option, } pub async fn authenticate(mut req: Request) -> Result { let Ok(body) = req.body_json::().await else { - return Err(YggdrasilError::new_bad_request("Bad Request").into()); + return Err(YggdrasilError::new_bad_request("Credentials can not be null.").into()); }; // Check current agent @@ -45,14 +48,15 @@ pub async fn authenticate(mut req: Request) -> Result { // Get account let account = Account::from_email(req.state(), body.username).await; - // Account doesn't exist let Some(account) = account else { - return Err(YggdrasilError::new_forbidden("Invalid credentials. Invalid username or password.").into()) + // Account doesn't exist + return Err(YggdrasilError::new_unauthorized("Invalid credentials. Invalid username or password.").into()); }; - // Password incorrect - if account.password_hash != body.password { - return Err(YggdrasilError::new_forbidden("Invalid credentials. Invalid username or password.").into()); + // Verify password + if !bcrypt::verify(body.password, &account.password_hash)? { + // Password incorrect + return Err(YggdrasilError::new_unauthorized("Invalid credentials. Invalid username or password.").into()); } // Response @@ -61,18 +65,21 @@ pub async fn authenticate(mut req: Request) -> Result { Some(t) => t }; + // New token + let Some(token) = Token::new(req.state(), account.to_owned(), client_token).await else { + return Err(YggdrasilError::new_bad_request("Couldn't create new token").into()) + }; + let mut response = json!({ - "clientToken": client_token, - "accessToken": "", // TODO: register_token - "availableProfiles": [], // TODO: get account profiles + "clientToken": token.client, + "accessToken": token.access, + "availableProfiles": account.get_all_profiles(req.state()).await.unwrap_or(Vec::new()), }); // Give selected profile if it exists - if account.selected_profile.is_some() { - let profile = account.to_owned().selected_profile.unwrap(); - + if let Some(profile) = account.selected_profile.to_owned() { response["selectedProfile"] = json!({ - "uuid": profile.uuid, + "id": profile.uuid, "name": profile.name, "name_history": profile.name_history, "skin_variant": profile.skin_variant, @@ -80,7 +87,7 @@ pub async fn authenticate(mut req: Request) -> Result { Some(capes) => Cape::capes_to_string(capes), None => "".to_string() }, - "active_cape": profile.active_cape.unwrap(), + "active_cape": profile.active_cape, "attributes": profile.attributes.to_json() }); } diff --git a/src/server/auth/invalidate.rs b/src/server/auth/invalidate.rs new file mode 100644 index 0000000..ae3410e --- /dev/null +++ b/src/server/auth/invalidate.rs @@ -0,0 +1,48 @@ +/* + * Yggdrasil: Minecraft authentication server + * Copyright (C) 2023 0xf8.dev@proton.me + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with this program. If not, see . + */ + +use anyhow::anyhow; +use tide::{prelude::*, Request, Result}; + +use yggdrasil::Database; +use yggdrasil::errors::YggdrasilError; +use yggdrasil::structs::token::Token; + +#[derive(Deserialize, Debug)] +struct InvalidateBody { + #[serde(rename = "accessToken")] + access_token: String, + + #[serde(rename = "clientToken")] + client_token: String, +} + +pub async fn invalidate(mut req: Request) -> Result { + let Ok(body) = req.body_json::().await else { + // No credentials + return Err(YggdrasilError::new_bad_request("Credentials can not be null.").into()) + }; + + let Some(token) = Token::from_access_token(req.state(), body.access_token).await else { + // Token doesn't exist + return Err(YggdrasilError::new_unauthorized("Invalid token.").into()) + }; + + // Verify token + if !token.validate_with(req.state(), body.client_token, false).await? { + return Err(YggdrasilError::new_unauthorized("Invalid token.").into()) + } + + // Delete token + token.delete(req.state()).await?; + + Ok("".into()) +} \ No newline at end of file diff --git a/src/server/authserver/mod.rs b/src/server/auth/mod.rs similarity index 100% rename from src/server/authserver/mod.rs rename to src/server/auth/mod.rs diff --git a/src/server/auth/refresh.rs b/src/server/auth/refresh.rs new file mode 100644 index 0000000..55581ba --- /dev/null +++ b/src/server/auth/refresh.rs @@ -0,0 +1,84 @@ +/* + * Yggdrasil: Minecraft authentication server + * Copyright (C) 2023 0xf8.dev@proton.me + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with this program. If not, see . + */ + +use anyhow::anyhow; +use log::debug; +use tide::{prelude::*, Request, Result}; + +use yggdrasil::Database; +use yggdrasil::errors::YggdrasilError; +use yggdrasil::structs::{cape::Cape, token::Token}; + +#[derive(Deserialize, Debug)] +struct RefreshBody { + #[serde(rename = "accessToken")] + access_token: String, + + #[serde(rename = "clientToken")] + client_token: String, + + #[serde(rename = "requestUser")] + pub request_user: Option, +} + +pub async fn refresh(mut req: Request) -> Result { + let Ok(body) = req.body_json::().await else { + // No credentials + return Err(YggdrasilError::new_bad_request("Credentials can not be null.").into()) + }; + + debug!("accessToken: {}", body.access_token); + debug!("clientToken: {}", body.client_token); + + let Some(token) = Token::from_access_token(req.state(), body.access_token).await else { + // Token doesn't exist + return Err(YggdrasilError::new_unauthorized("Invalid token.").into()) + }; + + // Verify token + if !token.validate_with(req.state(), body.client_token, false).await? { + return Err(YggdrasilError::new_unauthorized("Invalid token.").into()) + } + + // Delete old token + token.delete(req.state()).await?; + + let Some(new_token) = Token::new(req.state(), token.account, token.client).await else { + return Err(YggdrasilError::new_bad_request("Couldn't create new token").into()) + }; + + // Create response + let mut response = json!({ + "accessToken": new_token.access, + "clientToken": new_token.client + }); + + // Give selected profile if it exists + if let Some(profile) = new_token.account.selected_profile.to_owned() { + response["selectedProfile"] = json!({ + "id": profile.uuid, + "name": profile.name, + "name_history": profile.name_history, + "skin_variant": profile.skin_variant, + "capes": match profile.capes { + Some(capes) => Cape::capes_to_string(capes), + None => "".to_string() + }, + "active_cape": profile.active_cape, + "attributes": profile.attributes.to_json() + }); + } + + // Give user if requested + if body.request_user.unwrap_or(false) { response["user"] = new_token.account.to_user() } + + Ok(response.into()) +} \ No newline at end of file diff --git a/src/server/auth/signout.rs b/src/server/auth/signout.rs new file mode 100644 index 0000000..545a26c --- /dev/null +++ b/src/server/auth/signout.rs @@ -0,0 +1,48 @@ +/* + * Yggdrasil: Minecraft authentication server + * Copyright (C) 2023 0xf8.dev@proton.me + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with this program. If not, see . + */ + +use anyhow::anyhow; +use tide::{prelude::*, Request, Result}; + +use yggdrasil::Database; +use yggdrasil::errors::YggdrasilError; +use yggdrasil::structs::account::Account; +use yggdrasil::structs::token::Token; + +#[derive(Deserialize, Debug)] +struct SignoutBody { + pub username: String, + pub password: String +} + +pub async fn signout(mut req: Request) -> Result { + let Ok(body) = req.body_json::().await else { + // No credentials + return Err(YggdrasilError::new_bad_request("Credentials can not be null.").into()) + }; + + // Get account + let Some(account) = Account::from_email(req.state(), body.username).await else { + // Account doesn't exist + return Err(YggdrasilError::new_unauthorized("Invalid credentials. Invalid username or password.").into()) + }; + + // Verify password + if !bcrypt::verify(body.password, &account.password_hash)? { + // Password incorrect + return Err(YggdrasilError::new_unauthorized("Invalid credentials. Invalid username or password.").into()); + } + + // Delete all tokens + Token::delete_all_from(req.state(), account).await?; + + Ok("".into()) +} \ No newline at end of file diff --git a/src/server/auth/validate.rs b/src/server/auth/validate.rs new file mode 100644 index 0000000..ce10a69 --- /dev/null +++ b/src/server/auth/validate.rs @@ -0,0 +1,46 @@ +/* + * Yggdrasil: Minecraft authentication server + * Copyright (C) 2023 0xf8.dev@proton.me + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with this program. If not, see . + */ + +use anyhow::anyhow; +use tide::{prelude::*, Request, Result}; + +use yggdrasil::Database; +use yggdrasil::errors::YggdrasilError; +use yggdrasil::structs::token::Token; + +#[derive(Deserialize, Debug)] +struct ValidateBody { + #[serde(rename = "accessToken")] + access_token: String, + + #[serde(rename = "clientToken")] + client_token: String, +} + +pub async fn validate(mut req: Request) -> Result { + let Ok(body) = req.body_json::().await else { + // No credentials + return Err(YggdrasilError::new_illegal_argument("Credentials can not be null.").into()) + }; + + // Get token + let Some(token) = Token::from_access_token(req.state(), body.access_token).await else { + // Token doesn't exist + return Err(YggdrasilError::new_forbidden("Token expired.").into()) + }; + + // Verify token + if !token.validate_with(req.state(), body.client_token, false).await? { + return Err(YggdrasilError::new_forbidden("Token expired.").into()) + } + + Ok("".into()) +} \ No newline at end of file diff --git a/src/server/authlib/mod.rs b/src/server/authlib/mod.rs index 1bc9e27..04a460a 100644 --- a/src/server/authlib/mod.rs +++ b/src/server/authlib/mod.rs @@ -20,8 +20,8 @@ pub fn nest(db: Database) -> tide::Server { let mut nest = tide::with_state(db.to_owned()); nest.at("/").get(authlib_meta); - nest.at("/authserver").nest(super::authserver::nest(db.to_owned())); - nest.at("/sessionserver").nest(super::sessionserver::nest(db.to_owned())); + nest.at("/authserver").nest(super::auth::nest(db.to_owned())); + nest.at("/sessionserver").nest(super::session::nest(db.to_owned())); nest } diff --git a/src/server/authserver/signout.rs b/src/server/authserver/signout.rs deleted file mode 100644 index 0767360..0000000 --- a/src/server/authserver/signout.rs +++ /dev/null @@ -1,19 +0,0 @@ -/* - * Yggdrasil: Minecraft authentication server - * Copyright (C) 2023 0xf8.dev@proton.me - * - * This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License along with this program. If not, see . - */ - -use anyhow::anyhow; -use tide::{prelude::*, Request, Result}; - -use yggdrasil::Database; - -pub async fn signout(req: Request) -> Result { - Err(tide::Error::new(501, anyhow!("Not implemented yet")).into()) -} \ No newline at end of file diff --git a/src/server/minecraft/capes.rs b/src/server/minecraft/capes.rs index 1d3fc41..83623f4 100644 --- a/src/server/minecraft/capes.rs +++ b/src/server/minecraft/capes.rs @@ -10,7 +10,7 @@ */ use anyhow::anyhow; -use tide::{prelude::*, Request, Result}; +use tide::{Request, Result}; use yggdrasil::Database; @@ -18,7 +18,6 @@ pub async fn upload_cape(req: Request) -> Result { Err(tide::Error::new(501, anyhow!("Not implemented yet")).into()) } - pub async fn delete_cape(req: Request) -> Result { Err(tide::Error::new(501, anyhow!("Not implemented yet")).into()) } \ No newline at end of file diff --git a/src/server/mod.rs b/src/server/mod.rs index ef63c33..1b19247 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -9,25 +9,29 @@ * You should have received a copy of the GNU General Public License along with this program. If not, see . */ -use log::info; +use log::debug; use tide::{Request, Response, utils::After}; use yggdrasil::*; mod account; -mod authserver; +mod auth; mod authlib; mod minecraft; -mod sessionserver; +mod session; -pub async fn start(db: Database) -> anyhow::Result<()> { +pub async fn start(db: &Database) -> anyhow::Result<()> { let mut app = tide::with_state(db.to_owned()); // Error handling middleware app.with(After(|mut res: Response| async move { if let Some(err) = res.downcast_error::() { + debug!("{:?}", err.to_owned()); + let body = err.to_json(); - res.set_status(err.2); + let status = err.2; + + res.set_status(status); res.set_body(body); // TODO: pass through @@ -40,17 +44,20 @@ pub async fn start(db: Database) -> anyhow::Result<()> { })); // Index - app.at("/").get(|mut req: Request| async move { - req.append_header("x-authlib-injector-api-location", "/authlib/"); - Ok("Yggdrasil") + app.at("/").get(|req: Request| async move { + let res = Response::builder(200) + .header("x-authlib-injector-api-location", format!("{}/authlib/", req.state().config.external_base_url)) + .build(); + + Ok(res) }); // Routes app.at("/authlib/").nest(authlib::nest(db.to_owned())); app.at("/account/").nest(account::nest(db.to_owned())); app.at("/minecraft/").nest(minecraft::nest(db.to_owned())); - app.at("/authserver/").nest(authserver::nest(db.to_owned())); - app.at("/sessionserver/").nest(sessionserver::nest(db.to_owned())); + app.at("/auth/").nest(auth::nest(db.to_owned())); + app.at("/session/").nest(session::nest(db.to_owned())); // Listen app.listen(format!("{}:{}", &db.config.address, &db.config.port)).await?; diff --git a/src/server/session/has_joined.rs b/src/server/session/has_joined.rs new file mode 100644 index 0000000..eb732d7 --- /dev/null +++ b/src/server/session/has_joined.rs @@ -0,0 +1,58 @@ +/* + * Yggdrasil: Minecraft authentication server + * Copyright (C) 2023 0xf8.dev@proton.me + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with this program. If not, see . + */ + +use anyhow::anyhow; +use tide::{prelude::*, Request, Result}; + +use yggdrasil::Database; +use yggdrasil::errors::YggdrasilError; +use yggdrasil::structs::profile::Profile; +use yggdrasil::structs::session::Session; +use yggdrasil::structs::textured_object::TexturedObject; + +#[derive(Deserialize, Debug)] +struct HasJoinedBody { + pub username: String, + + #[serde(rename = "serverId")] + pub server_id: String, + + pub ip: Option, +} + +pub async fn has_joined(mut req: Request) -> Result { + let Ok(body) = req.body_json::().await else { + // No args + return Err(YggdrasilError::new_bad_request("One or more required fields was missing.").into()) + }; + + // Get profile + let Some(profile) = Profile::from_name(req.state(), body.username).await else { + return Err(YggdrasilError::new_bad_request("Profile does not exist.").into()) + }; + + // Get session + let Some(session) = Session::from_profile(req.state(), &profile).await else { + return Err(YggdrasilError::new_bad_request("Session does not exist.").into()) + }; + + // Check IP if requested + if let Some(ip) = body.ip { + if ip != session.ip_addr { + return Err(YggdrasilError::new_forbidden("IP address does not match.").into()) + } + } + + // Remove session + session.delete(req.state()).await?; + + Ok(TexturedObject::from_profile(req.state(), &profile).await.into()) +} \ No newline at end of file diff --git a/src/server/session/join.rs b/src/server/session/join.rs new file mode 100644 index 0000000..b208cf0 --- /dev/null +++ b/src/server/session/join.rs @@ -0,0 +1,60 @@ +/* + * Yggdrasil: Minecraft authentication server + * Copyright (C) 2023 0xf8.dev@proton.me + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with this program. If not, see . + */ + +use tide::{prelude::*, Request, Result}; + +use yggdrasil::Database; +use yggdrasil::errors::YggdrasilError; +use yggdrasil::structs::session::Session; +use yggdrasil::structs::token::Token; + +#[derive(Deserialize, Debug)] +struct JoinBody { + #[serde(rename = "accessToken")] + pub access_token: String, + + #[serde(rename = "selectedProfile")] + pub profile_uuid: String, + + #[serde(rename = "serverId")] + pub server_id: String +} + +pub async fn join(mut req: Request) -> Result { + let Ok(body) = req.body_json::().await else { + return Err(YggdrasilError::new_bad_request("Bad Request").into()) + }; + + let Some(token) = Token::from_access_token(req.state(), body.access_token).await else { + // Token doesnt exist + return Err(YggdrasilError::new_unauthorized("Invalid token.").into()) + }; + + if !token.validate(req.state(), false).await? { + // Invalid token + return Err(YggdrasilError::new_unauthorized("Invalid token.").into()) + } + + let Some(profile) = token.account.selected_profile.to_owned() else { + // No selected profile + return Err(YggdrasilError::new_unauthorized("Invalid token.").into()) + }; + + if body.profile_uuid != profile.uuid { + // UUID doesn't match + return Err(YggdrasilError::new_unauthorized("Invalid token.").into()) + } + + Session::create(req.state(), &profile, body.server_id, req.remote().unwrap().to_string()).await?; + + Ok("".into()) +} + diff --git a/src/server/sessionserver/mod.rs b/src/server/session/mod.rs similarity index 90% rename from src/server/sessionserver/mod.rs rename to src/server/session/mod.rs index db45c96..a622ba5 100644 --- a/src/server/sessionserver/mod.rs +++ b/src/server/session/mod.rs @@ -22,8 +22,8 @@ pub fn nest(db: Database) -> tide::Server { info!("Loading nest"); let mut nest = tide::with_state(db); - nest.at("hasJoined").get(has_joined::has_joined); - nest.at("join").post(join::join); + nest.at("minecraft/hasJoined").get(has_joined::has_joined); + nest.at("minecraft/join").post(join::join); nest.at("profile/:uuid").get(profile::profile); nest diff --git a/src/server/session/profile.rs b/src/server/session/profile.rs new file mode 100644 index 0000000..93112d7 --- /dev/null +++ b/src/server/session/profile.rs @@ -0,0 +1,40 @@ +/* + * Yggdrasil: Minecraft authentication server + * Copyright (C) 2023 0xf8.dev@proton.me + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with this program. If not, see . + */ + +use anyhow::anyhow; +use log::debug; +use tide::{prelude::*, Request, Result}; + +use yggdrasil::Database; +use yggdrasil::errors::YggdrasilError; +use yggdrasil::structs::profile::Profile; +use yggdrasil::structs::textured_object::TexturedObject; +use yggdrasil::structs::token::Token; + +// TODO: unsigned? +pub async fn profile(mut req: Request) -> Result { + let Ok(uuid) = req.param("uuid") else { + // No uuid + debug!("No uuid"); + return Err(YggdrasilError::new_bad_request("One or more required fields was missing.").into()) + }; + + let uuid = match uuid.find("-") { + None => Token::rehyphenate(uuid.to_string()), + Some(_) => uuid.to_string(), + }; + + let Some(profile) = Profile::from_uuid(req.state(), uuid).await else { + return Err(YggdrasilError::new_bad_request("Profile does not exist").into()) + }; + + Ok(TexturedObject::from_profile(req.state(), &profile).await.into()) +} \ No newline at end of file diff --git a/src/server/sessionserver/has_joined.rs b/src/server/sessionserver/has_joined.rs deleted file mode 100644 index b3119f0..0000000 --- a/src/server/sessionserver/has_joined.rs +++ /dev/null @@ -1,19 +0,0 @@ -/* - * Yggdrasil: Minecraft authentication server - * Copyright (C) 2023 0xf8.dev@proton.me - * - * This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License along with this program. If not, see . - */ - -use anyhow::anyhow; -use tide::{prelude::*, Request, Result}; - -use yggdrasil::Database; - -pub async fn has_joined(req: Request) -> Result { - Err(tide::Error::new(501, anyhow!("Not implemented yet")).into()) -} \ No newline at end of file diff --git a/src/server/sessionserver/join.rs b/src/server/sessionserver/join.rs deleted file mode 100644 index ceffb1e..0000000 --- a/src/server/sessionserver/join.rs +++ /dev/null @@ -1,19 +0,0 @@ -/* - * Yggdrasil: Minecraft authentication server - * Copyright (C) 2023 0xf8.dev@proton.me - * - * This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License along with this program. If not, see . - */ - -use anyhow::anyhow; -use tide::{prelude::*, Request, Result}; - -use yggdrasil::Database; - -pub async fn join(req: Request) -> Result { - Err(tide::Error::new(501, anyhow!("Not implemented yet")).into()) -} \ No newline at end of file diff --git a/src/server/sessionserver/profile.rs b/src/server/sessionserver/profile.rs deleted file mode 100644 index 3c8fdff..0000000 --- a/src/server/sessionserver/profile.rs +++ /dev/null @@ -1,19 +0,0 @@ -/* - * Yggdrasil: Minecraft authentication server - * Copyright (C) 2023 0xf8.dev@proton.me - * - * This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License along with this program. If not, see . - */ - -use anyhow::anyhow; -use tide::{prelude::*, Request, Result}; - -use yggdrasil::Database; - -pub async fn profile(req: Request) -> Result { - Err(tide::Error::new(501, anyhow!("Not implemented yet")).into()) -} \ No newline at end of file diff --git a/src/util/database.rs b/src/util/database.rs index 31e64f9..676f73e 100644 --- a/src/util/database.rs +++ b/src/util/database.rs @@ -14,6 +14,8 @@ use std::str::FromStr; use std::time::Duration; use anyhow::Result; +use futures::executor; +use log::debug; use sqlx::{ConnectOptions, SqlitePool}; use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; @@ -40,3 +42,15 @@ impl Database { }) } } + +pub struct DatabaseWrapper { + pub db: Database +} + +impl Drop for DatabaseWrapper { + fn drop(&mut self) { + debug!("Dropping database"); + executor::block_on(self.db.pool.close()); + } +} + diff --git a/src/util/errors.rs b/src/util/errors.rs index 3bb6ea7..dba1d55 100644 --- a/src/util/errors.rs +++ b/src/util/errors.rs @@ -13,7 +13,7 @@ use std::{error::Error, fmt}; use serde_json::json; -use crate::errors::YggdrasilErrorType::{BadRequestException, BaseYggdrasilException, ForbiddenOperationException, IllegalArgumentException}; +use YggdrasilErrorType::*; #[derive(Debug)] pub struct YggdrasilError(pub YggdrasilErrorType, pub String, pub u16, pub bool); @@ -23,6 +23,7 @@ pub struct YggdrasilError(pub YggdrasilErrorType, pub String, pub u16, pub bool) pub enum YggdrasilErrorType { BaseYggdrasilException, ForbiddenOperationException, + UnauthorizedOperationException, BadRequestException, IllegalArgumentException, } @@ -34,6 +35,7 @@ impl fmt::Display for YggdrasilError { use YggdrasilErrorType::*; match self.0 { + UnauthorizedOperationException | ForbiddenOperationException => write!(f, "FORBIDDEN"), BadRequestException => write!(f, "BAD_REQUEST"), _ => write!(f, "INTERNAL_SERVER_ERROR"), @@ -60,6 +62,15 @@ impl YggdrasilError { } } + pub fn new_unauthorized(msg: &str) -> Self { + Self { + 0: UnauthorizedOperationException, + 1: msg.to_string(), + 2: 401, + 3: true, + } + } + pub fn new_forbidden(msg: &str) -> Self { Self { 0: ForbiddenOperationException, @@ -86,5 +97,4 @@ impl YggdrasilError { 3: false } } - } diff --git a/src/util/input.rs b/src/util/input.rs new file mode 100644 index 0000000..12a8aeb --- /dev/null +++ b/src/util/input.rs @@ -0,0 +1,58 @@ +/* + * Yggdrasil: Minecraft authentication server + * Copyright (C) 2023 0xf8.dev@proton.me + * + * This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with this program. If not, see . + */ + +use anyhow::Result; +use dialoguer::{MultiSelect, Password}; +use dialoguer::theme::ColorfulTheme; + +use super::structs::profile_attributes::ProfileAttributesSimple; + +pub struct Input {} + +impl Input { + pub async fn password() -> Result { + let theme = ColorfulTheme::default(); + + let mut password = Password::with_theme(&theme); + password.with_prompt("Password"); + + Ok(password.interact()?) + } + + pub async fn attributes() -> Result { + let theme = ColorfulTheme::default(); + + let mut select = MultiSelect::with_theme(&theme); + select.with_prompt("Attributes"); + select.items(&["Can chat", "Can play multiplayer", "Can play realms", "Use profanity filter"]); + select.defaults(&[true, true, true, false]); + + let mut attr = ProfileAttributesSimple { + can_chat: false, + can_play_multiplayer: false, + can_play_realms: false, + use_filter: false, + }; + + for a in select.interact()? { + match a { + 0 => attr.can_chat = true, + 1 => attr.can_play_multiplayer = true, + 2 => attr.can_play_realms = true, + 3 => attr.use_filter = true, + _ => () + } + } + + Ok(attr) + } +} + diff --git a/src/util/mod.rs b/src/util/mod.rs index 7abc52c..1918a27 100644 --- a/src/util/mod.rs +++ b/src/util/mod.rs @@ -9,14 +9,25 @@ * You should have received a copy of the GNU General Public License along with this program. If not, see . */ +use std::time::UNIX_EPOCH; + pub use config::Config; pub use database::Database; +pub use database::DatabaseWrapper; +pub use input::Input; +pub use validate::Validate; mod config; -// TODO: fix signing -// https://github.com/RustCrypto/RSA/blob/master/tests/proptests.rs -// mod signing; mod database; +mod input; +mod validate; pub mod errors; pub mod structs; +// TODO: fix signing +// https://github.com/RustCrypto/RSA/blob/master/tests/proptests.rs +// mod signing; + +pub fn get_unix_timestamp() -> u128 { + std::time::SystemTime::now().duration_since(UNIX_EPOCH).expect("Time went backwards?!").as_millis() +} \ No newline at end of file diff --git a/src/util/structs/account.rs b/src/util/structs/account.rs index 3de6199..d231ee0 100644 --- a/src/util/structs/account.rs +++ b/src/util/structs/account.rs @@ -9,6 +9,8 @@ * You should have received a copy of the GNU General Public License along with this program. If not, see . */ +use anyhow::Result; +use log::debug; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; @@ -16,6 +18,7 @@ use structs::profile::{Profile, ProfileRaw}; use crate::*; +// TODO: 2FA #[derive(Deserialize, Serialize, Debug, Clone)] pub struct Account { pub id: i64, @@ -34,7 +37,7 @@ impl Account { match record { Ok(r) => Some(r.complete(db).await), - Err(_) => None + Err(e) => { debug!("{e}"); None }, } } @@ -45,7 +48,7 @@ impl Account { match record { Ok(r) => Some(r.complete(db).await), - Err(_) => None, + Err(e) => { debug!("{e}"); None }, } } @@ -60,10 +63,43 @@ impl Account { } Some(collection) } // oh boy - Err(_) => None + Err(e) => { debug!("{e}"); None }, } } + pub async fn set_selected_profile(&self, db: &Database, profile: &Profile) -> Result<()> { + sqlx::query!("UPDATE accounts SET selected_profile = $1 WHERE id = $2", profile.id, self.id) + .execute(&db.pool) + .await?; + + Ok(()) + } + + pub async fn new(db: &Database, email: String, language: String, country: String, password: String) -> Result { + let password_hash = bcrypt::hash(password, 12)?; + + let r = sqlx::query!("INSERT INTO accounts(email, language, country, password_hash) VALUES ($1, $2, $3, $4) RETURNING (id)", email, language, country, password_hash) + .fetch_one(&db.pool) + .await?; + + Ok(Account { + id: r.id, + email, + password_hash, + language, + country, + selected_profile: None, + }) + } + + pub async fn del(db: &Database, id: i64) -> Result { + let r = sqlx::query!("DELETE FROM accounts WHERE id = $1 RETURNING (email)", id) + .fetch_one(&db.pool) + .await?; + + Ok(r.email) + } + pub fn to_user(&self) -> Value { json!({ "id": self.id, diff --git a/src/util/structs/blocked_server.rs b/src/util/structs/blocked_server.rs index c9854d1..2eeca1e 100644 --- a/src/util/structs/blocked_server.rs +++ b/src/util/structs/blocked_server.rs @@ -9,6 +9,7 @@ * You should have received a copy of the GNU General Public License along with this program. If not, see . */ +use log::debug; use serde::{Deserialize, Serialize}; use crate::*; @@ -29,7 +30,7 @@ impl BlockedServer { match record { Ok(r) => Some(r), - Err(_) => None, + Err(e) => { debug!("{e}"); None }, } } } \ No newline at end of file diff --git a/src/util/structs/cape.rs b/src/util/structs/cape.rs index 53bce8e..78ffd65 100644 --- a/src/util/structs/cape.rs +++ b/src/util/structs/cape.rs @@ -9,6 +9,7 @@ * You should have received a copy of the GNU General Public License along with this program. If not, see . */ +use log::debug; use serde::{Deserialize, Serialize}; use crate::*; @@ -28,7 +29,7 @@ impl Cape { match record { Ok(r) => Some(r), - Err(_) => None, + Err(e) => { debug!("{e}"); None }, } } diff --git a/src/util/structs/profile.rs b/src/util/structs/profile.rs index d70b832..56fa5ab 100644 --- a/src/util/structs/profile.rs +++ b/src/util/structs/profile.rs @@ -9,12 +9,16 @@ * You should have received a copy of the GNU General Public License along with this program. If not, see . */ +use anyhow::Result; +use log::debug; use serde::{Deserialize, Serialize}; -use structs::{cape::Cape, profile_attributes::ProfileAttributes}; +use structs::{cape::Cape, profile_attributes::{ProfileAttributes, ProfileAttributesSimple}}; use crate::*; +use super::account::Account; + #[derive(Deserialize, Serialize, Debug, Clone)] pub struct Profile { pub id: i64, @@ -30,7 +34,7 @@ pub struct Profile { pub capes: Option>, pub active_cape: Option, - pub attributes: ProfileAttributes, + pub attributes: ProfileAttributesSimple, } impl Profile { @@ -41,7 +45,7 @@ impl Profile { match record { Ok(r) => Some(r.complete(db).await), - Err(_) => None, + Err(e) => { debug!("{e}"); None }, } } @@ -52,7 +56,7 @@ impl Profile { match record { Ok(r) => Some(r.complete(db).await), - Err(_) => None + Err(e) => { debug!("{e}"); None }, } } @@ -63,7 +67,54 @@ impl Profile { match record { Ok(r) => Some(r.complete(db).await), - Err(_) => None + Err(e) => { debug!("{e}"); None }, + } + } + + pub async fn get_owner(&self, db: &Database) -> Option { + Account::from_id(db, self.owner).await + } + + pub async fn new(db: &Database, owner: Account, name: String, attr: ProfileAttributesSimple) -> Result { + let created = (get_unix_timestamp() / 1000) as i64; + let uuidv4 = uuid::Uuid::new_v4().to_string(); + let attributes = attr.to_json().to_string(); + + let r = sqlx::query!("INSERT INTO profiles(uuid, created, owner, name, name_history, skin_variant, attributes) VALUES ($1, $2, $3, $4, $4, $5, $6) RETURNING (id)", + uuidv4, created, owner.id, name, "NONE", attributes) + .fetch_one(&db.pool) + .await?; + + Ok(Profile { + id: r.id, + uuid: uuidv4, + created, + owner: owner.id, + name: name.to_owned(), + name_history: name, + skin_variant: String::from("NONE"), + capes: None, + active_cape: None, + attributes: attr, + }) + } + + pub async fn del(db: &Database, id: i64) -> Result { + let r = sqlx::query!("DELETE FROM profiles WHERE id = $1 RETURNING (uuid)", id) + .fetch_one(&db.pool) + .await?; + + Ok(r.uuid) + } + + pub fn to_simple(self) -> ProfileSimple { + ProfileSimple { + id: self.id, + owner: self.owner, + uuid: self.uuid, + name: self.name, + active_cape: self.active_cape, + attributes: self.attributes, } } @@ -93,6 +144,19 @@ impl Profile { } } +#[derive(Deserialize, Serialize, Debug)] +pub struct ProfileSimple { + pub id: i64, + pub owner: i64, + + pub uuid: String, + pub name: String, + + pub active_cape: Option, + pub attributes: ProfileAttributesSimple +} + + #[derive(Deserialize, Serialize, Debug)] pub struct ProfileRaw { pub id: i64, @@ -137,8 +201,8 @@ impl ProfileRaw { None => None, Some(active_cape) => Cape::from_id(db, active_cape).await, }, - attributes: serde_json::from_str(self.attributes.as_str()) - .expect("Couldn't parse profile attributes"), + attributes: serde_json::from_str::(self.attributes.as_str()) + .expect("Couldn't parse profile attributes").to_simple(), } } } \ No newline at end of file diff --git a/src/util/structs/profile_attributes.rs b/src/util/structs/profile_attributes.rs index 755ef9a..5f2615a 100644 --- a/src/util/structs/profile_attributes.rs +++ b/src/util/structs/profile_attributes.rs @@ -9,19 +9,52 @@ * You should have received a copy of the GNU General Public License along with this program. If not, see . */ -use serde_json::{json, Value}; use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + +#[derive(Deserialize, Debug)] +pub struct AttributeEnabled { + pub enabled: bool +} + +#[derive(Deserialize, Debug)] +pub struct ProfanityFilter { + #[serde(rename = "profanityFilterOn")] + pub profanity_filter: bool +} + +#[derive(Deserialize, Debug)] +pub struct ProfileAttributesPrivileges { + #[serde(rename = "onlineChat")] + pub online_chat: AttributeEnabled, + + #[serde(rename = "multiplayerServer")] + pub multiplayer_server: AttributeEnabled, + + #[serde(rename = "multiplayerRealms")] + pub multiplayer_realms: AttributeEnabled, + + pub telemetry: AttributeEnabled, +} + +#[derive(Deserialize, Debug)] +pub struct ProfileAttributes { + pub privileges: ProfileAttributesPrivileges, + + #[serde(rename = "profanityFilterPreferences")] + pub profanity_filter: ProfanityFilter, +} #[derive(Deserialize, Serialize, Debug, Clone)] -pub struct ProfileAttributes { +pub struct ProfileAttributesSimple { pub can_chat: bool, pub can_play_multiplayer: bool, pub can_play_realms: bool, pub use_filter: bool, } -impl ProfileAttributes { +impl ProfileAttributesSimple { pub fn to_json(&self) -> Value { json!({ "privileges": { @@ -35,4 +68,27 @@ impl ProfileAttributes { } }) } + + pub fn to_full(&self) -> ProfileAttributes { + ProfileAttributes { + privileges: ProfileAttributesPrivileges { + online_chat: AttributeEnabled { enabled: self.can_chat }, + multiplayer_server: AttributeEnabled { enabled: self.can_play_multiplayer }, + multiplayer_realms: AttributeEnabled { enabled: self.can_play_realms }, + telemetry: AttributeEnabled { enabled: false }, + }, + profanity_filter: ProfanityFilter { profanity_filter: self.use_filter }, + } + } } + +impl ProfileAttributes { + pub fn to_simple(&self) -> ProfileAttributesSimple { + ProfileAttributesSimple { + can_chat: self.privileges.online_chat.enabled, + can_play_multiplayer: self.privileges.multiplayer_server.enabled, + can_play_realms: self.privileges.multiplayer_realms.enabled, + use_filter: self.profanity_filter.profanity_filter, + } + } +} \ No newline at end of file diff --git a/src/util/structs/session.rs b/src/util/structs/session.rs index 94e5030..441aff8 100644 --- a/src/util/structs/session.rs +++ b/src/util/structs/session.rs @@ -10,6 +10,7 @@ */ use anyhow::Result; +use log::debug; use serde::{Deserialize, Serialize}; use structs::profile::Profile; @@ -26,13 +27,24 @@ pub struct Session { impl Session { pub async fn from_id(db: &Database, id: i64) -> Option { - let record = sqlx::query_as!(RawSession, "SELECT * FROM sessions WHERE id = $1", id) + let record = sqlx::query_as!(SessionRaw, "SELECT * FROM sessions WHERE id = $1", id) .fetch_one(&db.pool) .await; match record { Ok(r) => Some(r.complete(db).await), - Err(_) => None, + Err(e) => { debug!("{e}"); None }, + } + } + + pub async fn from_profile(db: &Database, profile: &Profile) -> Option { + let record = sqlx::query_as!(SessionRaw, "SELECT * FROM sessions WHERE profile = $1", profile.id) + .fetch_one(&db.pool) + .await; + + match record { + Ok(r) => Some(r.complete(db).await), + Err(e) => { debug!("{e}"); None }, } } @@ -43,17 +55,25 @@ impl Session { Ok(()) } + + pub async fn delete(&self, db: &Database) -> Result<()> { + sqlx::query!("DELETE FROM sessions WHERE id = $1", self.id) + .execute(&db.pool) + .await?; + + Ok(()) + } } #[derive(Deserialize, Serialize, Debug)] -pub struct RawSession { +pub struct SessionRaw { pub id: i64, pub profile: i64, pub server_id: String, pub ip_addr: String } -impl RawSession { +impl SessionRaw { pub async fn complete(self, db: &Database) -> Session { Session { id: self.id, diff --git a/src/util/structs/textured_object.rs b/src/util/structs/textured_object.rs index 706633b..dd42f5b 100644 --- a/src/util/structs/textured_object.rs +++ b/src/util/structs/textured_object.rs @@ -9,10 +9,10 @@ * You should have received a copy of the GNU General Public License along with this program. If not, see . */ -use std::time::UNIX_EPOCH; - use serde::{Deserialize, Serialize}; -use json::{object, JsonValue}; +use serde_json::Value; +use tide::prelude::json; + use structs::profile::Profile; use crate::*; @@ -21,19 +21,19 @@ use crate::*; pub struct TexturedObject {} impl TexturedObject { - pub async fn from_profile(db: &Database, profile: &Profile) -> JsonValue { - let mut object = object! { - timestamp: std::time::SystemTime::now().duration_since(UNIX_EPOCH).expect("Time went backwards?!").as_millis() as u64, - profile_id: profile.uuid.to_owned(), - profile_name: profile.name.to_owned(), - textures: object!{} - }; + pub async fn from_profile(db: &Database, profile: &Profile) -> Value { + let mut object = json!({ + "timestamp": get_unix_timestamp() as u64, + "profile_id": profile.uuid.to_owned(), + "profile_name": profile.name.to_owned(), + "textures": {} + }); if profile.skin_variant != "NONE" { let skin_url = profile.get_skin(db).await; if skin_url.is_some() { - object["textures"]["SKIN"] = object! { url: skin_url }; + object["textures"]["SKIN"] = json!({ "url": skin_url }); } } @@ -41,36 +41,36 @@ impl TexturedObject { let cape_url = profile.get_cape(db).await; if cape_url.is_some() { - object["textures"]["CAPE"] = object! { url: cape_url }; + object["textures"]["CAPE"] = json!({ "url": cape_url }); } } - object! { - id: profile.uuid.replace("-", ""), - name: profile.name.to_owned(), - properties: [ + json!({ + "id": profile.uuid.replace("-", ""), + "name": profile.name.to_owned(), + "properties": [ // TODO: signing textures // unsigned ? encode : sign Self::encode_textures(&object) // Self::sign_textures(object) ] - } + }) } - pub fn encode_textures(textures: &JsonValue) -> JsonValue { + pub fn encode_textures(textures: &Value) -> Value { use base64::{Engine, engine::general_purpose::URL_SAFE as base64}; let serialized = textures.to_string(); let mut encoded = String::new(); base64.encode_string(serialized, &mut encoded); - object! { - name: "textures", - value: encoded - } + json!({ + "name": "textures", + "value": encoded + }) } - pub fn sign_textures(textures: &JsonValue) -> JsonValue { + pub fn sign_textures(textures: &Value) -> Value { // TODO: signing textures unimplemented!() } diff --git a/src/util/structs/token.rs b/src/util/structs/token.rs index 06dd0c5..4098749 100644 --- a/src/util/structs/token.rs +++ b/src/util/structs/token.rs @@ -10,6 +10,7 @@ */ use anyhow::Result; +use log::debug; use serde::{Deserialize, Serialize}; use sqlx::Error; @@ -29,35 +30,35 @@ pub struct Token { impl Token { pub async fn from_id(db: &Database, id: i64) -> Option { - let record = sqlx::query_as!(RawToken, "SELECT * FROM tokens WHERE id = $1", id) + let record = sqlx::query_as!(TokenRaw, "SELECT * FROM tokens WHERE id = $1", id) .fetch_one(&db.pool) .await; match record { Ok(r) => Some(r.complete(db).await), - Err(_) => None, + Err(e) => { debug!("{e}"); None }, } } pub async fn from_access_token(db: &Database, access: String) -> Option { - let record = sqlx::query_as!(RawToken, "SELECT * FROM tokens WHERE access = $1", access) + let record = sqlx::query_as!(TokenRaw, "SELECT * FROM tokens WHERE access = $1", access) .fetch_one(&db.pool) .await; match record { Ok(r) => Some(r.complete(db).await), - Err(_) => None + Err(e) => { debug!("{e}"); None }, } } pub async fn from_client_token(db: &Database, client: String) -> Option { - let record = sqlx::query_as!(RawToken, "SELECT * FROM tokens WHERE client = $1", client) + let record = sqlx::query_as!(TokenRaw, "SELECT * FROM tokens WHERE client = $1", client) .fetch_one(&db.pool) .await; match record { Ok(r) => Some(r.complete(db).await), - Err(_) => None + Err(e) => { debug!("{e}"); None }, } } @@ -65,6 +66,55 @@ impl Token { random_string::generate(128, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_.") } + pub fn rehyphenate(uuid: String) -> String { + format!("{}-{}-{}-{}-{}", + uuid[0..8].to_string(), + uuid[8..12].to_string(), + uuid[12..16].to_string(), + uuid[16..20].to_string(), + uuid[20..32].to_string() + ) + } + + pub async fn new(db: &Database, account: Account, client_token: String) -> Option { + let access_token = Self::random_token(); + let issued = (get_unix_timestamp() / 1000) as i64; + let expires = issued + 604800; + + let record = sqlx::query!("INSERT INTO tokens(access, client, account, issued, expires) VALUES ($1, $2, $3, $4, $5) RETURNING *", + access_token, client_token, account.id, issued, expires) + .fetch_one(&db.pool) + .await; + + match record { + Ok(r) => Some(Token { + id: r.id, + access: access_token, + client: client_token, + account, + issued, + expires, + }), + Err(e) => { debug!("{e}"); None }, + } + } + + pub async fn delete(&self, db: &Database) -> Result<()> { + sqlx::query!("DELETE FROM tokens WHERE id = $1", self.id) + .execute(&db.pool) + .await?; + + Ok(()) + } + + pub async fn delete_all_from(db: &Database, account: Account) -> Result<()> { + sqlx::query!("DELETE FROM tokens WHERE account = $1", account.id) + .execute(&db.pool) + .await?; + + Ok(()) + } + async fn remove_expired(db: &Database) -> Result<()> { let time = (get_unix_timestamp() / 1000) as f64; sqlx::query!("DELETE FROM tokens WHERE expires <= $1", time) @@ -94,16 +144,16 @@ impl Token { } } -pub struct RawToken { - id: i64, - access: String, - client: String, - account: i64, - issued: i64, - expires: i64 +pub struct TokenRaw { + pub id: i64, + pub access: String, + pub client: String, + pub account: i64, + pub issued: i64, + pub expires: i64 } -impl RawToken { +impl TokenRaw { pub async fn complete(self, db: &Database) -> Token { Token { id: self.id, diff --git a/src/server/authserver/validate.rs b/src/util/validate.rs similarity index 65% rename from src/server/authserver/validate.rs rename to src/util/validate.rs index e4bca3e..8a9b6b8 100644 --- a/src/server/authserver/validate.rs +++ b/src/util/validate.rs @@ -9,11 +9,21 @@ * You should have received a copy of the GNU General Public License along with this program. If not, see . */ -use anyhow::anyhow; -use tide::{prelude::*, Request, Result}; +use regex::Regex; -use yggdrasil::Database; +pub struct Validate {} + +impl Validate { + pub fn email(e: &str) -> bool { + Regex::new(r"[a-z0-9_\-.]*@[a-z0-9.]*").unwrap().is_match(e) + } + + pub fn lang(l: &str) -> bool { + Regex::new(r"[a-z]{2}-[a-z]{2}").unwrap().is_match(l) + } + + pub fn country(c: &str) -> bool { + Regex::new(r"[A-Z]{2}").unwrap().is_match(c) + } +} -pub async fn validate(req: Request) -> Result { - Err(tide::Error::new(501, anyhow!("Not implemented yet")).into()) -} \ No newline at end of file diff --git a/yggdrasil b/yggdrasil new file mode 100755 index 0000000..7d785d1 --- /dev/null +++ b/yggdrasil @@ -0,0 +1,14 @@ +#! /usr/bin/bash + +# +# Yggdrasil: Minecraft authentication server +# Copyright (C) 2023 0xf8.dev@proton.me +# +# This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with this program. If not, see . +# + +DATABASE_URL="sqlite:yggdrasil.db" cargo run --bin yggdrasil -- "$@" \ No newline at end of file