JJServiceManagerRelay/docs/설계서.md

312 lines
18 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# JJServiceManagerRelay 설계서 (v0.2)
JJServiceManagerEnt 중계 서버 — Synology NAS(DS1515+, DSM 7.1.1) Docker + PostgreSQL, `https://jjsm.joynala.com`
---
## 1. 목표
- 모든 PC(대상 PC, 관리 PC)가 **NAS로 바깥 방향 접속만** 하면 서로 연결된다. 대상 PC에 포트를 열 필요가 없다.
- 먼저 **직접 연결**을 시도하고, 안 되면 **NAS가 중계**한다 (RustDesk 방식).
- 계정·권한·기기 목록·감사 로그·연결 기록은 **PostgreSQL**에 저장한다.
- 관리는 **JJServiceManagerEnt 안의 [서버 관리] 탭**에서 한다 (관리자 계정일 때만 보임).
- **서버가 뚫려도 대상 PC는 안전**해야 한다. 서버는 통신 내용을 볼 수 없고, 대상 PC 접속 비밀번호도 모른다.
## 2. 전체 구조
```
Synology Router (443 → NAS)
│
┌─────────── Synology NAS ───────────┐
│ DSM 리버스 프록시 (jjsm.joynala.com, │
│ HTTPS 443, WebSocket, Let's Encrypt)│
│ │ http://localhost:21900 │
│ ┌──────────▼──────────┐ │
│ │ jjsm-server (.NET 8) │ │
│ └──────────┬──────────┘ │
│ ┌──────────▼──────────┐ │
│ │ jjsm-db (PostgreSQL) │ ← 바깥에 포트 안 엶
│ └─────────────────────┘ │
└──────────────────────────────────────┘
▲ WSS (443) ▲ WSS (443)
│ │
[관리 PC: JJServiceManagerEnt] [대상 PC: JJServiceManagerEnt]
└──────── (가능하면) 직접 연결 ────────┘
```
연결 세 종류 (모두 `wss://jjsm.joynala.com/...`):
| 연결 | 누가 | 언제 | 용도 |
|---|---|---|---|
| `/ws/agent` | 대상 PC | 항상 유지 | 등록, 온라인 상태, 연결 요청 받기, 감사 로그 보고 |
| `/ws/manager` | 관리 PC | 로그인 중 유지 | 로그인, 기기 목록·상태, 연결 요청, [서버 관리] |
| `/relay/{세션ID}` | 양쪽 | 중계할 때만 | 바이트를 그대로 이어 붙이는 통로 |
## 3. 연결 순서 (직접 → 중계)
```
관리 PC 서버 대상 PC
│ connect(기기X) │ │
│────────────────▶│ 권한 확인 │
│ │ 세션 생성, 티켓 서명 │
│ │──── incoming(세션) ───▶│
│◀─ 후보 주소 + 세션 + 티켓 ─│ │
│ │
│ ① 직접 연결 시도 (대상 PC 내부 IP:Active 포트, 3초)
│───────────────────────────────────────────▶│ 성공 → 직접 통신
│ │
│ ② 실패 시 /relay/{세션} 접속 ◀── 대상 PC도 /relay/{세션} 접속
│──────────▶│ 두 연결을 이어 붙임 │◀──────────│
│ │
│ ③ 그 안에서 기존 프로토콜 그대로 (TLS → hello(비밀번호 + 티켓) → list/start/stop/…)
```
- **직접 연결 조건**: 대상 PC가 [Active 수신]을 켜 두었고, 관리 PC에서 그 주소(같은 내부망 IP, 또는 대상 PC가 알려준 외부 IP)로 TCP 접속이 되는 경우. 공유기 구멍 뚫기(hole punching)는 하지 않는다 (TCP라 잘 안 됨).
- 직접 연결 후보는 서버가 알려준다: 대상 PC가 보고한 **내부 IP 목록**, 서버가 본 **외부 IP**, **Active 포트·방식**.
- 관리 PC와 대상 PC의 외부 IP가 같으면(같은 공유기 뒤) 내부 IP부터 시도한다.
- 직접 연결이 3초 안에 안 되면 중계로 넘어간다. 직접/중계 결과는 연결 기록에 남긴다.
## 4. 보안
### 4.1 통신 내용은 서버가 못 본다 (종단 간 암호화)
- 중계 통로 안에서 관리 PC와 대상 PC가 **기존과 같은 TLS**를 다시 맺는다 (WebSocket 위에 TLS). 서버는 암호화된 바이트만 넘긴다.
- 대상 PC 접속 비밀번호는 이 TLS 안에서만 오간다. 서버는 비밀번호를 알 수 없다.
- 결과: 서버가 뚫려도 대상 PC를 조작하려면 **대상 PC 접속 비밀번호**가 따로 있어야 한다.
### 4.2 권한 티켓 (서버 권한을 대상 PC가 직접 확인)
- 서버는 연결을 허락할 때 **권한 티켓**을 서명해서 관리 PC에 준다.
- 내용: 사용자 ID·이름, 기기 ID, 권한(아래 5장), 세션 ID, 만료 시각(5분)
- 서명: 서버 전용 ECDSA P-256 키. 공개키는 대상 PC가 등록할 때 받아 저장한다.
- 관리 PC는 hello 때 비밀번호와 함께 티켓을 보낸다. 대상 PC는 **서명·기기 ID·만료**를 확인하고, 그 세션에서 **티켓 권한을 넘는 요청은 거부**한다.
- 대상 PC 자체 설정(화면 보기 허용 등)도 그대로 적용된다. 실제 권한 = **서버 권한 ∩ 대상 PC 설정**.
### 4.3 계정·기기 인증
| 대상 | 방식 | 저장 |
|---|---|---|
| 관리 PC 사용자 | 아이디 + 비밀번호 → 로그인 토큰(12시간, 연결 유지 중 자동 연장) | DB: PBKDF2-SHA256 (210,000회) 해시 |
| 대상 PC 등록 | 등록 코드 한 번 입력 (`XXXX-XXXX`, 기본 **7일** 유효(발급할 때 바꿀 수 있음), 1회용) | DB: 코드 해시 |
| 대상 PC 이후 접속 | 기기 ID + 기기 키 (32바이트 무작위) | 대상 PC: DPAPI 암호화 / DB: 키 해시 |
- 로그인 실패 제한: 같은 아이디 또는 같은 IP에서 5분 동안 10번 넘게 틀리면 15분 동안 거부.
- 첫 관리자: docker-compose 환경 변수(`JJSM_ADMIN_USER`, `JJSM_ADMIN_PASSWORD`)로 **사용자가 하나도 없을 때만** 만든다. 첫 로그인 때 비밀번호 변경을 강제한다.
- 기기 차단: [서버 관리]에서 기기를 "사용 안 함"으로 바꾸면 그 기기 키로는 접속이 안 된다 (다시 등록해야 함).
### 4.4 6자리 숫자 확인
- 지금처럼 관리 PC에서 Start/Stop(설정에 따라), 원격관리(항상) 때 입력한다. 중계 서버와 관계없이 유지.
## 5. 권한
| 권한 | 내용 |
|---|---|
| 보기 | 서비스 목록·상태·Status·로그 보기 |
| 서비스 제어 | Start / Stop |
| 화면 보기 | 원격관리 화면 보기 |
| 화면 제어 | 원격관리 키보드·마우스 제어 (화면 보기 포함) |
- **관리자**: 모든 기기에 모든 권한 + [서버 관리].
- **일반 사용자**: 기기마다 위 권한을 켜고 끈다. 권한이 하나도 없는 기기는 목록에 안 보인다.
## 6. DB (PostgreSQL)
```sql
-- 사용자
users (
id bigserial primary key,
username text unique not null, -- 대소문자 구분 없이 비교
password_hash text not null, -- PBKDF2 (salt·횟수 포함)
role text not null, -- '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
)
-- 로그인 토큰
user_sessions (
token_hash bytea primary key,
user_id bigint not null references users(id),
created_at timestamptz not null default now(),
expires_at timestamptz not null,
client_ip text, client_host text
)
-- 대상 PC
devices (
id uuid primary key,
name text not null, -- 기본 hostname, 관리자가 바꿀 수 있음
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), -- 등록 코드를 만든 사람
last_seen_at timestamptz,
last_public_ip text,
lan_ips jsonb not null default '[]',
active_listen jsonb, -- {"mode":"Port","port":9988} 또는 null
app_version text, os_version text
)
-- 등록 코드
enrollment_codes (
id bigserial primary key,
code_hash bytea unique not null,
created_by bigint not null references users(id),
created_at timestamptz not null default now(),
expires_at timestamptz not null,
used_at timestamptz,
used_device uuid references devices(id),
note text not null default ''
)
-- 기기별 권한 (일반 사용자)
user_device_permissions (
user_id bigint references users(id),
device_id uuid references devices(id),
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)
)
-- 작업 감사 로그 (대상 PC가 실제로 실행한 것을 보고 + 서버 관리 작업)
audit_logs (
id bigserial primary key,
at timestamptz not null default now(),
user_id bigint, username text, -- 사용자가 지워져도 이름은 남김
device_id uuid, device_name text,
action text not null, -- start, stop, screenStart, screenEnd, login, loginFail,
-- userCreate, permChange, codeCreate, deviceEnroll, deviceDisable …
target text not null default '', -- 서비스 이름 등
result text not null, -- ok | fail | denied
detail text not null default '',
source_ip text
)
-- 연결 기록·통계
connections (
id bigserial primary key,
session_id uuid unique not null,
user_id bigint, device_id uuid,
started_at timestamptz not null default now(),
ended_at timestamptz,
mode text, -- direct | relay | failed
bytes_to_device bigint not null default 0,
bytes_from_device bigint not null default 0,
end_reason text
)
-- 서버 설정 (서명 키, 스키마 버전 등)
server_settings (key text primary key, value text not null)
```
- **감사 로그·연결 기록은 지우는 기능을 만들지 않는다** (JJServiceManager 로그 원칙과 같음). 조회는 기간·사용자·기기·작업으로 걸러 본다.
- 온라인 여부는 DB가 아니라 서버 메모리에서 관리한다 (접속 중인 `/ws/agent` 연결). DB에는 `last_seen_at`만 남긴다.
- 직접 연결의 트래픽량은 관리 PC가 연결을 끝낼 때 보고한다. 중계는 서버가 센다.
## 7. 메시지 (JSON, WebSocket 텍스트 프레임)
요청: `{"id":번호, "op":"이름", ...}` → 응답: `{"id":번호, "ok":true, ...}` 또는 `{"id":번호, "ok":false, "error":"한글 메시지"}`
서버가 먼저 보내는 알림: `{"event":"이름", ...}`
### 7.1 `/ws/agent` (대상 PC)
| op / event | 방향 | 내용 |
|---|---|---|
| `enroll` | PC→서버 | `code, hostname, os, appVersion` → `deviceId, deviceKey, serverPublicKey` |
| `auth` | PC→서버 | `deviceId, deviceKey, hostname, lanIps[], activeListen, appVersion` → `serverPublicKey, name` |
| `update` | PC→서버 | 내부 IP·Active 수신·hostname이 바뀌었을 때 |
| `audit` | PC→서버 | `userId, username, action, target, result, detail` (티켓 세션 안에서 실행된 작업) |
| `incoming` | 서버→PC | `sessionId, relayToken` — 중계 통로를 열라는 요청 (직접 연결이 될 수도 있으므로 항상 미리 엶) |
| `cancel` | 서버→PC | `sessionId` — 직접 연결 성공 등으로 중계가 필요 없어짐 |
| `disabled` | 서버→PC | 기기가 사용 안 함으로 바뀜 → 연결 끊고 재접속 안 함 |
| `ping` | 양쪽 | 30초마다 |
### 7.2 `/ws/manager` (관리 PC)
| op / event | 내용 |
|---|---|
| `login` | `username, password` → `token, user{role, mustChangePassword}` |
| `resume` | `token` (재접속 시) |
| `changePassword` | `old, new` |
| `devices` | 내가 볼 수 있는 기기 목록 (이름, hostname, 온라인, 권한, 마지막 접속) |
| `event: presence` | 기기 온라인/오프라인 변경 알림 |
| `connect` | `deviceId` → `sessionId, relayToken, ticket, candidates[{host,port,mode}]` |
| `connectResult` | `sessionId, mode(direct/relay), bytes…` (연결 끝날 때 통계) |
| `logout` | |
| **[서버 관리] (관리자만)** | |
| `admin.users.list / create / update / resetPassword / disable` | 계정 관리 |
| `admin.perms.get / set` | 사용자 × 기기 권한 |
| `admin.devices.list / rename / disable / enable / delete` | 기기 관리 (삭제는 기록이 남은 기기면 "사용 안 함" 권장) |
| `admin.codes.create / list / revoke` | 등록 코드 |
| `admin.audit.query` | `from, to, userId?, deviceId?, action?, page` |
| `admin.connections.query` | 연결 기록·통계 |
| `admin.server.info` | 서버 버전, DB 상태, 접속 중 기기·사용자 수 |
### 7.3 `/relay/{sessionId}?token=…&side=manager|agent`
- 양쪽이 모두 붙으면 바이너리 프레임을 그대로 넘긴다. 한쪽이 30초 안에 안 붙으면 닫는다.
- 토큰은 세션마다 한 번만 쓸 수 있고, 관리 PC 토큰과 대상 PC 토큰이 다르다.
- 통로 위에서는 기존 JJServiceManagerEnt 프로토콜(TLS → JSON 줄)을 그대로 쓴다. 대상 PC는 지금의 `ServeAsync`, 관리 PC는 지금의 `RemoteClient`를 그대로 쓰고, 전송 계층만 "WebSocket 스트림"이 하나 더 생긴다.
## 8. JJServiceManagerEnt 쪽 변경
### 8.1 대상 PC (설정 → 원격 접속 허용)
- 새 항목 **중계 서버 접속**: 서버 주소(`https://jjsm.joynala.com`), [등록] 버튼(등록 코드 입력), 상태(등록됨 / 온라인 / 사용 안 함)
- 기존 Active 수신·Passive 접속과 **동시에** 쓸 수 있다.
### 8.2 관리 PC (설정 → 관리 대상 PC)
- 새 항목 **중계 서버 로그인**: 서버 주소, 아이디, 비밀번호(관리 PC에 DPAPI로 저장, "자동 로그인" 선택)
- 로그인하면 권한 있는 기기가 초기화면에 자동으로 묶음으로 나타난다. 제목: `hostname (중계)` 또는 `(직접)`
- 기기마다 대상 PC 접속 비밀번호는 처음 연결할 때 묻고 저장한다.
### 8.3 [서버 관리] 탭 (관리자만)
| 화면 | 기능 |
|---|---|
| 계정 | 추가 / 비밀번호 초기화 / 역할 / 사용 안 함 |
| 권한 | 사용자 선택 → 기기별 4가지 권한 체크 |
| 기기 | 목록(온라인·IP·버전·마지막 접속), 이름 바꾸기, 사용 안 함 |
| 등록 코드 | 새 코드 발급(유효 시간·메모), 목록, 취소 |
| 감사 로그 | 기간·사용자·기기·작업으로 조회, 복사 |
| 연결 기록 | 직접/중계, 시간, 트래픽 |
## 9. Docker 구성 (NAS)
```
/volume1/docker/jjsm/
├─ src/ ← JJServiceManagerRelay 소스 (Gitea 압축 파일) + docker-compose.yml + .env(비밀번호)
├─ db/ ← PostgreSQL 데이터
├─ server/logs/ ← 중계 서버 로그 (날짜별, 지우지 않음)
└─ backup/ ← 매일 pg_dump, 최근 30개 보관 (오래된 백업 파일부터 정리)
```
| 컨테이너 | 이미지 | 포트 |
|---|---|---|
| `jjsm-db` | `postgres:16-alpine` | 바깥 공개 안 함 (컨테이너 네트워크만) |
| `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/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`
- 메모리 예상: 서버 약 100MB + PostgreSQL 약 100MB (NAS 16GB에 충분)
## 10. 장애·재접속
- 대상 PC·관리 PC는 서버 연결이 끊기면 5초 → 10초 → 30초 간격으로 다시 접속한다.
- 서버가 재시작돼도 기기 키·로그인 토큰은 DB에 있어 다시 붙는다 (토큰이 남아 있으면 재로그인 없이).
- 중계 중 서버가 끊기면 원격 연결도 끊기고, 기존처럼 자동 재연결한다 (직접 연결은 서버와 무관하게 유지).
- 서버가 아예 안 될 때도 기존 Active/Passive 연결은 그대로 동작한다.
## 11. 결정 사항
| 항목 | 결정 |
|---|---|
| 서버 이미지 | NAS에서 직접 빌드 (`docker-compose build`) |
| 등록 코드 기본 유효 시간 | 7일 (발급할 때 바꿀 수 있음) |
| DB 백업 | 매일 1회, 최근 30개 보관 |
| 직접 연결 범위 | 내부망 + 외부 IP (같은 공유기 뒤면 내부 IP 먼저, 아니면 대상 PC 외부 IP:Active 포트도 시도) |
| 대상 PC 접속 비밀번호 | 유지 (서버 로그인과 별도) |
| 첫 관리자 | docker-compose 환경 변수, 첫 로그인 때 비밀번호 변경 |
| 관리 화면 | JJServiceManagerEnt 설정의 [서버 관리] 탭 |