From b143cd9861d9a566de2b9597a881598abd6c8896 Mon Sep 17 00:00:00 2001 From: red binder Date: Sun, 7 Jun 2026 13:55:34 +0200 Subject: [PATCH] Initial NASC support --- ...a16810ebae9c7eb175937354954d21136b63.json} | 19 ++- ...88e9760470961f8ac6270781af9043eed0887.json | 65 +++++++++++ src/json_api/oauth/generate_token.rs | 2 +- src/main.rs | 2 + src/nasc/ac.rs | 108 ++++++++++++++++++ src/nasc/mod.rs | 2 + src/nasc/util.rs | 40 +++++++ src/nnid/provider.rs | 48 +++++--- 8 files changed, 268 insertions(+), 18 deletions(-) rename .sqlx/{query-5bd26d4c9e701bde77ce598fea2ce0b98ea2c7de03e71ac704c3ba047162c0b2.json => query-507f9df403f2e336baf929cfca8fa16810ebae9c7eb175937354954d21136b63.json} (55%) create mode 100644 .sqlx/query-9c5ef3e9c672de55eb85b1d912088e9760470961f8ac6270781af9043eed0887.json create mode 100644 src/nasc/ac.rs create mode 100644 src/nasc/mod.rs create mode 100644 src/nasc/util.rs diff --git a/.sqlx/query-5bd26d4c9e701bde77ce598fea2ce0b98ea2c7de03e71ac704c3ba047162c0b2.json b/.sqlx/query-507f9df403f2e336baf929cfca8fa16810ebae9c7eb175937354954d21136b63.json similarity index 55% rename from .sqlx/query-5bd26d4c9e701bde77ce598fea2ce0b98ea2c7de03e71ac704c3ba047162c0b2.json rename to .sqlx/query-507f9df403f2e336baf929cfca8fa16810ebae9c7eb175937354954d21136b63.json index c60c80d..1ab949d 100644 --- a/.sqlx/query-5bd26d4c9e701bde77ce598fea2ce0b98ea2c7de03e71ac704c3ba047162c0b2.json +++ b/.sqlx/query-507f9df403f2e336baf929cfca8fa16810ebae9c7eb175937354954d21136b63.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "select address, port from nex_servers where game_server_id = $1", + "query": "select address, port, maintenance_mode from nex_servers where game_server_id = $1 AND server_account_level <= $2", "describe": { "columns": [ { @@ -24,17 +24,30 @@ "name": "port" } } + }, + { + "ordinal": 2, + "name": "maintenance_mode", + "type_info": "Bool", + "origin": { + "Table": { + "table": "nex_servers", + "name": "maintenance_mode" + } + } } ], "parameters": { "Left": [ - "Text" + "Text", + "Int4" ] }, "nullable": [ + false, false, false ] }, - "hash": "5bd26d4c9e701bde77ce598fea2ce0b98ea2c7de03e71ac704c3ba047162c0b2" + "hash": "507f9df403f2e336baf929cfca8fa16810ebae9c7eb175937354954d21136b63" } diff --git a/.sqlx/query-9c5ef3e9c672de55eb85b1d912088e9760470961f8ac6270781af9043eed0887.json b/.sqlx/query-9c5ef3e9c672de55eb85b1d912088e9760470961f8ac6270781af9043eed0887.json new file mode 100644 index 0000000..221f1b6 --- /dev/null +++ b/.sqlx/query-9c5ef3e9c672de55eb85b1d912088e9760470961f8ac6270781af9043eed0887.json @@ -0,0 +1,65 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT game_server_id, maintenance_mode, address, port FROM nex_servers WHERE title_id = $1 AND server_account_level <= $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "game_server_id", + "type_info": "Varchar", + "origin": { + "Table": { + "table": "nex_servers", + "name": "game_server_id" + } + } + }, + { + "ordinal": 1, + "name": "maintenance_mode", + "type_info": "Bool", + "origin": { + "Table": { + "table": "nex_servers", + "name": "maintenance_mode" + } + } + }, + { + "ordinal": 2, + "name": "address", + "type_info": "Inet", + "origin": { + "Table": { + "table": "nex_servers", + "name": "address" + } + } + }, + { + "ordinal": 3, + "name": "port", + "type_info": "Int4", + "origin": { + "Table": { + "table": "nex_servers", + "name": "port" + } + } + } + ], + "parameters": { + "Left": [ + "Text", + "Int4" + ] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "9c5ef3e9c672de55eb85b1d912088e9760470961f8ac6270781af9043eed0887" +} diff --git a/src/json_api/oauth/generate_token.rs b/src/json_api/oauth/generate_token.rs index ebd8873..9cd07a0 100644 --- a/src/json_api/oauth/generate_token.rs +++ b/src/json_api/oauth/generate_token.rs @@ -3,7 +3,7 @@ use serde::Serialize; use rocket::FromForm; use sha2::{Sha256, Digest}; use bytemuck::bytes_of; -use rocket::{post, State, form::Form, http::Status, serde::json::Json, error}; +use rocket::{post, State, form::Form, http::Status, serde::json::Json}; use sqlx::Row; use std::env; use once_cell::sync::Lazy; diff --git a/src/main.rs b/src/main.rs index c7ba707..ec3b34f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -22,6 +22,7 @@ mod graphql; mod email; mod mii_util; mod json_api; +mod nasc; type Pool = sqlx::Pool; @@ -112,6 +113,7 @@ async fn launch() -> _ { nnid::provider::get_service_token, nnid::admin::mapped_ids, nnid::admin::get_time, + nasc::ac::nasc_ac, json_api::oauth::generate_token::generate_token, json_api::users::profile::get_own_profile, json_api::users::mii::get_mii_data_by_pid, diff --git a/src/nasc/ac.rs b/src/nasc/ac.rs new file mode 100644 index 0000000..d87ccca --- /dev/null +++ b/src/nasc/ac.rs @@ -0,0 +1,108 @@ +use rocket::FromForm; +use rocket::{post, State, form::Form}; +use std::net::Ipv4Addr; +use sqlx::types::ipnetwork::IpNetwork::V4; +use crate::Pool; +use crate::nnid::oauth::generate_token::create_token; +use crate::nnid::oauth::generate_token::token_type::AUTH_TOKEN; +use crate::nasc::util::{ + nintendo_base64_decode, + nintendo_base64_encode, + nasc_date_time, + nasc_error +}; + +#[derive(FromForm)] +pub struct NascRequestParams { + pub action: String, + pub titleid: String, + pub gameid: String, +} + +#[post("/ac", data = "")] +pub async fn nasc_ac( + pool: &State, + params: Form, +) -> String { + + let action = nintendo_base64_decode(¶ms.action); + let title_id = nintendo_base64_decode(¶ms.titleid); + let game_server_id = nintendo_base64_decode(¶ms.gameid); + + let server_row = match sqlx::query!( + "SELECT game_server_id, maintenance_mode, address, port FROM nex_servers WHERE title_id = $1", + title_id, + ) + .fetch_optional(pool.inner()) + .await + .expect("system error") { + Some(row) => row, + None => return nasc_error("110"), + }; + + if game_server_id != server_row.game_server_id { + return nasc_error("152"); + } + + if server_row.maintenance_mode { + return nasc_error("101"); + } + + let V4(ip_net) = server_row.address else { + return nasc_error("110"); + }; + let ip = ip_net.ip(); + let port = server_row.port as u16; + + if action == "LOGIN" && port == 0 && ip != Ipv4Addr::new(0, 0, 0, 0) { + return nasc_error("110"); + } + + match action.as_str() { + //"LOGIN" => process_login_request(pool, nex_account.pid, &title_id, ip, port).await, + //"SVCLOC" => process_service_token_request(pool, nex_account.pid, &title_id).await, + _ => nasc_error("nullnt"), + } +} + +async fn process_login_request( + pool: &State, + pid: i32, + title_id: &str, + ip: Ipv4Addr, + port: u16, +) -> String { + let nex_token_raw = create_token(pool.inner(), pid, AUTH_TOKEN, Some(title_id)).await; + let nex_token = nintendo_base64_encode(nex_token_raw.as_bytes()); + + let locator_str = format!("{}:{}", ip, port); + let locator = nintendo_base64_encode(locator_str.as_bytes()); + + format!( + "locator={}&retry={}&returncd={}&token={}&datetime={}", + locator, + nintendo_base64_encode(b"0"), + nintendo_base64_encode(b"001"), + nex_token, + nintendo_base64_encode(nasc_date_time().as_bytes()) + ) +} + +async fn process_service_token_request( + pool: &State, + pid: i32, + title_id: &str, +) -> String { + let service_token_raw = create_token(pool.inner(), pid, AUTH_TOKEN, Some(title_id)).await; + let service_token = nintendo_base64_encode(service_token_raw.as_bytes()); + + format!( + "retry={}&returncd={}&servicetoken={}&statusdata={}&svchost={}&datetime={}", + nintendo_base64_encode(b"0"), + nintendo_base64_encode(b"007"), + service_token, + nintendo_base64_encode(b"Y"), + nintendo_base64_encode(b"n/a"), + nintendo_base64_encode(nasc_date_time().as_bytes()) + ) +} \ No newline at end of file diff --git a/src/nasc/mod.rs b/src/nasc/mod.rs new file mode 100644 index 0000000..ab66157 --- /dev/null +++ b/src/nasc/mod.rs @@ -0,0 +1,2 @@ +pub mod util; +pub mod ac; \ No newline at end of file diff --git a/src/nasc/util.rs b/src/nasc/util.rs new file mode 100644 index 0000000..a02cbaf --- /dev/null +++ b/src/nasc/util.rs @@ -0,0 +1,40 @@ +use chrono::Local; + +pub fn nintendo_base64_decode(encoded: &str) -> String { + let standard_b64 = encoded + .replace('.', "+") + .replace('-', "/") + .replace('*', "="); + + match base64::Engine::decode(&base64::engine::general_purpose::STANDARD, standard_b64) { + Ok(bytes) => String::from_utf8_lossy(&bytes).into_owned(), + Err(_) => String::new(), + } +} + +pub fn nintendo_base64_encode(decoded: &[u8]) -> String { + let standard_b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, decoded); + standard_b64 + .replace('+', ".") + .replace('/', "-") + .replace('=', "*") +} + +pub fn nasc_date_time() -> String { + Local::now().format("%Y%m%d%H%M%S").to_string() +} + +pub fn nasc_error(error_code: &str) -> String { + let return_cd = if error_code == "null" { + error_code.to_string() + } else { + nintendo_base64_encode(error_code.as_bytes()) + }; + + format!( + "retry={}&returncd={}&datetime={}", + nintendo_base64_encode(b"1"), + return_cd, + nintendo_base64_encode(nasc_date_time().as_bytes()) + ) +} \ No newline at end of file diff --git a/src/nnid/provider.rs b/src/nnid/provider.rs index 973916a..96809f1 100644 --- a/src/nnid/provider.rs +++ b/src/nnid/provider.rs @@ -18,6 +18,15 @@ const NO_IPV4_ERROR: Errors = Errors{ ] }; +const SERVER_ERROR: Errors = Errors{ + error: &[ + Error{ + code: "9999", + message: "Internal Server Error" + } + ] +}; + const NO_SERVER_ERROR: Errors = Errors{ error: &[ Error{ @@ -27,6 +36,15 @@ const NO_SERVER_ERROR: Errors = Errors{ ] }; +const MAINTENANCE_ERROR: Errors = Errors{ + error: &[ + Error{ + code: "2002", + message: "The requested game server is under maintenance" + } + ] +}; + #[derive(Serialize)] #[serde(rename = "nex_token")] @@ -75,21 +93,25 @@ pub async fn get_nex_token(pool: &State, auth: Auth, game_ser // we change the way we read in data to essentially having the user object be a proxy for its // table row) + let account_level = auth.account_level; + let pool = pool.inner(); - let server = sqlx::query!( - "select address, port from nex_servers where game_server_id = $1", - game_server_id + let server = match sqlx::query!( + "select address, port, maintenance_mode from nex_servers where game_server_id = $1 AND server_account_level <= $2", + game_server_id, + account_level ) - .fetch_optional(pool) - .await - .expect("database error"); // only crash on db failure (not missing row) - - let server = match server { - Some(server) => server, - None => return Err(Some(NO_SERVER_ERROR)), // or custom error - }; - + .fetch_optional(pool) + .await + .expect("database error") { + Some(row) => row, + None => return Err(Some(NO_SERVER_ERROR)), + }; // only crash on db failure (not missing row) + + if server.maintenance_mode { + return Err(Some(MAINTENANCE_ERROR)) + } let token = create_token(pool, auth.pid, NEX_TOKEN, None).await; @@ -99,8 +121,6 @@ pub async fn get_nex_token(pool: &State, auth: Auth, game_ser let host = host.ip(); - - Ok( Xml( NexToken{