Panel: game server integration API
Game servers (Paper plugin, Fabric/Forge mods) authenticate with a per-server token and report to /api/server/v1: - hello: records software/version, returns the auth server URL - login: allow/deny with a message (account status + reason, access all/members/groups, optional "must join through the launcher" check matching the player's recent launch IP) - sync: online players, TPS, stat deltas and events; answers with players to kick when their account was disabled meanwhile Admins create servers (token shown once, stored hashed), regenerate tokens, and read leaderboards, events and per-player activity. The dashboard stats include who's online right now. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011ARcGWxLx21FwXJ3yfGriS
This commit is contained in:
4 files changed
+885
No files matched your search
@@ -0,0 +1,216 @@
|
||||
//! Game server integration API: tokens, login checks, stats sync.
|
||||
|
||||
mod common;
|
||||
use common::*;
|
||||
|
||||
async fn admin_and_player(t: &TestApp) -> (String, i64) {
|
||||
let admin = t.login("admin", "supersecret").await;
|
||||
let (s, v) = t.call("POST", "/api/admin/users", Some(&admin), Some(json!({"username": "Steve", "password": "password123"}))).await;
|
||||
assert_eq!(s, StatusCode::OK, "{v}");
|
||||
(admin, v["id"].as_i64().unwrap())
|
||||
}
|
||||
|
||||
async fn create_server(t: &TestApp, admin: &str, body: Value) -> (i64, String) {
|
||||
let (s, v) = t.call("POST", "/api/admin/servers", Some(admin), Some(body)).await;
|
||||
assert_eq!(s, StatusCode::OK, "{v}");
|
||||
let token = v["token"].as_str().unwrap().to_string();
|
||||
assert!(token.starts_with("sn_"));
|
||||
assert_eq!(v["server"]["token_hint"], token[token.len() - 4..]);
|
||||
assert!(v["server"].get("token_hash").is_none());
|
||||
(v["server"]["id"].as_i64().unwrap(), token)
|
||||
}
|
||||
|
||||
fn steve() -> String {
|
||||
scopenet_shared::offline_uuid("Steve")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tokens_authenticate_servers() {
|
||||
let t = setup().await;
|
||||
let (admin, _) = admin_and_player(&t).await;
|
||||
let (id, token) = create_server(&t, &admin, json!({"name": "Survival"})).await;
|
||||
|
||||
let (s, _) = t.call("POST", "/api/server/v1/hello", None, Some(json!({}))).await;
|
||||
assert_eq!(s, StatusCode::UNAUTHORIZED);
|
||||
let (s, _) = t.call("POST", "/api/server/v1/hello", Some("sn_nope"), Some(json!({}))).await;
|
||||
assert_eq!(s, StatusCode::UNAUTHORIZED);
|
||||
|
||||
let (s, v) = t
|
||||
.call(
|
||||
"POST",
|
||||
"/api/server/v1/hello",
|
||||
Some(&token),
|
||||
Some(json!({"software": "Paper", "mc_version": "1.21.1", "plugin_version": "0.1.0", "online_mode": true, "max_players": 50})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::OK, "{v}");
|
||||
assert_eq!(v["server_id"], id);
|
||||
assert_eq!(v["yggdrasil_url"], "https://panel.test/api/yggdrasil");
|
||||
|
||||
let (_, list) = t.call("GET", "/api/admin/servers", Some(&admin), None).await;
|
||||
assert_eq!(list[0]["software"], "Paper");
|
||||
assert_eq!(list[0]["online"], true);
|
||||
assert_eq!(list[0]["max_players"], 50);
|
||||
|
||||
// Regenerating invalidates the old token.
|
||||
let (s, v) = t.call("POST", &format!("/api/admin/servers/{id}/token"), Some(&admin), None).await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
let fresh = v["token"].as_str().unwrap();
|
||||
let (s, _) = t.call("POST", "/api/server/v1/hello", Some(&token), Some(json!({}))).await;
|
||||
assert_eq!(s, StatusCode::UNAUTHORIZED);
|
||||
let (s, _) = t.call("POST", "/api/server/v1/hello", Some(fresh), Some(json!({}))).await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
|
||||
// Players can't manage servers.
|
||||
let player = t.login("Steve", "password123").await;
|
||||
let (s, _) = t.call("GET", "/api/admin/servers", Some(&player), None).await;
|
||||
assert_eq!(s, StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn login_rules() {
|
||||
let t = setup().await;
|
||||
let (admin, steve_id) = admin_and_player(&t).await;
|
||||
let login = |token: String, uuid: String, name: &'static str, ip: Option<&'static str>| {
|
||||
let t = &t;
|
||||
async move { t.call("POST", "/api/server/v1/login", Some(&token), Some(json!({"uuid": uuid, "name": name, "ip": ip}))).await.1 }
|
||||
};
|
||||
|
||||
// "all": unknown players are fine, accounts are identified.
|
||||
let (_, open) = create_server(&t, &admin, json!({"name": "Open"})).await;
|
||||
let v = login(open.clone(), "00000000-0000-0000-0000-000000000001".into(), "Stranger", None).await;
|
||||
assert_eq!(v["allowed"], true, "{v}");
|
||||
let v = login(open.clone(), steve().replace('-', ""), "Steve", None).await;
|
||||
assert_eq!(v["allowed"], true);
|
||||
assert_eq!(v["account"]["username"], "Steve");
|
||||
|
||||
// "members": unknown players are refused.
|
||||
let (_, members) = create_server(&t, &admin, json!({"name": "Members", "access": "members"})).await;
|
||||
let v = login(members.clone(), "00000000-0000-0000-0000-000000000001".into(), "Stranger", None).await;
|
||||
assert_eq!(v["allowed"], false);
|
||||
assert!(v["message"].as_str().unwrap().contains("account"));
|
||||
|
||||
// "groups": only listed groups (admins always pass).
|
||||
let (s, v) = t.call("POST", "/api/admin/servers", Some(&admin), Some(json!({"name": "Staff", "access": "groups"}))).await;
|
||||
assert_eq!(s, StatusCode::BAD_REQUEST, "{v}");
|
||||
let (_, staff) = create_server(&t, &admin, json!({"name": "Staff", "access": "groups", "allowed_groups": ["builders"]})).await;
|
||||
let v = login(staff.clone(), steve(), "Steve", None).await;
|
||||
assert_eq!(v["allowed"], false);
|
||||
let admin_uuid = scopenet_shared::offline_uuid("admin");
|
||||
let v = login(staff.clone(), admin_uuid, "admin", None).await;
|
||||
assert_eq!(v["allowed"], true, "{v}");
|
||||
t.call("POST", "/api/admin/groups", Some(&admin), Some(json!({"name": "builders"}))).await;
|
||||
let (s, v) = t.call("PATCH", &format!("/api/admin/users/{steve_id}"), Some(&admin), Some(json!({"groups": ["builders"]}))).await;
|
||||
assert_eq!(s, StatusCode::OK, "{v}");
|
||||
let v = login(staff.clone(), steve(), "Steve", None).await;
|
||||
assert_eq!(v["allowed"], true, "{v}");
|
||||
|
||||
// Require launcher: a launch from the same IP is needed.
|
||||
let (_, strict) = create_server(&t, &admin, json!({"name": "Strict", "require_launcher": true})).await;
|
||||
let v = login(strict.clone(), steve(), "Steve", Some("203.0.113.9")).await;
|
||||
assert_eq!(v["allowed"], false);
|
||||
assert!(v["message"].as_str().unwrap().contains("launcher"));
|
||||
let player = t.login("Steve", "password123").await;
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/v1/launcher/events")
|
||||
.header("authorization", format!("Bearer {player}"))
|
||||
.header("content-type", "application/json")
|
||||
.header("x-forwarded-for", "203.0.113.9")
|
||||
.body(Body::from(json!({"kind": "launch", "instance_id": "survival"}).to_string()))
|
||||
.unwrap();
|
||||
let (s, v) = t.send(req).await;
|
||||
assert_eq!(s, StatusCode::OK, "{v}");
|
||||
let v = login(strict.clone(), steve(), "Steve", Some("203.0.113.9")).await;
|
||||
assert_eq!(v["allowed"], true, "{v}");
|
||||
let v = login(strict.clone(), steve(), "Steve", Some("198.51.100.1")).await;
|
||||
assert_eq!(v["allowed"], false);
|
||||
|
||||
// Disabled accounts see the reason.
|
||||
t.call(
|
||||
"PATCH",
|
||||
&format!("/api/admin/users/{steve_id}"),
|
||||
Some(&admin),
|
||||
Some(json!({"status": "disabled", "status_reason": "Griefing"})),
|
||||
)
|
||||
.await;
|
||||
let v = login(open, steve(), "Steve", None).await;
|
||||
assert_eq!(v["allowed"], false);
|
||||
assert!(v["message"].as_str().unwrap().contains("Griefing"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sync_tracks_players_stats_and_kicks() {
|
||||
let t = setup().await;
|
||||
let (admin, steve_id) = admin_and_player(&t).await;
|
||||
let (id, token) = create_server(&t, &admin, json!({"name": "Survival"})).await;
|
||||
t.call("POST", "/api/server/v1/hello", Some(&token), Some(json!({"max_players": 20}))).await;
|
||||
|
||||
let sync = json!({
|
||||
"tps": 19.8,
|
||||
"online": [{"uuid": steve().replace('-', ""), "name": "Steve"}],
|
||||
"stats": [{"uuid": steve(), "name": "Steve", "playtime_secs": 30, "joins": 1, "blocks_broken": 12, "deaths": 1}],
|
||||
"events": [
|
||||
{"uuid": steve(), "name": "Steve", "kind": "join"},
|
||||
{"uuid": steve(), "name": "Steve", "kind": "death", "detail": "Steve fell from a high place"}
|
||||
]
|
||||
});
|
||||
let (s, v) = t.call("POST", "/api/server/v1/sync", Some(&token), Some(sync)).await;
|
||||
assert_eq!(s, StatusCode::OK, "{v}");
|
||||
assert_eq!(v["kick"], json!([]));
|
||||
// Deltas add up.
|
||||
let (s, _) = t
|
||||
.call(
|
||||
"POST",
|
||||
"/api/server/v1/sync",
|
||||
Some(&token),
|
||||
Some(json!({"tps": 20.0, "online": [{"uuid": steve(), "name": "Steve"}], "stats": [{"uuid": steve(), "name": "Steve", "playtime_secs": 30, "blocks_broken": 3}]})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
|
||||
let (s, d) = t.call("GET", &format!("/api/admin/servers/{id}"), Some(&admin), None).await;
|
||||
assert_eq!(s, StatusCode::OK, "{d}");
|
||||
assert_eq!(d["server"]["players"], 1);
|
||||
assert_eq!(d["server"]["tps"], 20.0);
|
||||
assert_eq!(d["online_players"][0]["name"], "Steve");
|
||||
let row = &d["leaderboard"][0];
|
||||
assert_eq!(row["playtime_secs"], 60);
|
||||
assert_eq!(row["blocks_broken"], 15);
|
||||
assert_eq!(row["joins"], 1);
|
||||
assert_eq!(d["events"][0]["kind"], "death");
|
||||
assert_eq!(d["events"][1]["kind"], "join");
|
||||
assert_eq!(d["totals"]["players"], 1);
|
||||
|
||||
let (_, dash) = t.call("GET", "/api/admin/stats", Some(&admin), None).await;
|
||||
assert_eq!(dash["live"]["online_now"], 1);
|
||||
|
||||
let (_, users) = t.call("GET", "/api/admin/users", Some(&admin), None).await;
|
||||
let s_view = users.as_array().unwrap().iter().find(|u| u["username"] == "Steve").unwrap();
|
||||
assert_eq!(s_view["playtime_secs"], 60);
|
||||
let (s, act) = t.call("GET", &format!("/api/admin/users/{steve_id}/activity"), Some(&admin), None).await;
|
||||
assert_eq!(s, StatusCode::OK, "{act}");
|
||||
assert_eq!(act["servers"][0]["server_name"], "Survival");
|
||||
assert_eq!(act["online_on"][0]["name"], "Survival");
|
||||
|
||||
// Disabling an online player asks the server to kick them.
|
||||
t.call(
|
||||
"PATCH",
|
||||
&format!("/api/admin/users/{steve_id}"),
|
||||
Some(&admin),
|
||||
Some(json!({"status": "disabled", "status_reason": "Cheating"})),
|
||||
)
|
||||
.await;
|
||||
let (_, v) = t.call("POST", "/api/server/v1/sync", Some(&token), Some(json!({"online": [{"uuid": steve(), "name": "Steve"}]}))).await;
|
||||
assert_eq!(v["kick"][0]["uuid"], steve());
|
||||
assert!(v["kick"][0]["message"].as_str().unwrap().contains("Cheating"));
|
||||
|
||||
// Leaving empties the online list; deleting the server removes its data.
|
||||
t.call("POST", "/api/server/v1/sync", Some(&token), Some(json!({"online": []}))).await;
|
||||
let (_, d) = t.call("GET", &format!("/api/admin/servers/{id}"), Some(&admin), None).await;
|
||||
assert_eq!(d["online_players"], json!([]));
|
||||
let (s, _) = t.call("DELETE", &format!("/api/admin/servers/{id}"), Some(&admin), None).await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
let (s, _) = t.call("POST", "/api/server/v1/sync", Some(&token), Some(json!({}))).await;
|
||||
assert_eq!(s, StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
Reference in new issue
Block a user