중계 서버 1.0.0: 등록·로그인·권한·중개·중계·감사 로그·연결 기록, Docker 구성, NAS 설치 안내

- server/: ASP.NET Core WebSocket (/ws/agent, /ws/manager, /relay), PostgreSQL 스키마 자동 적용
- 권한 티켓 ECDSA P-256 서명, 로그인·등록 실패 제한
- docker-compose: jjsm-db(PostgreSQL 16), jjsm-server(127.0.0.1:21900), jjsm-backup(매일, 30개)
- tests/RelayTest: 자동 점검 60개 항목

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013U497kUNL8ArhV7jkzCpKa
main
diadmin 2026-10-27 06:36:32 +00:00
parent 887b03b147
commit 63ad4012e1
No known key found for this signature in database
22 changed files with 3348 additions and 9 deletions

5
.dockerignore Normal file
View File

@ -0,0 +1,5 @@
**/bin
**/obj
.git
.env
docs

18
.env.example Normal file
View File

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

6
.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
.env
bin/
obj/
*.user
.vs/
saved.json

24
Dockerfile Normal file
View File

@ -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"]

View File

@ -1,6 +1,36 @@
# JJServiceManagerRelay # 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
```

39
backup/backup.sh Executable file
View File

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

77
docker-compose.yml Normal file
View File

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

128
docs/NAS설치.md Normal file
View File

@ -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에서 직접 초기화하는 방법 안내).

View File

@ -273,12 +273,10 @@ server_settings (key text primary key, value text not null)
``` ```
/volume1/docker/jjsm/ /volume1/docker/jjsm/
├─ docker-compose.yml ├─ src/ ← JJServiceManagerRelay 소스 (Gitea 압축 파일) + docker-compose.yml + .env(비밀번호)
├─ .env ← 비밀번호 (POSTGRES_PASSWORD, JJSM_ADMIN_PASSWORD)
├─ server/ ← 중계 서버 로그
├─ db/ ← PostgreSQL 데이터 ├─ db/ ← PostgreSQL 데이터
├─ backup/ ← 매일 pg_dump, 최근 30개 보관 (오래된 백업 파일부터 정리) ├─ server/logs/ ← 중계 서버 로그 (날짜별, 지우지 않음)
└─ src/ ← JJServiceManagerRelay 소스 (Gitea에서 받음, 빌드용) └─ 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-server` | NAS에서 직접 빌드 (`dotnet/sdk:8.0`로 빌드 → `dotnet/aspnet:8.0`으로 실행) | `127.0.0.1:21900` → 리버스 프록시만 접근 |
| `jjsm-backup` | `postgres:16-alpine` + cron 스크립트 | 없음 | | `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/`를 새 버전으로 바꾸고 다시 빌드. - 빌드는 NAS에서 직접 한다 (DS1515+ 기준 수 분~10분 정도 예상). 업데이트 때는 `src/`를 새 버전으로 바꾸고 다시 빌드.
- 리버스 프록시: HTTPS `jjsm.joynala.com:443` → HTTP `localhost:21900`, 사용자 지정 머리글 **WebSocket** - 리버스 프록시: HTTPS `jjsm.joynala.com:443` → HTTP `localhost:21900`, 사용자 지정 머리글 **WebSocket**
- 서버 상태 확인: `https://jjsm.joynala.com/health` - 서버 상태 확인: `https://jjsm.joynala.com/health`

258
server/AgentHandler.cs Normal file
View File

@ -0,0 +1,258 @@
using System.Net.WebSockets;
using System.Security.Cryptography;
using System.Text.Json.Nodes;
namespace JJServiceManagerRelay;
/// <summary>/ws/agent — 대상 PC</summary>
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");
}
}
/// <summary>첫 메시지: enroll(등록 코드) 또는 auth(기기 키). 15초 안에 와야 함</summary>
private async Task<AgentConn> 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<JsonObject> 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<JsonObject> a)
{
a(o);
return o;
}
}

264
server/Common.cs Normal file
View File

