Compare commits

...

77 Commits

Author SHA1 Message Date
Claude
788346f2ae git clone 인증 + GPG overwrite 프롬프트 자동 처리
- git clone URL에 Gitea 토큰 포함 (인증 프롬프트 제거)
- gpg --batch --yes 추가 (Overwrite? y/N 프롬프트 제거)
- v1, v2 둘 다 수정

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 01:02:12 +00:00
Claude
4621fdcb6d pharmq-setup-v2: noVNC CT 설치 제거, 9단계로 최적화
v1 대비 변경:
- Phase 9(noVNC CT 설치) 제거 — gateway WebSocket 프록시로 대체됨
  - CT에 websockify/Flask/noVNC 설치 불필요
  - VNC는 gateway가 PVE API 직접 프록시
- Phase 10(검증) → Phase 9로 이동
- 총 10단계 → 9단계로 축소
- v1(1247줄) → v2(1222줄)

v2 실행:
curl -fsSL https://git.0bin.in/.../pharmq-setup-v2.sh | bash

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 06:56:10 +00:00
Claude
02ebc89fc1 pharmq-setup-v2.sh 생성 — 기존 v1 복사본 (최적화 전)
기존 pharmq-setup.sh(v1)를 그대로 복사.
이후 v2에서 불필요한 Phase 제거 및 최적화 진행.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 06:52:48 +00:00
Claude
3dad99747b Phase 8 RDP: 기본 계정 administrator → pqserver 수정
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 14:23:16 +00:00
Claude
b37b1281a5 noVNC 스크립트 CLI 인자 지원 + Phase 9 자동 PVE 접속 정보 전달
- VNC/pharmq-novnc-setup.sh: --pve-host, --pve-password, --pharmacy-code, --pharmacy-name 인자 추가
  - 인자 있으면 대화형 입력 스킵, 없으면 기존대로 대화형
  - set -euo → set -eo (unset 변수 에러 방지)
- pharmq-setup.sh Phase 9: PVE LAN IP 자동 감지 + 약국 정보 인자로 전달

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 14:12:32 +00:00
Claude
05b01e111b Phase 9: noVNC를 PVE host가 아닌 CT 내부에서 pct exec로 실행
- noVNC는 Ubuntu CT(ubuntu-api)에서 돌아야 함
- pct exec로 CT 안에서 설치 스크립트 실행
- CT 내부에 이미 설치돼있으면 스킵

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 14:02:24 +00:00
Claude
16fd28662c VPN IP로 약국 검색 API 추가 + Phase 3 기존 약국 감지 수정
- farmq-admin: GET /api/pharmacy/search?vpn_ip= 엔드포인트 추가
  - pharmacy_devices + pharmacies 테이블 모두 검색
- 스크립트: 기존 복잡한 Python 파싱 → 단순 API 호출로 변경
- PVE IP, CT IP 둘 다 검색 가능

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 13:57:03 +00:00
Claude
3359ca04f6 Phase 3: PVE Tailscale IP로 기존 약국 자동 감지 + Phase 7 중복 등록 방지
- Phase 3: PVE VPN IP로 farmq API 조회하여 이미 등록된 약국이면
  약국 정보 자동 로드 + 사용자 확인 후 스킵
- Phase 7: PHARMACY_CODE가 이미 있으면 등록 스킵, hostname만 업데이트
- 재실행 시 불필요한 약국 중복 생성 방지

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 13:51:15 +00:00
Claude
1f2939cca4 Phase 8(RDP), Phase 9(noVNC) 통합 — 총 10단계로 확장
- Phase 8: RDP 자동 연결 (xorg+openbox+freerdp+toggle API)
  - VM 201(192.168.0.201) 자동 감지, rdpuser 자동 로그인
  - RDP Toggle API (포트 8090) 설치
  - 이미 설치돼있으면 스킵
- Phase 9: noVNC 웹 서비스 (기존 스크립트 호출)
  - 이미 설치돼있으면 스킵
- Phase 10: 검증 + 결과 출력 (기존 Phase 8)
  - RDP API 정보 출력 추가
- 모든 Phase 번호 /8 → /10으로 변경

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 13:44:16 +00:00
Claude
8419c4271c Phase 8 검증: ping → tailscale ping으로 변경
일반 ping은 Tailscale iptables 규칙에 의해 DROP될 수 있음.
tailscale ping으로 변경하여 실제 VPN 통신 상태를 정확히 확인.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 13:33:27 +00:00
Claude
4d1845c6bc Phase 5/6 스킵 감지 안정화: set -e 충돌 방지 + grep -P 호환성 수정
- ct_exec 실패 시 || true로 스크립트 종료 방지
- grep -oP → sed로 변경 (Debian/PVE 호환)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 13:26:56 +00:00
Claude
9a662c1251 스마트 재실행: 기존 VM/CT/VPN 감지하여 완료된 Phase 자동 스킵
- Phase 4: VM이 이미 존재하면 PBS 복원 스킵 (qm status 체크)
- Phase 5: ubuntu-api CT가 이미 있으면 생성 스킵 (200~299 스캔)
- Phase 6: CT에 Tailscale + API 코드가 있으면 환경 구축 스킵
- PBS 인증 실패 시 exit 대신 스킵 후 계속 진행
- 상태 파일 없이 시스템 상태만 보고 판단

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 13:22:57 +00:00
Claude
566280f4c5 Fix Phase 7 crash + improve error handling across all phases
- Fixed: farmq.db empty date strings causing API 500 error
- Phase 7: exit 1 → return 1 (skip and continue instead of crash)
- Phase 4/7 failures no longer kill the script
- Error trap now shows current progress info (pharmacy, VPN IP, CT VMID, etc.)
- Helps debugging when script fails mid-execution

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 10:55:02 +00:00
Claude
dd53e869d0 Fix: VM selection by list number instead of raw backup ID
Users were entering list number (e.g. 4) but script expected backup ID (e.g. 201).
Now maps selection number to actual VM ID via temp file.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 00:36:43 +00:00
Claude
36fdae3eb1 Don't exit on PBS snapshot not found — skip and continue
VM restore failure should not kill the entire script.
Skip to Phase 5 (CT creation) instead.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 00:35:15 +00:00
Claude
f89bbecdcd Fix script crash: remove set -u, handle python exit safely
- set -euo → set -eo (unset var no longer fatal)
- Python snapshot lookup: catch exceptions, use || true
- Prevents script exit on empty PBS responses

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 00:27:23 +00:00
Claude
394a0b0a7c Fix: empty Enter on VM selection crashes due to set -u (unset variable)
Added || true on read and ${:-} default expansion to handle empty input safely.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 00:23:07 +00:00
Claude
f0900204fb Auto-default storage to local-lvm in non-interactive VM restore mode
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 15:47:52 +00:00
Claude
8d76dc8da2 Add --vm-id, --vm-vmid, --vm-storage for fully non-interactive PBS restore
Auto-selects VM backup, target VMID, storage, and auto-confirms existing VM deletion.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 15:44:35 +00:00
Claude
a0e94682bf Add --skip-pbs flag for non-interactive PBS phase
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 15:42:31 +00:00
Claude
6834612deb Add CLI args support: --name, --hira, --addr, --owner, --phone, --mssql
Allows non-interactive execution via SSH pipe or automation.
Falls back to interactive /dev/tty input when no args provided.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 15:40:53 +00:00
Claude
d08ff19470 Improve LAN detection: check Headscale port response, not just ping
Ping 192.168.0.100 can false-positive if external pharmacy has a device
at that IP. Now checks if 192.168.0.100:8070 actually responds like Headscale.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 14:48:21 +00:00
Claude
985ec18651 Fix Headscale connection: auto-detect LAN vs external network
Same-LAN PVE hosts can't reach head.pharmq.kr via public IP (NAT hairpinning).
Script now pings 192.168.0.100 to detect LAN and uses direct IP:8070 instead.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 14:45:58 +00:00
Claude
d0cd2b1137 Add unified PharmQ PVE setup script + design doc
Single script that handles: PVE repo fix, Tailscale/Headscale VPN registration,
PBS Windows VM restore, Ubuntu CT creation with API environment,
pharmacy + device registration in farmq.db, and gateway account creation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 14:31:05 +00:00
Claude
93a2313d37 fix: disable Magic DNS to prevent DNS issues
--accept-dns=false로 변경하여 시스템 DNS 설정 변경 방지

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 03:37:21 +00:00
Claude
59a10f48e5 Fix bash syntax error in pipe execution
read 명령의 파이프 호환성 문제 수정:
- `< /dev/tty` 제거 (파이프 환경에서 오류 발생)
- `-p` 옵션을 별도 echo로 분리
- 에러 출력 억제로 안정성 향상

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-21 14:20:02 +00:00
Claude
85c5f7f930 Add PBS auto restore script for VM 103 to 203
자동화된 PBS 복구 스크립트 추가:
- VM 103 백업을 VM 203으로 자동 복구
- 사용자 입력 최소화 (대부분 자동 처리)
- 고정 설정: source=103, target=203, storage=local-lvm
- 기존 VM 자동 삭제 및 재등록 지원

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-21 14:17:39 +00:00
Claude
aede1e9197 Fix Korean character encoding in config parsing
UTF-8 인코딩 명시적 지정:
- open() 함수에 encoding='utf-8' 추가
- 약국 이름이 잘리는 문제 해결 (예: 태령약 → 태령약국)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-21 14:02:42 +00:00
Claude
545ad63b50 Sort VM list by vmid for better readability
VM 목록을 vmid 순서로 정렬:
- running_vms.sort(key=lambda x: x['vmid']) 추가
- 201, 202, 203 순서로 표시되도록 개선

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-21 14:00:16 +00:00
Claude
5fa7812009 Improve VM selection prompts for clarity
사용자 안내 메시지 개선:
- VM 선택 시 예시 추가 (예: 201-SERVER를 선택하려면 '1' 입력)
- 프롬프트 메시지 명확화 (목록 번호 1, 2, 3 중 선택)
- VM2 선택 시 안내 강화 (VM1과 다른 번호)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-21 11:36:50 +00:00
Claude
d975723268 Fix array access for nounset mode
배열 접근 시 nounset 오류 수정:
- VM_INFO 배열 체크 시 parameter expansion 사용
- set -euo pipefail의 -u 옵션 호환성 개선

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-21 11:35:33 +00:00
Claude
eb4097e66e Update pharmq-novnc-setup.sh for Ubuntu VM installation
Proxmox API를 통한 VM 목록 조회로 변경:
- Ubuntu VM에서 실행되도록 수정
- Proxmox API를 통해 VM 목록 가져오기
- VNC 포트를 API로 조회
- 환경 자동 감지 (Proxmox Host vs Ubuntu VM)
- 기본 Proxmox 호스트 IP: 192.168.0.200

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-21 11:21:15 +00:00
Claude
1841d72ac3 Add PharmQ noVNC auto-installation script
대화형 curl 설치 스크립트 추가:
- VNC/ 폴더 생성
- pharmq-novnc-setup.sh: 대화형 설치 스크립트
  * VM 자동 감지 및 선택
  * 약국 정보 입력
  * Python 가상환경 자동 구성
  * systemd 서비스 자동 등록
  * 방화벽 설정 옵션
  * 재설치 및 업데이트 지원
- README.md: 설치 가이드 및 문서

RDP 스크립트와 동일한 패턴으로 구현
사용법: curl -fsSL https://git.0bin.in/thug0bin/pve9-repo-fix/raw/branch/main/VNC/pharmq-novnc-setup.sh | bash

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-21 11:17:16 +00:00
Claude
86e34d6916 Fix PBS API namespace: Add ns=PQ parameter to all API calls
모든 PBS API 호출에 ns=PQ 파라미터를 추가하여 PQ 네임스페이스의 백업만 조회하도록 수정

- groups API에 ns=PQ 추가
- snapshots API에 ns=PQ 추가 (get_snapshot_comment 함수)
- snapshots API에 ns=PQ 추가 (get_latest_snapshot 함수)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-21 01:35:43 +00:00
root
05a063eb1e Add PQ namespace to PBS storage registration
PBS 스토리지 등록 시 자동으로 PQ 네임스페이스를 지정하도록 개선했습니다.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-20 11:38:34 +09:00
Claude
267d262eb3 Simplify RDP config editor: auto-set local user to rdpuser
Changes:
- Remove local user prompt from config editor
- Auto-set local_user to "rdpuser" (default value)
- Display "(자동 설정)" label for local user in confirmation
- Fix "알 수 없음" display: show "설정되지 않음" for unset values
- Improve prompts: show "(Enter=유지)" or example values

This simplifies the config editing process since local_user
is always rdpuser by default in our setup.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-17 04:29:31 +00:00
Claude
fbea9419c3 Add RDP config editor to all-in-one installer rerun menu
When rerunning the installer on existing setup:
- Display current RDP configuration (server, username, local user)
- Add option 2: "RDP 설정 수정" to modify settings
- Allow editing: RDP server, username, password, local user
- Show current values as defaults (press Enter to keep)
- Update both /var/lib/rdp-toggle/config.json and .xinitrc
- After update, show toggle menu to apply changes

Menu options on rerun:
1. RDP toggle menu
2. Edit RDP settings (NEW)
3. Reinstall
4. Exit

This allows users to easily change RDP target without reinstalling.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-17 04:27:30 +00:00
Claude
86eb7ea806 Fix: Save user-input RDP config to API instead of using hardcoded values
Problem:
- All-in-one installer collected RDP connection info from user
- But API was using hardcoded DEFAULT_CONFIG values
- User's input was ignored by the API

Solution:
- Save user's RDP configuration to /var/lib/rdp-toggle/config.json
- API loads this config instead of hardcoded defaults
- Config includes: rdp_server, rdp_username, rdp_password, local_user

Now the API will use the actual RDP settings the user entered during installation.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-17 04:26:31 +00:00
Claude
d02883256f Add rerun detection and toggle menu to all-in-one RDP installer
When script is rerun on already-installed system:
- Detect existing RDP setup (API + getty config)
- Show menu with 3 options:
  1) RDP toggle menu - Interactive RDP/Shell switching
  2) Reinstall - Overwrite existing setup
  3) Exit

Toggle menu features:
- Switch between RDP and Shell modes
- Check current status
- Loop until user exits
- Uses same API as standalone installer

This turns the installer into a convenient RDP control tool on rerun.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-17 03:05:29 +00:00
Claude
e7d5dd02d2 Improve RDP API installer with rerun detection
Changes:
- Detect if RDP Toggle API is already installed and running
- Show test menu immediately on rerun (no reinstallation)
- Add option 3 (status check) and option 4 (exit) to test menu
- Extract test menu to reusable function show_test_menu()
- Both fresh install and rerun show same interactive test menu

Now users can easily test RDP toggle by simply rerunning the installer script.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-17 03:00:55 +00:00
Claude
1977d21a9b Add interactive testing menu to RDP API installer
Add post-installation testing menu with options to:
1) Test RDP mode activation
2) Test Shell mode activation
3) Skip testing

This allows users to immediately test API functionality after installation
without manually typing curl commands. The menu uses the installed API
to toggle modes and displays current status after each action.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-17 02:32:14 +00:00
Claude
2bf3754d62 Improve IP detection: prioritize Headscale VPN IP
- Added get_primary_ip() helper function
- Prioritize Headscale VPN IP (100.64.x.x) detection
- Fallback to local IP if Headscale not available
- Applied to both proxmox-auto-rdp-setup.sh and install-rdp-api.sh
- More reliable IP display for API endpoint

This ensures VPN IP is shown when available, making remote access
more reliable and predictable.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-17 01:58:43 +00:00
Claude
41e9fa1056 README 업데이트: RDP 올인원 설정 강조 및 통합 방식 설명
- 기존 '초기 설정'을 '올인원 설정 (권장)'으로 변경
- API 자동 설치 및 API 기반 활성화 명시
- 주요 특징 강조:
  * API 작동 = RDP 제어 가능 보장
  * 즉시 적용 옵션 (재부팅 불필요)
  * 원격 Shell/RDP 모드 전환 가능
- API 단독 설치는 이미 초기 설정 완료된 경우만 사용하도록 안내
2025-11-17 01:41:30 +00:00
Claude
9c952449d9 RDP 초기 설정에 API 자동 설치 및 API 기반 활성화 추가
- install_rdp_api() 함수 추가 (자동 API 설치)
- RDP 활성화 시 API를 통해 모드 전환 (curl POST /toggle)
- API 작동 = RDP 제어 가능 보장
- API 실패 시 자동으로 직접 활성화 fallback
- 설정 요약에 API 설치 상태 표시
- 수동 적용 안내에 API 명령어 포함

장점:
- API 없이 RDP만 켜지는 상황 방지
- 항상 원격 제어 가능한 상태 보장
- 사용자가 두 스크립트 따로 실행할 필요 없음
2025-11-17 01:40:59 +00:00
Claude
85c5e1ec29 RDP 초기 설정 스크립트 재부팅 대신 즉시 적용 옵션 추가
- 재부팅 없이 getty@tty1 서비스 재시작으로 즉시 적용 가능
- 사용자에게 3가지 선택 옵션 제공:
  1) 즉시 적용 (systemctl restart getty@tty1, 권장)
  2) 시스템 재부팅
  3) 나중에 수동 적용
- systemd daemon-reload 추가로 설정 파일 변경사항 즉시 반영
- 사용자 경험 개선 (불필요한 재부팅 방지)
2025-11-17 01:19:46 +00:00
Claude
13b1da4ee0 RDP 초기 설정 스크립트 패키지 확인 로직 개선
- dpkg 기반 확인에서 command -v 기반 확인으로 변경
- 실제 실행 파일 존재 여부로 설치 확인
- 패키지 이름 매칭 문제 해결
- 확인 실패 시 에러 대신 경고 후 계속 진행
- 검증 대상: startx, openbox, xfreerdp3, unclutter

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-17 01:06:18 +00:00
Claude
ccd34c7f54 RDP 초기 설정 스크립트 입력 처리 수정 (curl | bash 지원)
- 모든 read 명령에 </dev/tty 추가
- curl | bash 실행 시 표준 입력 문제 해결
- 파이프로 실행해도 사용자 입력 정상 작동
- 5개 입력 포인트 모두 수정:
  - RDP 서버 주소
  - RDP 사용자명
  - RDP 패스워드 (2회)
  - 로컬 사용자명
  - 설정 확인
  - 네트워크 연결 실패 시 계속 진행 여부
  - 재부팅 확인

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-17 01:02:31 +00:00
Claude
d0c8b26138 RDP 초기 설정 스크립트 버전 체크 로직 개선
- Proxmox 버전 확인 시 에러 처리 강화
- pveversion 명령 출력 형식 다양하게 지원
- 버전 확인 실패 시 경고 후 계속 진행
- 정규표현식으로 버전 번호 추출 개선
- 숫자 검증 로직 추가

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-17 00:53:11 +00:00
Claude
4934b0a8f9 RDP 자동화 시스템 문서 및 설치 스크립트 개선
- install-rdp-api.sh: curl 원라이너 설치 지원
  - requirements.txt 의존성 제거 (패키지 버전 스크립트 내장)
  - rdp-toggle-api.py Gitea에서 자동 다운로드
  - 상세한 설치 완료 메시지 추가

- RDP/README.md: 완전히 재구성
  - curl 원라이너 설치 가이드 추가
  - API 엔드포인트 상세 설명 및 응답 예시
  - React 프론트엔드 연동 예시 개선
  - 문제 해결 섹션 추가
  - 네트워크 설정 및 방화벽 가이드

- README.md: RDP 자동화 섹션 추가
  - Proxmox RDP 초기 설정 스크립트 소개
  - RDP Toggle API 설치 가이드
  - API 사용 예시 추가

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-17 00:42:19 +00:00
c6919abf1c Add RDP Toggle API with venv support
RDP 관련 파일들을 RDP 폴더로 정리하고 API 시스템 추가

주요 변경사항:
- FastAPI 기반 RDP/Shell 모드 전환 API 서버 추가
- venv 환경을 사용하는 자동 설치 스크립트
- requirements.txt로 패키지 의존성 관리
- systemd 서비스로 자동 시작 설정
- CORS 지원으로 외부 프론트엔드 연동 가능
- 실시간 상태 모니터링 API
- 웹 기반 컨트롤 패널 포함

파일 구성:
- rdp-toggle-api.py: FastAPI REST API 서버
- install-rdp-api.sh: venv 환경 자동 설치
- requirements.txt: Python 패키지 의존성
- rdp-toggle-web.html: 웹 컨트롤 패널
- README.md: 사용 가이드

API 기능:
- GET /status: 현재 모드 확인
- POST /toggle: RDP/Shell 모드 전환
- GET /config: 설정 확인
- PUT /config: 설정 업데이트

리액트 프론트엔드에서 토글로 화면 모드 제어 가능

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-17 09:14:41 +09:00
1f926d6b35 Add Proxmox Auto RDP Setup script
- Automated RDP connection setup for Proxmox VE hosts
- Full-screen RDP client with auto-login configuration
- X11 and FreeRDP3 integration
2025-11-17 08:52:28 +09:00
f5a7c7f695 Add 시간교정.md 2025-11-15 19:26:05 +09:00
Claude
4f7929fc61 약국 생성 시 장비 자동 등록 기능 추가
## 주요 변경사항

### 1. 장비 타입 자동 감지 (detect_device_info)
- Proxmox Host, Windows PC (WSL), macOS, Linux Server 자동 구분
- 호스트명 자동 수집
- 장비명 자동 생성 (예: "테스트약국 Proxmox Host")

### 2. API 호출 개선
- farmq-admin API(/api/pharmacy)에 장비 정보 전송
  - device_type: proxmox_host/windows_pc/mac/linux_server
  - device_name: 자동 생성된 장비명
  - hostname: 시스템 호스트명
- 응답에서 device_registered 확인 및 사용자 피드백

### 3. 출력 개선
- 장비 감지 정보 실시간 출력
- 최종 완료 메시지에 장비 정보 추가

## 백엔드 연동
- farmq-admin/app.py에서 pharmacy_devices 테이블 자동 등록
- 첫 장비는 자동으로 is_primary=TRUE 설정
- device_role: main_server로 고정

## 테스트
- 문법 검사 완료 (bash -n)
- Proxmox Host 환경에서 테스트 준비 완료

🤖 Generated with Claude Code
2025-11-15 10:20:38 +00:00
02137c50a8 Fix stdin issue for curl | bash execution
pbs_allinone.sh 입력 문제 수정:
- 모든 read -p 명령어에 < /dev/tty 추가
- curl | bash 실행 시 stdin 소진 문제 해결
- 사용자 입력이 정상적으로 작동하도록 수정

수정된 read 명령어:
- PBS 스토리지 재등록 확인
- 백업 타입 입력 (vm/ct)
- 백업 ID 입력
- 복구 VM/CT ID 입력
- 저장 스토리지 입력
- 복구 확인
- 기존 VM/CT 삭제 확인
- VM/CT 시작 확인

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-15 00:37:27 +00:00
Claude
1c6e6dcf56 pbs_allinone.sh: 스토리지 선택 개선
- 복구 시 사용 가능한 스토리지 목록 자동 표시
- 스토리지 존재 여부 및 VM/CT 이미지 저장 가능 여부 검증
- 잘못된 스토리지 입력 시 재입력 요청
- 스토리지 용량 및 사용률 정보 표시 (GB 단위)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-14 23:32:23 +09:00
Claude
269350c1d2 Fix: Success check failing due to JSON formatting with spaces
- 'success:true' → 'success' AND 'true' 검사로 변경
- pretty-printed JSON (공백/줄바꿈 포함) 대응
- 실제로는 성공했는데 실패로 표시되던 버그 수정

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-14 11:38:07 +00:00
Claude
d8ce36b8a4 Fix: Improve pharmacy_code extraction with better error messages
- grep + sed 조합으로 pharmacy_code 추출 개선
- [[:space:]]로 공백 처리 추가
- 추출 실패 시 상세한 디버깅 정보 출력
  - 원인 표시
  - JSON 응답 내용 출력
  - 추출 시도 결과 표시
- 에러 메시지 명확화 (약국 생성 API 실패 vs pharmacy_code 추출 실패)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-14 11:35:48 +00:00
Claude
8a391cabd8 Improve: Headscale auto-register script with better JSON parsing and output
주요 개선 사항:
- Python 없이 순수 bash(tr + sed)로 JSON 파싱
- 다중 라인 JSON 응답 대응 (tr -d '\n')
- pharmacy_code 추출 로직 개선
- 결과 요약 포맷 개선 (약국 코드, 약국명, 계정 정보 명확히 표시)
- VPN 정보 섹션 분리

기술적 변경:
- sed 's/.*"pharmacy_code": "\([^"]*\)".*/\1/' 패턴 사용
- Python 의존성 제거로 더 많은 환경에서 실행 가능
- 결과 출력 포맷 사용자 친화적으로 개선

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-14 11:31:51 +00:00
Claude
987662f95b fix: pharmacy_code 추출 정규식 수정 (\K 미지원 환경 대응)
문제:
- grep -oP '\K' 패턴이 일부 환경에서 미지원
- pharmacy_code 추출 실패로 gateway 계정 생성 안 됨

해결:
- cut -d'"' -f4로 변경하여 호환성 향상
- 모든 grep 버전에서 작동

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-14 10:58:03 +00:00
Claude
ae1782a6de fix: P001, P002 약국 보호 로직 추가
문제:
- cleanup 스크립트가 P0003 이후 삭제 시 P001, P002도 함께 삭제됨
- 문자열 비교 'P001' >= 'P0003'이 true로 평가됨

원인:
- SQLite 문자열 비교에서 'P001' < 'P0003'이지만
- 'P002' >= 'P0003'은 false인데, 기존 조건이 잘못됨

해결:
- LENGTH(pharmacy_code) = 5 조건 추가
- P0003 <= pharmacy_code <= P9999 범위 명시
- P001, P002 (4자), P0001, P0002 (5자) 모두 보호

변경 파일:
- cleanup-test-data.sh: 삭제 쿼리 수정
- CLEANUP_TEST_DATA.md: 문서 업데이트

보호되는 약국:
- P001: default 약국 (4자)
- P002: 새서울약국 (4자)
- P0002: 청춘약국 (5자, 범위 밖)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-14 10:53:59 +00:00
Claude
e7485983cc fix: pharmacy_code 추출 로직 수정
문제:
- farmq API 응답에서 pharmacy_code 추출 실패
- 약국은 생성되었지만 코드를 못 가져와서 gateway 계정 생성 불가

원인:
- JSON 구조가 {"pharmacy": {"pharmacy_code": "P0005"}}인데
- 기존 패턴은 최상위 레벨만 검색

해결:
- grep -oP '"pharmacy_code":"\K[^"]+' 패턴 사용
- \K를 사용하여 매칭된 부분 이전은 제외하고 pharmacy_code 값만 추출
- success 체크 먼저 수행

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-14 10:49:55 +00:00
Claude
c2b810c6fc debug: gateway API 호출 디버깅 정보 추가
전송 데이터와 응답을 화면에 출력하여 문제 확인

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-14 10:46:01 +00:00
Claude
a3fd18b1b0 docs: 테스트 데이터 정리 가이드 및 자동화 스크립트 추가
테스트 후 생성된 데이터를 쉽게 정리할 수 있도록 문서와 스크립트 추가

추가 파일:
- CLEANUP_TEST_DATA.md: 상세한 정리 가이드
  * farmq.db 약국 삭제 방법
  * gateway.db 사용자 삭제 방법
  * Headscale 노드 삭제 방법
  * 백업 및 복구 가이드

- cleanup-test-data.sh: 대화형 정리 스크립트
  * P0003 이후 약국 자동 삭제
  * ID 5 이후 사용자 자동 삭제
  * Headscale 노드 선택 삭제
  * 백업 생성 옵션
  * 안전 확인 프롬프트

변경 파일:
- README.md: Headscale 섹션 업데이트
  * 자동 등록 스크립트 설명 추가
  * 테스트 데이터 정리 가이드 링크 추가

사용 예시:
```bash
# 대화형 정리
bash cleanup-test-data.sh

# 원격 실행
curl -fsSL https://.../cleanup-test-data.sh | bash
```

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-14 10:44:05 +00:00
Claude
38c6257180 fix: 기본 비밀번호를 8자로 변경 (gateway 최소 길이 요구사항)
Gateway API의 비밀번호 최소 길이 검증(8자) 때문에
기존 "1234"에서 "12341234"로 변경

변경 사항:
- PASSWORD 변수: "1234" → "12341234"
- display_login_credentials 출력도 업데이트

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-14 10:31:03 +00:00
Claude
c0973c622a fix: stdin 리다이렉션 문제 수정 (curl 파이프 실행 지원)
curl | bash 방식으로 실행 시 read 명령어가 stdin을 읽을 수 없는 문제 수정
모든 사용자 입력을 /dev/tty에서 읽도록 변경

변경 사항:
- collect_pharmacy_info() 함수의 모든 read 명령어에 </dev/tty 추가
- 이제 스크립트를 파이프로 실행해도 사용자 입력 가능

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-14 10:22:59 +00:00
Claude
9c9a25218e fix: VPN IP 확인 명령어 수정 (tailscale ip -4 사용)
문제:
- tailscale status --json으로 파싱 시도했으나 동작하지 않음
- VPN IP 할당 대기 중 10초 타임아웃 발생

해결:
- 기존 스크립트와 동일하게 'tailscale ip -4' 명령어 사용
- JSON 파싱 불필요, 직접 IPv4 주소 반환

테스트:
- tailscale ip -4 명령어로 즉시 IP 확인 가능
- 100.64.0.14 형식의 IP 정상 출력

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-14 10:15:48 +00:00
Claude
7020339867 feat: 자동 등록 스크립트를 별도 파일로 분리
변경사항:
- headscale-quick-install.sh: 기존 스크립트 유지 (단순 VPN 등록만)
- headscale-auto-register.sh: 새로운 자동 등록 스크립트 (NEW!)

headscale-auto-register.sh 기능:
- Headscale VPN 자동 등록
- VPN IP 자동 확인 (10초 재시도)
- 약국 정보 수집 (약국명 필수)
- farmq.db에 약국 자동 생성 (demo.pharmq.kr)
- gateway.db에 admin 계정 자동 생성 (gateway.pharmq.kr)
- 로그인 정보 출력 (아이디: p{code}, 비밀번호: 1234)

사용법:
# 기존 방식 (VPN만 등록)
curl -fsSL https://git.0bin.in/.../headscale-quick-install.sh | bash

# 새로운 방식 (VPN + 약국 + 계정 자동 생성)
curl -fsSL https://git.0bin.in/.../headscale-auto-register.sh | bash

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-14 09:46:51 +00:00
Claude
9d4142ddb6 feat: Headscale 자동 등록 시 약국 및 계정 자동 생성
주요 변경사항:
- 약국 정보 수집 함수 추가 (collect_pharmacy_info)
- VPN IP 자동 확인 함수 추가 (get_assigned_vpn_ip)
- farmq-admin API 호출 함수 추가 (create_pharmacy_via_api)
- gateway API 호출 함수 추가 (create_gateway_user_via_api)
- 로그인 정보 출력 함수 추가 (display_login_credentials)

플로우:
1. Headscale VPN 등록
2. VPN IP 자동 확인 (최대 10초 대기)
3. 약국 정보 수집 (약국명 필수, 나머지 선택)
4. farmq.db에 약국 생성 (https://demo.pharmq.kr/api/pharmacy)
5. gateway.db에 admin 계정 생성 (https://gateway.pharmq.kr/api/auth/register)
6. 로그인 정보 출력 (아이디: p{pharmacy_code}, 비밀번호: 1234)

API 엔드포인트:
- farmq-admin: https://demo.pharmq.kr/api/pharmacy
- gateway: https://gateway.pharmq.kr/api/auth/register

백업 파일: headscale-quick-install.sh.backup

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-14 09:45:15 +00:00
Claude
60be9daff4 docs: headscale 자동 등록 개선 계획 문서 추가
추가된 문서:
- SCRIPT_IMPROVEMENT_PLAN.md: 스크립트 개선 계획
- FARMQ_ADMIN_INTEGRATION_ANALYSIS.md: farmq-admin API 분석
- HEADSCALE_AUTO_REGISTER_PLAN.md: 초기 계획

주요 내용:
- Headscale VPN 등록 시 자동 DB 생성
- API 엔드포인트: demo.pharmq.kr, gateway.pharmq.kr

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-14 09:40:54 +00:00
7a793ea77d Update README with PVE hostname changer guide
README 문서 업데이트:
- Proxmox VE 호스트명/FQDN 변경 섹션 추가
- 빠른 실행 명령어 제공
- 주요 기능 및 주의사항 안내
- 대화형 호스트명 변경 도구 소개

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-14 06:01:10 +00:00
ffa955a64f Add Proxmox VE hostname/FQDN changer script
Proxmox VE 호스트명/FQDN 안전 변경 스크립트 추가:
- 현재 설정 확인 및 백업 기능
- 호스트명/도메인 입력 검증
- /etc/hostname, /etc/hosts 자동 수정
- hostnamectl 적용
- Proxmox 서비스 재시작
- 인증서 자동 재발급
- 변경사항 검증 및 롤백 가이드
- 대화형 UI로 안전한 변경 지원

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-14 06:00:31 +00:00
912410ba90 Update README with Headscale and PBS installation guides
README 문서 업데이트:
- Headscale 빠른 설치 섹션 추가
- PBS 올인원 설치 섹션 추가
- 각 스크립트별 빠른 설치 명령어 제공
- 설치 내용 및 사용 방법 안내

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-14 04:50:59 +00:00
a45be35543 Add headscale quick installation script
헤드스케일 빠른 설치 스크립트 추가:
- Tailscale 대체 헤드스케일 클라이언트 등록
- PBS 서버 등록 전 필수 네트워크 설정
- Proxmox 환경을 위한 헤드스케일 통합

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-14 04:46:42 +00:00
187c90e29e Add PBS all-in-one installation script
Proxmox Backup Server 올인원 설치 스크립트 추가:
- PBS 저장소 설정
- PBS 패키지 설치
- PBS 저장소 생성 및 마운트
- 방화벽 설정
- Proxmox VE PBS 통합

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-14 04:37:03 +00:00
e309524709 Add Tailscale_TUN_Fix_25_11_02.md 2025-11-03 23:22:41 +09:00
1baba8626b docs: Update README with separate installation commands
 변경사항:
- code-server 설치 명령어를 각각 분리된 코드 블록으로 구성
- URL을 현재 리포지토리(pve9-repo-fix)로 변경
- Claude Code CLI 설치 섹션 추가

📝 개선사항:
- 복사하기 버튼으로 각 명령어 개별 복사 가능
- 기본 설치 / 포트 지정 / 무인 설치 명령어 분리
- 수동 설치 가이드 추가

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-03 23:16:56 +09:00
2780b5cf14 feat: Add code-server installation and setup script
 새로운 기능:
- code-server 자동 설치 (미설치 시)
- 설정 파일 자동 생성 및 구성
- 기존 프로세스 정리 (중복 실행 방지)
- 0.0.0.0 바인딩으로 외부 접속 가능
- nohup 백그라운드 실행 (세션 종료 후에도 유지)

🛠️ 사용 방법:
- 기본 포트(8080): ./code-server.sh
- 포트 지정: PORT=8443 ./code-server.sh
- 무인 설치: PASSWORD="pass" SKIP_CONFIRM=1 ./code-server.sh

🔧 주요 기능:
- systemd 미사용 (수동 관리)
- 설정 백업 자동 생성
- 프로세스 안전 종료 후 재시작

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-03 23:14:31 +09:00
28 changed files with 12595 additions and 10 deletions

285
CLEANUP_TEST_DATA.md Normal file
View File

@@ -0,0 +1,285 @@
# 테스트 데이터 정리 가이드
## 개요
headscale 자동 등록 스크립트 테스트 시 생성되는 데이터를 정리하는 방법
## 정리해야 할 데이터
1. **farmq.db**: 테스트 약국 데이터 (P0003 이후)
2. **gateway.db**: 테스트 사용자 계정 (ID 5 이후)
3. **Headscale**: 테스트 VPN 노드
---
## 1. farmq.db 테스트 약국 삭제
### 수동 삭제 (Python)
```bash
cd /srv/headscale-tailscale-replacement/farmq-admin
python3 << 'EOF'
import sqlite3
conn = sqlite3.connect('farmq.db')
cursor = conn.cursor()
print('현재 약국 목록:')
cursor.execute('SELECT pharmacy_code, pharmacy_name, tailscale_ip FROM pharmacies')
for row in cursor.fetchall():
print(f' {row[0]}: {row[1]} - {row[2]}')
# P0003~P9999 약국 삭제 (P001, P002, P0001, P0002는 보호)
print('\nP0003 이후 약국 삭제 중...')
cursor.execute("DELETE FROM pharmacies WHERE pharmacy_code >= 'P0003' AND pharmacy_code <= 'P9999' AND LENGTH(pharmacy_code) = 5")
deleted_count = cursor.rowcount
conn.commit()
print(f'✓ {deleted_count}개 약국 삭제 완료')
print('\n남은 약국 목록:')
cursor.execute('SELECT pharmacy_code, pharmacy_name, tailscale_ip FROM pharmacies')
for row in cursor.fetchall():
print(f' {row[0]}: {row[1]} - {row[2]}')
conn.close()
EOF
```
### 특정 약국만 삭제
```bash
cd /srv/headscale-tailscale-replacement/farmq-admin
python3 << 'EOF'
import sqlite3
conn = sqlite3.connect('farmq.db')
cursor = conn.cursor()
# 삭제할 약국 코드 지정
pharmacy_codes = ['P0003', 'P0004', 'P0005', 'P0006']
for code in pharmacy_codes:
cursor.execute('DELETE FROM pharmacies WHERE pharmacy_code = ?', (code,))
print(f'✓ {code} 삭제 완료')
conn.commit()
conn.close()
EOF
```
---
## 2. gateway.db 테스트 사용자 삭제
### 수동 삭제 (Python)
```bash
cd /srv/pharmq-gateway
python3 << 'EOF'
import sqlite3
conn = sqlite3.connect('gateway.db')
cursor = conn.cursor()
print('현재 사용자 목록:')
cursor.execute('SELECT id, username, name, primary_pharmacy_code FROM users')
for row in cursor.fetchall():
print(f' ID {row[0]}: {row[1]} ({row[2]}) - {row[3]}')
# ID 5 이후 사용자 삭제 (테스트 데이터)
print('\nID 5 이후 사용자 삭제 중...')
cursor.execute('DELETE FROM pharmacy_members WHERE user_id >= 5')
cursor.execute('DELETE FROM users WHERE id >= 5')
deleted_count = cursor.rowcount
conn.commit()
print(f'✓ {deleted_count}명 사용자 삭제 완료')
print('\n남은 사용자 목록:')
cursor.execute('SELECT id, username, name, primary_pharmacy_code FROM users')
for row in cursor.fetchall():
print(f' ID {row[0]}: {row[1]} ({row[2]}) - {row[3]}')
conn.close()
EOF
```
### 특정 사용자만 삭제
```bash
cd /srv/pharmq-gateway
python3 << 'EOF'
import sqlite3
conn = sqlite3.connect('gateway.db')
cursor = conn.cursor()
# 삭제할 사용자 ID 지정
user_ids = [5, 6, 7, 8]
for user_id in user_ids:
cursor.execute('DELETE FROM pharmacy_members WHERE user_id = ?', (user_id,))
cursor.execute('DELETE FROM users WHERE id = ?', (user_id,))
print(f'✓ ID {user_id} 삭제 완료')
conn.commit()
conn.close()
EOF
```
---
## 3. Headscale 테스트 노드 삭제
### 노드 목록 확인
```bash
docker exec headscale headscale nodes list
```
### 특정 노드 삭제 (ID 기준)
```bash
# 단일 노드 삭제
docker exec headscale headscale nodes delete --identifier <NODE_ID> --force
# 예시: ID 13 삭제
docker exec headscale headscale nodes delete --identifier 13 --force
```
### 여러 노드 한번에 삭제
```bash
# 삭제할 노드 ID들
NODE_IDS=(13 14 15 16 17 18)
for id in "${NODE_IDS[@]}"; do
docker exec headscale headscale nodes delete --identifier $id --force
echo "✓ Node ID $id 삭제 완료"
done
```
### 특정 이름 패턴 노드 삭제
```bash
# pve-로 시작하는 테스트 노드 찾기
docker exec headscale headscale nodes list | grep "pve-"
# 해당 노드들의 ID를 확인한 후 수동으로 삭제
docker exec headscale headscale nodes delete --identifier <ID> --force
```
---
## 자동화 스크립트
### 통합 정리 스크립트 실행
```bash
bash /srv/install_scripts/pve9-repo-fix/cleanup-test-data.sh
```
**또는 직접 다운로드하여 실행:**
```bash
curl -fsSL https://raw.githubusercontent.com/thug0bin/pve9-repo-fix/main/cleanup-test-data.sh | bash
```
---
## 사용 시나리오
### 시나리오 1: 전체 테스트 데이터 정리
```bash
# 1. farmq.db 정리 (P0003~P9999, P001/P002 보호)
cd /srv/headscale-tailscale-replacement/farmq-admin
python3 -c "import sqlite3; conn = sqlite3.connect('farmq.db'); cursor = conn.cursor(); cursor.execute(\"DELETE FROM pharmacies WHERE pharmacy_code >= 'P0003' AND pharmacy_code <= 'P9999' AND LENGTH(pharmacy_code) = 5\"); conn.commit(); print(f'✓ {cursor.rowcount}개 약국 삭제'); conn.close()"
# 2. gateway.db 정리 (ID 5 이후)
cd /srv/pharmq-gateway
python3 -c "import sqlite3; conn = sqlite3.connect('gateway.db'); cursor = conn.cursor(); cursor.execute('DELETE FROM pharmacy_members WHERE user_id >= 5'); cursor.execute('DELETE FROM users WHERE id >= 5'); conn.commit(); print(f'✓ {cursor.rowcount}명 사용자 삭제'); conn.close()"
# 3. Headscale 노드 정리 (수동 확인 후)
docker exec headscale headscale nodes list
# 테스트 노드 ID 확인 후 삭제
```
### 시나리오 2: 특정 약국 코드 범위만 정리
```bash
# P0005~P0010 범위만 삭제
cd /srv/headscale-tailscale-replacement/farmq-admin
python3 -c "import sqlite3; conn = sqlite3.connect('farmq.db'); cursor = conn.cursor(); cursor.execute(\"DELETE FROM pharmacies WHERE pharmacy_code >= 'P0005' AND pharmacy_code <= 'P0010'\"); conn.commit(); print(f'✓ {cursor.rowcount}개 약국 삭제'); conn.close()"
```
---
## 주의사항
⚠️ **운영 데이터 보호**
- P001, P002, P0002는 운영 약국이므로 삭제하지 마세요
- ID 1~4 사용자는 운영 계정이므로 삭제하지 마세요
- 삭제 전 반드시 데이터를 확인하세요
⚠️ **백업 권장**
```bash
# farmq.db 백업
cp /srv/headscale-tailscale-replacement/farmq-admin/farmq.db /srv/headscale-tailscale-replacement/farmq-admin/farmq.db.backup
# gateway.db 백업
cp /srv/pharmq-gateway/gateway.db /srv/pharmq-gateway/gateway.db.backup
```
⚠️ **Headscale 노드 삭제**
- offline 상태의 노드만 삭제 권장
- online 노드 삭제 시 연결이 끊어집니다
---
## 트러블슈팅
### 문제: "database is locked" 에러
**원인**: Flask 서버가 DB를 사용 중
**해결**:
```bash
# farmq-admin 재시작
cd /srv/headscale-tailscale-replacement/farmq-admin
pkill -f "flask run"
source venv/bin/activate
flask run --host=0.0.0.0 --port=5001 &
# gateway 재시작
cd /srv/pharmq-gateway
pkill -f "uvicorn"
source venv/bin/activate
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload &
```
### 문제: Python 스크립트 실행 안 됨
**해결**: 경로 확인
```bash
# 올바른 경로로 이동
cd /srv/headscale-tailscale-replacement/farmq-admin # farmq.db
cd /srv/pharmq-gateway # gateway.db
```
---
## 참고
- farmq.db 위치: `/srv/headscale-tailscale-replacement/farmq-admin/farmq.db`
- gateway.db 위치: `/srv/pharmq-gateway/gateway.db`
- Headscale: Docker 컨테이너 `headscale`
- 약국 코드 형식: P0001~P9999 (P + 4자리 숫자)
- 사용자 계정: pharmacy_code 소문자 (예: P0003 → p0003)

View File

@@ -0,0 +1,538 @@
# farmq-admin 통합 분석 및 개선 방안
## 📅 작성일
**2025년 11월 14일**
---
## 🎯 발견 사항
### farmq-admin이 이미 존재하고 실행 중!
```bash
프로세스: /srv/headscale-tailscale-replacement/farmq-admin/venv/bin/python app.py
포트: 5001
상태: 실행 중 (Nov 05부터 계속 실행)
```
**중요:** farmq-admin은 Flask 기반 웹 애플리케이션으로, **farmq.db를 관리하는 API를 이미 제공**하고 있습니다!
---
## 📊 farmq-admin 구조 분석
### 1. 실행 정보
```bash
위치: /srv/headscale-tailscale-replacement/farmq-admin/
메인: app.py (Flask 애플리케이션)
포트: 5001
DB: farmq.db (SQLAlchemy ORM 사용)
```
### 2. 주요 API 엔드포인트
#### 약국 관리 API
```python
POST /api/pharmacy # 새 약국 생성 ✨
GET /api/pharmacy/<id> # 약국 정보 조회
PUT /api/pharmacy/<id> # 약국 정보 수정
DELETE /api/pharmacy/<id> # 약국 삭제
```
### 3. 약국 생성 API 상세 (`POST /api/pharmacy`)
**요청 예시:**
```json
POST http://localhost:5001/api/pharmacy
Content-Type: application/json
{
"pharmacy_name": "행복약국",
"owner_name": "홍길동",
"owner_phone": "010-1234-5678",
"owner_email": "happy@pharmq.kr",
"phone": "02-1234-5678",
"address": "서울시 강남구...",
"api_port": 8082
}
```
**응답 예시:**
```json
{
"success": true,
"message": "약국 \"행복약국\" (코드: P004) 생성 완료",
"pharmacy": {
"id": 4,
"pharmacy_code": "P004",
"pharmacy_name": "행복약국",
"tailscale_ip": null,
"api_port": 8082,
"status": "active",
"owner_name": "홍길동",
"owner_phone": "010-1234-5678",
"owner_email": "happy@pharmq.kr"
}
}
```
### 4. 자동 생성 로직 (이미 구현됨!)
```python
# app.py Line 418-433
# pharmacy_code 자동 생성 (P001~P999)
last_pharmacy = farmq_session.query(PharmacyInfo)\
.filter(PharmacyInfo.pharmacy_code.like('P%'))\
.order_by(PharmacyInfo.pharmacy_code.desc())\
.first()
if last_pharmacy and last_pharmacy.pharmacy_code:
try:
last_num = int(last_pharmacy.pharmacy_code[1:])
new_num = last_num + 1
except:
new_num = 1
else:
new_num = 1
pharmacy_code = f"P{new_num:03d}" # P001, P002, P003...
```
**결과:** 마지막 약국 코드를 찾아서 자동으로 +1 증가!
---
## 🔄 개선된 통합 방안
### 기존 계획 vs 새로운 발견
#### ❌ 기존 계획 (불필요)
```
Python 스크립트로 직접 farmq.db INSERT
→ SQL 쿼리 작성
→ 에러 핸들링 직접 구현
→ 검증 로직 직접 구현
```
#### ✅ 새로운 방안 (API 활용)
```
farmq-admin API 호출
→ 이미 모든 로직 구현됨
→ 검증, 에러 핸들링 완료
→ pharmacy_code 자동 생성
```
---
## 🚀 최종 개선 방안
### 방법 1: farmq-admin API 활용 (권장 ⭐)
#### 장점
- ✅ 이미 구현된 API 활용
- ✅ pharmacy_code 자동 생성
- ✅ 검증 로직 포함
- ✅ 에러 핸들링 완료
- ✅ 유지보수 용이
#### 구현
```bash
# headscale-quick-install.sh에서 호출
register_to_farmq_api() {
print_status "farmq-admin API를 통해 약국 등록 중..."
# JSON 데이터 생성
JSON_DATA=$(cat <<EOF
{
"pharmacy_name": "$PHARMACY_NAME",
"owner_name": "$OWNER_NAME",
"owner_phone": "$OWNER_PHONE",
"owner_email": "$OWNER_EMAIL",
"phone": "$PHARMACY_PHONE",
"address": "$PHARMACY_ADDRESS",
"api_port": 8082
}
EOF
)
# API 호출
RESPONSE=$(curl -s -X POST \
http://localhost:5001/api/pharmacy \
-H "Content-Type: application/json" \
-d "$JSON_DATA")
# 응답 확인
SUCCESS=$(echo "$RESPONSE" | grep -o '"success"[[:space:]]*:[[:space:]]*true')
if [ -n "$SUCCESS" ]; then
# pharmacy_code 추출
PHARMACY_CODE=$(echo "$RESPONSE" | grep -o '"pharmacy_code"[[:space:]]*:[[:space:]]*"[^"]*"' | cut -d'"' -f4)
print_success "약국 등록 완료! 코드: $PHARMACY_CODE"
# VPN IP 업데이트 (별도 API 호출 필요)
update_pharmacy_vpn_ip "$PHARMACY_CODE" "$TAILSCALE_IP"
return 0
else
print_error "약국 등록 실패"
echo "$RESPONSE"
return 1
fi
}
```
#### VPN IP 업데이트
```bash
update_pharmacy_vpn_ip() {
local PHARMACY_CODE=$1
local VPN_IP=$2
# pharmacy_id 조회 필요 (또는 UPDATE API 수정)
# 현재는 PUT /api/pharmacy/<id>만 있음
# 임시 방안: 직접 DB 업데이트
python3 << EOF
import sqlite3
conn = sqlite3.connect('/srv/headscale-tailscale-replacement/farmq-admin/farmq.db')
cursor = conn.cursor()
cursor.execute("""
UPDATE pharmacies
SET tailscale_ip = ?
WHERE pharmacy_code = ?
""", ("$VPN_IP", "$PHARMACY_CODE"))
conn.commit()
conn.close()
print("VPN IP 업데이트 완료: $PHARMACY_CODE → $VPN_IP")
EOF
}
```
### 방법 2: 하이브리드 방식 (대안)
```bash
register_pharmacy_hybrid() {
# 1. farmq-admin API로 기본 정보 등록
PHARMACY_CODE=$(call_farmq_api_create)
# 2. VPN IP는 직접 업데이트 (API에 없는 필드)
update_vpn_ip_direct "$PHARMACY_CODE" "$TAILSCALE_IP"
# 3. gateway.db는 Python 스크립트로 생성
create_gateway_user "$PHARMACY_CODE"
}
```
---
## 📋 수정된 전체 흐름
### 새로운 스크립트 흐름
```
1. OS 감지
2. Tailscale 설치
3. Tailscale 서비스 시작
4. Headscale 등록 (preauth key 사용)
5. VPN IP 할당 받음 (예: 100.64.0.15)
6. 연결 확인
7. ✨ 약국 정보 입력 받기 (대화형)
8. ✨ farmq-admin API 호출 → farmq.db 등록
- pharmacy_code 자동 생성 (API가 처리)
- 기본 정보 저장
9. ✨ VPN IP 업데이트 (직접 DB 또는 API 개선)
10. ✨ gateway.db에 사용자 생성 (Python 스크립트)
11. ✨ 로그인 정보 출력
12. 종료 ✅
```
---
## 🛠️ 필요한 개선 사항
### farmq-admin API 개선 (선택사항)
#### 1. VPN IP 업데이트 API 추가
```python
# app.py에 추가
@app.route('/api/pharmacy/<pharmacy_code>/vpn-ip', methods=['PUT'])
def api_update_pharmacy_vpn_ip(pharmacy_code):
"""약국 VPN IP 업데이트"""
try:
data = request.get_json()
vpn_ip = data.get('vpn_ip', '').strip()
if not vpn_ip:
return jsonify({
'success': False,
'error': 'VPN IP는 필수입니다.'
}), 400
farmq_session = get_farmq_session()
try:
pharmacy = farmq_session.query(PharmacyInfo).filter(
PharmacyInfo.pharmacy_code == pharmacy_code
).first()
if not pharmacy:
return jsonify({
'success': False,
'error': '약국을 찾을 수 없습니다.'
}), 404
pharmacy.tailscale_ip = vpn_ip
farmq_session.commit()
return jsonify({
'success': True,
'message': f'VPN IP 업데이트 완료: {pharmacy_code}{vpn_ip}'
})
finally:
farmq_session.close()
except Exception as e:
return jsonify({
'success': False,
'error': f'서버 오류: {str(e)}'
}), 500
```
**사용:**
```bash
curl -X PUT http://localhost:5001/api/pharmacy/P004/vpn-ip \
-H "Content-Type: application/json" \
-d '{"vpn_ip": "100.64.0.15"}'
```
#### 2. pharmacy_code로 조회 API 추가
```python
@app.route('/api/pharmacy/code/<pharmacy_code>', methods=['GET'])
def api_get_pharmacy_by_code(pharmacy_code):
"""약국 코드로 약국 정보 조회"""
try:
farmq_session = get_farmq_session()
try:
pharmacy = farmq_session.query(PharmacyInfo).filter(
PharmacyInfo.pharmacy_code == pharmacy_code
).first()
if not pharmacy:
return jsonify({
'success': False,
'error': '약국을 찾을 수 없습니다.'
}), 404
return jsonify({
'success': True,
'pharmacy': pharmacy.to_dict()
})
finally:
farmq_session.close()
except Exception as e:
return jsonify({
'success': False,
'error': f'서버 오류: {str(e)}'
}), 500
```
---
## 📊 비교표
| 항목 | 직접 DB 접근 | farmq-admin API |
|------|-------------|-----------------|
| 구현 난이도 | 중 | 쉬움 ⭐ |
| pharmacy_code 생성 | 직접 구현 필요 | 자동 처리 ✅ |
| 검증 로직 | 직접 구현 필요 | 이미 구현됨 ✅ |
| 에러 핸들링 | 직접 구현 필요 | 이미 구현됨 ✅ |
| 유지보수 | 어려움 | 쉬움 ✅ |
| VPN IP 업데이트 | 직접 가능 | API 개선 필요 |
| 의존성 | SQLite3만 | curl 필요 |
---
## 🎯 최종 권장 사항
### Phase 1: 최소 변경 (즉시 적용 가능)
```bash
# headscale-quick-install.sh 수정
register_pharmacy() {
print_status "약국 등록 중..."
# 1. farmq-admin API로 약국 생성
RESPONSE=$(curl -s -X POST \
http://localhost:5001/api/pharmacy \
-H "Content-Type: application/json" \
-d "{
\"pharmacy_name\": \"$PHARMACY_NAME\",
\"owner_name\": \"$OWNER_NAME\",
\"owner_phone\": \"$OWNER_PHONE\",
\"owner_email\": \"$OWNER_EMAIL\",
\"api_port\": 8082
}")
# 2. pharmacy_code 추출
PHARMACY_CODE=$(echo "$RESPONSE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('pharmacy',{}).get('pharmacy_code',''))")
# 3. VPN IP 업데이트 (직접 DB)
python3 << EOF
import sqlite3
conn = sqlite3.connect('/srv/headscale-tailscale-replacement/farmq-admin/farmq.db')
cursor = conn.cursor()
cursor.execute("UPDATE pharmacies SET tailscale_ip = ? WHERE pharmacy_code = ?",
("$TAILSCALE_IP", "$PHARMACY_CODE"))
conn.commit()
conn.close()
EOF
# 4. gateway.db 사용자 생성 (별도 스크립트)
python3 /srv/pharmq-gateway/scripts/create_gateway_user.py \
--pharmacy-code "$PHARMACY_CODE" \
--owner "$OWNER_NAME" \
--phone "$OWNER_PHONE" \
--email "$OWNER_EMAIL"
}
```
### Phase 2: farmq-admin API 개선 (선택)
app.py에 다음 추가:
1. `PUT /api/pharmacy/<code>/vpn-ip` - VPN IP 업데이트
2. `GET /api/pharmacy/code/<code>` - 코드로 조회
### Phase 3: 완전 통합
gateway.db 생성도 API로 통합 (gateway API 서버에서)
---
## 📝 구현 예시 (최소 변경)
### 1. gateway 사용자 생성 스크립트
**파일:** `/srv/pharmq-gateway/scripts/create_gateway_user.py`
```python
#!/usr/bin/env python3
import sqlite3
import sys
import argparse
from datetime import datetime
import secrets
import string
from passlib.hash import pbkdf2_sha256
def generate_password(length=8):
alphabet = string.ascii_letters + string.digits
return ''.join(secrets.choice(alphabet) for _ in range(length))
def create_gateway_user(args):
username = f"{args.pharmacy_code.lower()}_admin"
password = generate_password(8)
password_hash = pbkdf2_sha256.hash(password)
conn = sqlite3.connect(args.gateway_db)
cursor = conn.cursor()
try:
# users 테이블
cursor.execute("""
INSERT INTO users (
username, email, password_hash, name, phone,
primary_pharmacy_code, role, status,
failed_login_attempts, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
username,
args.email,
password_hash,
args.owner,
args.phone,
args.pharmacy_code,
'admin',
'active',
0,
datetime.now().isoformat(),
datetime.now().isoformat()
))
user_id = cursor.lastrowid
# pharmacy_members 테이블
cursor.execute("""
INSERT INTO pharmacy_members (
pharmacy_code, user_id, position, access_level,
can_view_sales, can_view_inventory, can_manage_staff,
is_active, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
args.pharmacy_code, user_id, '대표약사', 'owner',
True, True, True, True,
datetime.now().isoformat(),
datetime.now().isoformat()
))
conn.commit()
# 결과 출력 (JSON)
import json
print(json.dumps({
'success': True,
'username': username,
'password': password,
'pharmacy_code': args.pharmacy_code
}))
except Exception as e:
import json
print(json.dumps({
'success': False,
'error': str(e)
}))
sys.exit(1)
finally:
conn.close()
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('--pharmacy-code', required=True)
parser.add_argument('--owner', required=True)
parser.add_argument('--phone', required=True)
parser.add_argument('--email', required=True)
parser.add_argument('--gateway-db',
default='/srv/pharmq-gateway/gateway.db')
args = parser.parse_args()
create_gateway_user(args)
```
---
## ✅ 결론
### 핵심 발견
**farmq-admin이 이미 약국 생성 API를 제공하고 있음!**
- pharmacy_code 자동 생성 ✅
- 검증 로직 완료 ✅
- 에러 핸들링 완료 ✅
### 개선 방안
1. **farmq.db**: farmq-admin API 활용 (POST /api/pharmacy)
2. **VPN IP**: 직접 DB 업데이트 (또는 API 추가)
3. **gateway.db**: Python 스크립트로 생성
### 장점
- ✅ 기존 시스템 재사용
- ✅ 중복 코드 방지
- ✅ 유지보수 용이
- ✅ 빠른 구현 가능
---
**작성일:** 2025년 11월 14일
**작성자:** Claude Code
**버전:** farmq-admin Integration Analysis v1.0

View File

@@ -0,0 +1,838 @@
# Headscale 자동 등록 및 DB 생성 개선 기획서
## 📅 작성일
**2025년 11월 14일**
---
## 🎯 목표
Headscale 설치 스크립트(`headscale-quick-install.sh`)를 개선하여:
1. ✅ Headscale VPN 등록 (현재 완료)
2.**farmq.db 자동 생성** (신규)
3.**gateway.db 자동 생성** (신규)
4.**즉시 프론트엔드 로그인 가능** (신규)
**최종 결과:** 스크립트 실행 → 즉시 React 프론트엔드에서 로그인하여 사용 가능
---
## 📊 현재 상태 분석
### 현재 스크립트 흐름
```
1. OS 감지
2. Tailscale 설치
3. Tailscale 서비스 시작
4. Headscale 등록 (preauth key 사용)
5. VPN IP 할당 받음 (예: 100.64.0.15)
6. 연결 확인
7. 종료 ❌ (DB 생성 없음)
```
### 문제점
- ❌ farmq.db에 약국 정보 없음
- ❌ gateway.db에 사용자 계정 없음
- ❌ 프론트엔드에서 로그인 불가능
- ❌ 수동으로 DB 수정 필요
---
## 🔄 개선된 흐름
### 새로운 스크립트 흐름
```
1. OS 감지
2. Tailscale 설치
3. Tailscale 서비스 시작
4. Headscale 등록 (preauth key 사용)
5. VPN IP 할당 받음 (예: 100.64.0.15)
6. 연결 확인
7. ✨ 약국 정보 입력 받기 (대화형)
8. ✨ farmq.db에 약국 등록
9. ✨ gateway.db에 사용자 생성
10. ✨ 로그인 정보 출력
11. 종료 ✅ (완전 자동화)
```
---
## 🗄️ 데이터베이스 맵핑 구조
### 3계층 DB 구조
```
┌─────────────────────────────────────────────────────────┐
│ gateway.db │
│ ┌───────────────────────────────────────────────────┐ │
│ │ users │ │
│ │ - username: "약국코드_admin" (예: P0004_admin) │ │
│ │ - password_hash: (자동 생성된 비밀번호) │ │
│ │ - primary_pharmacy_code: "P0004" ◄───────┐ │ │
│ │ - role: "admin" │ │ │
│ │ - email: "p0004@pharmq.kr" │ │ │
│ └───────────────────────────────────────────┼───────┘ │
│ │ │
│ ┌───────────────────────────────────────────┼───────┐ │
│ │ pharmacy_members │ │ │
│ │ - user_id: (위에서 생성된 ID) │ │ │
│ │ - pharmacy_code: "P0004" ◄───────────────┘ │ │
│ │ - position: "대표약사" │ │
│ │ - access_level: "owner" │ │
│ └───────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
↓ pharmacy_code
┌─────────────────────────────────────────────────────────┐
│ farmq.db │
│ ┌───────────────────────────────────────────────────┐ │
│ │ pharmacies │ │
│ │ - pharmacy_code: "P0004" ◄─────────────────┐ │ │
│ │ - pharmacy_name: "사용자 입력" │ │ │
│ │ - tailscale_ip: "100.64.0.15" (Headscale) │ │ │
│ │ - api_port: 8082 │ │ │
│ │ - status: "active" │ │ │
│ │ - owner_name: "사용자 입력" │ │ │
│ │ - owner_phone: "사용자 입력" │ │ │
│ │ - headscale_user_name: "default" │ │ │
│ └───────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
↓ vpn_ip
┌─────────────────────────────────────────────────────────┐
│ db.sqlite (Headscale - 읽기전용) │
│ ┌───────────────────────────────────────────────────┐ │
│ │ nodes │ │
│ │ - ipv4: "100.64.0.15" │ │
│ │ - last_seen: "실시간" │ │
│ │ - user_id: 1 (default) │ │
│ └───────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
```
---
## 📝 사용자 입력 항목
### 필수 입력 항목
```bash
1. 약국 이름 (pharmacy_name)
예: "행복약국", "새서울약국"
2. 대표약사 이름 (owner_name)
예: "홍길동"
3. 대표약사 전화번호 (owner_phone)
예: "010-1234-5678"
4. 이메일 (owner_email) - 선택
예: "happy@pharmq.kr"
```
### 자동 생성 항목
```bash
1. pharmacy_code
로직: farmq.db에서 가장 큰 번호 + 1
예: 마지막이 P0002 → P0003 생성
2. VPN IP (tailscale_ip)
로직: tailscale ip -4 명령으로 자동 획득
예: 100.64.0.15
3. username
로직: {pharmacy_code}_admin
예: P0003 → "p0003_admin"
4. password
로직: 랜덤 8자리 생성 (영문+숫자)
예: "aB3xK9mP"
5. email (입력 없을 시)
로직: {pharmacy_code}@pharmq.kr
예: "p0003@pharmq.kr"
```
---
## 🔧 기술 구현 방안
### 1. DB 접근 방법
#### Option A: SQLite CLI (간단)
```bash
sqlite3 /srv/headscale-tailscale-replacement/farmq-admin/farmq.db \
"INSERT INTO pharmacies (pharmacy_code, pharmacy_name, ...) VALUES (...);"
```
**장점:**
- ✅ 추가 의존성 없음
- ✅ 스크립트에 바로 통합 가능
**단점:**
- ❌ 복잡한 쿼리 작성 어려움
- ❌ 에러 핸들링 제한적
#### Option B: Python 스크립트 (권장 ⭐)
```bash
python3 /srv/pharmq-gateway/scripts/register_pharmacy.py \
--name "행복약국" \
--owner "홍길동" \
--phone "010-1234-5678" \
--vpn-ip "100.64.0.15"
```
**장점:**
- ✅ 복잡한 로직 구현 가능
- ✅ 에러 핸들링 우수
- ✅ 검증 로직 추가 가능
- ✅ 비밀번호 해싱 자동
**단점:**
- ❌ Python 의존성 필요 (대부분 시스템에 기본 설치됨)
### 2. 스크립트 구조
```bash
# headscale-quick-install.sh 개선안
main() {
# 기존 코드 (1-6단계)
detect_os
check_requirements
install_tailscale
start_tailscale
register_headscale
verify_connection
# ✨ 신규 추가 (7-10단계)
collect_pharmacy_info # 약국 정보 입력 받기
register_to_farmq_db # farmq.db 등록
create_gateway_user # gateway.db 사용자 생성
show_login_info # 로그인 정보 출력
# 기존 마무리
cleanup
show_final_info
}
```
---
## 📋 상세 구현 계획
### Step 7: 약국 정보 수집 (대화형)
```bash
collect_pharmacy_info() {
print_header "약국 정보 입력"
# VPN IP 자동 획득
TAILSCALE_IP=$(tailscale ip -4 2>/dev/null)
print_info "할당된 VPN IP: $TAILSCALE_IP"
# 다음 약국 코드 자동 생성
NEXT_CODE=$(get_next_pharmacy_code)
print_info "새 약국 코드: $NEXT_CODE"
# 사용자 입력
read -p "약국 이름을 입력하세요: " PHARMACY_NAME
read -p "대표약사 이름을 입력하세요: " OWNER_NAME
read -p "대표약사 전화번호 (010-XXXX-XXXX): " OWNER_PHONE
read -p "이메일 (선택, Enter로 건너뛰기): " OWNER_EMAIL
# 기본값 설정
if [ -z "$OWNER_EMAIL" ]; then
OWNER_EMAIL="${NEXT_CODE,,}@pharmq.kr" # 소문자로 변환
fi
# 확인
print_info "입력 정보 확인:"
echo " 약국 코드: $NEXT_CODE"
echo " 약국 이름: $PHARMACY_NAME"
echo " 대표약사: $OWNER_NAME"
echo " 전화번호: $OWNER_PHONE"
echo " 이메일: $OWNER_EMAIL"
echo " VPN IP: $TAILSCALE_IP"
read -p "위 정보로 등록하시겠습니까? (Y/n): " CONFIRM
if [[ ! $CONFIRM =~ ^[Yy]$ ]] && [ -n "$CONFIRM" ]; then
print_error "등록이 취소되었습니다."
exit 1
fi
}
```
### Step 8: farmq.db 등록
#### 8-1. Python 헬퍼 스크립트 생성
**파일:** `/srv/pharmq-gateway/scripts/register_pharmacy.py`
```python
#!/usr/bin/env python3
"""
약국 자동 등록 스크립트
Headscale 설치 후 farmq.db와 gateway.db에 약국 정보 자동 생성
"""
import sqlite3
import sys
import argparse
from datetime import datetime
import secrets
import string
from passlib.hash import pbkdf2_sha256
def generate_password(length=8):
"""랜덤 비밀번호 생성"""
alphabet = string.ascii_letters + string.digits
return ''.join(secrets.choice(alphabet) for _ in range(length))
def get_next_pharmacy_code(farmq_db_path):
"""다음 약국 코드 생성 (P0001, P0002, ...)"""
conn = sqlite3.connect(farmq_db_path)
cursor = conn.cursor()
cursor.execute("""
SELECT pharmacy_code FROM pharmacies
WHERE pharmacy_code LIKE 'P%'
ORDER BY pharmacy_code DESC
LIMIT 1
""")
result = cursor.fetchone()
conn.close()
if result:
# P0002 → 2 → 3 → P0003
last_num = int(result[0][1:])
next_num = last_num + 1
else:
next_num = 1
return f"P{next_num:04d}"
def register_pharmacy(args):
"""farmq.db에 약국 등록"""
print("=" * 60)
print("📋 farmq.db 약국 등록")
print("=" * 60)
# pharmacy_code 생성
if not args.code:
args.code = get_next_pharmacy_code(args.farmq_db)
print(f"\n약국 코드: {args.code}")
print(f"약국 이름: {args.name}")
print(f"VPN IP: {args.vpn_ip}")
conn = sqlite3.connect(args.farmq_db)
cursor = conn.cursor()
try:
cursor.execute("""
INSERT INTO pharmacies (
pharmacy_code,
pharmacy_name,
tailscale_ip,
api_port,
status,
owner_name,
owner_phone,
owner_email,
headscale_user_name,
created_at,
updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
args.code,
args.name,
args.vpn_ip,
8082, # 기본 API 포트
'active',
args.owner,
args.phone,
args.email or f"{args.code.lower()}@pharmq.kr",
'default', # Headscale user
datetime.now().isoformat(),
datetime.now().isoformat()
))
conn.commit()
print(f"\n✅ farmq.db 등록 완료 (pharmacy_code: {args.code})")
except sqlite3.IntegrityError as e:
print(f"\n❌ 오류: {e}")
print(f"약국 코드 '{args.code}'가 이미 존재합니다.")
sys.exit(1)
finally:
conn.close()
return args.code
def create_gateway_user(args, pharmacy_code):
"""gateway.db에 사용자 생성"""
print("\n" + "=" * 60)
print("🔐 gateway.db 사용자 생성")
print("=" * 60)
# username 생성
username = f"{pharmacy_code.lower()}_admin"
# 랜덤 비밀번호 생성
password = generate_password(8)
password_hash = pbkdf2_sha256.hash(password)
print(f"\nUsername: {username}")
print(f"Password: {password}")
conn = sqlite3.connect(args.gateway_db)
cursor = conn.cursor()
try:
# users 테이블에 삽입
cursor.execute("""
INSERT INTO users (
username,
email,
password_hash,
name,
phone,
primary_pharmacy_code,
role,
status,
failed_login_attempts,
created_at,
updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
username,
args.email or f"{pharmacy_code.lower()}@pharmq.kr",
password_hash,
args.owner,
args.phone,
pharmacy_code,
'admin',
'active',
0,
datetime.now().isoformat(),
datetime.now().isoformat()
))
user_id = cursor.lastrowid
# pharmacy_members 테이블에 삽입
cursor.execute("""
INSERT INTO pharmacy_members (
pharmacy_code,
user_id,
position,
access_level,
can_view_sales,
can_view_inventory,
can_manage_staff,
is_active,
created_at,
updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
pharmacy_code,
user_id,
'대표약사',
'owner',
True,
True,
True,
True,
datetime.now().isoformat(),
datetime.now().isoformat()
))
conn.commit()
print(f"\n✅ gateway.db 사용자 생성 완료 (user_id: {user_id})")
except sqlite3.IntegrityError as e:
print(f"\n❌ 오류: {e}")
sys.exit(1)
finally:
conn.close()
return username, password
def main():
parser = argparse.ArgumentParser(description='약국 자동 등록')
parser.add_argument('--name', required=True, help='약국 이름')
parser.add_argument('--owner', required=True, help='대표약사 이름')
parser.add_argument('--phone', required=True, help='전화번호')
parser.add_argument('--vpn-ip', required=True, help='VPN IP 주소')
parser.add_argument('--email', help='이메일 (선택)')
parser.add_argument('--code', help='약국 코드 (자동 생성)')
parser.add_argument('--farmq-db',
default='/srv/headscale-tailscale-replacement/farmq-admin/farmq.db',
help='farmq.db 경로')
parser.add_argument('--gateway-db',
default='/srv/pharmq-gateway/gateway.db',
help='gateway.db 경로')
args = parser.parse_args()
# 1. farmq.db 등록
pharmacy_code = register_pharmacy(args)
# 2. gateway.db 사용자 생성
username, password = create_gateway_user(args, pharmacy_code)
# 3. 최종 정보 출력
print("\n" + "=" * 60)
print("✅ 등록 완료!")
print("=" * 60)
print(f"\n📋 약국 정보:")
print(f" 약국 코드: {pharmacy_code}")
print(f" 약국 이름: {args.name}")
print(f" VPN IP: {args.vpn_ip}")
print(f" API 포트: 8082")
print(f"\n🔑 로그인 정보:")
print(f" Username: {username}")
print(f" Password: {password}")
print(f" 이메일: {args.email or f'{pharmacy_code.lower()}@pharmq.kr'}")
print(f"\n🌐 프론트엔드 접속:")
print(f" https://pharmq.kr")
print(f" 또는")
print(f" https://dev.pharmq.kr")
print("\n⚠️ 비밀번호를 안전한 곳에 보관하세요!")
print("=" * 60)
if __name__ == '__main__':
main()
```
#### 8-2. Bash 스크립트에서 호출
```bash
register_to_farmq_and_gateway() {
print_status "약국 정보 등록 중..."
# Python 스크립트 실행
REGISTER_OUTPUT=$(python3 /srv/pharmq-gateway/scripts/register_pharmacy.py \
--name "$PHARMACY_NAME" \
--owner "$OWNER_NAME" \
--phone "$OWNER_PHONE" \
--email "$OWNER_EMAIL" \
--vpn-ip "$TAILSCALE_IP" 2>&1)
if [ $? -eq 0 ]; then
print_success "등록 완료!"
echo "$REGISTER_OUTPUT"
# 로그인 정보 추출
USERNAME=$(echo "$REGISTER_OUTPUT" | grep "Username:" | awk '{print $2}')
PASSWORD=$(echo "$REGISTER_OUTPUT" | grep "Password:" | awk '{print $2}')
PHARMACY_CODE=$(echo "$REGISTER_OUTPUT" | grep "약국 코드:" | awk '{print $3}')
# 환경 변수 저장
export PHARMACY_CODE
export USERNAME
export PASSWORD
else
print_error "등록 실패!"
echo "$REGISTER_OUTPUT"
exit 1
fi
}
```
---
## 🎨 최종 사용자 경험
### 실행 예시
```bash
# 1. 스크립트 실행
curl -fsSL https://git.0bin.in/thug0bin/pve9-repo-fix/raw/branch/main/headscale-quick-install.sh | bash
# 2. Headscale 등록 자동 진행...
# 3. VPN IP 할당: 100.64.0.15
# 4. 약국 정보 입력
============================================
약국 정보 입력
============================================
할당된 VPN IP: 100.64.0.15
새 약국 코드: P0004
약국 이름을 입력하세요: 행복약국
대표약사 이름을 입력하세요: 홍길동
대표약사 전화번호 (010-XXXX-XXXX): 010-1234-5678
이메일 (선택, Enter로 건너뛰기):
입력 정보 확인:
약국 코드: P0004
약국 이름: 행복약국
대표약사: 홍길동
전화번호: 010-1234-5678
이메일: p0004@pharmq.kr
VPN IP: 100.64.0.15
위 정보로 등록하시겠습니까? (Y/n): Y
# 5. 자동 등록 진행...
============================================
설치 완료!
============================================
✅ Headscale VPN 연결 완료
✅ farmq.db 약국 등록 완료
✅ gateway.db 사용자 생성 완료
📋 약국 정보:
약국 코드: P0004
약국 이름: 행복약국
VPN IP: 100.64.0.15
API 포트: 8082
🔑 로그인 정보:
Username: p0004_admin
Password: aB3xK9mP
이메일: p0004@pharmq.kr
🌐 프론트엔드 접속:
https://pharmq.kr
또는
https://dev.pharmq.kr
⚠️ 비밀번호를 안전한 곳에 보관하세요!
============================================
```
### 프론트엔드 로그인
```
1. https://pharmq.kr 접속
2. Username: p0004_admin
3. Password: aB3xK9mP
4. 로그인 → 즉시 사용 가능! ✅
```
---
## ⚙️ 구현 우선순위
### Phase 1: Python 스크립트 생성 ⭐ (최우선)
```bash
/srv/pharmq-gateway/scripts/register_pharmacy.py
```
- farmq.db INSERT
- gateway.db users, pharmacy_members INSERT
- 비밀번호 해싱
- 에러 핸들링
### Phase 2: Bash 스크립트 통합 ⭐
```bash
headscale-quick-install.sh 수정
```
- collect_pharmacy_info() 함수 추가
- register_to_farmq_and_gateway() 함수 추가
- show_login_info() 함수 추가
### Phase 3: 테스트 ⭐
```bash
1. 새 VM에서 스크립트 실행
2. DB 확인
3. 프론트엔드 로그인 테스트
4. API 통신 테스트
```
### Phase 4: 문서화
```markdown
- README 업데이트
- 스크립트 주석 추가
- 트러블슈팅 가이드
```
---
## 🛡️ 보안 고려사항
### 1. 비밀번호 생성
- ✅ 8자리 이상
- ✅ 영문 대소문자 + 숫자 조합
-`secrets` 모듈 사용 (암호학적으로 안전)
### 2. 비밀번호 해싱
- ✅ pbkdf2_sha256 사용
- ✅ Salt 자동 생성
- ✅ Rainbow table 공격 방지
### 3. DB 접근 권한
- ✅ root 권한으로 스크립트 실행 필요
- ✅ DB 파일 권한 확인
- ✅ SQLite injection 방지 (parameterized query)
### 4. 로그인 정보 노출
- ⚠️ 스크립트 실행 화면에 비밀번호 표시됨
- ✅ 일회용 비밀번호 생성
- ✅ 초기 로그인 후 비밀번호 변경 권장
---
## 📊 데이터 흐름 다이어그램
```
┌─────────────────────────────────────────────────────────────┐
│ 사용자 (터미널) │
└─────────────────┬───────────────────────────────────────────┘
│ 1. 스크립트 실행
┌─────────────────────────────────────────────────────────────┐
│ headscale-quick-install.sh │
├─────────────────────────────────────────────────────────────┤
│ 1. OS 감지 │
│ 2. Tailscale 설치 │
│ 3. Headscale 등록 │
│ 4. VPN IP 획득: 100.64.0.15 │
│ 5. 약국 정보 입력 받기 ────────────┐ │
│ │ │
│ 6. Python 스크립트 호출 ───────────┼──────────┐ │
└─────────────────────────────────────┼──────────┼────────────┘
│ │
┌───────────────────┘ │
│ │
↓ ↓
┌─────────────────────────────────┐ ┌─────────────────────────┐
│ register_pharmacy.py │ │ register_pharmacy.py │
├─────────────────────────────────┤ ├─────────────────────────┤
│ 1. 다음 코드 생성: P0004 │ │ 1. username 생성 │
│ 2. farmq.db INSERT │ │ p0004_admin │
│ - pharmacy_code: P0004 │ │ 2. password 생성 │
│ - pharmacy_name: 행복약국 │ │ aB3xK9mP │
│ - tailscale_ip: 100.64.0.15 │ │ 3. password 해싱 │
│ - api_port: 8082 │ │ 4. gateway.db INSERT │
│ - status: active │ │ - users 테이블 │
│ 3. 성공 반환 │ │ - pharmacy_members │
└─────────────────┬───────────────┘ └──────────┬──────────────┘
│ │
│ │
└──────────┬──────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 최종 정보 출력 │
├─────────────────────────────────────────────────────────────┤
│ 약국 코드: P0004 │
│ 약국 이름: 행복약국 │
│ VPN IP: 100.64.0.15 │
│ Username: p0004_admin │
│ Password: aB3xK9mP │
│ 프론트엔드: https://pharmq.kr │
└─────────────────────────────────────────────────────────────┘
│ 사용자가 로그인 정보 복사
┌─────────────────────────────────────────────────────────────┐
│ React 프론트엔드 (https://pharmq.kr) │
├─────────────────────────────────────────────────────────────┤
│ 1. 로그인 페이지 │
│ 2. Username: p0004_admin 입력 │
│ 3. Password: aB3xK9mP 입력 │
│ 4. 로그인 버튼 클릭 │
└─────────────────┬───────────────────────────────────────────┘
│ POST /api/auth/login
┌─────────────────────────────────────────────────────────────┐
│ Gateway API (gateway.pharmq.kr:8000) │
├─────────────────────────────────────────────────────────────┤
│ 1. gateway.db users 조회 │
│ 2. 비밀번호 검증 (pbkdf2_sha256) │
│ 3. JWT 토큰 생성 │
│ payload: { │
│ username: "p0004_admin", │
│ pharmacy_code: "P0004", │
│ role: "admin" │
│ } │
│ 4. 토큰 반환 │
└─────────────────┬───────────────────────────────────────────┘
│ JWT Token
┌─────────────────────────────────────────────────────────────┐
│ React 프론트엔드 │
├─────────────────────────────────────────────────────────────┤
│ ✅ 로그인 성공! │
│ ✅ 대시보드 페이지 진입 │
│ ✅ API 요청 가능 (JWT 토큰 사용) │
└─────────────────────────────────────────────────────────────┘
```
---
## 🎯 성공 기준
### 기능적 요구사항
- ✅ 스크립트 실행 후 5분 이내 완료
- ✅ farmq.db에 약국 정보 자동 생성
- ✅ gateway.db에 사용자 계정 자동 생성
- ✅ 프론트엔드에서 즉시 로그인 가능
- ✅ API 통신 정상 작동
### 비기능적 요구사항
- ✅ 에러 발생 시 롤백
- ✅ 중복 등록 방지
- ✅ 입력 검증 (전화번호 형식 등)
- ✅ 로그 파일 생성 (추후)
---
## 📚 참고 자료
### 관련 문서
- [GATEWAY_ARCHITECTURE_EXPLAINED.md](GATEWAY_ARCHITECTURE_EXPLAINED.md)
- [DATABASE_MAPPING_EXPLAINED.md](DATABASE_MAPPING_EXPLAINED.md)
### 필요한 Python 라이브러리
```bash
# 대부분 기본 설치되어 있음
python3 -c "import sqlite3, secrets, string, argparse" # 기본 라이브러리
# passlib만 추가 설치 필요
pip3 install passlib
```
### DB 경로
```bash
farmq.db: /srv/headscale-tailscale-replacement/farmq-admin/farmq.db
gateway.db: /srv/pharmq-gateway/gateway.db
```
---
## ✅ 체크리스트
### 개발
- [ ] register_pharmacy.py 스크립트 작성
- [ ] headscale-quick-install.sh 수정
- [ ] 입력 검증 로직 추가
- [ ] 에러 핸들링 추가
### 테스트
- [ ] 로컬 테스트 (farmq.db, gateway.db)
- [ ] 새 VM에서 전체 플로우 테스트
- [ ] 프론트엔드 로그인 테스트
- [ ] API 통신 테스트
### 문서화
- [ ] README 업데이트
- [ ] 주석 추가
- [ ] 트러블슈팅 가이드 작성
### 배포
- [ ] Gitea에 커밋
- [ ] 기존 스크립트 백업
- [ ] 프로덕션 배포
---
**작성일:** 2025년 11월 14일
**작성자:** Claude Code
**버전:** Headscale Auto-Register Plan v1.0

160
RDP/RDP_TOGGLE_API.md Normal file
View File

@@ -0,0 +1,160 @@
# RDP Toggle API Documentation
## 개요
Proxmox VE 호스트에서 RDP/Shell 모드를 API로 전환할 수 있는 시스템입니다.
## 구성 요소
### 1. **rdp-toggle-api.py**
- FastAPI 기반 REST API 서버
- 포트: 8080
- RDP/Shell 모드 전환 제어
### 2. **rdp-toggle-web.html**
- 웹 기반 컨트롤 패널
- 실시간 상태 모니터링
- 설정 변경 인터페이스
### 3. **install-rdp-api.sh**
- 자동 설치 스크립트
- systemd 서비스 설정
## API 엔드포인트
### GET /status
현재 상태 확인
```bash
curl http://localhost:8080/status
```
### POST /toggle
모드 전환 (rdp/shell)
```bash
# RDP 모드로 전환
curl -X POST http://localhost:8080/toggle \
-H 'Content-Type: application/json' \
-d '{"mode":"rdp"}'
# Shell 모드로 전환
curl -X POST http://localhost:8080/toggle \
-H 'Content-Type: application/json' \
-d '{"mode":"shell"}'
```
### GET /config
현재 설정 확인
```bash
curl http://localhost:8080/config
```
### PUT /config
설정 업데이트
```bash
curl -X PUT http://localhost:8080/config \
-H 'Content-Type: application/json' \
-d '{
"rdp_server": "192.168.0.229",
"rdp_username": "0bin",
"rdp_password": "trajet6640"
}'
```
## 테스트 환경 설정
### RDP 서버 정보
- **서버 주소**: 192.168.0.229
- **사용자명**: 0bin
- **비밀번호**: trajet6640
- **로컬 사용자**: rdpuser
## 설치 방법
```bash
# 1. 설치 스크립트 실행
chmod +x install-rdp-api.sh
./install-rdp-api.sh
# 2. 서비스 상태 확인
systemctl status rdp-toggle-api
# 3. 웹 인터페이스 접속
# 브라우저에서 http://[PROXMOX_IP]:8080 접속
```
## 사용 시나리오
### 1. 초기 설정
```bash
# RDP 설정 구성
curl -X PUT http://localhost:8080/config \
-H 'Content-Type: application/json' \
-d '{
"rdp_server": "192.168.0.229",
"rdp_username": "0bin",
"rdp_password": "trajet6640"
}'
```
### 2. RDP 모드 활성화
```bash
curl -X POST http://localhost:8080/toggle \
-H 'Content-Type: application/json' \
-d '{"mode":"rdp"}'
```
### 3. Shell 모드로 복귀
```bash
curl -X POST http://localhost:8080/toggle \
-H 'Content-Type: application/json' \
-d '{"mode":"shell"}'
```
## 동작 원리
### RDP 모드 활성화 시
1. getty@tty1 자동 로그인 설정
2. X Window System 자동 시작
3. FreeRDP3 전체화면 실행
4. RDP 연결 자동 수립
### Shell 모드 활성화 시
1. RDP 프로세스 종료
2. X Window 종료
3. 자동 로그인 해제
4. 일반 TTY 로그인 화면 복원
## 상태 파일
- 상태 저장: `/var/lib/rdp-toggle/state.json`
- 설정 저장: `/var/lib/rdp-toggle/config.json`
## 문제 해결
### API 서버가 시작되지 않을 때
```bash
# 로그 확인
journalctl -u rdp-toggle-api -f
# Python 패키지 재설치
pip3 install --upgrade fastapi uvicorn
```
### RDP 연결이 실패할 때
```bash
# 현재 상태 확인
curl http://localhost:8080/status
# RDP 프로세스 확인
ps aux | grep xfreerdp3
```
### Shell 모드로 전환이 안 될 때
```bash
# 수동으로 RDP 종료
pkill -u rdpuser
systemctl restart getty@tty1
```
## 보안 고려사항
- API는 기본적으로 모든 IP에서 접근 가능 (0.0.0.0:8080)
- 프로덕션 환경에서는 방화벽 설정 권장
- 비밀번호는 평문으로 저장됨 (향후 암호화 필요)

304
RDP/README.md Normal file
View File

@@ -0,0 +1,304 @@
# Proxmox RDP 자동화 시스템
Proxmox VE 호스트에서 **RDP 초기 설정** 및 **RDP/Shell 모드 API 전환**을 지원하는 통합 솔루션
## 개요
이 시스템은 두 가지 주요 기능을 제공합니다:
1. **Proxmox RDP 초기 설정** - 부팅 시 자동으로 원격 Windows PC에 RDP 연결
2. **RDP Toggle API** - 외부에서 API 호출로 RDP/Shell 모드 실시간 전환
프론트엔드 또는 curl 명령으로 Proxmox 물리 화면을 Shell ↔ RDP 모드로 즉시 전환 가능합니다.
---
## 🚀 빠른 설치
### 1⃣ Proxmox RDP 초기 설정 (자동 부팅 연결)
Proxmox 호스트가 부팅 시 자동으로 RDP 연결하도록 설정:
```bash
curl -fsSL https://git.0bin.in/thug0bin/pve9-repo-fix/raw/branch/main/RDP/proxmox-auto-rdp-setup.sh | bash
```
**설치 내용:**
- ✅ X Window + Openbox 윈도우 매니저
- ✅ FreeRDP3 클라이언트 설치
- ✅ 자동 로그인 및 X 시작 설정
- ✅ RDP 서버 연결 정보 구성
- ✅ 풀스크린 RDP 자동 실행
### 2⃣ RDP Toggle API 설치 (원격 제어)
API를 통해 RDP/Shell 모드를 원격으로 전환:
```bash
curl -fsSL https://git.0bin.in/thug0bin/pve9-repo-fix/raw/branch/main/RDP/install-rdp-api.sh | bash
```
**설치 내용:**
- ✅ FastAPI 기반 REST API 서버
- ✅ Python venv 환경 구성
- ✅ systemd 서비스 자동 시작
- ✅ 포트 8090에서 API 실행
---
## 📖 사용 방법
### RDP Toggle API 사용
#### 서비스 확인
```bash
systemctl status rdp-toggle-api
```
#### API 테스트
```bash
# 현재 상태 확인
curl http://localhost:8090/status
# RDP 모드로 전환
curl -X POST http://localhost:8090/toggle \
-H 'Content-Type: application/json' \
-d '{"mode":"rdp"}'
# Shell 모드로 전환
curl -X POST http://localhost:8090/toggle \
-H 'Content-Type: application/json' \
-d '{"mode":"shell"}'
```
---
## 📡 API 엔드포인트
### GET /status
현재 RDP/Shell 모드 상태 확인
```bash
curl http://localhost:8090/status
```
**응답 예시:**
```json
{
"current_mode": "shell",
"rdp_active": false,
"last_changed": "2025-11-17T10:30:00",
"config": {
"rdp_server": "192.168.0.229",
"rdp_username": "user",
"local_user": "rdpuser"
}
}
```
### POST /toggle
RDP/Shell 모드 전환
```bash
# RDP 모드 활성화
curl -X POST http://localhost:8090/toggle \
-H 'Content-Type: application/json' \
-d '{"mode":"rdp"}'
# Shell 모드로 전환
curl -X POST http://localhost:8090/toggle \
-H 'Content-Type: application/json' \
-d '{"mode":"shell"}'
```
### GET /config
현재 RDP 연결 설정 조회
```bash
curl http://localhost:8090/config
```
### PUT /config
RDP 연결 설정 업데이트
```bash
curl -X PUT http://localhost:8090/config \
-H 'Content-Type: application/json' \
-d '{
"rdp_server": "new-server.example.com:3389",
"rdp_username": "newuser",
"rdp_password": "newpassword"
}'
```
---
## 🔗 프론트엔드 연동
### React 예시
```jsx
import { useState, useEffect } from 'react';
const RDPToggle = () => {
const [status, setStatus] = useState(null);
const API_URL = 'http://your-proxmox-ip:8090';
// 상태 확인
const fetchStatus = async () => {
const res = await fetch(`${API_URL}/status`);
const data = await res.json();
setStatus(data);
};
// 모드 전환
const toggleMode = async (mode) => {
await fetch(`${API_URL}/toggle`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode })
});
fetchStatus(); // 상태 갱신
};
useEffect(() => {
fetchStatus();
}, []);
return (
<div>
<p>현재 모드: {status?.current_mode}</p>
<button onClick={() => toggleMode('rdp')}>RDP 모드</button>
<button onClick={() => toggleMode('shell')}>Shell 모드</button>
</div>
);
};
```
### 웹 기반 컨트롤 패널
간단한 HTML 기반 컨트롤 패널도 제공됩니다:
```bash
curl -fsSL https://git.0bin.in/thug0bin/pve9-repo-fix/raw/branch/main/RDP/rdp-toggle-web.html -o rdp-control.html
```
브라우저에서 `rdp-control.html` 파일을 열어 사용하세요.
---
## 📂 구성 파일
### 설치 위치
- **API 서버**: `/opt/rdp-toggle-api/`
- **Python 가상환경**: `/opt/rdp-toggle-api/venv/`
- **systemd 서비스**: `/etc/systemd/system/rdp-toggle-api.service`
### 구성 파일 목록
- `rdp-toggle-api.py` - FastAPI 기반 REST API 서버
- `install-rdp-api.sh` - 자동 설치 스크립트 (curl 실행 가능)
- `proxmox-auto-rdp-setup.sh` - Proxmox RDP 초기 설정 스크립트
- `rdp-toggle-web.html` - 웹 기반 컨트롤 패널
- `requirements.txt` - Python 패키지 의존성
---
## ✨ 주요 기능
-**RDP ↔ Shell 모드 즉시 전환**
-**실시간 상태 모니터링** (프로세스 확인)
-**CORS 지원** (외부 프론트엔드 접근 가능)
-**Python venv 환경** (시스템 패키지 충돌 방지)
-**systemd 서비스** (자동 시작, 재시작)
-**설정 동적 변경** (API로 RDP 서버 정보 변경)
-**curl 원라이너 설치** (웹에서 바로 복사해서 실행)
---
## 🔧 서비스 관리
```bash
# 서비스 상태 확인
systemctl status rdp-toggle-api
# 서비스 재시작
systemctl restart rdp-toggle-api
# 서비스 중지
systemctl stop rdp-toggle-api
# 서비스 시작
systemctl start rdp-toggle-api
# 로그 확인
journalctl -u rdp-toggle-api -f
```
---
## 🌐 네트워크 설정
- **기본 포트**: `8090`
- **바인드 주소**: `0.0.0.0` (모든 인터페이스)
- **CORS**: 모든 origin 허용 (`allow_origins=["*"]`)
외부에서 접근하려면 방화벽에서 포트 8090을 허용하세요:
```bash
# UFW 사용 시
ufw allow 8090/tcp
# iptables 사용 시
iptables -A INPUT -p tcp --dport 8090 -j ACCEPT
```
---
## 📚 참고 문서
- [RDP_TOGGLE_API.md](RDP_TOGGLE_API.md) - API 상세 문서
- [proxmox_auto_rdp_setup_korean.md](proxmox_auto_rdp_setup_korean.md) - RDP 초기 설정 가이드
---
## 🐛 문제 해결
### API 서버가 시작되지 않는 경우
```bash
# 로그 확인
journalctl -u rdp-toggle-api -n 50
# Python 가상환경 재설치
rm -rf /opt/rdp-toggle-api/venv
python3 -m venv /opt/rdp-toggle-api/venv
/opt/rdp-toggle-api/venv/bin/pip install fastapi uvicorn python-multipart pydantic
systemctl restart rdp-toggle-api
```
### RDP 모드 전환이 작동하지 않는 경우
```bash
# RDP 초기 설정이 완료되었는지 확인
ls -la /home/rdpuser/.xinitrc
cat /etc/systemd/system/getty@tty1.service.d/override.conf
# FreeRDP3 설치 확인
which xfreerdp3
```
### 포트 8090이 이미 사용 중인 경우
```bash
# 포트 사용 확인
ss -tulpn | grep 8090
# API 서비스 파일에서 포트 변경
nano /opt/rdp-toggle-api/rdp-toggle-api.py
# 마지막 줄: uvicorn.run(app, host="0.0.0.0", port=8090)
# 포트 번호를 원하는 값으로 변경 후 저장
systemctl restart rdp-toggle-api
```
---
## 📄 라이선스
MIT License
---
**작성자**: thug0bin
**리포지토리**: https://git.0bin.in/thug0bin/pve9-repo-fix

188
RDP/install-rdp-api.sh Executable file
View File

@@ -0,0 +1,188 @@
#!/bin/bash
# RDP Toggle API 설치 스크립트
# curl -fsSL https://git.0bin.in/thug0bin/pve9-repo-fix/raw/branch/main/RDP/install-rdp-api.sh | bash
set -e
# IP 주소 가져오기 (Headscale 우선, 없으면 로컬 IP)
get_primary_ip() {
# Headscale VPN IP 확인 (100.64.x.x 대역)
local headscale_ip=$(hostname -I | tr ' ' '\n' | grep '^100\.64\.' | head -n1)
if [ -n "$headscale_ip" ]; then
echo "$headscale_ip"
return 0
fi
# Headscale IP가 없으면 로컬 IP (첫 번째)
local local_ip=$(hostname -I | awk '{print $1}')
if [ -n "$local_ip" ]; then
echo "$local_ip"
return 0
fi
# 그것도 없으면 localhost
echo "127.0.0.1"
}
# 테스트 메뉴 함수
show_test_menu() {
echo ""
echo "=========================================="
echo "RDP API를 테스트하시겠습니까?"
echo " 1) RDP 모드로 전환 테스트"
echo " 2) Shell 모드로 전환 테스트"
echo " 3) 현재 상태 확인"
echo " 4) 종료"
echo ""
echo -n "선택 [1/2/3/4]: "
read -r test_choice </dev/tty
case "$test_choice" in
1)
echo ""
echo "RDP 모드로 전환 중..."
if curl -s -X POST http://localhost:8090/toggle \
-H 'Content-Type: application/json' \
-d '{"mode":"rdp"}' > /dev/null 2>&1; then
echo "✅ RDP 모드로 전환 완료!"
echo ""
echo "현재 상태:"
curl -s http://localhost:8090/status | python3 -m json.tool 2>/dev/null || curl -s http://localhost:8090/status
else
echo "❌ RDP 모드 전환 실패. 서비스 상태를 확인하세요:"
echo " systemctl status rdp-toggle-api"
fi
;;
2)
echo ""
echo "Shell 모드로 전환 중..."
if curl -s -X POST http://localhost:8090/toggle \
-H 'Content-Type: application/json' \
-d '{"mode":"shell"}' > /dev/null 2>&1; then
echo "✅ Shell 모드로 전환 완료!"
echo ""
echo "현재 상태:"
curl -s http://localhost:8090/status | python3 -m json.tool 2>/dev/null || curl -s http://localhost:8090/status
else
echo "❌ Shell 모드 전환 실패. 서비스 상태를 확인하세요:"
echo " systemctl status rdp-toggle-api"
fi
;;
3)
echo ""
echo "현재 상태 확인 중..."
curl -s http://localhost:8090/status | python3 -m json.tool 2>/dev/null || curl -s http://localhost:8090/status
;;
4)
echo ""
echo "종료합니다."
return 0
;;
*)
echo ""
echo "잘못된 선택입니다."
;;
esac
echo ""
}
# 설치 디렉토리 설정
INSTALL_DIR="/opt/rdp-toggle-api"
VENV_DIR="$INSTALL_DIR/venv"
GITEA_BASE_URL="https://git.0bin.in/thug0bin/pve9-repo-fix/raw/branch/main/RDP"
# 이미 설치되어 있는지 확인
if systemctl is-active --quiet rdp-toggle-api.service && [ -f "$INSTALL_DIR/rdp-toggle-api.py" ]; then
echo ""
echo "=========================================="
echo "✅ RDP Toggle API가 이미 설치되어 실행 중입니다!"
echo "=========================================="
echo ""
echo "📍 API 서버: http://$(get_primary_ip):8090"
echo "📁 설치 위치: $INSTALL_DIR"
echo ""
# 바로 테스트 메뉴 보여주기
show_test_menu
echo ""
echo "=========================================="
echo "완료!"
echo "=========================================="
echo ""
exit 0
fi
echo "RDP Toggle API 설치 시작..."
# Python 및 venv 설치
echo "Python 및 필수 패키지 설치 중..."
apt update
apt install -y python3 python3-venv python3-pip curl
# 설치 디렉토리 생성
mkdir -p "$INSTALL_DIR"
# 가상환경 생성
echo "가상환경 생성 중..."
python3 -m venv "$VENV_DIR"
# 가상환경에서 패키지 설치
echo "Python 패키지 설치 중..."
"$VENV_DIR/bin/pip" install --upgrade pip
"$VENV_DIR/bin/pip" install fastapi==0.115.5 uvicorn==0.32.1 python-multipart==0.0.20 pydantic==2.10.3
# API 파일 다운로드
echo "API 서버 파일 다운로드 중..."
curl -fsSL "$GITEA_BASE_URL/rdp-toggle-api.py" -o "$INSTALL_DIR/rdp-toggle-api.py"
chmod +x "$INSTALL_DIR/rdp-toggle-api.py"
# systemd 서비스 생성
echo "systemd 서비스 생성 중..."
cat > /etc/systemd/system/rdp-toggle-api.service << EOF
[Unit]
Description=RDP Toggle API Service
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=$INSTALL_DIR
ExecStart=$VENV_DIR/bin/python $INSTALL_DIR/rdp-toggle-api.py
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
EOF
# 서비스 활성화 및 시작
echo "서비스 활성화 및 시작 중..."
systemctl daemon-reload
systemctl enable rdp-toggle-api.service
systemctl start rdp-toggle-api.service
# 잠시 대기 후 상태 확인
sleep 2
echo ""
echo "=========================================="
echo "RDP Toggle API 설치 완료!"
echo "=========================================="
echo ""
echo "📍 API 서버: http://$(get_primary_ip):8090"
echo "📁 설치 위치: $INSTALL_DIR"
echo ""
# 테스트 메뉴 보여주기
show_test_menu
echo ""
echo "=========================================="
echo "설치 및 설정이 완료되었습니다!"
echo "=========================================="
echo ""

919
RDP/proxmox-auto-rdp-setup.sh Executable file
View File

@@ -0,0 +1,919 @@
#!/bin/bash
# Proxmox Auto RDP Setup Script
# 자동으로 Proxmox 호스트를 RDP VM에 연결하는 설정을 수행합니다
# 사용법: bash -c "$(curl -fsSL [스크립트 URL])"
set -euo pipefail
# 색상 코드 정의
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
PURPLE='\033[0;35m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color
# 로그 함수들
msg_info() {
echo -e "${BLUE}[INFO]${NC} $1"
}
msg_ok() {
echo -e "${GREEN}[OK]${NC} $1"
}
msg_error() {
echo -e "${RED}[ERROR]${NC} $1"
exit 1
}
msg_warn() {
echo -e "${YELLOW}[WARN]${NC} $1"
}
# 헤더 출력
print_header() {
clear
echo -e "${PURPLE}"
echo "═══════════════════════════════════════════════════════════════════"
echo " Proxmox Auto RDP Setup Script v1.0"
echo "═══════════════════════════════════════════════════════════════════"
echo -e "${NC}"
echo "이 스크립트는 Proxmox VE 호스트가 부팅 시 자동으로 RDP 연결하도록 설정합니다."
echo ""
}
# Proxmox 버전 확인
check_proxmox_version() {
msg_info "Proxmox VE 버전 확인 중..."
# pveversion 명령어 확인
if ! command -v pveversion > /dev/null 2>&1; then
msg_warn "pveversion 명령을 찾을 수 없습니다. 버전 확인을 건너뜁니다."
return 0
fi
# 버전 추출 (여러 형식 지원)
local pve_version=$(pveversion 2>/dev/null | head -n1 | grep -oP '\d+\.\d+' | head -n1 | cut -d'.' -f1)
# 버전 번호를 추출할 수 없으면 경고만 하고 계속 진행
if [ -z "$pve_version" ]; then
msg_warn "Proxmox VE 버전을 확인할 수 없습니다. 계속 진행합니다."
return 0
fi
# 숫자인지 확인
if ! [[ "$pve_version" =~ ^[0-9]+$ ]]; then
msg_warn "Proxmox VE 버전 형식이 올바르지 않습니다 ($pve_version). 계속 진행합니다."
return 0
fi
msg_ok "Proxmox VE $pve_version.x 버전 확인됨"
}
# IP 주소 가져오기 (Headscale 우선, 없으면 로컬 IP)
get_primary_ip() {
# Headscale VPN IP 확인 (100.64.x.x 대역)
local headscale_ip=$(hostname -I | tr ' ' '\n' | grep '^100\.64\.' | head -n1)
if [ -n "$headscale_ip" ]; then
echo "$headscale_ip"
return 0
fi
# Headscale IP가 없으면 로컬 IP (첫 번째)
local local_ip=$(hostname -I | awk '{print $1}')
if [ -n "$local_ip" ]; then
echo "$local_ip"
return 0
fi
# 그것도 없으면 localhost
echo "127.0.0.1"
}
# 루트 권한 확인
check_root() {
if [ "$EUID" -ne 0 ]; then
msg_error "이 스크립트는 root 권한으로 실행해야 합니다. sudo를 사용하세요."
fi
}
# 입력 검증 함수
validate_rdp_server() {
local server="$1"
# 기본적인 형식 검증 (호스트:포트 또는 호스트만)
if [[ ! "$server" =~ ^[a-zA-Z0-9.-]+(:([0-9]{1,5}))?$ ]]; then
return 1
fi
# 포트 범위 검증
if [[ "$server" =~ :([0-9]+)$ ]]; then
local port="${BASH_REMATCH[1]}"
if [ "$port" -lt 1 ] || [ "$port" -gt 65535 ]; then
return 1
fi
fi
return 0
}
validate_username() {
local username="$1"
# 사용자명 길이 및 문자 검증
if [ ${#username} -lt 1 ] || [ ${#username} -gt 32 ]; then
return 1
fi
# 허용된 문자만 포함 확인
if [[ ! "$username" =~ ^[a-zA-Z0-9._-]+$ ]]; then
return 1
fi
return 0
}
# 사용자 입력 받기
get_user_input() {
echo -e "${CYAN}RDP 연결 정보를 입력해주세요:${NC}"
echo ""
# RDP 서버 정보 (검증 포함)
while true; do
read -p "RDP 서버 주소 (예: example.com:3389): " RDP_SERVER </dev/tty
if [ -z "$RDP_SERVER" ]; then
msg_error "RDP 서버 주소는 필수입니다."
elif ! validate_rdp_server "$RDP_SERVER"; then
msg_warn "잘못된 서버 주소 형식입니다. 다시 입력해주세요."
echo " 예시: example.com 또는 example.com:3389"
continue
else
break
fi
done
# 사용자명 (검증 포함)
while true; do
read -p "RDP 사용자명: " RDP_USERNAME </dev/tty
if [ -z "$RDP_USERNAME" ]; then
msg_error "RDP 사용자명은 필수입니다."
elif ! validate_username "$RDP_USERNAME"; then
msg_warn "잘못된 사용자명 형식입니다. 영문, 숫자, ., _, - 만 사용 가능합니다."
continue
else
break
fi
done
# 패스워드 (재확인 포함)
while true; do
echo -n "RDP 패스워드: "
read -s RDP_PASSWORD </dev/tty
echo ""
if [ -z "$RDP_PASSWORD" ]; then
msg_error "RDP 패스워드는 필수입니다."
fi
echo -n "패스워드 확인: "
read -s password_confirm </dev/tty
echo ""
if [ "$RDP_PASSWORD" != "$password_confirm" ]; then
msg_warn "패스워드가 일치하지 않습니다. 다시 입력해주세요."
continue
else
break
fi
done
# 로컬 사용자명 (선택사항, 검증 포함)
while true; do
read -p "로컬 사용자명 [rdpuser]: " LOCAL_USER </dev/tty
LOCAL_USER=${LOCAL_USER:-rdpuser}
if ! validate_username "$LOCAL_USER"; then
msg_warn "잘못된 로컬 사용자명 형식입니다. 영문, 숫자, ., _, - 만 사용 가능합니다."
continue
else
break
fi
done
echo ""
echo -e "${YELLOW}입력된 정보:${NC}"
echo " RDP 서버: $RDP_SERVER"
echo " RDP 사용자: $RDP_USERNAME"
echo " 로컬 사용자: $LOCAL_USER"
echo ""
# 최종 확인
local attempts=0
while [ $attempts -lt 3 ]; do
read -p "설정을 계속하시겠습니까? [y/N]: " confirm </dev/tty
case $confirm in
[yY]|[yY][eE][sS])
return 0
;;
[nN]|[nN][oO]|"")
msg_error "설정이 취소되었습니다."
;;
*)
msg_warn "y 또는 n으로 답해주세요."
((attempts++))
;;
esac
done
msg_error "너무 많은 잘못된 입력으로 설정이 취소되었습니다."
}
# 백업 생성
create_backup() {
msg_info "기존 설정 백업 중..."
local backup_dir="/root/proxmox-rdp-backup-$(date +%Y%m%d-%H%M%S)"
mkdir -p "$backup_dir"
# 기존 설정 백업
if [ -f /etc/systemd/system/getty@tty1.service.d/override.conf ]; then
cp -r /etc/systemd/system/getty@tty1.service.d "$backup_dir/" 2>/dev/null || true
fi
if id "$LOCAL_USER" &>/dev/null; then
cp -r "/home/$LOCAL_USER" "$backup_dir/home-$LOCAL_USER" 2>/dev/null || true
fi
msg_ok "백업 완료: $backup_dir"
}
# 네트워크 연결 확인
check_network() {
msg_info "네트워크 연결 확인 중..."
# 인터넷 연결 확인
if ! ping -c 1 -W 5 8.8.8.8 > /dev/null 2>&1; then
msg_error "인터넷 연결을 확인할 수 없습니다. 네트워크 설정을 확인해주세요."
fi
# RDP 서버 연결 확인
local server_host="${RDP_SERVER%:*}"
local server_port="${RDP_SERVER#*:}"
# 포트가 지정되지 않았으면 기본값 3389 사용
if [ "$server_port" = "$server_host" ]; then
server_port="3389"
fi
msg_info "RDP 서버 연결 확인 중... ($server_host:$server_port)"
if command -v timeout > /dev/null; then
if ! timeout 10 bash -c "</dev/tcp/$server_host/$server_port" > /dev/null 2>&1; then
msg_warn "RDP 서버에 연결할 수 없습니다. 서버 주소와 포트를 확인해주세요."
read -p "계속 진행하시겠습니까? [y/N]: " continue_anyway </dev/tty
case $continue_anyway in
[yY]|[yY][eE][sS]) ;;
*) msg_error "설정이 취소되었습니다." ;;
esac
else
msg_ok "RDP 서버 연결 확인됨"
fi
else
msg_warn "연결 테스트를 건너뜁니다 (timeout 명령어 없음)"
fi
}
# 필수 패키지 설치
install_packages() {
msg_info "필수 패키지 설치 중..."
# 패키지 목록 업데이트
msg_info " - 패키지 목록 업데이트 중..."
if ! apt update > /dev/null 2>&1; then
msg_error "패키지 목록 업데이트에 실패했습니다."
fi
# 필수 패키지들
local packages="xorg openbox unclutter freerdp3-x11"
for package in $packages; do
msg_info " - $package 설치 중..."
# 패키지가 이미 설치되어 있는지 확인
if dpkg -l | grep -q "^ii $package "; then
msg_ok " $package 이미 설치됨"
continue
fi
# 패키지 설치 시도
local retry_count=0
while [ $retry_count -lt 3 ]; do
if apt install -y "$package" > /dev/null 2>&1; then
msg_ok " $package 설치 완료"
break
else
((retry_count++))
if [ $retry_count -lt 3 ]; then
msg_warn " $package 설치 실패, 재시도 중... ($retry_count/3)"
sleep 2
else
msg_error "$package 설치에 실패했습니다. 네트워크 연결이나 패키지 저장소를 확인해주세요."
fi
fi
done
done
# 설치 확인 (실제 명령어 존재 여부로 확인)
msg_info "설치된 패키지 확인 중..."
# 각 패키지의 주요 실행 파일 확인
local check_commands="startx openbox xfreerdp3 unclutter"
local all_ok=true
for cmd in $check_commands; do
if command -v "$cmd" > /dev/null 2>&1; then
msg_ok " $cmd 설치 확인됨"
else
msg_warn " $cmd를 찾을 수 없습니다"
all_ok=false
fi
done
if [ "$all_ok" = false ]; then
msg_warn "일부 패키지가 정상적으로 설치되지 않았을 수 있습니다. 계속 진행합니다."
else
msg_ok "모든 패키지 설치 완료"
fi
}
# 사용자 계정 생성
setup_user() {
msg_info "사용자 계정 설정 중..."
if ! id "$LOCAL_USER" &>/dev/null; then
useradd -m -s /bin/bash "$LOCAL_USER"
msg_ok "사용자 '$LOCAL_USER' 생성됨"
else
msg_warn "사용자 '$LOCAL_USER'가 이미 존재합니다."
fi
# 홈 디렉토리 권한 설정
chown -R "$LOCAL_USER:$LOCAL_USER" "/home/$LOCAL_USER"
}
# 자동 로그인 설정
setup_autologin() {
msg_info "자동 로그인 설정 중..."
# getty@tty1 서비스 override 디렉토리 생성
mkdir -p /etc/systemd/system/getty@tty1.service.d
# override.conf 파일 생성
cat > /etc/systemd/system/getty@tty1.service.d/override.conf << EOF
[Service]
ExecStart=
ExecStart=-/sbin/agetty --autologin $LOCAL_USER --noclear %I \$TERM
Type=idle
EOF
# systemd 리로드
systemctl daemon-reload
msg_ok "자동 로그인 설정 완료"
}
# X 자동 시작 설정
setup_x_autostart() {
msg_info "X Window 자동 시작 설정 중..."
# .bash_profile 생성
cat > "/home/$LOCAL_USER/.bash_profile" << 'EOF'
# tty1에서만 X 자동 시작
if [[ -z $DISPLAY ]] && [[ $(tty) == /dev/tty1 ]]; then
startx
logout
fi
EOF
chown "$LOCAL_USER:$LOCAL_USER" "/home/$LOCAL_USER/.bash_profile"
msg_ok "X Window 자동 시작 설정 완료"
}
# RDP 연결 설정
setup_rdp_connection() {
msg_info "RDP 연결 설정 중..."
# .xinitrc 파일 생성
cat > "/home/$LOCAL_USER/.xinitrc" << EOF
#!/bin/bash
# 화면 절전 모드 비활성화
xset -dpms
xset s off
xset s noblank
# 마우스 커서 숨기기
unclutter -idle 0.1 -root &
# Openbox 윈도우 매니저 시작
openbox-session &
# 잠시 대기 (X 완전 초기화)
sleep 2
# FreeRDP3를 사용한 직접 RDP 연결 (풀스크린)
xfreerdp3 \\
/v:$RDP_SERVER \\
/u:$RDP_USERNAME \\
/p:"$RDP_PASSWORD" \\
+f \\
/cert:ignore \\
+dynamic-resolution \\
/sound:sys:alsa \\
+clipboard
# RDP 종료 시 X 세션도 종료
pkill -SIGTERM Xorg
EOF
# 실행 권한 및 소유권 설정
chmod +x "/home/$LOCAL_USER/.xinitrc"
chown "$LOCAL_USER:$LOCAL_USER" "/home/$LOCAL_USER/.xinitrc"
msg_ok "RDP 연결 설정 완료"
}
# Openbox 설정
setup_openbox() {
msg_info "Openbox 윈도우 매니저 설정 중..."
# Openbox 설정 디렉토리 생성
mkdir -p "/home/$LOCAL_USER/.config/openbox"
# rc.xml 설정 파일 생성
cat > "/home/$LOCAL_USER/.config/openbox/rc.xml" << 'EOF'
<?xml version="1.0" encoding="UTF-8"?>
<openbox_config xmlns="http://openbox.org/3.4/rc">
<applications>
<application class="*">
<decor>no</decor>
<maximized>yes</maximized>
</application>
</applications>
</openbox_config>
EOF
# 소유권 설정
chown -R "$LOCAL_USER:$LOCAL_USER" "/home/$LOCAL_USER/.config"
msg_ok "Openbox 설정 완료"
}
# RDP Toggle API 설치
install_rdp_api() {
msg_info "RDP Toggle API 설치 중..."
local INSTALL_DIR="/opt/rdp-toggle-api"
local VENV_DIR="$INSTALL_DIR/venv"
local GITEA_BASE_URL="https://git.0bin.in/thug0bin/pve9-repo-fix/raw/branch/main/RDP"
# Python 및 venv 설치
msg_info "Python 및 필수 패키지 설치 중..."
apt install -y python3 python3-venv python3-pip curl > /dev/null 2>&1
# 설치 디렉토리 생성
mkdir -p "$INSTALL_DIR"
# 가상환경 생성
msg_info "Python 가상환경 생성 중..."
python3 -m venv "$VENV_DIR"
# 가상환경에서 패키지 설치
msg_info "FastAPI 패키지 설치 중..."
"$VENV_DIR/bin/pip" install --upgrade pip > /dev/null 2>&1
"$VENV_DIR/bin/pip" install fastapi==0.115.5 uvicorn==0.32.1 python-multipart==0.0.20 pydantic==2.10.3 > /dev/null 2>&1
# API 파일 다운로드
msg_info "API 서버 파일 다운로드 중..."
curl -fsSL "$GITEA_BASE_URL/rdp-toggle-api.py" -o "$INSTALL_DIR/rdp-toggle-api.py"
chmod +x "$INSTALL_DIR/rdp-toggle-api.py"
# systemd 서비스 생성
msg_info "systemd 서비스 생성 중..."
cat > /etc/systemd/system/rdp-toggle-api.service << EOF
[Unit]
Description=RDP Toggle API Service
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=$INSTALL_DIR
ExecStart=$VENV_DIR/bin/python $INSTALL_DIR/rdp-toggle-api.py
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
EOF
# RDP 설정을 API에 저장
msg_info "RDP 설정 저장 중..."
mkdir -p /var/lib/rdp-toggle
cat > /var/lib/rdp-toggle/config.json << EOF
{
"rdp_server": "$RDP_SERVER",
"rdp_username": "$RDP_USERNAME",
"rdp_password": "$RDP_PASSWORD",
"local_user": "$LOCAL_USER"
}
EOF
# 서비스 활성화 및 시작
msg_info "API 서비스 활성화 및 시작 중..."
systemctl daemon-reload
systemctl enable rdp-toggle-api.service > /dev/null 2>&1
systemctl start rdp-toggle-api.service
# API 서비스 시작 대기
sleep 3
# API 서비스 확인
if systemctl is-active rdp-toggle-api.service > /dev/null 2>&1; then
msg_ok "RDP Toggle API 설치 완료 (포트 8090)"
return 0
else
msg_warn "API 서비스가 정상적으로 시작되지 않았습니다."
return 1
fi
}
# 설정 테스트
test_configuration() {
msg_info "설정 테스트 중..."
# systemd 서비스 상태 확인
if systemctl is-enabled getty@tty1.service > /dev/null 2>&1; then
msg_ok "getty@tty1 서비스가 활성화되어 있습니다."
else
msg_warn "getty@tty1 서비스가 비활성화되어 있습니다."
fi
# 필수 파일들 존재 확인
local files=(
"/etc/systemd/system/getty@tty1.service.d/override.conf"
"/home/$LOCAL_USER/.bash_profile"
"/home/$LOCAL_USER/.xinitrc"
"/home/$LOCAL_USER/.config/openbox/rc.xml"
)
for file in "${files[@]}"; do
if [ -f "$file" ]; then
msg_ok "설정 파일 존재: $file"
else
msg_error "설정 파일 누락: $file"
fi
done
# FreeRDP3 설치 확인
if command -v xfreerdp3 > /dev/null 2>&1; then
msg_ok "FreeRDP3 설치 확인됨"
else
msg_error "FreeRDP3가 설치되어 있지 않습니다."
fi
}
# 완료 메시지
print_completion() {
echo ""
echo -e "${GREEN}═══════════════════════════════════════════════════════════════════${NC}"
echo -e "${GREEN} 설정이 완료되었습니다!${NC}"
echo -e "${GREEN}═══════════════════════════════════════════════════════════════════${NC}"
echo ""
echo -e "${CYAN}설정 요약:${NC}"
echo " - RDP 서버: $RDP_SERVER"
echo " - RDP 사용자: $RDP_USERNAME"
echo " - 로컬 사용자: $LOCAL_USER"
echo " - 자동 로그인: 활성화됨 (tty1)"
echo " - 풀스크린 RDP: 활성화됨"
echo " - RDP Toggle API: 설치됨 (포트 8090)"
echo ""
echo -e "${YELLOW}다음 단계:${NC}"
echo " 1. RDP 연결을 활성화하세요 (즉시 적용 또는 재부팅)"
echo " 2. 자동으로 RDP 연결이 시작됩니다"
echo " 3. 문제 발생 시 Ctrl+Alt+F2로 다른 터미널에 접근 가능합니다"
echo ""
echo -e "${CYAN}RDP 연결을 어떻게 활성화하시겠습니까?${NC}"
echo " 1) 즉시 적용 (getty@tty1 서비스 재시작, 권장)"
echo " 2) 시스템 재부팅"
echo " 3) 나중에 수동으로 적용"
echo ""
echo -n "선택 [1/2/3]: "
read -r apply_choice </dev/tty
case $apply_choice in
1)
msg_info "API를 통해 RDP 모드로 전환합니다..."
systemctl daemon-reload
sleep 1
# API를 통해 RDP 모드 활성화
local api_response=$(curl -s -X POST http://localhost:8090/toggle \
-H 'Content-Type: application/json' \
-d '{"mode":"rdp"}' 2>/dev/null)
if echo "$api_response" | grep -q '"status":"success"'; then
echo ""
echo -e "${GREEN}✅ RDP 모드가 API를 통해 활성화되었습니다!${NC}"
echo ""
echo -e "${YELLOW}참고:${NC}"
echo " - 물리 모니터(tty1)에서 RDP 연결이 시작됩니다"
echo " - SSH 세션은 계속 사용 가능합니다"
echo " - RDP Toggle API: http://$(get_primary_ip):8090"
echo ""
echo -e "${CYAN}API 사용 예시:${NC}"
echo " Shell 모드로 전환: curl -X POST http://localhost:8090/toggle -H 'Content-Type: application/json' -d '{\"mode\":\"shell\"}'"
echo " 상태 확인: curl http://localhost:8090/status"
echo ""
else
msg_warn "API를 통한 RDP 활성화에 실패했습니다. 수동으로 활성화합니다..."
systemctl restart getty@tty1.service
sleep 1
echo ""
echo -e "${GREEN}✅ RDP 연결이 tty1에서 활성화되었습니다!${NC}"
echo ""
fi
;;
2)
msg_info "시스템을 재부팅합니다..."
sleep 2
reboot
;;
*)
echo ""
echo -e "${GREEN}설정이 완료되었습니다.${NC}"
echo ""
echo -e "${YELLOW}수동 적용 방법:${NC}"
echo " API로 RDP 활성화: curl -X POST http://localhost:8090/toggle -H 'Content-Type: application/json' -d '{\"mode\":\"rdp\"}'"
echo " 또는 직접 활성화: systemctl daemon-reload && systemctl restart getty@tty1"
echo " 또는 재부팅: reboot"
echo ""
;;
esac
}
# RDP 토글 테스트 메뉴
show_rdp_toggle_menu() {
while true; do
echo ""
echo "=========================================="
echo "RDP 제어 메뉴"
echo " 1) RDP 모드로 전환"
echo " 2) Shell 모드로 전환"
echo " 3) 현재 상태 확인"
echo " 4) 종료"
echo ""
echo -n "선택 [1/2/3/4]: "
read -r choice </dev/tty
case "$choice" in
1)
echo ""
msg_info "RDP 모드로 전환 중..."
if curl -s -X POST http://localhost:8090/toggle \
-H 'Content-Type: application/json' \
-d '{"mode":"rdp"}' > /dev/null 2>&1; then
msg_ok "RDP 모드로 전환 완료!"
echo ""
echo "현재 상태:"
curl -s http://localhost:8090/status | python3 -m json.tool 2>/dev/null || curl -s http://localhost:8090/status
else
msg_error "RDP 모드 전환 실패. 서비스 상태를 확인하세요: systemctl status rdp-toggle-api"
fi
;;
2)
echo ""
msg_info "Shell 모드로 전환 중..."
if curl -s -X POST http://localhost:8090/toggle \
-H 'Content-Type: application/json' \
-d '{"mode":"shell"}' > /dev/null 2>&1; then
msg_ok "Shell 모드로 전환 완료!"
echo ""
echo "현재 상태:"
curl -s http://localhost:8090/status | python3 -m json.tool 2>/dev/null || curl -s http://localhost:8090/status
else
msg_error "Shell 모드 전환 실패. 서비스 상태를 확인하세요: systemctl status rdp-toggle-api"
fi
;;
3)
echo ""
msg_info "현재 상태 확인 중..."
curl -s http://localhost:8090/status | python3 -m json.tool 2>/dev/null || curl -s http://localhost:8090/status
;;
4)
echo ""
msg_ok "종료합니다."
return 0
;;
*)
echo ""
msg_warn "잘못된 선택입니다."
;;
esac
echo ""
done
}
# 메인 함수
main() {
print_header
# 사전 검사
check_root
check_proxmox_version
# 이미 설치되어 있는지 확인
if systemctl is-active --quiet rdp-toggle-api.service && \
[ -f "/opt/rdp-toggle-api/rdp-toggle-api.py" ] && \
[ -f "/etc/systemd/system/getty@tty1.service.d/override.conf" ]; then
# 현재 설정 로드
if [ -f "/var/lib/rdp-toggle/config.json" ]; then
CURRENT_RDP_SERVER=$(python3 -c "import json; print(json.load(open('/var/lib/rdp-toggle/config.json'))['rdp_server'])" 2>/dev/null || echo "")
CURRENT_RDP_USERNAME=$(python3 -c "import json; print(json.load(open('/var/lib/rdp-toggle/config.json'))['rdp_username'])" 2>/dev/null || echo "")
CURRENT_LOCAL_USER=$(python3 -c "import json; print(json.load(open('/var/lib/rdp-toggle/config.json'))['local_user'])" 2>/dev/null || echo "rdpuser")
else
CURRENT_RDP_SERVER=""
CURRENT_RDP_USERNAME=""
CURRENT_LOCAL_USER="rdpuser"
fi
echo ""
echo -e "${GREEN}=========================================="
echo "✅ RDP 자동화 시스템이 이미 설치되어 있습니다!"
echo -e "==========================================${NC}"
echo ""
echo -e "${CYAN}📍 API 서버: http://$(get_primary_ip):8090${NC}"
echo ""
echo -e "${CYAN}현재 설정:${NC}"
echo " RDP 서버: ${CURRENT_RDP_SERVER:-'설정되지 않음'}"
echo " RDP 사용자: ${CURRENT_RDP_USERNAME:-'설정되지 않음'}"
echo " 로컬 사용자: $CURRENT_LOCAL_USER"
echo ""
echo "다음 중 선택하세요:"
echo " 1) RDP 토글 메뉴 (RDP ↔ Shell 전환)"
echo " 2) RDP 설정 수정"
echo " 3) 재설치 (기존 설정 삭제 후 새로 설치)"
echo " 4) 종료"
echo ""
echo -n "선택 [1/2/3/4]: "
read -r reinstall_choice </dev/tty
case "$reinstall_choice" in
1)
# RDP 토글 메뉴 실행
show_rdp_toggle_menu
exit 0
;;
2)
# RDP 설정 수정
echo ""
echo -e "${CYAN}=========================================="
echo "RDP 설정 수정"
echo -e "==========================================${NC}"
echo ""
# 새로운 RDP 정보 입력
if [ -n "$CURRENT_RDP_SERVER" ]; then
echo -n "RDP 서버 주소 (현재: $CURRENT_RDP_SERVER, Enter=유지): "
else
echo -n "RDP 서버 주소 (예: 192.168.0.201:3389): "
fi
read -r NEW_RDP_SERVER </dev/tty
[ -z "$NEW_RDP_SERVER" ] && NEW_RDP_SERVER="$CURRENT_RDP_SERVER"
if [ -n "$CURRENT_RDP_USERNAME" ]; then
echo -n "RDP 사용자명 (현재: $CURRENT_RDP_USERNAME, Enter=유지): "
else
echo -n "RDP 사용자명: "
fi
read -r NEW_RDP_USERNAME </dev/tty
[ -z "$NEW_RDP_USERNAME" ] && NEW_RDP_USERNAME="$CURRENT_RDP_USERNAME"
echo -n "RDP 비밀번호: "
read -s NEW_RDP_PASSWORD </dev/tty
echo ""
# 로컬 사용자는 기본 rdpuser로 자동 설정
NEW_LOCAL_USER="${CURRENT_LOCAL_USER:-rdpuser}"
echo ""
echo "새로운 설정:"
echo " RDP 서버: $NEW_RDP_SERVER"
echo " RDP 사용자: $NEW_RDP_USERNAME"
echo " 로컬 사용자: $NEW_LOCAL_USER (자동 설정)"
echo ""
echo -n "이 설정으로 업데이트하시겠습니까? [y/N]: "
read -r confirm </dev/tty
if [[ "$confirm" =~ ^[Yy]$ ]]; then
# 설정 파일 업데이트
cat > /var/lib/rdp-toggle/config.json << EOF
{
"rdp_server": "$NEW_RDP_SERVER",
"rdp_username": "$NEW_RDP_USERNAME",
"rdp_password": "$NEW_RDP_PASSWORD",
"local_user": "$NEW_LOCAL_USER"
}
EOF
# RDP 설정 파일도 업데이트
RDP_SERVER="$NEW_RDP_SERVER"
RDP_USERNAME="$NEW_RDP_USERNAME"
RDP_PASSWORD="$NEW_RDP_PASSWORD"
LOCAL_USER="$NEW_LOCAL_USER"
# .xinitrc 업데이트
user_home="/home/$LOCAL_USER"
if [ -f "$user_home/.xinitrc" ]; then
cat > "$user_home/.xinitrc" << EOF
#!/bin/bash
xset -dpms
xset s off
xset s noblank
unclutter -idle 0.1 -root &
openbox-session &
sleep 2
xfreerdp3 /v:$RDP_SERVER /u:$RDP_USERNAME /p:"$RDP_PASSWORD" +f /cert:ignore +dynamic-resolution /sound:sys:alsa +clipboard
pkill -SIGTERM Xorg
EOF
chmod +x "$user_home/.xinitrc"
chown "$LOCAL_USER:$LOCAL_USER" "$user_home/.xinitrc"
fi
msg_ok "RDP 설정이 업데이트되었습니다!"
echo ""
echo "변경사항을 적용하려면 RDP 모드를 다시 활성화하세요."
echo ""
# 토글 메뉴로 이동
show_rdp_toggle_menu
else
msg_warn "설정 변경이 취소되었습니다."
fi
exit 0
;;
3)
echo ""
msg_warn "기존 설정을 삭제하고 재설치를 시작합니다..."
# 기존 설정 삭제는 하지 않고 덮어쓰기로 진행
echo ""
;;
4)
echo ""
msg_ok "종료합니다."
exit 0
;;
*)
echo ""
msg_warn "잘못된 선택입니다. 종료합니다."
exit 1
;;
esac
fi
# 사용자 입력
get_user_input
# 네트워크 확인
check_network
# 백업 생성
create_backup
# 설정 수행
install_packages
setup_user
setup_autologin
setup_x_autostart
setup_rdp_connection
setup_openbox
# RDP Toggle API 설치
install_rdp_api
# 테스트 및 완료
test_configuration
print_completion
}
# 에러 핸들링
trap 'msg_error "스크립트 실행 중 오류가 발생했습니다."' ERR
# 스크립트 실행
main "$@"

View File

@@ -0,0 +1,212 @@
# Proxmox 9.0 자동 RDP 연결 설정 가이드
## 개요
Proxmox VE 9.0 (Debian 13 기반) 호스트가 부팅될 때 자동으로 Windows VM에 RDP로 풀스크린 연결하는 설정 가이드입니다.
**목표**: CLI 화면을 보지 않고 부팅 후 바로 RDP 화면이 풀스크린으로 표시
## 환경 정보
- **OS**: Proxmox VE 9.0.5 (Debian 13 기반)
- **RDP 대상**: ysleadersos.com:6642
- **인증정보**: doctor-03 / @flejtm301
## 전체 설정 과정
### 1단계: 필수 패키지 설치
```bash
# X 윈도우 시스템 및 관련 패키지 설치
apt update
apt install -y xorg openbox unclutter freerdp3-x11
# 설치된 패키지 확인
dpkg -l | grep -E "(xorg|openbox|freerdp)"
```
### 2단계: 사용자 계정 생성 및 설정
```bash
# rdpuser 계정 생성 (이미 존재한다면 건너뛰기)
useradd -m -s /bin/bash rdpuser
passwd rdpuser
# 사용자 홈 디렉토리 권한 설정
chown -R rdpuser:rdpuser /home/rdpuser
```
### 3단계: systemd 자동 로그인 설정
```bash
# getty@tty1 서비스 override 디렉토리 생성
mkdir -p /etc/systemd/system/getty@tty1.service.d
# override.conf 파일 생성
cat > /etc/systemd/system/getty@tty1.service.d/override.conf << 'EOF'
[Service]
ExecStart=
ExecStart=-/sbin/agetty --autologin rdpuser --noclear %I $TERM
Type=idle
EOF
# systemd 설정 리로드
systemctl daemon-reload
systemctl restart getty@tty1.service
```
### 4단계: 자동 X 시작 설정
```bash
# rdpuser의 .bash_profile 생성
cat > /home/rdpuser/.bash_profile << 'EOF'
# tty1에서만 X 자동 시작
if [[ -z $DISPLAY ]] && [[ $(tty) == /dev/tty1 ]]; then
startx
logout
fi
EOF
# 파일 소유권 설정
chown rdpuser:rdpuser /home/rdpuser/.bash_profile
```
### 5단계: X 세션 설정 (.xinitrc)
```bash
# .xinitrc 파일 생성
cat > /home/rdpuser/.xinitrc << 'EOF'
#!/bin/bash
# 화면 절전 모드 비활성화
xset -dpms
xset s off
xset s noblank
# 마우스 커서 숨기기
unclutter -idle 0.1 -root &
# Openbox 윈도우 매니저 시작
openbox-session &
# 잠시 대기 (X 완전 초기화)
sleep 2
# FreeRDP3를 사용한 직접 RDP 연결 (풀스크린)
xfreerdp3 \
/v:ysleadersos.com:6642 \
/u:doctor-03 \
/p:"@flejtm301" \
+f \
/cert:ignore \
+dynamic-resolution \
/sound:sys:alsa \
+clipboard
# RDP 종료 시 X 세션도 종료
pkill -SIGTERM Xorg
EOF
# 실행 권한 및 소유권 설정
chmod +x /home/rdpuser/.xinitrc
chown rdpuser:rdpuser /home/rdpuser/.xinitrc
```
### 6단계: Openbox 설정 (풀스크린 최적화)
```bash
# Openbox 설정 디렉토리 생성
mkdir -p /home/rdpuser/.config/openbox
# rc.xml 설정 파일 생성 (윈도우 장식 제거, 풀스크린 강제)
cat > /home/rdpuser/.config/openbox/rc.xml << 'EOF'
<?xml version="1.0" encoding="UTF-8"?>
<openbox_config xmlns="http://openbox.org/3.4/rc">
<applications>
<application class="*">
<decor>no</decor>
<maximized>yes</maximized>
</application>
</applications>
</openbox_config>
EOF
# 디렉토리 및 파일 소유권 설정
chown -R rdpuser:rdpuser /home/rdpuser/.config
```
## 주요 문제 해결 과정
### 문제 1: 초기 Remmina 사용 시 연결 실패
- **증상**: 부팅 후 화면 깜빡임, RDP 연결되지 않음
- **원인**: Remmina가 자동 실행 환경에서 불안정
- **해결**: Remmina를 FreeRDP3로 교체
### 문제 2: .bash_profile의 exec startx 문제
- **증상**: 로그인/로그아웃 반복 루프
- **원인**: `exec startx`로 인한 세션 교체 문제
- **해결**: `exec startx``startx`로 변경하고 `logout` 추가
### 문제 3: FreeRDP3 명령어 문법 오류
- **증상**: "Unexpected keyword" 오류
- **해결**: 올바른 FreeRDP3 문법 적용
- `/cert-ignore``/cert:ignore`
- `/f``+f`
- `/dynamic-resolution``+dynamic-resolution`
- `/clipboard``+clipboard`
## 설정 파일 요약
### 핵심 설정 파일들:
1. `/etc/systemd/system/getty@tty1.service.d/override.conf` - 자동 로그인
2. `/home/rdpuser/.bash_profile` - X 자동 시작
3. `/home/rdpuser/.xinitrc` - RDP 연결 실행
4. `/home/rdpuser/.config/openbox/rc.xml` - 풀스크린 최적화
## 동작 흐름
1. **부팅 완료** → systemd가 tty1에서 rdpuser 자동 로그인
2. **로그인** → .bash_profile이 tty1에서 startx 실행
3. **X 시작** → .xinitrc가 실행됨
4. **Openbox 실행** → 윈도우 매니저 시작
5. **FreeRDP3 실행** → 풀스크린 RDP 연결
6. **RDP 종료시** → X 세션도 함께 종료
## 테스트 및 확인
### 설정 확인 명령어:
```bash
# 자동 로그인 서비스 상태 확인
systemctl status getty@tty1.service
# X 서버 실행 확인
ps aux | grep Xorg
# RDP 연결 테스트 (수동)
su - rdpuser -c "DISPLAY=:0 xfreerdp3 /v:ysleadersos.com:6642 /u:doctor-03 /p:'@flejtm301' +f /cert:ignore"
```
### 로그 확인:
```bash
# systemd 로그 확인
journalctl -u getty@tty1.service -f
# X 서버 로그 확인
cat /home/rdpuser/.local/share/xorg/Xorg.0.log
```
## 최종 결과
설정 완료 후 Proxmox 호스트를 재부팅하면:
- ✅ CLI 화면을 보지 않고 바로 RDP 화면이 표시됨
- ✅ 풀스크린 모드로 Windows VM에 자동 연결
- ✅ 사용자 개입 없이 완전 자동화된 부팅-RDP 연결
## 주의사항
1. **보안**: 패스워드가 설정 파일에 평문으로 저장됨 (운영 환경에서는 보안 강화 필요)
2. **네트워크**: RDP 대상 서버가 접근 가능한 상태여야 함
3. **백업**: 설정 변경 전 기존 설정 백업 권장
4. **권한**: 모든 설정 파일의 소유권이 rdpuser로 설정되어야 함
---
*생성일: 2025-08-24*
*작성자: Claude Code Assistant*

276
RDP/rdp-toggle-api.py Normal file
View File

@@ -0,0 +1,276 @@
#!/usr/bin/env python3
"""
RDP Toggle API Server
Control RDP/Shell display mode via REST API
"""
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
import subprocess
import os
import json
from typing import Optional
from datetime import datetime
import uvicorn
app = FastAPI(title="RDP Toggle API", version="1.0.0")
# CORS 설정 (외부에서 접근 가능)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 상태 저장 파일
STATE_FILE = "/var/lib/rdp-toggle/state.json"
CONFIG_FILE = "/var/lib/rdp-toggle/config.json"
# 기본 설정
DEFAULT_CONFIG = {
"rdp_server": "192.168.0.229",
"rdp_username": "0bin",
"rdp_password": "trajet6640",
"local_user": "rdpuser"
}
class ToggleRequest(BaseModel):
mode: str # "rdp" or "shell"
class ConfigUpdate(BaseModel):
rdp_server: Optional[str] = None
rdp_username: Optional[str] = None
rdp_password: Optional[str] = None
local_user: Optional[str] = None
class StatusResponse(BaseModel):
current_mode: str
rdp_active: bool
last_changed: str
config: dict
def ensure_directories():
"""필요한 디렉토리 생성"""
os.makedirs("/var/lib/rdp-toggle", exist_ok=True)
def load_state():
"""현재 상태 로드"""
if os.path.exists(STATE_FILE):
with open(STATE_FILE, 'r') as f:
return json.load(f)
return {
"current_mode": "shell",
"rdp_active": False,
"last_changed": datetime.now().isoformat()
}
def save_state(state):
"""상태 저장"""
ensure_directories()
with open(STATE_FILE, 'w') as f:
json.dump(state, f)
def load_config():
"""설정 로드"""
if os.path.exists(CONFIG_FILE):
with open(CONFIG_FILE, 'r') as f:
return json.load(f)
return DEFAULT_CONFIG
def save_config(config):
"""설정 저장"""
ensure_directories()
with open(CONFIG_FILE, 'w') as f:
json.dump(config, f)
def enable_rdp():
"""RDP 모드 활성화"""
config = load_config()
# 자동 로그인 설정
subprocess.run([
"mkdir", "-p", "/etc/systemd/system/getty@tty1.service.d"
], check=False)
override_content = f"""[Service]
ExecStart=
ExecStart=-/sbin/agetty --autologin {config['local_user']} --noclear %I $TERM
Type=idle"""
with open("/etc/systemd/system/getty@tty1.service.d/override.conf", "w") as f:
f.write(override_content)
# X 자동 시작 스크립트
bash_profile = f"""if [[ -z $DISPLAY ]] && [[ $(tty) == /dev/tty1 ]]; then
startx
logout
fi"""
user_home = f"/home/{config['local_user']}"
with open(f"{user_home}/.bash_profile", "w") as f:
f.write(bash_profile)
# .xinitrc 업데이트
xinitrc_content = f"""#!/bin/bash
xset -dpms
xset s off
xset s noblank
unclutter -idle 0.1 -root &
openbox-session &
sleep 2
xfreerdp3 /v:{config['rdp_server']} /u:{config['rdp_username']} /p:"{config['rdp_password']}" +f /cert:ignore +dynamic-resolution /sound:sys:alsa +clipboard
pkill -SIGTERM Xorg"""
with open(f"{user_home}/.xinitrc", "w") as f:
f.write(xinitrc_content)
subprocess.run(["chmod", "+x", f"{user_home}/.xinitrc"])
subprocess.run(["chown", f"{config['local_user']}:{config['local_user']}",
f"{user_home}/.bash_profile", f"{user_home}/.xinitrc"])
# systemd 리로드 및 getty 재시작
subprocess.run(["systemctl", "daemon-reload"])
subprocess.run(["systemctl", "restart", "getty@tty1.service"])
return True
def disable_rdp():
"""Shell 모드로 전환 (RDP 비활성화)"""
config = load_config()
# RDP 프로세스 종료
subprocess.run(["pkill", "-u", config['local_user'], "xfreerdp3"], check=False)
subprocess.run(["pkill", "-u", config['local_user'], "-f", "xinit|Xorg|openbox"], check=False)
# 자동 로그인 설정 제거
subprocess.run(["rm", "-f", "/etc/systemd/system/getty@tty1.service.d/override.conf"], check=False)
# 자동 시작 스크립트 제거
user_home = f"/home/{config['local_user']}"
subprocess.run(["rm", "-f", f"{user_home}/.bash_profile"], check=False)
# systemd 리로드 및 getty 재시작
subprocess.run(["systemctl", "daemon-reload"])
subprocess.run(["systemctl", "restart", "getty@tty1.service"])
# TTY1으로 전환
subprocess.run(["chvt", "1"], check=False)
return True
@app.get("/")
async def root():
"""API 정보"""
return {
"name": "RDP Toggle API",
"version": "1.0.0",
"endpoints": {
"GET /status": "현재 상태 확인",
"POST /toggle": "모드 전환 (rdp/shell)",
"GET /config": "현재 설정 확인",
"PUT /config": "설정 업데이트"
}
}
@app.get("/status", response_model=StatusResponse)
async def get_status():
"""현재 상태 반환"""
state = load_state()
config = load_config()
# 실제 프로세스 확인
try:
result = subprocess.run(
["pgrep", "-f", "xfreerdp3"],
capture_output=True,
text=True
)
rdp_running = result.returncode == 0
state["rdp_active"] = rdp_running
state["current_mode"] = "rdp" if rdp_running else "shell"
except:
pass
return StatusResponse(
current_mode=state["current_mode"],
rdp_active=state["rdp_active"],
last_changed=state["last_changed"],
config=config
)
@app.post("/toggle")
async def toggle_mode(request: ToggleRequest):
"""모드 전환"""
if request.mode not in ["rdp", "shell"]:
raise HTTPException(status_code=400, detail="Mode must be 'rdp' or 'shell'")
state = load_state()
try:
if request.mode == "rdp":
success = enable_rdp()
if success:
state["current_mode"] = "rdp"
state["rdp_active"] = True
else: # shell
success = disable_rdp()
if success:
state["current_mode"] = "shell"
state["rdp_active"] = False
state["last_changed"] = datetime.now().isoformat()
save_state(state)
return {
"status": "success",
"mode": request.mode,
"message": f"Switched to {request.mode} mode"
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.get("/config")
async def get_config():
"""현재 설정 반환"""
return load_config()
@app.put("/config")
async def update_config(update: ConfigUpdate):
"""설정 업데이트"""
config = load_config()
if update.rdp_server:
config["rdp_server"] = update.rdp_server
if update.rdp_username:
config["rdp_username"] = update.rdp_username
if update.rdp_password is not None:
config["rdp_password"] = update.rdp_password
if update.local_user:
config["local_user"] = update.local_user
save_config(config)
# 현재 RDP 모드인 경우 재시작
state = load_state()
if state["rdp_active"]:
disable_rdp()
enable_rdp()
return {
"status": "success",
"config": config
}
if __name__ == "__main__":
# Root 권한 확인
if os.geteuid() != 0:
print("This script must be run as root")
exit(1)
ensure_directories()
# 서버 시작
uvicorn.run(app, host="0.0.0.0", port=8090)

480
RDP/rdp-toggle-web.html Normal file
View File

@@ -0,0 +1,480 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RDP Toggle Control</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.container {
background: white;
border-radius: 20px;
padding: 40px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
max-width: 500px;
width: 100%;
margin: 20px;
}
h1 {
text-align: center;
color: #333;
margin-bottom: 30px;
font-size: 28px;
}
.status-card {
background: #f7f8fc;
border-radius: 12px;
padding: 20px;
margin-bottom: 30px;
}
.status-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.status-row:last-child {
margin-bottom: 0;
}
.status-label {
color: #666;
font-size: 14px;
}
.status-value {
font-weight: 600;
color: #333;
}
.status-indicator {
display: inline-block;
width: 12px;
height: 12px;
border-radius: 50%;
margin-right: 8px;
animation: pulse 2s infinite;
}
.status-indicator.active {
background: #4caf50;
}
.status-indicator.inactive {
background: #f44336;
}
@keyframes pulse {
0% { opacity: 1; }
50% { opacity: 0.5; }
100% { opacity: 1; }
}
.toggle-section {
text-align: center;
margin-bottom: 30px;
}
.toggle-wrapper {
display: inline-block;
position: relative;
}
.toggle-label {
display: flex;
align-items: center;
gap: 15px;
font-size: 18px;
font-weight: 500;
color: #333;
}
.toggle-switch {
position: relative;
width: 80px;
height: 40px;
background: #ccc;
border-radius: 40px;
cursor: pointer;
transition: background 0.3s;
}
.toggle-switch.active {
background: #667eea;
}
.toggle-slider {
position: absolute;
top: 4px;
left: 4px;
width: 32px;
height: 32px;
background: white;
border-radius: 50%;
transition: transform 0.3s;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
}
.toggle-switch.active .toggle-slider {
transform: translateX(40px);
}
.mode-labels {
display: flex;
justify-content: space-between;
margin-top: 10px;
font-size: 14px;
color: #666;
}
.config-section {
background: #f7f8fc;
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
}
.config-title {
font-size: 16px;
font-weight: 600;
color: #333;
margin-bottom: 15px;
}
.config-row {
display: flex;
align-items: center;
margin-bottom: 10px;
}
.config-label {
flex: 0 0 120px;
color: #666;
font-size: 14px;
}
.config-value {
flex: 1;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 14px;
background: white;
}
.button-group {
display: flex;
gap: 10px;
}
.btn {
flex: 1;
padding: 12px 24px;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s;
}
.btn-primary {
background: #667eea;
color: white;
}
.btn-primary:hover {
background: #5a67d8;
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4);
}
.btn-secondary {
background: #e2e8f0;
color: #333;
}
.btn-secondary:hover {
background: #cbd5e0;
}
.alert {
padding: 12px 16px;
border-radius: 8px;
margin-bottom: 20px;
font-size: 14px;
display: none;
}
.alert.success {
background: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.alert.error {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.loading {
display: none;
text-align: center;
padding: 20px;
}
.spinner {
display: inline-block;
width: 40px;
height: 40px;
border: 4px solid #f3f3f3;
border-top: 4px solid #667eea;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
</head>
<body>
<div class="container">
<h1>🖥️ RDP Toggle Control</h1>
<div class="alert" id="alert"></div>
<div class="status-card">
<div class="status-row">
<span class="status-label">연결 상태</span>
<span class="status-value">
<span class="status-indicator" id="status-indicator"></span>
<span id="connection-status">확인 중...</span>
</span>
</div>
<div class="status-row">
<span class="status-label">현재 모드</span>
<span class="status-value" id="current-mode">-</span>
</div>
<div class="status-row">
<span class="status-label">마지막 변경</span>
<span class="status-value" id="last-changed">-</span>
</div>
</div>
<div class="toggle-section">
<div class="toggle-wrapper">
<label class="toggle-label">
<span>Shell</span>
<div class="toggle-switch" id="toggle-switch">
<div class="toggle-slider"></div>
</div>
<span>RDP</span>
</label>
</div>
</div>
<div class="config-section">
<div class="config-title">RDP 설정</div>
<div class="config-row">
<span class="config-label">서버 주소:</span>
<input type="text" class="config-value" id="rdp-server" placeholder="예: 192.168.0.150">
</div>
<div class="config-row">
<span class="config-label">사용자명:</span>
<input type="text" class="config-value" id="rdp-username" placeholder="예: administrator">
</div>
<div class="config-row">
<span class="config-label">비밀번호:</span>
<input type="password" class="config-value" id="rdp-password" placeholder="비밀번호">
</div>
</div>
<div class="button-group">
<button class="btn btn-secondary" onclick="refreshStatus()">새로고침</button>
<button class="btn btn-primary" onclick="saveConfig()">설정 저장</button>
</div>
<div class="loading" id="loading">
<div class="spinner"></div>
<p style="margin-top: 10px; color: #666;">처리 중...</p>
</div>
</div>
<script>
const API_URL = window.location.hostname === 'localhost'
? 'http://localhost:8090'
: `http://${window.location.hostname}:8090`;
let currentMode = 'shell';
let isUpdating = false;
async function fetchStatus() {
try {
const response = await fetch(`${API_URL}/status`);
if (!response.ok) throw new Error('API 연결 실패');
const data = await response.json();
updateUI(data);
return data;
} catch (error) {
console.error('Status fetch error:', error);
showAlert('API 서버에 연결할 수 없습니다.', 'error');
document.getElementById('connection-status').textContent = '오프라인';
document.getElementById('status-indicator').className = 'status-indicator inactive';
}
}
function updateUI(data) {
currentMode = data.current_mode;
// 상태 업데이트
document.getElementById('connection-status').textContent = '온라인';
document.getElementById('status-indicator').className = 'status-indicator active';
document.getElementById('current-mode').textContent =
currentMode === 'rdp' ? 'RDP 모드' : 'Shell 모드';
// 시간 포맷
const lastChanged = new Date(data.last_changed);
document.getElementById('last-changed').textContent =
lastChanged.toLocaleString('ko-KR');
// 토글 스위치 업데이트
const toggleSwitch = document.getElementById('toggle-switch');
if (currentMode === 'rdp') {
toggleSwitch.classList.add('active');
} else {
toggleSwitch.classList.remove('active');
}
// 설정 값 업데이트
if (data.config) {
document.getElementById('rdp-server').value = data.config.rdp_server || '';
document.getElementById('rdp-username').value = data.config.rdp_username || '';
document.getElementById('rdp-password').value = data.config.rdp_password || '';
}
}
async function toggleMode() {
if (isUpdating) return;
isUpdating = true;
showLoading(true);
const newMode = currentMode === 'rdp' ? 'shell' : 'rdp';
try {
const response = await fetch(`${API_URL}/toggle`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ mode: newMode })
});
if (!response.ok) throw new Error('모드 전환 실패');
const data = await response.json();
showAlert(`${newMode === 'rdp' ? 'RDP' : 'Shell'} 모드로 전환되었습니다.`, 'success');
// 상태 새로고침
setTimeout(() => fetchStatus(), 1000);
} catch (error) {
console.error('Toggle error:', error);
showAlert('모드 전환에 실패했습니다.', 'error');
// 토글 스위치 원래대로
const toggleSwitch = document.getElementById('toggle-switch');
if (currentMode === 'rdp') {
toggleSwitch.classList.add('active');
} else {
toggleSwitch.classList.remove('active');
}
} finally {
isUpdating = false;
showLoading(false);
}
}
async function saveConfig() {
showLoading(true);
const config = {
rdp_server: document.getElementById('rdp-server').value,
rdp_username: document.getElementById('rdp-username').value,
rdp_password: document.getElementById('rdp-password').value
};
try {
const response = await fetch(`${API_URL}/config`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(config)
});
if (!response.ok) throw new Error('설정 저장 실패');
showAlert('설정이 저장되었습니다.', 'success');
fetchStatus();
} catch (error) {
console.error('Config save error:', error);
showAlert('설정 저장에 실패했습니다.', 'error');
} finally {
showLoading(false);
}
}
function refreshStatus() {
fetchStatus();
showAlert('상태를 새로고침했습니다.', 'success');
}
function showAlert(message, type) {
const alert = document.getElementById('alert');
alert.textContent = message;
alert.className = `alert ${type}`;
alert.style.display = 'block';
setTimeout(() => {
alert.style.display = 'none';
}, 3000);
}
function showLoading(show) {
document.getElementById('loading').style.display = show ? 'block' : 'none';
}
// 토글 스위치 이벤트
document.getElementById('toggle-switch').addEventListener('click', toggleMode);
// 초기 로드
fetchStatus();
// 주기적 상태 업데이트 (10초마다)
setInterval(fetchStatus, 10000);
</script>
</body>
</html>

4
RDP/requirements.txt Normal file
View File

@@ -0,0 +1,4 @@
fastapi==0.115.5
uvicorn==0.32.1
python-multipart==0.0.20
pydantic==2.10.3

207
README.md
View File

@@ -114,21 +114,21 @@ grep -r "enterprise.proxmox.com" /etc/apt/sources.list.d/
```
## 💻 개발 환경 설정 (code-server)
팜큐 네트워크에 연결된 서버에서 **웹 기반 VS Code 개발 환경**을 빠르게 구축:
서버에서 **웹 기반 VS Code 개발 환경**을 빠르게 구축:
### 한 줄 설치 (권장)
### 기본 설치 (포트 8080)
```bash
# 기본 포트 8080으로 설치
curl -fsSL https://git.0bin.in/thug0bin/headscale-tailscale-replacement/raw/branch/live/pharmq-headscale-production/docs/code-server.sh | bash
# 포트 지정 설치 (예: 8443)
curl -fsSL https://git.0bin.in/thug0bin/headscale-tailscale-replacement/raw/branch/live/pharmq-headscale-production/docs/code-server.sh | PORT=8443 bash
curl -fsSL https://git.0bin.in/thug0bin/pve9-repo-fix/raw/branch/main/code-server.sh | bash
```
### 무인 설치 (비밀번호 환경변수 설정)
### 포트 지정 설치
```bash
# 비밀번호를 환경변수로 전달
curl -fsSL https://git.0bin.in/thug0bin/headscale-tailscale-replacement/raw/branch/live/pharmq-headscale-production/docs/code-server.sh | PASSWORD="your-secure-password" SKIP_CONFIRM=1 bash
curl -fsSL https://git.0bin.in/thug0bin/pve9-repo-fix/raw/branch/main/code-server.sh | PORT=8443 bash
```
### 무인 설치 (비밀번호 환경변수)
```bash
curl -fsSL https://git.0bin.in/thug0bin/pve9-repo-fix/raw/branch/main/code-server.sh | PASSWORD="your-secure-password" SKIP_CONFIRM=1 bash
```
### 자동 설치 기능
@@ -142,6 +142,193 @@ curl -fsSL https://git.0bin.in/thug0bin/headscale-tailscale-replacement/raw/bran
```bash
# 브라우저에서 접속
http://<서버IP>:8080
```
## 🤖 Claude Code CLI 설치
**AI 기반 개발 도구**를 서버에 설치:
### 기본 설치
```bash
curl -fsSL https://git.0bin.in/thug0bin/pve9-repo-fix/raw/branch/main/install-claude-code.sh | bash
```
### 수동 설치 (스크립트 확인 후 실행)
```bash
wget https://git.0bin.in/thug0bin/pve9-repo-fix/raw/branch/main/install-claude-code.sh
chmod +x install-claude-code.sh
./install-claude-code.sh
```
### 설치 내용
-**Node.js 20.x LTS** 자동 설치
-**npm** 포함 설치
-**Claude Code CLI** 글로벌 설치
- ✅ 설치 확인 및 버전 출력
### 사용 방법
```bash
claude-code # Claude Code 시작
claude-code --help # 도움말 보기
```
## 🌐 Headscale VPN 등록 및 약국 자동 생성
**Headscale VPN 등록 + 약국/계정 자동 생성** 올인원 스크립트:
### 자동 등록 스크립트 (권장)
```bash
curl -fsSL https://git.0bin.in/thug0bin/pve9-repo-fix/raw/branch/main/headscale-auto-register.sh | bash
```
**기능:**
- ✅ Headscale VPN 자동 등록
- ✅ farmq.db에 약국 자동 생성 (P0003, P0004...)
- ✅ gateway.db에 관리자 계정 자동 생성
- ✅ 즉시 프론트엔드 로그인 가능
- ✅ 로그인 정보 자동 출력
### VPN만 등록 (계정 생성 없이)
```bash
curl -fsSL https://git.0bin.in/thug0bin/pve9-repo-fix/raw/branch/main/headscale-quick-install.sh | bash
```
**기능:**
- ✅ Headscale 클라이언트 등록만 수행
- ✅ PBS 서버 등록 전 필수 네트워크 설정
- ✅ Proxmox 환경 통합
### 테스트 데이터 정리
스크립트 테스트 후 생성된 데이터 정리:
```bash
curl -fsSL https://git.0bin.in/thug0bin/pve9-repo-fix/raw/branch/main/cleanup-test-data.sh | bash
```
**또는:**
```bash
bash /srv/install_scripts/pve9-repo-fix/cleanup-test-data.sh
```
**정리 내용:**
- 🗑️ farmq.db에서 P0003 이후 테스트 약국 삭제
- 🗑️ gateway.db에서 ID 5 이후 테스트 사용자 삭제
- 🗑️ Headscale 테스트 노드 삭제 (선택)
- 💾 백업 생성 옵션
📖 **자세한 정리 가이드**: [CLEANUP_TEST_DATA.md](CLEANUP_TEST_DATA.md)
## 💾 Proxmox Backup Server 올인원 설치
**PBS 서버 구축 및 Proxmox VE 통합**을 한 번에:
### 빠른 설치
```bash
curl -fsSL https://git.0bin.in/thug0bin/pve9-repo-fix/raw/branch/main/pbs_allinone.sh | bash
```
### 수동 설치
```bash
wget https://git.0bin.in/thug0bin/pve9-repo-fix/raw/branch/main/pbs_allinone.sh
chmod +x pbs_allinone.sh
./pbs_allinone.sh
```
### 설치 내용
-**PBS 저장소 설정** (no-subscription)
-**PBS 패키지 설치**
-**백업 저장소 생성 및 마운트**
-**방화벽 설정** (포트 8007)
-**Proxmox VE PBS 통합**
### 설치 후 접속
```bash
# 브라우저에서 PBS 웹 인터페이스 접속
https://<서버IP>:8007
```
## 🏷️ Proxmox VE 호스트명/FQDN 변경
**Proxmox 설치 후 안전하게 호스트명 및 FQDN 변경**:
### 빠른 실행
```bash
curl -fsSL https://git.0bin.in/thug0bin/pve9-repo-fix/raw/branch/main/pve-host-changer.sh | bash
```
### 수동 실행
```bash
wget https://git.0bin.in/thug0bin/pve9-repo-fix/raw/branch/main/pve-host-changer.sh
chmod +x pve-host-changer.sh
./pve-host-changer.sh
```
### 주요 기능
-**현재 설정 확인** 및 백업
-**호스트명/도메인 입력 검증**
-**자동 설정 파일 수정** (/etc/hostname, /etc/hosts)
-**Proxmox 서비스 재시작**
-**인증서 자동 재발급**
-**변경사항 검증** 및 롤백 가이드
### 주의사항
- 단독 노드(Single node)에서는 안전하게 변경 가능
- 클러스터 구성 시 신중하게 진행 필요
- 변경 후 시스템 재부팅 권장
## 🖥️ Proxmox RDP 자동화
**Proxmox 호스트에서 RDP 자동 연결 및 원격 제어**:
### RDP 올인원 설정 (권장)
```bash
curl -fsSL https://git.0bin.in/thug0bin/pve9-repo-fix/raw/branch/main/RDP/proxmox-auto-rdp-setup.sh | bash
```
**설치 내용:**
- ✅ X Window + Openbox 윈도우 매니저
- ✅ FreeRDP3 클라이언트 설치
- ✅ 자동 로그인 및 X 시작 설정
- ✅ RDP 서버 연결 정보 구성
-**RDP Toggle API 자동 설치** (포트 8090)
-**API를 통한 RDP 모드 활성화**
**특징:**
- 🔒 **API 작동 = RDP 제어 가능 보장**
- 🚀 **즉시 적용 옵션** (재부팅 불필요)
- 🎛️ **원격 Shell/RDP 모드 전환 가능**
### RDP Toggle API 단독 설치
이미 RDP 초기 설정이 완료된 경우에만 사용:
```bash
curl -fsSL https://git.0bin.in/thug0bin/pve9-repo-fix/raw/branch/main/RDP/install-rdp-api.sh | bash
```
**기능:**
-**REST API로 RDP/Shell 모드 전환**
- ✅ FastAPI 기반 서버 (포트 8090)
- ✅ 프론트엔드 통합 가능 (CORS 지원)
- ✅ 실시간 상태 모니터링
- ✅ 설정 동적 변경 (API로 RDP 서버 정보 변경)
### API 사용 예시
```bash
# 현재 상태 확인
curl http://localhost:8090/status
# RDP 모드로 전환
curl -X POST http://localhost:8090/toggle \
-H 'Content-Type: application/json' \
-d '{"mode":"rdp"}'
# Shell 모드로 전환
curl -X POST http://localhost:8090/toggle \
-H 'Content-Type: application/json' \
-d '{"mode":"shell"}'
```
📖 **자세한 가이드**: [RDP/README.md](RDP/README.md)
## 라이선스

263
SCRIPT_IMPROVEMENT_PLAN.md Normal file
View File

@@ -0,0 +1,263 @@
# headscale-quick-install.sh 개선 계획
## 목표
Headscale VPN 등록 시 **farmq.db와 gateway.db에 자동으로 약국 및 관리자 계정 생성**하여
스크립트 실행만으로 **즉시 프론트엔드 로그인 가능**하게 만들기
## 자동 생성 플로우
```
1. Headscale VPN 등록 → VPN IP 부여 (예: 100.64.0.25)
2. farmq-admin API 호출 → farmq.db에 약국 생성
- pharmacy_code: P0005 (자동 증가)
- pharmacy_name: 사용자 입력
- tailscale_ip: 100.64.0.25 (VPN IP)
- hira_code: 사용자 입력 (선택)
- api_port: 8082 (기본값)
3. gateway API 호출 → gateway.db에 admin 계정 생성
- username: p0005 (pharmacy_code 소문자)
- password: 1234 (기본 비밀번호)
- email: p0005@pharmq.internal
- name: {pharmacy_name} 관리자
- role: admin
- primary_pharmacy_code: P0005
- pharmacy_members에도 자동 매핑됨
4. 로그인 정보 출력
```
## 추가할 함수들
### 1. `collect_pharmacy_info()`
약국 기본 정보를 사용자로부터 입력받음
```bash
collect_pharmacy_info() {
echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e "${WHITE}약국 정보 입력${NC}"
echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
# 약국명 입력 (필수)
while [ -z "$PHARMACY_NAME" ]; do
read -p "약국명을 입력하세요: " PHARMACY_NAME
done
# 요양기관부호 입력 (선택)
read -p "요양기관부호 (선택, Enter로 건너뛰기): " HIRA_CODE
# 약국 주소 입력 (선택)
read -p "약국 주소 (선택): " PHARMACY_ADDRESS
# 약국장 이름 입력 (선택)
read -p "약국장 이름 (선택): " OWNER_NAME
# 연락처 입력 (선택)
read -p "약국 연락처 (선택): " PHARMACY_PHONE
echo -e "${GREEN}✓ 약국 정보 입력 완료${NC}"
}
```
### 2. `get_assigned_vpn_ip()`
Headscale에서 부여받은 VPN IP 가져오기
```bash
get_assigned_vpn_ip() {
echo -e "${BLUE}VPN IP 확인 중...${NC}"
# tailscale status로 IP 추출
VPN_IP=$(tailscale status --json 2>/dev/null | grep -oP '"TailscaleIPs":\["(\d+\.\d+\.\d+\.\d+)"' | grep -oP '\d+\.\d+\.\d+\.\d+' | head -1)
if [ -z "$VPN_IP" ]; then
echo -e "${RED}✗ VPN IP를 가져올 수 없습니다${NC}"
return 1
fi
echo -e "${GREEN}✓ VPN IP: $VPN_IP${NC}"
return 0
}
```
### 3. `create_pharmacy_via_api()`
farmq-admin API를 호출하여 약국 생성
```bash
create_pharmacy_via_api() {
echo -e "${BLUE}약국 등록 중 (farmq.db)...${NC}"
# JSON 데이터 구성
JSON_DATA=$(cat <<EOF
{
"pharmacy_name": "$PHARMACY_NAME",
"vpn_ip": "$VPN_IP",
"hira_code": "$HIRA_CODE",
"address": "$PHARMACY_ADDRESS",
"owner_name": "$OWNER_NAME",
"phone": "$PHARMACY_PHONE",
"api_port": 8082
}
EOF
)
# API 호출 (외부 도메인)
RESPONSE=$(curl -s -X POST https://demo.pharmq.kr/api/pharmacy \
-H "Content-Type: application/json" \
-d "$JSON_DATA")
# pharmacy_code 추출
PHARMACY_CODE=$(echo "$RESPONSE" | grep -oP '"pharmacy_code":"[^"]*"' | cut -d'"' -f4)
if [ -z "$PHARMACY_CODE" ]; then
echo -e "${RED}✗ 약국 생성 실패${NC}"
echo "$RESPONSE"
return 1
fi
echo -e "${GREEN}✓ 약국 생성 완료: $PHARMACY_CODE${NC}"
return 0
}
```
### 4. `create_gateway_user_via_api()`
gateway API를 호출하여 관리자 계정 생성
```bash
create_gateway_user_via_api() {
echo -e "${BLUE}관리자 계정 생성 중 (gateway.db)...${NC}"
# username: pharmacy_code 소문자 (P0005 → p0005)
USERNAME=$(echo "$PHARMACY_CODE" | tr '[:upper:]' '[:lower:]')
PASSWORD="1234" # 기본 비밀번호
EMAIL="${USERNAME}@pharmq.internal"
# JSON 데이터 구성
JSON_DATA=$(cat <<EOF
{
"username": "$USERNAME",
"email": "$EMAIL",
"password": "$PASSWORD",
"name": "${PHARMACY_NAME} 관리자",
"phone": "$PHARMACY_PHONE",
"primary_pharmacy_code": "$PHARMACY_CODE",
"role": "admin"
}
EOF
)
# API 호출 (외부 도메인)
RESPONSE=$(curl -s -X POST https://gateway.pharmq.kr/api/auth/register \
-H "Content-Type: application/json" \
-d "$JSON_DATA")
# 성공 여부 확인
if echo "$RESPONSE" | grep -q '"success":true'; then
echo -e "${GREEN}✓ 관리자 계정 생성 완료${NC}"
return 0
else
echo -e "${RED}✗ 관리자 계정 생성 실패${NC}"
echo "$RESPONSE"
return 1
fi
}
```
### 5. `display_login_credentials()`
생성된 로그인 정보 출력
```bash
display_login_credentials() {
echo -e "\n${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e "${WHITE}🎉 설치 및 등록 완료!${NC}"
echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e "\n${GREEN}약국 정보:${NC}"
echo -e " 약국 코드: ${WHITE}$PHARMACY_CODE${NC}"
echo -e " 약국명: ${WHITE}$PHARMACY_NAME${NC}"
echo -e " VPN IP: ${WHITE}$VPN_IP${NC}"
echo -e "\n${GREEN}프론트엔드 로그인 정보:${NC}"
echo -e " URL: ${WHITE}https://pharmq.kr${NC}"
echo -e " 아이디: ${WHITE}$(echo "$PHARMACY_CODE" | tr '[:upper:]' '[:lower:]')${NC}"
echo -e " 비밀번호: ${WHITE}1234${NC}"
echo -e " ${YELLOW}⚠ 최초 로그인 후 비밀번호를 변경하세요!${NC}"
echo -e "\n${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
}
```
## main() 함수 수정
```bash
main() {
print_header "팜큐(FARMQ) Headscale 원클릭 설치"
# 사전 체크
detect_os
check_requirements
# 설치 과정
install_tailscale
start_tailscale
register_headscale
# VPN IP 확인
sleep 3 # Headscale에서 IP 할당 대기
get_assigned_vpn_ip || exit 1
# 약국 정보 수집
collect_pharmacy_info
# 약국 및 계정 생성
create_pharmacy_via_api || exit 1
create_gateway_user_via_api || exit 1
# 사후 설정
configure_firewall
verify_connection
# 정리 및 완료
cleanup
display_login_credentials
}
```
## API 엔드포인트
| API | URL | 용도 | 접근 방식 |
|-----|-----|------|----------|
| farmq-admin | https://demo.pharmq.kr/api/pharmacy | 약국 생성 | 외부 도메인 (HTTPS) |
| gateway | https://gateway.pharmq.kr/api/auth/register | 사용자 생성 | 외부 도메인 (HTTPS) |
**장점**:
- ✅ 모든 API가 외부 도메인으로 통일되어 있음
- ✅ HTTPS로 보안 통신
- ✅ 내부 네트워크 접근 불필요
- ✅ VPN 망 내부/외부 어디서든 실행 가능
## 보안 고려사항
1. **기본 비밀번호 1234**: 간단하지만 프론트엔드에서 강제 변경 유도 필요
2. **API 키 인증**: 현재는 public API지만 나중에 인증 토큰 추가 고려
3. **HTTPS**: 현재 HTTP이지만 production에서는 HTTPS 사용 권장
## 에러 처리
- VPN IP 할당 실패 시 스크립트 중단
- 약국 생성 실패 시 스크립트 중단
- 사용자 생성 실패 시 경고만 출력 (약국은 이미 생성됨)
## 테스트 시나리오
1. ✅ 정상 플로우: 모든 정보 입력 → 약국 및 계정 생성 → 로그인 성공
2. ✅ 선택 정보 생략: 약국명만 입력 → 약국 및 계정 생성 → 로그인 성공
3. ✅ VPN IP 할당 실패: 에러 메시지 출력 후 종료
4. ✅ 약국 생성 실패: 에러 메시지 출력 후 종료
5. ✅ 사용자 생성 실패: 경고 출력, 수동 생성 안내
## 다음 단계
1. ✅ farmq-admin API 개선 (VPN IP, hira_code 지원) - 완료
2. ✅ gateway API 개선 (pharmacy 검증, pharmacy_members 자동 추가) - 완료
3. ⏳ headscale-quick-install.sh 수정 - 진행 중
4. ⏳ 통합 테스트

View File

@@ -0,0 +1,217 @@
# Tailscale TUN 디바이스 설정 성공 보고서
**작성일시**: 2025-11-02 17:21 KST
**대상 컨테이너**: Ubuntu24 LXC (VMID 101)
**작업 목적**: LXC 컨테이너에서 Tailscale 실행을 위한 TUN 디바이스 설정
---
## 문제 상황
### 초기 증상
- Tailscale 데몬(tailscaled.service) 실행 실패
- 상태: `failed (Result: exit-code)`
- 재시작 시도 5회 후 중단
### 원인 분석
```
Nov 02 16:22:09 Ubuntu24 tailscaled[299184]: wgengine.NewUserspaceEngine(tun "tailscale0") error:
tstun.New("tailscale0"): CreateTUN("tailscale0") failed; /dev/net/tun does not exist
```
**핵심 문제**: LXC 컨테이너 내부에 `/dev/net/tun` 디바이스가 존재하지 않음
### 기술적 배경
- Tailscale은 VPN 터널링을 위해 TUN 네트워크 디바이스가 필요
- LXC 컨테이너는 기본적으로 보안을 위해 디바이스 접근을 제한
- TUN 디바이스는 문자 디바이스(character device) `c 10:200`
---
## 해결 과정
### 1. 사전 백업
```bash
# 스냅샷 생성
pct snapshot 101 tailscale_fix_before -description "Tailscale TUN 설정 전 백업 - 2025-11-02 17:16"
# 설정 파일 백업
cp /etc/pve/lxc/101.conf /etc/pve/lxc/101.conf.backup
```
**백업 항목**:
- LXC 전체 시스템 스냅샷
- 설정 파일 백업
- 실행 중인 서비스 상태 리포트 작성
### 2. 컨테이너 중지
```bash
pct stop 101
```
**영향받은 서비스**:
- code-server (포트 8680, 8080)
- gp9.service (약국 크롤링)
- prescription-monitoring.service (처방전 모니터링)
- mosquitto (MQTT 브로커)
- samba (파일 공유)
### 3. LXC 설정 파일 수정
**수정 파일**: `/etc/pve/lxc/101.conf`
**추가된 설정**:
```conf
lxc.cgroup2.devices.allow: c 10:200 rwm
lxc.mount.entry: /dev/net dev/net none bind,create=dir
```
**설정 설명**:
- `lxc.cgroup2.devices.allow: c 10:200 rwm`
- cgroup2를 통해 문자 디바이스 10:200 (TUN) 접근 허용
- rwm = read, write, mknod 권한
- `lxc.mount.entry: /dev/net dev/net none bind,create=dir`
- 호스트의 `/dev/net` 디렉토리를 컨테이너에 바인드 마운트
- `create=dir`: 디렉토리가 없으면 자동 생성
**설정 위치**: 메인 컨테이너 설정 블록 끝 (unprivileged: 0 다음)
### 4. 컨테이너 재시작
```bash
pct start 101
```
**재시작 완료**: 약 5초 소요
### 5. TUN 디바이스 확인
```bash
pct exec 101 -- ls -la /dev/net/tun
```
**결과**:
```
crw-rw-rw- 1 root root 10, 200 Sep 18 22:34 /dev/net/tun
```
✅ TUN 디바이스 정상 생성 확인
### 6. Tailscale 서비스 시작
```bash
pct exec 101 -- systemctl start tailscaled
pct exec 101 -- systemctl status tailscaled
```
**결과**:
```
● tailscaled.service - Tailscale node agent
Loaded: loaded (/usr/lib/systemd/system/tailscaled.service; enabled; preset: enabled)
Active: active (running) since Sun 2025-11-02 17:20:55 KST
Main PID: 256 (tailscaled)
Status: "Needs login: "
Tasks: 11
Memory: 50.7M
CPU: 602ms
```
✅ Tailscale 데몬 정상 실행 확인
---
## 최종 결과
### 성공 확인
- TUN 디바이스: **정상 작동** (`/dev/net/tun` 존재)
- Tailscale 데몬: **active (running)**
- 서비스 상태: "Needs login" (정상 - 로그인 대기 상태)
- 에러 로그: **없음**
### 추가 작업 필요
Tailscale 네트워크 연결을 위해 로그인 필요:
```bash
pct exec 101 -- tailscale up
```
위 명령 실행 후 제공되는 인증 URL로 접속하여 Tailscale 계정 인증
---
## 기술 요약
### 적용된 LXC 설정
| 설정 항목 | 값 | 용도 |
|----------|-----|------|
| lxc.cgroup2.devices.allow | c 10:200 rwm | TUN 디바이스 접근 권한 |
| lxc.mount.entry | /dev/net dev/net none bind,create=dir | TUN 디바이스 마운트 |
### 디바이스 정보
- **디바이스 타입**: Character device
- **Major number**: 10
- **Minor number**: 200
- **권한**: crw-rw-rw- (읽기/쓰기)
- **경로**: /dev/net/tun
---
## 복원 방법
문제 발생 시 이전 상태로 복원:
### 방법 1: 스냅샷 복원
```bash
pct rollback 101 tailscale_fix_before
pct start 101
```
### 방법 2: 설정 파일 복원
```bash
pct stop 101
cp /etc/pve/lxc/101.conf.backup /etc/pve/lxc/101.conf
pct start 101
```
---
## 참고 자료
### LXC에서 Tailscale 실행하기
- Proxmox LXC는 기본적으로 보안을 위해 장치 접근 제한
- VPN 솔루션(Tailscale, WireGuard 등)은 TUN/TAP 디바이스 필요
- unprivileged 컨테이너에서는 추가 설정 필요할 수 있음
### 관련 문서
- Tailscale 공식 문서: https://tailscale.com/kb/
- Proxmox LXC 문서: https://pve.proxmox.com/wiki/Linux_Container
- Linux TUN/TAP: https://www.kernel.org/doc/Documentation/networking/tuntap.txt
---
## 작업 이력
| 시간 | 작업 내용 | 상태 |
|------|-----------|------|
| 17:16 | 스냅샷 생성 (tailscale_fix_before) | ✅ 완료 |
| 17:16 | 리포트 작성 (Ubuntu24_LXC_Status_Report.md) | ✅ 완료 |
| 17:17 | LXC 컨테이너 중지 | ✅ 완료 |
| 17:18 | TUN 디바이스 설정 추가 | ✅ 완료 |
| 17:19 | LXC 컨테이너 시작 | ✅ 완료 |
| 17:20 | TUN 디바이스 확인 | ✅ 정상 |
| 17:20 | Tailscale 서비스 시작 | ✅ 정상 |
| 17:21 | Tailscale 상태 확인 | ✅ 정상 |
---
## 결론
LXC 컨테이너에서 Tailscale을 실행하기 위해 필요한 TUN 디바이스 설정을 성공적으로 완료했습니다.
**주요 성과**:
1. ✅ TUN 디바이스 접근 권한 설정
2. ✅ Tailscale 데몬 정상 실행
3. ✅ 모든 기존 서비스 정상 작동
4. ✅ 백업 및 복원 방법 확보
**다음 단계**: Tailscale 계정 인증 후 네트워크 연결
---
**작성자**: Claude Code
**문서 버전**: 1.0
**최종 수정**: 2025-11-02 17:21 KST

341
UNIFIED_INSTALL_DESIGN.md Normal file
View File

@@ -0,0 +1,341 @@
# PharmQ PVE 원클릭 통합 설치 스크립트 설계
## 목표
**PVE host 1대에서 스크립트 1번 실행으로 모든 것을 완료:**
1. PVE 구독 리포 수정 (no-subscription)
2. PVE host에 Tailscale 설치 + Headscale 등록
3. PBS 등록 + Windows VM(팜IT3000) 복원
4. Ubuntu CT 자동 생성 (API 서버용)
5. CT 내부에 Tailscale 설치 + Headscale 등록 + API 환경 구축
6. farmq.db에 약국 + 장비 2개 등록
7. gateway.db에 관리자 계정 생성
8. 로그인 정보 출력
## 실행 방법
```bash
# PVE host의 shell에서 실행
curl -fsSL https://git.0bin.in/thug0bin/pve9-repo-fix/raw/branch/main/pharmq-setup.sh | bash
```
## Phase 구조 (총 8단계)
### Phase 1: PVE Repository Fix (자동, ~30초)
- proxmox-archive-keyring 확인
- no-subscription 리포 설정 (deb822)
- enterprise 리포 비활성화
- `apt-get update`
### Phase 2: PVE Host Tailscale 등록 (자동, ~20초)
- Tailscale 패키지 설치 (이미 있으면 스킵)
- tailscaled 서비스 시작
- `tailscale up --login-server=http://head.pharmq.kr --authkey=... --hostname=PXXXX-pve-pharmq`
- PVE host VPN IP 획득 → `$PVE_VPN_IP`
- **주의**: hostname은 Phase 3에서 약국 코드 확정 후 `tailscale set --hostname=...`으로 변경
### Phase 3: 약국 정보 수집 (대화형, 사용자 입력)
- 약국명 (필수)
- 요양기관부호 (선택)
- 주소, 약국장, 연락처 (선택)
- MSSQL 서버 IP (기본: `192.168.0.201\PM2014` — Windows VM 복원 후 변경될 수 있음)
**참고**: `curl | bash` 파이프 실행 시 `read``/dev/tty`에서 읽음
### Phase 4: PBS 등록 + Windows VM 복원 (반자동, ~5분)
PBS에서 팜IT3000 Windows VM을 복원하는 단계.
#### 4-1. PBS 스토리지 등록 (자동)
```bash
pvesm add pbs "PBS-Auto" \
--server "100.64.0.10" \
--port "8007" \
--datastore "PBS-DVA" \
--username "0bin@pbs" \
--password "@Trajet6640" \
--fingerprint "24:42:c6:..." \
--namespace "PQ"
```
- 이미 등록돼있으면 스킵
#### 4-2. PBS API 인증 + 백업 목록 조회 (자동)
```bash
# PBS API 로그인 → ticket 획득
# GET /api2/json/admin/datastore/PBS-DVA/groups?ns=PQ
# VM 백업 목록 표시
```
#### 4-3. 복원할 VM 선택 (사용자 입력)
```
=== VM 백업 ===
1. VM 100 - 3개 백업 (최근: 2026-04-05 14:00)
└─ 팜IT3000 Windows Server 템플릿
2. VM 200 - 2개 백업 (최근: 2026-04-03 10:00)
└─ 기본 Windows 10
복원할 VM 번호: 1
복원 VMID [기본: 200]: 200
저장 스토리지 [기본: local-lvm]: local-lvm
```
#### 4-4. VM 복원 실행 (자동)
```bash
# 최신 스냅샷 자동 선택
qmrestore "PBS-Auto:backup/vm/$TEMPLATE_VMID/$LATEST_SNAPSHOT" $TARGET_VMID \
--storage local-lvm --unique 1
```
#### 4-5. Windows VM 시작 (자동)
```bash
qm start $TARGET_VMID
sleep 30 # Windows 부팅 대기
```
- VM의 LAN IP → `$MSSQL_SERVER` 로 사용 (기본 `192.168.0.201\PM2014`)
### Phase 5: Ubuntu CT 생성 (자동, ~2분)
#### 5-1. 템플릿 준비
```bash
pveam update
pveam download local ubuntu-24.04-standard_24.04-2_amd64.tar.zst
# 이미 있으면 스킵
```
#### 5-2. VMID 자동 선택
- 200~299 범위에서 사용 가능한 VMID 자동 탐색 (VM과 겹치지 않게)
- `pct list` + `qm list`로 기존 VM/CT 확인
#### 5-3. LAN IP 자동 선택
- 192.168.0.100~199 범위에서 사용 가능한 IP 탐색
- `ping -c1 -W1`로 충돌 확인
#### 5-4. CT 생성
```bash
pct create $CT_VMID local:vztmpl/ubuntu-24.04-standard_24.04-2_amd64.tar.zst \
--hostname ubuntu-api \
--cores 4 --memory 8192 \
--rootfs local-lvm:30 \
--net0 name=eth0,bridge=vmbr0,ip=192.168.0.$X/24,gw=192.168.0.1 \
--nameserver 8.8.8.8 \
--password 'trajet6640' \
--unprivileged 1 \
--features nesting=1
```
#### 5-5. TUN 디바이스 설정 (Tailscale 필수)
```bash
echo 'lxc.cgroup2.devices.allow: c 10:200 rwm' >> /etc/pve/lxc/$CT_VMID.conf
echo 'lxc.mount.entry: /dev/net/tun dev/net/tun none bind,create=file' >> /etc/pve/lxc/$CT_VMID.conf
```
#### 5-6. CT 시작
```bash
pct start $CT_VMID
sleep 5 # 부팅 대기
```
### Phase 6: CT 내부 환경 구축 (자동, ~3분)
모든 명령은 `pct exec $CT_VMID -- bash -c '...'`로 실행
#### 6-1. 기본 패키지
```bash
apt-get update
apt-get install -y curl git python3-pip python3-dev python3-venv \
pkg-config unixodbc unixodbc-dev build-essential
```
#### 6-2. Microsoft ODBC Driver 18 (FreeTDS 아님!)
```bash
curl -fsSL https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor -o /usr/share/keyrings/microsoft-prod.gpg
echo "deb [arch=amd64 signed-by=/usr/share/keyrings/microsoft-prod.gpg] https://packages.microsoft.com/ubuntu/24.04/prod noble main" > /etc/apt/sources.list.d/mssql-release.list
apt-get update && ACCEPT_EULA=Y apt-get install -y msodbcsql18
# tdsodbc 절대 설치하지 않음 — named instance 접속 불가
```
#### 6-3. Tailscale 설치 + Headscale 등록
```bash
curl -fsSL https://pkgs.tailscale.com/stable/ubuntu/jammy.noarmor.gpg | tee /usr/share/keyrings/tailscale-archive-keyring.gpg >/dev/null
curl -fsSL https://pkgs.tailscale.com/stable/ubuntu/jammy.tailscale-keyring.list | tee /etc/apt/sources.list.d/tailscale.list
apt-get update && apt-get install -y tailscale
systemctl enable --now tailscaled
sleep 3
tailscale up --login-server=http://head.pharmq.kr \
--authkey=$PREAUTH_KEY \
--hostname=$PHARMACY_CODE-ubuntu-api \
--accept-routes --accept-dns=false
```
- CT VPN IP 획득 → `$CT_VPN_IP`
#### 6-4. API 서버 코드 클론 + 설치
```bash
git clone https://git.0bin.in/thug0bin/person-lookup-web.git /srv/person-lookup-web-local
cd /srv/person-lookup-web-local
pip3 install -r requirements.txt --break-system-packages
# 추가 누락 패키지 (P0019 freeze 기준)
pip3 install brother_ql pytz qrcode netifaces pydantic anthropic httpx PyYAML rich --break-system-packages
```
#### 6-5. .env 파일 생성
```bash
cat > /srv/person-lookup-web-local/.env << EOF
MSSQL_SERVER=$MSSQL_SERVER
MSSQL_USER=sa
MSSQL_PASSWORD=tmddls214!%(
PHARMACY_CODE=$PHARMACY_CODE
EOF
```
#### 6-6. systemd 서비스 등록
```bash
cat > /etc/systemd/system/pharmq-api.service << EOF
[Unit]
Description=PharmQ API Server
After=network.target
[Service]
Type=simple
WorkingDirectory=/srv/person-lookup-web-local
ExecStart=/usr/bin/python3 app.py
Restart=always
RestartSec=5
Environment=FLASK_ENV=production
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable --now pharmq-api
```
### Phase 7: 약국 + 장비 등록 (자동, ~5초)
#### 7-1. farmq.db — 약국 생성 (CT를 primary 장비로)
```bash
curl -s -X POST https://demo.pharmq.kr/api/pharmacy \
-H "Content-Type: application/json" \
-d '{
"pharmacy_name": "$PHARMACY_NAME",
"vpn_ip": "$CT_VPN_IP",
"device_type": "linux_server",
"device_name": "$PHARMACY_CODE-ubuntu-api",
"hostname": "ubuntu-api",
"hira_code": "$HIRA_CODE",
"address": "$ADDRESS",
"owner_name": "$OWNER_NAME",
"phone": "$PHONE",
"api_port": 8082
}'
# → pharmacy_code 응답 (P0022 등)
```
#### 7-2. farmq.db — PVE host 장비 추가 등록 ✅ NEW API
```bash
curl -s -X POST https://demo.pharmq.kr/api/pharmacy/$PHARMACY_CODE/device \
-H "Content-Type: application/json" \
-d '{
"tailscale_ip": "$PVE_VPN_IP",
"device_name": "$PHARMACY_CODE-pve-pharmq",
"device_type": "proxmox_host",
"device_role": "main_server",
"hostname": "$PVE_HOSTNAME",
"is_primary": false
}'
```
#### 7-3. gateway.db — 관리자 계정 생성
```bash
curl -s -X POST https://gateway.pharmq.kr/api/auth/register \
-H "Content-Type: application/json" \
-d '{
"username": "$PHARMACY_CODE_LOWER",
"email": "$PHARMACY_CODE_LOWER@pharmq.internal",
"password": "12341234",
"name": "$PHARMACY_NAME 관리자",
"phone": "$PHONE",
"primary_pharmacy_code": "$PHARMACY_CODE",
"role": "admin"
}'
```
### Phase 8: 검증 + 결과 출력 (자동, ~10초)
1. CT API 서버 헬스체크: `curl http://$CT_VPN_IP:8082/api/status`
2. Windows VM 상태 확인: `qm status $VM_VMID`
3. PVE → CT 네트워크 확인: `ping -c3 $CT_VPN_IP`
4. 최종 결과 출력:
```
============================================
PharmQ 원클릭 설치 완료!
============================================
약국 코드: P0022
약국명: OO약국
[Windows VM - 팜IT3000]
VMID: 200
MSSQL: 192.168.0.201\PM2014
[PVE Host]
VPN IP: 100.64.0.XX
hostname: P0022-pve-pharmq
[Ubuntu API CT]
VPN IP: 100.64.0.YY
hostname: P0022-ubuntu-api
CT VMID: 204
LAN IP: 192.168.0.103
API: http://100.64.0.YY:8082
로그인 정보:
URL: https://pharmq.kr
ID: p0022
PW: 12341234
⚠ 최초 로그인 후 비밀번호를 변경하세요!
============================================
```
## 주의사항
1. **ODBC Driver**: FreeTDS(tdsodbc) 절대 설치 금지. MSSQL named instance 접속 불가
2. **Preauth Key**: 현재 키 `b4692...` 만료일 2026-09-22 (충분)
3. **CT VMID**: 200~299 자동 탐색, VM과 겹치지 않게
4. **DNS**: CT에 `--nameserver 8.8.8.8` 필수 (Headscale DNS만 쓰면 apt 불가)
5. **TUN 디바이스**: CT 생성 후 start 전에 설정 필수
6. **중복 실행**: 같은 PVE에서 다시 돌리면 새 약국 코드 생성됨 (기존 것 덮어쓰지 않음)
7. **PBS 접근**: VPN 연결 후에만 PBS(100.64.0.10) 접근 가능 → Phase 2 이후 실행
## API 엔드포인트 (완료)
| 엔드포인트 | 용도 | 상태 |
|-----------|------|------|
| `POST /api/pharmacy` | 약국 생성 + 1번째 장비 등록 | 기존 |
| `POST /api/pharmacy/<code>/device` | 추가 장비 등록 | ✅ 신규 추가 |
| `POST /api/auth/register` | 관리자 계정 생성 | 기존 (gateway) |
## PBS 설정 (하드코딩)
| 항목 | 값 |
|------|-----|
| PBS 서버 | 100.64.0.10:8007 |
| 사용자 | 0bin@pbs |
| 비밀번호 | @Trajet6640 |
| 데이터스토어 | PBS-DVA |
| 네임스페이스 | PQ |
| Fingerprint | 24:42:c6:0f:a8:... |
## 파일 구조
```
pve9-repo-fix/
├── pharmq-setup.sh ← 통합 스크립트 (메인)
├── fix-pve9-repos.sh ← 기존 Phase 1 (참고용, 통합에 흡수)
├── headscale-auto-register.sh ← 기존 (참고용, 통합에 흡수)
├── pbs_allinone.sh ← 기존 PBS (참고용, Phase 4에 흡수)
├── UNIFIED_INSTALL_DESIGN.md ← 이 문서
└── README.md
```

436
VNC/README.md Normal file
View File

@@ -0,0 +1,436 @@
# PharmQ noVNC 자동 설치 스크립트
각 약국 Proxmox Host에 noVNC over WebSocket을 자동으로 설치하는 대화형 스크립트입니다.
## 📦 개요
이 스크립트는 Proxmox VE 환경에서 실행 중인 VM의 VNC 화면을 웹 브라우저로 제공하는 시스템을 자동으로 설치합니다.
**주요 기능:**
- ✅ 대화형 설치 (VM 자동 감지 및 선택)
- ✅ Python 가상환경 자동 구성
- ✅ systemd 서비스 자동 등록
- ✅ 방화벽 설정 (Tailscale VPN 전용)
- ✅ 헬스체크 및 상태 확인
- ✅ 재설치 및 업데이트 지원
## 🚀 빠른 설치
### 원클릭 설치 (curl)
```bash
curl -fsSL https://git.0bin.in/thug0bin/pve9-repo-fix/raw/branch/main/VNC/pharmq-novnc-setup.sh | bash
```
### 수동 다운로드 후 설치
```bash
wget https://git.0bin.in/thug0bin/pve9-repo-fix/raw/branch/main/VNC/pharmq-novnc-setup.sh
chmod +x pharmq-novnc-setup.sh
./pharmq-novnc-setup.sh
```
## 📋 사전 요구사항
### 필수 조건
- ✅ Proxmox VE 환경
- ✅ Root 권한
- ✅ 실행 중인 VM (VNC 활성화)
- ✅ 인터넷 연결
### 자동 설치되는 패키지
- `python3`, `python3-venv`, `python3-pip`
- `git`, `curl`
- `websockify`
- Flask 및 관련 Python 패키지
## 🎯 설치 과정
### 1단계: 스크립트 실행
```bash
curl -fsSL https://git.0bin.in/thug0bin/pve9-repo-fix/raw/branch/main/VNC/pharmq-novnc-setup.sh | bash
```
### 2단계: VM 선택
스크립트가 자동으로 실행 중인 VM 목록을 표시합니다:
```
실행 중인 VM 목록:
1) VM 201 - PharmQ Server (VM 201) (VNC: 5988)
2) VM 202 - PharmQ Client (VM 202) (VNC: 5989)
VM1 선택 (번호 입력): 1
VM2를 추가하시겠습니까? [y/N]: y
VM2 선택 (번호 입력): 2
```
### 3단계: 약국 정보 입력
```
약국 코드 (예: P0014): P0014
약국 이름 (예: 늘기쁨약국): 늘기쁨약국
Proxmox 호스트 IP [192.168.0.200]: (Enter)
Proxmox 사용자명 [root@pam]: (Enter)
Proxmox 비밀번호: ********
```
### 4단계: 자동 설치
- ✅ 패키지 설치
- ✅ pharmq-novnc 다운로드
- ✅ Python 가상환경 구성
- ✅ 설정 파일 생성
- ✅ systemd 서비스 등록
- ✅ 서비스 시작
### 5단계: 완료
```
═══════════════════════════════════════════════════════════════════
설치가 완료되었습니다!
═══════════════════════════════════════════════════════════════════
설정 요약:
약국 코드: P0014
약국 이름: 늘기쁨약국
설치 경로: /srv/pharmq-novnc
VM 설정:
VM1: 201 - PharmQ Server (VM 201)
- VNC 포트: 5988
- WebSocket 포트: 6085
VM2: 202 - PharmQ Client (VM 202)
- VNC 포트: 5989
- WebSocket 포트: 6086
접속 URL:
로컬 헬스체크: http://localhost:6000/health
VM1 noVNC: http://localhost:6000/
VM2 noVNC: http://localhost:6000/vnc2
Gateway를 통한 접속 (외부):
https://gateway.pharmq.kr/api/novnc/P0014/vnc1
https://gateway.pharmq.kr/api/novnc/P0014/vnc2
```
## 🔧 설치 후 확인
### 서비스 상태 확인
```bash
systemctl status pharmq-vnc-app.service
systemctl status pharmq-websockify-vnc1.service
systemctl status pharmq-websockify-vnc2.service
```
### 포트 리스닝 확인
```bash
ss -tlnp | grep -E "6000|6085|6086"
```
### 헬스체크
```bash
curl http://localhost:6000/health
```
**정상 응답 예시:**
```json
{
"status": "healthy",
"pharmacy_id": "P0014",
"pharmacy_name": "늘기쁨약국",
"vms": [
{
"id": 201,
"name": "PharmQ Server (VM 201)",
"vnc_connected": true
},
{
"id": 202,
"name": "PharmQ Client (VM 202)",
"vnc_connected": true
}
]
}
```
## 🌐 접속 방법
### 1. 로컬 접속 (Proxmox Host 내부)
```
http://localhost:6000/ # VM1
http://localhost:6000/vnc2 # VM2
http://localhost:6000/health # 헬스체크
```
### 2. Tailscale VPN을 통한 직접 접속
```
http://100.64.0.24:6000/ # VM1 (약국 VPN IP 사용)
http://100.64.0.24:6000/vnc2 # VM2
```
### 3. Gateway를 통한 접속 (권장)
```
https://gateway.pharmq.kr/api/novnc/P0014/vnc1 # VM1
https://gateway.pharmq.kr/api/novnc/P0014/vnc2 # VM2
```
## 🔒 보안 설정
스크립트 실행 중 방화벽 설정 옵션:
```
방화벽 설정을 진행하시겠습니까? [y/N]: y
```
이 옵션을 선택하면:
- ✅ Tailscale VPN(`tailscale0`)에서만 접근 허용
- ✅ 외부 인터넷에서 직접 접근 차단
- ✅ SSH 접속은 영향 없음
### 수동 방화벽 설정
```bash
# Tailscale VPN 인터페이스에서만 허용
ufw allow in on tailscale0 to any port 6000,6085,6086 proto tcp
# 외부 접근 차단
ufw deny 6000/tcp
ufw deny 6085/tcp
ufw deny 6086/tcp
# 방화벽 활성화
ufw enable
```
## 🔄 재설치 및 업데이트
### 재설치
스크립트를 다시 실행하면 기존 설치를 감지하고 옵션을 제공합니다:
```bash
curl -fsSL https://git.0bin.in/thug0bin/pve9-repo-fix/raw/branch/main/VNC/pharmq-novnc-setup.sh | bash
```
```
✅ PharmQ noVNC가 이미 설치되어 있습니다!
현재 설정:
약국 코드: P0014
약국 이름: 늘기쁨약국
다음 중 선택하세요:
1) 상태 확인
2) 재설치 (기존 설정 백업 후 새로 설치)
3) 종료
선택 [1/2/3]:
```
### 서비스 재시작
```bash
# 전체 서비스 재시작
systemctl restart pharmq-websockify-vnc1.service
systemctl restart pharmq-websockify-vnc2.service
systemctl restart pharmq-vnc-app.service
```
### 설정 파일 수동 편집
```bash
nano /srv/pharmq-novnc/config.json
systemctl restart pharmq-vnc-app.service
```
## 📊 관리 명령어
### 상태 확인
```bash
/srv/pharmq-novnc/scripts/check-status.sh
```
### 로그 확인
```bash
# 실시간 로그
journalctl -u pharmq-vnc-app.service -f
journalctl -u pharmq-websockify-vnc1.service -f
# 최근 로그
journalctl -u pharmq-vnc-app.service -n 100
```
### 서비스 중지
```bash
systemctl stop pharmq-vnc-app.service
systemctl stop pharmq-websockify-vnc1.service
systemctl stop pharmq-websockify-vnc2.service
```
### 서비스 비활성화
```bash
systemctl disable pharmq-vnc-app.service
systemctl disable pharmq-websockify-vnc1.service
systemctl disable pharmq-websockify-vnc2.service
```
## 🐛 트러블슈팅
### 문제: 서비스가 시작되지 않음
```bash
# 로그 확인
journalctl -xeu pharmq-vnc-app.service
# 수동 테스트
source /srv/pharmq-novnc/venv/bin/activate
cd /srv/pharmq-novnc
python3 app.py
```
### 문제: VNC 화면이 검은색
```bash
# VM 상태 확인
qm status 201
qm status 202
# VNC 포트 확인
nc -zv localhost 5988
nc -zv localhost 5989
# Websockify 재시작
systemctl restart pharmq-websockify-vnc1.service
systemctl restart pharmq-websockify-vnc2.service
```
### 문제: Gateway에서 502 에러
```bash
# 약국 서버 헬스체크
curl http://localhost:6000/health
# Tailscale 연결 확인
tailscale status
# Flask App 재시작
systemctl restart pharmq-vnc-app.service
```
### 문제: 포트 충돌
```bash
# 포트 사용 중인 프로세스 확인
ss -tlnp | grep 6000
ss -tlnp | grep 6085
ss -tlnp | grep 6086
# 프로세스 종료
kill <PID>
```
## 📁 디렉토리 구조
```
/srv/pharmq-novnc/
├── app.py # Flask 애플리케이션
├── config.json # 설정 파일
├── requirements.txt # Python 패키지 목록
├── venv/ # Python 가상환경
├── static/ # 정적 파일 (CSS, JS)
├── templates/ # HTML 템플릿
├── systemd/ # systemd 서비스 파일
│ ├── pharmq-vnc-app.service
│ ├── pharmq-websockify-vnc1.service
│ └── pharmq-websockify-vnc2.service
└── scripts/
├── check-status.sh # 상태 확인 스크립트
└── restart-all.sh # 전체 재시작 스크립트
```
## 🔗 관련 문서
- **pharmq-novnc 리포지토리**: https://git.0bin.in/thug0bin/pharmq-novnc
- **Gateway 통합 문서**: /srv/docs/VNCoverVPN.md
- **Frontend 통합 가이드**: pharmq_on CloudBillingService 컴포넌트
## 📞 지원
설치 중 문제 발생 시:
1. 스크립트 로그 저장:
```bash
script /tmp/install-log.txt
curl -fsSL https://git.0bin.in/.../pharmq-novnc-setup.sh | bash
exit
```
2. 서비스 로그 수집:
```bash
journalctl -u pharmq-vnc-app.service -n 100 > /tmp/vnc-app.log
journalctl -u pharmq-websockify-vnc1.service -n 100 > /tmp/vnc1.log
```
3. PharmQ 개발팀에 문의
## 🎯 setupScripts.ts 통합
프론트엔드 [setupScripts.ts](pharmq_on/src/constants/setupScripts.ts)에 추가:
```typescript
{
id: 'pharmq-novnc-setup',
title: '5단계: PharmQ noVNC 설치',
emoji: '🖥️',
description: 'VM VNC 화면을 웹 브라우저로 제공 (대화형 설치)',
command: 'curl -fsSL https://git.0bin.in/thug0bin/pve9-repo-fix/raw/branch/main/VNC/pharmq-novnc-setup.sh | bash',
category: 'core',
step: 5,
details: {
prerequisite: '⚠️ 1단계(Repository Fix) + 2단계(VPN 등록) 완료 필수',
problemDescription: [
'VM VNC 화면에 직접 접근하기 어려움',
'중앙 관리자가 약국 PC 화면을 원격으로 확인 필요',
'VNC 프로토콜을 웹 브라우저에서 사용 불가'
],
features: [
'VM 자동 감지 및 선택',
'noVNC over WebSocket 설치',
'Proxmox API 통합 (마우스 리셋)',
'systemd 서비스 자동 등록',
'방화벽 설정 (Tailscale VPN 전용)',
'Gateway 연동 (https://gateway.pharmq.kr)'
],
verification: [
'설치 완료 후 표시되는 URL 확인',
'curl http://localhost:6000/health',
'https://gateway.pharmq.kr/api/novnc/P0014/vnc1 접속'
],
warnings: [
'1단계 + 2단계 완료 후 실행',
'Proxmox VE 환경 필수',
'Root 권한 필요',
'실행 중인 VM이 있어야 함'
]
}
}
```
---
**버전**: 1.0
**작성일**: 2025-11-21
**작성자**: PharmQ Development Team

802
VNC/pharmq-novnc-setup.sh Executable file
View File

@@ -0,0 +1,802 @@
#!/bin/bash
# PharmQ noVNC Setup Script
# Ubuntu VM에서 실행하여 Proxmox Host의 VM VNC를 noVNC로 제공
# 사용법: curl -fsSL https://git.0bin.in/thug0bin/pve9-repo-fix/raw/branch/main/VNC/pharmq-novnc-setup.sh | bash
set -eo pipefail
# 명령행 인자 처리 (통합 스크립트에서 호출 시 사용)
ARG_PVE_HOST=""
ARG_PVE_USER=""
ARG_PVE_PASSWORD=""
ARG_PHARMACY_CODE=""
ARG_PHARMACY_NAME=""
while [[ $# -gt 0 ]]; do
case $1 in
--pve-host) ARG_PVE_HOST="$2"; shift 2 ;;
--pve-user) ARG_PVE_USER="$2"; shift 2 ;;
--pve-password) ARG_PVE_PASSWORD="$2"; shift 2 ;;
--pharmacy-code) ARG_PHARMACY_CODE="$2"; shift 2 ;;
--pharmacy-name) ARG_PHARMACY_NAME="$2"; shift 2 ;;
*) shift ;;
esac
done
# 색상 코드 정의
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
PURPLE='\033[0;35m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color
# 로그 함수들
msg_info() {
echo -e "${BLUE}[INFO]${NC} $1"
}
msg_ok() {
echo -e "${GREEN}[OK]${NC} $1"
}
msg_error() {
echo -e "${RED}[ERROR]${NC} $1"
exit 1
}
msg_warn() {
echo -e "${YELLOW}[WARN]${NC} $1"
}
# 헤더 출력
print_header() {
clear
echo -e "${PURPLE}"
echo "═══════════════════════════════════════════════════════════════════"
echo " PharmQ noVNC Setup Script v1.0"
echo "═══════════════════════════════════════════════════════════════════"
echo -e "${NC}"
echo "이 스크립트는 Ubuntu VM에서 Proxmox Host의 VM VNC를 웹으로 제공합니다."
echo "각 약국의 VM VNC 화면을 웹 브라우저로 제공합니다."
echo ""
}
# 환경 감지 (Proxmox Host vs Ubuntu VM)
detect_environment() {
msg_info "실행 환경 감지 중..."
if command -v pveversion > /dev/null 2>&1; then
INSTALL_ENV="proxmox"
msg_warn "Proxmox VE Host에서 실행 중입니다."
msg_warn "이 스크립트는 Proxmox 내부의 Ubuntu VM에서 실행되어야 합니다."
read -p "계속 진행하시겠습니까? [y/N]: " continue_anyway </dev/tty
case $continue_anyway in
[yY]|[yY][eE][sS]) ;;
*) msg_error "설치가 취소되었습니다." ;;
esac
else
INSTALL_ENV="ubuntu"
msg_ok "Ubuntu 환경에서 실행 중입니다."
fi
}
# 루트 권한 확인
check_root() {
if [ "$EUID" -ne 0 ]; then
msg_error "이 스크립트는 root 권한으로 실행해야 합니다. sudo를 사용하세요."
fi
}
# Proxmox API를 통해 VM 목록 가져오기
get_vm_list_from_api() {
local pve_host=$1
local pve_user=$2
local pve_password=$3
msg_info "Proxmox API를 통해 VM 목록 가져오는 중..."
# API 로그인
local ticket_response=$(curl -k -s -X POST \
"https://${pve_host}:8006/api2/json/access/ticket" \
-d "username=${pve_user}&password=${pve_password}")
if [ -z "$ticket_response" ]; then
msg_error "Proxmox API 로그인 실패: 응답 없음"
fi
local ticket=$(echo "$ticket_response" | python3 -c "import sys, json; print(json.load(sys.stdin)['data']['ticket'])" 2>/dev/null || echo "")
local csrf_token=$(echo "$ticket_response" | python3 -c "import sys, json; print(json.load(sys.stdin)['data']['CSRFPreventionToken'])" 2>/dev/null || echo "")
if [ -z "$ticket" ]; then
msg_error "Proxmox API 로그인 실패: 티켓을 가져올 수 없습니다."
fi
# 노드 목록 가져오기
local nodes_response=$(curl -k -s -X GET \
"https://${pve_host}:8006/api2/json/nodes" \
--cookie "PVEAuthCookie=${ticket}")
local node_name=$(echo "$nodes_response" | python3 -c "import sys, json; print(json.load(sys.stdin)['data'][0]['node'])" 2>/dev/null || echo "")
if [ -z "$node_name" ]; then
msg_error "Proxmox 노드를 찾을 수 없습니다."
fi
msg_ok "Proxmox 노드: $node_name"
# VM 목록 가져오기
local vms_response=$(curl -k -s -X GET \
"https://${pve_host}:8006/api2/json/nodes/${node_name}/qemu" \
--cookie "PVEAuthCookie=${ticket}")
# VM 목록 파싱 (running 상태만, vmid 순서로 정렬)
VM_LIST=$(echo "$vms_response" | python3 -c "
import sys, json
vms = json.load(sys.stdin)['data']
running_vms = [vm for vm in vms if vm.get('status') == 'running']
# vmid 순서로 정렬
running_vms.sort(key=lambda x: x['vmid'])
for vm in running_vms:
print(f\"{vm['vmid']}:{vm.get('name', 'VM-' + str(vm['vmid']))}\")
" 2>/dev/null || echo "")
if [ -z "$VM_LIST" ]; then
msg_warn "실행 중인 VM이 없습니다."
return 1
fi
msg_ok "VM 목록 가져오기 완료"
return 0
}
# VNC 포트 가져오기 (Proxmox API)
get_vnc_port_from_api() {
local pve_host=$1
local pve_user=$2
local pve_password=$3
local vmid=$4
# API 로그인
local ticket_response=$(curl -k -s -X POST \
"https://${pve_host}:8006/api2/json/access/ticket" \
-d "username=${pve_user}&password=${pve_password}")
local ticket=$(echo "$ticket_response" | python3 -c "import sys, json; print(json.load(sys.stdin)['data']['ticket'])" 2>/dev/null || echo "")
# 노드 가져오기
local nodes_response=$(curl -k -s -X GET \
"https://${pve_host}:8006/api2/json/nodes" \
--cookie "PVEAuthCookie=${ticket}")
local node_name=$(echo "$nodes_response" | python3 -c "import sys, json; print(json.load(sys.stdin)['data'][0]['node'])" 2>/dev/null || echo "")
# VM 설정 가져오기
local vm_config=$(curl -k -s -X GET \
"https://${pve_host}:8006/api2/json/nodes/${node_name}/qemu/${vmid}/config" \
--cookie "PVEAuthCookie=${ticket}")
# VNC 포트 추출
local vnc_port=$(echo "$vm_config" | python3 -c "
import sys, json
config = json.load(sys.stdin)['data']
# VNC display 번호로부터 포트 계산: 5900 + display
for key, value in config.items():
if 'vnc' in key or 'vga' in key:
if 'vnc' in str(value):
# vnc 설정에서 display 번호 추출 (예: 'vnc,:1')
parts = str(value).split(',')
for part in parts:
if part.startswith(':'):
display_num = part[1:]
if display_num.isdigit():
print(5900 + int(display_num))
sys.exit(0)
# VNC display를 찾지 못한 경우 기본값
print(5900 + $vmid % 100)
" 2>/dev/null || echo "5900")
echo "$vnc_port"
}
# VM 선택 및 설정
configure_vms() {
# 인자가 있으면 사용, 없으면 대화형
if [ -n "$ARG_PVE_HOST" ] && [ -n "$ARG_PVE_PASSWORD" ]; then
PVE_HOST="$ARG_PVE_HOST"
PVE_USER="${ARG_PVE_USER:-root@pam}"
PVE_PASSWORD="$ARG_PVE_PASSWORD"
msg_ok "PVE 접속 정보 자동 설정: $PVE_HOST ($PVE_USER)"
else
echo -e "${CYAN}Proxmox API 접속 정보 입력:${NC}"
echo ""
read -p "Proxmox 호스트 IP [192.168.0.200]: " PVE_HOST </dev/tty
PVE_HOST=${PVE_HOST:-192.168.0.200}
read -p "Proxmox 사용자명 [root@pam]: " PVE_USER </dev/tty
PVE_USER=${PVE_USER:-root@pam}
echo -n "Proxmox 비밀번호: "
read -s PVE_PASSWORD </dev/tty
echo ""
if [ -z "$PVE_PASSWORD" ]; then
msg_error "Proxmox 비밀번호는 필수입니다."
fi
fi
echo ""
msg_info "Proxmox API 연결 테스트 중..."
# VM 목록 가져오기
if ! get_vm_list_from_api "$PVE_HOST" "$PVE_USER" "$PVE_PASSWORD"; then
msg_error "VM 목록을 가져올 수 없습니다."
fi
echo ""
echo "실행 중인 VM 목록:"
echo ""
declare -A VM_INFO
local idx=1
while IFS=: read -r vmid vm_name; do
echo " $idx) VM $vmid - $vm_name"
VM_INFO[$idx]="$vmid:$vm_name"
((idx++))
done <<< "$VM_LIST"
if [ ${#VM_INFO[@]} -eq 0 ]; then
msg_error "실행 중인 VM이 없습니다."
fi
echo ""
echo "noVNC로 제공할 VM을 최대 2개까지 선택하세요."
echo "(예: 201-SERVER를 선택하려면 '1' 입력)"
echo ""
# VM1 선택
while true; do
read -p "VM1 선택 (목록 번호 1, 2, 3 중 선택): " vm1_choice </dev/tty
if [[ -n "${VM_INFO[$vm1_choice]:-}" ]]; then
IFS=':' read -r VM1_ID VM1_NAME <<< "${VM_INFO[$vm1_choice]}"
break
else
msg_warn "잘못된 선택입니다. 다시 입력해주세요."
fi
done
msg_info "VM1 VNC 포트 조회 중..."
VM1_VNC_PORT=$(get_vnc_port_from_api "$PVE_HOST" "$PVE_USER" "$PVE_PASSWORD" "$VM1_ID")
msg_ok "VM1: $VM1_ID - $VM1_NAME (VNC: $VM1_VNC_PORT)"
# VM2 선택 (선택사항)
echo ""
read -p "VM2를 추가하시겠습니까? [y/N]: " add_vm2 </dev/tty
VM2_ENABLED=false
case $add_vm2 in
[yY]|[yY][eE][sS])
while true; do
read -p "VM2 선택 (목록 번호 입력, VM1과 다른 번호): " vm2_choice </dev/tty
if [[ -n "${VM_INFO[$vm2_choice]:-}" ]]; then
if [ "$vm2_choice" = "$vm1_choice" ]; then
msg_warn "VM1과 다른 VM을 선택해주세요."
continue
fi
IFS=':' read -r VM2_ID VM2_NAME <<< "${VM_INFO[$vm2_choice]}"
VM2_ENABLED=true
msg_info "VM2 VNC 포트 조회 중..."
VM2_VNC_PORT=$(get_vnc_port_from_api "$PVE_HOST" "$PVE_USER" "$PVE_PASSWORD" "$VM2_ID")
msg_ok "VM2: $VM2_ID - $VM2_NAME (VNC: $VM2_VNC_PORT)"
break
else
msg_warn "잘못된 선택입니다. 다시 입력해주세요."
fi
done
;;
*)
msg_info "VM2 설정을 건너뜁니다."
;;
esac
}
# 약국 정보 입력
get_pharmacy_info() {
# 인자가 있으면 사용
if [ -n "$ARG_PHARMACY_CODE" ] && [ -n "$ARG_PHARMACY_NAME" ]; then
PHARMACY_CODE="$ARG_PHARMACY_CODE"
PHARMACY_NAME="$ARG_PHARMACY_NAME"
msg_ok "약국 정보 자동 설정: $PHARMACY_CODE ($PHARMACY_NAME)"
else
echo ""
echo -e "${CYAN}약국 정보를 입력해주세요:${NC}"
echo ""
while true; do
read -p "약국 코드 (예: P0014): " PHARMACY_CODE </dev/tty
if [ -z "$PHARMACY_CODE" ]; then
msg_warn "약국 코드는 필수입니다."
continue
fi
if [[ ! "$PHARMACY_CODE" =~ ^P[0-9]{4}$ ]]; then
msg_warn "약국 코드는 P0001 형식이어야 합니다."
continue
fi
break
done
while true; do
read -p "약국 이름 (예: 늘기쁨약국): " PHARMACY_NAME </dev/tty
if [ -z "$PHARMACY_NAME" ]; then
msg_warn "약국 이름은 필수입니다."
continue
fi
break
done
fi
# WebSocket 포트
WS_PORT1=6085
WS_PORT2=6086
FLASK_PORT=6000
echo ""
echo -e "${YELLOW}입력된 정보:${NC}"
echo " 약국 코드: $PHARMACY_CODE"
echo " 약국 이름: $PHARMACY_NAME"
echo " Proxmox 호스트: $PVE_HOST"
echo " VM1: $VM1_ID - $VM1_NAME (VNC: $VM1_VNC_PORT, WebSocket: $WS_PORT1)"
if [ "$VM2_ENABLED" = true ]; then
echo " VM2: $VM2_ID - $VM2_NAME (VNC: $VM2_VNC_PORT, WebSocket: $WS_PORT2)"
fi
echo " Flask 포트: $FLASK_PORT"
echo ""
read -p "이 설정으로 계속하시겠습니까? [y/N]: " confirm </dev/tty
case $confirm in
[yY]|[yY][eE][sS])
return 0
;;
*)
msg_error "설치가 취소되었습니다."
;;
esac
}
# 네트워크 연결 확인
check_network() {
msg_info "네트워크 연결 확인 중..."
if ! ping -c 1 -W 5 8.8.8.8 > /dev/null 2>&1; then
msg_error "인터넷 연결을 확인할 수 없습니다. 네트워크 설정을 확인해주세요."
fi
# Proxmox 서버 연결 확인
msg_info "Proxmox 서버 연결 확인 중... ($PVE_HOST:8006)"
if command -v timeout > /dev/null; then
if ! timeout 10 bash -c "</dev/tcp/$PVE_HOST/8006" > /dev/null 2>&1; then
msg_warn "Proxmox 서버에 연결할 수 없습니다. 설정을 확인해주세요."
read -p "계속 진행하시겠습니까? [y/N]: " continue_anyway </dev/tty
case $continue_anyway in
[yY]|[yY][eE][sS]) ;;
*) msg_error "설치가 취소되었습니다." ;;
esac
else
msg_ok "Proxmox 서버 연결 확인됨"
fi
fi
msg_ok "네트워크 연결 확인됨"
}
# 필수 패키지 설치
install_packages() {
msg_info "필수 패키지 설치 중..."
msg_info " - 패키지 목록 업데이트 중..."
if ! apt update > /dev/null 2>&1; then
msg_error "패키지 목록 업데이트에 실패했습니다."
fi
local packages="python3 python3-venv python3-pip git curl websockify"
for package in $packages; do
if dpkg -l | grep -q "^ii $package "; then
msg_ok " $package 이미 설치됨"
continue
fi
msg_info " - $package 설치 중..."
if apt install -y "$package" > /dev/null 2>&1; then
msg_ok " $package 설치 완료"
else
msg_error "$package 설치에 실패했습니다."
fi
done
msg_ok "모든 패키지 설치 완료"
}
# pharmq-novnc 다운로드 및 설치
install_pharmq_novnc() {
msg_info "pharmq-novnc 애플리케이션 설치 중..."
local INSTALL_DIR="/srv/pharmq-novnc"
local REPO_URL="https://git.0bin.in/thug0bin/pharmq-novnc.git"
# 기존 설치 확인
if [ -d "$INSTALL_DIR" ]; then
msg_warn "기존 설치 디렉토리가 발견되었습니다: $INSTALL_DIR"
read -p "기존 설치를 덮어쓰시겠습니까? [y/N]: " overwrite </dev/tty
case $overwrite in
[yY]|[yY][eE][sS])
msg_info "기존 설치 백업 중..."
mv "$INSTALL_DIR" "${INSTALL_DIR}.backup.$(date +%Y%m%d-%H%M%S)"
;;
*)
msg_error "설치가 취소되었습니다."
;;
esac
fi
# Git clone
msg_info "pharmq-novnc 리포지토리 다운로드 중..."
if git clone "$REPO_URL" "$INSTALL_DIR" > /dev/null 2>&1; then
msg_ok "다운로드 완료"
else
msg_error "리포지토리 다운로드에 실패했습니다."
fi
# Python 가상환경 생성
msg_info "Python 가상환경 생성 중..."
cd "$INSTALL_DIR"
python3 -m venv venv
source venv/bin/activate
pip install --upgrade pip > /dev/null 2>&1
pip install -r requirements.txt > /dev/null 2>&1
deactivate
msg_ok "Python 가상환경 설정 완료"
}
# config.json 생성
create_config() {
msg_info "설정 파일 생성 중..."
local INSTALL_DIR="/srv/pharmq-novnc"
local CONFIG_FILE="$INSTALL_DIR/config.json"
if [ "$VM2_ENABLED" = true ]; then
cat > "$CONFIG_FILE" << EOF
{
"pharmacy_id": "$PHARMACY_CODE",
"pharmacy_name": "$PHARMACY_NAME",
"vnc_host": "$PVE_HOST",
"proxmox": {
"host": "$PVE_HOST",
"port": 8006,
"username": "$PVE_USER",
"password": "$PVE_PASSWORD",
"verify_ssl": false
},
"vms": [
{
"id": $VM1_ID,
"name": "$VM1_NAME",
"vnc_port": $VM1_VNC_PORT,
"websockify_port": $WS_PORT1
},
{
"id": $VM2_ID,
"name": "$VM2_NAME",
"vnc_port": $VM2_VNC_PORT,
"websockify_port": $WS_PORT2
}
],
"flask": {
"host": "0.0.0.0",
"port": $FLASK_PORT
},
"notes": "vnc_host는 Proxmox VNC 서버 주소입니다. Flask는 Ubuntu VM에서 실행되며 Proxmox API를 통해 원격 VM을 제어합니다."
}
EOF
else
cat > "$CONFIG_FILE" << EOF
{
"pharmacy_id": "$PHARMACY_CODE",
"pharmacy_name": "$PHARMACY_NAME",
"vnc_host": "$PVE_HOST",
"proxmox": {
"host": "$PVE_HOST",
"port": 8006,
"username": "$PVE_USER",
"password": "$PVE_PASSWORD",
"verify_ssl": false
},
"vms": [
{
"id": $VM1_ID,
"name": "$VM1_NAME",
"vnc_port": $VM1_VNC_PORT,
"websockify_port": $WS_PORT1
}
],
"flask": {
"host": "0.0.0.0",
"port": $FLASK_PORT
},
"notes": "vnc_host는 Proxmox VNC 서버 주소입니다. Flask는 Ubuntu VM에서 실행되며 Proxmox API를 통해 원격 VM을 제어합니다."
}
EOF
fi
msg_ok "설정 파일 생성 완료: $CONFIG_FILE"
}
# systemd 서비스 설치
setup_systemd_services() {
msg_info "systemd 서비스 설치 중..."
local INSTALL_DIR="/srv/pharmq-novnc"
# 서비스 파일 복사
cp "$INSTALL_DIR/systemd/"*.service /etc/systemd/system/
# systemd 리로드
systemctl daemon-reload
msg_ok "systemd 서비스 등록 완료"
}
# 서비스 시작
start_services() {
msg_info "서비스 시작 중..."
local services=("pharmq-websockify-vnc1" "pharmq-vnc-app")
if [ "$VM2_ENABLED" = true ]; then
services+=("pharmq-websockify-vnc2")
fi
for service in "${services[@]}"; do
msg_info " - $service.service 시작 중..."
systemctl enable "$service.service" > /dev/null 2>&1
systemctl restart "$service.service"
sleep 2
if systemctl is-active "$service.service" > /dev/null 2>&1; then
msg_ok " $service: 실행 중"
else
msg_warn " $service: 시작 실패"
msg_info " 로그 확인: journalctl -xeu $service.service"
fi
done
msg_ok "모든 서비스 시작 완료"
}
# 방화벽 설정 (선택사항)
configure_firewall() {
echo ""
echo -e "${CYAN}방화벽 설정 (선택사항)${NC}"
echo ""
echo "Tailscale VPN으로만 접근하도록 방화벽을 설정하시겠습니까?"
echo "이 설정은 외부에서의 직접 접근을 차단하고 VPN을 통한 접근만 허용합니다."
echo ""
read -p "방화벽 설정을 진행하시겠습니까? [y/N]: " setup_fw </dev/tty
case $setup_fw in
[yY]|[yY][eE][sS])
msg_info "방화벽 설정 중..."
if ! command -v ufw > /dev/null 2>&1; then
msg_info "ufw 설치 중..."
apt install -y ufw > /dev/null 2>&1
fi
# Tailscale 인터페이스 확인
if ip link show tailscale0 > /dev/null 2>&1; then
ufw allow in on tailscale0 to any port $FLASK_PORT,$WS_PORT1,$WS_PORT2 proto tcp
ufw deny $FLASK_PORT/tcp
ufw deny $WS_PORT1/tcp
ufw deny $WS_PORT2/tcp
msg_ok "방화벽 설정 완료 (Tailscale VPN만 허용)"
else
msg_warn "Tailscale 인터페이스를 찾을 수 없습니다. 방화벽 설정을 건너뜁니다."
fi
;;
*)
msg_info "방화벽 설정을 건너뜁니다."
;;
esac
}
# 설치 테스트
test_installation() {
msg_info "설치 테스트 중..."
# 포트 리스닝 확인
sleep 3
if ss -tlnp 2>/dev/null | grep -q ":$FLASK_PORT "; then
msg_ok "Flask 앱이 포트 $FLASK_PORT에서 실행 중입니다."
else
msg_warn "Flask 앱이 포트 $FLASK_PORT에서 실행되지 않습니다."
fi
if ss -tlnp 2>/dev/null | grep -q ":$WS_PORT1 "; then
msg_ok "WebSocket 1이 포트 $WS_PORT1에서 실행 중입니다."
else
msg_warn "WebSocket 1이 포트 $WS_PORT1에서 실행되지 않습니다."
fi
if [ "$VM2_ENABLED" = true ]; then
if ss -tlnp 2>/dev/null | grep -q ":$WS_PORT2 "; then
msg_ok "WebSocket 2가 포트 $WS_PORT2에서 실행 중입니다."
else
msg_warn "WebSocket 2가 포트 $WS_PORT2에서 실행되지 않습니다."
fi
fi
# 헬스체크
if curl -s -f "http://localhost:$FLASK_PORT/health" > /dev/null 2>&1; then
msg_ok "헬스체크 성공"
else
msg_warn "헬스체크 실패 (서비스 시작 대기 중일 수 있습니다)"
fi
}
# 완료 메시지
print_completion() {
echo ""
echo -e "${GREEN}═══════════════════════════════════════════════════════════════════${NC}"
echo -e "${GREEN} 설치가 완료되었습니다!${NC}"
echo -e "${GREEN}═══════════════════════════════════════════════════════════════════${NC}"
echo ""
echo -e "${CYAN}설정 요약:${NC}"
echo " 약국 코드: $PHARMACY_CODE"
echo " 약국 이름: $PHARMACY_NAME"
echo " 설치 경로: /srv/pharmq-novnc"
echo " Proxmox 호스트: $PVE_HOST"
echo ""
echo -e "${CYAN}VM 설정:${NC}"
echo " VM1: $VM1_ID - $VM1_NAME"
echo " - VNC 포트: $VM1_VNC_PORT"
echo " - WebSocket 포트: $WS_PORT1"
if [ "$VM2_ENABLED" = true ]; then
echo " VM2: $VM2_ID - $VM2_NAME"
echo " - VNC 포트: $VM2_VNC_PORT"
echo " - WebSocket 포트: $WS_PORT2"
fi
echo ""
echo -e "${CYAN}접속 URL:${NC}"
echo " 로컬 헬스체크: http://localhost:$FLASK_PORT/health"
echo " VM1 noVNC: http://localhost:$FLASK_PORT/"
if [ "$VM2_ENABLED" = true ]; then
echo " VM2 noVNC: http://localhost:$FLASK_PORT/vnc2"
fi
echo ""
echo -e "${CYAN}Gateway를 통한 접속 (외부):${NC}"
echo " https://gateway.pharmq.kr/api/novnc/$PHARMACY_CODE/vnc1"
if [ "$VM2_ENABLED" = true ]; then
echo " https://gateway.pharmq.kr/api/novnc/$PHARMACY_CODE/vnc2"
fi
echo ""
echo -e "${CYAN}관리 명령어:${NC}"
echo " 상태 확인: /srv/pharmq-novnc/scripts/check-status.sh"
echo " 전체 재시작: systemctl restart pharmq-websockify-vnc1 pharmq-vnc-app"
if [ "$VM2_ENABLED" = true ]; then
echo " systemctl restart pharmq-websockify-vnc2"
fi
echo " 로그 확인: journalctl -u pharmq-vnc-app.service -f"
echo ""
echo -e "${YELLOW}다음 단계:${NC}"
echo " 1. 헬스체크 테스트: curl http://localhost:$FLASK_PORT/health"
echo " 2. Gateway에서 접속 테스트"
echo " 3. 브라우저에서 noVNC 화면 확인"
echo ""
}
# 메인 함수
main() {
print_header
# 사전 검사
check_root
detect_environment
# 기존 설치 확인
if [ -f "/srv/pharmq-novnc/config.json" ] && \
systemctl is-active --quiet pharmq-vnc-app.service; then
echo ""
echo -e "${GREEN}=========================================="
echo "✅ PharmQ noVNC가 이미 설치되어 있습니다!"
echo -e "==========================================${NC}"
echo ""
CURRENT_PHARMACY_CODE=$(python3 -c "import json; print(json.load(open('/srv/pharmq-novnc/config.json', encoding='utf-8'))['pharmacy_id'])" 2>/dev/null || echo "")
CURRENT_PHARMACY_NAME=$(python3 -c "import json; print(json.load(open('/srv/pharmq-novnc/config.json', encoding='utf-8'))['pharmacy_name'])" 2>/dev/null || echo "")
echo -e "${CYAN}현재 설정:${NC}"
echo " 약국 코드: ${CURRENT_PHARMACY_CODE:-'설정되지 않음'}"
echo " 약국 이름: ${CURRENT_PHARMACY_NAME:-'설정되지 않음'}"
echo ""
echo "다음 중 선택하세요:"
echo " 1) 상태 확인"
echo " 2) 재설치 (기존 설정 백업 후 새로 설치)"
echo " 3) 종료"
echo ""
echo -n "선택 [1/2/3]: "
read -r choice </dev/tty
case "$choice" in
1)
echo ""
msg_info "서비스 상태 확인 중..."
if [ -x "/srv/pharmq-novnc/scripts/check-status.sh" ]; then
/srv/pharmq-novnc/scripts/check-status.sh
else
systemctl status pharmq-vnc-app.service pharmq-websockify-vnc1.service
fi
exit 0
;;
2)
msg_warn "재설치를 시작합니다..."
echo ""
;;
3)
msg_ok "종료합니다."
exit 0
;;
*)
msg_warn "잘못된 선택입니다. 종료합니다."
exit 1
;;
esac
fi
# VM 설정
configure_vms
# 약국 정보 입력
get_pharmacy_info
# 네트워크 확인
check_network
# 설치 진행
install_packages
install_pharmq_novnc
create_config
setup_systemd_services
start_services
# 방화벽 설정 (선택사항)
configure_firewall
# 테스트 및 완료
test_installation
print_completion
}
# 에러 핸들링
trap 'msg_error "스크립트 실행 중 오류가 발생했습니다."' ERR
# 스크립트 실행
main "$@"

258
cleanup-test-data.sh Executable file
View File

@@ -0,0 +1,258 @@
#!/bin/bash
# ================================
# 테스트 데이터 정리 스크립트
# ================================
#
# 용도: headscale 자동 등록 테스트 후 생성된 데이터 정리
# - farmq.db: P0003 이후 약국 삭제
# - gateway.db: ID 5 이후 사용자 삭제
# - Headscale: 테스트 노드 삭제 (선택)
#
set -e
# ================================
# 색상 정의
# ================================
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
WHITE='\033[1;37m'
NC='\033[0m' # No Color
# ================================
# 헤더 출력
# ================================
print_header() {
echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e "${WHITE}$1${NC}"
echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
}
# ================================
# 1. farmq.db 테스트 약국 삭제
# ================================
cleanup_farmq_db() {
print_header "1. farmq.db 테스트 약국 정리"
FARMQ_DB="/srv/headscale-tailscale-replacement/farmq-admin/farmq.db"
if [ ! -f "$FARMQ_DB" ]; then
echo -e "${RED}✗ farmq.db를 찾을 수 없습니다: $FARMQ_DB${NC}"
return 1
fi
echo -e "${BLUE}현재 약국 목록:${NC}"
python3 << EOF
import sqlite3
conn = sqlite3.connect('$FARMQ_DB')
cursor = conn.cursor()
cursor.execute('SELECT pharmacy_code, pharmacy_name, tailscale_ip FROM pharmacies ORDER BY pharmacy_code')
for row in cursor.fetchall():
print(f' {row[0]}: {row[1]} - {row[2]}')
conn.close()
EOF
echo -e "\n${YELLOW}P0003 이후 약국을 삭제하시겠습니까? (y/N)${NC}"
read -p "> " -r response </dev/tty
if [[ "$response" =~ ^[Yy]$ ]]; then
python3 << EOF
import sqlite3
conn = sqlite3.connect('$FARMQ_DB')
cursor = conn.cursor()
# P0003~P9999만 삭제 (P001, P002, P0001, P0002는 보호)
cursor.execute("DELETE FROM pharmacies WHERE pharmacy_code >= 'P0003' AND pharmacy_code <= 'P9999' AND LENGTH(pharmacy_code) = 5")
deleted_count = cursor.rowcount
conn.commit()
conn.close()
print(f'✓ {deleted_count}개 약국 삭제 완료')
EOF
echo -e "\n${GREEN}남은 약국 목록:${NC}"
python3 << EOF
import sqlite3
conn = sqlite3.connect('$FARMQ_DB')
cursor = conn.cursor()
cursor.execute('SELECT pharmacy_code, pharmacy_name, tailscale_ip FROM pharmacies ORDER BY pharmacy_code')
for row in cursor.fetchall():
print(f' {row[0]}: {row[1]} - {row[2]}')
conn.close()
EOF
else
echo -e "${YELLOW}건너뜀${NC}"
fi
}
# ================================
# 2. gateway.db 테스트 사용자 삭제
# ================================
cleanup_gateway_db() {
print_header "2. gateway.db 테스트 사용자 정리"
GATEWAY_DB="/srv/pharmq-gateway/gateway.db"
if [ ! -f "$GATEWAY_DB" ]; then
echo -e "${RED}✗ gateway.db를 찾을 수 없습니다: $GATEWAY_DB${NC}"
return 1
fi
echo -e "${BLUE}현재 사용자 목록:${NC}"
python3 << EOF
import sqlite3
conn = sqlite3.connect('$GATEWAY_DB')
cursor = conn.cursor()
cursor.execute('SELECT id, username, name, primary_pharmacy_code FROM users ORDER BY id')
for row in cursor.fetchall():
print(f' ID {row[0]}: {row[1]} ({row[2]}) - {row[3]}')
conn.close()
EOF
echo -e "\n${YELLOW}ID 5 이후 사용자를 삭제하시겠습니까? (y/N)${NC}"
read -p "> " -r response </dev/tty
if [[ "$response" =~ ^[Yy]$ ]]; then
python3 << EOF
import sqlite3
conn = sqlite3.connect('$GATEWAY_DB')
cursor = conn.cursor()
cursor.execute('DELETE FROM pharmacy_members WHERE user_id >= 5')
cursor.execute('DELETE FROM users WHERE id >= 5')
deleted_count = cursor.rowcount
conn.commit()
conn.close()
print(f'✓ {deleted_count}명 사용자 삭제 완료')
EOF
echo -e "\n${GREEN}남은 사용자 목록:${NC}"
python3 << EOF
import sqlite3
conn = sqlite3.connect('$GATEWAY_DB')
cursor = conn.cursor()
cursor.execute('SELECT id, username, name, primary_pharmacy_code FROM users ORDER BY id')
for row in cursor.fetchall():
print(f' ID {row[0]}: {row[1]} ({row[2]}) - {row[3]}')
conn.close()
EOF
else
echo -e "${YELLOW}건너뜀${NC}"
fi
}
# ================================
# 3. Headscale 테스트 노드 삭제
# ================================
cleanup_headscale_nodes() {
print_header "3. Headscale 테스트 노드 정리"
# Docker 컨테이너 확인
if ! docker ps | grep -q headscale; then
echo -e "${RED}✗ Headscale 컨테이너를 찾을 수 없습니다${NC}"
return 1
fi
echo -e "${BLUE}현재 노드 목록:${NC}"
docker exec headscale headscale nodes list
echo -e "\n${YELLOW}삭제할 노드 ID를 입력하세요 (공백으로 구분, Enter로 건너뛰기):${NC}"
echo -e "${YELLOW}예: 13 14 15 16${NC}"
read -p "> " -r node_ids </dev/tty
if [ -z "$node_ids" ]; then
echo -e "${YELLOW}건너뜀${NC}"
return 0
fi
echo -e "\n${BLUE}노드 삭제 중...${NC}"
for id in $node_ids; do
if docker exec headscale headscale nodes delete --identifier "$id" --force 2>/dev/null; then
echo -e "${GREEN}✓ Node ID $id 삭제 완료${NC}"
else
echo -e "${RED}✗ Node ID $id 삭제 실패${NC}"
fi
done
echo -e "\n${GREEN}남은 노드 목록:${NC}"
docker exec headscale headscale nodes list
}
# ================================
# 4. 백업 생성
# ================================
create_backup() {
print_header "백업 생성 (선택)"
echo -e "${YELLOW}데이터베이스 백업을 생성하시겠습니까? (y/N)${NC}"
read -p "> " -r response </dev/tty
if [[ "$response" =~ ^[Yy]$ ]]; then
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
# farmq.db 백업
if [ -f "/srv/headscale-tailscale-replacement/farmq-admin/farmq.db" ]; then
cp /srv/headscale-tailscale-replacement/farmq-admin/farmq.db \
/srv/headscale-tailscale-replacement/farmq-admin/farmq.db.backup_$TIMESTAMP
echo -e "${GREEN}✓ farmq.db 백업 생성: farmq.db.backup_$TIMESTAMP${NC}"
fi
# gateway.db 백업
if [ -f "/srv/pharmq-gateway/gateway.db" ]; then
cp /srv/pharmq-gateway/gateway.db \
/srv/pharmq-gateway/gateway.db.backup_$TIMESTAMP
echo -e "${GREEN}✓ gateway.db 백업 생성: gateway.db.backup_$TIMESTAMP${NC}"
fi
else
echo -e "${YELLOW}백업 건너뜀${NC}"
fi
}
# ================================
# 메인 함수
# ================================
main() {
print_header "팜큐(FARMQ) 테스트 데이터 정리"
echo -e "${CYAN}이 스크립트는 다음 작업을 수행합니다:${NC}"
echo -e " 1. farmq.db에서 P0003 이후 약국 삭제"
echo -e " 2. gateway.db에서 ID 5 이후 사용자 삭제"
echo -e " 3. Headscale 테스트 노드 삭제 (선택)"
echo ""
echo -e "${YELLOW}⚠ 주의: 운영 데이터는 삭제되지 않습니다${NC}"
echo -e " - 보호되는 약국: P001, P002, P0002"
echo -e " - 보호되는 사용자: ID 1~4"
echo ""
read -p "계속하시겠습니까? (y/N) " -r response </dev/tty
if [[ ! "$response" =~ ^[Yy]$ ]]; then
echo -e "${YELLOW}취소되었습니다${NC}"
exit 0
fi
# 백업 생성 (선택)
create_backup
echo ""
# 1. farmq.db 정리
cleanup_farmq_db
echo ""
# 2. gateway.db 정리
cleanup_gateway_db
echo ""
# 3. Headscale 노드 정리
cleanup_headscale_nodes
echo ""
print_header "정리 완료!"
echo -e "${GREEN}모든 작업이 완료되었습니다.${NC}"
}
# 스크립트 실행
main "$@"

128
code-server.sh Executable file
View File

@@ -0,0 +1,128 @@
#!/usr/bin/env bash
#
# setup-code-server.sh
# - code-server 미설치 시 자동 설치
# - 최초 1회 실행해 ~/.config/code-server/config.yaml 생성
# - config.yaml을 0.0.0.0:<PORT> + 지정 비밀번호로 갱신
# - 기존에 떠있는 code-server(수동/비-systemd) 프로세스 정리
# - systemd 미사용: nohup으로 백그라운드 실행
#
# 환경변수:
# PORT=8080 # 바인드 포트 (기본 8080)
# PASSWORD= # 비밀번호(무인 실행용)
# SKIP_CONFIRM=0/1 # 비밀번호 확인 입력 생략
#
set -euo pipefail
PORT="${PORT:-8080}"
CONFIG_DIR="${HOME}/.config/code-server"
CONFIG_FILE="${CONFIG_DIR}/config.yaml"
LOG_FILE="${HOME}/code-server.log"
say() { echo -e "$@"; }
die() { echo -e "$@" >&2; exit 1; }
# 0) 필수 도구 준비 (curl/timeout/pgrep 등)
if ! command -v curl >/dev/null 2>&1 || ! command -v timeout >/dev/null 2>&1; then
say "📦 필요 패키지 설치 중 (curl, coreutils, procps 등)..."
if command -v apt >/dev/null 2>&1; then
apt update -y >/dev/null 2>&1 || true
apt install -y curl ca-certificates coreutils procps >/dev/null 2>&1
else
die "apt 환경이 아닙니다. curl/timeout/pgrep가 필요합니다."
fi
fi
# 1) code-server 설치 확인 및 자동 설치
if ! command -v code-server >/dev/null 2>&1; then
say "📦 code-server 미설치 상태 → 설치 진행..."
bash <(curl -fsSL https://code-server.dev/install.sh)
command -v code-server >/dev/null 2>&1 || die "code-server 설치 실패"
say "✅ code-server 설치 완료"
else
say "✅ code-server 이미 설치됨"
fi
# 2) config.yaml 생성 (없으면 최초 1회 3~5초 실행)
if [ ! -f "${CONFIG_FILE}" ]; then
say "📝 config.yaml 이 없어 최초 1회 실행으로 생성합니다..."
mkdir -p "${CONFIG_DIR}"
timeout 5s code-server >/dev/null 2>&1 || true
[ -f "${CONFIG_FILE}" ] || die "config.yaml 생성 실패"
say "✅ 기본 config.yaml 생성됨: ${CONFIG_FILE}"
else
say " 기존 config.yaml 감지: ${CONFIG_FILE}"
fi
# 3) 비밀번호 입력/확정
if [ "${PASSWORD-}" = "" ]; then
read -rsp "🔐 code-server 접속 비밀번호 입력: " PASS; echo
if [ "${SKIP_CONFIRM-0}" != "1" ]; then
read -rsp "🔐 비밀번호 확인 입력: " PASS2; echo
[ "$PASS" = "$PASS2" ] || die "비밀번호 불일치"
fi
else
PASS="$PASSWORD"
fi
[ -n "$PASS" ] || die "비밀번호는 비어 있을 수 없습니다."
# 4) 기존 파일 백업 후 config.yaml 갱신
ts="$(date +%Y%m%d%H%M%S)"
if [ -f "${CONFIG_FILE}" ]; then
cp -a "${CONFIG_FILE}" "${CONFIG_FILE}.bak.${ts}"
say "🗂 백업 생성: ${CONFIG_FILE}.bak.${ts}"
fi
cat > "${CONFIG_FILE}" <<EOF
bind-addr: 0.0.0.0:${PORT}
auth: password
password: ${PASS}
cert: false
EOF
say "✅ config.yaml 갱신됨 (bind-addr=0.0.0.0:${PORT})"
# 5) systemd로 떠있다면 중지(원하시는 게 '비-systemd' 운영이므로)
if command -v systemctl >/dev/null 2>&1; then
if systemctl is-active --quiet "code-server@${USER}"; then
say "⏹ systemd 서비스(code-server@${USER}) 중지"
systemctl stop "code-server@${USER}" || true
fi
if [ "$EUID" -eq 0 ] && systemctl is-active --quiet "code-server@root"; then
say "⏹ systemd 서비스(code-server@root}) 중지"
systemctl stop "code-server@root" || true
fi
fi
# 6) 수동/기존 실행 프로세스 정리 (부모/자식 순서 종료)
say "🧹 기존 code-server 수동 프로세스 정리..."
# 부모 엔트리(메인/entry) TERM
pids="$(pgrep -f "/usr/lib/code-server/lib/node /usr/lib/code-server($|/out/node/entry)" || true)"
if [ -n "${pids}" ]; then
for p in $pids; do
pkill -TERM -P "$p" 2>/dev/null || true
kill -TERM "$p" 2>/dev/null || true
done
sleep 2
fi
# 남아있으면 KILL
pids="$(pgrep -f "/usr/lib/code-server/lib/node /usr/lib/code-server($|/out/node/entry)" || true)"
[ -n "${pids}" ] && kill -9 $pids 2>/dev/null || true
# 보조 호스트/터미널 프로세스 잔여물 정리(있어도 없어도 무방)
pkill -f "vscode/out/bootstrap-fork --type=ptyHost" 2>/dev/null || true
pkill -f "vscode/out/bootstrap-fork --type=extensionHost" 2>/dev/null || true
pkill -f "shellIntegration-bash.sh" 2>/dev/null || true
# 7) 비-systemd 백그라운드 실행
say "🚀 code-server 일반 실행(nohup 백그라운드) 시작..."
nohup code-server > "${LOG_FILE}" 2>&1 &
pid=$!
disown || true
sleep 1
say "✅ 실행됨 (PID: ${pid})"
say "📄 로그 보기: tail -f ${LOG_FILE}"
say "🌐 접속 URL: http://<서버IP>:${PORT}"
say "🔑 비밀번호: (방금 설정한 값)"
say "🔒 보안 권장: 역프록시(Caddy/Nginx) + HTTPS 사용 시 config는 127.0.0.1로 바꾸세요."

758
headscale-auto-register.sh Executable file
View File

@@ -0,0 +1,758 @@
#!/bin/bash
# 팜큐(FARMQ) Headscale 원클릭 설치 및 등록 스크립트
# 사용법: curl -fsSL https://git.0bin.in/.../quick-install.sh | sudo bash
# 또는: wget -qO- https://git.0bin.in/.../quick-install.sh | sudo bash
# root 계정: curl -fsSL https://git.0bin.in/.../quick-install.sh | bash
# 강제 재등록: curl -fsSL https://git.0bin.in/.../quick-install.sh | bash -s -- --force
set -e
# ================================
# 설정 (필요시 수정)
# ================================
HEADSCALE_SERVER="http://head.pharmq.kr" # Headscale 서버 주소
PREAUTH_KEY="b46923995afeaec90e588168f2e1bf99801775e8657ce003" # 7일간 재사용 가능한 키
FARMQ_NETWORK="100.64.0.0/10" # 팜큐 네트워크 대역
# 명령행 옵션 처리
FORCE_REGISTER=false
for arg in "$@"; do
case $arg in
--force|-f)
FORCE_REGISTER=true
shift
;;
--help|-h)
echo "사용법: $0 [옵션]"
echo "옵션:"
echo " --force, -f 기존 연결을 강제로 해제하고 재등록"
echo " --help, -h 도움말 표시"
exit 0
;;
*)
# 알 수 없는 옵션 무시
;;
esac
done
# ================================
# 색상 출력 함수
# ================================
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
PURPLE='\033[0;35m'
CYAN='\033[0;36m'
WHITE='\033[1;37m'
NC='\033[0m' # No Color
print_header() {
echo -e "\n${PURPLE}============================================${NC}"
echo -e "${WHITE}$1${NC}"
echo -e "${PURPLE}============================================${NC}\n"
}
print_status() {
echo -e "\n${BLUE}🔧 $1${NC}"
}
print_success() {
echo -e "\n${GREEN}$1${NC}"
}
print_error() {
echo -e "\n${RED}$1${NC}"
}
print_info() {
echo -e "\n${CYAN}📋 $1${NC}"
}
print_warning() {
echo -e "\n${YELLOW}⚠️ $1${NC}"
}
# ================================
# 운영체제 감지
# ================================
detect_os() {
if [ -f /etc/os-release ]; then
. /etc/os-release
OS=$ID
VERSION=$VERSION_ID
CODENAME=$VERSION_CODENAME
else
print_error "지원하지 않는 운영체제입니다."
exit 1
fi
print_info "감지된 OS: $OS $VERSION ($CODENAME)"
}
# ================================
# 시스템 요구사항 확인
# ================================
check_requirements() {
print_status "시스템 요구사항 확인 중..."
# Root 권한 확인
if [ "$EUID" -ne 0 ]; then
print_error "이 스크립트는 root 권한으로 실행해야 합니다."
print_info "다음 중 하나의 방법으로 다시 실행해주세요:"
print_info "1. sudo가 있는 경우: curl ... | sudo bash"
print_info "2. root 계정인 경우: curl ... | bash"
exit 1
fi
# curl 또는 wget 확인
if ! command -v curl >/dev/null 2>&1 && ! command -v wget >/dev/null 2>&1; then
print_error "curl 또는 wget이 필요합니다."
exit 1
fi
# 네트워크 연결 확인
if ! ping -c 1 8.8.8.8 >/dev/null 2>&1; then
print_warning "인터넷 연결을 확인해주세요."
fi
print_success "시스템 요구사항 확인 완료"
}
# ================================
# Tailscale 설치
# ================================
install_tailscale() {
print_status "Tailscale 클라이언트 설치 중..."
# 이미 설치되어 있는지 확인
if command -v tailscale >/dev/null 2>&1; then
print_info "Tailscale이 이미 설치되어 있습니다."
TAILSCALE_VERSION=$(tailscale version | head -n1)
print_info "현재 버전: $TAILSCALE_VERSION"
return
fi
case $OS in
ubuntu|debian)
print_info "Ubuntu/Debian용 Tailscale 설치 중..."
# GPG 키 추가
curl -fsSL https://pkgs.tailscale.com/stable/ubuntu/jammy.noarmor.gpg | tee /usr/share/keyrings/tailscale-archive-keyring.gpg >/dev/null
curl -fsSL https://pkgs.tailscale.com/stable/ubuntu/jammy.tailscale-keyring.list | tee /etc/apt/sources.list.d/tailscale.list
# 패키지 설치
apt-get update -qq
apt-get install -y tailscale
;;
centos|rhel|rocky|almalinux)
print_info "CentOS/RHEL/Rocky용 Tailscale 설치 중..."
# 리포지토리 추가
curl -fsSL https://pkgs.tailscale.com/stable/rhel/tailscale.repo | tee /etc/yum.repos.d/tailscale.repo
# 패키지 설치
if command -v dnf >/dev/null 2>&1; then
dnf install -y tailscale
else
yum install -y tailscale
fi
;;
fedora)
print_info "Fedora용 Tailscale 설치 중..."
dnf install -y tailscale
;;
arch)
print_info "Arch Linux용 Tailscale 설치 중..."
pacman -S --noconfirm tailscale
;;
*)
print_warning "지원하지 않는 배포판입니다. 수동 설치를 시도합니다."
# Universal binary 다운로드
ARCH=$(uname -m)
case $ARCH in
x86_64) TAILSCALE_ARCH="amd64" ;;
aarch64) TAILSCALE_ARCH="arm64" ;;
armv7l) TAILSCALE_ARCH="arm" ;;
*)
print_error "지원하지 않는 아키텍처: $ARCH"
exit 1
;;
esac
# 최신 버전 다운로드
TAILSCALE_VERSION=$(curl -s https://api.github.com/repos/tailscale/tailscale/releases/latest | grep '"tag_name"' | cut -d'"' -f4)
DOWNLOAD_URL="https://pkgs.tailscale.com/stable/tailscale_${TAILSCALE_VERSION#v}_linux_${TAILSCALE_ARCH}.tgz"
cd /tmp
curl -LO "$DOWNLOAD_URL"
tar xzf "tailscale_${TAILSCALE_VERSION#v}_linux_${TAILSCALE_ARCH}.tgz"
# 바이너리 복사
cp "tailscale_${TAILSCALE_VERSION#v}_linux_${TAILSCALE_ARCH}/tailscale" /usr/bin/
cp "tailscale_${TAILSCALE_VERSION#v}_linux_${TAILSCALE_ARCH}/tailscaled" /usr/sbin/
# 시스템 서비스 파일 생성
cat > /etc/systemd/system/tailscaled.service << 'EOF'
[Unit]
Description=Tailscale node agent
Documentation=https://tailscale.com/kb/
Wants=network-pre.target
After=network-pre.target NetworkManager.service systemd-resolved.service
[Service]
EnvironmentFile=/etc/default/tailscaled
ExecStart=/usr/sbin/tailscaled --state=/var/lib/tailscale/tailscaled.state --socket=/run/tailscale/tailscaled.sock --port=$PORT $FLAGS
ExecStopPost=/usr/bin/tailscale logout
Restart=on-failure
RestartSec=5
Type=notify
RuntimeDirectory=tailscale
RuntimeDirectoryMode=0755
StateDirectory=tailscale
StateDirectoryMode=0700
CacheDirectory=tailscale
CacheDirectoryMode=0750
[Install]
WantedBy=multi-user.target
EOF
# 환경 설정 파일
mkdir -p /etc/default
echo 'FLAGS=""' > /etc/default/tailscaled
echo 'PORT="41641"' >> /etc/default/tailscaled
systemctl daemon-reload
;;
esac
print_success "Tailscale 설치 완료"
# 버전 확인
TAILSCALE_VERSION=$(tailscale version | head -n1)
print_info "설치된 버전: $TAILSCALE_VERSION"
}
# ================================
# Tailscale 서비스 시작
# ================================
start_tailscale() {
print_status "Tailscale 서비스 시작 중..."
# systemd 서비스 활성화 및 시작
systemctl enable tailscaled >/dev/null 2>&1 || true
systemctl start tailscaled >/dev/null 2>&1 || true
# 서비스 상태 확인
sleep 3
if systemctl is-active --quiet tailscaled; then
print_success "Tailscaled 서비스가 실행 중입니다."
else
print_error "Tailscaled 서비스 시작에 실패했습니다."
print_info "수동으로 시작을 시도합니다..."
/usr/sbin/tailscaled --state=/var/lib/tailscale/tailscaled.state &
sleep 5
fi
}
# ================================
# Headscale 등록
# ================================
register_headscale() {
print_status "Headscale 서버에 등록 중..."
# 기존 연결 확인
if tailscale status >/dev/null 2>&1; then
print_warning "이미 Tailscale/Headscale에 연결되어 있습니다."
# 현재 연결 상태 표시
CURRENT_STATUS=$(tailscale status 2>/dev/null | head -5)
print_info "현재 연결 상태:"
echo "$CURRENT_STATUS"
# 현재 서버 확인
CURRENT_SERVER=$(tailscale status --json 2>/dev/null | grep -o '"CurrentTailnet":[^,]*' | cut -d'"' -f4 2>/dev/null || echo "알 수 없음")
TARGET_SERVER=$(echo "$HEADSCALE_SERVER" | sed 's|https\?://||' | sed 's|:[0-9]*||')
print_info "현재 서버: $CURRENT_SERVER"
print_info "대상 서버: $TARGET_SERVER"
# 강제 등록 옵션 확인
if [ "$FORCE_REGISTER" = true ]; then
print_warning "강제 재등록 옵션이 활성화되었습니다."
print_info "기존 연결을 해제하고 재등록합니다..."
tailscale logout >/dev/null 2>&1 || true
sleep 3
# 같은 서버인지 확인
elif [[ "$CURRENT_SERVER" == *"$TARGET_SERVER"* ]] || [[ "$TARGET_SERVER" == *"$CURRENT_SERVER"* ]]; then
print_success "이미 올바른 Headscale 서버에 연결되어 있습니다!"
print_info "등록을 건너뜁니다."
return 0
# 대화형 실행인지 확인 (터미널에서 직접 실행)
elif [ -t 0 ] && [ -t 1 ]; then
print_warning "다른 서버에 연결되어 있습니다."
echo -n "기존 연결을 해제하고 팜큐 Headscale로 등록하시겠습니까? (Y/n): "
read -r REPLY
# 기본값을 Y로 변경 (엔터만 누르면 Y)
if [[ -z "$REPLY" ]] || [[ $REPLY =~ ^[Yy]$ ]]; then
print_info "기존 연결을 해제합니다..."
tailscale logout >/dev/null 2>&1 || true
sleep 3
else
print_info "등록을 건너뜁니다."
return 0
fi
else
# 파이프 실행 시 자동으로 재등록 (기본값: Y)
print_warning "다른 서버에 연결되어 있어 자동으로 팜큐 Headscale로 재등록합니다."
print_info "기존 연결을 해제합니다..."
tailscale logout >/dev/null 2>&1 || true
sleep 3
fi
# 추가 확인: 완전히 로그아웃되었는지 검증
print_status "연결 해제 확인 중..."
for i in {1..10}; do
if ! tailscale status >/dev/null 2>&1; then
print_success "기존 연결이 완전히 해제되었습니다."
break
fi
print_info "로그아웃 대기 중... ($i/10)"
sleep 2
if [ $i -eq 10 ]; then
print_warning "로그아웃이 완료되지 않았지만 계속 진행합니다."
fi
done
fi
print_info "Headscale 서버: $HEADSCALE_SERVER"
print_info "Pre-auth Key: ${PREAUTH_KEY:0:8}***************"
# Headscale 등록 시도
print_status "등록 명령 실행 중..."
if tailscale up \
--login-server="$HEADSCALE_SERVER" \
--authkey="$PREAUTH_KEY" \
--accept-routes \
--accept-dns=false >/dev/null 2>&1; then
print_success "Headscale 등록 성공!"
else
print_error "자동 등록에 실패했습니다. 수동 등록을 진행합니다."
# 수동 등록 모드
print_info "다음 명령을 실행하여 수동 등록하세요:"
echo ""
echo "tailscale up --login-server=\"$HEADSCALE_SERVER\" --authkey=\"$PREAUTH_KEY\" --accept-routes --accept-dns=false"
echo ""
# 등록 URL 시도
REGISTER_URL=$(tailscale up --login-server="$HEADSCALE_SERVER" 2>&1 | grep -o 'https://[^[:space:]]*' | head -1)
if [ -n "$REGISTER_URL" ]; then
print_info "또는 다음 URL을 방문하여 등록하세요:"
echo "$REGISTER_URL"
fi
return 1
fi
}
# ================================
# 연결 상태 확인
# ================================
verify_connection() {
print_status "연결 상태 확인 중..."
# 잠시 대기 (연결 안정화)
sleep 5
# Tailscale 상태 확인
if ! tailscale status >/dev/null 2>&1; then
print_error "Tailscale 연결에 문제가 있습니다."
return 1
fi
# IP 주소 확인
TAILSCALE_IP=$(tailscale ip -4 2>/dev/null || echo "N/A")
TAILSCALE_IP6=$(tailscale ip -6 2>/dev/null || echo "N/A")
print_success "Headscale 네트워크 연결 완료!"
print_info "할당된 IPv4: $TAILSCALE_IP"
print_info "할당된 IPv6: $TAILSCALE_IP6"
# 네트워크 테스트
print_status "네트워크 연결 테스트 중..."
if ping -c 3 -W 5 100.64.0.1 >/dev/null 2>&1; then
print_success "팜큐 네트워크($FARMQ_NETWORK) 연결 정상!"
else
print_warning "네트워크 테스트 실패. 방화벽을 확인해주세요."
fi
# 연결된 노드 확인
print_info "네트워크 상태:"
tailscale status | head -10
}
# ================================
# 방화벽 설정 (선택사항)
# ================================
configure_firewall() {
print_status "방화벽 설정 확인 중..."
# UFW (Ubuntu/Debian)
if command -v ufw >/dev/null 2>&1; then
print_info "UFW 방화벽 감지됨"
if ufw status | grep -q "Status: active"; then
print_info "Tailscale 트래픽 허용 중..."
ufw allow in on tailscale0 >/dev/null 2>&1 || true
ufw allow 41641/udp comment "Tailscale" >/dev/null 2>&1 || true
fi
fi
# firewalld (CentOS/RHEL/Fedora)
if command -v firewall-cmd >/dev/null 2>&1; then
print_info "firewalld 방화벽 감지됨"
if firewall-cmd --state >/dev/null 2>&1; then
print_info "Tailscale 트래픽 허용 중..."
firewall-cmd --permanent --add-service=tailscale >/dev/null 2>&1 || true
firewall-cmd --permanent --add-port=41641/udp >/dev/null 2>&1 || true
firewall-cmd --reload >/dev/null 2>&1 || true
fi
fi
print_success "방화벽 설정 완료"
}
# ================================
# 정리 작업
# ================================
cleanup() {
print_status "정리 작업 수행 중..."
# 임시 파일 정리
rm -rf /tmp/tailscale_* >/dev/null 2>&1 || true
# 시스템 정보 업데이트
if command -v updatedb >/dev/null 2>&1; then
updatedb >/dev/null 2>&1 &
fi
print_success "정리 작업 완료"
}
# ================================
# 최종 정보 출력
# ================================
show_final_info() {
print_header "팜큐 Headscale 설치 완료!"
# 시스템 정보
HOSTNAME=$(hostname)
TAILSCALE_IP=$(tailscale ip -4 2>/dev/null || echo "N/A")
echo -e "${GREEN}🎉 설치가 성공적으로 완료되었습니다!${NC}\n"
echo -e "${CYAN}📋 시스템 정보:${NC}"
echo -e " 호스트명: $HOSTNAME"
echo -e " Tailscale IP: $TAILSCALE_IP"
echo -e " OS: $OS $VERSION"
echo -e " Headscale 서버: $HEADSCALE_SERVER"
echo -e "\n${YELLOW}🔧 유용한 명령어:${NC}"
echo -e " tailscale status # 연결 상태 확인"
echo -e " tailscale ip # 할당된 IP 확인"
echo -e " tailscale ping <node> # 다른 노드와 연결 테스트"
echo -e " tailscale logout # 네트워크에서 해제"
echo -e "\n${PURPLE}🌐 팜큐 관리자 페이지:${NC}"
echo -e " http://192.168.0.151:5002"
echo -e " http://192.168.0.151:5002/vms (VM 관리)"
echo -e "\n${WHITE}문제가 있을 경우 로그를 확인하세요:${NC}"
echo -e " journalctl -u tailscaled -f"
print_header "설치 완료 - 팜큐 네트워크를 사용할 수 있습니다!"
}
# ================================
# 약국 자동 등록 함수들
# ================================
# 장비 타입 및 정보 자동 감지
detect_device_info() {
echo -e "${BLUE}장비 정보 감지 중...${NC}"
# hostname 수집
DEVICE_HOSTNAME=$(hostname)
# Proxmox 호스트 감지
if [ -f /etc/pve/local/pve-ssl.pem ] || command -v pveversion >/dev/null 2>&1; then
DEVICE_TYPE="proxmox_host"
DEVICE_NAME="${PHARMACY_NAME} Proxmox Host"
echo -e "${GREEN}✓ Proxmox VE 호스트 감지됨${NC}"
# Windows (WSL 환경)
elif grep -qi microsoft /proc/version 2>/dev/null; then
DEVICE_TYPE="windows_pc"
DEVICE_NAME="${PHARMACY_NAME} Windows PC"
echo -e "${GREEN}✓ Windows PC (WSL) 감지됨${NC}"
# macOS
elif [ "$(uname -s)" = "Darwin" ]; then
DEVICE_TYPE="mac"
DEVICE_NAME="${PHARMACY_NAME} Mac"
echo -e "${GREEN}✓ macOS 감지됨${NC}"
# Linux 서버 (일반)
else
DEVICE_TYPE="linux_server"
DEVICE_NAME="${PHARMACY_NAME} Linux Server"
echo -e "${GREEN}✓ Linux 서버 감지됨${NC}"
fi
echo -e "${CYAN} 장비명: $DEVICE_NAME${NC}"
echo -e "${CYAN} 장비 타입: $DEVICE_TYPE${NC}"
echo -e "${CYAN} 호스트명: $DEVICE_HOSTNAME${NC}"
}
# 약국 정보 수집
collect_pharmacy_info() {
echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e "${WHITE}약국 정보 입력${NC}"
echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
# 약국명 입력 (필수)
while [ -z "$PHARMACY_NAME" ]; do
read -p "약국명을 입력하세요: " PHARMACY_NAME </dev/tty
done
# 요양기관부호 입력 (선택)
read -p "요양기관부호 (선택, Enter로 건너뛰기): " HIRA_CODE </dev/tty
# 약국 주소 입력 (선택)
read -p "약국 주소 (선택): " PHARMACY_ADDRESS </dev/tty
# 약국장 이름 입력 (선택)
read -p "약국장 이름 (선택): " OWNER_NAME </dev/tty
# 연락처 입력 (선택)
read -p "약국 연락처 (선택): " PHARMACY_PHONE </dev/tty
echo -e "${GREEN}✓ 약국 정보 입력 완료${NC}"
}
# VPN IP 확인
get_assigned_vpn_ip() {
echo -e "${BLUE}VPN IP 확인 중...${NC}"
# tailscale ip -4로 IP 추출 (최대 10초 대기)
for i in {1..10}; do
VPN_IP=$(tailscale ip -4 2>/dev/null)
if [ -n "$VPN_IP" ]; then
echo -e "${GREEN}✓ VPN IP: $VPN_IP${NC}"
return 0
fi
echo -e "${YELLOW}⏳ VPN IP 할당 대기 중... ($i/10)${NC}"
sleep 1
done
echo -e "${RED}✗ VPN IP를 가져올 수 없습니다${NC}"
return 1
}
# farmq-admin API 호출하여 약국 생성
create_pharmacy_via_api() {
echo -e "${BLUE}약국 등록 중 (farmq.db)...${NC}"
# JSON 데이터 구성 (장비 정보 포함)
JSON_DATA=$(cat <<EOF
{
"pharmacy_name": "$PHARMACY_NAME",
"vpn_ip": "$VPN_IP",
"hira_code": "$HIRA_CODE",
"address": "$PHARMACY_ADDRESS",
"owner_name": "$OWNER_NAME",
"phone": "$PHARMACY_PHONE",
"api_port": 8082,
"device_type": "$DEVICE_TYPE",
"device_name": "$DEVICE_NAME",
"hostname": "$DEVICE_HOSTNAME"
}
EOF
)
# API 호출 (외부 도메인)
RESPONSE=$(curl -s -X POST https://demo.pharmq.kr/api/pharmacy \
-H "Content-Type: application/json" \
-d "$JSON_DATA")
# 성공 여부 먼저 확인 (공백/줄바꿈 허용)
if ! echo "$RESPONSE" | grep -q '"success"' || ! echo "$RESPONSE" | grep -q 'true'; then
echo -e "${RED}✗ 약국 생성 API 실패${NC}"
echo -e "${YELLOW}[응답 내용]${NC}"
echo "$RESPONSE"
return 1
fi
# pharmacy_code 추출 (순수 bash - grep 사용)
# 방법: grep으로 pharmacy_code 라인 찾고, sed로 값 추출
PHARMACY_CODE=$(echo "$RESPONSE" | grep '"pharmacy_code"' | sed 's/.*"pharmacy_code":[[:space:]]*"\([^"]*\)".*/\1/' | head -1)
# 추출 실패 시 디버깅 정보 출력
if [ -z "$PHARMACY_CODE" ]; then
echo -e "${RED}✗ pharmacy_code 추출 실패${NC}"
echo -e "${YELLOW}[원인] JSON 파싱 에러${NC}"
echo -e "${YELLOW}[응답 내용]${NC}"
echo "$RESPONSE"
echo -e "${YELLOW}[추출 시도 결과]${NC}"
echo "$RESPONSE" | grep '"pharmacy_code"'
return 1
fi
echo -e "${GREEN}✓ 약국 생성 완료: $PHARMACY_CODE${NC}"
# 장비 등록 여부 확인
if echo "$RESPONSE" | grep -q '"device_registered"' && echo "$RESPONSE" | grep -q 'true'; then
echo -e "${GREEN}✓ 장비 등록 완료: $DEVICE_TYPE${NC}"
else
echo -e "${YELLOW}⚠️ 장비 등록 실패 (약국은 정상 생성됨)${NC}"
fi
return 0
}
# gateway API 호출하여 관리자 계정 생성
create_gateway_user_via_api() {
echo -e "${BLUE}관리자 계정 생성 중 (gateway.db)...${NC}"
# username: pharmacy_code 소문자 (P0005 → p0005)
USERNAME=$(echo "$PHARMACY_CODE" | tr '[:upper:]' '[:lower:]')
PASSWORD="12341234" # 기본 비밀번호 (최소 8자)
EMAIL="${USERNAME}@pharmq.internal"
# JSON 데이터 구성
JSON_DATA=$(cat <<EOF
{
"username": "$USERNAME",
"email": "$EMAIL",
"password": "$PASSWORD",
"name": "${PHARMACY_NAME} 관리자",
"phone": "$PHARMACY_PHONE",
"primary_pharmacy_code": "$PHARMACY_CODE",
"role": "admin"
}
EOF
)
# 디버깅: 전송할 데이터 출력
echo -e "${CYAN}[DEBUG] 전송 데이터:${NC}"
echo "$JSON_DATA"
# API 호출 (외부 도메인)
RESPONSE=$(curl -s -X POST https://gateway.pharmq.kr/api/auth/register \
-H "Content-Type: application/json" \
-d "$JSON_DATA")
# 디버깅: 응답 출력
echo -e "${CYAN}[DEBUG] API 응답:${NC}"
echo "$RESPONSE"
# 성공 여부 확인
if echo "$RESPONSE" | grep -q '"success":true'; then
echo -e "${GREEN}✓ 관리자 계정 생성 완료${NC}"
return 0
else
echo -e "${RED}✗ 관리자 계정 생성 실패${NC}"
return 1
fi
}
# 로그인 정보 출력
display_login_credentials() {
USERNAME=$(echo "$PHARMACY_CODE" | tr '[:upper:]' '[:lower:]')
echo -e "\n${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e "${WHITE}🎉 설치 및 등록 완료!${NC}"
echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e "\n${GREEN}=== 결과 요약 ===${NC}"
echo -e "약국 코드: ${WHITE}$PHARMACY_CODE${NC}"
echo -e "약국명: ${WHITE}$PHARMACY_NAME${NC}"
echo -e "생성된 계정: ${WHITE}$USERNAME${NC}"
echo -e "로그인 URL: ${WHITE}https://pharmq.kr${NC}"
echo -e "비밀번호: ${WHITE}12341234${NC}"
echo -e "\n${GREEN}VPN 정보:${NC}"
echo -e " VPN IP: ${WHITE}$VPN_IP${NC}"
[ -n "$HIRA_CODE" ] && echo -e " 요양기관부호: ${WHITE}$HIRA_CODE${NC}"
[ -n "$PHARMACY_ADDRESS" ] && echo -e " 주소: ${WHITE}$PHARMACY_ADDRESS${NC}"
[ -n "$PHARMACY_PHONE" ] && echo -e " 전화번호: ${WHITE}$PHARMACY_PHONE${NC}"
echo -e "\n${GREEN}장비 정보:${NC}"
echo -e " 장비명: ${WHITE}$DEVICE_NAME${NC}"
echo -e " 장비 타입: ${WHITE}$DEVICE_TYPE${NC}"
echo -e " 호스트명: ${WHITE}$DEVICE_HOSTNAME${NC}"
echo -e "\n${YELLOW}⚠ 최초 로그인 후 비밀번호를 변경하세요!${NC}"
echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
}
# ================================
# 메인 함수
# ================================
main() {
print_header "팜큐(FARMQ) Headscale 원클릭 설치 및 약국 등록"
# 사전 체크
detect_os
check_requirements
# 설치 과정
install_tailscale
start_tailscale
register_headscale
# VPN IP 확인 (Headscale 등록 직후)
sleep 3 # Headscale에서 IP 할당 대기
get_assigned_vpn_ip || exit 1
# 약국 정보 수집
collect_pharmacy_info
# 장비 정보 자동 감지
detect_device_info
# 약국 및 계정 생성 (장비 등록 포함)
create_pharmacy_via_api || exit 1
create_gateway_user_via_api || exit 1
# 사후 설정
configure_firewall
verify_connection
# 정리 및 완료
cleanup
display_login_credentials
}
# ================================
# 에러 핸들링
# ================================
trap 'echo -e "\n❌ 설치 중 오류가 발생했습니다. 로그를 확인해주세요."; exit 1' ERR
# 스크립트 실행
main "$@"

519
headscale-quick-install.sh Executable file
View File

@@ -0,0 +1,519 @@
#!/bin/bash
# 팜큐(FARMQ) Headscale 원클릭 설치 및 등록 스크립트
# 사용법: curl -fsSL https://git.0bin.in/.../quick-install.sh | sudo bash
# 또는: wget -qO- https://git.0bin.in/.../quick-install.sh | sudo bash
# root 계정: curl -fsSL https://git.0bin.in/.../quick-install.sh | bash
# 강제 재등록: curl -fsSL https://git.0bin.in/.../quick-install.sh | bash -s -- --force
set -e
# ================================
# 설정 (필요시 수정)
# ================================
HEADSCALE_SERVER="http://head.pharmq.kr" # Headscale 서버 주소
PREAUTH_KEY="b46923995afeaec90e588168f2e1bf99801775e8657ce003" # 7일간 재사용 가능한 키
FARMQ_NETWORK="100.64.0.0/10" # 팜큐 네트워크 대역
# 명령행 옵션 처리
FORCE_REGISTER=false
for arg in "$@"; do
case $arg in
--force|-f)
FORCE_REGISTER=true
shift
;;
--help|-h)
echo "사용법: $0 [옵션]"
echo "옵션:"
echo " --force, -f 기존 연결을 강제로 해제하고 재등록"
echo " --help, -h 도움말 표시"
exit 0
;;
*)
# 알 수 없는 옵션 무시
;;
esac
done
# ================================
# 색상 출력 함수
# ================================
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
PURPLE='\033[0;35m'
CYAN='\033[0;36m'
WHITE='\033[1;37m'
NC='\033[0m' # No Color
print_header() {
echo -e "\n${PURPLE}============================================${NC}"
echo -e "${WHITE}$1${NC}"
echo -e "${PURPLE}============================================${NC}\n"
}
print_status() {
echo -e "\n${BLUE}🔧 $1${NC}"
}
print_success() {
echo -e "\n${GREEN}$1${NC}"
}
print_error() {
echo -e "\n${RED}$1${NC}"
}
print_info() {
echo -e "\n${CYAN}📋 $1${NC}"
}
print_warning() {
echo -e "\n${YELLOW}⚠️ $1${NC}"
}
# ================================
# 운영체제 감지
# ================================
detect_os() {
if [ -f /etc/os-release ]; then
. /etc/os-release
OS=$ID
VERSION=$VERSION_ID
CODENAME=$VERSION_CODENAME
else
print_error "지원하지 않는 운영체제입니다."
exit 1
fi
print_info "감지된 OS: $OS $VERSION ($CODENAME)"
}
# ================================
# 시스템 요구사항 확인
# ================================
check_requirements() {
print_status "시스템 요구사항 확인 중..."
# Root 권한 확인
if [ "$EUID" -ne 0 ]; then
print_error "이 스크립트는 root 권한으로 실행해야 합니다."
print_info "다음 중 하나의 방법으로 다시 실행해주세요:"
print_info "1. sudo가 있는 경우: curl ... | sudo bash"
print_info "2. root 계정인 경우: curl ... | bash"
exit 1
fi
# curl 또는 wget 확인
if ! command -v curl >/dev/null 2>&1 && ! command -v wget >/dev/null 2>&1; then
print_error "curl 또는 wget이 필요합니다."
exit 1
fi
# 네트워크 연결 확인
if ! ping -c 1 8.8.8.8 >/dev/null 2>&1; then
print_warning "인터넷 연결을 확인해주세요."
fi
print_success "시스템 요구사항 확인 완료"
}
# ================================
# Tailscale 설치
# ================================
install_tailscale() {
print_status "Tailscale 클라이언트 설치 중..."
# 이미 설치되어 있는지 확인
if command -v tailscale >/dev/null 2>&1; then
print_info "Tailscale이 이미 설치되어 있습니다."
TAILSCALE_VERSION=$(tailscale version | head -n1)
print_info "현재 버전: $TAILSCALE_VERSION"
return
fi
case $OS in
ubuntu|debian)
print_info "Ubuntu/Debian용 Tailscale 설치 중..."
# GPG 키 추가
curl -fsSL https://pkgs.tailscale.com/stable/ubuntu/jammy.noarmor.gpg | tee /usr/share/keyrings/tailscale-archive-keyring.gpg >/dev/null
curl -fsSL https://pkgs.tailscale.com/stable/ubuntu/jammy.tailscale-keyring.list | tee /etc/apt/sources.list.d/tailscale.list
# 패키지 설치
apt-get update -qq
apt-get install -y tailscale
;;
centos|rhel|rocky|almalinux)
print_info "CentOS/RHEL/Rocky용 Tailscale 설치 중..."
# 리포지토리 추가
curl -fsSL https://pkgs.tailscale.com/stable/rhel/tailscale.repo | tee /etc/yum.repos.d/tailscale.repo
# 패키지 설치
if command -v dnf >/dev/null 2>&1; then
dnf install -y tailscale
else
yum install -y tailscale
fi
;;
fedora)
print_info "Fedora용 Tailscale 설치 중..."
dnf install -y tailscale
;;
arch)
print_info "Arch Linux용 Tailscale 설치 중..."
pacman -S --noconfirm tailscale
;;
*)
print_warning "지원하지 않는 배포판입니다. 수동 설치를 시도합니다."
# Universal binary 다운로드
ARCH=$(uname -m)
case $ARCH in
x86_64) TAILSCALE_ARCH="amd64" ;;
aarch64) TAILSCALE_ARCH="arm64" ;;
armv7l) TAILSCALE_ARCH="arm" ;;
*)
print_error "지원하지 않는 아키텍처: $ARCH"
exit 1
;;
esac
# 최신 버전 다운로드
TAILSCALE_VERSION=$(curl -s https://api.github.com/repos/tailscale/tailscale/releases/latest | grep '"tag_name"' | cut -d'"' -f4)
DOWNLOAD_URL="https://pkgs.tailscale.com/stable/tailscale_${TAILSCALE_VERSION#v}_linux_${TAILSCALE_ARCH}.tgz"
cd /tmp
curl -LO "$DOWNLOAD_URL"
tar xzf "tailscale_${TAILSCALE_VERSION#v}_linux_${TAILSCALE_ARCH}.tgz"
# 바이너리 복사
cp "tailscale_${TAILSCALE_VERSION#v}_linux_${TAILSCALE_ARCH}/tailscale" /usr/bin/
cp "tailscale_${TAILSCALE_VERSION#v}_linux_${TAILSCALE_ARCH}/tailscaled" /usr/sbin/
# 시스템 서비스 파일 생성
cat > /etc/systemd/system/tailscaled.service << 'EOF'
[Unit]
Description=Tailscale node agent
Documentation=https://tailscale.com/kb/
Wants=network-pre.target
After=network-pre.target NetworkManager.service systemd-resolved.service
[Service]
EnvironmentFile=/etc/default/tailscaled
ExecStart=/usr/sbin/tailscaled --state=/var/lib/tailscale/tailscaled.state --socket=/run/tailscale/tailscaled.sock --port=$PORT $FLAGS
ExecStopPost=/usr/bin/tailscale logout
Restart=on-failure
RestartSec=5
Type=notify
RuntimeDirectory=tailscale
RuntimeDirectoryMode=0755
StateDirectory=tailscale
StateDirectoryMode=0700
CacheDirectory=tailscale
CacheDirectoryMode=0750
[Install]
WantedBy=multi-user.target
EOF
# 환경 설정 파일
mkdir -p /etc/default
echo 'FLAGS=""' > /etc/default/tailscaled
echo 'PORT="41641"' >> /etc/default/tailscaled
systemctl daemon-reload
;;
esac
print_success "Tailscale 설치 완료"
# 버전 확인
TAILSCALE_VERSION=$(tailscale version | head -n1)
print_info "설치된 버전: $TAILSCALE_VERSION"
}
# ================================
# Tailscale 서비스 시작
# ================================
start_tailscale() {
print_status "Tailscale 서비스 시작 중..."
# systemd 서비스 활성화 및 시작
systemctl enable tailscaled >/dev/null 2>&1 || true
systemctl start tailscaled >/dev/null 2>&1 || true
# 서비스 상태 확인
sleep 3
if systemctl is-active --quiet tailscaled; then
print_success "Tailscaled 서비스가 실행 중입니다."
else
print_error "Tailscaled 서비스 시작에 실패했습니다."
print_info "수동으로 시작을 시도합니다..."
/usr/sbin/tailscaled --state=/var/lib/tailscale/tailscaled.state &
sleep 5
fi
}
# ================================
# Headscale 등록
# ================================
register_headscale() {
print_status "Headscale 서버에 등록 중..."
# 기존 연결 확인
if tailscale status >/dev/null 2>&1; then
print_warning "이미 Tailscale/Headscale에 연결되어 있습니다."
# 현재 연결 상태 표시
CURRENT_STATUS=$(tailscale status 2>/dev/null | head -5)
print_info "현재 연결 상태:"
echo "$CURRENT_STATUS"
# 현재 서버 확인
CURRENT_SERVER=$(tailscale status --json 2>/dev/null | grep -o '"CurrentTailnet":[^,]*' | cut -d'"' -f4 2>/dev/null || echo "알 수 없음")
TARGET_SERVER=$(echo "$HEADSCALE_SERVER" | sed 's|https\?://||' | sed 's|:[0-9]*||')
print_info "현재 서버: $CURRENT_SERVER"
print_info "대상 서버: $TARGET_SERVER"
# 강제 등록 옵션 확인
if [ "$FORCE_REGISTER" = true ]; then
print_warning "강제 재등록 옵션이 활성화되었습니다."
print_info "기존 연결을 해제하고 재등록합니다..."
tailscale logout >/dev/null 2>&1 || true
sleep 3
# 같은 서버인지 확인
elif [[ "$CURRENT_SERVER" == *"$TARGET_SERVER"* ]] || [[ "$TARGET_SERVER" == *"$CURRENT_SERVER"* ]]; then
print_success "이미 올바른 Headscale 서버에 연결되어 있습니다!"
print_info "등록을 건너뜁니다."
return 0
# 대화형 실행인지 확인 (터미널에서 직접 실행)
elif [ -t 0 ] && [ -t 1 ]; then
print_warning "다른 서버에 연결되어 있습니다."
echo -n "기존 연결을 해제하고 팜큐 Headscale로 등록하시겠습니까? (Y/n): "
read -r REPLY
# 기본값을 Y로 변경 (엔터만 누르면 Y)
if [[ -z "$REPLY" ]] || [[ $REPLY =~ ^[Yy]$ ]]; then
print_info "기존 연결을 해제합니다..."
tailscale logout >/dev/null 2>&1 || true
sleep 3
else
print_info "등록을 건너뜁니다."
return 0
fi
else
# 파이프 실행 시 자동으로 재등록 (기본값: Y)
print_warning "다른 서버에 연결되어 있어 자동으로 팜큐 Headscale로 재등록합니다."
print_info "기존 연결을 해제합니다..."
tailscale logout >/dev/null 2>&1 || true
sleep 3
fi
# 추가 확인: 완전히 로그아웃되었는지 검증
print_status "연결 해제 확인 중..."
for i in {1..10}; do
if ! tailscale status >/dev/null 2>&1; then
print_success "기존 연결이 완전히 해제되었습니다."
break
fi
print_info "로그아웃 대기 중... ($i/10)"
sleep 2
if [ $i -eq 10 ]; then
print_warning "로그아웃이 완료되지 않았지만 계속 진행합니다."
fi
done
fi
print_info "Headscale 서버: $HEADSCALE_SERVER"
print_info "Pre-auth Key: ${PREAUTH_KEY:0:8}***************"
# Headscale 등록 시도
print_status "등록 명령 실행 중..."
if tailscale up \
--login-server="$HEADSCALE_SERVER" \
--authkey="$PREAUTH_KEY" \
--accept-routes \
--accept-dns=true >/dev/null 2>&1; then
print_success "Headscale 등록 성공!"
else
print_error "자동 등록에 실패했습니다. 수동 등록을 진행합니다."
# 수동 등록 모드
print_info "다음 명령을 실행하여 수동 등록하세요:"
echo ""
echo "tailscale up --login-server=\"$HEADSCALE_SERVER\" --authkey=\"$PREAUTH_KEY\" --accept-routes --accept-dns=true"
echo ""
# 등록 URL 시도
REGISTER_URL=$(tailscale up --login-server="$HEADSCALE_SERVER" 2>&1 | grep -o 'https://[^[:space:]]*' | head -1)
if [ -n "$REGISTER_URL" ]; then
print_info "또는 다음 URL을 방문하여 등록하세요:"
echo "$REGISTER_URL"
fi
return 1
fi
}
# ================================
# 연결 상태 확인
# ================================
verify_connection() {
print_status "연결 상태 확인 중..."
# 잠시 대기 (연결 안정화)
sleep 5
# Tailscale 상태 확인
if ! tailscale status >/dev/null 2>&1; then
print_error "Tailscale 연결에 문제가 있습니다."
return 1
fi
# IP 주소 확인
TAILSCALE_IP=$(tailscale ip -4 2>/dev/null || echo "N/A")
TAILSCALE_IP6=$(tailscale ip -6 2>/dev/null || echo "N/A")
print_success "Headscale 네트워크 연결 완료!"
print_info "할당된 IPv4: $TAILSCALE_IP"
print_info "할당된 IPv6: $TAILSCALE_IP6"
# 네트워크 테스트
print_status "네트워크 연결 테스트 중..."
if ping -c 3 -W 5 100.64.0.1 >/dev/null 2>&1; then
print_success "팜큐 네트워크($FARMQ_NETWORK) 연결 정상!"
else
print_warning "네트워크 테스트 실패. 방화벽을 확인해주세요."
fi
# 연결된 노드 확인
print_info "네트워크 상태:"
tailscale status | head -10
}
# ================================
# 방화벽 설정 (선택사항)
# ================================
configure_firewall() {
print_status "방화벽 설정 확인 중..."
# UFW (Ubuntu/Debian)
if command -v ufw >/dev/null 2>&1; then
print_info "UFW 방화벽 감지됨"
if ufw status | grep -q "Status: active"; then
print_info "Tailscale 트래픽 허용 중..."
ufw allow in on tailscale0 >/dev/null 2>&1 || true
ufw allow 41641/udp comment "Tailscale" >/dev/null 2>&1 || true
fi
fi
# firewalld (CentOS/RHEL/Fedora)
if command -v firewall-cmd >/dev/null 2>&1; then
print_info "firewalld 방화벽 감지됨"
if firewall-cmd --state >/dev/null 2>&1; then
print_info "Tailscale 트래픽 허용 중..."
firewall-cmd --permanent --add-service=tailscale >/dev/null 2>&1 || true
firewall-cmd --permanent --add-port=41641/udp >/dev/null 2>&1 || true
firewall-cmd --reload >/dev/null 2>&1 || true
fi
fi
print_success "방화벽 설정 완료"
}
# ================================
# 정리 작업
# ================================
cleanup() {
print_status "정리 작업 수행 중..."
# 임시 파일 정리
rm -rf /tmp/tailscale_* >/dev/null 2>&1 || true
# 시스템 정보 업데이트
if command -v updatedb >/dev/null 2>&1; then
updatedb >/dev/null 2>&1 &
fi
print_success "정리 작업 완료"
}
# ================================
# 최종 정보 출력
# ================================
show_final_info() {
print_header "팜큐 Headscale 설치 완료!"
# 시스템 정보
HOSTNAME=$(hostname)
TAILSCALE_IP=$(tailscale ip -4 2>/dev/null || echo "N/A")
echo -e "${GREEN}🎉 설치가 성공적으로 완료되었습니다!${NC}\n"
echo -e "${CYAN}📋 시스템 정보:${NC}"
echo -e " 호스트명: $HOSTNAME"
echo -e " Tailscale IP: $TAILSCALE_IP"
echo -e " OS: $OS $VERSION"
echo -e " Headscale 서버: $HEADSCALE_SERVER"
echo -e "\n${YELLOW}🔧 유용한 명령어:${NC}"
echo -e " tailscale status # 연결 상태 확인"
echo -e " tailscale ip # 할당된 IP 확인"
echo -e " tailscale ping <node> # 다른 노드와 연결 테스트"
echo -e " tailscale logout # 네트워크에서 해제"
echo -e "\n${PURPLE}🌐 팜큐 관리자 페이지:${NC}"
echo -e " http://192.168.0.151:5002"
echo -e " http://192.168.0.151:5002/vms (VM 관리)"
echo -e "\n${WHITE}문제가 있을 경우 로그를 확인하세요:${NC}"
echo -e " journalctl -u tailscaled -f"
print_header "설치 완료 - 팜큐 네트워크를 사용할 수 있습니다!"
}
# ================================
# 메인 함수
# ================================
main() {
print_header "팜큐(FARMQ) Headscale 원클릭 설치"
# 사전 체크
detect_os
check_requirements
# 설치 과정
install_tailscale
start_tailscale
register_headscale
# 사후 설정
configure_firewall
verify_connection
# 정리 및 완료
cleanup
show_final_info
}
# ================================
# 에러 핸들링
# ================================
trap 'echo -e "\n❌ 설치 중 오류가 발생했습니다. 로그를 확인해주세요."; exit 1' ERR
# 스크립트 실행
main "$@"

519
headscale-quick-install.sh.backup Executable file
View File

@@ -0,0 +1,519 @@
#!/bin/bash
# 팜큐(FARMQ) Headscale 원클릭 설치 및 등록 스크립트
# 사용법: curl -fsSL https://git.0bin.in/.../quick-install.sh | sudo bash
# 또는: wget -qO- https://git.0bin.in/.../quick-install.sh | sudo bash
# root 계정: curl -fsSL https://git.0bin.in/.../quick-install.sh | bash
# 강제 재등록: curl -fsSL https://git.0bin.in/.../quick-install.sh | bash -s -- --force
set -e
# ================================
# 설정 (필요시 수정)
# ================================
HEADSCALE_SERVER="http://head.pharmq.kr" # Headscale 서버 주소
PREAUTH_KEY="b46923995afeaec90e588168f2e1bf99801775e8657ce003" # 7일간 재사용 가능한 키
FARMQ_NETWORK="100.64.0.0/10" # 팜큐 네트워크 대역
# 명령행 옵션 처리
FORCE_REGISTER=false
for arg in "$@"; do
case $arg in
--force|-f)
FORCE_REGISTER=true
shift
;;
--help|-h)
echo "사용법: $0 [옵션]"
echo "옵션:"
echo " --force, -f 기존 연결을 강제로 해제하고 재등록"
echo " --help, -h 도움말 표시"
exit 0
;;
*)
# 알 수 없는 옵션 무시
;;
esac
done
# ================================
# 색상 출력 함수
# ================================
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
PURPLE='\033[0;35m'
CYAN='\033[0;36m'
WHITE='\033[1;37m'
NC='\033[0m' # No Color
print_header() {
echo -e "\n${PURPLE}============================================${NC}"
echo -e "${WHITE}$1${NC}"
echo -e "${PURPLE}============================================${NC}\n"
}
print_status() {
echo -e "\n${BLUE}🔧 $1${NC}"
}
print_success() {
echo -e "\n${GREEN}✅ $1${NC}"
}
print_error() {
echo -e "\n${RED}❌ $1${NC}"
}
print_info() {
echo -e "\n${CYAN}📋 $1${NC}"
}
print_warning() {
echo -e "\n${YELLOW}⚠️ $1${NC}"
}
# ================================
# 운영체제 감지
# ================================
detect_os() {
if [ -f /etc/os-release ]; then
. /etc/os-release
OS=$ID
VERSION=$VERSION_ID
CODENAME=$VERSION_CODENAME
else
print_error "지원하지 않는 운영체제입니다."
exit 1
fi
print_info "감지된 OS: $OS $VERSION ($CODENAME)"
}
# ================================
# 시스템 요구사항 확인
# ================================
check_requirements() {
print_status "시스템 요구사항 확인 중..."
# Root 권한 확인
if [ "$EUID" -ne 0 ]; then
print_error "이 스크립트는 root 권한으로 실행해야 합니다."
print_info "다음 중 하나의 방법으로 다시 실행해주세요:"
print_info "1. sudo가 있는 경우: curl ... | sudo bash"
print_info "2. root 계정인 경우: curl ... | bash"
exit 1
fi
# curl 또는 wget 확인
if ! command -v curl >/dev/null 2>&1 && ! command -v wget >/dev/null 2>&1; then
print_error "curl 또는 wget이 필요합니다."
exit 1
fi
# 네트워크 연결 확인
if ! ping -c 1 8.8.8.8 >/dev/null 2>&1; then
print_warning "인터넷 연결을 확인해주세요."
fi
print_success "시스템 요구사항 확인 완료"
}
# ================================
# Tailscale 설치
# ================================
install_tailscale() {
print_status "Tailscale 클라이언트 설치 중..."
# 이미 설치되어 있는지 확인
if command -v tailscale >/dev/null 2>&1; then
print_info "Tailscale이 이미 설치되어 있습니다."
TAILSCALE_VERSION=$(tailscale version | head -n1)
print_info "현재 버전: $TAILSCALE_VERSION"
return
fi
case $OS in
ubuntu|debian)
print_info "Ubuntu/Debian용 Tailscale 설치 중..."
# GPG 키 추가
curl -fsSL https://pkgs.tailscale.com/stable/ubuntu/jammy.noarmor.gpg | tee /usr/share/keyrings/tailscale-archive-keyring.gpg >/dev/null
curl -fsSL https://pkgs.tailscale.com/stable/ubuntu/jammy.tailscale-keyring.list | tee /etc/apt/sources.list.d/tailscale.list
# 패키지 설치
apt-get update -qq
apt-get install -y tailscale
;;
centos|rhel|rocky|almalinux)
print_info "CentOS/RHEL/Rocky용 Tailscale 설치 중..."
# 리포지토리 추가
curl -fsSL https://pkgs.tailscale.com/stable/rhel/tailscale.repo | tee /etc/yum.repos.d/tailscale.repo
# 패키지 설치
if command -v dnf >/dev/null 2>&1; then
dnf install -y tailscale
else
yum install -y tailscale
fi
;;
fedora)
print_info "Fedora용 Tailscale 설치 중..."
dnf install -y tailscale
;;
arch)
print_info "Arch Linux용 Tailscale 설치 중..."
pacman -S --noconfirm tailscale
;;
*)
print_warning "지원하지 않는 배포판입니다. 수동 설치를 시도합니다."
# Universal binary 다운로드
ARCH=$(uname -m)
case $ARCH in
x86_64) TAILSCALE_ARCH="amd64" ;;
aarch64) TAILSCALE_ARCH="arm64" ;;
armv7l) TAILSCALE_ARCH="arm" ;;
*)
print_error "지원하지 않는 아키텍처: $ARCH"
exit 1
;;
esac
# 최신 버전 다운로드
TAILSCALE_VERSION=$(curl -s https://api.github.com/repos/tailscale/tailscale/releases/latest | grep '"tag_name"' | cut -d'"' -f4)
DOWNLOAD_URL="https://pkgs.tailscale.com/stable/tailscale_${TAILSCALE_VERSION#v}_linux_${TAILSCALE_ARCH}.tgz"
cd /tmp
curl -LO "$DOWNLOAD_URL"
tar xzf "tailscale_${TAILSCALE_VERSION#v}_linux_${TAILSCALE_ARCH}.tgz"
# 바이너리 복사
cp "tailscale_${TAILSCALE_VERSION#v}_linux_${TAILSCALE_ARCH}/tailscale" /usr/bin/
cp "tailscale_${TAILSCALE_VERSION#v}_linux_${TAILSCALE_ARCH}/tailscaled" /usr/sbin/
# 시스템 서비스 파일 생성
cat > /etc/systemd/system/tailscaled.service << 'EOF'
[Unit]
Description=Tailscale node agent
Documentation=https://tailscale.com/kb/
Wants=network-pre.target
After=network-pre.target NetworkManager.service systemd-resolved.service
[Service]
EnvironmentFile=/etc/default/tailscaled
ExecStart=/usr/sbin/tailscaled --state=/var/lib/tailscale/tailscaled.state --socket=/run/tailscale/tailscaled.sock --port=$PORT $FLAGS
ExecStopPost=/usr/bin/tailscale logout
Restart=on-failure
RestartSec=5
Type=notify
RuntimeDirectory=tailscale
RuntimeDirectoryMode=0755
StateDirectory=tailscale
StateDirectoryMode=0700
CacheDirectory=tailscale
CacheDirectoryMode=0750
[Install]
WantedBy=multi-user.target
EOF
# 환경 설정 파일
mkdir -p /etc/default
echo 'FLAGS=""' > /etc/default/tailscaled
echo 'PORT="41641"' >> /etc/default/tailscaled
systemctl daemon-reload
;;
esac
print_success "Tailscale 설치 완료"
# 버전 확인
TAILSCALE_VERSION=$(tailscale version | head -n1)
print_info "설치된 버전: $TAILSCALE_VERSION"
}
# ================================
# Tailscale 서비스 시작
# ================================
start_tailscale() {
print_status "Tailscale 서비스 시작 중..."
# systemd 서비스 활성화 및 시작
systemctl enable tailscaled >/dev/null 2>&1 || true
systemctl start tailscaled >/dev/null 2>&1 || true
# 서비스 상태 확인
sleep 3
if systemctl is-active --quiet tailscaled; then
print_success "Tailscaled 서비스가 실행 중입니다."
else
print_error "Tailscaled 서비스 시작에 실패했습니다."
print_info "수동으로 시작을 시도합니다..."
/usr/sbin/tailscaled --state=/var/lib/tailscale/tailscaled.state &
sleep 5
fi
}
# ================================
# Headscale 등록
# ================================
register_headscale() {
print_status "Headscale 서버에 등록 중..."
# 기존 연결 확인
if tailscale status >/dev/null 2>&1; then
print_warning "이미 Tailscale/Headscale에 연결되어 있습니다."
# 현재 연결 상태 표시
CURRENT_STATUS=$(tailscale status 2>/dev/null | head -5)
print_info "현재 연결 상태:"
echo "$CURRENT_STATUS"
# 현재 서버 확인
CURRENT_SERVER=$(tailscale status --json 2>/dev/null | grep -o '"CurrentTailnet":[^,]*' | cut -d'"' -f4 2>/dev/null || echo "알 수 없음")
TARGET_SERVER=$(echo "$HEADSCALE_SERVER" | sed 's|https\?://||' | sed 's|:[0-9]*||')
print_info "현재 서버: $CURRENT_SERVER"
print_info "대상 서버: $TARGET_SERVER"
# 강제 등록 옵션 확인
if [ "$FORCE_REGISTER" = true ]; then
print_warning "강제 재등록 옵션이 활성화되었습니다."
print_info "기존 연결을 해제하고 재등록합니다..."
tailscale logout >/dev/null 2>&1 || true
sleep 3
# 같은 서버인지 확인
elif [[ "$CURRENT_SERVER" == *"$TARGET_SERVER"* ]] || [[ "$TARGET_SERVER" == *"$CURRENT_SERVER"* ]]; then
print_success "이미 올바른 Headscale 서버에 연결되어 있습니다!"
print_info "등록을 건너뜁니다."
return 0
# 대화형 실행인지 확인 (터미널에서 직접 실행)
elif [ -t 0 ] && [ -t 1 ]; then
print_warning "다른 서버에 연결되어 있습니다."
echo -n "기존 연결을 해제하고 팜큐 Headscale로 등록하시겠습니까? (Y/n): "
read -r REPLY
# 기본값을 Y로 변경 (엔터만 누르면 Y)
if [[ -z "$REPLY" ]] || [[ $REPLY =~ ^[Yy]$ ]]; then
print_info "기존 연결을 해제합니다..."
tailscale logout >/dev/null 2>&1 || true
sleep 3
else
print_info "등록을 건너뜁니다."
return 0
fi
else
# 파이프 실행 시 자동으로 재등록 (기본값: Y)
print_warning "다른 서버에 연결되어 있어 자동으로 팜큐 Headscale로 재등록합니다."
print_info "기존 연결을 해제합니다..."
tailscale logout >/dev/null 2>&1 || true
sleep 3
fi
# 추가 확인: 완전히 로그아웃되었는지 검증
print_status "연결 해제 확인 중..."
for i in {1..10}; do
if ! tailscale status >/dev/null 2>&1; then
print_success "기존 연결이 완전히 해제되었습니다."
break
fi
print_info "로그아웃 대기 중... ($i/10)"
sleep 2
if [ $i -eq 10 ]; then
print_warning "로그아웃이 완료되지 않았지만 계속 진행합니다."
fi
done
fi
print_info "Headscale 서버: $HEADSCALE_SERVER"
print_info "Pre-auth Key: ${PREAUTH_KEY:0:8}***************"
# Headscale 등록 시도
print_status "등록 명령 실행 중..."
if tailscale up \
--login-server="$HEADSCALE_SERVER" \
--authkey="$PREAUTH_KEY" \
--accept-routes \
--accept-dns=true >/dev/null 2>&1; then
print_success "Headscale 등록 성공!"
else
print_error "자동 등록에 실패했습니다. 수동 등록을 진행합니다."
# 수동 등록 모드
print_info "다음 명령을 실행하여 수동 등록하세요:"
echo ""
echo "tailscale up --login-server=\"$HEADSCALE_SERVER\" --authkey=\"$PREAUTH_KEY\" --accept-routes --accept-dns=true"
echo ""
# 등록 URL 시도
REGISTER_URL=$(tailscale up --login-server="$HEADSCALE_SERVER" 2>&1 | grep -o 'https://[^[:space:]]*' | head -1)
if [ -n "$REGISTER_URL" ]; then
print_info "또는 다음 URL을 방문하여 등록하세요:"
echo "$REGISTER_URL"
fi
return 1
fi
}
# ================================
# 연결 상태 확인
# ================================
verify_connection() {
print_status "연결 상태 확인 중..."
# 잠시 대기 (연결 안정화)
sleep 5
# Tailscale 상태 확인
if ! tailscale status >/dev/null 2>&1; then
print_error "Tailscale 연결에 문제가 있습니다."
return 1
fi
# IP 주소 확인
TAILSCALE_IP=$(tailscale ip -4 2>/dev/null || echo "N/A")
TAILSCALE_IP6=$(tailscale ip -6 2>/dev/null || echo "N/A")
print_success "Headscale 네트워크 연결 완료!"
print_info "할당된 IPv4: $TAILSCALE_IP"
print_info "할당된 IPv6: $TAILSCALE_IP6"
# 네트워크 테스트
print_status "네트워크 연결 테스트 중..."
if ping -c 3 -W 5 100.64.0.1 >/dev/null 2>&1; then
print_success "팜큐 네트워크($FARMQ_NETWORK) 연결 정상!"
else
print_warning "네트워크 테스트 실패. 방화벽을 확인해주세요."
fi
# 연결된 노드 확인
print_info "네트워크 상태:"
tailscale status | head -10
}
# ================================
# 방화벽 설정 (선택사항)
# ================================
configure_firewall() {
print_status "방화벽 설정 확인 중..."
# UFW (Ubuntu/Debian)
if command -v ufw >/dev/null 2>&1; then
print_info "UFW 방화벽 감지됨"
if ufw status | grep -q "Status: active"; then
print_info "Tailscale 트래픽 허용 중..."
ufw allow in on tailscale0 >/dev/null 2>&1 || true
ufw allow 41641/udp comment "Tailscale" >/dev/null 2>&1 || true
fi
fi
# firewalld (CentOS/RHEL/Fedora)
if command -v firewall-cmd >/dev/null 2>&1; then
print_info "firewalld 방화벽 감지됨"
if firewall-cmd --state >/dev/null 2>&1; then
print_info "Tailscale 트래픽 허용 중..."
firewall-cmd --permanent --add-service=tailscale >/dev/null 2>&1 || true
firewall-cmd --permanent --add-port=41641/udp >/dev/null 2>&1 || true
firewall-cmd --reload >/dev/null 2>&1 || true
fi
fi
print_success "방화벽 설정 완료"
}
# ================================
# 정리 작업
# ================================
cleanup() {
print_status "정리 작업 수행 중..."
# 임시 파일 정리
rm -rf /tmp/tailscale_* >/dev/null 2>&1 || true
# 시스템 정보 업데이트
if command -v updatedb >/dev/null 2>&1; then
updatedb >/dev/null 2>&1 &
fi
print_success "정리 작업 완료"
}
# ================================
# 최종 정보 출력
# ================================
show_final_info() {
print_header "팜큐 Headscale 설치 완료!"
# 시스템 정보
HOSTNAME=$(hostname)
TAILSCALE_IP=$(tailscale ip -4 2>/dev/null || echo "N/A")
echo -e "${GREEN}🎉 설치가 성공적으로 완료되었습니다!${NC}\n"
echo -e "${CYAN}📋 시스템 정보:${NC}"
echo -e " 호스트명: $HOSTNAME"
echo -e " Tailscale IP: $TAILSCALE_IP"
echo -e " OS: $OS $VERSION"
echo -e " Headscale 서버: $HEADSCALE_SERVER"
echo -e "\n${YELLOW}🔧 유용한 명령어:${NC}"
echo -e " tailscale status # 연결 상태 확인"
echo -e " tailscale ip # 할당된 IP 확인"
echo -e " tailscale ping <node> # 다른 노드와 연결 테스트"
echo -e " tailscale logout # 네트워크에서 해제"
echo -e "\n${PURPLE}🌐 팜큐 관리자 페이지:${NC}"
echo -e " http://192.168.0.151:5002"
echo -e " http://192.168.0.151:5002/vms (VM 관리)"
echo -e "\n${WHITE}문제가 있을 경우 로그를 확인하세요:${NC}"
echo -e " journalctl -u tailscaled -f"
print_header "설치 완료 - 팜큐 네트워크를 사용할 수 있습니다!"
}
# ================================
# 메인 함수
# ================================
main() {
print_header "팜큐(FARMQ) Headscale 원클릭 설치"
# 사전 체크
detect_os
check_requirements
# 설치 과정
install_tailscale
start_tailscale
register_headscale
# 사후 설정
configure_firewall
verify_connection
# 정리 및 완료
cleanup
show_final_info
}
# ================================
# 에러 핸들링
# ================================
trap 'echo -e "\n❌ 설치 중 오류가 발생했습니다. 로그를 확인해주세요."; exit 1' ERR
# 스크립트 실행
main "$@"

629
pbs_allinone.sh Normal file
View File

@@ -0,0 +1,629 @@
#!/bin/bash
#
# PBS 올인원 스크립트 - Proxmox 호스트에서 실행
# 작성일: 2025-11-04
# 용도: PBS 등록 → 백업 조회 → 복구를 한 번에
#
set -e
# 색상
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
MAGENTA='\033[0;35m'
NC='\033[0m'
# PBS 서버 설정 (자동 입력)
PBS_SERVER="100.64.0.10"
PBS_PORT="8007"
PBS_USERNAME="0bin@pbs"
PBS_PASSWORD="@Trajet6640"
PBS_DATASTORE="PBS-DVA"
PBS_FINGERPRINT="24:42:c6:0f:a8:1b:93:32:32:44:84:be:6a:c5:71:97:e4:4d:61:fc:a4:48:12:0c:97:3b:9f:1f:cc:b2:54:e8"
PBS_STORAGE_NAME="PBS-Auto"
# 복구 설정
TEMPLATE_VMID=""
TARGET_VMID=""
TARGET_STORAGE="local-lvm"
BACKUP_TYPE=""
LATEST_SNAPSHOT=""
# 로그 함수
log_info() { echo -e "${BLUE}[INFO]${NC} $1"; }
log_success() { echo -e "${GREEN}[SUCCESS]${NC} $1"; }
log_warning() { echo -e "${YELLOW}[WARNING]${NC} $1"; }
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
log_step() { echo -e "${CYAN}╰─► ${NC}$1"; }
# 배너
print_banner() {
clear
echo -e "${MAGENTA}"
cat << "EOF"
╔═══════════════════════════════════════════════════════════════╗
║ ║
║ PBS 올인원 스크립트 ║
║ 등록 → 조회 → 복구 한 번에! ║
║ ║
╚═══════════════════════════════════════════════════════════════╝
EOF
echo -e "${NC}"
echo ""
}
# Proxmox 환경 확인
check_proxmox() {
if [ ! -f /etc/pve/storage.cfg ]; then
log_error "Proxmox VE가 설치되어 있지 않습니다."
exit 1
fi
log_success "Proxmox VE 환경 확인 완료"
}
# PBS 스토리지 등록
register_pbs() {
log_step "PBS 스토리지 등록 중..."
# 이미 등록되어 있는지 확인
if pvesm status | grep -q "^${PBS_STORAGE_NAME} "; then
log_warning "PBS 스토리지가 이미 등록되어 있습니다."
read -p "$(echo -e ${YELLOW}재등록하시겠습니까?${NC}) (y/N): " reregister < /dev/tty
if [[ "$reregister" =~ ^[Yy]$ ]]; then
log_info "기존 PBS 스토리지 제거 중..."
pvesm remove "${PBS_STORAGE_NAME}"
sleep 1
else
log_info "기존 PBS 스토리지 사용"
return 0
fi
fi
# PBS 등록
log_info "PBS 서버 등록 중..."
pvesm add pbs "${PBS_STORAGE_NAME}" \
--server "${PBS_SERVER}" \
--port "${PBS_PORT}" \
--datastore "${PBS_DATASTORE}" \
--username "${PBS_USERNAME}" \
--password "${PBS_PASSWORD}" \
--fingerprint "${PBS_FINGERPRINT}" \
--namespace "PQ" 2>&1
if [ $? -eq 0 ]; then
log_success "PBS 스토리지 등록 완료!"
else
log_error "PBS 스토리지 등록 실패"
return 1
fi
# 연결 테스트
log_info "PBS 연결 테스트 중..."
if pvesm status -storage "${PBS_STORAGE_NAME}" &>/dev/null; then
log_success "PBS 연결 성공!"
echo ""
pvesm status -storage "${PBS_STORAGE_NAME}"
echo ""
else
log_error "PBS 연결 테스트 실패"
return 1
fi
}
# PBS API 인증
pbs_login() {
log_info "PBS API 인증 중..."
curl -k -s -X POST "https://${PBS_SERVER}:${PBS_PORT}/api2/json/access/ticket" \
-d "username=${PBS_USERNAME}" \
-d "password=${PBS_PASSWORD}" > /tmp/pbs_auth.json
if [ ! -s /tmp/pbs_auth.json ]; then
log_error "PBS API 응답 없음. 서버 연결을 확인하세요."
return 1
fi
PBS_TICKET=$(python3 -c 'import json; data=json.load(open("/tmp/pbs_auth.json")); print(data["data"]["ticket"])' 2>/dev/null)
PBS_CSRF=$(python3 -c 'import json; data=json.load(open("/tmp/pbs_auth.json")); print(data["data"]["CSRFPreventionToken"])' 2>/dev/null)
if [ -z "$PBS_TICKET" ]; then
log_error "PBS API 인증 실패. 응답:"
cat /tmp/pbs_auth.json | head -5
return 1
fi
log_success "PBS API 인증 완료"
}
# 백업 그룹 목록 조회 및 선택
list_and_select_backup() {
log_step "PBS에서 백업 목록 조회 중..."
echo ""
# 응답을 파일에 저장 (PQ 네임스페이스 명시)
curl -k -s -X GET "https://${PBS_SERVER}:${PBS_PORT}/api2/json/admin/datastore/${PBS_DATASTORE}/groups?ns=PQ" \
-H "Cookie: PBSAuthCookie=${PBS_TICKET}" \
-H "CSRFPreventionToken: ${PBS_CSRF}" > /tmp/pbs_groups.json
# 디버깅: 응답 확인
if [ ! -s /tmp/pbs_groups.json ]; then
log_error "PBS API 응답이 비어있습니다"
log_info "PBS_TICKET: ${PBS_TICKET:0:50}..."
log_info "PBS_CSRF: ${PBS_CSRF:0:50}..."
return 1
fi
# 디버깅: 응답 길이 확인
log_info "API 응답 수신 ($(stat -c%s /tmp/pbs_groups.json) bytes)"
# PBS 인증 정보를 환경 변수로 export
export PBS_TICKET
export PBS_CSRF
python3 << PYEOF
import sys, json, subprocess, os
# PBS 인증 정보 가져오기
PBS_TICKET = os.environ.get('PBS_TICKET', '')
PBS_CSRF = os.environ.get('PBS_CSRF', '')
PBS_SERVER = "${PBS_SERVER}"
PBS_PORT = "${PBS_PORT}"
PBS_DATASTORE = "${PBS_DATASTORE}"
def get_snapshot_comment(backup_type, backup_id):
"""최신 스냅샷의 comment를 가져오기"""
try:
cmd = [
'curl', '-k', '-s', '-X', 'GET',
f'https://{PBS_SERVER}:{PBS_PORT}/api2/json/admin/datastore/{PBS_DATASTORE}/snapshots?backup-type={backup_type}&backup-id={backup_id}&ns=PQ',
'-H', f'Cookie: PBSAuthCookie={PBS_TICKET}',
'-H', f'CSRFPreventionToken: {PBS_CSRF}'
]
result = subprocess.run(cmd, capture_output=True, text=True)
data = json.loads(result.stdout)
snapshots = data.get("data", [])
if snapshots:
# 가장 최근 스냅샷 찾기
latest = max(snapshots, key=lambda x: x.get("backup-time", 0))
return latest.get("comment", "")
except:
pass
return ""
try:
with open('/tmp/pbs_groups.json', 'r') as f:
data = json.load(f)
groups = data.get("data", [])
vm_groups = [g for g in groups if g.get("backup-type") == "vm"]
ct_groups = [g for g in groups if g.get("backup-type") == "ct"]
if vm_groups:
print("\033[1;36m=== VM 백업 ===\033[0m")
for i, group in enumerate(vm_groups, 1):
backup_id = group.get("backup-id", "N/A")
backup_count = group.get("backup-count", 0)
last_backup = group.get("last-backup", 0)
# groups에서 comment 가져오기 (없으면 스냅샷에서 조회)
comment = group.get("comment", "")
if not comment:
comment = get_snapshot_comment("vm", backup_id)
from datetime import datetime
if last_backup > 0:
last_str = datetime.fromtimestamp(last_backup).strftime("%Y-%m-%d %H:%M")
else:
last_str = "N/A"
if comment:
print(f" {i}. VM {backup_id:<6} - {backup_count}개 백업 (최근: {last_str})")
print(f" \033[0;90m└─ {comment}\033[0m")
else:
print(f" {i}. VM {backup_id:<6} - {backup_count}개 백업 (최근: {last_str})")
if ct_groups:
print("\n\033[1;36m=== CT 백업 ===\033[0m")
for i, group in enumerate(ct_groups, 1):
backup_id = group.get("backup-id", "N/A")
backup_count = group.get("backup-count", 0)
last_backup = group.get("last-backup", 0)
# groups에서 comment 가져오기 (없으면 스냅샷에서 조회)
comment = group.get("comment", "")
if not comment:
comment = get_snapshot_comment("ct", backup_id)
from datetime import datetime
if last_backup > 0:
last_str = datetime.fromtimestamp(last_backup).strftime("%Y-%m-%d %H:%M")
else:
last_str = "N/A"
if comment:
print(f" {i}. CT {backup_id:<6} - {backup_count}개 백업 (최근: {last_str})")
print(f" \033[0;90m└─ {comment}\033[0m")
else:
print(f" {i}. CT {backup_id:<6} - {backup_count}개 백업 (최근: {last_str})")
print("")
except Exception as e:
print(f"오류: {e}")
sys.exit(1)
PYEOF
# 백업 선택
echo ""
while true; do
read -p "$(echo -e ${CYAN}백업 타입${NC}) (vm/ct): " BACKUP_TYPE < /dev/tty
if [[ "$BACKUP_TYPE" =~ ^(vm|ct)$ ]]; then
break
fi
log_error "vm 또는 ct를 입력하세요"
done
read -p "$(echo -e ${CYAN}백업 ID${NC}): " TEMPLATE_VMID < /dev/tty
if [ -z "$TEMPLATE_VMID" ]; then
log_error "백업 ID는 필수입니다"
exit 1
fi
# 최신 스냅샷 찾기
log_info "최신 백업 조회 중..."
if get_latest_snapshot "$BACKUP_TYPE" "$TEMPLATE_VMID"; then
log_success "최신 백업: ${LATEST_SNAPSHOT}"
else
log_error "${BACKUP_TYPE} ${TEMPLATE_VMID}의 백업을 찾을 수 없습니다"
exit 1
fi
}
# 최신 스냅샷 조회
get_latest_snapshot() {
local backup_type="$1"
local backup_id="$2"
curl -k -s -X GET "https://${PBS_SERVER}:${PBS_PORT}/api2/json/admin/datastore/${PBS_DATASTORE}/snapshots?backup-type=${backup_type}&backup-id=${backup_id}&ns=PQ" \
-H "Cookie: PBSAuthCookie=${PBS_TICKET}" \
-H "CSRFPreventionToken: ${PBS_CSRF}" > /tmp/pbs_snapshots.json
LATEST_SNAPSHOT=$(python3 << 'PYEOF'
import sys, json
try:
with open('/tmp/pbs_snapshots.json', 'r') as f:
data = json.load(f)
snapshots = data.get("data", [])
if not snapshots:
print("")
sys.exit(1)
# 가장 최근 백업 선택
latest = max(snapshots, key=lambda x: x.get("backup-time", 0))
backup_time = latest.get("backup-time", 0)
from datetime import datetime
backup_time_str = datetime.utcfromtimestamp(backup_time).strftime("%Y-%m-%dT%H:%M:%SZ")
print(backup_time_str)
except Exception as e:
print("", file=sys.stderr)
sys.exit(1)
PYEOF
)
if [ -z "$LATEST_SNAPSHOT" ]; then
return 1
fi
return 0
}
# 사용 가능한 스토리지 목록 표시
list_available_storage() {
echo ""
log_step "사용 가능한 스토리지 목록"
echo ""
# VM/CT 이미지를 저장할 수 있는 스토리지만 필터링
pvesm status -content images,rootdir 2>/dev/null | tail -n +2 | while read -r line; do
storage_name=$(echo "$line" | awk '{print $1}')
storage_type=$(echo "$line" | awk '{print $2}')
total_mb=$(echo "$line" | awk '{print $4}')
used_mb=$(echo "$line" | awk '{print $5}')
avail_mb=$(echo "$line" | awk '{print $6}')
usage_pct=$(echo "$line" | awk '{print $7}')
# MB를 GB로 변환
total_gb=$(echo "scale=2; $total_mb / 1024 / 1024" | bc)
avail_gb=$(echo "scale=2; $avail_mb / 1024 / 1024" | bc)
echo -e " ${GREEN}${NC} ${CYAN}${storage_name}${NC}"
echo -e " 타입: ${storage_type} | 용량: ${total_gb}GB | 여유: ${avail_gb}GB | 사용률: ${usage_pct}"
done
echo ""
}
# 복구 설정 입력
get_restore_config() {
echo ""
log_step "복구 설정을 입력하세요"
echo ""
# 복구할 VM/CT ID
read -p "$(echo -e ${CYAN}복구할 VM/CT ID${NC}) [기본값: ${TEMPLATE_VMID}]: " TARGET_VMID < /dev/tty
TARGET_VMID=${TARGET_VMID:-$TEMPLATE_VMID}
# 사용 가능한 스토리지 목록 표시
list_available_storage
# 스토리지 선택
while true; do
read -p "$(echo -e ${CYAN}저장 스토리지 이름${NC}) [기본값: local-lvm]: " input_storage < /dev/tty
TARGET_STORAGE=${input_storage:-local-lvm}
# 입력한 스토리지가 존재하는지 확인
if pvesm status -storage "${TARGET_STORAGE}" &>/dev/null; then
# VM/CT 이미지를 저장할 수 있는지 확인
if pvesm status -storage "${TARGET_STORAGE}" -content images &>/dev/null || \
pvesm status -storage "${TARGET_STORAGE}" -content rootdir &>/dev/null; then
log_success "스토리지 '${TARGET_STORAGE}' 선택됨"
break
else
log_error "스토리지 '${TARGET_STORAGE}'는 VM/CT 이미지를 저장할 수 없습니다"
fi
else
log_error "스토리지 '${TARGET_STORAGE}'를 찾을 수 없습니다"
fi
echo ""
done
echo ""
log_info "복구 요약:"
echo " 백업 소스: ${BACKUP_TYPE}/${TEMPLATE_VMID}"
echo " 백업 시점: ${LATEST_SNAPSHOT}"
echo " 복구 ID: ${TARGET_VMID}"
echo " 저장 위치: ${TARGET_STORAGE}"
echo ""
read -p "$(echo -e ${YELLOW}이 설정으로 복구하시겠습니까?${NC}) (y/N): " confirm < /dev/tty
if [[ ! "$confirm" =~ ^[Yy]$ ]]; then
log_info "작업 취소됨"
exit 0
fi
}
# VM/CT 복구
restore_backup() {
log_step "백업 복구 중... (시간이 걸릴 수 있습니다)"
echo ""
# 백업 경로 구성
BACKUP_PATH="${PBS_STORAGE_NAME}:backup/${BACKUP_TYPE}/${TEMPLATE_VMID}/${LATEST_SNAPSHOT}"
log_info "백업 경로: ${BACKUP_PATH}"
# 기존 VM/CT 확인
if qm status ${TARGET_VMID} 2>/dev/null || pct status ${TARGET_VMID} 2>/dev/null; then
log_warning "VM/CT ${TARGET_VMID}가 이미 존재합니다"
read -p "$(echo -e ${YELLOW}삭제하고 복구하시겠습니까?${NC}) (y/N): " delete_confirm < /dev/tty
if [[ "$delete_confirm" =~ ^[Yy]$ ]]; then
log_info "기존 VM/CT 삭제 중..."
if [ "$BACKUP_TYPE" = "vm" ]; then
qm stop ${TARGET_VMID} --skiplock || true
sleep 2
qm destroy ${TARGET_VMID} --purge --skiplock || true
else
pct stop ${TARGET_VMID} || true
sleep 2
pct destroy ${TARGET_VMID} --purge || true
fi
sleep 2
log_success "기존 VM/CT 삭제 완료"
else
log_error "복구 취소됨"
exit 1
fi
fi
# 복구 실행
if [ "$BACKUP_TYPE" = "vm" ]; then
log_info "VM 복구 시작..."
echo ""
qmrestore "${BACKUP_PATH}" ${TARGET_VMID} \
--storage ${TARGET_STORAGE} \
--unique 1 2>&1 | tee /tmp/pbs-restore.log
if [ ${PIPESTATUS[0]} -eq 0 ]; then
echo ""
log_success "VM 복구 완료!"
else
echo ""
log_error "VM 복구 실패. 로그: /tmp/pbs-restore.log"
return 1
fi
else
log_info "CT 복구 시작..."
echo ""
pct restore ${TARGET_VMID} "${BACKUP_PATH}" \
--storage ${TARGET_STORAGE} \
--unprivileged 1 2>&1 | tee /tmp/pbs-restore.log
if [ ${PIPESTATUS[0]} -eq 0 ]; then
echo ""
log_success "CT 복구 완료!"
else
echo ""
log_error "CT 복구 실패. 로그: /tmp/pbs-restore.log"
return 1
fi
fi
}
# VM/CT 시작
start_vm() {
echo ""
read -p "$(echo -e ${YELLOW}VM/CT를 바로 시작하시겠습니까?${NC}) (y/N): " start_confirm < /dev/tty
if [[ "$start_confirm" =~ ^[Yy]$ ]]; then
log_info "VM/CT 시작 중..."
if [ "$BACKUP_TYPE" = "vm" ]; then
qm start ${TARGET_VMID}
else
pct start ${TARGET_VMID}
fi
if [ $? -eq 0 ]; then
log_success "VM/CT 시작 완료!"
sleep 3
log_info "상태 확인 중..."
if [ "$BACKUP_TYPE" = "vm" ]; then
qm status ${TARGET_VMID}
else
pct status ${TARGET_VMID}
fi
else
log_error "시작 실패"
return 1
fi
else
log_info "수동으로 시작하세요:"
if [ "$BACKUP_TYPE" = "vm" ]; then
echo " qm start ${TARGET_VMID}"
else
echo " pct start ${TARGET_VMID}"
fi
fi
}
# 완료 메시지
print_summary() {
echo ""
echo -e "${GREEN}"
cat << "EOF"
╔═══════════════════════════════════════════════════════════════╗
║ ║
║ ✅ 복구 완료! ║
║ ║
╚═══════════════════════════════════════════════════════════════╝
EOF
echo -e "${NC}"
echo ""
log_info "복구 정보:"
echo " 백업 소스: ${BACKUP_TYPE}/${TEMPLATE_VMID}"
echo " 백업 시점: ${LATEST_SNAPSHOT}"
echo " 복구 ID: ${TARGET_VMID}"
echo " 스토리지: ${TARGET_STORAGE}"
echo ""
log_info "Proxmox Web UI:"
local pve_ip=$(hostname -I | awk '{print $1}')
echo " https://${pve_ip}:8006"
echo ""
if [ "$BACKUP_TYPE" = "vm" ]; then
log_info "유용한 명령어:"
echo " VM 상태: qm status ${TARGET_VMID}"
echo " VM 시작: qm start ${TARGET_VMID}"
echo " VM 중지: qm stop ${TARGET_VMID}"
echo " VM 정보: qm config ${TARGET_VMID}"
else
log_info "유용한 명령어:"
echo " CT 상태: pct status ${TARGET_VMID}"
echo " CT 시작: pct start ${TARGET_VMID}"
echo " CT 중지: pct stop ${TARGET_VMID}"
echo " CT 접속: pct enter ${TARGET_VMID}"
fi
echo ""
log_info "PBS 스토리지:"
echo " 이름: ${PBS_STORAGE_NAME}"
echo " 제거: pvesm remove ${PBS_STORAGE_NAME}"
echo ""
}
# 메인 실행
main() {
print_banner
# Proxmox 환경 확인
check_proxmox
echo ""
# PBS 스토리지 등록
register_pbs || exit 1
echo ""
# PBS API 인증
pbs_login || exit 1
echo ""
# 백업 목록 조회 및 선택
list_and_select_backup
echo ""
# 복구 설정 입력
get_restore_config
echo ""
# 백업 복구
restore_backup || exit 1
echo ""
# VM/CT 시작
start_vm
echo ""
# 완료 메시지
print_summary
}
# 도움말
if [ "$1" = "--help" ] || [ "$1" = "-h" ]; then
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "PBS 올인원 스크립트 - Proxmox 호스트에서 실행"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
echo "사용법: $0"
echo ""
echo "이 스크립트는:"
echo " 1. PBS 스토리지를 자동 등록"
echo " 2. API로 백업 목록 조회"
echo " 3. 선택한 백업을 복구"
echo ""
echo "실행 예시:"
echo " bash $0"
echo ""
echo "사전 준비:"
echo " - Proxmox VE 설치 완료"
echo " - PBS 서버 (100.64.0.10) 접근 가능"
echo " - python3 설치 (보통 기본 설치됨)"
echo ""
echo "모든 설정(IP, 비밀번호 등)은 자동 입력됩니다!"
echo ""
exit 0
fi
# Root 권한 확인
if [ "$EUID" -ne 0 ]; then
log_error "이 스크립트는 root 권한이 필요합니다."
log_info "다음 명령으로 실행하세요: sudo $0"
exit 1
fi
# 스크립트 실행
main

471
pbs_auto_restore_103_to_203.sh Executable file
View File

@@ -0,0 +1,471 @@
#!/bin/bash
#
# PBS 자동 복구 스크립트 - VM 103 → 203
# 작성일: 2025-11-21
# 용도: PBS 등록 → VM 103 백업 자동 복구 → VM 203으로 설치
#
set -e
# 색상
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
MAGENTA='\033[0;35m'
NC='\033[0m'
# PBS 서버 설정 (자동 입력)
PBS_SERVER="100.64.0.10"
PBS_PORT="8007"
PBS_USERNAME="0bin@pbs"
PBS_PASSWORD="@Trajet6640"
PBS_DATASTORE="PBS-DVA"
PBS_FINGERPRINT="24:42:c6:0f:a8:1b:93:32:32:44:84:be:6a:c5:71:97:e4:4d:61:fc:a4:48:12:0c:97:3b:9f:1f:cc:b2:54:e8"
PBS_STORAGE_NAME="PBS-Auto"
# 고정 복구 설정
SOURCE_VMID="103" # 백업 소스 VM ID
TARGET_VMID="203" # 복구할 VM ID
TARGET_STORAGE="local-lvm" # 저장 스토리지
BACKUP_TYPE="vm" # 백업 타입 (vm 고정)
LATEST_SNAPSHOT="" # 최신 스냅샷 (자동 조회)
# 로그 함수
log_info() { echo -e "${BLUE}[INFO]${NC} $1"; }
log_success() { echo -e "${GREEN}[SUCCESS]${NC} $1"; }
log_warning() { echo -e "${YELLOW}[WARNING]${NC} $1"; }
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
log_step() { echo -e "${CYAN}╰─► ${NC}$1"; }
# 배너
print_banner() {
clear
echo -e "${MAGENTA}"
cat << "EOF"
╔═══════════════════════════════════════════════════════════════╗
║ ║
║ PBS 자동 복구 스크립트 ║
║ VM 103 → VM 203 자동 복구 ║
║ ║
╚═══════════════════════════════════════════════════════════════╝
EOF
echo -e "${NC}"
echo ""
}
# Proxmox 환경 확인
check_proxmox() {
if [ ! -f /etc/pve/storage.cfg ]; then
log_error "Proxmox VE가 설치되어 있지 않습니다."
exit 1
fi
log_success "Proxmox VE 환경 확인 완료"
}
# PBS 스토리지 등록
register_pbs() {
log_step "PBS 스토리지 등록 중..."
# 이미 등록되어 있는지 확인
if pvesm status | grep -q "^${PBS_STORAGE_NAME} "; then
log_warning "PBS 스토리지가 이미 등록되어 있습니다."
log_info "기존 PBS 스토리지 제거 중..."
pvesm remove "${PBS_STORAGE_NAME}" 2>/dev/null || true
sleep 1
fi
# PBS 등록
log_info "PBS 서버 등록 중..."
pvesm add pbs "${PBS_STORAGE_NAME}" \
--server "${PBS_SERVER}" \
--port "${PBS_PORT}" \
--datastore "${PBS_DATASTORE}" \
--username "${PBS_USERNAME}" \
--password "${PBS_PASSWORD}" \
--fingerprint "${PBS_FINGERPRINT}" \
--namespace "PQ" 2>&1
if [ $? -eq 0 ]; then
log_success "PBS 스토리지 등록 완료!"
else
log_error "PBS 스토리지 등록 실패"
return 1
fi
# 연결 테스트
log_info "PBS 연결 테스트 중..."
if pvesm status -storage "${PBS_STORAGE_NAME}" &>/dev/null; then
log_success "PBS 연결 성공!"
echo ""
pvesm status -storage "${PBS_STORAGE_NAME}"
echo ""
else
log_error "PBS 연결 테스트 실패"
return 1
fi
}
# PBS API 인증
pbs_login() {
log_info "PBS API 인증 중..."
curl -k -s -X POST "https://${PBS_SERVER}:${PBS_PORT}/api2/json/access/ticket" \
-d "username=${PBS_USERNAME}" \
-d "password=${PBS_PASSWORD}" > /tmp/pbs_auth.json
if [ ! -s /tmp/pbs_auth.json ]; then
log_error "PBS API 응답 없음. 서버 연결을 확인하세요."
return 1
fi
PBS_TICKET=$(python3 -c 'import json; data=json.load(open("/tmp/pbs_auth.json")); print(data["data"]["ticket"])' 2>/dev/null)
PBS_CSRF=$(python3 -c 'import json; data=json.load(open("/tmp/pbs_auth.json")); print(data["data"]["CSRFPreventionToken"])' 2>/dev/null)
if [ -z "$PBS_TICKET" ]; then
log_error "PBS API 인증 실패. 응답:"
cat /tmp/pbs_auth.json | head -5
return 1
fi
log_success "PBS API 인증 완료"
}
# VM 103 백업 확인
check_backup_exists() {
log_step "VM ${SOURCE_VMID} 백업 확인 중..."
curl -k -s -X GET "https://${PBS_SERVER}:${PBS_PORT}/api2/json/admin/datastore/${PBS_DATASTORE}/groups?ns=PQ" \
-H "Cookie: PBSAuthCookie=${PBS_TICKET}" \
-H "CSRFPreventionToken: ${PBS_CSRF}" > /tmp/pbs_groups.json
# VM 103 백업이 있는지 확인
local has_backup=$(python3 << PYEOF
import sys, json
try:
with open('/tmp/pbs_groups.json', 'r') as f:
data = json.load(f)
groups = data.get("data", [])
for group in groups:
if group.get("backup-type") == "vm" and group.get("backup-id") == "${SOURCE_VMID}":
backup_count = group.get("backup-count", 0)
last_backup = group.get("last-backup", 0)
from datetime import datetime
if last_backup > 0:
last_str = datetime.fromtimestamp(last_backup).strftime("%Y-%m-%d %H:%M")
else:
last_str = "N/A"
print(f"VM {group.get('backup-id')} - {backup_count}개 백업 (최근: {last_str})")
sys.exit(0)
print("")
sys.exit(1)
except Exception as e:
print("")
sys.exit(1)
PYEOF
)
if [ $? -eq 0 ]; then
log_success "백업 발견: ${has_backup}"
return 0
else
log_error "VM ${SOURCE_VMID} 백업을 찾을 수 없습니다"
return 1
fi
}
# 최신 스냅샷 조회
get_latest_snapshot() {
log_info "최신 백업 조회 중..."
curl -k -s -X GET "https://${PBS_SERVER}:${PBS_PORT}/api2/json/admin/datastore/${PBS_DATASTORE}/snapshots?backup-type=${BACKUP_TYPE}&backup-id=${SOURCE_VMID}&ns=PQ" \
-H "Cookie: PBSAuthCookie=${PBS_TICKET}" \
-H "CSRFPreventionToken: ${PBS_CSRF}" > /tmp/pbs_snapshots.json
LATEST_SNAPSHOT=$(python3 << 'PYEOF'
import sys, json
try:
with open('/tmp/pbs_snapshots.json', 'r') as f:
data = json.load(f)
snapshots = data.get("data", [])
if not snapshots:
print("")
sys.exit(1)
# 가장 최근 백업 선택
latest = max(snapshots, key=lambda x: x.get("backup-time", 0))
backup_time = latest.get("backup-time", 0)
from datetime import datetime
backup_time_str = datetime.utcfromtimestamp(backup_time).strftime("%Y-%m-%dT%H:%M:%SZ")
print(backup_time_str)
except Exception as e:
print("", file=sys.stderr)
sys.exit(1)
PYEOF
)
if [ -z "$LATEST_SNAPSHOT" ]; then
log_error "최신 백업을 찾을 수 없습니다"
return 1
fi
log_success "최신 백업: ${LATEST_SNAPSHOT}"
return 0
}
# 스토리지 확인
check_storage() {
log_step "저장 스토리지 확인 중..."
if pvesm status -storage "${TARGET_STORAGE}" &>/dev/null; then
# VM 이미지를 저장할 수 있는지 확인
if pvesm status -storage "${TARGET_STORAGE}" -content images &>/dev/null; then
log_success "스토리지 '${TARGET_STORAGE}' 확인 완료"
# 스토리지 정보 표시
local storage_info=$(pvesm status -storage "${TARGET_STORAGE}" 2>/dev/null | tail -n +2)
local total_mb=$(echo "$storage_info" | awk '{print $4}')
local avail_mb=$(echo "$storage_info" | awk '{print $6}')
local usage_pct=$(echo "$storage_info" | awk '{print $7}')
if [ -n "$total_mb" ]; then
local total_gb=$(echo "scale=2; $total_mb / 1024 / 1024" | bc 2>/dev/null || echo "N/A")
local avail_gb=$(echo "scale=2; $avail_mb / 1024 / 1024" | bc 2>/dev/null || echo "N/A")
log_info "스토리지 정보: ${total_gb}GB 총용량 | ${avail_gb}GB 여유 | ${usage_pct} 사용률"
fi
return 0
else
log_error "스토리지 '${TARGET_STORAGE}'는 VM 이미지를 저장할 수 없습니다"
return 1
fi
else
log_error "스토리지 '${TARGET_STORAGE}'를 찾을 수 없습니다"
return 1
fi
}
# 복구 설정 확인
show_restore_config() {
echo ""
log_info "복구 설정:"
echo " 백업 소스: VM ${SOURCE_VMID}"
echo " 백업 시점: ${LATEST_SNAPSHOT}"
echo " 복구 ID: VM ${TARGET_VMID}"
echo " 저장 위치: ${TARGET_STORAGE}"
echo ""
}
# VM 복구
restore_backup() {
log_step "백업 복구 중... (시간이 걸릴 수 있습니다)"
echo ""
# 백업 경로 구성
BACKUP_PATH="${PBS_STORAGE_NAME}:backup/${BACKUP_TYPE}/${SOURCE_VMID}/${LATEST_SNAPSHOT}"
log_info "백업 경로: ${BACKUP_PATH}"
# 기존 VM 확인
if qm status ${TARGET_VMID} 2>/dev/null; then
log_warning "VM ${TARGET_VMID}가 이미 존재합니다"
log_info "기존 VM 삭제 중..."
qm stop ${TARGET_VMID} --skiplock 2>/dev/null || true
sleep 2
qm destroy ${TARGET_VMID} --purge --skiplock 2>/dev/null || true
sleep 2
log_success "기존 VM 삭제 완료"
fi
# 복구 실행
log_info "VM 복구 시작..."
echo ""
qmrestore "${BACKUP_PATH}" ${TARGET_VMID} \
--storage ${TARGET_STORAGE} \
--unique 1 2>&1 | tee /tmp/pbs-restore.log
if [ ${PIPESTATUS[0]} -eq 0 ]; then
echo ""
log_success "VM 복구 완료!"
return 0
else
echo ""
log_error "VM 복구 실패. 로그: /tmp/pbs-restore.log"
return 1
fi
}
# VM 자동 시작 (선택)
start_vm() {
echo ""
log_info "VM ${TARGET_VMID}를 바로 시작하시겠습니까?"
echo -e "${YELLOW}10초 내 입력하세요 (y/N):${NC} "
if read -t 10 start_confirm 2>/dev/null; then
:
else
start_confirm="N"
fi
echo ""
if [[ "$start_confirm" =~ ^[Yy]$ ]]; then
log_info "VM 시작 중..."
qm start ${TARGET_VMID}
if [ $? -eq 0 ]; then
log_success "VM 시작 완료!"
sleep 3
log_info "상태 확인 중..."
qm status ${TARGET_VMID}
else
log_error "시작 실패"
return 1
fi
else
log_info "VM을 수동으로 시작하세요: qm start ${TARGET_VMID}"
fi
}
# 완료 메시지
print_summary() {
echo ""
echo -e "${GREEN}"
cat << "EOF"
╔═══════════════════════════════════════════════════════════════╗
║ ║
║ ✅ 복구 완료! ║
║ ║
╚═══════════════════════════════════════════════════════════════╝
EOF
echo -e "${NC}"
echo ""
log_info "복구 정보:"
echo " 백업 소스: VM ${SOURCE_VMID}"
echo " 백업 시점: ${LATEST_SNAPSHOT}"
echo " 복구 ID: VM ${TARGET_VMID}"
echo " 스토리지: ${TARGET_STORAGE}"
echo ""
log_info "Proxmox Web UI:"
local pve_ip=$(hostname -I | awk '{print $1}')
echo " https://${pve_ip}:8006"
echo ""
log_info "유용한 명령어:"
echo " VM 상태: qm status ${TARGET_VMID}"
echo " VM 시작: qm start ${TARGET_VMID}"
echo " VM 중지: qm stop ${TARGET_VMID}"
echo " VM 정보: qm config ${TARGET_VMID}"
echo ""
log_info "PBS 스토리지:"
echo " 이름: ${PBS_STORAGE_NAME}"
echo " 제거: pvesm remove ${PBS_STORAGE_NAME}"
echo ""
}
# 메인 실행
main() {
print_banner
# Proxmox 환경 확인
check_proxmox
echo ""
# PBS 스토리지 등록
register_pbs || exit 1
echo ""
# PBS API 인증
pbs_login || exit 1
echo ""
# VM 103 백업 확인
check_backup_exists || exit 1
echo ""
# 최신 스냅샷 조회
get_latest_snapshot || exit 1
echo ""
# 스토리지 확인
check_storage || exit 1
echo ""
# 복구 설정 표시
show_restore_config
# 자동 복구 시작 (3초 대기)
log_warning "3초 후 자동으로 복구를 시작합니다..."
sleep 3
echo ""
# 백업 복구
restore_backup || exit 1
echo ""
# VM 시작 (선택)
start_vm
echo ""
# 완료 메시지
print_summary
}
# 도움말
if [ "$1" = "--help" ] || [ "$1" = "-h" ]; then
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "PBS 자동 복구 스크립트 - VM 103 → VM 203"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
echo "사용법: $0"
echo ""
echo "이 스크립트는:"
echo " 1. PBS 스토리지를 자동 등록"
echo " 2. VM 103 백업 자동 조회"
echo " 3. VM 203으로 자동 복구"
echo ""
echo "고정 설정:"
echo " 소스 VM ID: 103"
echo " 복구 VM ID: 203"
echo " 스토리지: local-lvm"
echo " PBS 서버: 100.64.0.10"
echo ""
echo "실행 예시:"
echo " bash $0"
echo ""
echo "사전 준비:"
echo " - Proxmox VE 설치 완료"
echo " - PBS 서버 (100.64.0.10) 접근 가능"
echo " - python3 설치 (보통 기본 설치됨)"
echo ""
echo "모든 설정이 자동화되어 있어 사용자 입력이 최소화됩니다!"
echo ""
exit 0
fi
# Root 권한 확인
if [ "$EUID" -ne 0 ]; then
log_error "이 스크립트는 root 권한이 필요합니다."
log_info "다음 명령으로 실행하세요: sudo $0"
exit 1
fi
# 스크립트 실행
main

1222
pharmq-setup-v2.sh Normal file

File diff suppressed because it is too large Load Diff

1247
pharmq-setup.sh Normal file

File diff suppressed because it is too large Load Diff

383
pve-host-changer.sh Normal file
View File

@@ -0,0 +1,383 @@
#!/bin/bash
#
# Proxmox VE 호스트명/FQDN 수정 스크립트
# 작성일: 2025-11-14
# 용도: Proxmox 설치 후 안전하게 호스트명 및 FQDN 변경
#
set -e
# 색상 정의
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
MAGENTA='\033[0;35m'
NC='\033[0m' # No Color
# 설정 변수
NEW_HOSTNAME=""
NEW_DOMAIN=""
NEW_FQDN=""
HOST_IP=""
# 로그 함수
log_info() {
echo -e "${BLUE}[INFO]${NC} $1"
}
log_success() {
echo -e "${GREEN}[SUCCESS]${NC} $1"
}
log_warning() {
echo -e "${YELLOW}[WARNING]${NC} $1"
}
log_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
log_step() {
echo -e "${CYAN}╰─► ${NC}$1"
}
# 배너 출력
print_banner() {
clear
echo -e "${MAGENTA}"
cat << "EOF"
╔═══════════════════════════════════════════════════════════════╗
║ ║
║ Proxmox VE 호스트명/FQDN 변경 스크립트 ║
║ 안전하게 호스트명을 변경합니다 ║
║ ║
╚═══════════════════════════════════════════════════════════════╝
EOF
echo -e "${NC}"
echo ""
}
# Root 권한 확인
check_root() {
if [ "$EUID" -ne 0 ]; then
log_error "이 스크립트는 root 권한이 필요합니다."
log_info "다음 명령으로 실행하세요: sudo $0"
exit 1
fi
}
# Proxmox VE 확인
check_proxmox() {
if [ ! -f /etc/pve/storage.cfg ]; then
log_error "Proxmox VE가 설치되어 있지 않습니다."
exit 1
fi
log_success "Proxmox VE 확인 완료"
}
# 현재 설정 표시
show_current_config() {
log_info "현재 호스트명 설정:"
echo ""
echo " FQDN: $(hostname -f)"
echo " 호스트명: $(hostname)"
echo " IP 주소: $(hostname -I | awk '{print $1}')"
echo ""
log_info "현재 /etc/hostname 내용:"
cat /etc/hostname | sed 's/^/ /'
echo ""
log_info "현재 /etc/hosts 내용:"
cat /etc/hosts | sed 's/^/ /'
echo ""
}
# 사용자 입력 받기
get_user_input() {
echo ""
log_info "새로운 호스트명 정보를 입력하세요."
echo ""
# 새 호스트명 입력
while true; do
read -p "$(echo -e ${CYAN}새 호스트명${NC}) (예: p0001-pve): " NEW_HOSTNAME
if [ -n "$NEW_HOSTNAME" ]; then
# 호스트명 검증 (알파벳, 숫자, 하이픈만 허용)
if [[ "$NEW_HOSTNAME" =~ ^[a-zA-Z0-9-]+$ ]]; then
break
else
log_error "호스트명은 알파벳, 숫자, 하이픈(-)만 사용 가능합니다."
fi
else
log_error "호스트명은 필수입니다."
fi
done
# 도메인 입력
while true; do
read -p "$(echo -e ${CYAN}도메인${NC}) (예: pharmq.kr): " NEW_DOMAIN
if [ -n "$NEW_DOMAIN" ]; then
# 도메인 검증 (기본적인 형식 체크)
if [[ "$NEW_DOMAIN" =~ ^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ ]]; then
break
else
log_error "올바른 도메인 형식이 아닙니다. (예: example.com)"
fi
else
log_error "도메인은 필수입니다."
fi
done
# FQDN 구성
NEW_FQDN="${NEW_HOSTNAME}.${NEW_DOMAIN}"
# IP 주소 자동 감지
HOST_IP=$(hostname -I | awk '{print $1}')
log_info "감지된 IP 주소: ${HOST_IP}"
read -p "$(echo -e ${CYAN}IP 주소 변경${NC}) [엔터로 ${HOST_IP} 사용]: " input_ip
if [ -n "$input_ip" ]; then
HOST_IP="$input_ip"
fi
echo ""
log_info "변경 요약:"
echo " 현재 FQDN: $(hostname -f)"
echo " 새 FQDN: ${NEW_FQDN}"
echo " 현재 호스트명: $(hostname)"
echo " 새 호스트명: ${NEW_HOSTNAME}"
echo " IP 주소: ${HOST_IP}"
echo ""
log_warning "⚠️ 주의사항:"
echo " 1. 클러스터 구성된 노드는 신중하게 변경하세요"
echo " 2. DNS에 ${NEW_FQDN}${HOST_IP} 레코드 등록 권장"
echo " 3. 인증서가 자동으로 재발급됩니다"
echo ""
read -p "$(echo -e ${YELLOW}이 설정으로 변경하시겠습니까?${NC}) (y/N): " confirm
if [[ ! "$confirm" =~ ^[Yy]$ ]]; then
log_info "작업 취소됨"
exit 0
fi
}
# 백업 생성
create_backup() {
log_step "현재 설정 백업 중..."
BACKUP_DIR="/root/hostname_backup_$(date +%Y%m%d_%H%M%S)"
mkdir -p "$BACKUP_DIR"
cp /etc/hostname "$BACKUP_DIR/hostname.bak"
cp /etc/hosts "$BACKUP_DIR/hosts.bak"
log_success "백업 완료: ${BACKUP_DIR}"
echo ""
}
# /etc/hostname 수정
update_hostname_file() {
log_step "/etc/hostname 수정 중..."
echo "$NEW_HOSTNAME" > /etc/hostname
log_success "/etc/hostname 수정 완료"
}
# /etc/hosts 수정
update_hosts_file() {
log_step "/etc/hosts 수정 중..."
# 기존 127.0.1.1 라인 제거 및 새 설정 추가
cat > /etc/hosts << EOF
127.0.0.1 localhost.localdomain localhost
${HOST_IP} ${NEW_FQDN} ${NEW_HOSTNAME}
# The following lines are desirable for IPv6 capable hosts
::1 ip6-localhost ip6-loopback
fe00::0 ip6-localnet
ff00::0 ip6-mcastprefix
ff02::1 ip6-allnodes
ff02::2 ip6-allrouters
ff02::3 ip6-allhosts
EOF
log_success "/etc/hosts 수정 완료"
}
# hostnamectl 적용
apply_hostnamectl() {
log_step "hostnamectl로 호스트명 적용 중..."
hostnamectl set-hostname "$NEW_HOSTNAME"
log_success "hostnamectl 적용 완료"
}
# Proxmox 서비스 재시작
restart_proxmox_services() {
log_step "Proxmox 서비스 재시작 중..."
systemctl restart pveproxy
systemctl restart pvedaemon
log_success "Proxmox 서비스 재시작 완료"
}
# 인증서 재발급
renew_certificate() {
log_step "인증서 재발급 중..."
echo ""
# 기존 인증서 백업
if [ -f /etc/pve/local/pveproxy-ssl.pem ]; then
cp /etc/pve/local/pveproxy-ssl.pem "$BACKUP_DIR/pveproxy-ssl.pem.bak" 2>/dev/null || true
fi
if [ -f /etc/pve/local/pveproxy-ssl.key ]; then
cp /etc/pve/local/pveproxy-ssl.key "$BACKUP_DIR/pveproxy-ssl.key.bak" 2>/dev/null || true
fi
# 인증서 재발급
pvecm updatecerts -f 2>/dev/null || pvenode cert renew 2>&1
sleep 2
systemctl restart pveproxy
echo ""
log_success "인증서 재발급 완료"
}
# 변경 사항 확인
verify_changes() {
log_step "변경 사항 확인 중..."
echo ""
CURRENT_FQDN=$(hostname -f)
CURRENT_HOSTNAME=$(hostname)
if [ "$CURRENT_FQDN" = "$NEW_FQDN" ] && [ "$CURRENT_HOSTNAME" = "$NEW_HOSTNAME" ]; then
log_success "호스트명 변경 성공!"
echo " FQDN: ${CURRENT_FQDN}"
echo " 호스트명: ${CURRENT_HOSTNAME}"
else
log_warning "호스트명이 완전히 적용되지 않았습니다."
echo " 예상 FQDN: ${NEW_FQDN}"
echo " 현재 FQDN: ${CURRENT_FQDN}"
echo " 예상 호스트명: ${NEW_HOSTNAME}"
echo " 현재 호스트명: ${CURRENT_HOSTNAME}"
echo ""
log_info "시스템 재부팅 후 완전히 적용됩니다."
fi
echo ""
}
# 다음 단계 안내
print_next_steps() {
echo ""
echo -e "${GREEN}╔═══════════════════════════════════════════════════════════════╗${NC}"
echo -e "${GREEN}║ 호스트명 변경 완료! ║${NC}"
echo -e "${GREEN}╚═══════════════════════════════════════════════════════════════╝${NC}"
echo ""
log_info "다음 단계:"
echo ""
echo "1. 시스템 재부팅 (권장):"
echo " reboot"
echo ""
echo "2. DNS에 A 레코드 등록 (선택사항):"
echo " ${NEW_FQDN}${HOST_IP}"
echo ""
echo "3. Proxmox Web UI 접속:"
echo " https://${HOST_IP}:8006"
echo " https://${NEW_FQDN}:8006"
echo ""
echo "4. 인증서 경고 발생 시:"
echo " 브라우저에서 예외 추가 또는 Let's Encrypt 인증서 발급"
echo " pvenode acme account register default mail@example.com"
echo " pvenode acme cert order"
echo ""
echo "5. 설정 롤백 (필요시):"
echo " cp ${BACKUP_DIR}/hostname.bak /etc/hostname"
echo " cp ${BACKUP_DIR}/hosts.bak /etc/hosts"
echo " reboot"
echo ""
log_info "백업 위치: ${BACKUP_DIR}"
echo ""
}
# 메인 실행
main() {
print_banner
# 권한 확인
check_root
# Proxmox 확인
check_proxmox
echo ""
# 현재 설정 표시
show_current_config
# 사용자 입력
get_user_input
# 백업 생성
create_backup
# 호스트명 변경
update_hostname_file
update_hosts_file
apply_hostnamectl
echo ""
# Proxmox 서비스 재시작
restart_proxmox_services
echo ""
# 인증서 재발급
renew_certificate
echo ""
# 변경 사항 확인
verify_changes
# 다음 단계 안내
print_next_steps
}
# 도움말
if [ "$1" = "--help" ] || [ "$1" = "-h" ]; then
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "Proxmox VE 호스트명/FQDN 변경 스크립트"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
echo "사용법: $0"
echo ""
echo "이 스크립트는:"
echo " 1. 현재 호스트명/FQDN 설정 백업"
echo " 2. /etc/hostname, /etc/hosts 수정"
echo " 3. hostnamectl로 호스트명 적용"
echo " 4. Proxmox 서비스 재시작"
echo " 5. 인증서 자동 재발급"
echo ""
echo "실행 예시:"
echo " bash $0"
echo ""
echo "주의사항:"
echo " - 단독 노드(Single node)에서는 안전하게 변경 가능"
echo " - 클러스터 구성 시 신중하게 진행 필요"
echo " - 변경 후 시스템 재부팅 권장"
echo ""
exit 0
fi
# 스크립트 실행
main

1
시간교정.md Normal file
View File

@@ -0,0 +1 @@
apt update && apt install -y ntpsec-ntpdate && ntpdate -u time.google.com && timedatectl