Compare commits

..

2 Commits

Author SHA1 Message Date
Alexa a142625f3d Add DorkNet.Contracts reference to monolith Server build
DorkNet.Server uses DorkNetRouteOwnership from DorkNet.Contracts but the
project never referenced it and the Dockerfile never copied it, so the
monolith image failed to compile (CS0234). Add the project reference and
copy the project into the build stage.
2026-06-25 12:42:46 +03:00
Alexa 52e1d4f61c Route microservices through Traefik behind Cloudflare Tunnel
Add scoped Traefik host labels to the gateway and put cloudflared on the
Traefik network so the tunnel hands off to Traefik instead of forwarding
every host straight to the app. Keep backend services internal-only.
2026-06-25 12:04:06 +03:00
3 changed files with 13 additions and 42 deletions
@@ -167,11 +167,6 @@ public static class DorkNetRouteOwnership
public static IReadOnlyList<string> PublicSubdomains =>
ServiceRouteGroups
.SelectMany(group => group.HostSubdomains)
// api.* is a shared public host: it owns no service by host and is
// dispatched by path (see ResolvePublicService's path-prefix pass),
// so it never appears in a group's HostSubdomains. It must still be
// whitelisted here or HostFiltering rejects every api.{apex} request.
.Append("api")
.Append(AdminHost)
.Append("feed")
.Distinct(StringComparer.OrdinalIgnoreCase)
@@ -20,6 +20,7 @@ namespace DorkNet.Server.Controllers.Match;
[ApiController]
public class MatchPlayerController(
PlayerPresenceService presence,
NotificationService notifications,
RoomService rooms,
PrivateInstanceService privateInstances,
IConfiguration config,
@@ -36,7 +37,7 @@ public class MatchPlayerController(
/// </summary>
[HttpPost("/player/login")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
public IActionResult PlayerLogin([FromForm(Name = "LoginLock")] string? loginLock)
public async Task<IActionResult> PlayerLogin([FromForm(Name = "LoginLock")] string? loginLock)
{
var playerId = TryGetCurrentPlayerId();
if (playerId != 0 && !string.IsNullOrWhiteSpace(loginLock))
@@ -45,15 +46,17 @@ public class MatchPlayerController(
if (replaced)
{
log.LogWarning(
"[player-login] concurrent session marker replaced for player={PlayerId}; stale SignalR connections will be evicted on hub connect",
"[player-login] concurrent session for player={PlayerId}; kicking old session(s) via ModerationKick push",
playerId);
// Do not broadcast ModerationKick to the whole player group
// here. During reconnects the active watch can already have a
// SignalR connection in that group, and the 2020 client crashes
// hard if it receives a malformed/stale moderation payload.
// NotifyHub.OnConnectedAsync snapshots existing connection ids
// before adding the new connection and sends a targeted kick to
// those ids only.
// The new session hasn't opened its SignalR socket yet
// (negotiate fires AFTER /player/login on the watch's
// boot sequence — verified in the live trace), so the
// ModerationKick push hits only the OLDER session's
// existing connections. They'll show the "you've been
// kicked" dialog and gracefully disconnect via the
// watch's LocalModerationKickSelf path.
await notifications.KickStaleSession(playerId,
"Your account was signed in on another device.");
}
}
return Ok(Array.Empty<object>());
+1 -28
View File
@@ -64,12 +64,7 @@ public class GameSessionService(DorkNetDbContext db)
// in, so we save once with a placeholder, then patch and save
// again. Two SaveChangesAsync calls is cheap at our QPS.
PhotonRoomName = "pending",
// Track the room's "Max Player Count in This Subroom" slider
// instead of a hardcoded 8, so matchmade joiners fill the room to
// the host's chosen limit rather than fragmenting into a new
// instance every 8 players. (Existing sessions keep whatever cap
// they were created with.)
MaxCapacity = await ResolveRoomMaxCapacityAsync(effectiveRoom),
MaxCapacity = 8,
PlayerCount = 1,
CreatedAt = DateTime.UtcNow,
};
@@ -80,28 +75,6 @@ public class GameSessionService(DorkNetDbContext db)
return ToDto(session);
}
// Default session cap when we can't resolve a room-specific value (system
// rooms like the dorm use a GUID roomId that doesn't map to a RoomEntity).
private const int DefaultMaxCapacity = 8;
/// <summary>Resolve a new session's capacity from the room's entry
/// subroom (lowest <c>OrderIndex</c>) <see cref="RoomSceneEntity.MaxPlayers"/>
/// — the value the in-game "Max Player Count in This Subroom" slider
/// writes. Matchmade sessions land in the entry subroom, so its cap is the
/// right ceiling. GameSession roomIds are the room's numeric Id as a
/// string for user rooms; non-numeric (dorm GUID, etc.) → default. Clamped
/// to a sane Photon ceiling.</summary>
private async Task<int> ResolveRoomMaxCapacityAsync(string roomId)
{
if (!long.TryParse(roomId, out var roomLongId)) return DefaultMaxCapacity;
var max = await db.RoomScenes
.Where(s => s.RoomId == roomLongId)
.OrderBy(s => s.OrderIndex)
.Select(s => (int?)s.MaxPlayers)
.FirstOrDefaultAsync();
return max is int v && v > 0 ? Math.Clamp(v, 1, 80) : DefaultMaxCapacity;
}
public async Task<GameSession?> GetByIdAsync(long id)
{
var s = await db.GameSessions.AsNoTracking()