@ -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;
/// <summary>서버 설정 (환경 변수)</summary>
public sealed class ServerConfig
{
public string ConnectionString { get; init; }
public int Port { get; init; } = 21900;
public string LogDir { get; init; } = "logs";
public string AdminUser { get; init; } = "";
public string AdminPassword { get; init; } = "";
public TimeSpan TokenLife { get; init; } = TimeSpan.FromHours(12);
public TimeSpan DefaultCodeLife { get; init; } = TimeSpan.FromDays(7);
public List<(IPAddress net, int bits)> TrustedProxies { get; init; } = new();
public static ServerConfig FromEnvironment()
{
string Env(string k, string d = "") => Environment.GetEnvironmentVariable(k) is { Length: > 0 } v ? v : d;
var cs = Env("JJSM_DB");
if (string.IsNullOrEmpty(cs))
{
cs = $"Host={Env("POSTGRES_HOST", "localhost")};Port={Env("POSTGRES_PORT", "5432")};" +
$"Database={Env("POSTGRES_DB", "jjsm")};Username={Env("POSTGRES_USER", "jjsm")};" +
$"Password={Env("POSTGRES_PASSWORD", "")};Maximum Pool Size=50";
}
var proxies = new List<(IPAddress, int)>();
foreach (var part in Env("JJSM_TRUSTED_PROXIES", "127.0.0.0/8,::1/128,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16")
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
var p = part.Split('/');
if (IPAddress.TryParse(p[0], out var ip))
proxies.Add((ip, p.Length > 1 && int.TryParse(p[1], out var b) ? b : (ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork ? 32 : 128)));
}
return new ServerConfig
{
ConnectionString = cs,
Port = int.TryParse(Env("JJSM_PORT", "21900"), out var port) ? port : 21900,
LogDir = Env("JJSM_LOG_DIR", "logs"),
AdminUser = Env("JJSM_ADMIN_USER"),
AdminPassword = Env("JJSM_ADMIN_PASSWORD"),
DefaultCodeLife = TimeSpan.FromHours(double.TryParse(Env("JJSM_CODE_HOURS", "168"), out var h) ? h : 168),
TrustedProxies = proxies
};
}
public bool IsTrustedProxy(IPAddress ip)
{
if (ip == null) return false;
if (ip.IsIPv4MappedToIPv6) ip = ip.MapToIPv4();
foreach (var (net, bits) in TrustedProxies)
{
if (net.AddressFamily != ip.AddressFamily) continue;
var a = net.GetAddressBytes();
var b = ip.GetAddressBytes();
int full = bits / 8, rest = bits % 8;
bool ok = true;
for (int i = 0; i < full && ok; i++) ok = a[i] == b[i];
if (ok && rest > 0)
{
int mask = 0xFF << (8 - rest) & 0xFF;
ok = (a[full] & mask) == (b[full] & mask);
}
if (ok) return true;
}
return false;
}
/// <summary>실제 접속한 쪽 IP (리버스 프록시 뒤라면 X-Real-IP / X-Forwarded-For)</summary>
public string ClientIp(HttpContext ctx)
{
var remote = ctx.Connection.RemoteIpAddress;
if (IsTrustedProxy(remote))
{
var real = ctx.Request.Headers["X-Real-IP"].ToString();
if (IPAddress.TryParse(real, out var r)) return Clean(r);
var xff = ctx.Request.Headers["X-Forwarded-For"].ToString();
if (!string.IsNullOrEmpty(xff))
{
var last = xff.Split(',').Select(s => s.Trim()).LastOrDefault();
if (IPAddress.TryParse(last, out var f)) return Clean(f);
}
}
return remote == null ? "" : Clean(remote);
}
private static string Clean(IPAddress ip) => (ip.IsIPv4MappedToIPv6 ? ip.MapToIPv4() : ip).ToString();
}
/// <summary>콘솔 + 날짜별 파일 로그 (추가만, 지우지 않음)</summary>
public sealed class ServerLog
{
private readonly string _dir;
private readonly object _lock = new();
public ServerLog(string dir)
{
_dir = dir;
try { Directory.CreateDirectory(dir); } catch { }
}
public void Info(string m) => Write("INFO", m);
public void Warn(string m) => Write("WARN", m);
public void Error(string m) => Write("ERROR", m);
private void Write(string level, string m)
{
var now = DateTime.Now;
var line = $"{now:yyyy-MM-dd HH:mm:ss} [{level}] {m}";
lock (_lock)
{
Console.WriteLine(line);
try { File.AppendAllText(Path.Combine(_dir, $"{now:yyyy-MM-dd}.log"), line + Environment.NewLine, Encoding.UTF8); }
catch { }
}
}
}
/// <summary>WebSocket 위 JSON 메시지 (텍스트 프레임 하나 = 메시지 하나)</summary>
public sealed class WsJson
{
private readonly WebSocket _ws;
private readonly SemaphoreSlim _send = new(1, 1);
private const int MaxMessage = 4 * 1024 * 1024;
public WsJson(WebSocket ws) => _ws = ws;
public WebSocketState State => _ws.State;
public async Task<JsonObject> ReceiveAsync(CancellationToken ct)
{
var buf = new byte[16 * 1024];
using var ms = new MemoryStream();
while (true)
{
var r = await _ws.ReceiveAsync(buf, ct);
if (r.MessageType == WebSocketMessageType.Close) return null;
ms.Write(buf, 0, r.Count);
if (ms.Length > MaxMessage) throw new InvalidDataException("메시지가 너무 큽니다.");
if (r.EndOfMessage) break;
}
if (ms.Length == 0) return new JsonObject();
try
{
return JsonNode.Parse(ms.ToArray()) as JsonObject ?? throw new InvalidDataException("JSON 객체가 아닙니다.");
}
catch (JsonException)
{
throw new InvalidDataException("JSON 형식이 아닙니다.");
}
}
public async Task SendAsync(JsonObject msg, CancellationToken ct = default)
{
var bytes = Encoding.UTF8.GetBytes(msg.ToJsonString());
await _send.WaitAsync(ct);
try
{
if (_ws.State == WebSocketState.Open)
await _ws.SendAsync(bytes, WebSocketMessageType.Text, true, ct);
}
finally
{
_send.Release();
}
}
/// <summary>보내기 실패는 무시 (연결이 끊긴 상대에게 알림 보낼 때)</summary>
public async Task TrySendAsync(JsonObject msg)
{
try
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
await SendAsync(msg, cts.Token);
}
catch { }
}
public async Task CloseAsync(string reason)
{
try
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
if (_ws.State == WebSocketState.Open || _ws.State == WebSocketState.CloseReceived)
await _ws.CloseAsync(WebSocketCloseStatus.NormalClosure, Truncate(reason), cts.Token);
}
catch { }
finally
{
try { _ws.Abort(); } catch { }
}
}
/// <summary>WebSocket 닫기 사유는 123바이트까지</summary>
private static string Truncate(string s)
{
s ??= "";
while (Encoding.UTF8.GetByteCount(s) > 120) s = s[..^1];
return s;
}
public static JsonObject Ok(long? id, JsonObject extra = null)
{
var o = extra ?? new JsonObject();
if (id != null) o["id"] = id;
o["ok"] = true;
return o;
}
public static JsonObject Error(long? id, string message)
{
var o = new JsonObject { ["ok"] = false, ["error"] = message };
if (id != null) o["id"] = id;
return o;
}
}
/// <summary>요청이 잘못되었을 때 (메시지를 그대로 응답)</summary>
public sealed class UserError : Exception
{
public UserError(string message) : base(message) { }
}
public static class J
{
public static string Str(JsonObject o, string k, string d = "")
{
try { return o?[k]?.GetValue<string>() ?? d; } catch { return o?[k]?.ToString() ?? d; }
}
public static long? Long(JsonObject o, string k)
{
try { return o?[k]?.GetValue<long>(); }
catch
{
return long.TryParse(o?[k]?.ToString(), out var v) ? v : null;
}
}
public static bool? Bool(JsonObject o, string k)
{
try { return o?[k]?.GetValue<bool>(); } catch { return null; }
}
public static Guid? Guid(JsonObject o, string k) => System.Guid.TryParse(Str(o, k), out var g) ? g : null;
public static DateTime? Time(JsonObject o, string k) =>
DateTime.TryParse(Str(o, k), null, System.Globalization.DateTimeStyles.AdjustToUniversal | System.Globalization.DateTimeStyles.AssumeUniversal, out var t)
? DateTime.SpecifyKind(t, DateTimeKind.Utc) : null;
public static string RequireStr(JsonObject o, string k, string label, int max = 200)
{
var s = Str(o, k).Trim();
if (s.Length == 0) throw new UserError($"{label}을(를) 입력하세요.");
if (s.Length > max) throw new UserError($"{label}이(가) 너무 깁니다.");
return s;
}
}

