panel — open-source game server manager (public release)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
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 { }
|
||||
Reference in New Issue
Block a user