From 2125753e377316cc90199cdb1abe09c2eb7ec94b Mon Sep 17 00:00:00 2001 From: ppc Date: Mon, 5 Jan 2026 21:10:05 +0000 Subject: [PATCH] complete base rewrite --- amnet-server/Cargo.lock | 144 +++--------------------- amnet-server/Cargo.toml | 4 +- amnet-server/src/amserver.rs | 108 ------------------ amnet-server/src/{amcard.rs => card.rs} | 9 +- amnet-server/src/config.rs | 68 ++++++----- amnet-server/src/lib.rs | 99 +++++++++++++++- amnet-server/src/metrics.rs | 12 +- amnet-server/src/utils.rs | 6 +- amnet-server/src/webapi.rs | 36 ++++++ amnet-server/src/webserver.rs | 112 ++++++++++++++++++ 10 files changed, 315 insertions(+), 283 deletions(-) delete mode 100644 amnet-server/src/amserver.rs rename amnet-server/src/{amcard.rs => card.rs} (91%) create mode 100644 amnet-server/src/webapi.rs create mode 100644 amnet-server/src/webserver.rs diff --git a/amnet-server/Cargo.lock b/amnet-server/Cargo.lock index a6d7605..0a00bb3 100644 --- a/amnet-server/Cargo.lock +++ b/amnet-server/Cargo.lock @@ -31,8 +31,8 @@ dependencies = [ "bytes", "form_urlencoded", "futures-util", - "http 1.4.0", - "http-body 1.0.1", + "http", + "http-body", "http-body-util", "hyper", "hyper-util", @@ -62,8 +62,8 @@ checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" dependencies = [ "bytes", "futures-core", - "http 1.4.0", - "http-body 1.0.1", + "http", + "http-body", "http-body-util", "mime", "pin-project-lite", @@ -73,12 +73,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - [[package]] name = "bitflags" version = "2.10.0" @@ -132,22 +126,6 @@ dependencies = [ "const-random", ] -[[package]] -name = "errno" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - [[package]] name = "form_urlencoded" version = "1.2.2" @@ -218,17 +196,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "http" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" -dependencies = [ - "bytes", - "fnv", - "itoa", -] - [[package]] name = "http" version = "1.4.0" @@ -239,17 +206,6 @@ dependencies = [ "itoa", ] -[[package]] -name = "http-body" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" -dependencies = [ - "bytes", - "http 0.2.12", - "pin-project-lite", -] - [[package]] name = "http-body" version = "1.0.1" @@ -257,7 +213,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http 1.4.0", + "http", ] [[package]] @@ -268,17 +224,11 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http 1.4.0", - "http-body 1.0.1", + "http", + "http-body", "pin-project-lite", ] -[[package]] -name = "http-range-header" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "add0ab9360ddbd88cfeb3bd9574a1d85cfdfa14db10b3e21d3700dbc4328758f" - [[package]] name = "httparse" version = "1.10.1" @@ -301,8 +251,8 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "http 1.4.0", - "http-body 1.0.1", + "http", + "http-body", "httparse", "httpdate", "itoa", @@ -320,8 +270,8 @@ checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" dependencies = [ "bytes", "futures-core", - "http 1.4.0", - "http-body 1.0.1", + "http", + "http-body", "hyper", "pin-project-lite", "tokio", @@ -340,15 +290,6 @@ version = "0.2.179" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c5a2d376baa530d1238d133232d15e239abad80d05838b4b59354e5268af431f" -[[package]] -name = "lock_api" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] - [[package]] name = "log" version = "0.4.29" @@ -400,29 +341,6 @@ dependencies = [ "hashbrown", ] -[[package]] -name = "parking_lot" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-link", -] - [[package]] name = "percent-encoding" version = "2.3.2" @@ -459,15 +377,6 @@ dependencies = [ "proc-macro2", ] -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags 2.10.0", -] - [[package]] name = "rust-ini" version = "0.21.3" @@ -484,12 +393,6 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - [[package]] name = "serde" version = "1.0.228" @@ -556,16 +459,6 @@ dependencies = [ "serde", ] -[[package]] -name = "signal-hook-registry" -version = "1.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" -dependencies = [ - "errno", - "libc", -] - [[package]] name = "smallvec" version = "1.15.1" @@ -614,12 +507,9 @@ version = "1.49.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" dependencies = [ - "bytes", "libc", "mio", - "parking_lot", "pin-project-lite", - "signal-hook-registry", "socket2", "tokio-macros", "windows-sys 0.61.2", @@ -654,17 +544,13 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.3.5" +version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f873044bf02dd1e8239e9c1293ea39dad76dc594ec16185d0a1bf31d8dc8d858" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ - "bitflags 1.3.2", + "bitflags", "bytes", - "futures-core", - "futures-util", - "http 0.2.12", - "http-body 0.4.6", - "http-range-header", + "http", "pin-project-lite", "tower-layer", "tower-service", diff --git a/amnet-server/Cargo.toml b/amnet-server/Cargo.toml index d1111d8..d0ce904 100644 --- a/amnet-server/Cargo.toml +++ b/amnet-server/Cargo.toml @@ -12,5 +12,5 @@ hostname = "0.4" log = "0.4" rust-ini = "0.21" serde = { version = "1.0", features = ["derive"] } -tokio = { version = "1", features = ["full"] } -tower-http = { version = "0.3", features = ["cors"] } \ No newline at end of file +tokio = { version = "1", features = ["rt-multi-thread", "macros", "net"] } +tower-http = { version = "0.6.8", features = ["cors"] } \ No newline at end of file diff --git a/amnet-server/src/amserver.rs b/amnet-server/src/amserver.rs deleted file mode 100644 index 96cf034..0000000 --- a/amnet-server/src/amserver.rs +++ /dev/null @@ -1,108 +0,0 @@ -use std::time::Instant; - -use axum::{Json, http::{StatusCode, header}, response::{IntoResponse, Response}}; -use serde::{Deserialize, Serialize}; - -use crate::{amcard::{CardPresentResult, CardPresenter}, config::Config, metrics::SystemMetrics}; - -pub const API_VERSION: u8 = 1; - -#[derive(Serialize)] -struct ServerError { - error: String, -} - -#[derive(Serialize)] -struct ServerState { - #[serde(rename = "apiVersion")] - api_version: u8, - - #[serde(rename = "gameId")] - game_id: Option, - - #[serde(rename = "serverName")] - server_name: String, - - #[serde(rename = "sessionUptime")] - session_uptime: u64, - - #[serde(rename = "timeSinceLastPoll")] - time_since_last_poll: u64, -} - -pub fn server_state_handler() -> Json { - let config = Config::instance(); - let metrics = SystemMetrics::instance(); - - let state = ServerState { - api_version: API_VERSION, - game_id: config.game_id.clone(), - server_name: config.server_name.clone(), - session_uptime: metrics.start_time.map_or(0, |start| start.elapsed().as_secs()), - time_since_last_poll: metrics.last_poll_time.map_or(0, |last_poll| last_poll.elapsed().as_secs()), - }; - - Json(state) -} - -#[derive(Deserialize)] -struct CardSubmissionRequest { - #[serde(rename = "cardId")] - access_code: String, - - #[serde(rename = "physicalCardIDm")] - card_idm_hex: Option, -} - -pub fn card_submission_handler(Json(payload): Json) -> impl IntoResponse { - if payload.access_code.len() > 20 || payload.access_code.len() == 0 { - 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 payload.card_idm_hex.as_ref().is_some_and(|idm| idm.chars().any(|c| !c.is_ascii_hexdigit())) { - return build_error_response(StatusCode::UNPROCESSABLE_ENTITY, "Invalid IDm provided"); - } - - let presenter = CardPresenter::instance().present_card( - &payload.access_code, - payload.card_idm_hex.as_ref(), - std::time::Duration::from_secs(5) - ); - - match presenter { - CardPresentResult::Accepted => StatusCode::ACCEPTED.into_response(), - CardPresentResult::Rejected(_) => StatusCode::FORBIDDEN.into_response(), - CardPresentResult::SlotUnavailable(release_time) => { - let now = Instant::now(); - let wait_secs = if release_time > now { - (release_time - now).as_secs() - } else { - 0 - }; - - let mut response = build_error_response(StatusCode::TOO_MANY_REQUESTS, "A card has already been submitted. Please wait before submitting another."); - response.headers_mut().insert( - header::RETRY_AFTER, - wait_secs.to_string().parse().unwrap(), - ); - - response - } - } -} - -fn build_error_response(status: StatusCode, message: &str) -> Response { - let error = ServerError { - error: message.to_string(), - }; - - (status, Json(error)).into_response() -} \ No newline at end of file diff --git a/amnet-server/src/amcard.rs b/amnet-server/src/card.rs similarity index 91% rename from amnet-server/src/amcard.rs rename to amnet-server/src/card.rs index 68cfac8..d67f705 100644 --- a/amnet-server/src/amcard.rs +++ b/amnet-server/src/card.rs @@ -3,7 +3,7 @@ use std::{sync::{Mutex, OnceLock}, time::{Duration, Instant}}; use crate::utils::{parse_access_code, parse_idm_hex}; #[derive(Debug, Clone)] -pub (in crate) struct StoredCard { +pub (crate) struct StoredCard { pub access_code: [u8; 10], pub card_idm: Option<[u8; 8]>, @@ -16,7 +16,7 @@ impl StoredCard { } } -pub enum CardPresentResult { +pub (crate) enum CardPresentResult { Accepted, Rejected(String /* reason */), SlotUnavailable(Instant /* release time */), @@ -24,7 +24,8 @@ pub enum CardPresentResult { static PRESENTER: OnceLock = OnceLock::new(); -pub (in crate) struct CardPresenter { +#[derive(Debug)] +pub (crate) struct CardPresenter { current_card: Mutex>, } @@ -41,7 +42,7 @@ impl CardPresenter { PRESENTER.get_or_init(|| CardPresenter::default()) } - pub fn request_card(&mut self, require_idm: Option) -> Option { + pub fn request_card(&self, require_idm: Option) -> Option { let mut guard = self.current_card.lock().unwrap(); let card = guard.as_ref()?; diff --git a/amnet-server/src/config.rs b/amnet-server/src/config.rs index 62e6c34..8fd089d 100644 --- a/amnet-server/src/config.rs +++ b/amnet-server/src/config.rs @@ -4,22 +4,27 @@ use std::env; use std::path::PathBuf; use std::sync::OnceLock; -const DEFAULT_CONFIG_FILE: &str = "./segatools.ini"; -const CONFIG_FILE_OVERRIDE_ENV: &str = "SEGATOOLS_CONFIG_PATH"; +mod defaults { + pub const CONFIG_FILE: &str = "./segatools.ini"; + pub const CONFIG_FILE_ENV: &str = "SEGATOOLS_CONFIG_PATH"; -const DEFAULT_SERVER_NAME: &str = "AMNET-PC"; + pub const SERVER_NAME: &str = "AMNET-PC"; + pub const SERVER_ADDRESS: &str = "0.0.0.0:6070"; -const DEFAULT_AIME_FILE: &str = r"DEVICE\aime.txt"; -const DEFAULT_AIME_KEYBOARD_ENABLED: bool = true; + pub const AIME_FILE: &str = r"DEVICE\aime.txt"; + pub const AIME_KEYBOARD_ENABLED: bool = true; + pub const INCLUDE_IDM_FOR_PHYSICAL_CARDS: bool = false; +} -const DEFAULT_INCLUDE_IDM_FOR_PHYSICAL_CARDS: bool = false; +mod limits { + pub const GAME_ID_LEN: usize = 4; + pub const SERVER_NAME_MAX_LEN: usize = 16; +} -const GAME_ID_EXACT_LEN: usize = 4; -const SERVER_NAME_MAX_LEN: usize = 16; static CONFIG: OnceLock = OnceLock::new(); -pub (in crate) struct Config { +pub (crate) struct Config { /// Game ID (4 characters or none) pub game_id: Option, @@ -36,6 +41,9 @@ pub (in crate) struct Config { /// Path to the aime.txt file pub aime_txt_path: PathBuf, + + /// Server address that should be listened on + pub server_listen_address: String, } impl Config { @@ -49,7 +57,7 @@ impl Config { } fn load() -> Result> { - let config_path = env::var(CONFIG_FILE_OVERRIDE_ENV).unwrap_or_else(|_| DEFAULT_CONFIG_FILE.to_string()); + let config_path = env::var(defaults::CONFIG_FILE_ENV).unwrap_or_else(|_| defaults::CONFIG_FILE.to_string()); let ini = Ini::load_from_file(&config_path).map_err(|e| format!("Failed to load config file '{}': {}", config_path, e))?; let io_section = ini.section(Some("aimeio")).ok_or("Missing [aimeio] section")?; @@ -59,46 +67,51 @@ impl Config { let game_id = io_section.get("gameId").unwrap_or(""); let game_id = if game_id.is_empty() { None - } else if game_id.chars().count() == GAME_ID_EXACT_LEN && game_id.chars().all(|c| c.is_ascii_alphabetic()) { + } else if game_id.chars().count() == limits::GAME_ID_LEN && game_id.chars().all(|c| c.is_ascii_alphabetic()) { Some(game_id.to_ascii_uppercase().to_string()) } else { - error!("Invalid gameId '{game_id}': must be exactly {GAME_ID_EXACT_LEN} characters or empty"); + error!("Invalid gameId '{game_id}': must be exactly {} characters or empty", limits::GAME_ID_LEN); None }; let default_hostname = hostname::get() .ok() .and_then(|h| h.into_string().ok()) - .unwrap_or_else(|| DEFAULT_SERVER_NAME.to_string()); - + .unwrap_or_else(|| defaults::SERVER_NAME.to_string()); + let server_name = io_section.get("serverName") .unwrap_or(&default_hostname) .chars() - .take(SERVER_NAME_MAX_LEN) + .take(limits::SERVER_NAME_MAX_LEN) .collect(); // Read boolean flags let physical_card_use_idm = io_section.get("useAimeDBForPhysicalCards") .and_then(|v| v.parse::().ok()) .map(|v| v > 0) - .unwrap_or(false); - + .unwrap_or(defaults::INCLUDE_IDM_FOR_PHYSICAL_CARDS); + let enable_aime_txt = io_section.get("enableKeyboardMode") .and_then(|v| v.parse::().ok()) .map(|v| v > 0) - .unwrap_or(DEFAULT_AIME_KEYBOARD_ENABLED); - + .unwrap_or(defaults::AIME_KEYBOARD_ENABLED); + let aime_txt_path = aime_section .and_then(|s| s.get("aimePath")) - .unwrap_or(DEFAULT_AIME_FILE) + .unwrap_or(defaults::AIME_FILE) .into(); + let server_listen_address = io_section.get("listenAddress") + .unwrap_or(defaults::SERVER_ADDRESS) + .to_string(); + Ok(Config { game_id, server_name, physical_card_use_idm, enable_aime_txt, aime_txt_path, + server_listen_address, }) } } @@ -108,14 +121,15 @@ impl Default for Config { let default_hostname = hostname::get() .ok() .and_then(|h| h.into_string().ok()) - .unwrap_or_else(|| DEFAULT_SERVER_NAME.to_string()); - - Config { + .unwrap_or_else(|| defaults::SERVER_NAME.to_string()); + + Self { game_id: None, - server_name: default_hostname.chars().take(SERVER_NAME_MAX_LEN).collect(), - physical_card_use_idm: DEFAULT_INCLUDE_IDM_FOR_PHYSICAL_CARDS, - enable_aime_txt: DEFAULT_AIME_KEYBOARD_ENABLED, - aime_txt_path: PathBuf::from(DEFAULT_AIME_FILE), + server_name: default_hostname.chars().take(limits::SERVER_NAME_MAX_LEN).collect(), + physical_card_use_idm: defaults::INCLUDE_IDM_FOR_PHYSICAL_CARDS, + enable_aime_txt: defaults::AIME_KEYBOARD_ENABLED, + aime_txt_path: PathBuf::from(defaults::AIME_FILE), + server_listen_address: defaults::SERVER_ADDRESS.to_string(), } } } diff --git a/amnet-server/src/lib.rs b/amnet-server/src/lib.rs index ff2409e..f259eb1 100644 --- a/amnet-server/src/lib.rs +++ b/amnet-server/src/lib.rs @@ -1,10 +1,101 @@ +use tokio::net::TcpListener; +use tokio::runtime::Runtime; +use crate::card::CardPresenter; +use crate::config::Config; +use crate::metrics::SystemMetrics; + mod config; -mod amcard; -mod amserver; +mod card; +mod webapi; mod metrics; mod utils; +mod webserver; #[unsafe(no_mangle)] -pub extern "C" fn say_hello() { - println!("Hello, world!"); +pub extern "C" fn aime_io_get_api_version() -> u16 { + 0x0100 +} + +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"); + }); + }); + + return 0; +} + +pub extern "C" fn aime_io_nfc_poll(unit_no: u8) -> i32 { + let _ = unit_no; // not used + + if let Ok(mut last_poll) = SystemMetrics::instance().last_poll_time.lock() { + *last_poll = Some(std::time::Instant::now()); + } + + return 0; +} + +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; + } + + let require_idm = if Config::instance().physical_card_use_idm { + Some(false) + } else { + None + }; + + let Some(card) = CardPresenter::instance().request_card(require_idm) else { + return 1; + }; + + let copy_size = std::cmp::min(card.access_code.len(), luid_size); + + unsafe { + std::ptr::copy_nonoverlapping(card.access_code.as_ptr(), luid, copy_size); + } + + return 0; +} + +pub extern "C" fn aime_io_nfc_get_felica_id(unit_no: u8, idm: *mut u64) -> i32 { + if unit_no != 0 { + return 1; + } + + let Some(card) = CardPresenter::instance().request_card(Some(true)) else { + return 1; + }; + + let Some(card_idm) = card.card_idm else { + return 1; + }; + + let mut idm_value: u64 = 0; + for byte in card_idm { + idm_value = (idm_value << 8) | (byte as u64); + } + + unsafe { + *idm = idm_value; + } + + return 0; +} + +pub extern "C" fn aime_io_led_set_color(_unit_no: u8, _r: u8, _g: u8, _b: u8) { + // do nothing + return; } diff --git a/amnet-server/src/metrics.rs b/amnet-server/src/metrics.rs index 0b90eda..fca9feb 100644 --- a/amnet-server/src/metrics.rs +++ b/amnet-server/src/metrics.rs @@ -1,17 +1,17 @@ -use std::sync::OnceLock; +use std::sync::{OnceLock, Mutex}; static METRICS: OnceLock = OnceLock::new(); -pub (in crate) struct SystemMetrics { - pub start_time: Option, - pub last_poll_time: Option, +pub (crate) struct SystemMetrics { + pub start_time: Mutex>, + pub last_poll_time: Mutex>, } impl Default for SystemMetrics { fn default() -> Self { Self { - start_time: None, - last_poll_time: None, + start_time: Mutex::new(None), + last_poll_time: Mutex::new(None), } } } diff --git a/amnet-server/src/utils.rs b/amnet-server/src/utils.rs index e1c5771..570e15e 100644 --- a/amnet-server/src/utils.rs +++ b/amnet-server/src/utils.rs @@ -1,11 +1,11 @@ -pub (in crate) fn format_hex_bytes(bytes: &[u8]) -> String { +pub (crate) fn format_hex_bytes(bytes: &[u8]) -> String { bytes.chunks(2) .map(|chunk| chunk.iter().map(|b| format!("{:02X}", b)).collect::()) .collect::>() .join(" ") } -pub (in crate) fn parse_access_code(code: &String) -> Option<[u8; 10]> { +pub (crate) fn parse_access_code(code: &String) -> Option<[u8; 10]> { let matrix_code = format!("{:0>20}", code.replace(" ", "")); let mut bytes: [u8; 10] = [0; 10]; @@ -19,7 +19,7 @@ pub (in crate) fn parse_access_code(code: &String) -> Option<[u8; 10]> { Some(bytes) } -pub (in crate) fn parse_idm_hex(idm_hex: &String) -> Option<[u8; 8]> { +pub (crate) fn parse_idm_hex(idm_hex: &String) -> Option<[u8; 8]> { if idm_hex.len() > 16 || !idm_hex.chars().all(|c| c.is_ascii_hexdigit()) { return None; } diff --git a/amnet-server/src/webapi.rs b/amnet-server/src/webapi.rs new file mode 100644 index 0000000..e9f8d40 --- /dev/null +++ b/amnet-server/src/webapi.rs @@ -0,0 +1,36 @@ +use serde::{Deserialize, Serialize}; + +pub const API_VERSION: u8 = 1; + +#[derive(Serialize)] +pub struct ServerError { + pub error: String, +} + +#[derive(Serialize)] +pub struct ServerState { + #[serde(rename = "apiVersion")] + pub api_version: u8, + + #[serde(rename = "gameId")] + pub game_id: Option, + + #[serde(rename = "serverName")] + pub server_name: String, + + #[serde(rename = "sessionUptime")] + pub session_uptime: u64, + + #[serde(rename = "timeSinceLastPoll")] + pub time_since_last_poll: Option, +} + + +#[derive(Deserialize)] +pub struct CardSubmissionRequest { + #[serde(rename = "cardId")] + pub access_code: String, + + #[serde(rename = "physicalCardIDm")] + pub card_idm_hex: Option, +} diff --git a/amnet-server/src/webserver.rs b/amnet-server/src/webserver.rs new file mode 100644 index 0000000..02f4e1a --- /dev/null +++ b/amnet-server/src/webserver.rs @@ -0,0 +1,112 @@ +use crate::card::{CardPresentResult, CardPresenter}; +use crate::config::Config; +use crate::metrics::SystemMetrics; +use crate::webapi::API_VERSION; +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}; + +pub fn build_server() -> Router { + let cors = 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) +} + +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 state = crate::webapi::ServerState { + api_version: API_VERSION, + game_id: config.game_id.clone(), + server_name: config.server_name.clone(), + session_uptime, + time_since_last_poll, + }; + + Json(state) +} + +async fn card_submission_handler( + Json(payload): Json, +) -> impl IntoResponse { + if payload.access_code.len() > 20 || payload.access_code.len() == 0 { + 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 payload.card_idm_hex.as_ref().is_some_and(|idm| idm.chars().any(|c| !c.is_ascii_hexdigit())) { + return build_error_response(StatusCode::UNPROCESSABLE_ENTITY, "Invalid IDm provided"); + } + + let presenter = CardPresenter::instance().present_card( + &payload.access_code, + payload.card_idm_hex.as_ref(), + std::time::Duration::from_secs(5), + ); + + match presenter { + CardPresentResult::Accepted => StatusCode::ACCEPTED.into_response(), + CardPresentResult::Rejected(message) => build_error_response(StatusCode::FORBIDDEN, format!("Card rejected: {}", message).as_str()), + CardPresentResult::SlotUnavailable(release_time) => { + let now = Instant::now(); + let wait_secs = if release_time > now { + (release_time - now).as_secs() + } else { + 0 + }; + + let mut response = build_error_response( + StatusCode::TOO_MANY_REQUESTS, + "A card has already been submitted. Please wait before submitting another.", + ); + + let headers = response.headers_mut(); + headers.insert(header::RETRY_AFTER, wait_secs.to_string().parse().unwrap()); + + response + } + } +} + +fn build_error_response(status: StatusCode, message: &str) -> Response { + let error = crate::webapi::ServerError { + error: message.to_string(), + }; + + (status, Json(error)).into_response() +}