696
server/Db.cs Normal file
View File

@ -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
};
}
/// <summary>PostgreSQL 접근</summary>
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<NpgsqlConnection> 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<string> 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<string> GetSettingAsync(string key)
{
await using var c = await OpenAsync();
return await GetSetting(c, key);
}
/// <summary>없을 때만 저장하고, 실제 저장된 값을 돌려줌</summary>
public async Task<string> GetOrCreateSettingAsync(string key, Func<string> 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<bool> 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<long> 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<UserRow> 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<UserRow> 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<List<UserRow>> ListUsersAsync()
{
var list = new List<UserRow>();
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<long> 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<long> 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();
}
/// <summary>유효한 토큰이면 사용자 ID (만료 시간 연장)</summary>
public async Task<long?> 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();
}
/// <summary>만료된 로그인 토큰 정리 (로그가 아니라 인증 정보이므로 정리함)</summary>
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<DeviceRow> 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<List<DeviceRow>> ListDevicesAsync()
{
var list = new List<DeviceRow>();
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;
}
/// <summary>등록 코드를 쓰고 기기를 만든다 (한 트랜잭션). 실패 사유 문자열 또는 null</summary>
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<Perm> 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<Dictionary<Guid, Perm>> GetPermsForUserAsync(long userId)
{
var map = new Dictionary<Guid, Perm>();
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<long> 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<JsonArray> 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<bool> 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<JsonArray> 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();
}
/// <summary>서버가 재시작되어 끝을 못 적은 연결 정리</summary>
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<bool> 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<JsonArray> 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];
}

