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("비밀번호가 너무 깁니다.");
}
}