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 + +