197
server/Hub.cs Normal file
View File

@ -0,0 +1,197 @@
using System.Collections.Concurrent;
using System.Net.WebSockets;
using System.Text.Json.Nodes;
namespace JJServiceManagerRelay;
/// <summary>접속 중인 대상 PC</summary>
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; }
}
/// <summary>로그인한 관리 PC</summary>
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<Guid, Perm> 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;
}
/// <summary>관리 PC ↔ 대상 PC 연결 하나 (중개 → 직접 또는 중계)</summary>
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);
/// <summary>직접 연결 성공 → 중계 통로에서 기다리던 쪽을 깨움</summary>
public readonly TaskCompletionSource Wake = new(TaskCreationOptions.RunContinuationsAsynchronously);
}
/// <summary>서버 전체 상태</summary>
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<Guid, AgentConn> Agents { get; } = new();
public ConcurrentDictionary<Guid, ManagerConn> Managers { get; } = new();
public ConcurrentDictionary<Guid, RelaySession> 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);
// ───────────────────────── 알림 ─────────────────────────
/// <summary>기기 온라인/오프라인을 볼 수 있는 관리 PC에 알림</summary>
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());
}
}
/// <summary>기기 목록·권한이 바뀜 → 관리 PC들이 목록을 다시 받게</summary>
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" });
}
}
/// <summary>사용자 계정이 바뀜 (사용 안 함·역할 변경) → 그 사용자의 연결을 다시 확인</summary>
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" });
}
}
/// <summary>기기를 사용 안 함/삭제 → 접속 중이면 끊음</summary>
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);
}
/// <summary>30초마다: 아무도 붙지 않은 세션 정리, 만료 토큰 정리</summary>
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); }
}
}
}

View File

@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>disable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<AssemblyName>jjsm-server</AssemblyName>
<RootNamespace>JJServiceManagerRelay</RootNamespace>
<Version>1.0.0</Version>
<InvariantGlobalization>false</InvariantGlobalization>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Npgsql" Version="8.0.5" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Schema\*.sql" />
</ItemGroup>
</Project>

647
server/ManagerHandler.cs Normal file
View File

@ -0,0 +1,647 @@
using System.Net.WebSockets;
using System.Text.RegularExpressions;
using System.Text.Json.Nodes;
namespace JJServiceManagerRelay;
/// <summary>/ws/manager — 관리 PC (로그인, 기기 목록, 연결 요청, [서버 관리])</summary>
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<ManagerConn> 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<ManagerConn> 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<JsonObject> 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<JsonArray> 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;
}
/// <summary>연결 요청: 권한 확인 → 세션·티켓 → 대상 PC에 알림 → 직접 연결 후보 + 중계 정보</summary>
private async Task<JsonObject> 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
};
}
/// <summary>직접 연결 후보: 같은 공유기 뒤(외부 IP 같음)면 내부 IP 먼저, 아니면 외부 IP 먼저</summary>
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;
}
/// <summary>관리 PC가 알려주는 연결 결과: direct(직접 성공) / failed / ended(끝, 트래픽)</summary>
private async Task<JsonObject> 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<JsonObject> 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<string>();
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("비밀번호가 너무 깁니다.");
}
}

