JJServiceManagerRelay/server/Common.cs

265 lines
9.2 KiB
C#

using System.Net;
using System.Net.WebSockets;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace JJServiceManagerRelay;
/// <summary>서버 설정 (환경 변수)</summary>
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;
}
/// <summary>실제 접속한 쪽 IP (리버스 프록시 뒤라면 X-Real-IP / X-Forwarded-For)</summary>
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();
}
/// <summary>콘솔 + 날짜별 파일 로그 (추가만, 지우지 않음)</summary>
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 { }
}
}
}
/// <summary>WebSocket 위 JSON 메시지 (텍스트 프레임 하나 = 메시지 하나)</summary>
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<JsonObject> 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();
}
}
/// <summary>보내기 실패는 무시 (연결이 끊긴 상대에게 알림 보낼 때)</summary>
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 { }
}
}
/// <summary>WebSocket 닫기 사유는 123바이트까지</summary>
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;
}
}
/// <summary>요청이 잘못되었을 때 (메시지를 그대로 응답)</summary>
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<string>() ?? d; } catch { return o?[k]?.ToString() ?? d; }
}
public static long? Long(JsonObject o, string k)
{
try { return o?[k]?.GetValue<long>(); }
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<bool>(); } 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;
}
}