From d043f57ad0695be11a0665fd3dff735d80b4b2d1 Mon Sep 17 00:00:00 2001 From: ppc Date: Mon, 5 Jan 2026 21:10:05 +0000 Subject: [PATCH] finish implementing features, add logging, windows-specific features --- amnet-server/Cargo.lock => Cargo.lock | 22 ++++- amnet-server/Cargo.toml | 7 +- amnet-server/src/config.rs | 10 +- amnet-server/src/lib.rs | 133 ++++++++++++++++++++++---- amnet-server/src/metrics.rs | 44 ++++++++- amnet-server/src/utils.rs | 23 +++++ amnet-server/src/webapi.rs | 1 - amnet-server/src/webserver.rs | 54 ++++------- 8 files changed, 225 insertions(+), 69 deletions(-) rename amnet-server/Cargo.lock => Cargo.lock (97%) diff --git a/amnet-server/Cargo.lock b/Cargo.lock similarity index 97% rename from amnet-server/Cargo.lock rename to Cargo.lock index 0a00bb3..7f1df3f 100644 --- a/amnet-server/Cargo.lock +++ b/Cargo.lock @@ -8,11 +8,13 @@ version = "2.0.0" dependencies = [ "axum", "hostname", - "log", "rust-ini", "serde", "tokio", + "tower", "tower-http", + "tracing", + "windows-sys 0.61.2", ] [[package]] @@ -551,9 +553,11 @@ dependencies = [ "bitflags", "bytes", "http", + "http-body", "pin-project-lite", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -576,9 +580,21 @@ checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "log", "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tracing-core" version = "0.1.36" @@ -691,6 +707,6 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "zmij" -version = "1.0.10" +version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30e0d8dffbae3d840f64bda38e28391faef673a7b5a6017840f2a106c8145868" +checksum = "dcb2c125bd7365735bebeb420ccb880265ed2d2bddcbcd49f597fdfe6bd5e577" diff --git a/amnet-server/Cargo.toml b/amnet-server/Cargo.toml index 5269dd7..f845d1d 100644 --- a/amnet-server/Cargo.toml +++ b/amnet-server/Cargo.toml @@ -1,6 +1,5 @@ [package] name = "amnet-server" -version = "2.0.0" edition = "2024" version.workspace = true @@ -13,8 +12,10 @@ crate-type = ["cdylib"] [dependencies] axum = "0.8.8" hostname = "0.4" -log = "0.4" +tracing = "0.1" rust-ini = "0.21" serde = { version = "1.0", features = ["derive"] } tokio = { version = "1", features = ["rt-multi-thread", "macros", "net"] } -tower-http = { version = "0.6.8", features = ["cors"] } \ No newline at end of file +tower = "0.5.2" +tower-http = { version = "0.6.8", features = ["cors", "trace"] } +windows-sys = { version = "0.61.2", features = ["Win32_System_Console", "Win32_UI_Input_KeyboardAndMouse"] } diff --git a/amnet-server/src/config.rs b/amnet-server/src/config.rs index 8fd089d..7f46642 100644 --- a/amnet-server/src/config.rs +++ b/amnet-server/src/config.rs @@ -1,14 +1,15 @@ -use ini::Ini; -use log::error; use std::env; use std::path::PathBuf; use std::sync::OnceLock; +use ini::Ini; +use tracing::{error, warn}; + mod defaults { pub const CONFIG_FILE: &str = "./segatools.ini"; pub const CONFIG_FILE_ENV: &str = "SEGATOOLS_CONFIG_PATH"; - pub const SERVER_NAME: &str = "AMNET-PC"; + pub const SERVER_NAME: &str = "AMNET-SERVER"; pub const SERVER_ADDRESS: &str = "0.0.0.0:6070"; pub const AIME_FILE: &str = r"DEVICE\aime.txt"; @@ -21,7 +22,6 @@ mod limits { pub const SERVER_NAME_MAX_LEN: usize = 16; } - static CONFIG: OnceLock = OnceLock::new(); pub (crate) struct Config { @@ -50,7 +50,7 @@ impl Config { pub fn instance() -> &'static Config { CONFIG.get_or_init(|| { Config::load().unwrap_or_else(|e| { - log::warn!("Failed to load config: {}. Using defaults.", e); + warn!("Failed to load config: {}. Using defaults.", e); Config::default() }) }) diff --git a/amnet-server/src/lib.rs b/amnet-server/src/lib.rs index f259eb1..f971761 100644 --- a/amnet-server/src/lib.rs +++ b/amnet-server/src/lib.rs @@ -1,5 +1,9 @@ +use std::sync::OnceLock; + use tokio::net::TcpListener; use tokio::runtime::Runtime; +use tracing::{info, warn}; + use crate::card::CardPresenter; use crate::config::Config; use crate::metrics::SystemMetrics; @@ -11,41 +15,93 @@ mod metrics; mod utils; mod webserver; +static WEBSERVER_INIT: OnceLock<()> = OnceLock::<()>::new(); + #[unsafe(no_mangle)] pub extern "C" fn aime_io_get_api_version() -> u16 { 0x0100 } +#[unsafe(no_mangle)] pub extern "C" fn aime_io_init() -> i32 { - std::thread::spawn(|| { - let runtime = Runtime::new().expect("Failed to create tokio runtime"); - - runtime.block_on(async { - let app = webserver::build_server(); - let listen_addr = Config::instance().server_listen_address.as_str(); - let listener = TcpListener::bind(listen_addr) - .await - .unwrap_or_else(|e| panic!("Failed to bind to {}: {}", listen_addr, e)); - - axum::serve(listener, app) - .await - .expect("Failed to start server"); + WEBSERVER_INIT.get_or_init(|| { + std::thread::spawn(|| { + let rt = Runtime::new().expect("Failed to create tokio runtime"); + rt.block_on(start_server_async()); }); }); return 0; } +#[unsafe(no_mangle)] pub extern "C" fn aime_io_nfc_poll(unit_no: u8) -> i32 { - let _ = unit_no; // not used + if unit_no != 0 { + return 1; + } - if let Ok(mut last_poll) = SystemMetrics::instance().last_poll_time.lock() { - *last_poll = Some(std::time::Instant::now()); + if let Err(_) = SystemMetrics::instance().set_last_poll() { + warn!("Failed to update last poll time"); + return 1; + } + + let enter_pressed: bool; + #[cfg(target_os = "windows")] + { + use windows_sys::Win32::UI::Input::KeyboardAndMouse::GetAsyncKeyState; + enter_pressed = unsafe { (GetAsyncKeyState(0x0D) & (0x8000u16 as i16)) != 0 }; + } + #[cfg(not(target_os = "windows"))] + { + enter_pressed = false; // Default to false on non-Windows platforms + } + + if !enter_pressed { + return 1; + } + + let aime_file_path = &Config::instance().aime_txt_path; + let aime_code = match std::fs::read_to_string(aime_file_path) { + Ok(content) => content.trim().to_string(), + Err(e) => { + warn!("Failed to read text file at {}: {}", aime_file_path.display(), e); + return 1; + } + }; + + if aime_code.is_empty() || !utils::is_valid_access_code(&aime_code) { + warn!("AIME code in {} is invalid", aime_file_path.display()); + return 1; + } + + info!("AIME code read from {}: {}", aime_file_path.display(), aime_code); + let presenter_result = CardPresenter::instance().present_card( + &aime_code, + None, + std::time::Duration::from_secs(5), + ); + + match presenter_result { + crate::card::CardPresentResult::Accepted => (), + crate::card::CardPresentResult::Rejected(reason) => { + warn!("AIME code from {} was rejected: {}", aime_file_path.display(), reason); + return 1; + } + crate::card::CardPresentResult::SlotUnavailable(release_time) => { + warn!( + "AIME code from {} could not be presented: slot unavailable until {:?}", + aime_file_path.display(), + release_time, + ); + + return 1; + } } return 0; } +#[unsafe(no_mangle)] pub extern "C" fn aime_io_nfc_get_aime_id(unit_no: u8, luid: *mut u8, luid_size: usize) -> i32 { if unit_no != 0 { return 1; @@ -58,18 +114,20 @@ pub extern "C" fn aime_io_nfc_get_aime_id(unit_no: u8, luid: *mut u8, luid_size: }; let Some(card) = CardPresenter::instance().request_card(require_idm) else { + // todo debug logging return 1; }; - let copy_size = std::cmp::min(card.access_code.len(), luid_size); - unsafe { + let copy_size = std::cmp::min(card.access_code.len(), luid_size); std::ptr::copy_nonoverlapping(card.access_code.as_ptr(), luid, copy_size); } + info!("Provided AIME access code: {}", utils::format_hex_bytes(&card.access_code)); return 0; } +#[unsafe(no_mangle)] pub extern "C" fn aime_io_nfc_get_felica_id(unit_no: u8, idm: *mut u64) -> i32 { if unit_no != 0 { return 1; @@ -92,10 +150,49 @@ pub extern "C" fn aime_io_nfc_get_felica_id(unit_no: u8, idm: *mut u64) -> i32 { *idm = idm_value; } + info!("Provided AIME IDm: {}", utils::format_hex_bytes(&card_idm)); return 0; } +#[unsafe(no_mangle)] pub extern "C" fn aime_io_led_set_color(_unit_no: u8, _r: u8, _g: u8, _b: u8) { // do nothing return; } + +#[unsafe(no_mangle)] +#[allow(non_snake_case)] +#[cfg(target_os = "windows")] +pub extern "system" fn DllMain(_hinst_dll: *mut std::ffi::c_void, fdw_reason: u32, _lpv_reserved: *mut std::ffi::c_void) -> i32 { + if fdw_reason != 1 /* DLL_PROCESS_ATTACH */ { + return 1; + } + + unsafe { + windows_sys::Win32::System::Console::AllocConsole(); + } + + return 1; +} + +async fn start_server_async() { + let app = webserver::build_server(); + let config = Config::instance(); + + let listen_addr = config.server_listen_address.as_str(); + let listener = TcpListener::bind(listen_addr) + .await + .unwrap_or_else(|e| panic!("Failed to bind to {}: {}", listen_addr, e)); + + SystemMetrics::instance().set_start_time(); + + let game_id = config.game_id.clone().unwrap_or_else(|| "SXXX".to_string()); + + info!("AMNet Server - Version {} ({}) loaded", env!("CARGO_PKG_VERSION"), game_id); + info!("Listening on {}", listen_addr); + info!("Visit http://card.ppc.moe from a mobile device or use the AMNet App to get started."); + + axum::serve(listener, app) + .await + .expect("Failed to start server"); +} \ No newline at end of file diff --git a/amnet-server/src/metrics.rs b/amnet-server/src/metrics.rs index fca9feb..ba6dbea 100644 --- a/amnet-server/src/metrics.rs +++ b/amnet-server/src/metrics.rs @@ -1,16 +1,17 @@ -use std::sync::{OnceLock, Mutex}; +use std::{sync::{Mutex, OnceLock}, time::{Duration, Instant}}; static METRICS: OnceLock = OnceLock::new(); +#[derive(Debug)] pub (crate) struct SystemMetrics { - pub start_time: Mutex>, - pub last_poll_time: Mutex>, + start_time: OnceLock, + last_poll_time: Mutex>, } impl Default for SystemMetrics { fn default() -> Self { Self { - start_time: Mutex::new(None), + start_time: OnceLock::new(), last_poll_time: Mutex::new(None), } } @@ -20,4 +21,39 @@ impl SystemMetrics { pub fn instance() -> &'static SystemMetrics { METRICS.get_or_init(|| SystemMetrics::default()) } + + /// get the start_time of the server. + pub fn get_server_uptime(&self) -> Option { + let Some(time) = self.start_time.get() else { + return None; + }; + + Some(time.elapsed()) + } + + /// sets the start time of the server. + /// this does nothing if the time was already set. + pub fn set_start_time(&self) { + self.start_time.get_or_init(|| Instant::now()); + } + + /// get the last time the card was polled by the game. + /// if the data is not available, an empty err will be returned. + pub fn get_last_poll(&self) -> Option { + let Ok(time_guard) = self.last_poll_time.lock() else { + return None; + }; + + Some(time_guard.as_ref()?.elapsed()) + } + + /// Set the last poll time to now, returning the previously stored value + pub fn set_last_poll(&self) -> Result<(), ()> { + let Ok(mut time_guard) = self.last_poll_time.lock() else { + return Err(()); + }; + + time_guard.replace(Instant::now()); + return Ok(()); + } } diff --git a/amnet-server/src/utils.rs b/amnet-server/src/utils.rs index 570e15e..8bbf556 100644 --- a/amnet-server/src/utils.rs +++ b/amnet-server/src/utils.rs @@ -36,3 +36,26 @@ pub (crate) fn parse_idm_hex(idm_hex: &String) -> Option<[u8; 8]> { Some(idm_bytes) } + +pub (crate) fn is_valid_access_code(code: &String) -> bool { + if code.len() > 20 { + return false; + } + + let cleaned_code: String = code.chars().filter(|c| !c.is_whitespace()).collect(); + + if cleaned_code.len() != 20 { + return false; + } + + let mut seen_non_zero = false; + let is_all_digits = cleaned_code.chars().all(|c| { + if c != '0' { + seen_non_zero = true; + } + + c.is_ascii_digit() + }); + + is_all_digits && seen_non_zero +} diff --git a/amnet-server/src/webapi.rs b/amnet-server/src/webapi.rs index e9f8d40..0868c9a 100644 --- a/amnet-server/src/webapi.rs +++ b/amnet-server/src/webapi.rs @@ -25,7 +25,6 @@ pub struct ServerState { pub time_since_last_poll: Option, } - #[derive(Deserialize)] pub struct CardSubmissionRequest { #[serde(rename = "cardId")] diff --git a/amnet-server/src/webserver.rs b/amnet-server/src/webserver.rs index 02f4e1a..83738de 100644 --- a/amnet-server/src/webserver.rs +++ b/amnet-server/src/webserver.rs @@ -1,41 +1,35 @@ -use crate::card::{CardPresentResult, CardPresenter}; -use crate::config::Config; -use crate::metrics::SystemMetrics; -use crate::webapi::API_VERSION; +use std::time::{Duration, Instant}; + use axum::http::{StatusCode, header}; use axum::response::{IntoResponse, Response}; use axum::routing::{get, post}; use axum::{Json, Router}; -use std::time::Instant; -use tower_http::cors::{Any, CorsLayer}; +use tower::ServiceBuilder; +use tower_http::{cors::{Any, CorsLayer}, trace::TraceLayer}; + +use crate::card::{CardPresentResult, CardPresenter}; +use crate::config::Config; +use crate::metrics::SystemMetrics; +use crate::utils; +use crate::webapi::API_VERSION; pub fn build_server() -> Router { - let cors = CorsLayer::new() - .allow_origin(Any) - .allow_methods(Any); + let services = ServiceBuilder::new() + .layer(TraceLayer::new_for_http()) + .layer(CorsLayer::new().allow_origin(Any).allow_methods(Any)); Router::<()>::new() .route("/amnet/info", get(server_state_handler)) .route("/amnet/signin", post(card_submission_handler)) - .layer(cors) + .layer(services) } async fn server_state_handler() -> Json { let config = Config::instance(); let metrics = SystemMetrics::instance(); - let session_uptime = metrics - .start_time - .lock() - .ok() - .and_then(|start| start.as_ref().map(|s| s.elapsed().as_secs())) - .unwrap_or(0); - - let time_since_last_poll = metrics - .last_poll_time - .lock() - .ok() - .and_then(|last_poll| last_poll.as_ref().map(|lp| lp.elapsed().as_secs())); + let session_uptime = metrics.get_server_uptime().map(|d| d.as_secs()).unwrap_or(0); + let time_since_last_poll = metrics.get_last_poll().map(|d| d.as_secs()); let state = crate::webapi::ServerState { api_version: API_VERSION, @@ -55,18 +49,8 @@ async fn card_submission_handler( return build_error_response(StatusCode::BAD_REQUEST, "Access code is required"); } - if payload.access_code.chars().any(|c| !c.is_ascii_digit()) { - return build_error_response( - StatusCode::UNPROCESSABLE_ENTITY, - "Invalid access code provided", - ); - } - - if payload.access_code.chars().all(|c| c == '0') { - return build_error_response( - StatusCode::FORBIDDEN, - "All-zero access codes are forbidden.", - ); + if !utils::is_valid_access_code(&payload.access_code) { + return build_error_response(StatusCode::UNPROCESSABLE_ENTITY, "Invalid access code provided"); } if payload.card_idm_hex.as_ref().is_some_and(|idm| idm.chars().any(|c| !c.is_ascii_hexdigit())) { @@ -76,7 +60,7 @@ async fn card_submission_handler( let presenter = CardPresenter::instance().present_card( &payload.access_code, payload.card_idm_hex.as_ref(), - std::time::Duration::from_secs(5), + Duration::from_secs(5), ); match presenter {