92
server/Program.cs Normal file
View File

@ -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();

141
server/RelayHandler.cs Normal file
View File

@ -0,0 +1,141 @@
using System.Buffers;
using System.Net.WebSockets;
using System.Security.Cryptography;
using System.Text;
namespace JJServiceManagerRelay;
/// <summary>/relay/{세션ID}?token=…&amp;side=manager|agent — 두 연결을 이어 붙여 바이트를 그대로 넘김</summary>
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);
}
}
/// <summary>from에서 받은 프레임을 to로 그대로 보냄. 끝난 사유를 돌려줌</summary>
private static async Task<string> PumpAsync(WebSocket from, WebSocket to, Action<long> count)
{
var buf = ArrayPool<byte>.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<byte>(buf, 0, r.Count), r.MessageType, r.EndOfMessage, CancellationToken.None);
}
}
catch (WebSocketException)
{
return "연결 끊김";
}
catch (ObjectDisposedException)
{
return "연결 끊김";
}
catch (OperationCanceledException)
{
return "연결 끊김";
}
finally
{
ArrayPool<byte>.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 { }
}
}

104
server/Schema/001_init.sql Normal file
View File

@ -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);

152
server/Security.cs Normal file
View File

@ -0,0 +1,152 @@
using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json.Nodes;
namespace JJServiceManagerRelay;
/// <summary>비밀번호 해시, 토큰, 코드 만들기</summary>
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;
}
}
/// <summary>비교용 비밀번호를 늘 계산해서 "없는 아이디"도 같은 시간이 걸리게</summary>
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 ?? ""));
/// <summary>등록 코드: XXXX-XXXX (헷갈리는 글자 O/0/I/1 제외)</summary>
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();
}
/// <summary>입력한 코드 정리 (대소문자·공백·하이픈 무시)</summary>
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);
}
}
/// <summary>권한 티켓 서명 (ECDSA P-256). 대상 PC가 서버 공개키로 확인한다.</summary>
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());
}
/// <summary>티켓 = base64url(JSON) + "." + base64url(서명)</summary>
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);
}
}
/// <summary>로그인·등록 실패 제한: 5분 동안 10번 넘게 틀리면 15분 거부</summary>
public sealed class Throttle
{
private readonly ConcurrentDictionary<string, List<DateTime>> _fails = new();
private readonly ConcurrentDictionary<string, DateTime> _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<DateTime>());
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 _);
}
}

425
tests/RelayTest/Program.cs Normal file
View File

