mirror of
https://gitea.tendokyu.moe/ppc/amnet.git
synced 2026-09-30 18:57:54 +03:00
initial rust commit
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
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 access_code: [u8; 10],
|
||||
pub card_idm: Option<[u8; 8]>,
|
||||
|
||||
pub expiry_time: Instant
|
||||
}
|
||||
|
||||
impl StoredCard {
|
||||
pub fn is_expired(&self) -> bool {
|
||||
Instant::now() > self.expiry_time
|
||||
}
|
||||
}
|
||||
|
||||
pub enum CardPresentResult {
|
||||
Accepted,
|
||||
Rejected(String /* reason */),
|
||||
SlotUnavailable(Instant /* release time */),
|
||||
}
|
||||
|
||||
static PRESENTER: OnceLock<CardPresenter> = OnceLock::new();
|
||||
|
||||
pub (in crate) struct CardPresenter {
|
||||
current_card: Mutex<Option<StoredCard>>,
|
||||
}
|
||||
|
||||
impl Default for CardPresenter {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
current_card: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CardPresenter {
|
||||
pub fn instance() -> &'static CardPresenter {
|
||||
PRESENTER.get_or_init(|| CardPresenter::default())
|
||||
}
|
||||
|
||||
pub fn request_card(&mut self, require_idm: Option<bool>) -> Option<StoredCard> {
|
||||
let mut guard = self.current_card.lock().unwrap();
|
||||
let card = guard.as_ref()?;
|
||||
|
||||
if let Some(require) = require_idm {
|
||||
if card.card_idm.is_some() != require {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
guard.take()
|
||||
}
|
||||
|
||||
pub fn present_card(&self, access_code: &String, idm_hex: Option<&String>, validity: Duration) -> CardPresentResult {
|
||||
let mut guard = self.current_card.lock().unwrap();
|
||||
|
||||
if let Some(card) = guard.as_ref() && !card.is_expired() {
|
||||
return CardPresentResult::SlotUnavailable(card.expiry_time);
|
||||
}
|
||||
|
||||
let access_code_bytes = match parse_access_code(access_code) {
|
||||
Some(bytes) => bytes,
|
||||
None => return CardPresentResult::Rejected("Invalid access code format".to_string()),
|
||||
};
|
||||
|
||||
let mut card_idm_bytes = None;
|
||||
if let Some(idm_hex) = idm_hex {
|
||||
match parse_idm_hex(idm_hex) {
|
||||
Some(bytes) => card_idm_bytes = Some(bytes),
|
||||
None => return CardPresentResult::Rejected("Invalid IDm format".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
let stored_card = StoredCard {
|
||||
access_code: access_code_bytes,
|
||||
card_idm: card_idm_bytes,
|
||||
expiry_time: Instant::now() + validity,
|
||||
};
|
||||
|
||||
*guard = Some(stored_card);
|
||||
|
||||
CardPresentResult::Accepted
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
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<String>,
|
||||
|
||||
#[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<ServerState> {
|
||||
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<String>,
|
||||
}
|
||||
|
||||
pub fn card_submission_handler(Json(payload): Json<CardSubmissionRequest>) -> 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()
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
use ini::Ini;
|
||||
use log::error;
|
||||
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";
|
||||
|
||||
const DEFAULT_SERVER_NAME: &str = "AMNET-PC";
|
||||
|
||||
const DEFAULT_AIME_FILE: &str = r"DEVICE\aime.txt";
|
||||
const DEFAULT_AIME_KEYBOARD_ENABLED: bool = true;
|
||||
|
||||
const DEFAULT_INCLUDE_IDM_FOR_PHYSICAL_CARDS: bool = false;
|
||||
|
||||
const GAME_ID_EXACT_LEN: usize = 4;
|
||||
const SERVER_NAME_MAX_LEN: usize = 16;
|
||||
|
||||
static CONFIG: OnceLock<Config> = OnceLock::new();
|
||||
|
||||
pub (in crate) struct Config {
|
||||
/// Game ID (4 characters or none)
|
||||
pub game_id: Option<String>,
|
||||
|
||||
/// Server name shown on devices (max 16 chars). Defaults to the machine hostname.
|
||||
pub server_name: String,
|
||||
|
||||
/// Whether physically scanned cards should have their IDm forwarded to the game
|
||||
/// instead of the access code.
|
||||
pub physical_card_use_idm: bool,
|
||||
|
||||
/// Whether holding the enter key should trigger a card scan using the code
|
||||
/// from the aime.txt file
|
||||
pub enable_aime_txt: bool,
|
||||
|
||||
/// Path to the aime.txt file
|
||||
pub aime_txt_path: PathBuf,
|
||||
}
|
||||
|
||||
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);
|
||||
Config::default()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn load() -> Result<Self, Box<dyn std::error::Error>> {
|
||||
let config_path = env::var(CONFIG_FILE_OVERRIDE_ENV).unwrap_or_else(|_| DEFAULT_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")?;
|
||||
let aime_section = ini.section(Some("aime"));
|
||||
|
||||
// gameId must be exactly 4 ascii letters or empty
|
||||
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()) {
|
||||
Some(game_id.to_ascii_uppercase().to_string())
|
||||
} else {
|
||||
error!("Invalid gameId '{game_id}': must be exactly {GAME_ID_EXACT_LEN} characters or empty");
|
||||
None
|
||||
};
|
||||
|
||||
let default_hostname = hostname::get()
|
||||
.ok()
|
||||
.and_then(|h| h.into_string().ok())
|
||||
.unwrap_or_else(|| DEFAULT_SERVER_NAME.to_string());
|
||||
|
||||
let server_name = io_section.get("serverName")
|
||||
.unwrap_or(&default_hostname)
|
||||
.chars()
|
||||
.take(SERVER_NAME_MAX_LEN)
|
||||
.collect();
|
||||
|
||||
// Read boolean flags
|
||||
let physical_card_use_idm = io_section.get("useAimeDBForPhysicalCards")
|
||||
.and_then(|v| v.parse::<i32>().ok())
|
||||
.map(|v| v > 0)
|
||||
.unwrap_or(false);
|
||||
|
||||
let enable_aime_txt = io_section.get("enableKeyboardMode")
|
||||
.and_then(|v| v.parse::<i32>().ok())
|
||||
.map(|v| v > 0)
|
||||
.unwrap_or(DEFAULT_AIME_KEYBOARD_ENABLED);
|
||||
|
||||
let aime_txt_path = aime_section
|
||||
.and_then(|s| s.get("aimePath"))
|
||||
.unwrap_or(DEFAULT_AIME_FILE)
|
||||
.into();
|
||||
|
||||
Ok(Config {
|
||||
game_id,
|
||||
server_name,
|
||||
physical_card_use_idm,
|
||||
enable_aime_txt,
|
||||
aime_txt_path,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
let default_hostname = hostname::get()
|
||||
.ok()
|
||||
.and_then(|h| h.into_string().ok())
|
||||
.unwrap_or_else(|| DEFAULT_SERVER_NAME.to_string());
|
||||
|
||||
Config {
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
mod config;
|
||||
mod amcard;
|
||||
mod amserver;
|
||||
mod metrics;
|
||||
mod utils;
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn say_hello() {
|
||||
println!("Hello, world!");
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
use std::sync::OnceLock;
|
||||
|
||||
static METRICS: OnceLock<SystemMetrics> = OnceLock::new();
|
||||
|
||||
pub (in crate) struct SystemMetrics {
|
||||
pub start_time: Option<std::time::Instant>,
|
||||
pub last_poll_time: Option<std::time::Instant>,
|
||||
}
|
||||
|
||||
impl Default for SystemMetrics {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
start_time: None,
|
||||
last_poll_time: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SystemMetrics {
|
||||
pub fn instance() -> &'static SystemMetrics {
|
||||
METRICS.get_or_init(|| SystemMetrics::default())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
pub (in crate) fn format_hex_bytes(bytes: &[u8]) -> String {
|
||||
bytes.chunks(2)
|
||||
.map(|chunk| chunk.iter().map(|b| format!("{:02X}", b)).collect::<String>())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
pub (in 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];
|
||||
|
||||
for i in 0..bytes.len() {
|
||||
let slice_str = &matrix_code[i*2..i*2+2];
|
||||
let slice_val = u8::from_str_radix(slice_str, 16).ok()?;
|
||||
|
||||
bytes[i] = slice_val;
|
||||
}
|
||||
|
||||
Some(bytes)
|
||||
}
|
||||
|
||||
pub (in 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;
|
||||
}
|
||||
|
||||
let padded_idm = format!("{:0>16}", idm_hex);
|
||||
let mut idm_bytes: [u8; 8] = [0; 8];
|
||||
|
||||
for i in 0..idm_bytes.len() {
|
||||
let slice_str = &padded_idm[i*2..i*2+2];
|
||||
let slice_val = u8::from_str_radix(slice_str, 16).ok()?;
|
||||
|
||||
idm_bytes[i] = slice_val;
|
||||
}
|
||||
|
||||
Some(idm_bytes)
|
||||
}
|
||||
Reference in New Issue
Block a user