using System.Reflection; using Eleon.Modding; using EmpyrionBridge; var builder = WebApplication.CreateBuilder(args); builder.Logging.ClearProviders(); builder.Logging.AddSimpleConsole(o => { o.SingleLine = true; o.TimestampFormat = "HH:mm:ss "; }); builder.Services.AddSingleton(); builder.WebHost.UseUrls(Environment.GetEnvironmentVariable("BRIDGE_URLS") ?? "http://0.0.0.0:8090"); // Eleon's protobuf-net contracts (PlayerInfo, FactionInfo, etc.) expose // their data as PUBLIC FIELDS, not properties. System.Text.Json ignores // public fields by default, which made `Results.Json(playerInfo)` emit // `{}` even though the deserialized object had real data inside. Turning // IncludeFields on (globally for every endpoint that doesn't project // explicitly) fixes the player-list, player-info, dedi-stats, and any // future endpoint that returns a raw mod-API DTO. // // AllowNamedFloatingPointLiterals lets us serialize `NaN` / `Infinity` // as the JSON string forms. Empyrion's mod API hands those back for // uninitialized float fields (heading, posY, etc.) and STJ will throw // at serialization time without this — breaking the entire response. builder.Services.ConfigureHttpJsonOptions(o => { o.SerializerOptions.IncludeFields = true; o.SerializerOptions.NumberHandling = System.Text.Json.Serialization.JsonNumberHandling.AllowNamedFloatingPointLiterals; }); var app = builder.Build(); var pool = app.Services.GetRequiredService(); var requestTimeout = TimeSpan.FromSeconds(double.TryParse(Environment.GetEnvironmentVariable("BRIDGE_REQUEST_TIMEOUT_S"), out var t) ? t : 8); var apiToken = (Environment.GetEnvironmentVariable("BRIDGE_API_TOKEN") ?? "").Trim(); if (string.IsNullOrEmpty(apiToken)) { app.Logger.LogWarning("BRIDGE_API_TOKEN is not set — auth is OFF. Anyone reaching the bridge can drive it. Suitable for loopback-only deployments."); } // Bearer-token middleware. Loopback (127.0.0.1, ::1) and unix sockets are // always allowed without a token, matching the panel's existing pattern. // Health endpoint always allowed. app.Use(async (ctx, next) => { if (string.IsNullOrEmpty(apiToken)) { await next(); return; } if (ctx.Request.Path.StartsWithSegments("/healthz")) { await next(); return; } var ip = ctx.Connection.RemoteIpAddress; if (ip is not null && (System.Net.IPAddress.IsLoopback(ip))) { await next(); return; } var auth = ctx.Request.Headers["Authorization"].FirstOrDefault() ?? ""; if (auth == $"Bearer {apiToken}") { await next(); return; } ctx.Response.StatusCode = 401; await ctx.Response.WriteAsJsonAsync(new { error = "unauthorized" }); }); string? Q(HttpRequest r, string k) => r.Query[k].FirstOrDefault(); (string host, int port)? Endpoint(HttpRequest r) { var h = Q(r, "host"); var p = Q(r, "port"); if (string.IsNullOrEmpty(h) || !int.TryParse(p, out var pi)) return null; return (h, pi); } app.MapGet("/healthz", () => Results.Json(new { ok = true, version = typeof(Program).Assembly.GetCustomAttribute()?.InformationalVersion, pid = Environment.ProcessId, authMode = string.IsNullOrEmpty(apiToken) ? "none" : "bearer-token", })); app.MapGet("/api/v1/factions", async (HttpRequest r) => { var ep = Endpoint(r); if (ep is null) return Results.BadRequest(new { error = "missing host or port" }); var conn = await pool.GetAsync(ep.Value.host, ep.Value.port); var resp = await conn.RequestAsync(CmdId.Request_Get_Factions, CmdId.Event_Get_Factions, new Id(1), requestTimeout); return Results.Json(new { host = ep.Value.host, port = ep.Value.port, factions = resp?.factions?.Select(f => new { origin = f.origin, factionId = f.factionId, name = f.name, abbrev = f.abbrev, }) ?? Enumerable.Empty(), }); }); app.MapGet("/api/v1/players", async (HttpRequest r) => { var ep = Endpoint(r); if (ep is null) return Results.BadRequest(new { error = "missing host or port" }); var conn = await pool.GetAsync(ep.Value.host, ep.Value.port); var resp = await conn.RequestAsync(CmdId.Request_Player_List, CmdId.Event_Player_List, null, requestTimeout); return Results.Json(new { host = ep.Value.host, port = ep.Value.port, playerIds = resp?.list ?? new List(), count = resp?.list?.Count ?? 0, }); }); app.MapGet("/api/v1/players/{id:int}", async (int id, HttpRequest r) => { var ep = Endpoint(r); if (ep is null) return Results.BadRequest(new { error = "missing host or port" }); var conn = await pool.GetAsync(ep.Value.host, ep.Value.port); var resp = await conn.RequestAsync(CmdId.Request_Player_Info, CmdId.Event_Player_Info, new Id(id), requestTimeout); if (resp is null) return Results.NotFound(new { error = "no info for player", id }); return Results.Json(resp); }); app.MapGet("/api/v1/playfields", async (HttpRequest r) => { var ep = Endpoint(r); if (ep is null) return Results.BadRequest(new { error = "missing host or port" }); var conn = await pool.GetAsync(ep.Value.host, ep.Value.port); var resp = await conn.RequestAsync(CmdId.Request_Playfield_List, CmdId.Event_Playfield_List, null, requestTimeout); return Results.Json(new { host = ep.Value.host, port = ep.Value.port, playfields = resp?.playfields ?? new List(), }); }); app.MapGet("/api/v1/playfields/{name}/entities", async (string name, HttpRequest r) => { var ep = Endpoint(r); if (ep is null) return Results.BadRequest(new { error = "missing host or port" }); var conn = await pool.GetAsync(ep.Value.host, ep.Value.port); var resp = await conn.RequestAsync(CmdId.Request_Playfield_Entity_List, CmdId.Event_Playfield_Entity_List, new PString(name), requestTimeout); return Results.Json(new { host = ep.Value.host, port = ep.Value.port, playfield = name, entities = resp?.entities ?? new List(), }); }); app.MapPost("/api/v1/console", async (HttpRequest r) => { var ep = Endpoint(r); if (ep is null) return Results.BadRequest(new { error = "missing host or port" }); using var sr = new StreamReader(r.Body); var body = await sr.ReadToEndAsync(); var line = body.Trim(); if (string.IsNullOrEmpty(line)) return Results.BadRequest(new { error = "empty body" }); var conn = await pool.GetAsync(ep.Value.host, ep.Value.port); conn.SendFireAndForget(CmdId.Request_ConsoleCommand, new PString(line)); return Results.Json(new { sent = line }); }); EndpointMaps.MapAll(app, pool, requestTimeout); app.Logger.LogInformation("panel-empyrion-bridge listening; request_timeout={Timeout}s authMode={Mode}", requestTimeout.TotalSeconds, string.IsNullOrEmpty(apiToken) ? "none" : "bearer"); app.Run(); public partial class Program { }