@ -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<bool>(), "재시작 뒤 로그인 토큰 유지", 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<bool>() && 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<bool>() == false, "틀린 비밀번호 거부", r.ToJsonString());
r = await admin.Req("login", new() { ["username"] = "ADMIN", ["password"] = adminPw0, ["host"] = "TESTPC" });
Check(r["ok"]!.GetValue<bool>() && r["user"]!["mustChangePassword"]!.GetValue<bool>(), "첫 로그인 성공 + 비밀번호 변경 필요 (대소문자 무시)", r.ToJsonString());
var adminToken = r["token"]?.ToString();
r = await admin.Req("devices");
Check(!r["ok"]!.GetValue<bool>(), "비밀번호 바꾸기 전 다른 요청 거부", r.ToJsonString());
r = await admin.Req("changePassword", new() { ["old"] = adminPw0, ["new"] = "short" });
Check(!r["ok"]!.GetValue<bool>(), "짧은 새 비밀번호 거부");
r = await admin.Req("changePassword", new() { ["old"] = adminPw0, ["new"] = "AdminPass#2026" });
Check(r["ok"]!.GetValue<bool>() && !r["user"]!["mustChangePassword"]!.GetValue<bool>(), "비밀번호 변경", 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<bool>(), "틀린 등록 코드 거부", 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<bool>(), "등록 성공 (소문자·공백 코드도 허용)", 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<bool>() && 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<bool>() && 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<bool>() && r["serverPublicKey"]?.ToString() == pubKey, "기기 키로 접속", r.ToJsonString());
var pres = await presenceTask;
Check(pres != null && pres["online"]!.GetValue<bool>(), "관리 PC에 온라인 알림", pres?.ToJsonString());
r = await admin.Req("devices");
Check(r["devices"]!.AsArray().Count == 1 && r["devices"]![0]!["online"]!.GetValue<bool>(), "관리자 기기 목록 (온라인)", 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<bool>(), "사용자 bob 생성", r.ToJsonString());
var bobId = r["userId"]!.GetValue<long>();
r = await admin.Req("admin.users.create", new() { ["username"] = "Bob", ["password"] = "BobPass#123" });
Check(!r["ok"]!.GetValue<bool>(), "대소문자만 다른 같은 아이디 거부");
var bob = await Client.Open(baseUrl + "/ws/manager");
r = await bob.Req("login", new() { ["username"] = "bob", ["password"] = "BobPass#123" });
Check(r["ok"]!.GetValue<bool>(), "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<bool>(), "권한 없으면 연결 거부", r["error"]?.ToString());
r = await bob.Req("admin.users.list");
Check(!r["ok"]!.GetValue<bool>(), "일반 사용자는 [서버 관리] 거부");
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<bool>() && r["view"]!.GetValue<bool>() && r["control"]!.GetValue<bool>() && !r["screenView"]!.GetValue<bool>(),
"권한 설정: 서비스 제어 → 보기 자동 포함", 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<bool>(), "연결 요청", 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<long>() == 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<bool>() && !tp["perms"]!["screenView"]!.GetValue<bool>(),
"티켓 서명 확인 (서버 공개키) + 내용", 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<bool>(), "직접 연결 성공 알림");
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<bool>(), "다른 사용자의 연결 상태 변경 거부");
Console.WriteLine("== 6. 감사 로그 (대상 PC 보고) / 조회");
r = await agent.Req("audit", new() { ["userId"] = bobId, ["username"] = "bob", ["action"] = "stop", ["target"] = "Spooler", ["result"] = "ok" });
Check(r["ok"]!.GetValue<bool>(), "대상 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<int>() == 1 && r["onlineManagers"]!.GetValue<int>() == 2 && r["db"]!.GetValue<bool>(), "서버 상태", 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<bool>(), "토큰으로 재접속");
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<bool>() && r["code"]?.ToString() == "loginRequired", "로그아웃한 토큰 거부");
var adm2 = await Client.Open(baseUrl + "/ws/manager");
r = await adm2.Req("resume", new() { ["token"] = adminToken });
Check(r["ok"]!.GetValue<bool>(), "비밀번호를 바꾼 연결의 토큰은 계속 유효", r.ToJsonString());
Console.WriteLine("== 8. 관리자 보호 / 사용 안 함");
r = await admin.Req("admin.users.update", new() { ["userId"] = 1, ["role"] = "user" });
Check(!r["ok"]!.GetValue<bool>(), "마지막 관리자 역할 변경 거부", 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<bool>() && 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<bool>(), "사용 안 함 계정 로그인 거부", 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<bool>() && await disTask != null, "기기 사용 안 함 → 대상 PC에 알림");
var off = await offTask;
Check(off != null && !off["online"]!.GetValue<bool>(), "관리 PC에 오프라인 알림");
var agent2 = await Client.Open(baseUrl + "/ws/agent");
r = await agent2.Req("auth", new() { ["deviceId"] = deviceId, ["deviceKey"] = deviceKey });
Check(!r["ok"]!.GetValue<bool>() && 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<bool>(), "다시 사용 → 접속됨");
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<bool>() && 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<long, TaskCompletionSource<JsonObject>> _pending = new();
private readonly List<(string ev, TaskCompletionSource<JsonObject> tcs)> _waiters = new();
private readonly List<JsonObject> _events = new();
public static async Task<Client> 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<long>(), 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<JsonObject> 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<JsonObject>(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<JsonObject> WaitEvent(string ev, TimeSpan timeout)
{
TaskCompletionSource<JsonObject> tcs;
lock (_waiters)
{
var e = _events.FirstOrDefault(x => x["event"]!.ToString() == ev);
if (e != null) { _events.Remove(e); return e; }
tcs = new TaskCompletionSource<JsonObject>(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<ClientWebSocket> 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<byte[]> 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<bool> 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; }
}
}

View File

@ -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
```

View File

@ -0,0 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<Nullable>disable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>