From 69449e5c858cce7849c227bde25d791ceb4d73cc Mon Sep 17 00:00:00 2001 From: diadmin Date: Tue, 27 Oct 2026 06:16:17 +0000 Subject: [PATCH] =?UTF-8?q?=EC=84=A4=EA=B3=84=EC=84=9C=20=EC=B4=88?= =?UTF-8?q?=EC=95=88=20v0.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013U497kUNL8ArhV7jkzCpKa --- README.md | 6 + docs/설계서.md | 306 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 312 insertions(+) create mode 100644 README.md create mode 100644 docs/설계서.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..f61b4f0 --- /dev/null +++ b/README.md @@ -0,0 +1,6 @@ +# JJServiceManagerRelay + +JJServiceManagerEnt 중계 서버 (Synology NAS Docker + PostgreSQL, `https://jjsm.joynala.com`) + +- 설계서: [docs/설계서.md](docs/설계서.md) +- 상태: 설계 검토 중 diff --git a/docs/설계서.md b/docs/설계서.md new file mode 100644 index 0000000..1fd7871 --- /dev/null +++ b/docs/설계서.md @@ -0,0 +1,306 @@ +# JJServiceManagerRelay 설계서 (초안 v0.1) + +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`, 기본 24시간 유효, 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/ + ├─ docker-compose.yml + ├─ .env ← 비밀번호 (POSTGRES_PASSWORD, JJSM_ADMIN_PASSWORD) + ├─ server/ ← 중계 서버 로그 + ├─ db/ ← PostgreSQL 데이터 + └─ backup/ ← 매일 pg_dump (보관 개수 설정, 기본 30개) +``` + +| 컨테이너 | 이미지 | 포트 | +|---|---|---| +| `jjsm-db` | `postgres:16-alpine` | 바깥 공개 안 함 (컨테이너 네트워크만) | +| `jjsm-server` | `jjsm-server` (`mcr.microsoft.com/dotnet/aspnet:8.0` 기반) | `127.0.0.1:21900` → 리버스 프록시만 접근 | +| `jjsm-backup` | `postgres:16-alpine` + cron 스크립트 | 없음 | + +- 실행: SSH → `cd /volume1/docker/jjsm && sudo docker-compose up -d` +- 리버스 프록시: 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. 정할 것 (다음 질문) + +1. 서버 이미지를 NAS에서 어떻게 만들지 (NAS에서 직접 빌드 / 미리 빌드한 파일 받기 / Gitea 패키지 레지스트리) +2. 등록 코드 기본 유효 시간 (24시간 제안) +3. DB 백업 보관 개수 (30개 제안) +4. 직접 연결을 시도할 범위 (같은 공유기 뒤일 때만 / 외부 IP도 시도)