using System.Net; using System.Net.WebSockets; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; namespace JJServiceManagerRelay; /// 서버 설정 (환경 변수) public sealed class ServerConfig { public string ConnectionString { get; init; } public int Port { get; init; } = 21900; public string LogDir { get; init; } = "logs"; public string AdminUser { get; init; } = ""; public string AdminPassword { get; init; } = ""; public TimeSpan TokenLife { get; init; } = TimeSpan.FromHours(12); public TimeSpan DefaultCodeLife { get; init; } = TimeSpan.FromDays(7); public List<(IPAddress net, int bits)> TrustedProxies { get; init; } = new(); public static ServerConfig FromEnvironment() { string Env(string k, string d = "") => Environment.GetEnvironmentVariable(k) is { Length: > 0 } v ? v : d; var cs = Env("JJSM_DB"); if (string.IsNullOrEmpty(cs)) { cs = $"Host={Env("POSTGRES_HOST", "localhost")};Port={Env("POSTGRES_PORT", "5432")};" + $"Database={Env("POSTGRES_DB", "jjsm")};Username={Env("POSTGRES_USER", "jjsm")};" + $"Password={Env("POSTGRES_PASSWORD", "")};Maximum Pool Size=50"; } var proxies = new List<(IPAddress, int)>(); foreach (var part in Env("JJSM_TRUSTED_PROXIES", "127.0.0.0/8,::1/128,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16") .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) { var p = part.Split('/'); if (IPAddress.TryParse(p[0], out var ip)) proxies.Add((ip, p.Length > 1 && int.TryParse(p[1], out var b) ? b : (ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork ? 32 : 128))); } return new ServerConfig { ConnectionString = cs, Port = int.TryParse(Env("JJSM_PORT", "21900"), out var port) ? port : 21900, LogDir = Env("JJSM_LOG_DIR", "logs"), AdminUser = Env("JJSM_ADMIN_USER"), AdminPassword = Env("JJSM_ADMIN_PASSWORD"), DefaultCodeLife = TimeSpan.FromHours(double.TryParse(Env("JJSM_CODE_HOURS", "168"), out var h) ? h : 168), TrustedProxies = proxies }; } public bool IsTrustedProxy(IPAddress ip) { if (ip == null) return false; if (ip.IsIPv4MappedToIPv6) ip = ip.MapToIPv4(); foreach (var (net, bits) in TrustedProxies) { if (net.AddressFamily != ip.AddressFamily) continue; var a = net.GetAddressBytes(); var b = ip.GetAddressBytes(); int full = bits / 8, rest = bits % 8; bool ok = true; for (int i = 0; i < full && ok; i++) ok = a[i] == b[i]; if (ok && rest > 0) { int mask = 0xFF << (8 - rest) & 0xFF; ok = (a[full] & mask) == (b[full] & mask); } if (ok) return true; } return false; } /// 실제 접속한 쪽 IP (리버스 프록시 뒤라면 X-Real-IP / X-Forwarded-For) public string ClientIp(HttpContext ctx) { var remote = ctx.Connection.RemoteIpAddress; if (IsTrustedProxy(remote)) { var real = ctx.Request.Headers["X-Real-IP"].ToString(); if (IPAddress.TryParse(real, out var r)) return Clean(r); var xff = ctx.Request.Headers["X-Forwarded-For"].ToString(); if (!string.IsNullOrEmpty(xff)) { var last = xff.Split(',').Select(s => s.Trim()).LastOrDefault(); if (IPAddress.TryParse(last, out var f)) return Clean(f); } } return remote == null ? "" : Clean(remote); } private static string Clean(IPAddress ip) => (ip.IsIPv4MappedToIPv6 ? ip.MapToIPv4() : ip).ToString(); } /// 콘솔 + 날짜별 파일 로그 (추가만, 지우지 않음) public sealed class ServerLog { private readonly string _dir; private readonly object _lock = new(); public ServerLog(string dir) { _dir = dir; try { Directory.CreateDirectory(dir); } catch { } } public void Info(string m) => Write("INFO", m); public void Warn(string m) => Write("WARN", m); public void Error(string m) => Write("ERROR", m); private void Write(string level, string m) { var now = DateTime.Now; var line = $"{now:yyyy-MM-dd HH:mm:ss} [{level}] {m}"; lock (_lock) { Console.WriteLine(line); try { File.AppendAllText(Path.Combine(_dir, $"{now:yyyy-MM-dd}.log"), line + Environment.NewLine, Encoding.UTF8); } catch { } } } } /// WebSocket 위 JSON 메시지 (텍스트 프레임 하나 = 메시지 하나) public sealed class WsJson { private readonly WebSocket _ws; private readonly SemaphoreSlim _send = new(1, 1); private const int MaxMessage = 4 * 1024 * 1024; public WsJson(WebSocket ws) => _ws = ws; public WebSocketState State => _ws.State; public async Task ReceiveAsync(CancellationToken ct) { var buf = new byte[16 * 1024]; using var ms = new MemoryStream(); while (true) { var r = await _ws.ReceiveAsync(buf, ct); if (r.MessageType == WebSocketMessageType.Close) return null; ms.Write(buf, 0, r.Count); if (ms.Length > MaxMessage) throw new InvalidDataException("메시지가 너무 큽니다."); if (r.EndOfMessage) break; } if (ms.Length == 0) return new JsonObject(); try { return JsonNode.Parse(ms.ToArray()) as JsonObject ?? throw new InvalidDataException("JSON 객체가 아닙니다."); } catch (JsonException) { throw new InvalidDataException("JSON 형식이 아닙니다."); } } public async Task SendAsync(JsonObject msg, CancellationToken ct = default) { var bytes = Encoding.UTF8.GetBytes(msg.ToJsonString()); await _send.WaitAsync(ct); try { if (_ws.State == WebSocketState.Open) await _ws.SendAsync(bytes, WebSocketMessageType.Text, true, ct); } finally { _send.Release(); } } /// 보내기 실패는 무시 (연결이 끊긴 상대에게 알림 보낼 때) public async Task TrySendAsync(JsonObject msg) { try { using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); await SendAsync(msg, cts.Token); } catch { } } public async Task CloseAsync(string reason) { try { using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); if (_ws.State == WebSocketState.Open || _ws.State == WebSocketState.CloseReceived) await _ws.CloseAsync(WebSocketCloseStatus.NormalClosure, Truncate(reason), cts.Token); } catch { } finally { try { _ws.Abort(); } catch { } } } /// WebSocket 닫기 사유는 123바이트까지 private static string Truncate(string s) { s ??= ""; while (Encoding.UTF8.GetByteCount(s) > 120) s = s[..^1]; return s; } public static JsonObject Ok(long? id, JsonObject extra = null) { var o = extra ?? new JsonObject(); if (id != null) o["id"] = id; o["ok"] = true; return o; } public static JsonObject Error(long? id, string message) { var o = new JsonObject { ["ok"] = false, ["error"] = message }; if (id != null) o["id"] = id; return o; } } /// 요청이 잘못되었을 때 (메시지를 그대로 응답) public sealed class UserError : Exception { public UserError(string message) : base(message) { } } public static class J { public static string Str(JsonObject o, string k, string d = "") { try { return o?[k]?.GetValue() ?? d; } catch { return o?[k]?.ToString() ?? d; } } public static long? Long(JsonObject o, string k) { try { return o?[k]?.GetValue(); } catch { return long.TryParse(o?[k]?.ToString(), out var v) ? v : null; } } public static bool? Bool(JsonObject o, string k) { try { return o?[k]?.GetValue(); } catch { return null; } } public static Guid? Guid(JsonObject o, string k) => System.Guid.TryParse(Str(o, k), out var g) ? g : null; public static DateTime? Time(JsonObject o, string k) => DateTime.TryParse(Str(o, k), null, System.Globalization.DateTimeStyles.AdjustToUniversal | System.Globalization.DateTimeStyles.AssumeUniversal, out var t) ? DateTime.SpecifyKind(t, DateTimeKind.Utc) : null; public static string RequireStr(JsonObject o, string k, string label, int max = 200) { var s = Str(o, k).Trim(); if (s.Length == 0) throw new UserError($"{label}을(를) 입력하세요."); if (s.Length > max) throw new UserError($"{label}이(가) 너무 깁니다."); return s; } }