Files
panel/modules/empyrion-bridge/bridge/Program.cs
T
2026-07-14 23:25:11 -07:00

170 lines
6.9 KiB
C#

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<BridgePool>();
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<BridgePool>();
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<AssemblyInformationalVersionAttribute>()?.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<FactionInfoList>(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<object>(),
});
});
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<IdList>(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<int>(),
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<PlayerInfo>(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<PlayfieldList>(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<string>(),
});
});
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<PlayfieldEntityList>(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<EntityInfo>(),
});
});
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 { }