중계 서버 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_013U497kUNL8ArhV7jkzCpKamain
parent
887b03b147
commit
63ad4012e1
|
|
@ -0,0 +1,5 @@
|
|||
**/bin
|
||||
**/obj
|
||||
.git
|
||||
.env
|
||||
docs
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
.env
|
||||
bin/
|
||||
obj/
|
||||
*.user
|
||||
.vs/
|
||||
saved.json
|
||||
|
|
@ -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"]
|
||||
36
README.md
36
README.md
|
|
@ -1,6 +1,36 @@
|
|||
# JJServiceManagerRelay
|
||||
|
||||
JJServiceManagerEnt 중계 서버 (Synology NAS Docker + PostgreSQL, `https://jjsm.joynala.com`)
|
||||
JJServiceManagerEnt 중계 서버 — Synology NAS Docker + PostgreSQL, `https://jjsm.joynala.com`
|
||||
|
||||
- 설계서: [docs/설계서.md](docs/설계서.md)
|
||||
- 상태: 설계 검토 중
|
||||
모든 PC가 NAS로 바깥 방향 접속만 하면 서로 연결됩니다 (먼저 직접 연결, 안 되면 NAS 중계).
|
||||
서버는 통신 내용을 볼 수 없고(종단 간 TLS), 대상 PC 접속 비밀번호도 모릅니다.
|
||||
|
||||
| 문서 | 내용 |
|
||||
|---|---|
|
||||
| [docs/설계서.md](docs/설계서.md) | 구조, 보안, 권한, DB, 메시지 |
|
||||
| [docs/NAS설치.md](docs/NAS설치.md) | NAS에 설치·운영·업데이트 |
|
||||
|
||||
## 구성
|
||||
| 경로 | 내용 |
|
||||
|---|---|
|
||||
| `server/` | 중계 서버 (C# .NET 8, ASP.NET Core WebSocket, Npgsql) |
|
||||
| `server/Schema/` | DB 스키마 (서버가 시작할 때 자동 적용) |
|
||||
| `Dockerfile` | NAS에서 직접 빌드 (`dotnet/sdk:8.0` → `dotnet/aspnet:8.0`) |
|
||||
| `docker-compose.yml` | `jjsm-db` (PostgreSQL 16), `jjsm-server`, `jjsm-backup` |
|
||||
| `backup/backup.sh` | 매일 pg_dump, 최근 30개 보관 |
|
||||
| `.env.example` | 비밀번호·설정 예시 (`.env`로 복사해서 사용) |
|
||||
|
||||
## 주소
|
||||
| 경로 | 용도 |
|
||||
|---|---|
|
||||
| `GET /health` | 상태 확인 |
|
||||
| `wss://…/ws/agent` | 대상 PC (등록·온라인 상태·연결 요청 받기·감사 로그 보고) |
|
||||
| `wss://…/ws/manager` | 관리 PC (로그인·기기 목록·연결 요청·[서버 관리]) |
|
||||
| `wss://…/relay/{세션}` | 중계 통로 |
|
||||
|
||||
## 개발 환경에서 실행
|
||||
```sh
|
||||
cd server
|
||||
JJSM_DB="Host=127.0.0.1;Port=5432;Database=jjsm;Username=jjsm;Password=..." \
|
||||
JJSM_ADMIN_USER=admin JJSM_ADMIN_PASSWORD=처음비밀번호 dotnet run
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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에서 직접 초기화하는 방법 안내).
|
||||
10
docs/설계서.md
10
docs/설계서.md
|
|
@ -273,12 +273,10 @@ server_settings (key text primary key, value text not null)
|
|||
|
||||
```
|
||||
/volume1/docker/jjsm/
|
||||
├─ docker-compose.yml
|
||||
├─ .env ← 비밀번호 (POSTGRES_PASSWORD, JJSM_ADMIN_PASSWORD)
|
||||
├─ server/ ← 중계 서버 로그
|
||||
├─ src/ ← JJServiceManagerRelay 소스 (Gitea 압축 파일) + docker-compose.yml + .env(비밀번호)
|
||||
├─ db/ ← PostgreSQL 데이터
|
||||
├─ backup/ ← 매일 pg_dump, 최근 30개 보관 (오래된 백업 파일부터 정리)
|
||||
└─ src/ ← JJServiceManagerRelay 소스 (Gitea에서 받음, 빌드용)
|
||||
├─ server/logs/ ← 중계 서버 로그 (날짜별, 지우지 않음)
|
||||
└─ backup/ ← 매일 pg_dump, 최근 30개 보관 (오래된 백업 파일부터 정리)
|
||||
```
|
||||
|
||||
| 컨테이너 | 이미지 | 포트 |
|
||||
|
|
@ -287,7 +285,7 @@ server_settings (key text primary key, value text not null)
|
|||
| `jjsm-server` | NAS에서 직접 빌드 (`dotnet/sdk:8.0`로 빌드 → `dotnet/aspnet:8.0`으로 실행) | `127.0.0.1:21900` → 리버스 프록시만 접근 |
|
||||
| `jjsm-backup` | `postgres:16-alpine` + cron 스크립트 | 없음 |
|
||||
|
||||
- 처음 실행·업데이트: SSH → `cd /volume1/docker/jjsm && sudo docker-compose build && sudo docker-compose up -d`
|
||||
- 처음 실행·업데이트: SSH → `cd /volume1/docker/jjsm/src && sudo docker-compose build && sudo docker-compose up -d` (자세한 순서: [NAS설치.md](NAS설치.md))
|
||||
- 빌드는 NAS에서 직접 한다 (DS1515+ 기준 수 분~10분 정도 예상). 업데이트 때는 `src/`를 새 버전으로 바꾸고 다시 빌드.
|
||||
- 리버스 프록시: HTTPS `jjsm.joynala.com:443` → HTTP `localhost:21900`, 사용자 지정 머리글 **WebSocket**
|
||||
- 서버 상태 확인: `https://jjsm.joynala.com/health`
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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];
|
||||
}
|
||||
|
|
@ -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); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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("비밀번호가 너무 깁니다.");
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
@ -0,0 +1,141 @@
|
|||
using System.Buffers;
|
||||
using System.Net.WebSockets;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace JJServiceManagerRelay;
|
||||
|
||||
/// <summary>/relay/{세션ID}?token=…&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 { }
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -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 _);
|
||||
}
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
```
|
||||
|
|
@ -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>
|
||||
Loading…
Reference in New Issue