265 lines
9.2 KiB
C#
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;
|
|
}
|
|
}
|