JJServiceManagerRelay/docs/설계서.md

18 KiB
Raw Blame History

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)

-- 사용자
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에서 직접 한다 (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 설정의 [서버 관리] 탭