using System.Net.WebSockets; using System.Text.RegularExpressions; using System.Text.Json.Nodes; namespace JJServiceManagerRelay; /// /ws/manager — 관리 PC (로그인, 기기 목록, 연결 요청, [서버 관리]) public sealed class ManagerHandler { private readonly Hub _hub; private static readonly Regex UsernameRule = new(@"^[\p{L}\p{N}._@-]{2,32}$"); public ManagerHandler(Hub hub) => _hub = hub; public async Task RunAsync(HttpContext ctx) { var ip = _hub.Config.ClientIp(ctx); using var socket = await ctx.WebSockets.AcceptWebSocketAsync(); var ws = new WsJson(socket); ManagerConn m = null; try { m = await AuthenticateAsync(ws, ip, ctx.RequestAborted); if (m == null) return; _hub.Managers[m.Id] = m; _hub.Log.Info($"관리 PC 로그인: {m.User.Username} {ip}"); await ws.SendAsync(m.PendingReply, ctx.RequestAborted); m.PendingReply = null; using var linked = CancellationTokenSource.CreateLinkedTokenSource(ctx.RequestAborted, m.Cts.Token); var ct = linked.Token; while (!ct.IsCancellationRequested) { JsonObject msg; using (var idle = CancellationTokenSource.CreateLinkedTokenSource(ct)) { idle.CancelAfter(TimeSpan.FromSeconds(120)); msg = await ws.ReceiveAsync(idle.Token); } if (msg == null) break; var id = J.Long(msg, "id"); var op = J.Str(msg, "op"); try { var res = await HandleAsync(m, op, msg); if (id != null) await ws.SendAsync(WsJson.Ok(id, res), ct); if (op == "logout") break; } catch (UserError ue) { if (id != null) await ws.SendAsync(WsJson.Error(id, ue.Message), ct); } catch (Exception ex) when (ex is not OperationCanceledException and not WebSocketException) { _hub.Log.Error($"관리 요청 처리 오류 ({m.User.Username}, {op}): {ex}"); if (id != null) await ws.SendAsync(WsJson.Error(id, "서버 오류: " + ex.Message), ct); } } } catch (OperationCanceledException) { } catch (WebSocketException) { } catch (InvalidDataException ex) { _hub.Log.Warn($"관리 PC 메시지 오류 {ip}: {ex.Message}"); } catch (Exception ex) { _hub.Log.Error($"관리 PC 처리 오류 {ip}: {ex}"); } finally { if (m != null) { _hub.Managers.TryRemove(m.Id, out _); // 직접 연결 중이던 세션은 끝 정보를 받을 수 없으므로 여기서 마감 foreach (var s in _hub.Sessions.Values.Where(x => x.ManagerConnId == m.Id && x.Direct).ToList()) await _hub.FinishSessionAsync(s, "direct", "관리 PC가 서버 연결을 끊음 (트래픽 정보 없음)"); _hub.Log.Info($"관리 PC 연결 끊김: {m.User.Username}"); } await ws.CloseAsync("bye"); } } // ───────────────────────── 로그인 ───────────────────────── private async Task AuthenticateAsync(WsJson ws, string ip, CancellationToken ct) { using var first = CancellationTokenSource.CreateLinkedTokenSource(ct); first.CancelAfter(TimeSpan.FromSeconds(30)); while (true) { var msg = await ws.ReceiveAsync(first.Token); if (msg == null) return null; var id = J.Long(msg, "id"); var op = J.Str(msg, "op"); var host = J.Str(msg, "host"); if (op == "ping") { await ws.SendAsync(WsJson.Ok(id), ct); continue; } if (op == "login") { var username = J.Str(msg, "username").Trim(); var password = J.Str(msg, "password"); var uKey = "user:" + username.ToLowerInvariant(); var ipKey = "login-ip:" + ip; if (_hub.Throttle.IsBlocked(uKey, ipKey)) { await ws.SendAsync(WsJson.Error(id, "로그인 실패가 너무 많습니다. 15분 뒤에 다시 시도하세요."), ct); continue; } var user = await _hub.Db.FindUserAsync(username); bool ok = Secrets.VerifyPassword(password, user?.PasswordHash ?? Secrets.DummyHash) && user != null; if (!ok) { _hub.Throttle.Fail(uKey, ipKey); await _hub.Db.AuditAsync(user?.Id, username, null, "", "loginFail", "", "fail", "아이디 또는 비밀번호가 틀림", ip); await ws.SendAsync(WsJson.Error(id, "아이디 또는 비밀번호가 맞지 않습니다."), ct); continue; } if (!user.Enabled) { await _hub.Db.AuditAsync(user.Id, user.Username, null, "", "loginFail", "", "denied", "사용 안 함 계정", ip); await ws.SendAsync(WsJson.Error(id, "사용 안 함으로 설정된 계정입니다."), ct); continue; } _hub.Throttle.Success(uKey, ipKey); var token = Secrets.NewToken(); var th = Secrets.Sha256(token); await _hub.Db.CreateSessionAsync(th, user.Id, _hub.Config.TokenLife, ip, host); await _hub.Db.TouchLoginAsync(user.Id); await _hub.Db.AuditAsync(user.Id, user.Username, null, "", "login", host, "ok", "", ip); var m = await NewConnAsync(ws, ip, host, user, th); m.PendingReply = WsJson.Ok(id, new JsonObject { ["token"] = token, ["user"] = UserJson(user) }); return m; } if (op == "resume") { var th = Secrets.Sha256(J.Str(msg, "token")); var userId = await _hub.Db.UseSessionAsync(th, _hub.Config.TokenLife); var user = userId == null ? null : await _hub.Db.GetUserAsync(userId.Value); if (user == null || !user.Enabled) { await ws.SendAsync(WsJson.Error(id, "로그인이 만료되었습니다. 다시 로그인하세요.").Also(o => o["code"] = "loginRequired"), ct); continue; } var m = await NewConnAsync(ws, ip, host, user, th); m.PendingReply = WsJson.Ok(id, new JsonObject { ["user"] = UserJson(user) }); return m; } await ws.SendAsync(WsJson.Error(id, "먼저 로그인하세요.").Also(o => o["code"] = "loginRequired"), ct); } } private async Task NewConnAsync(WsJson ws, string ip, string host, UserRow user, byte[] th) => new() { User = user, TokenHash = th, Ws = ws, Ip = ip, Host = host ?? "", Perms = user.IsAdmin ? new() : await _hub.Db.GetPermsForUserAsync(user.Id) }; private static JsonObject UserJson(UserRow u) => new() { ["id"] = u.Id, ["username"] = u.Username, ["role"] = u.Role, ["mustChangePassword"] = u.MustChangePassword }; // ───────────────────────── 요청 ───────────────────────── private async Task HandleAsync(ManagerConn m, string op, JsonObject msg) { // 비밀번호 변경이 필요하면 그것만 허용 if (m.User.MustChangePassword && op is not ("changePassword" or "logout" or "ping")) throw new UserError("먼저 비밀번호를 바꿔야 합니다."); if (op.StartsWith("admin.", StringComparison.Ordinal)) { if (!m.User.IsAdmin) throw new UserError("관리자만 할 수 있습니다."); return await AdminAsync(m, op, msg); } switch (op) { case "ping": if (DateTime.UtcNow - m.LastExtend > TimeSpan.FromMinutes(30)) { m.LastExtend = DateTime.UtcNow; await _hub.Db.UseSessionAsync(m.TokenHash, _hub.Config.TokenLife); } return new JsonObject { ["time"] = Db.Iso(DateTime.UtcNow) }; case "logout": await _hub.Db.DeleteSessionAsync(m.TokenHash); await _hub.Db.AuditAsync(m.User.Id, m.User.Username, null, "", "logout", m.Host, "ok", "", m.Ip); return new JsonObject(); case "changePassword": { var oldPw = J.Str(msg, "old"); var newPw = J.Str(msg, "new"); var fresh = await _hub.Db.GetUserAsync(m.User.Id); if (fresh == null || !Secrets.VerifyPassword(oldPw, fresh.PasswordHash)) throw new UserError("지금 비밀번호가 맞지 않습니다."); CheckPassword(newPw); if (newPw == oldPw) throw new UserError("지금과 다른 비밀번호를 입력하세요."); await _hub.Db.SetPasswordAsync(m.User.Id, Secrets.HashPassword(newPw), false); await _hub.Db.DeleteUserSessionsAsync(m.User.Id, m.TokenHash); await _hub.Db.AuditAsync(m.User.Id, m.User.Username, null, "", "passwordChange", "", "ok", "", m.Ip); m.User = await _hub.Db.GetUserAsync(m.User.Id); return new JsonObject { ["user"] = UserJson(m.User) }; } case "devices": return new JsonObject { ["devices"] = await VisibleDevicesAsync(m) }; case "connect": return await ConnectAsync(m, msg); case "connectStatus": return await ConnectStatusAsync(m, msg); default: throw new UserError("알 수 없는 요청: " + op); } } private async Task VisibleDevicesAsync(ManagerConn m) { if (!m.User.IsAdmin) m.Perms = await _hub.Db.GetPermsForUserAsync(m.User.Id); var arr = new JsonArray(); foreach (var d in await _hub.Db.ListDevicesAsync()) { if (!m.User.IsAdmin && !d.Enabled) continue; var p = m.PermFor(d.Id); if (!p.Any) continue; arr.Add(new JsonObject { ["id"] = d.Id.ToString(), ["name"] = d.Name, ["hostname"] = d.Hostname, ["online"] = _hub.IsOnline(d.Id), ["enabled"] = d.Enabled, ["lastSeenAt"] = d.LastSeenAt == null ? null : Db.Iso(d.LastSeenAt.Value), ["appVersion"] = d.AppVersion, ["perms"] = p.ToJson() }); } return arr; } /// 연결 요청: 권한 확인 → 세션·티켓 → 대상 PC에 알림 → 직접 연결 후보 + 중계 정보 private async Task ConnectAsync(ManagerConn m, JsonObject msg) { var deviceId = J.Guid(msg, "deviceId") ?? throw new UserError("기기를 지정하세요."); var dev = await _hub.Db.GetDeviceAsync(deviceId) ?? throw new UserError("없는 기기입니다."); if (!m.User.IsAdmin) m.Perms = await _hub.Db.GetPermsForUserAsync(m.User.Id); var perm = m.PermFor(deviceId); if (!perm.Any || !dev.Enabled) { await _hub.Db.AuditAsync(m.User.Id, m.User.Username, dev.Id, dev.Name, "connect", "", "denied", "권한 없음", m.Ip); throw new UserError("이 기기에 대한 권한이 없습니다."); } if (!_hub.Agents.TryGetValue(deviceId, out var agent)) throw new UserError("대상 PC가 서버에 접속해 있지 않습니다 (오프라인)."); var s = new RelaySession { DeviceId = dev.Id, DeviceName = dev.Name, UserId = m.User.Id, Username = m.User.Username, ManagerConnId = m.Id }; _hub.Sessions[s.Id] = s; await _hub.Db.ConnectionStartAsync(s.Id, m.User.Id, m.User.Username, dev.Id, dev.Name, m.Ip); var now = DateTimeOffset.UtcNow; var ticket = _hub.Signer.Sign(new JsonObject { ["v"] = 1, ["sid"] = s.Id.ToString(), ["deviceId"] = dev.Id.ToString(), ["userId"] = m.User.Id, ["username"] = m.User.Username, ["role"] = m.User.Role, ["perms"] = perm.ToJson(), ["iat"] = now.ToUnixTimeSeconds(), ["exp"] = now.AddMinutes(5).ToUnixTimeSeconds() }); // 대상 PC: 중계 통로를 미리 열어 둠 (직접 연결이 되면 cancel) await agent.Ws.SendAsync(new JsonObject { ["event"] = "incoming", ["sessionId"] = s.Id.ToString(), ["relayToken"] = s.AgentToken, ["userId"] = m.User.Id, ["username"] = m.User.Username }); await _hub.Db.AuditAsync(m.User.Id, m.User.Username, dev.Id, dev.Name, "connect", "", "ok", "", m.Ip); return new JsonObject { ["sessionId"] = s.Id.ToString(), ["relayToken"] = s.ManagerToken, ["ticket"] = ticket, ["perms"] = perm.ToJson(), ["candidates"] = Candidates(agent, m.Ip), ["deviceName"] = dev.Name, ["hostname"] = agent.Hostname }; } /// 직접 연결 후보: 같은 공유기 뒤(외부 IP 같음)면 내부 IP 먼저, 아니면 외부 IP 먼저 private static JsonArray Candidates(AgentConn a, string managerIp) { var arr = new JsonArray(); if (a.ActiveListen == null) return arr; var port = J.Long(a.ActiveListen, "port") ?? 0; var mode = J.Str(a.ActiveListen, "mode", "Port"); var lan = a.LanIps.Select(x => x?.ToString()).Where(x => !string.IsNullOrEmpty(x)).ToList(); var order = new List<(string host, string kind)>(); bool sameNetwork = !string.IsNullOrEmpty(a.PublicIp) && a.PublicIp == managerIp; if (sameNetwork) order.AddRange(lan.Select(h => (h, "lan"))); if (!string.IsNullOrEmpty(a.PublicIp) && !lan.Contains(a.PublicIp)) order.Add((a.PublicIp, "public")); if (!sameNetwork) order.AddRange(lan.Select(h => (h, "lan"))); foreach (var (host, kind) in order.DistinctBy(x => x.host)) arr.Add(new JsonObject { ["host"] = host, ["port"] = port, ["mode"] = mode, ["kind"] = kind }); return arr; } /// 관리 PC가 알려주는 연결 결과: direct(직접 성공) / failed / ended(끝, 트래픽) private async Task ConnectStatusAsync(ManagerConn m, JsonObject msg) { var sid = J.Guid(msg, "sessionId") ?? throw new UserError("세션을 지정하세요."); var status = J.Str(msg, "status"); _hub.Sessions.TryGetValue(sid, out var s); if (s != null && s.UserId != m.User.Id) throw new UserError("다른 사용자의 연결입니다."); if (s == null && !await _hub.Db.ConnectionBelongsToAsync(sid, m.User.Id)) throw new UserError("없는 연결입니다."); switch (status) { case "direct": if (s != null) { bool cancelRelay; lock (s.Lock) { cancelRelay = !s.Paired; if (cancelRelay) s.Direct = true; } if (cancelRelay) { await _hub.Db.ConnectionModeAsync(sid, "direct"); if (_hub.Agents.TryGetValue(s.DeviceId, out var agent)) await agent.Ws.TrySendAsync(new JsonObject { ["event"] = "cancel", ["sessionId"] = sid.ToString() }); // 중계 통로에서 기다리던 쪽을 깨워 닫게 함 s.Wake.TrySetResult(); } } return new JsonObject(); case "ended": { long to = J.Long(msg, "bytesToDevice") ?? 0, from = J.Long(msg, "bytesFromDevice") ?? 0; var reason = J.Str(msg, "reason"); if (s != null) { if (s.Direct) { s.BytesToDevice = to; s.BytesFromDevice = from; await _hub.FinishSessionAsync(s, "direct", reason); } } else { await _hub.Db.ConnectionEndAsync(sid, null, to, from, reason); } return new JsonObject(); } case "failed": if (s != null) await _hub.FinishSessionAsync(s, "failed", J.Str(msg, "reason")); return new JsonObject(); default: throw new UserError("알 수 없는 상태: " + status); } } // ───────────────────────── [서버 관리] ───────────────────────── private async Task AdminAsync(ManagerConn m, string op, JsonObject msg) { var db = _hub.Db; var me = m.User; switch (op) { // ── 계정 ── case "admin.users.list": { var arr = new JsonArray(); foreach (var u in await db.ListUsersAsync()) { arr.Add(new JsonObject { ["id"] = u.Id, ["username"] = u.Username, ["role"] = u.Role, ["enabled"] = u.Enabled, ["mustChangePassword"] = u.MustChangePassword, ["note"] = u.Note, ["createdAt"] = Db.Iso(u.CreatedAt), ["lastLoginAt"] = u.LastLoginAt == null ? null : Db.Iso(u.LastLoginAt.Value), ["online"] = _hub.Managers.Values.Any(x => x.User.Id == u.Id) }); } return new JsonObject { ["users"] = arr }; } case "admin.users.create": { var username = J.RequireStr(msg, "username", "아이디", 32); if (!UsernameRule.IsMatch(username)) throw new UserError("아이디는 2~32자, 글자·숫자·. _ @ - 만 쓸 수 있습니다."); if (await db.FindUserAsync(username) != null) throw new UserError("이미 있는 아이디입니다."); var pw = J.Str(msg, "password"); CheckPassword(pw); var role = J.Str(msg, "role", "user") == "admin" ? "admin" : "user"; var newId = await db.CreateUserAsync(username, Secrets.HashPassword(pw), role, J.Bool(msg, "mustChangePassword") ?? true, J.Str(msg, "note")); await db.AuditAsync(me.Id, me.Username, null, "", "userCreate", username, "ok", "역할: " + role, m.Ip); return new JsonObject { ["userId"] = newId }; } case "admin.users.update": { var uid = J.Long(msg, "userId") ?? throw new UserError("사용자를 지정하세요."); var u = await db.GetUserAsync(uid) ?? throw new UserError("없는 사용자입니다."); string role = msg.ContainsKey("role") ? (J.Str(msg, "role") == "admin" ? "admin" : "user") : null; bool? enabled = J.Bool(msg, "enabled"); string note = msg.ContainsKey("note") ? J.Str(msg, "note") : null; bool losesAdmin = u.IsAdmin && u.Enabled && ((role != null && role != "admin") || enabled == false); if (losesAdmin && await db.CountEnabledAdminsAsync() <= 1) throw new UserError("마지막 관리자는 역할을 바꾸거나 사용 안 함으로 할 수 없습니다."); if (uid == me.Id && enabled == false) throw new UserError("자기 계정은 사용 안 함으로 할 수 없습니다."); await db.UpdateUserAsync(uid, role, enabled, note); var changes = new List(); if (role != null && role != u.Role) changes.Add("역할 " + u.Role + "→" + role); if (enabled != null && enabled != u.Enabled) changes.Add(enabled.Value ? "사용" : "사용 안 함"); if (note != null && note != u.Note) changes.Add("메모"); await db.AuditAsync(me.Id, me.Username, null, "", "userUpdate", u.Username, "ok", string.Join(", ", changes), m.Ip); if (enabled == false) await db.DeleteUserSessionsAsync(uid); await _hub.RefreshUserAsync(uid); return new JsonObject(); } case "admin.users.resetPassword": { var uid = J.Long(msg, "userId") ?? throw new UserError("사용자를 지정하세요."); var u = await db.GetUserAsync(uid) ?? throw new UserError("없는 사용자입니다."); var pw = J.Str(msg, "password"); CheckPassword(pw); await db.SetPasswordAsync(uid, Secrets.HashPassword(pw), true); await db.DeleteUserSessionsAsync(uid); await db.AuditAsync(me.Id, me.Username, null, "", "passwordReset", u.Username, "ok", "", m.Ip); if (uid != me.Id) await _hub.RefreshUserAsync(uid, "관리자가 비밀번호를 초기화했습니다. 다시 로그인하세요."); return new JsonObject(); } // ── 권한 ── case "admin.perms.get": { var uid = J.Long(msg, "userId") ?? throw new UserError("사용자를 지정하세요."); var u = await db.GetUserAsync(uid) ?? throw new UserError("없는 사용자입니다."); var perms = await db.GetPermsForUserAsync(uid); var arr = new JsonArray(); foreach (var d in await db.ListDevicesAsync()) { var p = u.IsAdmin ? Perm.All : perms.TryGetValue(d.Id, out var x) ? x : Perm.None; var o = p.ToJson(); o["deviceId"] = d.Id.ToString(); o["name"] = d.Name; o["hostname"] = d.Hostname; o["enabled"] = d.Enabled; arr.Add(o); } return new JsonObject { ["isAdmin"] = u.IsAdmin, ["devices"] = arr }; } case "admin.perms.set": { var uid = J.Long(msg, "userId") ?? throw new UserError("사용자를 지정하세요."); var u = await db.GetUserAsync(uid) ?? throw new UserError("없는 사용자입니다."); if (u.IsAdmin) throw new UserError("관리자는 모든 기기에 모든 권한이 있습니다."); var did = J.Guid(msg, "deviceId") ?? throw new UserError("기기를 지정하세요."); var d = await db.GetDeviceAsync(did) ?? throw new UserError("없는 기기입니다."); bool sc = J.Bool(msg, "screenControl") ?? false; bool sv = (J.Bool(msg, "screenView") ?? false) || sc; // 화면 제어는 화면 보기 포함 bool c = J.Bool(msg, "control") ?? false; bool v = (J.Bool(msg, "view") ?? false) || c || sv; // 무엇이든 되면 보기 포함 var p = new Perm(v, c, sv, sc); await db.SetPermAsync(uid, did, p); await db.AuditAsync(me.Id, me.Username, did, d.Name, "permChange", u.Username, "ok", $"보기={On(v)} 제어={On(c)} 화면보기={On(sv)} 화면제어={On(sc)}", m.Ip); await _hub.RefreshUserAsync(uid); return p.ToJson(); } // ── 기기 ── case "admin.devices.list": { var arr = new JsonArray(); foreach (var d in await db.ListDevicesAsync()) { _hub.Agents.TryGetValue(d.Id, out var a); arr.Add(new JsonObject { ["id"] = d.Id.ToString(), ["name"] = d.Name, ["hostname"] = d.Hostname, ["enabled"] = d.Enabled, ["online"] = a != null, ["note"] = d.Note, ["enrolledAt"] = Db.Iso(d.EnrolledAt), ["lastSeenAt"] = d.LastSeenAt == null ? null : Db.Iso(d.LastSeenAt.Value), ["publicIp"] = a?.PublicIp ?? d.LastPublicIp, ["lanIps"] = JsonNode.Parse(a?.LanIps.ToJsonString() ?? d.LanIpsJson), ["activeListen"] = string.IsNullOrEmpty(d.ActiveListenJson) ? null : JsonNode.Parse(d.ActiveListenJson), ["appVersion"] = d.AppVersion, ["os"] = d.OsVersion }); } return new JsonObject { ["devices"] = arr }; } case "admin.devices.update": { var did = J.Guid(msg, "deviceId") ?? throw new UserError("기기를 지정하세요."); var d = await db.GetDeviceAsync(did) ?? throw new UserError("없는 기기입니다."); string name = msg.ContainsKey("name") ? J.RequireStr(msg, "name", "기기 이름", 100) : null; string note = msg.ContainsKey("note") ? J.Str(msg, "note") : null; await db.RenameDeviceAsync(did, name, note); if (name != null && _hub.Agents.TryGetValue(did, out var a)) a.Name = name; await db.AuditAsync(me.Id, me.Username, did, name ?? d.Name, "deviceUpdate", d.Name, "ok", name != null && name != d.Name ? $"이름 {d.Name}→{name}" : "메모", m.Ip); await _hub.NotifyDevicesChangedAsync(); return new JsonObject(); } case "admin.devices.disable": case "admin.devices.enable": { var did = J.Guid(msg, "deviceId") ?? throw new UserError("기기를 지정하세요."); var d = await db.GetDeviceAsync(did) ?? throw new UserError("없는 기기입니다."); bool enable = op == "admin.devices.enable"; await db.SetDeviceEnabledAsync(did, enable); if (!enable) await _hub.KickAgentAsync(did, "서버에서 사용 안 함으로 설정했습니다."); await db.AuditAsync(me.Id, me.Username, did, d.Name, enable ? "deviceEnable" : "deviceDisable", "", "ok", "", m.Ip); await _hub.NotifyDevicesChangedAsync(); return new JsonObject(); } case "admin.devices.delete": { var did = J.Guid(msg, "deviceId") ?? throw new UserError("기기를 지정하세요."); var d = await db.GetDeviceAsync(did) ?? throw new UserError("없는 기기입니다."); await _hub.KickAgentAsync(did, "서버에서 기기를 삭제했습니다. 다시 등록해야 합니다."); await db.DeleteDeviceAsync(did); await db.AuditAsync(me.Id, me.Username, did, d.Name, "deviceDelete", "", "ok", "감사 로그·연결 기록은 남음", m.Ip); await _hub.NotifyDevicesChangedAsync(); return new JsonObject(); } // ── 등록 코드 ── case "admin.codes.create": { double hours = J.Long(msg, "validHours") ?? (long)_hub.Config.DefaultCodeLife.TotalHours; if (hours < 1 || hours > 24 * 90) throw new UserError("유효 시간은 1시간 ~ 90일 사이로 정하세요."); var code = Secrets.NewCode(); var note = J.Str(msg, "note"); var cid = await db.CreateCodeAsync(Secrets.Sha256(code), me.Id, TimeSpan.FromHours(hours), note); await db.AuditAsync(me.Id, me.Username, null, "", "codeCreate", code[..4] + "-****", "ok", $"유효 {hours}시간" + (note.Length > 0 ? ", " + note : ""), m.Ip); return new JsonObject { ["codeId"] = cid, ["code"] = code, ["expiresAt"] = Db.Iso(DateTime.UtcNow.AddHours(hours)) }; } case "admin.codes.list": return new JsonObject { ["codes"] = await db.ListCodesAsync() }; case "admin.codes.revoke": { var cid = J.Long(msg, "codeId") ?? throw new UserError("코드를 지정하세요."); if (!await db.RevokeCodeAsync(cid)) throw new UserError("없거나 이미 사용한 코드입니다."); await db.AuditAsync(me.Id, me.Username, null, "", "codeRevoke", cid.ToString(), "ok", "", m.Ip); return new JsonObject(); } // ── 조회 ── case "admin.audit.query": return new JsonObject { ["rows"] = await db.QueryAuditAsync(J.Time(msg, "from"), J.Time(msg, "to"), J.Long(msg, "userId"), J.Guid(msg, "deviceId"), J.Str(msg, "action"), J.Long(msg, "beforeId"), (int)(J.Long(msg, "limit") ?? 200)) }; case "admin.connections.query": return new JsonObject { ["rows"] = await db.QueryConnectionsAsync(J.Time(msg, "from"), J.Time(msg, "to"), J.Long(msg, "userId"), J.Guid(msg, "deviceId"), J.Long(msg, "beforeId"), (int)(J.Long(msg, "limit") ?? 200)) }; case "admin.server.info": return new JsonObject { ["version"] = _hub.Version, ["startedAt"] = Db.Iso(_hub.StartedAt), ["db"] = await db.PingAsync(), ["onlineDevices"] = _hub.Agents.Count, ["onlineManagers"] = _hub.Managers.Count, ["activeSessions"] = _hub.Sessions.Count, ["serverPublicKey"] = _hub.Signer.PublicKeyBase64 }; default: throw new UserError("알 수 없는 관리 요청: " + op); } } private static string On(bool b) => b ? "O" : "X"; private static void CheckPassword(string pw) { if (string.IsNullOrEmpty(pw) || pw.Length < 8) throw new UserError("비밀번호는 8자 이상이어야 합니다."); if (pw.Length > 128) throw new UserError("비밀번호가 너무 깁니다."); } }