diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..ad80d41
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,5 @@
+**/bin
+**/obj
+.git
+.env
+docs
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..1e96857
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,18 @@
+# .env.example 를 .env 로 복사한 뒤 값을 바꾸세요. (.env 는 git에 올리지 않습니다)
+
+# PostgreSQL 비밀번호 (처음 실행 전에 정하고, 이후 바꾸지 마세요)
+POSTGRES_PASSWORD=여기에-긴-무작위-비밀번호
+
+# 첫 관리자 계정 — 사용자가 하나도 없을 때만 만들어짐. 첫 로그인 때 비밀번호 변경을 강제합니다.
+JJSM_ADMIN_USER=admin
+JJSM_ADMIN_PASSWORD=처음-비밀번호-8자이상
+
+# 리버스 프록시가 연결할 NAS 내부 포트 (127.0.0.1에만 열림)
+JJSM_PORT=21900
+
+# 등록 코드 기본 유효 시간 (시간 단위, 168 = 7일)
+JJSM_CODE_HOURS=168
+
+# DB 백업: 매일 몇 시에, 몇 개 보관
+BACKUP_HOUR=3
+BACKUP_KEEP=30
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..bdf1453
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,6 @@
+.env
+bin/
+obj/
+*.user
+.vs/
+saved.json
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..c4ef4ed
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,24 @@
+# JJServiceManagerRelay 서버 이미지 (NAS에서 직접 빌드)
+# 빌드: sudo docker-compose build
+
+FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
+WORKDIR /src
+COPY server/JJServiceManagerRelay.Server.csproj server/
+RUN dotnet restore server/JJServiceManagerRelay.Server.csproj
+COPY server/ server/
+RUN dotnet publish server/JJServiceManagerRelay.Server.csproj -c Release -o /out --no-restore /p:UseAppHost=false
+
+FROM mcr.microsoft.com/dotnet/aspnet:8.0
+ENV TZ=Asia/Seoul \
+ DOTNET_gcServer=0 \
+ JJSM_PORT=21900 \
+ JJSM_LOG_DIR=/app/logs
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends tzdata curl \
+ && rm -rf /var/lib/apt/lists/*
+WORKDIR /app
+COPY --from=build /out/ ./
+EXPOSE 21900
+HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
+ CMD curl -fsS http://localhost:21900/health || exit 1
+ENTRYPOINT ["dotnet", "jjsm-server.dll"]
diff --git a/README.md b/README.md
index f61b4f0..0df808c 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,36 @@
# JJServiceManagerRelay
-JJServiceManagerEnt 중계 서버 (Synology NAS Docker + PostgreSQL, `https://jjsm.joynala.com`)
+JJServiceManagerEnt 중계 서버 — Synology NAS Docker + PostgreSQL, `https://jjsm.joynala.com`
-- 설계서: [docs/설계서.md](docs/설계서.md)
-- 상태: 설계 검토 중
+모든 PC가 NAS로 바깥 방향 접속만 하면 서로 연결됩니다 (먼저 직접 연결, 안 되면 NAS 중계).
+서버는 통신 내용을 볼 수 없고(종단 간 TLS), 대상 PC 접속 비밀번호도 모릅니다.
+
+| 문서 | 내용 |
+|---|---|
+| [docs/설계서.md](docs/설계서.md) | 구조, 보안, 권한, DB, 메시지 |
+| [docs/NAS설치.md](docs/NAS설치.md) | NAS에 설치·운영·업데이트 |
+
+## 구성
+| 경로 | 내용 |
+|---|---|
+| `server/` | 중계 서버 (C# .NET 8, ASP.NET Core WebSocket, Npgsql) |
+| `server/Schema/` | DB 스키마 (서버가 시작할 때 자동 적용) |
+| `Dockerfile` | NAS에서 직접 빌드 (`dotnet/sdk:8.0` → `dotnet/aspnet:8.0`) |
+| `docker-compose.yml` | `jjsm-db` (PostgreSQL 16), `jjsm-server`, `jjsm-backup` |
+| `backup/backup.sh` | 매일 pg_dump, 최근 30개 보관 |
+| `.env.example` | 비밀번호·설정 예시 (`.env`로 복사해서 사용) |
+
+## 주소
+| 경로 | 용도 |
+|---|---|
+| `GET /health` | 상태 확인 |
+| `wss://…/ws/agent` | 대상 PC (등록·온라인 상태·연결 요청 받기·감사 로그 보고) |
+| `wss://…/ws/manager` | 관리 PC (로그인·기기 목록·연결 요청·[서버 관리]) |
+| `wss://…/relay/{세션}` | 중계 통로 |
+
+## 개발 환경에서 실행
+```sh
+cd server
+JJSM_DB="Host=127.0.0.1;Port=5432;Database=jjsm;Username=jjsm;Password=..." \
+JJSM_ADMIN_USER=admin JJSM_ADMIN_PASSWORD=처음비밀번호 dotnet run
+```
diff --git a/backup/backup.sh b/backup/backup.sh
new file mode 100755
index 0000000..0d50e64
--- /dev/null
+++ b/backup/backup.sh
@@ -0,0 +1,39 @@
+#!/bin/sh
+# JJServiceManagerRelay DB 백업 — 매일 BACKUP_HOUR시에 pg_dump, 최근 BACKUP_KEEP개 보관
+# 수동 백업: sudo docker exec jjsm-backup /bin/sh /backup.sh now
+
+KEEP="${BACKUP_KEEP:-30}"
+HOUR="${BACKUP_HOUR:-3}"
+
+run_backup() {
+ ts=$(date +%Y%m%d-%H%M%S)
+ file="/backup/jjsm-$ts.sql.gz"
+ if pg_dump --no-owner --no-privileges | gzip -9 > "$file.tmp"; then
+ mv "$file.tmp" "$file"
+ echo "$(date '+%F %T') 백업 완료: $file ($(du -h "$file" | cut -f1))"
+ else
+ rm -f "$file.tmp"
+ echo "$(date '+%F %T') 백업 실패" >&2
+ return 1
+ fi
+ # 오래된 백업 파일 정리 (최근 KEEP개만 남김)
+ ls -1t /backup/jjsm-*.sql.gz 2>/dev/null | tail -n +$((KEEP + 1)) | while read -r old; do
+ rm -f "$old" && echo "$(date '+%F %T') 오래된 백업 정리: $old"
+ done
+}
+
+if [ "$1" = "now" ]; then
+ run_backup
+ exit $?
+fi
+
+echo "$(date '+%F %T') 백업 대기 시작 (매일 ${HOUR}시, ${KEEP}개 보관)"
+until pg_isready -q; do sleep 5; done
+last=""
+while true; do
+ today=$(date +%F)
+ if [ "$(date +%H | sed 's/^0//')" = "$HOUR" ] && [ "$last" != "$today" ]; then
+ run_backup && last="$today"
+ fi
+ sleep 60
+done
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..02ca779
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,77 @@
+# JJServiceManagerRelay — Synology NAS (DSM 7.1 Docker 패키지, docker-compose v1)
+#
+# 처음 실행 / 업데이트:
+# cd /volume1/docker/jjsm/src
+# sudo docker-compose build
+# sudo docker-compose up -d
+#
+# 비밀번호는 같은 폴더의 .env 파일에 넣습니다 (.env.example 참고).
+# 데이터는 한 단계 위 폴더(/volume1/docker/jjsm/db, server, backup)에 저장됩니다.
+
+version: "3.8"
+
+services:
+ db:
+ image: postgres:16-alpine
+ container_name: jjsm-db
+ restart: unless-stopped
+ environment:
+ POSTGRES_DB: jjsm
+ POSTGRES_USER: jjsm
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD를 .env에 넣으세요}
+ TZ: Asia/Seoul
+ volumes:
+ - ../db:/var/lib/postgresql/data
+ networks: [jjsm]
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U jjsm -d jjsm"]
+ interval: 10s
+ timeout: 5s
+ retries: 10
+
+ server:
+ build: .
+ image: jjsm-server:latest
+ container_name: jjsm-server
+ restart: unless-stopped
+ depends_on:
+ - db
+ environment:
+ POSTGRES_HOST: db
+ POSTGRES_DB: jjsm
+ POSTGRES_USER: jjsm
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
+ JJSM_ADMIN_USER: ${JJSM_ADMIN_USER:-admin}
+ JJSM_ADMIN_PASSWORD: ${JJSM_ADMIN_PASSWORD:-}
+ JJSM_CODE_HOURS: ${JJSM_CODE_HOURS:-168}
+ TZ: Asia/Seoul
+ volumes:
+ - ../server/logs:/app/logs
+ ports:
+ # 리버스 프록시(DSM)만 접근하도록 NAS 자신에게만 엶
+ - "127.0.0.1:${JJSM_PORT:-21900}:21900"
+ networks: [jjsm]
+
+ backup:
+ image: postgres:16-alpine
+ container_name: jjsm-backup
+ restart: unless-stopped
+ depends_on:
+ - db
+ environment:
+ PGHOST: db
+ PGDATABASE: jjsm
+ PGUSER: jjsm
+ PGPASSWORD: ${POSTGRES_PASSWORD}
+ BACKUP_KEEP: ${BACKUP_KEEP:-30}
+ BACKUP_HOUR: ${BACKUP_HOUR:-3}
+ TZ: Asia/Seoul
+ volumes:
+ - ../backup:/backup
+ - ./backup/backup.sh:/backup.sh:ro
+ entrypoint: ["/bin/sh", "/backup.sh"]
+ networks: [jjsm]
+
+networks:
+ jjsm:
+ name: jjsm
diff --git a/docs/NAS설치.md b/docs/NAS설치.md
new file mode 100644
index 0000000..ea17ecc
--- /dev/null
+++ b/docs/NAS설치.md
@@ -0,0 +1,128 @@
+# Synology NAS 설치 안내 (DS1515+, DSM 7.1.1, Docker 패키지)
+
+중계 서버를 `https://jjsm.joynala.com`으로 띄우는 순서입니다. 처음 한 번만 하면 되고, 업데이트는 맨 아래 "업데이트"를 보세요.
+
+## 0. 준비 (한 번만)
+
+| 할 일 | 어디서 | 내용 |
+|---|---|---|
+| DNS | 도메인 관리 | `jjsm.joynala.com` → gitea와 같은 외부 IP (A 레코드) 또는 CNAME `gitea.joynala.com` |
+| 포트 포워딩 | Synology Router | 443, 80 → NAS (gitea 때문에 이미 되어 있으면 그대로) |
+| SSH | DSM 제어판 → 터미널 및 SNMP | SSH 서비스 활성화 |
+| Docker | DSM 패키지 센터 | Docker 설치되어 있어야 함 |
+
+## 1. 폴더 만들기 + 소스 받기
+
+NAS에 SSH로 접속합니다 (Windows: `ssh 관리자계정@NAS내부IP`).
+
+```sh
+sudo mkdir -p /volume1/docker/jjsm
+cd /volume1/docker/jjsm
+sudo mkdir -p db server/logs backup
+
+# 소스 받기 (Gitea 압축 파일)
+sudo curl -fL -o src.tar.gz https://gitea.joynala.com/diadmin/JJServiceManagerRelay/archive/main.tar.gz
+sudo tar xzf src.tar.gz # → jjservicemanagerrelay 폴더가 생김
+sudo mv jjservicemanagerrelay src
+sudo rm src.tar.gz
+```
+
+폴더 구성:
+```
+/volume1/docker/jjsm/
+ ├─ src/ ← 소스 + docker-compose.yml + .env
+ ├─ db/ ← PostgreSQL 데이터 (지우지 마세요)
+ ├─ server/logs ← 서버 로그 (날짜별, 지우지 않음)
+ └─ backup/ ← 매일 DB 백업 (최근 30개)
+```
+
+## 2. 비밀번호 파일 (.env)
+
+```sh
+cd /volume1/docker/jjsm/src
+sudo cp .env.example .env
+sudo vi .env
+```
+
+| 항목 | 넣을 값 |
+|---|---|
+| `POSTGRES_PASSWORD` | DB 비밀번호 — 길고 무작위로. **처음 실행 뒤에는 바꾸지 마세요** (DB에 이미 저장됨) |
+| `JJSM_ADMIN_USER` | 첫 관리자 아이디 (기본 `admin`) |
+| `JJSM_ADMIN_PASSWORD` | 첫 관리자 비밀번호 (8자 이상). 첫 로그인 때 바꾸게 됩니다 |
+| 나머지 | 기본값 그대로 (포트 21900, 등록 코드 7일, 백업 매일 3시 30개) |
+
+```sh
+sudo chmod 600 .env
+```
+
+## 3. 빌드 + 실행
+
+```sh
+cd /volume1/docker/jjsm/src
+sudo docker-compose build # 처음엔 수 분~10분 (.NET SDK 이미지를 받아 빌드)
+sudo docker-compose up -d
+sudo docker-compose ps # jjsm-db, jjsm-server, jjsm-backup 모두 Up
+sudo docker logs jjsm-server # "첫 관리자 계정을 만들었습니다: admin" 확인
+curl -s http://127.0.0.1:21900/health
+# {"status":"ok","version":"1.0.0","db":true,...}
+```
+
+## 4. 인증서 (Let's Encrypt)
+
+DSM 제어판 → 보안 → 인증서 → 추가 → 새 인증서 추가 → Let's Encrypt에서 인증서 얻기
+- 도메인 이름: `jjsm.joynala.com`
+- 이메일: 관리자 이메일
+
+## 5. 리버스 프록시
+
+DSM 제어판 → 로그인 포털 → 고급 → 역방향 프록시 → 생성
+
+| 항목 | 값 |
+|---|---|
+| 설명 | jjsm |
+| 소스 프로토콜 / 호스트 이름 / 포트 | HTTPS / `jjsm.joynala.com` / 443 |
+| 대상 프로토콜 / 호스트 이름 / 포트 | HTTP / `localhost` / 21900 |
+| **사용자 지정 머리글** | **생성 → WebSocket** (Upgrade, Connection 두 줄이 자동으로 들어감) — 빠지면 연결 안 됨 |
+| 고급 설정 | 프록시 연결 시간 초과 / 전송 / 읽기 시간 초과: **3600**초 권장 |
+
+그다음 제어판 → 보안 → 인증서 → 설정 → `jjsm.joynala.com` 항목에 4번 인증서를 지정합니다.
+
+확인 (밖에서, 예: 휴대폰 LTE): 브라우저로 `https://jjsm.joynala.com/health` → `{"status":"ok",...}`
+
+## 6. 프로그램에서 연결
+
+JJServiceManagerEnt 쪽 기능이 준비되면 안내합니다 (관리 PC: 서버 주소 + 아이디/비밀번호, 대상 PC: 등록 코드).
+
+## 운영
+
+| 할 일 | 명령 |
+|---|---|
+| 상태 | `cd /volume1/docker/jjsm/src && sudo docker-compose ps` |
+| 서버 로그 | `sudo docker logs --tail 100 jjsm-server` 또는 `/volume1/docker/jjsm/server/logs/날짜.log` |
+| 지금 바로 DB 백업 | `sudo docker exec jjsm-backup /bin/sh /backup.sh now` |
+| 다시 시작 | `sudo docker-compose restart server` |
+| 멈춤 | `sudo docker-compose stop` |
+
+### 업데이트
+```sh
+cd /volume1/docker/jjsm
+sudo docker exec jjsm-backup /bin/sh /backup.sh now # 먼저 백업
+sudo mv src src.old
+sudo curl -fL -o src.tar.gz https://gitea.joynala.com/diadmin/JJServiceManagerRelay/archive/main.tar.gz
+sudo tar xzf src.tar.gz && sudo mv jjservicemanagerrelay src && sudo rm src.tar.gz
+sudo cp src.old/.env src/.env
+cd src && sudo docker-compose build && sudo docker-compose up -d
+```
+DB 구조가 바뀌면 서버가 시작할 때 자동으로 고칩니다 (기존 데이터 유지).
+
+### 백업에서 되돌리기
+```sh
+cd /volume1/docker/jjsm/src
+sudo docker-compose stop server
+sudo docker exec jjsm-db createdb -U jjsm jjsm_restore_test
+gunzip -c ../backup/jjsm-YYYYMMDD-HHMMSS.sql.gz | sudo docker exec -i jjsm-db psql -U jjsm -d jjsm_restore_test # 먼저 시험용 DB에
+# 시험이 괜찮으면 원래 DB를 바꾸는 방법은 그때 따로 안내합니다.
+```
+
+### 관리자 비밀번호를 잊었을 때
+다른 관리자 계정으로 [서버 관리] → 계정 → 비밀번호 초기화. 관리자가 한 명뿐이면 문의하세요 (DB에서 직접 초기화하는 방법 안내).
diff --git a/docs/설계서.md b/docs/설계서.md
index fb12516..10c49ac 100644
--- a/docs/설계서.md
+++ b/docs/설계서.md
@@ -273,12 +273,10 @@ server_settings (key text primary key, value text not null)
```
/volume1/docker/jjsm/
- ├─ docker-compose.yml
- ├─ .env ← 비밀번호 (POSTGRES_PASSWORD, JJSM_ADMIN_PASSWORD)
- ├─ server/ ← 중계 서버 로그
+ ├─ src/ ← JJServiceManagerRelay 소스 (Gitea 압축 파일) + docker-compose.yml + .env(비밀번호)
├─ db/ ← PostgreSQL 데이터
- ├─ backup/ ← 매일 pg_dump, 최근 30개 보관 (오래된 백업 파일부터 정리)
- └─ src/ ← JJServiceManagerRelay 소스 (Gitea에서 받음, 빌드용)
+ ├─ server/logs/ ← 중계 서버 로그 (날짜별, 지우지 않음)
+ └─ backup/ ← 매일 pg_dump, 최근 30개 보관 (오래된 백업 파일부터 정리)
```
| 컨테이너 | 이미지 | 포트 |
@@ -287,7 +285,7 @@ server_settings (key text primary key, value text not null)
| `jjsm-server` | NAS에서 직접 빌드 (`dotnet/sdk:8.0`로 빌드 → `dotnet/aspnet:8.0`으로 실행) | `127.0.0.1:21900` → 리버스 프록시만 접근 |
| `jjsm-backup` | `postgres:16-alpine` + cron 스크립트 | 없음 |
-- 처음 실행·업데이트: SSH → `cd /volume1/docker/jjsm && sudo docker-compose build && sudo docker-compose up -d`
+- 처음 실행·업데이트: SSH → `cd /volume1/docker/jjsm/src && sudo docker-compose build && sudo docker-compose up -d` (자세한 순서: [NAS설치.md](NAS설치.md))
- 빌드는 NAS에서 직접 한다 (DS1515+ 기준 수 분~10분 정도 예상). 업데이트 때는 `src/`를 새 버전으로 바꾸고 다시 빌드.
- 리버스 프록시: HTTPS `jjsm.joynala.com:443` → HTTP `localhost:21900`, 사용자 지정 머리글 **WebSocket**
- 서버 상태 확인: `https://jjsm.joynala.com/health`
diff --git a/server/AgentHandler.cs b/server/AgentHandler.cs
new file mode 100644
index 0000000..fd01909
--- /dev/null
+++ b/server/AgentHandler.cs
@@ -0,0 +1,258 @@
+using System.Net.WebSockets;
+using System.Security.Cryptography;
+using System.Text.Json.Nodes;
+
+namespace JJServiceManagerRelay;
+
+/// /ws/agent — 대상 PC
+public sealed class AgentHandler
+{
+ private readonly Hub _hub;
+ public AgentHandler(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);
+ AgentConn agent = null;
+ try
+ {
+ agent = await AuthenticateAsync(ws, ip, ctx.RequestAborted);
+ if (agent == null) return;
+
+ // 같은 기기로 이미 접속해 있으면 이전 연결을 끊음
+ if (_hub.Agents.TryGetValue(agent.DeviceId, out var old))
+ {
+ await old.Ws.TrySendAsync(new JsonObject { ["event"] = "replaced", ["reason"] = "같은 기기로 다른 곳에서 접속했습니다." });
+ old.Cts.Cancel();
+ }
+ _hub.Agents[agent.DeviceId] = agent;
+ _hub.Log.Info($"대상 PC 접속: {agent.Name} ({agent.DeviceId}) {ip}");
+ // 목록에 넣은 뒤에 응답 (응답 직후 들어오는 요청·교체가 이 연결을 보도록)
+ await ws.SendAsync(agent.PendingReply, ctx.RequestAborted);
+ agent.PendingReply = null;
+ if (agent.NotifyDevicesChanged) await _hub.NotifyDevicesChangedAsync();
+ await _hub.NotifyPresenceAsync(agent.DeviceId, agent.Name, agent.Hostname, true);
+
+ using var linked = CancellationTokenSource.CreateLinkedTokenSource(ctx.RequestAborted, agent.Cts.Token);
+ var ct = linked.Token;
+ var lastTouch = DateTime.UtcNow;
+ while (!ct.IsCancellationRequested)
+ {
+ JsonObject msg;
+ using (var idle = CancellationTokenSource.CreateLinkedTokenSource(ct))
+ {
+ idle.CancelAfter(TimeSpan.FromSeconds(120)); // 2분 동안 아무것도 없으면 끊김으로 봄
+ msg = await ws.ReceiveAsync(idle.Token);
+ }
+ if (msg == null) break;
+ var id = J.Long(msg, "id");
+ try
+ {
+ var res = await HandleAsync(agent, msg, ip);
+ if (id != null) await ws.SendAsync(WsJson.Ok(id, res), ct);
+ }
+ catch (UserError ue)
+ {
+ if (id != null) await ws.SendAsync(WsJson.Error(id, ue.Message), ct);
+ }
+ if (DateTime.UtcNow - lastTouch > TimeSpan.FromMinutes(1))
+ {
+ lastTouch = DateTime.UtcNow;
+ await _hub.Db.TouchDeviceAsync(agent.DeviceId);
+ }
+ }
+ }
+ 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 (agent != null)
+ {
+ // 다른 연결로 바뀐 경우가 아니면 목록에서 뺌
+ if (_hub.Agents.TryGetValue(agent.DeviceId, out var cur) && ReferenceEquals(cur, agent))
+ {
+ _hub.Agents.TryRemove(agent.DeviceId, out _);
+ _hub.Log.Info($"대상 PC 연결 끊김: {agent.Name} ({agent.DeviceId})");
+ try { await _hub.Db.TouchDeviceAsync(agent.DeviceId); } catch { }
+ await _hub.NotifyPresenceAsync(agent.DeviceId, agent.Name, agent.Hostname, false);
+ }
+ }
+ await ws.CloseAsync("bye");
+ }
+ }
+
+ /// 첫 메시지: enroll(등록 코드) 또는 auth(기기 키). 15초 안에 와야 함
+ private async Task AuthenticateAsync(WsJson ws, string ip, CancellationToken ct)
+ {
+ using var first = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ first.CancelAfter(TimeSpan.FromSeconds(15));
+ 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 ipKey = "agent-ip:" + ip;
+
+ if (_hub.Throttle.IsBlocked(ipKey))
+ {
+ await ws.SendAsync(WsJson.Error(id, "실패가 너무 많습니다. 15분 뒤에 다시 시도하세요."), ct);
+ return null;
+ }
+
+ if (op == "enroll")
+ {
+ var code = Secrets.NormalizeCode(J.Str(msg, "code"));
+ var hostname = Clip(J.Str(msg, "hostname"), 100);
+ if (hostname.Length == 0) hostname = "PC";
+ var key = RandomNumberGenerator.GetBytes(32);
+ var keyText = Secrets.Base64Url(key);
+ var (deviceId, createdBy, error) = await _hub.Db.EnrollAsync(Secrets.Sha256(code), Guid.NewGuid(), hostname,
+ Secrets.Sha256(keyText), Clip(J.Str(msg, "os"), 200), Clip(J.Str(msg, "appVersion"), 50));
+ if (error != null)
+ {
+ _hub.Throttle.Fail(ipKey);
+ await _hub.Db.AuditAsync(null, "", null, hostname, "deviceEnroll", code.Length > 4 ? code[..4] + "-****" : "", "fail", error, ip);
+ await ws.SendAsync(WsJson.Error(id, error), ct);
+ return null;
+ }
+ _hub.Throttle.Success(ipKey);
+ var creator = createdBy == null ? null : await _hub.Db.GetUserAsync(createdBy.Value);
+ await _hub.Db.AuditAsync(createdBy, creator?.Username ?? "", deviceId, hostname, "deviceEnroll", "", "ok",
+ "등록 코드로 기기 등록", ip);
+ _hub.Log.Info($"기기 등록: {hostname} ({deviceId}) {ip}");
+ var agent = new AgentConn { DeviceId = deviceId, Name = hostname, Hostname = hostname, Ws = ws, PublicIp = ip };
+ ApplyState(agent, msg);
+ await SaveStateAsync(agent, msg);
+ agent.PendingReply = WsJson.Ok(id, new JsonObject
+ {
+ ["deviceId"] = deviceId.ToString(),
+ ["deviceKey"] = keyText,
+ ["serverPublicKey"] = _hub.Signer.PublicKeyBase64,
+ ["name"] = hostname,
+ ["publicIp"] = ip
+ });
+ agent.NotifyDevicesChanged = true;
+ return agent;
+ }
+
+ if (op == "auth")
+ {
+ var deviceId = J.Guid(msg, "deviceId");
+ var keyText = J.Str(msg, "deviceKey");
+ var dev = deviceId == null ? null : await _hub.Db.GetDeviceAsync(deviceId.Value);
+ bool ok = dev != null && CryptographicOperations.FixedTimeEquals(dev.KeyHash, Secrets.Sha256(keyText));
+ if (!ok)
+ {
+ _hub.Throttle.Fail(ipKey);
+ await ws.SendAsync(WsJson.Error(id, "등록되지 않은 기기이거나 기기 키가 맞지 않습니다. 다시 등록하세요.")
+ .Also(o => o["code"] = "notEnrolled"), ct);
+ return null;
+ }
+ if (!dev.Enabled)
+ {
+ await ws.SendAsync(WsJson.Error(id, "서버에서 사용 안 함으로 설정된 기기입니다.").Also(o => o["code"] = "disabled"), ct);
+ return null;
+ }
+ _hub.Throttle.Success(ipKey);
+ var agent = new AgentConn
+ {
+ DeviceId = dev.Id,
+ Name = dev.Name,
+ Hostname = string.IsNullOrWhiteSpace(J.Str(msg, "hostname")) ? dev.Hostname : Clip(J.Str(msg, "hostname"), 100),
+ Ws = ws,
+ PublicIp = ip
+ };
+ ApplyState(agent, msg);
+ await SaveStateAsync(agent, msg);
+ agent.PendingReply = WsJson.Ok(id, new JsonObject
+ {
+ ["serverPublicKey"] = _hub.Signer.PublicKeyBase64,
+ ["name"] = dev.Name,
+ ["publicIp"] = ip
+ });
+ return agent;
+ }
+
+ await ws.SendAsync(WsJson.Error(id, "먼저 enroll 또는 auth를 보내야 합니다."), ct);
+ return null;
+ }
+
+ private async Task HandleAsync(AgentConn agent, JsonObject msg, string ip)
+ {
+ switch (J.Str(msg, "op"))
+ {
+ case "ping":
+ return new JsonObject { ["time"] = Db.Iso(DateTime.UtcNow) };
+
+ case "update":
+ ApplyState(agent, msg);
+ await SaveStateAsync(agent, msg);
+ return new JsonObject();
+
+ case "audit":
+ {
+ // 대상 PC가 실제로 실행한 작업 (티켓에서 확인한 사용자)
+ var action = Clip(J.Str(msg, "action"), 50);
+ if (action.Length == 0) throw new UserError("action이 없습니다.");
+ await _hub.Db.AuditAsync(J.Long(msg, "userId"), Clip(J.Str(msg, "username"), 100), agent.DeviceId, agent.Name,
+ action, J.Str(msg, "target"), Clip(J.Str(msg, "result", "ok"), 20), J.Str(msg, "detail"), J.Str(msg, "sourceIp", ""));
+ return new JsonObject();
+ }
+
+ default:
+ throw new UserError("알 수 없는 요청: " + J.Str(msg, "op"));
+ }
+ }
+
+ private static void ApplyState(AgentConn a, JsonObject msg)
+ {
+ var host = Clip(J.Str(msg, "hostname"), 100);
+ if (host.Length > 0) a.Hostname = host;
+ if (msg["lanIps"] is JsonArray lan)
+ {
+ var clean = new JsonArray();
+ foreach (var n in lan.Take(16))
+ {
+ var s = n?.ToString() ?? "";
+ if (System.Net.IPAddress.TryParse(s, out _)) clean.Add(s);
+ }
+ a.LanIps = clean;
+ }
+ if (msg.ContainsKey("activeListen"))
+ {
+ if (msg["activeListen"] is JsonObject al && J.Long(al, "port") is long p && p > 0 && p < 65536)
+ {
+ var mode = J.Str(al, "mode") == "Ssh" ? "Ssh" : "Port";
+ a.ActiveListen = new JsonObject { ["mode"] = mode, ["port"] = p };
+ }
+ else
+ {
+ a.ActiveListen = null;
+ }
+ }
+ var av = Clip(J.Str(msg, "appVersion"), 50);
+ if (av.Length > 0) a.AppVersion = av;
+ }
+
+ private Task SaveStateAsync(AgentConn a, JsonObject msg) =>
+ _hub.Db.UpdateDeviceStateAsync(a.DeviceId, a.Hostname, a.PublicIp, a.LanIps.ToJsonString(),
+ a.ActiveListen?.ToJsonString(), a.AppVersion, Clip(J.Str(msg, "os"), 200));
+
+ private static string Clip(string s, int max)
+ {
+ s = (s ?? "").Trim();
+ return s.Length <= max ? s : s[..max];
+ }
+}
+
+public static class JsonObjectExtensions
+{
+ public static JsonObject Also(this JsonObject o, Action a)
+ {
+ a(o);
+ return o;
+ }
+}
diff --git a/server/Common.cs b/server/Common.cs
new file mode 100644
index 0000000..246d957
--- /dev/null
+++ b/server/Common.cs
@@ -0,0 +1,264 @@
+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;
+ }
+}
diff --git a/server/Db.cs b/server/Db.cs
new file mode 100644
index 0000000..99c14c7
--- /dev/null
+++ b/server/Db.cs
@@ -0,0 +1,696 @@
+using System.Reflection;
+using System.Text.Json.Nodes;
+using Npgsql;
+using NpgsqlTypes;
+
+namespace JJServiceManagerRelay;
+
+public sealed record UserRow(long Id, string Username, string PasswordHash, string Role, bool MustChangePassword,
+ bool Enabled, string Note, DateTime CreatedAt, DateTime? LastLoginAt)
+{
+ public bool IsAdmin => Role == "admin";
+}
+
+public sealed record DeviceRow(Guid Id, string Name, string Hostname, byte[] KeyHash, bool Enabled, string Note,
+ DateTime EnrolledAt, DateTime? LastSeenAt, string LastPublicIp, string LanIpsJson, string ActiveListenJson,
+ string AppVersion, string OsVersion);
+
+public sealed record Perm(bool View, bool Control, bool ScreenView, bool ScreenControl)
+{
+ public static readonly Perm All = new(true, true, true, true);
+ public static readonly Perm None = new(false, false, false, false);
+ public bool Any => View || Control || ScreenView || ScreenControl;
+
+ public JsonObject ToJson() => new()
+ {
+ ["view"] = View,
+ ["control"] = Control,
+ ["screenView"] = ScreenView,
+ ["screenControl"] = ScreenControl
+ };
+}
+
+/// PostgreSQL 접근
+public sealed class Db
+{
+ private readonly NpgsqlDataSource _ds;
+ private readonly ServerLog _log;
+
+ public Db(string connectionString, ServerLog log)
+ {
+ _ds = NpgsqlDataSource.Create(connectionString);
+ _log = log;
+ }
+
+ public async Task OpenAsync() => await _ds.OpenConnectionAsync();
+
+ // ───────────────────────── 시작: DB 기다리기 + 스키마 ─────────────────────────
+
+ public async Task InitializeAsync(CancellationToken ct)
+ {
+ // PostgreSQL 컨테이너가 늦게 뜰 수 있으므로 최대 2분 기다림
+ for (int i = 1; ; i++)
+ {
+ try
+ {
+ await using var c = await _ds.OpenConnectionAsync(ct);
+ break;
+ }
+ catch (Exception ex) when (i < 60 && !ct.IsCancellationRequested)
+ {
+ _log.Warn($"DB 연결 대기 중 ({i}/60): {ex.Message}");
+ await Task.Delay(2000, ct);
+ }
+ }
+
+ await using var conn = await _ds.OpenConnectionAsync(ct);
+ // 여러 서버가 동시에 올라와도 한 번만 적용되도록 잠금
+ await Exec(conn, "SELECT pg_advisory_lock(7200421)");
+ try
+ {
+ await Exec(conn, "CREATE TABLE IF NOT EXISTS server_settings (key text PRIMARY KEY, value text NOT NULL)");
+ int version = int.TryParse(await GetSetting(conn, "schema_version"), out var v) ? v : 0;
+ var asm = Assembly.GetExecutingAssembly();
+ var scripts = asm.GetManifestResourceNames()
+ .Where(n => n.Contains(".Schema.") && n.EndsWith(".sql"))
+ .OrderBy(n => n, StringComparer.Ordinal)
+ .ToList();
+ foreach (var name in scripts)
+ {
+ var file = name.Substring(name.IndexOf(".Schema.", StringComparison.Ordinal) + 8);
+ int num = int.Parse(file.Split('_')[0]);
+ if (num <= version) continue;
+ using var sr = new StreamReader(asm.GetManifestResourceStream(name)!);
+ var sql = await sr.ReadToEndAsync(ct);
+ await using var tx = await conn.BeginTransactionAsync(ct);
+ await using (var cmd = new NpgsqlCommand(sql, conn, tx)) await cmd.ExecuteNonQueryAsync(ct);
+ await using (var cmd = new NpgsqlCommand(
+ "INSERT INTO server_settings(key, value) VALUES ('schema_version', @v) ON CONFLICT (key) DO UPDATE SET value = excluded.value", conn, tx))
+ {
+ cmd.Parameters.AddWithValue("v", num.ToString());
+ await cmd.ExecuteNonQueryAsync(ct);
+ }
+ await tx.CommitAsync(ct);
+ _log.Info($"DB 스키마 적용: {file}");
+ version = num;
+ }
+ }
+ finally
+ {
+ await Exec(conn, "SELECT pg_advisory_unlock(7200421)");
+ }
+ }
+
+ private static async Task Exec(NpgsqlConnection c, string sql)
+ {
+ await using var cmd = new NpgsqlCommand(sql, c);
+ await cmd.ExecuteNonQueryAsync();
+ }
+
+ private static async Task GetSetting(NpgsqlConnection c, string key)
+ {
+ await using var cmd = new NpgsqlCommand("SELECT value FROM server_settings WHERE key = @k", c);
+ cmd.Parameters.AddWithValue("k", key);
+ return (string)await cmd.ExecuteScalarAsync();
+ }
+
+ public async Task GetSettingAsync(string key)
+ {
+ await using var c = await OpenAsync();
+ return await GetSetting(c, key);
+ }
+
+ /// 없을 때만 저장하고, 실제 저장된 값을 돌려줌
+ public async Task GetOrCreateSettingAsync(string key, Func create)
+ {
+ await using var c = await OpenAsync();
+ await using (var cmd = new NpgsqlCommand("INSERT INTO server_settings(key, value) VALUES (@k, @v) ON CONFLICT (key) DO NOTHING", c))
+ {
+ cmd.Parameters.AddWithValue("k", key);
+ cmd.Parameters.AddWithValue("v", create());
+ await cmd.ExecuteNonQueryAsync();
+ }
+ return await GetSetting(c, key);
+ }
+
+ public async Task PingAsync()
+ {
+ try
+ {
+ await using var c = await OpenAsync();
+ await using var cmd = new NpgsqlCommand("SELECT 1", c);
+ await cmd.ExecuteScalarAsync();
+ return true;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ // ───────────────────────── 사용자 ─────────────────────────
+
+ private const string UserCols = "id, username, password_hash, role, must_change_password, enabled, note, created_at, last_login_at";
+
+ private static UserRow ReadUser(NpgsqlDataReader r) => new(
+ r.GetInt64(0), r.GetString(1), r.GetString(2), r.GetString(3), r.GetBoolean(4), r.GetBoolean(5),
+ r.GetString(6), r.GetDateTime(7), r.IsDBNull(8) ? null : r.GetDateTime(8));
+
+ public async Task CountUsersAsync()
+ {
+ await using var c = await OpenAsync();
+ await using var cmd = new NpgsqlCommand("SELECT count(*) FROM users", c);
+ return (long)await cmd.ExecuteScalarAsync();
+ }
+
+ public async Task FindUserAsync(string username)
+ {
+ await using var c = await OpenAsync();
+ await using var cmd = new NpgsqlCommand($"SELECT {UserCols} FROM users WHERE lower(username) = lower(@u)", c);
+ cmd.Parameters.AddWithValue("u", username ?? "");
+ await using var r = await cmd.ExecuteReaderAsync();
+ return await r.ReadAsync() ? ReadUser(r) : null;
+ }
+
+ public async Task GetUserAsync(long id)
+ {
+ await using var c = await OpenAsync();
+ await using var cmd = new NpgsqlCommand($"SELECT {UserCols} FROM users WHERE id = @id", c);
+ cmd.Parameters.AddWithValue("id", id);
+ await using var r = await cmd.ExecuteReaderAsync();
+ return await r.ReadAsync() ? ReadUser(r) : null;
+ }
+
+ public async Task> ListUsersAsync()
+ {
+ var list = new List();
+ await using var c = await OpenAsync();
+ await using var cmd = new NpgsqlCommand($"SELECT {UserCols} FROM users ORDER BY lower(username)", c);
+ await using var r = await cmd.ExecuteReaderAsync();
+ while (await r.ReadAsync()) list.Add(ReadUser(r));
+ return list;
+ }
+
+ public async Task CreateUserAsync(string username, string passwordHash, string role, bool mustChange, string note)
+ {
+ await using var c = await OpenAsync();
+ await using var cmd = new NpgsqlCommand(
+ "INSERT INTO users(username, password_hash, role, must_change_password, note) VALUES (@u, @p, @r, @m, @n) RETURNING id", c);
+ cmd.Parameters.AddWithValue("u", username);
+ cmd.Parameters.AddWithValue("p", passwordHash);
+ cmd.Parameters.AddWithValue("r", role);
+ cmd.Parameters.AddWithValue("m", mustChange);
+ cmd.Parameters.AddWithValue("n", note ?? "");
+ return (long)await cmd.ExecuteScalarAsync();
+ }
+
+ public async Task UpdateUserAsync(long id, string role, bool? enabled, string note)
+ {
+ await using var c = await OpenAsync();
+ await using var cmd = new NpgsqlCommand(
+ "UPDATE users SET role = coalesce(@r, role), enabled = coalesce(@e, enabled), note = coalesce(@n, note) WHERE id = @id", c);
+ cmd.Parameters.AddWithValue("id", id);
+ cmd.Parameters.Add(new NpgsqlParameter("r", NpgsqlDbType.Text) { Value = (object)role ?? DBNull.Value });
+ cmd.Parameters.Add(new NpgsqlParameter("e", NpgsqlDbType.Boolean) { Value = (object)enabled ?? DBNull.Value });
+ cmd.Parameters.Add(new NpgsqlParameter("n", NpgsqlDbType.Text) { Value = (object)note ?? DBNull.Value });
+ await cmd.ExecuteNonQueryAsync();
+ }
+
+ public async Task SetPasswordAsync(long id, string passwordHash, bool mustChange)
+ {
+ await using var c = await OpenAsync();
+ await using var cmd = new NpgsqlCommand("UPDATE users SET password_hash = @p, must_change_password = @m WHERE id = @id", c);
+ cmd.Parameters.AddWithValue("id", id);
+ cmd.Parameters.AddWithValue("p", passwordHash);
+ cmd.Parameters.AddWithValue("m", mustChange);
+ await cmd.ExecuteNonQueryAsync();
+ }
+
+ public async Task TouchLoginAsync(long id)
+ {
+ await using var c = await OpenAsync();
+ await using var cmd = new NpgsqlCommand("UPDATE users SET last_login_at = now() WHERE id = @id", c);
+ cmd.Parameters.AddWithValue("id", id);
+ await cmd.ExecuteNonQueryAsync();
+ }
+
+ public async Task CountEnabledAdminsAsync()
+ {
+ await using var c = await OpenAsync();
+ await using var cmd = new NpgsqlCommand("SELECT count(*) FROM users WHERE role = 'admin' AND enabled", c);
+ return (long)await cmd.ExecuteScalarAsync();
+ }
+
+ // ───────────────────────── 로그인 토큰 ─────────────────────────
+
+ public async Task CreateSessionAsync(byte[] tokenHash, long userId, TimeSpan life, string ip, string host)
+ {
+ await using var c = await OpenAsync();
+ await using var cmd = new NpgsqlCommand(
+ "INSERT INTO user_sessions(token_hash, user_id, expires_at, client_ip, client_host) VALUES (@t, @u, now() + @l, @ip, @h)", c);
+ cmd.Parameters.AddWithValue("t", tokenHash);
+ cmd.Parameters.AddWithValue("u", userId);
+ cmd.Parameters.AddWithValue("l", life);
+ cmd.Parameters.AddWithValue("ip", ip ?? "");
+ cmd.Parameters.AddWithValue("h", host ?? "");
+ await cmd.ExecuteNonQueryAsync();
+ }
+
+ /// 유효한 토큰이면 사용자 ID (만료 시간 연장)
+ public async Task UseSessionAsync(byte[] tokenHash, TimeSpan life)
+ {
+ await using var c = await OpenAsync();
+ await using var cmd = new NpgsqlCommand(
+ "UPDATE user_sessions SET expires_at = now() + @l WHERE token_hash = @t AND expires_at > now() RETURNING user_id", c);
+ cmd.Parameters.AddWithValue("t", tokenHash);
+ cmd.Parameters.AddWithValue("l", life);
+ var v = await cmd.ExecuteScalarAsync();
+ return v == null ? null : (long)v;
+ }
+
+ public async Task DeleteSessionAsync(byte[] tokenHash)
+ {
+ await using var c = await OpenAsync();
+ await using var cmd = new NpgsqlCommand("DELETE FROM user_sessions WHERE token_hash = @t", c);
+ cmd.Parameters.AddWithValue("t", tokenHash);
+ await cmd.ExecuteNonQueryAsync();
+ }
+
+ public async Task DeleteUserSessionsAsync(long userId, byte[] exceptTokenHash = null)
+ {
+ await using var c = await OpenAsync();
+ await using var cmd = new NpgsqlCommand(
+ "DELETE FROM user_sessions WHERE user_id = @u AND (@e::bytea IS NULL OR token_hash <> @e)", c);
+ cmd.Parameters.AddWithValue("u", userId);
+ cmd.Parameters.Add(new NpgsqlParameter("e", NpgsqlDbType.Bytea) { Value = (object)exceptTokenHash ?? DBNull.Value });
+ await cmd.ExecuteNonQueryAsync();
+ }
+
+ /// 만료된 로그인 토큰 정리 (로그가 아니라 인증 정보이므로 정리함)
+ public async Task PurgeExpiredSessionsAsync()
+ {
+ await using var c = await OpenAsync();
+ await using var cmd = new NpgsqlCommand("DELETE FROM user_sessions WHERE expires_at < now() - interval '1 day'", c);
+ await cmd.ExecuteNonQueryAsync();
+ }
+
+ // ───────────────────────── 기기 ─────────────────────────
+
+ private const string DeviceCols = "id, name, hostname, key_hash, enabled, note, enrolled_at, last_seen_at, coalesce(last_public_ip, ''), lan_ips::text, coalesce(active_listen::text, ''), coalesce(app_version, ''), coalesce(os_version, '')";
+
+ private static DeviceRow ReadDevice(NpgsqlDataReader r) => new(
+ r.GetGuid(0), r.GetString(1), r.GetString(2), (byte[])r[3], r.GetBoolean(4), r.GetString(5), r.GetDateTime(6),
+ r.IsDBNull(7) ? null : r.GetDateTime(7), r.GetString(8), r.GetString(9), r.GetString(10), r.GetString(11), r.GetString(12));
+
+ public async Task GetDeviceAsync(Guid id)
+ {
+ await using var c = await OpenAsync();
+ await using var cmd = new NpgsqlCommand($"SELECT {DeviceCols} FROM devices WHERE id = @id", c);
+ cmd.Parameters.AddWithValue("id", id);
+ await using var r = await cmd.ExecuteReaderAsync();
+ return await r.ReadAsync() ? ReadDevice(r) : null;
+ }
+
+ public async Task> ListDevicesAsync()
+ {
+ var list = new List();
+ await using var c = await OpenAsync();
+ await using var cmd = new NpgsqlCommand($"SELECT {DeviceCols} FROM devices ORDER BY lower(name)", c);
+ await using var r = await cmd.ExecuteReaderAsync();
+ while (await r.ReadAsync()) list.Add(ReadDevice(r));
+ return list;
+ }
+
+ /// 등록 코드를 쓰고 기기를 만든다 (한 트랜잭션). 실패 사유 문자열 또는 null
+ public async Task<(Guid id, long? createdBy, string error)> EnrollAsync(byte[] codeHash, Guid newId, string hostname,
+ byte[] keyHash, string os, string appVersion)
+ {
+ await using var c = await OpenAsync();
+ await using var tx = await c.BeginTransactionAsync();
+ long codeId;
+ long? createdBy;
+ await using (var cmd = new NpgsqlCommand(
+ "SELECT id, created_by, expires_at > now(), used_at IS NULL, revoked FROM enrollment_codes WHERE code_hash = @h FOR UPDATE", c, tx))
+ {
+ cmd.Parameters.AddWithValue("h", codeHash);
+ await using var r = await cmd.ExecuteReaderAsync();
+ if (!await r.ReadAsync()) return (Guid.Empty, null, "등록 코드가 올바르지 않습니다.");
+ codeId = r.GetInt64(0);
+ createdBy = r.IsDBNull(1) ? null : r.GetInt64(1);
+ bool valid = r.GetBoolean(2), unused = r.GetBoolean(3), revoked = r.GetBoolean(4);
+ if (revoked) return (Guid.Empty, null, "취소된 등록 코드입니다.");
+ if (!unused) return (Guid.Empty, null, "이미 사용한 등록 코드입니다.");
+ if (!valid) return (Guid.Empty, null, "유효 시간이 지난 등록 코드입니다.");
+ }
+ await using (var cmd = new NpgsqlCommand(
+ "INSERT INTO devices(id, name, hostname, key_hash, enrolled_by, os_version, app_version) VALUES (@id, @n, @n, @k, @b, @os, @av)", c, tx))
+ {
+ cmd.Parameters.AddWithValue("id", newId);
+ cmd.Parameters.AddWithValue("n", hostname);
+ cmd.Parameters.AddWithValue("k", keyHash);
+ cmd.Parameters.Add(new NpgsqlParameter("b", NpgsqlDbType.Bigint) { Value = (object)createdBy ?? DBNull.Value });
+ cmd.Parameters.AddWithValue("os", os ?? "");
+ cmd.Parameters.AddWithValue("av", appVersion ?? "");
+ await cmd.ExecuteNonQueryAsync();
+ }
+ await using (var cmd = new NpgsqlCommand("UPDATE enrollment_codes SET used_at = now(), used_device = @d WHERE id = @id", c, tx))
+ {
+ cmd.Parameters.AddWithValue("id", codeId);
+ cmd.Parameters.AddWithValue("d", newId);
+ await cmd.ExecuteNonQueryAsync();
+ }
+ await tx.CommitAsync();
+ return (newId, createdBy, null);
+ }
+
+ public async Task UpdateDeviceStateAsync(Guid id, string hostname, string publicIp, string lanIpsJson,
+ string activeListenJson, string appVersion, string os)
+ {
+ await using var c = await OpenAsync();
+ await using var cmd = new NpgsqlCommand(
+ @"UPDATE devices SET hostname = coalesce(nullif(@h, ''), hostname), last_seen_at = now(), last_public_ip = @ip,
+ lan_ips = @lan::jsonb, active_listen = @al::jsonb,
+ app_version = coalesce(nullif(@av, ''), app_version), os_version = coalesce(nullif(@os, ''), os_version)
+ WHERE id = @id", c);
+ cmd.Parameters.AddWithValue("id", id);
+ cmd.Parameters.AddWithValue("h", hostname ?? "");
+ cmd.Parameters.AddWithValue("ip", publicIp ?? "");
+ cmd.Parameters.AddWithValue("lan", lanIpsJson ?? "[]");
+ cmd.Parameters.Add(new NpgsqlParameter("al", NpgsqlDbType.Text) { Value = (object)activeListenJson ?? DBNull.Value });
+ cmd.Parameters.AddWithValue("av", appVersion ?? "");
+ cmd.Parameters.AddWithValue("os", os ?? "");
+ await cmd.ExecuteNonQueryAsync();
+ }
+
+ public async Task TouchDeviceAsync(Guid id)
+ {
+ await using var c = await OpenAsync();
+ await using var cmd = new NpgsqlCommand("UPDATE devices SET last_seen_at = now() WHERE id = @id", c);
+ cmd.Parameters.AddWithValue("id", id);
+ await cmd.ExecuteNonQueryAsync();
+ }
+
+ public async Task RenameDeviceAsync(Guid id, string name, string note)
+ {
+ await using var c = await OpenAsync();
+ await using var cmd = new NpgsqlCommand(
+ "UPDATE devices SET name = coalesce(@n, name), note = coalesce(@note, note) WHERE id = @id", c);
+ cmd.Parameters.AddWithValue("id", id);
+ cmd.Parameters.Add(new NpgsqlParameter("n", NpgsqlDbType.Text) { Value = (object)name ?? DBNull.Value });
+ cmd.Parameters.Add(new NpgsqlParameter("note", NpgsqlDbType.Text) { Value = (object)note ?? DBNull.Value });
+ await cmd.ExecuteNonQueryAsync();
+ }
+
+ public async Task SetDeviceEnabledAsync(Guid id, bool enabled)
+ {
+ await using var c = await OpenAsync();
+ await using var cmd = new NpgsqlCommand("UPDATE devices SET enabled = @e WHERE id = @id", c);
+ cmd.Parameters.AddWithValue("id", id);
+ cmd.Parameters.AddWithValue("e", enabled);
+ await cmd.ExecuteNonQueryAsync();
+ }
+
+ public async Task DeleteDeviceAsync(Guid id)
+ {
+ await using var c = await OpenAsync();
+ await using var cmd = new NpgsqlCommand("DELETE FROM devices WHERE id = @id", c);
+ cmd.Parameters.AddWithValue("id", id);
+ await cmd.ExecuteNonQueryAsync();
+ }
+
+ // ───────────────────────── 권한 ─────────────────────────
+
+ public async Task GetPermAsync(long userId, Guid deviceId)
+ {
+ await using var c = await OpenAsync();
+ await using var cmd = new NpgsqlCommand(
+ "SELECT can_view, can_control, can_screen_view, can_screen_control FROM user_device_permissions WHERE user_id = @u AND device_id = @d", c);
+ cmd.Parameters.AddWithValue("u", userId);
+ cmd.Parameters.AddWithValue("d", deviceId);
+ await using var r = await cmd.ExecuteReaderAsync();
+ return await r.ReadAsync() ? new Perm(r.GetBoolean(0), r.GetBoolean(1), r.GetBoolean(2), r.GetBoolean(3)) : Perm.None;
+ }
+
+ public async Task> GetPermsForUserAsync(long userId)
+ {
+ var map = new Dictionary();
+ await using var c = await OpenAsync();
+ await using var cmd = new NpgsqlCommand(
+ "SELECT device_id, can_view, can_control, can_screen_view, can_screen_control FROM user_device_permissions WHERE user_id = @u", c);
+ cmd.Parameters.AddWithValue("u", userId);
+ await using var r = await cmd.ExecuteReaderAsync();
+ while (await r.ReadAsync())
+ map[r.GetGuid(0)] = new Perm(r.GetBoolean(1), r.GetBoolean(2), r.GetBoolean(3), r.GetBoolean(4));
+ return map;
+ }
+
+ public async Task SetPermAsync(long userId, Guid deviceId, Perm p)
+ {
+ await using var c = await OpenAsync();
+ if (!p.Any)
+ {
+ await using var del = new NpgsqlCommand("DELETE FROM user_device_permissions WHERE user_id = @u AND device_id = @d", c);
+ del.Parameters.AddWithValue("u", userId);
+ del.Parameters.AddWithValue("d", deviceId);
+ await del.ExecuteNonQueryAsync();
+ return;
+ }
+ await using var cmd = new NpgsqlCommand(
+ @"INSERT INTO user_device_permissions(user_id, device_id, can_view, can_control, can_screen_view, can_screen_control)
+ VALUES (@u, @d, @v, @c, @sv, @sc)
+ ON CONFLICT (user_id, device_id) DO UPDATE SET can_view = excluded.can_view, can_control = excluded.can_control,
+ can_screen_view = excluded.can_screen_view, can_screen_control = excluded.can_screen_control", c);
+ cmd.Parameters.AddWithValue("u", userId);
+ cmd.Parameters.AddWithValue("d", deviceId);
+ cmd.Parameters.AddWithValue("v", p.View);
+ cmd.Parameters.AddWithValue("c", p.Control);
+ cmd.Parameters.AddWithValue("sv", p.ScreenView);
+ cmd.Parameters.AddWithValue("sc", p.ScreenControl);
+ await cmd.ExecuteNonQueryAsync();
+ }
+
+ // ───────────────────────── 등록 코드 ─────────────────────────
+
+ public async Task CreateCodeAsync(byte[] codeHash, long createdBy, TimeSpan valid, string note)
+ {
+ await using var c = await OpenAsync();
+ await using var cmd = new NpgsqlCommand(
+ "INSERT INTO enrollment_codes(code_hash, created_by, expires_at, note) VALUES (@h, @b, now() + @v, @n) RETURNING id", c);
+ cmd.Parameters.AddWithValue("h", codeHash);
+ cmd.Parameters.AddWithValue("b", createdBy);
+ cmd.Parameters.AddWithValue("v", valid);
+ cmd.Parameters.AddWithValue("n", note ?? "");
+ return (long)await cmd.ExecuteScalarAsync();
+ }
+
+ public async Task ListCodesAsync()
+ {
+ var arr = new JsonArray();
+ await using var c = await OpenAsync();
+ await using var cmd = new NpgsqlCommand(
+ @"SELECT e.id, coalesce(u.username, ''), e.created_at, e.expires_at, e.used_at, coalesce(d.name, ''), e.revoked, e.note,
+ e.expires_at > now()
+ FROM enrollment_codes e LEFT JOIN users u ON u.id = e.created_by LEFT JOIN devices d ON d.id = e.used_device
+ ORDER BY e.created_at DESC LIMIT 200", c);
+ await using var r = await cmd.ExecuteReaderAsync();
+ while (await r.ReadAsync())
+ {
+ bool used = !r.IsDBNull(4), revoked = r.GetBoolean(6), valid = r.GetBoolean(8);
+ arr.Add(new JsonObject
+ {
+ ["id"] = r.GetInt64(0),
+ ["createdBy"] = r.GetString(1),
+ ["createdAt"] = Iso(r.GetDateTime(2)),
+ ["expiresAt"] = Iso(r.GetDateTime(3)),
+ ["usedAt"] = used ? Iso(r.GetDateTime(4)) : null,
+ ["usedDevice"] = r.GetString(5),
+ ["note"] = r.GetString(7),
+ ["status"] = used ? "used" : revoked ? "revoked" : valid ? "valid" : "expired"
+ });
+ }
+ return arr;
+ }
+
+ public async Task RevokeCodeAsync(long id)
+ {
+ await using var c = await OpenAsync();
+ await using var cmd = new NpgsqlCommand("UPDATE enrollment_codes SET revoked = true WHERE id = @id AND used_at IS NULL", c);
+ cmd.Parameters.AddWithValue("id", id);
+ return await cmd.ExecuteNonQueryAsync() > 0;
+ }
+
+ // ───────────────────────── 감사 로그 ─────────────────────────
+
+ public async Task AuditAsync(long? userId, string username, Guid? deviceId, string deviceName, string action,
+ string target, string result, string detail, string ip)
+ {
+ try
+ {
+ await using var c = await OpenAsync();
+ await using var cmd = new NpgsqlCommand(
+ @"INSERT INTO audit_logs(user_id, username, device_id, device_name, action, target, result, detail, source_ip)
+ VALUES (@u, @un, @d, @dn, @a, @t, @r, @de, @ip)", c);
+ cmd.Parameters.Add(new NpgsqlParameter("u", NpgsqlDbType.Bigint) { Value = (object)userId ?? DBNull.Value });
+ cmd.Parameters.AddWithValue("un", username ?? "");
+ cmd.Parameters.Add(new NpgsqlParameter("d", NpgsqlDbType.Uuid) { Value = (object)deviceId ?? DBNull.Value });
+ cmd.Parameters.AddWithValue("dn", deviceName ?? "");
+ cmd.Parameters.AddWithValue("a", action ?? "");
+ cmd.Parameters.AddWithValue("t", Cut(target, 500));
+ cmd.Parameters.AddWithValue("r", result ?? "");
+ cmd.Parameters.AddWithValue("de", Cut(detail, 2000));
+ cmd.Parameters.AddWithValue("ip", ip ?? "");
+ await cmd.ExecuteNonQueryAsync();
+ }
+ catch (Exception ex)
+ {
+ _log.Error("감사 로그 저장 실패: " + ex.Message);
+ }
+ }
+
+ public async Task QueryAuditAsync(DateTime? from, DateTime? to, long? userId, Guid? deviceId, string action,
+ long? beforeId, int limit)
+ {
+ var arr = new JsonArray();
+ await using var c = await OpenAsync();
+ await using var cmd = new NpgsqlCommand(
+ @"SELECT id, at, coalesce(user_id, 0), username, device_id, device_name, action, target, result, detail, source_ip
+ FROM audit_logs
+ WHERE (@f::timestamptz IS NULL OR at >= @f) AND (@t::timestamptz IS NULL OR at < @t)
+ AND (@u::bigint IS NULL OR user_id = @u) AND (@d::uuid IS NULL OR device_id = @d)
+ AND (@a::text IS NULL OR action = @a) AND (@b::bigint IS NULL OR id < @b)
+ ORDER BY id DESC LIMIT @l", c);
+ AddFilters(cmd, from, to, userId, deviceId, action, beforeId, limit);
+ await using var r = await cmd.ExecuteReaderAsync();
+ while (await r.ReadAsync())
+ {
+ arr.Add(new JsonObject
+ {
+ ["id"] = r.GetInt64(0),
+ ["at"] = Iso(r.GetDateTime(1)),
+ ["userId"] = r.GetInt64(2),
+ ["username"] = r.GetString(3),
+ ["deviceId"] = r.IsDBNull(4) ? null : r.GetGuid(4).ToString(),
+ ["deviceName"] = r.GetString(5),
+ ["action"] = r.GetString(6),
+ ["target"] = r.GetString(7),
+ ["result"] = r.GetString(8),
+ ["detail"] = r.GetString(9),
+ ["ip"] = r.GetString(10)
+ });
+ }
+ return arr;
+ }
+
+ private static void AddFilters(NpgsqlCommand cmd, DateTime? from, DateTime? to, long? userId, Guid? deviceId,
+ string action, long? beforeId, int limit)
+ {
+ cmd.Parameters.Add(new NpgsqlParameter("f", NpgsqlDbType.TimestampTz) { Value = (object)from ?? DBNull.Value });
+ cmd.Parameters.Add(new NpgsqlParameter("t", NpgsqlDbType.TimestampTz) { Value = (object)to ?? DBNull.Value });
+ cmd.Parameters.Add(new NpgsqlParameter("u", NpgsqlDbType.Bigint) { Value = (object)userId ?? DBNull.Value });
+ cmd.Parameters.Add(new NpgsqlParameter("d", NpgsqlDbType.Uuid) { Value = (object)deviceId ?? DBNull.Value });
+ cmd.Parameters.Add(new NpgsqlParameter("a", NpgsqlDbType.Text) { Value = string.IsNullOrEmpty(action) ? DBNull.Value : action });
+ cmd.Parameters.Add(new NpgsqlParameter("b", NpgsqlDbType.Bigint) { Value = (object)beforeId ?? DBNull.Value });
+ cmd.Parameters.AddWithValue("l", Math.Clamp(limit, 1, 500));
+ }
+
+ // ───────────────────────── 연결 기록 ─────────────────────────
+
+ public async Task ConnectionStartAsync(Guid sessionId, long userId, string username, Guid deviceId, string deviceName, string managerIp)
+ {
+ await using var c = await OpenAsync();
+ await using var cmd = new NpgsqlCommand(
+ @"INSERT INTO connections(session_id, user_id, username, device_id, device_name, manager_ip)
+ VALUES (@s, @u, @un, @d, @dn, @ip)", c);
+ cmd.Parameters.AddWithValue("s", sessionId);
+ cmd.Parameters.AddWithValue("u", userId);
+ cmd.Parameters.AddWithValue("un", username ?? "");
+ cmd.Parameters.AddWithValue("d", deviceId);
+ cmd.Parameters.AddWithValue("dn", deviceName ?? "");
+ cmd.Parameters.AddWithValue("ip", managerIp ?? "");
+ await cmd.ExecuteNonQueryAsync();
+ }
+
+ public async Task ConnectionModeAsync(Guid sessionId, string mode)
+ {
+ await using var c = await OpenAsync();
+ await using var cmd = new NpgsqlCommand("UPDATE connections SET mode = @m WHERE session_id = @s AND ended_at IS NULL", c);
+ cmd.Parameters.AddWithValue("s", sessionId);
+ cmd.Parameters.AddWithValue("m", mode);
+ await cmd.ExecuteNonQueryAsync();
+ }
+
+ public async Task ConnectionEndAsync(Guid sessionId, string mode, long toDevice, long fromDevice, string reason)
+ {
+ await using var c = await OpenAsync();
+ await using var cmd = new NpgsqlCommand(
+ @"UPDATE connections SET ended_at = now(), mode = coalesce(@m, mode), bytes_to_device = @td,
+ bytes_from_device = @fd, end_reason = @r
+ WHERE session_id = @s AND ended_at IS NULL", c);
+ cmd.Parameters.AddWithValue("s", sessionId);
+ cmd.Parameters.Add(new NpgsqlParameter("m", NpgsqlDbType.Text) { Value = (object)mode ?? DBNull.Value });
+ cmd.Parameters.AddWithValue("td", toDevice);
+ cmd.Parameters.AddWithValue("fd", fromDevice);
+ cmd.Parameters.AddWithValue("r", Cut(reason, 500));
+ await cmd.ExecuteNonQueryAsync();
+ }
+
+ /// 서버가 재시작되어 끝을 못 적은 연결 정리
+ public async Task CloseDanglingConnectionsAsync()
+ {
+ await using var c = await OpenAsync();
+ await using var cmd = new NpgsqlCommand(
+ "UPDATE connections SET ended_at = now(), end_reason = '서버 재시작' WHERE ended_at IS NULL", c);
+ await cmd.ExecuteNonQueryAsync();
+ }
+
+ public async Task ConnectionBelongsToAsync(Guid sessionId, long userId)
+ {
+ await using var c = await OpenAsync();
+ await using var cmd = new NpgsqlCommand("SELECT 1 FROM connections WHERE session_id = @s AND user_id = @u", c);
+ cmd.Parameters.AddWithValue("s", sessionId);
+ cmd.Parameters.AddWithValue("u", userId);
+ return await cmd.ExecuteScalarAsync() != null;
+ }
+
+ public async Task QueryConnectionsAsync(DateTime? from, DateTime? to, long? userId, Guid? deviceId,
+ long? beforeId, int limit)
+ {
+ var arr = new JsonArray();
+ await using var c = await OpenAsync();
+ await using var cmd = new NpgsqlCommand(
+ @"SELECT id, session_id, coalesce(user_id, 0), username, device_id, device_name, started_at, ended_at,
+ coalesce(mode, ''), bytes_to_device, bytes_from_device, end_reason, manager_ip
+ FROM connections
+ WHERE (@f::timestamptz IS NULL OR started_at >= @f) AND (@t::timestamptz IS NULL OR started_at < @t)
+ AND (@u::bigint IS NULL OR user_id = @u) AND (@d::uuid IS NULL OR device_id = @d)
+ AND (@a::text IS NULL OR mode = @a) AND (@b::bigint IS NULL OR id < @b)
+ ORDER BY id DESC LIMIT @l", c);
+ AddFilters(cmd, from, to, userId, deviceId, null, beforeId, limit);
+ await using var r = await cmd.ExecuteReaderAsync();
+ while (await r.ReadAsync())
+ {
+ arr.Add(new JsonObject
+ {
+ ["id"] = r.GetInt64(0),
+ ["sessionId"] = r.GetGuid(1).ToString(),
+ ["userId"] = r.GetInt64(2),
+ ["username"] = r.GetString(3),
+ ["deviceId"] = r.IsDBNull(4) ? null : r.GetGuid(4).ToString(),
+ ["deviceName"] = r.GetString(5),
+ ["startedAt"] = Iso(r.GetDateTime(6)),
+ ["endedAt"] = r.IsDBNull(7) ? null : Iso(r.GetDateTime(7)),
+ ["mode"] = r.GetString(8),
+ ["bytesToDevice"] = r.GetInt64(9),
+ ["bytesFromDevice"] = r.GetInt64(10),
+ ["endReason"] = r.GetString(11),
+ ["managerIp"] = r.GetString(12)
+ });
+ }
+ return arr;
+ }
+
+ // ───────────────────────── 공통 ─────────────────────────
+
+ public static string Iso(DateTime t) => DateTime.SpecifyKind(t, DateTimeKind.Utc).ToString("o");
+ private static string Cut(string s, int max) => s == null ? "" : s.Length <= max ? s : s[..max];
+}
diff --git a/server/Hub.cs b/server/Hub.cs
new file mode 100644
index 0000000..477af16
--- /dev/null
+++ b/server/Hub.cs
@@ -0,0 +1,197 @@
+using System.Collections.Concurrent;
+using System.Net.WebSockets;
+using System.Text.Json.Nodes;
+
+namespace JJServiceManagerRelay;
+
+/// 접속 중인 대상 PC
+public sealed class AgentConn
+{
+ public Guid DeviceId { get; init; }
+ public string Name { get; set; }
+ public string Hostname { get; set; }
+ public WsJson Ws { get; init; }
+ public string PublicIp { get; init; }
+ public JsonArray LanIps { get; set; } = new();
+ public JsonObject ActiveListen { get; set; }
+ public string AppVersion { get; set; } = "";
+ public DateTime ConnectedAt { get; } = DateTime.UtcNow;
+ public CancellationTokenSource Cts { get; } = new();
+ public JsonObject PendingReply { get; set; }
+ public bool NotifyDevicesChanged { get; set; }
+}
+
+/// 로그인한 관리 PC
+public sealed class ManagerConn
+{
+ public Guid Id { get; } = Guid.NewGuid();
+ public UserRow User { get; set; }
+ public byte[] TokenHash { get; set; }
+ public WsJson Ws { get; init; }
+ public string Ip { get; init; }
+ public string Host { get; set; } = "";
+ public Dictionary Perms { get; set; } = new();
+ public DateTime LastExtend { get; set; } = DateTime.UtcNow;
+ public CancellationTokenSource Cts { get; } = new();
+ public JsonObject PendingReply { get; set; }
+
+ public Perm PermFor(Guid deviceId) =>
+ User.IsAdmin ? Perm.All : Perms.TryGetValue(deviceId, out var p) ? p : Perm.None;
+}
+
+/// 관리 PC ↔ 대상 PC 연결 하나 (중개 → 직접 또는 중계)
+public sealed class RelaySession
+{
+ public Guid Id { get; } = Guid.NewGuid();
+ public Guid DeviceId { get; init; }
+ public string DeviceName { get; init; }
+ public long UserId { get; init; }
+ public string Username { get; init; }
+ public Guid ManagerConnId { get; init; }
+ public string ManagerToken { get; } = Secrets.NewToken();
+ public string AgentToken { get; } = Secrets.NewToken();
+ public DateTime CreatedAt { get; } = DateTime.UtcNow;
+
+ public readonly object Lock = new();
+ public WebSocket ManagerWs;
+ public WebSocket AgentWs;
+ public bool ManagerUsed, AgentUsed;
+ public bool Paired;
+ public bool Direct; // 관리 PC가 직접 연결에 성공했다고 알림
+ public bool Finished;
+ public long BytesToDevice, BytesFromDevice;
+ public readonly TaskCompletionSource Done = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ /// 직접 연결 성공 → 중계 통로에서 기다리던 쪽을 깨움
+ public readonly TaskCompletionSource Wake = new(TaskCreationOptions.RunContinuationsAsynchronously);
+}
+
+/// 서버 전체 상태
+public sealed class Hub
+{
+ public Db Db { get; }
+ public ServerConfig Config { get; }
+ public ServerLog Log { get; }
+ public TicketSigner Signer { get; set; }
+ public Throttle Throttle { get; } = new();
+ public DateTime StartedAt { get; } = DateTime.UtcNow;
+ public string Version { get; } = typeof(Hub).Assembly.GetName().Version?.ToString(3) ?? "1.0.0";
+
+ public ConcurrentDictionary Agents { get; } = new();
+ public ConcurrentDictionary Managers { get; } = new();
+ public ConcurrentDictionary Sessions { get; } = new();
+
+ public Hub(Db db, ServerConfig config, ServerLog log)
+ {
+ Db = db;
+ Config = config;
+ Log = log;
+ }
+
+ public bool IsOnline(Guid deviceId) => Agents.ContainsKey(deviceId);
+
+ // ───────────────────────── 알림 ─────────────────────────
+
+ /// 기기 온라인/오프라인을 볼 수 있는 관리 PC에 알림
+ public async Task NotifyPresenceAsync(Guid deviceId, string name, string hostname, bool online)
+ {
+ var msg = new JsonObject
+ {
+ ["event"] = "presence",
+ ["deviceId"] = deviceId.ToString(),
+ ["name"] = name,
+ ["hostname"] = hostname,
+ ["online"] = online,
+ ["at"] = Db.Iso(DateTime.UtcNow)
+ };
+ foreach (var m in Managers.Values)
+ {
+ if (m.User.MustChangePassword) continue;
+ if (!m.PermFor(deviceId).Any) continue;
+ await m.Ws.TrySendAsync((JsonObject)msg.DeepClone());
+ }
+ }
+
+ /// 기기 목록·권한이 바뀜 → 관리 PC들이 목록을 다시 받게
+ public async Task NotifyDevicesChangedAsync()
+ {
+ foreach (var m in Managers.Values)
+ {
+ try
+ {
+ if (!m.User.IsAdmin) m.Perms = await Db.GetPermsForUserAsync(m.User.Id);
+ }
+ catch { }
+ await m.Ws.TrySendAsync(new JsonObject { ["event"] = "devicesChanged" });
+ }
+ }
+
+ /// 사용자 계정이 바뀜 (사용 안 함·역할 변경) → 그 사용자의 연결을 다시 확인
+ public async Task RefreshUserAsync(long userId, string kickReason = null)
+ {
+ var user = await Db.GetUserAsync(userId);
+ foreach (var m in Managers.Values.Where(x => x.User.Id == userId).ToList())
+ {
+ if (user == null || !user.Enabled || kickReason != null)
+ {
+ await m.Ws.TrySendAsync(new JsonObject { ["event"] = "loggedOut", ["reason"] = kickReason ?? "계정을 사용할 수 없습니다." });
+ m.Cts.Cancel();
+ continue;
+ }
+ m.User = user;
+ m.Perms = user.IsAdmin ? new() : await Db.GetPermsForUserAsync(user.Id);
+ await m.Ws.TrySendAsync(new JsonObject { ["event"] = "devicesChanged" });
+ }
+ }
+
+ /// 기기를 사용 안 함/삭제 → 접속 중이면 끊음
+ public async Task KickAgentAsync(Guid deviceId, string reason)
+ {
+ if (Agents.TryGetValue(deviceId, out var a))
+ {
+ await a.Ws.TrySendAsync(new JsonObject { ["event"] = "disabled", ["reason"] = reason });
+ a.Cts.Cancel();
+ }
+ }
+
+ // ───────────────────────── 연결 세션 정리 ─────────────────────────
+
+ public async Task FinishSessionAsync(RelaySession s, string mode, string reason)
+ {
+ lock (s.Lock)
+ {
+ if (s.Finished) return;
+ s.Finished = true;
+ }
+ Sessions.TryRemove(s.Id, out _);
+ s.Done.TrySetResult();
+ s.Wake.TrySetResult();
+ await Db.ConnectionEndAsync(s.Id, mode, s.BytesToDevice, s.BytesFromDevice, reason);
+ }
+
+ /// 30초마다: 아무도 붙지 않은 세션 정리, 만료 토큰 정리
+ public async Task HousekeepingLoopAsync(CancellationToken ct)
+ {
+ var lastPurge = DateTime.MinValue;
+ while (!ct.IsCancellationRequested)
+ {
+ try
+ {
+ await Task.Delay(TimeSpan.FromSeconds(10), ct);
+ var now = DateTime.UtcNow;
+ foreach (var s in Sessions.Values)
+ {
+ bool stale;
+ lock (s.Lock) stale = !s.Paired && !s.Direct && now - s.CreatedAt > TimeSpan.FromSeconds(60);
+ if (stale) await FinishSessionAsync(s, "failed", "시간 초과 (양쪽이 연결되지 않음)");
+ }
+ if (now - lastPurge > TimeSpan.FromHours(1))
+ {
+ lastPurge = now;
+ await Db.PurgeExpiredSessionsAsync();
+ }
+ }
+ catch (OperationCanceledException) { break; }
+ catch (Exception ex) { Log.Error("정리 작업 오류: " + ex.Message); }
+ }
+ }
+}
diff --git a/server/JJServiceManagerRelay.Server.csproj b/server/JJServiceManagerRelay.Server.csproj
new file mode 100644
index 0000000..f74fed8
--- /dev/null
+++ b/server/JJServiceManagerRelay.Server.csproj
@@ -0,0 +1,21 @@
+
+
+
+ net8.0
+ disable
+ enable
+ jjsm-server
+ JJServiceManagerRelay
+ 1.0.0
+ false
+
+
+
+
+
+
+
+
+
+
+
diff --git a/server/ManagerHandler.cs b/server/ManagerHandler.cs
new file mode 100644
index 0000000..06e1f2d
--- /dev/null
+++ b/server/ManagerHandler.cs
@@ -0,0 +1,647 @@
+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("비밀번호가 너무 깁니다.");
+ }
+}
diff --git a/server/Program.cs b/server/Program.cs
new file mode 100644
index 0000000..1530ab5
--- /dev/null
+++ b/server/Program.cs
@@ -0,0 +1,92 @@
+using System.Text.Json.Nodes;
+using JJServiceManagerRelay;
+
+var config = ServerConfig.FromEnvironment();
+var log = new ServerLog(config.LogDir);
+var db = new Db(config.ConnectionString, log);
+var hub = new Hub(db, config, log);
+
+log.Info($"JJServiceManagerRelay {hub.Version} 시작 (포트 {config.Port})");
+
+using var startCts = new CancellationTokenSource(TimeSpan.FromMinutes(3));
+await db.InitializeAsync(startCts.Token);
+await db.CloseDanglingConnectionsAsync();
+
+// 티켓 서명 키 (처음 한 번 만들고 DB에 보관)
+hub.Signer = new TicketSigner(await db.GetOrCreateSettingAsync("ticket_private_key", TicketSigner.NewPrivateKey));
+
+// 첫 관리자: 사용자가 하나도 없을 때만 환경 변수로 만듦
+if (await db.CountUsersAsync() == 0)
+{
+ if (!string.IsNullOrWhiteSpace(config.AdminUser) && !string.IsNullOrEmpty(config.AdminPassword))
+ {
+ if (config.AdminPassword.Length < 8)
+ {
+ log.Error("JJSM_ADMIN_PASSWORD는 8자 이상이어야 합니다. 관리자를 만들지 않았습니다.");
+ }
+ else
+ {
+ await db.CreateUserAsync(config.AdminUser.Trim(), Secrets.HashPassword(config.AdminPassword), "admin", true, "첫 관리자 (환경 변수)");
+ await db.AuditAsync(null, "system", null, "", "userCreate", config.AdminUser.Trim(), "ok", "첫 관리자 (환경 변수)", "");
+ log.Info($"첫 관리자 계정을 만들었습니다: {config.AdminUser.Trim()} (첫 로그인 때 비밀번호를 바꿔야 합니다)");
+ }
+ }
+ else
+ {
+ log.Warn("사용자가 없습니다. JJSM_ADMIN_USER / JJSM_ADMIN_PASSWORD 환경 변수를 넣고 다시 시작하세요.");
+ }
+}
+
+var builder = WebApplication.CreateBuilder(new WebApplicationOptions { Args = args });
+builder.Logging.ClearProviders();
+builder.Logging.AddSimpleConsole(o => o.SingleLine = true);
+builder.Logging.SetMinimumLevel(LogLevel.Warning);
+builder.WebHost.UseUrls($"http://0.0.0.0:{config.Port}");
+builder.WebHost.ConfigureKestrel(k => k.AddServerHeader = false);
+
+var app = builder.Build();
+app.UseWebSockets(new WebSocketOptions { KeepAliveInterval = TimeSpan.FromSeconds(30) });
+
+var agentHandler = new AgentHandler(hub);
+var managerHandler = new ManagerHandler(hub);
+var relayHandler = new RelayHandler(hub);
+
+app.MapGet("/", () => Results.Text("JJServiceManagerRelay\n", "text/plain; charset=utf-8"));
+
+app.MapGet("/health", async () =>
+{
+ bool dbOk = await db.PingAsync();
+ var o = new JsonObject
+ {
+ ["status"] = dbOk ? "ok" : "db-error",
+ ["version"] = hub.Version,
+ ["db"] = dbOk,
+ ["onlineDevices"] = hub.Agents.Count,
+ ["onlineManagers"] = hub.Managers.Count
+ };
+ return Results.Text(o.ToJsonString(), "application/json; charset=utf-8", statusCode: dbOk ? 200 : 503);
+});
+
+app.Map("/ws/agent", async ctx =>
+{
+ if (!ctx.WebSockets.IsWebSocketRequest) { ctx.Response.StatusCode = 400; return; }
+ await agentHandler.RunAsync(ctx);
+});
+
+app.Map("/ws/manager", async ctx =>
+{
+ if (!ctx.WebSockets.IsWebSocketRequest) { ctx.Response.StatusCode = 400; return; }
+ await managerHandler.RunAsync(ctx);
+});
+
+app.Map("/relay/{sid}", async (HttpContext ctx, string sid) =>
+{
+ if (!ctx.WebSockets.IsWebSocketRequest) { ctx.Response.StatusCode = 400; return; }
+ await relayHandler.RunAsync(ctx, sid);
+});
+
+var stop = app.Lifetime.ApplicationStopping;
+_ = Task.Run(() => hub.HousekeepingLoopAsync(stop));
+app.Lifetime.ApplicationStopping.Register(() => log.Info("서버 종료 중"));
+
+await app.RunAsync();
diff --git a/server/RelayHandler.cs b/server/RelayHandler.cs
new file mode 100644
index 0000000..35bbd9d
--- /dev/null
+++ b/server/RelayHandler.cs
@@ -0,0 +1,141 @@
+using System.Buffers;
+using System.Net.WebSockets;
+using System.Security.Cryptography;
+using System.Text;
+
+namespace JJServiceManagerRelay;
+
+/// /relay/{세션ID}?token=…&side=manager|agent — 두 연결을 이어 붙여 바이트를 그대로 넘김
+public sealed class RelayHandler
+{
+ private readonly Hub _hub;
+ private static readonly TimeSpan PairWait = TimeSpan.FromSeconds(30);
+
+ public RelayHandler(Hub hub) => _hub = hub;
+
+ public async Task RunAsync(HttpContext ctx, string sessionId)
+ {
+ var side = ctx.Request.Query["side"].ToString();
+ var token = ctx.Request.Query["token"].ToString();
+ if (!Guid.TryParse(sessionId, out var sid) || !_hub.Sessions.TryGetValue(sid, out var s) || (side != "manager" && side != "agent"))
+ {
+ ctx.Response.StatusCode = 404;
+ return;
+ }
+ var expect = side == "manager" ? s.ManagerToken : s.AgentToken;
+ bool tokenOk = CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(token), Encoding.UTF8.GetBytes(expect));
+ lock (s.Lock)
+ {
+ bool used = side == "manager" ? s.ManagerUsed : s.AgentUsed;
+ if (!tokenOk || used || s.Finished || s.Direct)
+ {
+ ctx.Response.StatusCode = 403;
+ return;
+ }
+ if (side == "manager") s.ManagerUsed = true; else s.AgentUsed = true;
+ }
+
+ using var ws = await ctx.WebSockets.AcceptWebSocketAsync();
+ WebSocket other = null;
+ lock (s.Lock)
+ {
+ if (side == "manager") s.ManagerWs = ws; else s.AgentWs = ws;
+ if (s.ManagerWs != null && s.AgentWs != null && !s.Paired)
+ {
+ s.Paired = true;
+ other = side == "manager" ? s.AgentWs : s.ManagerWs;
+ }
+ }
+
+ if (other == null)
+ {
+ // 상대를 기다림 (상대 쪽 요청이 이어 붙이기를 맡음)
+ await Task.WhenAny(s.Done.Task, s.Wake.Task, Task.Delay(PairWait, ctx.RequestAborted).ContinueWith(_ => { }));
+ bool paired;
+ lock (s.Lock) paired = s.Paired;
+ if (!paired)
+ {
+ await CloseAsync(ws, s.Direct ? "직접 연결됨" : "상대가 연결되지 않았습니다.");
+ if (!s.Direct) await _hub.FinishSessionAsync(s, "failed", $"중계: {(side == "manager" ? "대상 PC" : "관리 PC")}가 30초 안에 붙지 않음");
+ return;
+ }
+ await s.Done.Task; // 이어 붙인 쪽이 끝낼 때까지 이 요청을 살려 둠
+ return;
+ }
+
+ // 두 번째로 붙은 쪽: 양방향으로 넘김
+ await _hub.Db.ConnectionModeAsync(s.Id, "relay");
+ var managerWs = side == "manager" ? ws : other;
+ var agentWs = side == "manager" ? other : ws;
+ _hub.Log.Info($"중계 시작: {s.Username} → {s.DeviceName} ({s.Id})");
+ string reason = "정상 종료";
+ try
+ {
+ var t1 = PumpAsync(managerWs, agentWs, n => Interlocked.Add(ref s.BytesToDevice, n));
+ var t2 = PumpAsync(agentWs, managerWs, n => Interlocked.Add(ref s.BytesFromDevice, n));
+ var first = await Task.WhenAny(t1, t2);
+ reason = await first;
+ // 한쪽이 끝나면 다른 쪽도 닫음
+ await CloseAsync(managerWs, reason);
+ await CloseAsync(agentWs, reason);
+ // 상대가 닫기 응답을 안 하면 2초 뒤 강제로 끊음
+ await Task.WhenAny(Task.WhenAll(t1, t2), Task.Delay(2000));
+ try { managerWs.Abort(); } catch { }
+ try { agentWs.Abort(); } catch { }
+ }
+ catch (Exception ex)
+ {
+ reason = "중계 오류: " + ex.Message;
+ }
+ finally
+ {
+ _hub.Log.Info($"중계 끝: {s.Username} → {s.DeviceName} ({reason}, 보냄 {s.BytesToDevice}B / 받음 {s.BytesFromDevice}B)");
+ await _hub.FinishSessionAsync(s, "relay", reason);
+ }
+ }
+
+ /// from에서 받은 프레임을 to로 그대로 보냄. 끝난 사유를 돌려줌
+ private static async Task PumpAsync(WebSocket from, WebSocket to, Action count)
+ {
+ var buf = ArrayPool.Shared.Rent(64 * 1024);
+ try
+ {
+ while (true)
+ {
+ var r = await from.ReceiveAsync(buf, CancellationToken.None);
+ if (r.MessageType == WebSocketMessageType.Close) return "상대가 연결을 닫음";
+ count(r.Count);
+ await to.SendAsync(new ArraySegment(buf, 0, r.Count), r.MessageType, r.EndOfMessage, CancellationToken.None);
+ }
+ }
+ catch (WebSocketException)
+ {
+ return "연결 끊김";
+ }
+ catch (ObjectDisposedException)
+ {
+ return "연결 끊김";
+ }
+ catch (OperationCanceledException)
+ {
+ return "연결 끊김";
+ }
+ finally
+ {
+ ArrayPool.Shared.Return(buf);
+ }
+ }
+
+ private static async Task CloseAsync(WebSocket ws, string reason)
+ {
+ try
+ {
+ using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
+ var b = reason ?? "";
+ while (Encoding.UTF8.GetByteCount(b) > 120) b = b[..^1];
+ if (ws.State == WebSocketState.Open || ws.State == WebSocketState.CloseReceived)
+ await ws.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, b, cts.Token);
+ }
+ catch { }
+ }
+}
diff --git a/server/Schema/001_init.sql b/server/Schema/001_init.sql
new file mode 100644
index 0000000..a447d9b
--- /dev/null
+++ b/server/Schema/001_init.sql
@@ -0,0 +1,104 @@
+-- JJServiceManagerRelay 스키마 v1
+
+CREATE TABLE IF NOT EXISTS server_settings (
+ key text PRIMARY KEY,
+ value text NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS users (
+ id bigserial PRIMARY KEY,
+ username text NOT NULL,
+ password_hash text NOT NULL,
+ role text NOT NULL CHECK (role IN ('admin', 'user')),
+ must_change_password boolean NOT NULL DEFAULT false,
+ enabled boolean NOT NULL DEFAULT true,
+ note text NOT NULL DEFAULT '',
+ created_at timestamptz NOT NULL DEFAULT now(),
+ last_login_at timestamptz
+);
+CREATE UNIQUE INDEX IF NOT EXISTS users_username_lower ON users (lower(username));
+
+CREATE TABLE IF NOT EXISTS user_sessions (
+ token_hash bytea PRIMARY KEY,
+ user_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ expires_at timestamptz NOT NULL,
+ client_ip text,
+ client_host text
+);
+CREATE INDEX IF NOT EXISTS user_sessions_user ON user_sessions (user_id);
+
+CREATE TABLE IF NOT EXISTS devices (
+ id uuid PRIMARY KEY,
+ name text NOT NULL,
+ hostname text NOT NULL,
+ key_hash bytea NOT NULL,
+ enabled boolean NOT NULL DEFAULT true,
+ note text NOT NULL DEFAULT '',
+ enrolled_at timestamptz NOT NULL DEFAULT now(),
+ enrolled_by bigint REFERENCES users(id) ON DELETE SET NULL,
+ last_seen_at timestamptz,
+ last_public_ip text,
+ lan_ips jsonb NOT NULL DEFAULT '[]',
+ active_listen jsonb,
+ app_version text,
+ os_version text
+);
+
+CREATE TABLE IF NOT EXISTS enrollment_codes (
+ id bigserial PRIMARY KEY,
+ code_hash bytea NOT NULL UNIQUE,
+ created_by bigint REFERENCES users(id) ON DELETE SET NULL,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ expires_at timestamptz NOT NULL,
+ used_at timestamptz,
+ used_device uuid REFERENCES devices(id) ON DELETE SET NULL,
+ revoked boolean NOT NULL DEFAULT false,
+ note text NOT NULL DEFAULT ''
+);
+
+CREATE TABLE IF NOT EXISTS user_device_permissions (
+ user_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ device_id uuid NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
+ can_view boolean NOT NULL DEFAULT true,
+ can_control boolean NOT NULL DEFAULT false,
+ can_screen_view boolean NOT NULL DEFAULT false,
+ can_screen_control boolean NOT NULL DEFAULT false,
+ PRIMARY KEY (user_id, device_id)
+);
+
+-- 감사 로그: 지우는 기능 없음
+CREATE TABLE IF NOT EXISTS audit_logs (
+ id bigserial PRIMARY KEY,
+ at timestamptz NOT NULL DEFAULT now(),
+ user_id bigint,
+ username text NOT NULL DEFAULT '',
+ device_id uuid,
+ device_name text NOT NULL DEFAULT '',
+ action text NOT NULL,
+ target text NOT NULL DEFAULT '',
+ result text NOT NULL,
+ detail text NOT NULL DEFAULT '',
+ source_ip text NOT NULL DEFAULT ''
+);
+CREATE INDEX IF NOT EXISTS audit_logs_at ON audit_logs (at DESC);
+CREATE INDEX IF NOT EXISTS audit_logs_user ON audit_logs (user_id, at DESC);
+CREATE INDEX IF NOT EXISTS audit_logs_device ON audit_logs (device_id, at DESC);
+
+-- 연결 기록: 지우는 기능 없음
+CREATE TABLE IF NOT EXISTS connections (
+ id bigserial PRIMARY KEY,
+ session_id uuid NOT NULL UNIQUE,
+ user_id bigint,
+ username text NOT NULL DEFAULT '',
+ device_id uuid,
+ device_name text NOT NULL DEFAULT '',
+ started_at timestamptz NOT NULL DEFAULT now(),
+ ended_at timestamptz,
+ mode text,
+ bytes_to_device bigint NOT NULL DEFAULT 0,
+ bytes_from_device bigint NOT NULL DEFAULT 0,
+ end_reason text NOT NULL DEFAULT '',
+ manager_ip text NOT NULL DEFAULT ''
+);
+CREATE INDEX IF NOT EXISTS connections_started ON connections (started_at DESC);
diff --git a/server/Security.cs b/server/Security.cs
new file mode 100644
index 0000000..2fa886d
--- /dev/null
+++ b/server/Security.cs
@@ -0,0 +1,152 @@
+using System.Collections.Concurrent;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json.Nodes;
+
+namespace JJServiceManagerRelay;
+
+/// 비밀번호 해시, 토큰, 코드 만들기
+public static class Secrets
+{
+ private const int Iterations = 210_000;
+
+ public static string HashPassword(string password)
+ {
+ var salt = RandomNumberGenerator.GetBytes(16);
+ var hash = Rfc2898DeriveBytes.Pbkdf2(password, salt, Iterations, HashAlgorithmName.SHA256, 32);
+ return $"pbkdf2-sha256${Iterations}${Convert.ToBase64String(salt)}${Convert.ToBase64String(hash)}";
+ }
+
+ public static bool VerifyPassword(string password, string stored)
+ {
+ try
+ {
+ var p = stored.Split('$');
+ if (p.Length != 4 || p[0] != "pbkdf2-sha256") return false;
+ int iter = int.Parse(p[1]);
+ var salt = Convert.FromBase64String(p[2]);
+ var expect = Convert.FromBase64String(p[3]);
+ var got = Rfc2898DeriveBytes.Pbkdf2(password ?? "", salt, iter, HashAlgorithmName.SHA256, expect.Length);
+ return CryptographicOperations.FixedTimeEquals(got, expect);
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ /// 비교용 비밀번호를 늘 계산해서 "없는 아이디"도 같은 시간이 걸리게
+ public static readonly string DummyHash = HashPassword(Guid.NewGuid().ToString());
+
+ public static string NewToken() => Base64Url(RandomNumberGenerator.GetBytes(32));
+
+ public static byte[] Sha256(string s) => SHA256.HashData(Encoding.UTF8.GetBytes(s ?? ""));
+
+ /// 등록 코드: XXXX-XXXX (헷갈리는 글자 O/0/I/1 제외)
+ public static string NewCode()
+ {
+ const string chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
+ var sb = new StringBuilder();
+ for (int i = 0; i < 8; i++)
+ {
+ if (i == 4) sb.Append('-');
+ sb.Append(chars[RandomNumberGenerator.GetInt32(chars.Length)]);
+ }
+ return sb.ToString();
+ }
+
+ /// 입력한 코드 정리 (대소문자·공백·하이픈 무시)
+ public static string NormalizeCode(string code)
+ {
+ var s = new string((code ?? "").ToUpperInvariant().Where(char.IsLetterOrDigit).ToArray());
+ return s.Length == 8 ? s[..4] + "-" + s[4..] : s;
+ }
+
+ public static string Base64Url(byte[] b) => Convert.ToBase64String(b).TrimEnd('=').Replace('+', '-').Replace('/', '_');
+
+ public static byte[] FromBase64Url(string s)
+ {
+ s = s.Replace('-', '+').Replace('_', '/');
+ switch (s.Length % 4) { case 2: s += "=="; break; case 3: s += "="; break; }
+ return Convert.FromBase64String(s);
+ }
+}
+
+/// 권한 티켓 서명 (ECDSA P-256). 대상 PC가 서버 공개키로 확인한다.
+public sealed class TicketSigner
+{
+ private readonly ECDsa _key;
+ public string PublicKeyBase64 { get; }
+
+ public TicketSigner(string pkcs8Base64)
+ {
+ _key = ECDsa.Create();
+ _key.ImportPkcs8PrivateKey(Convert.FromBase64String(pkcs8Base64), out _);
+ PublicKeyBase64 = Convert.ToBase64String(_key.ExportSubjectPublicKeyInfo());
+ }
+
+ public static string NewPrivateKey()
+ {
+ using var k = ECDsa.Create(ECCurve.NamedCurves.nistP256);
+ return Convert.ToBase64String(k.ExportPkcs8PrivateKey());
+ }
+
+ /// 티켓 = base64url(JSON) + "." + base64url(서명)
+ public string Sign(JsonObject payload)
+ {
+ var body = Encoding.UTF8.GetBytes(payload.ToJsonString());
+ var sig = _key.SignData(body, HashAlgorithmName.SHA256, DSASignatureFormat.IeeeP1363FixedFieldConcatenation);
+ return Secrets.Base64Url(body) + "." + Secrets.Base64Url(sig);
+ }
+}
+
+/// 로그인·등록 실패 제한: 5분 동안 10번 넘게 틀리면 15분 거부
+public sealed class Throttle
+{
+ private readonly ConcurrentDictionary> _fails = new();
+ private readonly ConcurrentDictionary _blocked = new();
+ private static readonly TimeSpan Window = TimeSpan.FromMinutes(5);
+ private static readonly TimeSpan Block = TimeSpan.FromMinutes(15);
+ private const int MaxFails = 10;
+
+ public bool IsBlocked(params string[] keys)
+ {
+ var now = DateTime.UtcNow;
+ foreach (var k in keys)
+ {
+ if (k == null) continue;
+ if (_blocked.TryGetValue(k, out var until))
+ {
+ if (until > now) return true;
+ _blocked.TryRemove(k, out _);
+ }
+ }
+ return false;
+ }
+
+ public void Fail(params string[] keys)
+ {
+ var now = DateTime.UtcNow;
+ foreach (var k in keys)
+ {
+ if (k == null) continue;
+ var list = _fails.GetOrAdd(k, _ => new List());
+ lock (list)
+ {
+ list.Add(now);
+ list.RemoveAll(t => now - t > Window);
+ if (list.Count > MaxFails)
+ {
+ _blocked[k] = now + Block;
+ list.Clear();
+ }
+ }
+ }
+ }
+
+ public void Success(params string[] keys)
+ {
+ foreach (var k in keys)
+ if (k != null) _fails.TryRemove(k, out _);
+ }
+}
diff --git a/tests/RelayTest/Program.cs b/tests/RelayTest/Program.cs
new file mode 100644
index 0000000..60e66b9
--- /dev/null
+++ b/tests/RelayTest/Program.cs
@@ -0,0 +1,425 @@
+using System.Collections.Concurrent;
+using System.Net.WebSockets;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json.Nodes;
+
+var baseUrl = args.Length > 0 ? args[0] : "ws://127.0.0.1:21900";
+var adminPw0 = args.Length > 1 ? args[1] : "firstpass1";
+int pass = 0, fail = 0;
+void Check(bool ok, string name, string extra = "")
+{
+ if (ok) { pass++; Console.WriteLine($" [OK] {name}"); }
+ else { fail++; Console.WriteLine($" [FAIL] {name} {extra}"); }
+}
+
+if (args.Length > 2 && args[2] == "phase2")
+{
+ // 서버 재시작 뒤: 저장해 둔 토큰·기기 키가 그대로 동작하는지 + 아무도 안 붙는 세션 정리
+ var saved = JsonNode.Parse(File.ReadAllText("saved.json"))!.AsObject();
+ Console.WriteLine("== 10. 서버 재시작 뒤");
+ var a2 = await Client.Open(baseUrl + "/ws/manager");
+ var rr = await a2.Req("resume", new() { ["token"] = saved["adminToken"]!.ToString() });
+ Check(rr["ok"]!.GetValue(), "재시작 뒤 로그인 토큰 유지", rr.ToJsonString());
+ var ag = await Client.Open(baseUrl + "/ws/agent");
+ rr = await ag.Req("auth", new() { ["deviceId"] = saved["deviceId"]!.ToString(), ["deviceKey"] = saved["deviceKey"]!.ToString(),
+ ["activeListen"] = new JsonObject { ["mode"] = "Port", ["port"] = 9988 } });
+ Check(rr["ok"]!.GetValue() && rr["serverPublicKey"]!.ToString() == saved["pubKey"]!.ToString(), "재시작 뒤 기기 키·서버 공개키 유지");
+ rr = await a2.Req("connect", new() { ["deviceId"] = saved["deviceId"]!.ToString() });
+ var sidT = rr["sessionId"]!.ToString();
+ Console.WriteLine(" (아무도 중계에 붙지 않고 75초 기다림)");
+ for (int i = 0; i < 15; i++) { await a2.Req("ping"); await ag.Req("ping"); await Task.Delay(5000); }
+ var rowT = Sql($"SELECT mode, end_reason FROM connections WHERE session_id = '{sidT}'");
+ Check(rowT.StartsWith("failed|"), "아무도 안 붙은 세션은 60초 뒤 실패로 정리", rowT);
+ Console.WriteLine($"\n결과: 통과 {pass} / 실패 {fail}");
+ return fail == 0 ? 0 : 1;
+}
+
+Console.WriteLine("== 1. 관리자 로그인 / 비밀번호 변경 강제");
+var admin = await Client.Open(baseUrl + "/ws/manager");
+var r = await admin.Req("login", new() { ["username"] = "admin", ["password"] = "wrong" });
+Check(r["ok"]!.GetValue() == false, "틀린 비밀번호 거부", r.ToJsonString());
+r = await admin.Req("login", new() { ["username"] = "ADMIN", ["password"] = adminPw0, ["host"] = "TESTPC" });
+Check(r["ok"]!.GetValue() && r["user"]!["mustChangePassword"]!.GetValue(), "첫 로그인 성공 + 비밀번호 변경 필요 (대소문자 무시)", r.ToJsonString());
+var adminToken = r["token"]?.ToString();
+r = await admin.Req("devices");
+Check(!r["ok"]!.GetValue(), "비밀번호 바꾸기 전 다른 요청 거부", r.ToJsonString());
+r = await admin.Req("changePassword", new() { ["old"] = adminPw0, ["new"] = "short" });
+Check(!r["ok"]!.GetValue(), "짧은 새 비밀번호 거부");
+r = await admin.Req("changePassword", new() { ["old"] = adminPw0, ["new"] = "AdminPass#2026" });
+Check(r["ok"]!.GetValue() && !r["user"]!["mustChangePassword"]!.GetValue(), "비밀번호 변경", r.ToJsonString());
+
+Console.WriteLine("== 2. 등록 코드 / 대상 PC 등록");
+r = await admin.Req("admin.codes.create", new() { ["note"] = "테스트 PC" });
+var code = r["code"]?.ToString();
+Check(code != null && code.Length == 9, "등록 코드 발급: " + code);
+var agentA = await Client.Open(baseUrl + "/ws/agent");
+r = await agentA.Req("enroll", new() { ["code"] = "AAAA-BBBB", ["hostname"] = "X" });
+Check(!r["ok"]!.GetValue(), "틀린 등록 코드 거부", r["error"]?.ToString());
+agentA = await Client.Open(baseUrl + "/ws/agent");
+r = await agentA.Req("enroll", new()
+{
+ ["code"] = code!.ToLower().Replace("-", " "), ["hostname"] = "HOME-PC", ["os"] = "Windows 11",
+ ["appVersion"] = "2.1.0", ["lanIps"] = new JsonArray("192.168.0.10", "bad-ip"),
+ ["activeListen"] = new JsonObject { ["mode"] = "Port", ["port"] = 9988 }
+});
+Check(r["ok"]!.GetValue(), "등록 성공 (소문자·공백 코드도 허용)", r.ToJsonString());
+var deviceId = r["deviceId"]?.ToString();
+var deviceKey = r["deviceKey"]?.ToString();
+var pubKey = r["serverPublicKey"]?.ToString();
+var other = await Client.Open(baseUrl + "/ws/agent");
+r = await other.Req("enroll", new() { ["code"] = code, ["hostname"] = "Y" });
+Check(!r["ok"]!.GetValue() && r["error"]!.ToString().Contains("이미"), "같은 코드 두 번째 사용 거부", r["error"]?.ToString());
+
+// 등록한 연결을 끊고 기기 키로 다시 접속
+await agentA.CloseAsync();
+await Task.Delay(300);
+var agent = await Client.Open(baseUrl + "/ws/agent");
+r = await agent.Req("auth", new() { ["deviceId"] = deviceId, ["deviceKey"] = "wrongkey" });
+Check(!r["ok"]!.GetValue() && r["code"]?.ToString() == "notEnrolled", "틀린 기기 키 거부");
+agent = await Client.Open(baseUrl + "/ws/agent");
+var presenceTask = admin.WaitEvent("presence", TimeSpan.FromSeconds(5));
+r = await agent.Req("auth", new()
+{
+ ["deviceId"] = deviceId, ["deviceKey"] = deviceKey, ["hostname"] = "HOME-PC",
+ ["lanIps"] = new JsonArray("192.168.0.10"), ["activeListen"] = new JsonObject { ["mode"] = "Port", ["port"] = 9988 }
+});
+Check(r["ok"]!.GetValue() && r["serverPublicKey"]?.ToString() == pubKey, "기기 키로 접속", r.ToJsonString());
+var pres = await presenceTask;
+Check(pres != null && pres["online"]!.GetValue(), "관리 PC에 온라인 알림", pres?.ToJsonString());
+
+r = await admin.Req("devices");
+Check(r["devices"]!.AsArray().Count == 1 && r["devices"]![0]!["online"]!.GetValue(), "관리자 기기 목록 (온라인)", r.ToJsonString());
+
+Console.WriteLine("== 3. 일반 사용자 권한");
+r = await admin.Req("admin.users.create", new() { ["username"] = "bob", ["password"] = "BobPass#123", ["role"] = "user", ["mustChangePassword"] = false });
+Check(r["ok"]!.GetValue(), "사용자 bob 생성", r.ToJsonString());
+var bobId = r["userId"]!.GetValue();
+r = await admin.Req("admin.users.create", new() { ["username"] = "Bob", ["password"] = "BobPass#123" });
+Check(!r["ok"]!.GetValue(), "대소문자만 다른 같은 아이디 거부");
+var bob = await Client.Open(baseUrl + "/ws/manager");
+r = await bob.Req("login", new() { ["username"] = "bob", ["password"] = "BobPass#123" });
+Check(r["ok"]!.GetValue(), "bob 로그인");
+var bobToken = r["token"]!.ToString();
+r = await bob.Req("devices");
+Check(r["devices"]!.AsArray().Count == 0, "권한 없으면 기기 안 보임");
+r = await bob.Req("connect", new() { ["deviceId"] = deviceId });
+Check(!r["ok"]!.GetValue(), "권한 없으면 연결 거부", r["error"]?.ToString());
+r = await bob.Req("admin.users.list");
+Check(!r["ok"]!.GetValue(), "일반 사용자는 [서버 관리] 거부");
+var changed = bob.WaitEvent("devicesChanged", TimeSpan.FromSeconds(5));
+r = await admin.Req("admin.perms.set", new() { ["userId"] = bobId, ["deviceId"] = deviceId, ["control"] = true });
+Check(r["ok"]!.GetValue() && r["view"]!.GetValue() && r["control"]!.GetValue() && !r["screenView"]!.GetValue(),
+ "권한 설정: 서비스 제어 → 보기 자동 포함", r.ToJsonString());
+Check(await changed != null, "bob에게 목록 변경 알림");
+r = await bob.Req("devices");
+Check(r["devices"]!.AsArray().Count == 1, "권한 생기면 기기 보임");
+
+Console.WriteLine("== 4. 연결 요청 + 티켓 + 중계");
+var incomingTask = agent.WaitEvent("incoming", TimeSpan.FromSeconds(5));
+r = await bob.Req("connect", new() { ["deviceId"] = deviceId });
+Check(r["ok"]!.GetValue(), "연결 요청", r.ToJsonString());
+var sid = r["sessionId"]!.ToString();
+var mTok = r["relayToken"]!.ToString();
+var ticket = r["ticket"]!.ToString();
+var cands = r["candidates"]!.AsArray();
+Check(cands.Count >= 1 && cands.Any(c => c!["host"]!.ToString() == "192.168.0.10" && c["port"]!.GetValue() == 9988),
+ "직접 연결 후보 (내부 IP:Active 포트)", cands.ToJsonString());
+Check(VerifyTicket(ticket, pubKey!, out var tp) && tp!["sid"]!.ToString() == sid && tp["deviceId"]!.ToString() == deviceId
+ && tp["username"]!.ToString() == "bob" && tp["perms"]!["control"]!.GetValue() && !tp["perms"]!["screenView"]!.GetValue(),
+ "티켓 서명 확인 (서버 공개키) + 내용", tp?.ToJsonString());
+Check(!VerifyTicket(Tamper(ticket), pubKey!, out _), "변조한 티켓은 서명 실패");
+var inc = await incomingTask;
+Check(inc != null && inc["sessionId"]!.ToString() == sid && inc["username"]!.ToString() == "bob", "대상 PC에 incoming 알림");
+var aTok = inc!["relayToken"]!.ToString();
+
+var bad = await Raw.TryOpen($"{baseUrl}/relay/{sid}?side=manager&token=wrong");
+Check(bad == null, "틀린 중계 토큰 거부");
+var relayA = await Raw.TryOpen($"{baseUrl}/relay/{sid}?side=agent&token={aTok}");
+var relayM = await Raw.TryOpen($"{baseUrl}/relay/{sid}?side=manager&token={mTok}");
+Check(relayA != null && relayM != null, "양쪽 중계 통로 접속");
+var reuse = await Raw.TryOpen($"{baseUrl}/relay/{sid}?side=manager&token={mTok}");
+Check(reuse == null, "같은 중계 토큰 재사용 거부");
+
+var big = RandomNumberGenerator.GetBytes(1_000_000);
+var echoTask = Task.Run(async () =>
+{
+ // 대상 PC 쪽: 받은 것을 그대로 돌려보냄
+ var got = await Raw.ReceiveMessage(relayA!);
+ await relayA!.SendAsync(got, WebSocketMessageType.Binary, true, CancellationToken.None);
+ return got.Length;
+});
+await relayM!.SendAsync(big, WebSocketMessageType.Binary, true, CancellationToken.None);
+var back = await Raw.ReceiveMessage(relayM);
+Check(back.SequenceEqual(big) && await echoTask == big.Length, "1MB 데이터 왕복 (관리 PC → 서버 → 대상 PC → 서버 → 관리 PC)");
+await relayM.CloseAsync(WebSocketCloseStatus.NormalClosure, "done", CancellationToken.None);
+var aEnd = await Raw.WaitClosed(relayA!, TimeSpan.FromSeconds(5));
+Check(aEnd, "한쪽이 닫으면 다른 쪽도 닫힘");
+string row = "";
+for (int i = 0; i < 25; i++)
+{
+ row = Sql($"SELECT mode, bytes_to_device, bytes_from_device, ended_at IS NOT NULL FROM connections WHERE session_id = '{sid}'");
+ if (row.EndsWith("|t")) break;
+ await Task.Delay(200);
+}
+Check(row.StartsWith("relay|1000000|1000000|t"), "연결 기록: relay, 트래픽 1MB/1MB, 종료 시각", row);
+
+Console.WriteLine("== 5. 직접 연결 성공 → 중계 취소");
+incomingTask = agent.WaitEvent("incoming", TimeSpan.FromSeconds(5));
+r = await bob.Req("connect", new() { ["deviceId"] = deviceId });
+var sid2 = r["sessionId"]!.ToString();
+inc = await incomingTask;
+var relayA2 = await Raw.TryOpen($"{baseUrl}/relay/{sid2}?side=agent&token={inc!["relayToken"]}");
+var cancelTask = agent.WaitEvent("cancel", TimeSpan.FromSeconds(5));
+r = await bob.Req("connectStatus", new() { ["sessionId"] = sid2, ["status"] = "direct" });
+Check(r["ok"]!.GetValue(), "직접 연결 성공 알림");
+Check((await cancelTask)?["sessionId"]?.ToString() == sid2, "대상 PC에 cancel 알림");
+Check(await Raw.WaitClosed(relayA2!, TimeSpan.FromSeconds(5)), "기다리던 중계 통로 닫힘");
+var lateM = await Raw.TryOpen($"{baseUrl}/relay/{sid2}?side=manager&token={r["relayToken"]}");
+Check(lateM == null, "직접 연결된 세션의 중계 접속 거부");
+r = await bob.Req("connectStatus", new() { ["sessionId"] = sid2, ["status"] = "ended", ["bytesToDevice"] = 1234, ["bytesFromDevice"] = 5678, ["reason"] = "사용자가 닫음" });
+await Task.Delay(300);
+row = Sql($"SELECT mode, bytes_to_device, bytes_from_device, end_reason FROM connections WHERE session_id = '{sid2}'");
+Check(row == "direct|1234|5678|사용자가 닫음", "연결 기록: direct + 관리 PC가 보고한 트래픽", row);
+r = await admin.Req("connectStatus", new() { ["sessionId"] = sid2, ["status"] = "ended" });
+Check(!r["ok"]!.GetValue(), "다른 사용자의 연결 상태 변경 거부");
+
+Console.WriteLine("== 6. 감사 로그 (대상 PC 보고) / 조회");
+r = await agent.Req("audit", new() { ["userId"] = bobId, ["username"] = "bob", ["action"] = "stop", ["target"] = "Spooler", ["result"] = "ok" });
+Check(r["ok"]!.GetValue(), "대상 PC 감사 로그 보고");
+r = await admin.Req("admin.audit.query", new() { ["deviceId"] = deviceId, ["action"] = "stop" });
+var rows = r["rows"]!.AsArray();
+Check(rows.Count == 1 && rows[0]!["target"]!.ToString() == "Spooler" && rows[0]!["deviceName"]!.ToString() == "HOME-PC", "감사 로그 조회 (기기·작업 필터)", r.ToJsonString());
+r = await admin.Req("admin.audit.query", new() { ["limit"] = 100 });
+var actions = r["rows"]!.AsArray().Select(x => x!["action"]!.ToString()).ToHashSet();
+Check(new[] { "login", "loginFail", "passwordChange", "codeCreate", "deviceEnroll", "userCreate", "permChange", "connect" }.All(actions.Contains),
+ "서버 작업도 감사 로그에 기록", string.Join(",", actions));
+r = await admin.Req("admin.connections.query", new() { ["deviceId"] = deviceId });
+Check(r["rows"]!.AsArray().Count == 2, "연결 기록 조회", r["rows"]!.AsArray().Count.ToString());
+r = await admin.Req("admin.server.info");
+Check(r["onlineDevices"]!.GetValue() == 1 && r["onlineManagers"]!.GetValue() == 2 && r["db"]!.GetValue(), "서버 상태", r.ToJsonString());
+r = await admin.Req("admin.codes.list");
+Check(r["codes"]![0]!["status"]!.ToString() == "used" && r["codes"]![0]!["usedDevice"]!.ToString() == "HOME-PC", "등록 코드 목록 (사용됨)", r.ToJsonString());
+
+Console.WriteLine("== 7. 재접속 (토큰) / 로그아웃");
+var bob2 = await Client.Open(baseUrl + "/ws/manager");
+r = await bob2.Req("resume", new() { ["token"] = bobToken });
+Check(r["ok"]!.GetValue(), "토큰으로 재접속");
+r = await bob2.Req("logout");
+var bob3 = await Client.Open(baseUrl + "/ws/manager");
+r = await bob3.Req("resume", new() { ["token"] = bobToken });
+Check(!r["ok"]!.GetValue() && r["code"]?.ToString() == "loginRequired", "로그아웃한 토큰 거부");
+var adm2 = await Client.Open(baseUrl + "/ws/manager");
+r = await adm2.Req("resume", new() { ["token"] = adminToken });
+Check(r["ok"]!.GetValue(), "비밀번호를 바꾼 연결의 토큰은 계속 유효", r.ToJsonString());
+
+Console.WriteLine("== 8. 관리자 보호 / 사용 안 함");
+r = await admin.Req("admin.users.update", new() { ["userId"] = 1, ["role"] = "user" });
+Check(!r["ok"]!.GetValue(), "마지막 관리자 역할 변경 거부", r["error"]?.ToString());
+bob = await Client.Open(baseUrl + "/ws/manager");
+await bob.Req("login", new() { ["username"] = "bob", ["password"] = "BobPass#123" });
+var outTask = bob.WaitEvent("loggedOut", TimeSpan.FromSeconds(5));
+r = await admin.Req("admin.users.update", new() { ["userId"] = bobId, ["enabled"] = false });
+Check(r["ok"]!.GetValue() && await outTask != null, "사용자 사용 안 함 → 접속 중인 연결 끊김");
+var bob4 = await Client.Open(baseUrl + "/ws/manager");
+r = await bob4.Req("login", new() { ["username"] = "bob", ["password"] = "BobPass#123" });
+Check(!r["ok"]!.GetValue(), "사용 안 함 계정 로그인 거부", r["error"]?.ToString());
+
+var disTask = agent.WaitEvent("disabled", TimeSpan.FromSeconds(5));
+var offTask = admin.WaitEvent("presence", TimeSpan.FromSeconds(5));
+r = await admin.Req("admin.devices.disable", new() { ["deviceId"] = deviceId });
+Check(r["ok"]!.GetValue() && await disTask != null, "기기 사용 안 함 → 대상 PC에 알림");
+var off = await offTask;
+Check(off != null && !off["online"]!.GetValue(), "관리 PC에 오프라인 알림");
+var agent2 = await Client.Open(baseUrl + "/ws/agent");
+r = await agent2.Req("auth", new() { ["deviceId"] = deviceId, ["deviceKey"] = deviceKey });
+Check(!r["ok"]!.GetValue() && r["code"]?.ToString() == "disabled", "사용 안 함 기기 접속 거부");
+await admin.Req("admin.devices.enable", new() { ["deviceId"] = deviceId });
+agent2 = await Client.Open(baseUrl + "/ws/agent");
+r = await agent2.Req("auth", new() { ["deviceId"] = deviceId, ["deviceKey"] = deviceKey });
+Check(r["ok"]!.GetValue(), "다시 사용 → 접속됨");
+var agent3 = await Client.Open(baseUrl + "/ws/agent");
+var replaced = agent2.WaitEvent("replaced", TimeSpan.FromSeconds(5));
+r = await agent3.Req("auth", new() { ["deviceId"] = deviceId, ["deviceKey"] = deviceKey });
+Check(r["ok"]!.GetValue() && await replaced != null, "같은 기기 두 번째 접속 → 이전 연결 교체");
+
+Console.WriteLine("== 9. 로그인 실패 제한");
+string last = "";
+for (int i = 0; i < 12; i++)
+{
+ var t = await Client.Open(baseUrl + "/ws/manager");
+ var x = await t.Req("login", new() { ["username"] = "nobody", ["password"] = "x" });
+ last = x["error"]?.ToString() ?? "";
+ await t.CloseAsync();
+}
+Check(last.Contains("너무 많습니다"), "11번 넘게 틀리면 잠시 거부", last);
+
+File.WriteAllText("saved.json", new JsonObject { ["adminToken"] = adminToken, ["deviceId"] = deviceId, ["deviceKey"] = deviceKey, ["pubKey"] = pubKey }.ToJsonString());
+Console.WriteLine($"\n결과: 통과 {pass} / 실패 {fail}");
+return fail == 0 ? 0 : 1;
+
+static string Sql(string q)
+{
+ var p = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo("psql",
+ $"-h 127.0.0.1 -p 55432 -U jjsm -d jjsm -At -c \"{q}\"") { RedirectStandardOutput = true })!;
+ var s = p.StandardOutput.ReadToEnd().Trim();
+ p.WaitForExit();
+ return s;
+}
+
+static bool VerifyTicket(string ticket, string pubKeyB64, out JsonObject payload)
+{
+ payload = null;
+ try
+ {
+ var parts = ticket.Split('.');
+ var body = B64(parts[0]);
+ var sig = B64(parts[1]);
+ using var k = ECDsa.Create();
+ k.ImportSubjectPublicKeyInfo(Convert.FromBase64String(pubKeyB64), out _);
+ if (!k.VerifyData(body, sig, HashAlgorithmName.SHA256, DSASignatureFormat.IeeeP1363FixedFieldConcatenation)) return false;
+ payload = JsonNode.Parse(body) as JsonObject;
+ return true;
+ }
+ catch { return false; }
+}
+
+static string Tamper(string ticket)
+{
+ var parts = ticket.Split('.');
+ var body = JsonNode.Parse(B64(parts[0]))!.AsObject();
+ body["perms"]!["screenControl"] = true;
+ return Convert.ToBase64String(Encoding.UTF8.GetBytes(body.ToJsonString())).TrimEnd('=').Replace('+', '-').Replace('/', '_') + "." + parts[1];
+}
+
+static byte[] B64(string s)
+{
+ s = s.Replace('-', '+').Replace('_', '/');
+ switch (s.Length % 4) { case 2: s += "=="; break; case 3: s += "="; break; }
+ return Convert.FromBase64String(s);
+}
+
+sealed class Client
+{
+ private ClientWebSocket _ws = new();
+ private long _id;
+ private readonly ConcurrentDictionary> _pending = new();
+ private readonly List<(string ev, TaskCompletionSource tcs)> _waiters = new();
+ private readonly List _events = new();
+
+ public static async Task Open(string url)
+ {
+ var c = new Client();
+ await c._ws.ConnectAsync(new Uri(url), CancellationToken.None);
+ _ = c.ReadLoop();
+ return c;
+ }
+
+ private async Task ReadLoop()
+ {
+ var buf = new byte[1 << 20];
+ try
+ {
+ while (true)
+ {
+ using var ms = new MemoryStream();
+ WebSocketReceiveResult r;
+ do
+ {
+ r = await _ws.ReceiveAsync(buf, CancellationToken.None);
+ if (r.MessageType == WebSocketMessageType.Close) { Fail(); return; }
+ ms.Write(buf, 0, r.Count);
+ } while (!r.EndOfMessage);
+ var o = JsonNode.Parse(ms.ToArray())!.AsObject();
+ if (o["event"] != null)
+ {
+ lock (_waiters)
+ {
+ var w = _waiters.FirstOrDefault(x => x.ev == o["event"]!.ToString());
+ if (w.tcs != null) { _waiters.Remove(w); w.tcs.TrySetResult(o); }
+ else _events.Add(o);
+ }
+ }
+ else if (o["id"] != null && _pending.TryRemove(o["id"]!.GetValue(), out var t)) t.TrySetResult(o);
+ }
+ }
+ catch { Fail(); }
+ }
+
+ private void Fail()
+ {
+ foreach (var p in _pending.Values) p.TrySetResult(new JsonObject { ["ok"] = false, ["error"] = "연결 끊김" });
+ }
+
+ public async Task Req(string op, JsonObject args = null)
+ {
+ var id = Interlocked.Increment(ref _id);
+ var o = args ?? new JsonObject();
+ o["id"] = id;
+ o["op"] = op;
+ var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ _pending[id] = tcs;
+ try { await _ws.SendAsync(Encoding.UTF8.GetBytes(o.ToJsonString()), WebSocketMessageType.Text, true, CancellationToken.None); }
+ catch { return new JsonObject { ["ok"] = false, ["error"] = "보내기 실패" }; }
+ var done = await Task.WhenAny(tcs.Task, Task.Delay(10000));
+ return done == tcs.Task ? tcs.Task.Result : new JsonObject { ["ok"] = false, ["error"] = "시간 초과" };
+ }
+
+ public async Task WaitEvent(string ev, TimeSpan timeout)
+ {
+ TaskCompletionSource tcs;
+ lock (_waiters)
+ {
+ var e = _events.FirstOrDefault(x => x["event"]!.ToString() == ev);
+ if (e != null) { _events.Remove(e); return e; }
+ tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ _waiters.Add((ev, tcs));
+ }
+ var d = await Task.WhenAny(tcs.Task, Task.Delay(timeout));
+ return d == tcs.Task ? tcs.Task.Result : null;
+ }
+
+ public async Task CloseAsync()
+ {
+ try { await _ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "", CancellationToken.None); } catch { }
+ }
+}
+
+static class Raw
+{
+ public static async Task TryOpen(string url)
+ {
+ var ws = new ClientWebSocket();
+ try { await ws.ConnectAsync(new Uri(url), CancellationToken.None); return ws; }
+ catch { return null; }
+ }
+
+ public static async Task ReceiveMessage(ClientWebSocket ws)
+ {
+ var buf = new byte[64 * 1024];
+ using var ms = new MemoryStream();
+ WebSocketReceiveResult r;
+ do
+ {
+ r = await ws.ReceiveAsync(buf, CancellationToken.None);
+ ms.Write(buf, 0, r.Count);
+ } while (!r.EndOfMessage);
+ return ms.ToArray();
+ }
+
+ public static async Task WaitClosed(ClientWebSocket ws, TimeSpan timeout)
+ {
+ try
+ {
+ using var cts = new CancellationTokenSource(timeout);
+ var buf = new byte[1024];
+ while (true)
+ {
+ var r = await ws.ReceiveAsync(buf, cts.Token);
+ if (r.MessageType == WebSocketMessageType.Close) return true;
+ }
+ }
+ catch (OperationCanceledException) { return false; }
+ catch { return true; }
+ }
+}
diff --git a/tests/RelayTest/README.md b/tests/RelayTest/README.md
new file mode 100644
index 0000000..70f86e1
--- /dev/null
+++ b/tests/RelayTest/README.md
@@ -0,0 +1,9 @@
+# RelayTest
+
+중계 서버 자동 점검 (60개 항목). 로컬 PostgreSQL(127.0.0.1:55432, 사용자 jjsm, DB jjsm)과 서버(21900)를 띄운 뒤:
+
+```sh
+dotnet run -c Release -- ws://127.0.0.1:21900 첫관리자비밀번호
+# 서버 재시작 뒤
+dotnet run -c Release -- ws://127.0.0.1:21900 x phase2
+```
diff --git a/tests/RelayTest/relaytest.csproj b/tests/RelayTest/relaytest.csproj
new file mode 100644
index 0000000..12a235a
--- /dev/null
+++ b/tests/RelayTest/relaytest.csproj
@@ -0,0 +1,8 @@
+
+
+ Exe
+ net8.0
+ disable
+ enable
+
+