Compare commits
26 Commits
895b7a8ee7
...
live/pharm
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99fd031d5a | ||
|
|
32118e7c6c | ||
|
|
b581a2b3a8 | ||
|
|
7f3a6b9302 | ||
|
|
e778ecd4fc | ||
|
|
1ae707a985 | ||
|
|
41d3e7d946 | ||
|
|
8d27461f76 | ||
|
|
f739916737 | ||
|
|
46192651f9 | ||
|
|
efcd653db2 | ||
|
|
be4d337e2c | ||
|
|
057c5ccd0a | ||
|
|
560de20778 | ||
|
|
46b1580e52 | ||
|
|
36a4dca165 | ||
| 7aa08682b8 | |||
| be3795c7bf | |||
| c68ed59946 | |||
| a9aa31cc4a | |||
| b4ce883546 | |||
| 4123babcea | |||
| fb00b0a5fd | |||
| ac620a0e15 | |||
| 1dc09101cc | |||
| 0dda1423f8 |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -158,4 +158,7 @@ venv.bak/
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
.pyre/
|
||||
|
||||
# Submodules managed separately
|
||||
farmq-admin/
|
||||
289
FARMQ_ADMIN_MACHINE_NAME_FIX_PLAN.md
Normal file
289
FARMQ_ADMIN_MACHINE_NAME_FIX_PLAN.md
Normal file
@@ -0,0 +1,289 @@
|
||||
# FarmQ Admin 머신 이름 표시 문제 해결 계획
|
||||
|
||||
## 📋 문제 상황
|
||||
|
||||
현재 FarmQ Admin의 머신 관리 페이지에서 **머신 이름이 hostname으로만 표시**되고 있어, Magic DNS에서 사용되는 실제 노드 이름(`given_name`)과 일치하지 않는 문제가 발생하고 있습니다.
|
||||
|
||||
## 🔍 문제 분석
|
||||
|
||||
### 1. 현재 상황
|
||||
|
||||
**Headscale CLI API 실제 데이터:**
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"name": "0bin-Ubuntu-VM", // ❌ 실제 Magic DNS에서 사용되는 이름
|
||||
"given_name": "0bin-ubuntu-vm", // ✅ Magic DNS 접두어 (pev.headscale.local)
|
||||
"ip_addresses": ["100.64.0.1"],
|
||||
"user": {"name": "myuser"},
|
||||
"online": true
|
||||
}
|
||||
```
|
||||
|
||||
**FarmQ Admin 현재 표시:**
|
||||
```html
|
||||
<strong>{{ machine_data.machine_name or machine_data.hostname }}</strong>
|
||||
<div class="small text-muted">{{ machine_data.hostname }}</div>
|
||||
```
|
||||
|
||||
### 2. 문제의 근본 원인
|
||||
|
||||
#### A. 데이터베이스 스키마 불일치
|
||||
|
||||
**Headscale 모델:**
|
||||
```python
|
||||
# models/headscale_models.py
|
||||
class Node:
|
||||
hostname = Column(String) # 시스템 호스트명 (0bin-Ubuntu-VM)
|
||||
given_name = Column(String) # 사용자 지정 이름 (0bin-ubuntu-vm) - Magic DNS용
|
||||
```
|
||||
|
||||
**FarmQ 모델:**
|
||||
```python
|
||||
# models/farmq_models.py
|
||||
class MachineProfile:
|
||||
hostname = Column(String) # hostname으로 복사됨
|
||||
machine_name = Column(String) # hostname으로 중복 저장됨 ❌
|
||||
```
|
||||
|
||||
#### B. 동기화 로직 문제
|
||||
|
||||
```python
|
||||
# utils/database_new.py (line 343)
|
||||
machine_data = {
|
||||
'hostname': node.hostname, # "0bin-Ubuntu-VM"
|
||||
'machine_name': node.hostname, # ❌ hostname 중복! given_name이어야 함
|
||||
'tailscale_ip': node.ipv4,
|
||||
}
|
||||
```
|
||||
|
||||
#### C. 템플릿 표시 문제
|
||||
|
||||
```html
|
||||
<!-- templates/machines/list.html (line 115) -->
|
||||
<strong>{{ machine_data.machine_name or machine_data.hostname }}</strong>
|
||||
<!-- 현재: "0bin-Ubuntu-VM" (hostname) -->
|
||||
<!-- 원하는: "0bin-ubuntu-vm" (given_name) -->
|
||||
```
|
||||
|
||||
## ✅ 해결 방안
|
||||
|
||||
### 1. 즉시 수정 (Quick Fix)
|
||||
|
||||
#### A. 데이터베이스 동기화 로직 수정
|
||||
```python
|
||||
# farmq-admin/utils/database_new.py
|
||||
def get_all_machines_with_details():
|
||||
machine_data = {
|
||||
'hostname': node.hostname, # 시스템 호스트명
|
||||
'machine_name': node.given_name, # ✅ Magic DNS 이름으로 변경
|
||||
'display_name': node.given_name or node.hostname, # 표시용 이름
|
||||
'tailscale_ip': node.ipv4,
|
||||
}
|
||||
```
|
||||
|
||||
#### B. FarmQ 모델 동기화 수정
|
||||
```python
|
||||
# models/farmq_models.py - sync_machine_from_headscale
|
||||
machine = MachineProfile(
|
||||
hostname=headscale_node_data.get('hostname'),
|
||||
machine_name=headscale_node_data.get('given_name'), # ✅ given_name 사용
|
||||
tailscale_ip=headscale_node_data.get('ipv4'),
|
||||
)
|
||||
```
|
||||
|
||||
#### C. 템플릿 표시 개선
|
||||
```html
|
||||
<!-- templates/machines/list.html -->
|
||||
<strong>{{ machine_data.machine_name or machine_data.hostname }}</strong>
|
||||
<div class="small text-muted">
|
||||
<i class="fas fa-network-wired"></i> {{ machine_data.machine_name }}.headscale.local
|
||||
</div>
|
||||
<div class="small text-muted">
|
||||
<i class="fas fa-server"></i> {{ machine_data.hostname }}
|
||||
</div>
|
||||
```
|
||||
|
||||
### 2. 근본적 개선 (Long-term)
|
||||
|
||||
#### A. 필드 명칭 명확화
|
||||
```python
|
||||
class MachineProfile:
|
||||
system_hostname = Column(String) # 시스템 호스트명 (0bin-Ubuntu-VM)
|
||||
headscale_name = Column(String) # Headscale given_name (0bin-ubuntu-vm)
|
||||
magic_dns_name = Column(String) # Magic DNS 전체 이름 (0bin-ubuntu-vm.headscale.local)
|
||||
display_name = Column(String) # 사용자 표시용 이름
|
||||
```
|
||||
|
||||
#### B. Magic DNS 정보 표시 강화
|
||||
```html
|
||||
<div class="machine-info">
|
||||
<h5>{{ machine.display_name }}</h5>
|
||||
<div class="dns-info">
|
||||
<code>{{ machine.magic_dns_name }}</code>
|
||||
<button class="btn btn-sm" onclick="copyToClipboard('{{ machine.magic_dns_name }}')">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="system-info small text-muted">
|
||||
시스템: {{ machine.system_hostname }}
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
## 🚀 구현 단계
|
||||
|
||||
### Phase 1: 긴급 수정 (1-2시간)
|
||||
|
||||
1. **데이터 동기화 로직 수정**
|
||||
```bash
|
||||
# 파일: farmq-admin/utils/database_new.py
|
||||
# 라인: 343
|
||||
'machine_name': node.given_name, # hostname → given_name
|
||||
```
|
||||
|
||||
2. **템플릿 표시 개선**
|
||||
```bash
|
||||
# 파일: farmq-admin/templates/machines/list.html
|
||||
# Magic DNS 정보 추가 표시
|
||||
```
|
||||
|
||||
3. **동기화 함수 수정**
|
||||
```bash
|
||||
# 파일: farmq-admin/models/farmq_models.py
|
||||
# sync_machine_from_headscale 함수 수정
|
||||
```
|
||||
|
||||
### Phase 2: 표시 개선 (2-3시간)
|
||||
|
||||
1. **Magic DNS 정보 강화 표시**
|
||||
- `.headscale.local` 접미사 자동 표시
|
||||
- 클립보드 복사 기능
|
||||
- 연결 테스트 기능
|
||||
|
||||
2. **필터링 기능 추가**
|
||||
- Magic DNS 이름으로 검색
|
||||
- 온라인/오프라인 필터
|
||||
- 사용자별 필터
|
||||
|
||||
### Phase 3: 구조적 개선 (4-6시간)
|
||||
|
||||
1. **데이터베이스 스키마 개선**
|
||||
- 필드명 명확화
|
||||
- Magic DNS 전용 필드 추가
|
||||
- 인덱스 최적화
|
||||
|
||||
2. **API 통합 개선**
|
||||
- Headscale CLI 응답 캐싱
|
||||
- 실시간 상태 업데이트
|
||||
- WebSocket을 통한 실시간 알림
|
||||
|
||||
## 📊 예상 결과
|
||||
|
||||
### Before (현재)
|
||||
```
|
||||
머신 이름: 0bin-Ubuntu-VM # hostname (시스템명)
|
||||
호스트명: 0bin-Ubuntu-VM # 중복 정보
|
||||
Magic DNS: 사용불가 ❌ # given_name 정보 부족
|
||||
```
|
||||
|
||||
### After (수정 후)
|
||||
```
|
||||
머신 이름: 0bin-ubuntu-vm # given_name (Magic DNS용)
|
||||
Magic DNS: 0bin-ubuntu-vm.headscale.local ✅
|
||||
시스템명: 0bin-Ubuntu-VM # hostname (참고 정보)
|
||||
IP 주소: 100.64.0.1 # 현재와 동일
|
||||
```
|
||||
|
||||
## 🧪 테스트 계획
|
||||
|
||||
### 1. 데이터 검증
|
||||
```python
|
||||
# 테스트 스크립트
|
||||
def test_machine_name_mapping():
|
||||
nodes = headscale_session.query(Node).all()
|
||||
for node in nodes:
|
||||
print(f"ID: {node.id}")
|
||||
print(f"Hostname: {node.hostname}") # 0bin-Ubuntu-VM
|
||||
print(f"Given Name: {node.given_name}") # 0bin-ubuntu-vm
|
||||
print(f"Magic DNS: {node.given_name}.headscale.local")
|
||||
print("---")
|
||||
```
|
||||
|
||||
### 2. Magic DNS 연결 테스트
|
||||
```bash
|
||||
# 각 노드별 Magic DNS 테스트
|
||||
ping 0bin-ubuntu-vm.headscale.local
|
||||
ping pev.headscale.local
|
||||
ping pqserver.headscale.local
|
||||
```
|
||||
|
||||
### 3. UI 표시 확인
|
||||
- 머신 목록에서 올바른 이름 표시
|
||||
- Magic DNS 주소 복사 기능
|
||||
- 연결 상태와 일치성 확인
|
||||
|
||||
## 📝 체크리스트
|
||||
|
||||
### 코드 수정
|
||||
- [ ] `farmq-admin/utils/database_new.py` - 동기화 로직 수정
|
||||
- [ ] `farmq-admin/models/farmq_models.py` - 모델 동기화 수정
|
||||
- [ ] `farmq-admin/templates/machines/list.html` - 표시 개선
|
||||
- [ ] `farmq-admin/templates/machines/detail.html` - 상세 페이지 수정
|
||||
|
||||
### 테스트
|
||||
- [ ] 데이터베이스 동기화 테스트
|
||||
- [ ] Magic DNS 이름 표시 확인
|
||||
- [ ] UI 표시 정상성 확인
|
||||
- [ ] 기존 기능 호환성 테스트
|
||||
|
||||
### 문서화
|
||||
- [ ] 변경사항 README 업데이트
|
||||
- [ ] API 문서 갱신
|
||||
- [ ] 사용자 가이드 수정
|
||||
|
||||
## 🔧 구현 파일 목록
|
||||
|
||||
### 수정할 파일들
|
||||
1. **`farmq-admin/utils/database_new.py`** (라인 343)
|
||||
- `machine_name` 필드를 `given_name`으로 변경
|
||||
|
||||
2. **`farmq-admin/models/farmq_models.py`** (라인 445)
|
||||
- 동기화 시 `given_name` 사용
|
||||
|
||||
3. **`farmq-admin/templates/machines/list.html`** (라인 115-119)
|
||||
- Magic DNS 정보 추가 표시
|
||||
|
||||
4. **`farmq-admin/templates/machines/detail.html`**
|
||||
- 상세 페이지 Magic DNS 정보 개선
|
||||
|
||||
### 새로 추가할 기능
|
||||
- Magic DNS 주소 클립보드 복사
|
||||
- 연결 테스트 버튼
|
||||
- 실시간 온라인 상태 표시
|
||||
|
||||
## 💡 장기적 개선사항
|
||||
|
||||
### 1. Headscale API 직접 통합
|
||||
현재 CLI 기반 → REST API 직접 호출로 전환하여 성능 개선
|
||||
|
||||
### 2. 실시간 모니터링
|
||||
WebSocket을 통한 실시간 노드 상태 업데이트
|
||||
|
||||
### 3. Magic DNS 관리 기능
|
||||
- 노드 이름 변경
|
||||
- Magic DNS 도메인 설정
|
||||
- DNS 해석 테스트 도구
|
||||
|
||||
## 📅 구현 일정
|
||||
|
||||
| 단계 | 작업 | 소요시간 | 완료일 |
|
||||
|------|------|---------|---------|
|
||||
| Phase 1 | 긴급 수정 | 2시간 | 당일 |
|
||||
| Phase 2 | 표시 개선 | 3시간 | 1일 |
|
||||
| Phase 3 | 구조 개선 | 6시간 | 2-3일 |
|
||||
|
||||
---
|
||||
|
||||
**작성일:** 2025년 9월 13일
|
||||
**업데이트:** FarmQ Admin 머신 이름 표시 문제 분석 및 해결 계획 수립 완료
|
||||
362
FARMQ_인프라_아키텍처_완전분석.md
Normal file
362
FARMQ_인프라_아키텍처_완전분석.md
Normal file
@@ -0,0 +1,362 @@
|
||||
# FARMQ 인프라 아키텍처 완전 분석
|
||||
|
||||
## 📋 개요
|
||||
|
||||
FARMQ는 100개 약국 네트워크를 관리하는 혁신적인 인프라로, **기존 VPN 방식의 한계를 뛰어넘는 SSL 도메인 직접 접속 구조**를 구현했습니다. Headscale을 관리 목적으로 활용하면서도, 일반 사용자는 VPN 설치 없이 웹브라우저만으로 각 지역 Proxmox에 접속할 수 있는 독창적인 아키텍처입니다.
|
||||
|
||||
## 🏗️ 전체 네트워크 구성
|
||||
|
||||
### 1. 외부 인증 및 라우팅 계층
|
||||
```
|
||||
[클라우드플레어]
|
||||
├── DNS-01 챌린지 (API 인증)
|
||||
├── Let's Encrypt SSL 인증서 발급
|
||||
└── *.pharmq.kr 와일드카드 인증서
|
||||
```
|
||||
|
||||
### 2. 물리적 네트워크 구성
|
||||
```
|
||||
[ISP KT] 192.168.0.1 (게이트웨이)
|
||||
└── Proxmox Host: 192.168.0.200
|
||||
├── Ubuntu VM 104: 192.168.0.100 (Headscale 중앙서버)
|
||||
└── Debian LXC 103: 192.168.0.19 (Caddy 리버스 프록시)
|
||||
```
|
||||
|
||||
## 🔧 현재 시스템 상태 확인 (2025-09-22 13:28 기준)
|
||||
|
||||
### Docker 컨테이너 상태
|
||||
```
|
||||
CONTAINER: headscale (a1e850fbf942)
|
||||
├── 상태: Up 4 hours (healthy)
|
||||
├── 포트: 8070→8080 (FARMQ Admin), 9090→9090 (Headscale API)
|
||||
├── 이미지: headscale/headscale:latest
|
||||
└── 헬스체크: 정상
|
||||
```
|
||||
|
||||
### 활성 프로세스
|
||||
```
|
||||
✅ Docker 서비스: Active (running) - PID 36505
|
||||
✅ Headscale 컨테이너: /ko-app/headscale serve - PID 38393
|
||||
✅ FARMQ Admin: Python Flask 앱 - PID 53064
|
||||
✅ Docker 프록시: 4개 프로세스 (포트 8070, 9090 바인딩)
|
||||
```
|
||||
|
||||
### Headscale 네트워크 상태
|
||||
```
|
||||
노드 상태 (docker exec headscale headscale nodes list):
|
||||
├── ubuntu (ID: 1, 100.64.0.1): 🟢 온라인 - Last seen: 2025-09-22 12:17:26
|
||||
├── pve5 (ID: 2, 100.64.0.2): 🟢 온라인 - Last seen: 2025-09-22 12:17:26
|
||||
└── caddy (ID: 3, 100.64.0.3): 🟢 온라인 - Last seen: 2025-09-22 12:17:26
|
||||
|
||||
사용자: default (ID: 1, Created: 2025-09-22 09:12:02)
|
||||
IP 대역: 100.64.0.x/10, fd7a:115c:a1e0::/48 (IPv6)
|
||||
```
|
||||
|
||||
### 서비스 엔드포인트 상태
|
||||
```
|
||||
✅ FARMQ Health Check (localhost:8070): {"status":"pass"}
|
||||
✅ 포트 바인딩 확인 (ss -tlnp):
|
||||
- 0.0.0.0:8070 (FARMQ Admin)
|
||||
- 0.0.0.0:9090 (Headscale API)
|
||||
- IPv6 지원 활성화
|
||||
```
|
||||
|
||||
### 시스템 리소스
|
||||
```
|
||||
디스크 사용량: 4.2G/14G (31% 사용)
|
||||
메모리 사용량:
|
||||
├── Headscale 컨테이너: ~56MB
|
||||
├── FARMQ Admin Python: ~83MB
|
||||
└── Docker 데몬: ~84MB
|
||||
```
|
||||
|
||||
## ⚡ 핵심 혁신: VPN 불필요 SSL 접속
|
||||
|
||||
### 기존 Magic DNS vs 우리 아키텍처
|
||||
|
||||
#### ❌ 기존 Magic DNS 한계
|
||||
```
|
||||
Magic DNS (Tailscale/Headscale 표준)
|
||||
├── 클라이언트가 반드시 VPN 네트워크에 포함되어야 함
|
||||
├── 100.x.x.x 내부 IP로만 접근 가능
|
||||
├── 외부 네트워크에서 직접 접근 불가
|
||||
├── 모든 접속 장치에 노드 설치 필요
|
||||
├── 복잡한 키 관리 및 네트워크 설정
|
||||
└── 방화벽 및 보안 정책 충돌 가능성
|
||||
```
|
||||
|
||||
#### ✅ 우리의 혁신적 구조
|
||||
```
|
||||
SSL 도메인 직접 접속 (VPN 불필요)
|
||||
├── 외부 인터넷에서 바로 pve1.pharmq.kr 접속
|
||||
├── SSL 인증서로 보안 연결 (Let's Encrypt)
|
||||
├── 클라이언트에 VPN 설치 불필요
|
||||
├── 일반 웹브라우저로 즉시 접근 가능
|
||||
├── 복잡한 네트워크 설정 없음
|
||||
└── 사용자 친화적 웹 인터페이스
|
||||
```
|
||||
|
||||
## 🌐 SSL 도메인 직접 접속 구조
|
||||
|
||||
### 접속 플로우 (VPN 없이)
|
||||
```
|
||||
[외부 사용자]
|
||||
↓ HTTPS 요청 (웹브라우저)
|
||||
[pve1.pharmq.kr:8006]
|
||||
↓ Cloudflare DNS 조회
|
||||
[Caddy 리버스 프록시] (192.168.0.19)
|
||||
↓ SSL 터미네이션 + 라우팅
|
||||
[지역 Proxmox Host] (로컬망/LTE망)
|
||||
↓ 웹 인터페이스 제공
|
||||
[Proxmox 관리 화면]
|
||||
```
|
||||
|
||||
### 각 지역 Proxmox 접속 예시
|
||||
```
|
||||
🏥 약국 A: pve1.pharmq.kr → 부산 지역 Proxmox
|
||||
🏥 약국 B: pve2.pharmq.kr → 서울 지역 Proxmox
|
||||
🏥 약국 C: pve3.pharmq.kr → 대구 지역 Proxmox
|
||||
🏥 약국 D: pve4.pharmq.kr → 대전 지역 Proxmox
|
||||
|
||||
모든 접속이 SSL 보안 + 공인 도메인으로 가능
|
||||
브라우저 주소창에 직접 입력하여 접속
|
||||
```
|
||||
|
||||
## 🔄 Headscale의 실제 역할
|
||||
|
||||
### Headscale 네트워크 (내부 관리용)
|
||||
```
|
||||
Headscale은 관리 목적으로만 사용:
|
||||
├── 중앙 서버 ↔ 지역 Proxmox 간 관리 통신
|
||||
├── 모니터링 및 상태 확인
|
||||
├── 원격 유지보수 및 업데이트
|
||||
├── FARMQ Admin 웹 인터페이스 데이터 수집
|
||||
└── 100.64.0.x 대역으로 내부 관리망 구성
|
||||
```
|
||||
|
||||
### 일반 사용자 접속 (Headscale 독립)
|
||||
```
|
||||
일반 사용자는 Headscale 불필요:
|
||||
├── 웹브라우저로 pve1.pharmq.kr 직접 접속
|
||||
├── VPN 클라이언트 설치 없음
|
||||
├── 복잡한 네트워크 설정 없음
|
||||
├── 즉시 Proxmox 웹 인터페이스 사용
|
||||
└── 스마트폰에서도 동일하게 접속 가능
|
||||
```
|
||||
|
||||
## 🎯 아키텍처의 혁신적 장점
|
||||
|
||||
### 1. 사용자 편의성
|
||||
```
|
||||
기존 VPN 방식:
|
||||
❌ 각 PC에 Tailscale/Headscale 클라이언트 설치
|
||||
❌ 복잡한 네트워크 설정 및 키 관리
|
||||
❌ 방화벽 및 보안 정책 충돌 가능성
|
||||
❌ 모바일 장치에서 복잡한 설정
|
||||
|
||||
우리 SSL 방식:
|
||||
✅ 웹브라우저만 있으면 즉시 접속
|
||||
✅ 설치나 설정 과정 불필요
|
||||
✅ 일반 웹사이트처럼 직관적 접근
|
||||
✅ 모든 플랫폼에서 동일한 사용자 경험
|
||||
```
|
||||
|
||||
### 2. 네트워크 투명성
|
||||
```
|
||||
지역별 Proxmox 환경:
|
||||
├── 로컬 라우터 뒤 (NAT 환경)
|
||||
├── LTE/5G 모바일 연결
|
||||
├── 기업용 방화벽 뒤
|
||||
├── 공공 WiFi 환경
|
||||
└── 모든 환경에서 동일한 pveX.pharmq.kr 접속
|
||||
```
|
||||
|
||||
### 3. 보안 및 확장성
|
||||
```
|
||||
SSL 인증서 자동 관리:
|
||||
├── Let's Encrypt 자동 갱신 (90일마다)
|
||||
├── Cloudflare DNS-01 챌린지
|
||||
├── 와일드카드 인증서로 무제한 서브도메인
|
||||
├── 각 지역별 독립적 보안 정책 적용 가능
|
||||
└── TLS 1.3 최신 보안 프로토콜 지원
|
||||
```
|
||||
|
||||
## 📊 FARMQ Admin 구현 아키텍처
|
||||
|
||||
### 계층 구조
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ FARMQ Admin │ ← 웹 UI, 약국 관리, 대시보드
|
||||
│ (Flask + Bootstrap + JS) │
|
||||
├─────────────────────────────────────┤
|
||||
│ API Layer │ ← REST API, CLI 인터페이스
|
||||
│ (Python subprocess calls) │
|
||||
├─────────────────────────────────────┤
|
||||
│ Headscale CLI │ ← 네트워크 관리 엔진
|
||||
│ (Docker containerized) │
|
||||
├─────────────────────────────────────┤
|
||||
│ Database Layer │ ← 이중 데이터베이스
|
||||
│ ┌─────────────┬─────────────────┐ │
|
||||
│ │ FARMQ DB │ Headscale DB │ │
|
||||
│ │ (약국정보) │ (노드정보) │ │
|
||||
│ └─────────────┴─────────────────┘ │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### CLI 기반 기능 구현 패턴
|
||||
```python
|
||||
# 표준 구현 패턴
|
||||
def headscale_function():
|
||||
try:
|
||||
# Docker를 통해 Headscale CLI 실행
|
||||
result = subprocess.run(
|
||||
['docker', 'exec', 'headscale', 'headscale', 'command', 'args'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True
|
||||
)
|
||||
|
||||
# JSON 출력 파싱 (가능한 경우)
|
||||
if '-o json' in args:
|
||||
data = json.loads(result.stdout)
|
||||
return data
|
||||
|
||||
return {'success': True, 'output': result.stdout}
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
return {'success': False, 'error': e.stderr}
|
||||
```
|
||||
|
||||
## 🌐 데이터 플로우 및 연결성
|
||||
|
||||
### 인바운드 트래픽
|
||||
```
|
||||
외부 클라이언트
|
||||
↓ HTTPS://pve1.pharmq.kr
|
||||
Cloudflare DNS
|
||||
↓ IP 주소 해석
|
||||
KT ISP (192.168.0.1)
|
||||
↓ 라우팅
|
||||
Caddy LXC (192.168.0.19)
|
||||
↓ SSL 터미네이션 + 프록시
|
||||
Headscale VM (192.168.0.100)
|
||||
↓ 최종 서비스
|
||||
```
|
||||
|
||||
### 내부 네트워크 통신
|
||||
```
|
||||
FARMQ Admin (8070) ← 웹 인터페이스 및 API
|
||||
Headscale API (9090) ← CLI 명령 처리 및 노드 관리
|
||||
Docker 네트워크 (172.18.0.2) ← 컨테이너 간 통신
|
||||
Headscale VPN (100.64.0.x) ← 관리 목적 내부 통신
|
||||
```
|
||||
|
||||
## 🚀 확장 시나리오
|
||||
|
||||
### 새로운 지역 추가 시
|
||||
```
|
||||
1. 새 Proxmox Host 설치 (임의의 네트워크 환경)
|
||||
2. pveN.pharmq.kr DNS 레코드 추가 (Cloudflare)
|
||||
3. Caddy 라우팅 규칙 업데이트
|
||||
4. SSL 인증서 자동 발급 (Let's Encrypt)
|
||||
5. 즉시 외부 접속 가능
|
||||
|
||||
선택사항:
|
||||
- Headscale VPN 노드 추가 (관리 목적)
|
||||
- FARMQ Admin에서 모니터링 설정
|
||||
```
|
||||
|
||||
### 100개 약국 확장 예시
|
||||
```
|
||||
pve1.pharmq.kr → 서울 강남구 약국
|
||||
pve2.pharmq.kr → 부산 해운대구 약국
|
||||
pve3.pharmq.kr → 대구 중구 약국
|
||||
...
|
||||
pve100.pharmq.kr → 제주도 약국
|
||||
|
||||
각각 독립적인 SSL 도메인으로 접속
|
||||
중앙에서 FARMQ Admin으로 통합 관리
|
||||
```
|
||||
|
||||
## 🔐 보안 고려사항
|
||||
|
||||
### 1. SSL/TLS 보안
|
||||
```
|
||||
인증서 관리:
|
||||
├── Let's Encrypt 무료 인증서
|
||||
├── 90일 자동 갱신
|
||||
├── TLS 1.3 최신 프로토콜
|
||||
├── Perfect Forward Secrecy (PFS)
|
||||
└── HSTS (HTTP Strict Transport Security)
|
||||
```
|
||||
|
||||
### 2. 네트워크 보안
|
||||
```
|
||||
접근 제어:
|
||||
├── Cloudflare DDoS 보호
|
||||
├── Caddy 리버스 프록시 보안 헤더
|
||||
├── Proxmox 자체 인증 시스템
|
||||
├── 각 지역별 독립적 보안 정책
|
||||
└── VPN 관리망은 별도 보안 채널
|
||||
```
|
||||
|
||||
### 3. 관리 보안
|
||||
```
|
||||
FARMQ Admin:
|
||||
├── Flask 세션 관리
|
||||
├── API 엔드포인트 권한 확인
|
||||
├── Headscale CLI 명령 검증
|
||||
├── 약국별 데이터 접근 제한
|
||||
└── 관리자/사용자 역할 구분
|
||||
```
|
||||
|
||||
## 📈 성능 최적화
|
||||
|
||||
### 1. 네트워크 최적화
|
||||
```
|
||||
연결 경로 최적화:
|
||||
├── Cloudflare CDN 활용
|
||||
├── Caddy HTTP/2 지원
|
||||
├── Keep-Alive 연결 유지
|
||||
├── Gzip 압축 활성화
|
||||
└── 정적 자원 캐싱
|
||||
```
|
||||
|
||||
### 2. 서버 최적화
|
||||
```
|
||||
리소스 관리:
|
||||
├── Docker 컨테이너 리소스 제한
|
||||
├── Python Flask 앱 최적화
|
||||
├── 데이터베이스 쿼리 최적화
|
||||
├── CLI 호출 최소화
|
||||
└── 결과 캐싱 (단기간)
|
||||
```
|
||||
|
||||
## 🎯 결론
|
||||
|
||||
### 핵심 혁신점
|
||||
1. **VPN 설치 불필요**: 웹브라우저만으로 모든 Proxmox 접속
|
||||
2. **사용자 친화성**: 복잡한 네트워크 설정 없이 즉시 사용
|
||||
3. **확장성**: 새 지역 추가 시 DNS 레코드만 추가하면 완료
|
||||
4. **보안성**: SSL/TLS 표준 보안 + Cloudflare 보호
|
||||
5. **투명성**: 네트워크 환경에 관계없이 동일한 접속 방법
|
||||
|
||||
### 기존 방식 대비 우위
|
||||
```
|
||||
Magic DNS/VPN 방식:
|
||||
- 복잡한 클라이언트 설치 및 설정
|
||||
- 네트워크 정책 충돌 가능성
|
||||
- 모바일에서 사용성 제한
|
||||
|
||||
우리 SSL 방식:
|
||||
- 웹 표준 기술 활용
|
||||
- 모든 플랫폼에서 동일한 경험
|
||||
- 기업 방화벽과 충돌 없음
|
||||
```
|
||||
|
||||
FARMQ 인프라는 **Magic DNS보다 훨씬 실용적이고 사용자 친화적**인 구조로, **VPN의 복잡성 없이 SSL의 보안성**을 제공하는 **혁신적인 하이브리드 아키텍처**입니다.
|
||||
|
||||
---
|
||||
*Document Generated: 2025-09-22 13:28 UTC*
|
||||
*System Status: All Services Operational*
|
||||
*Generated with [Claude Code](https://claude.ai/code)*
|
||||
343
Multi-Proxmox-Management-System.md
Normal file
343
Multi-Proxmox-Management-System.md
Normal file
@@ -0,0 +1,343 @@
|
||||
# 다중 Proxmox 호스트 관리 시스템 기획서
|
||||
|
||||
## 🎯 프로젝트 개요
|
||||
|
||||
약국별 독립적인 Proxmox 서버를 중앙에서 통합 관리하는 시스템 구축. Headscale 네트워크를 통해 여러 Proxmox 호스트에 접속하여 VM을 관리하고 VNC 원격 접속을 제공.
|
||||
|
||||
## 📋 현재 상황 분석
|
||||
|
||||
### 기존 구현 현황
|
||||
- ✅ 단일 Proxmox 서버 (`pve7.0bin.in`) 연동 완료
|
||||
- ✅ VNC WebSocket 직접 연결 구현
|
||||
- ✅ VNC 인증 실패 자동 해결 시스템
|
||||
- ✅ VM 목록 조회 및 상태 확인
|
||||
- ✅ VM 시작/정지 기능
|
||||
|
||||
### 확장 요구사항
|
||||
- 약국 수: 100개 (예상)
|
||||
- Proxmox 호스트: 약국별 1개씩 (총 100대)
|
||||
- 네트워크: Headscale VPN으로 연결된 사설 IP (예: 100.64.0.x)
|
||||
- 인증: 모든 Proxmox에서 동일한 `root@pam` 계정 사용
|
||||
|
||||
## 🏗️ 시스템 아키텍처
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Central Management Web UI │
|
||||
│ (farmq-admin) │
|
||||
└─────────────────┬───────────────────────┘
|
||||
│
|
||||
┌─────────┴─────────┐
|
||||
│ Headscale VPN │
|
||||
│ Network │
|
||||
└─────────┬─────────┘
|
||||
│
|
||||
┌─────────────┼─────────────┐
|
||||
│ │ │
|
||||
┌───▼───┐ ┌───▼───┐ ┌───▼───┐
|
||||
│약국 A │ │약국 B │ │약국 C │
|
||||
│Proxmox│ │Proxmox│ │Proxmox│
|
||||
│100.64.│ │100.64.│ │100.64.│
|
||||
│0.10 │ │0.11 │ │0.12 │
|
||||
└───┬───┘ └───┬───┘ └───┬───┘
|
||||
│ │ │
|
||||
┌───▼───┐ ┌───▼───┐ ┌───▼───┐
|
||||
│VM들 │ │VM들 │ │VM들 │
|
||||
│- 키오스│ │- POS │ │- 서버 │
|
||||
│- POS │ │- 키오스│ │- 백업 │
|
||||
└───────┘ └───────┘ └───────┘
|
||||
```
|
||||
|
||||
## 📊 데이터베이스 설계
|
||||
|
||||
### 1. Proxmox 호스트 관리 테이블
|
||||
```sql
|
||||
CREATE TABLE proxmox_hosts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
pharmacy_id INTEGER NOT NULL,
|
||||
pharmacy_name VARCHAR(255) NOT NULL,
|
||||
host_ip VARCHAR(15) NOT NULL, -- 100.64.0.x
|
||||
host_port INTEGER DEFAULT 443,
|
||||
username VARCHAR(50) DEFAULT 'root@pam',
|
||||
password VARCHAR(255) NOT NULL,
|
||||
status ENUM('online', 'offline', 'maintenance') DEFAULT 'offline',
|
||||
last_check TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (pharmacy_id) REFERENCES pharmacies(pharmacy_id)
|
||||
);
|
||||
```
|
||||
|
||||
### 2. VM 인벤토리 테이블
|
||||
```sql
|
||||
CREATE TABLE vm_inventory (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
proxmox_host_id INTEGER NOT NULL,
|
||||
vmid INTEGER NOT NULL,
|
||||
vm_name VARCHAR(255) NOT NULL,
|
||||
vm_type ENUM('kiosk', 'pos', 'server', 'backup', 'other') DEFAULT 'other',
|
||||
node_name VARCHAR(100) NOT NULL,
|
||||
status ENUM('running', 'stopped', 'suspended') DEFAULT 'stopped',
|
||||
cpu_cores INTEGER,
|
||||
memory_mb INTEGER,
|
||||
disk_gb INTEGER,
|
||||
ip_address VARCHAR(15),
|
||||
description TEXT,
|
||||
last_sync TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (proxmox_host_id) REFERENCES proxmox_hosts(id)
|
||||
);
|
||||
```
|
||||
|
||||
### 3. VNC 세션 확장 테이블
|
||||
```sql
|
||||
CREATE TABLE vnc_sessions_extended (
|
||||
session_id VARCHAR(36) PRIMARY KEY,
|
||||
proxmox_host_id INTEGER NOT NULL,
|
||||
vmid INTEGER NOT NULL,
|
||||
vm_name VARCHAR(255) NOT NULL,
|
||||
websocket_url TEXT NOT NULL,
|
||||
password VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TIMESTAMP,
|
||||
last_refresh TIMESTAMP,
|
||||
FOREIGN KEY (proxmox_host_id) REFERENCES proxmox_hosts(id)
|
||||
);
|
||||
```
|
||||
|
||||
## 🔧 주요 기능 구현 계획
|
||||
|
||||
### 1. Proxmox 호스트 관리
|
||||
- **호스트 등록/수정/삭제**
|
||||
- 약국 정보와 연동하여 Proxmox 호스트 정보 관리
|
||||
- IP, 포트, 인증 정보 저장
|
||||
|
||||
- **호스트 상태 모니터링**
|
||||
- 주기적 헬스체크 (ping, API 연결 확인)
|
||||
- 오프라인 호스트 알림 및 상태 표시
|
||||
|
||||
- **보안 관리**
|
||||
- 패스워드 암호화 저장
|
||||
- API 토큰 관리 옵션 제공
|
||||
|
||||
### 2. 통합 VM 관리 대시보드
|
||||
- **멀티 호스트 VM 목록**
|
||||
```
|
||||
약국A (100.64.0.10) - 온라인
|
||||
├── VM 101: Kiosk-1 (실행중)
|
||||
├── VM 102: POS-System (정지됨)
|
||||
└── VM 103: Backup-Server (실행중)
|
||||
|
||||
약국B (100.64.0.11) - 오프라인
|
||||
├── 연결 불가
|
||||
|
||||
약국C (100.64.0.12) - 온라인
|
||||
├── VM 201: Main-Server (실행중)
|
||||
└── VM 202: Kiosk-Terminal (실행중)
|
||||
```
|
||||
|
||||
- **통합 검색 및 필터링**
|
||||
- 약국별, VM 타입별, 상태별 필터링
|
||||
- VM 이름 및 IP 주소 검색
|
||||
- 상태별 대시보드 (전체 실행중/정지됨 VM 수)
|
||||
|
||||
- **배치 작업**
|
||||
- 여러 VM 동시 시작/정지
|
||||
- 약국별 전체 VM 관리
|
||||
- 예약 작업 (특정 시간에 VM 시작/정지)
|
||||
|
||||
### 3. 동적 VNC 접속 시스템
|
||||
- **호스트 자동 선택**
|
||||
```python
|
||||
def connect_to_vm(pharmacy_id, vmid):
|
||||
# 1. pharmacy_id로 Proxmox 호스트 정보 조회
|
||||
host_info = get_proxmox_host_by_pharmacy(pharmacy_id)
|
||||
|
||||
# 2. 해당 호스트에서 VM 정보 확인
|
||||
vm_info = get_vm_info(host_info, vmid)
|
||||
|
||||
# 3. VNC 티켓 생성 및 연결
|
||||
vnc_ticket = create_vnc_ticket(host_info, vm_info)
|
||||
return vnc_ticket
|
||||
```
|
||||
|
||||
- **다중 세션 관리**
|
||||
- 서로 다른 Proxmox 호스트의 여러 VM에 동시 VNC 접속
|
||||
- 세션별 독립적인 티켓 관리
|
||||
- 탭 기반 다중 VNC 창 지원
|
||||
|
||||
### 4. 모니터링 및 알림
|
||||
- **리소스 모니터링**
|
||||
- 모든 Proxmox 호스트의 CPU, 메모리, 스토리지 사용량
|
||||
- VM별 리소스 사용 현황
|
||||
- 임계치 초과 시 알림
|
||||
|
||||
- **이벤트 로그**
|
||||
- VM 시작/정지 이력
|
||||
- VNC 접속 이력
|
||||
- 시스템 오류 및 복구 이력
|
||||
|
||||
## 🛠️ API 설계
|
||||
|
||||
### Proxmox 호스트 관리 API
|
||||
```http
|
||||
GET /api/proxmox/hosts # 호스트 목록 조회
|
||||
POST /api/proxmox/hosts # 호스트 등록
|
||||
PUT /api/proxmox/hosts/{host_id} # 호스트 정보 수정
|
||||
DELETE /api/proxmox/hosts/{host_id} # 호스트 삭제
|
||||
GET /api/proxmox/hosts/{host_id}/health # 호스트 상태 확인
|
||||
```
|
||||
|
||||
### 통합 VM 관리 API
|
||||
```http
|
||||
GET /api/vms # 모든 호스트의 VM 목록
|
||||
GET /api/vms/pharmacy/{pharmacy_id} # 특정 약국의 VM 목록
|
||||
GET /api/vms/sync # VM 정보 동기화
|
||||
POST /api/vms/batch/start # 여러 VM 일괄 시작
|
||||
POST /api/vms/batch/stop # 여러 VM 일괄 정지
|
||||
```
|
||||
|
||||
### 동적 VNC 접속 API
|
||||
```http
|
||||
POST /api/vnc/connect # 동적 VNC 연결 생성
|
||||
# Request Body:
|
||||
{
|
||||
"pharmacy_id": 1,
|
||||
"vmid": 101,
|
||||
"vm_type": "kiosk"
|
||||
}
|
||||
|
||||
GET /api/vnc/sessions # 활성 VNC 세션 목록
|
||||
POST /api/vnc/refresh/{session_id} # VNC 티켓 새로고침 (기존)
|
||||
```
|
||||
|
||||
## 🎨 UI/UX 설계
|
||||
|
||||
### 1. 대시보드 개선
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 🏥 PharmQ - 다중 Proxmox 관리 시스템 │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 📊 전체 현황 │
|
||||
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
|
||||
│ │호스트수 │ │총VM수 │ │실행중 │ │오프라인 │ │
|
||||
│ │ 100 │ │ 450 │ │ 380 │ │ 15 │ │
|
||||
│ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 🔍 검색 및 필터 │
|
||||
│ [약국명/IP 검색____] [호스트상태▼] [VM타입▼] [VM상태▼] │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 2. 호스트 목록 페이지
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 🖥️ Proxmox 호스트 관리 [+ 호스트 추가] │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 약국명 │ IP주소 │ 상태 │ VM수 │ 마지막확인 │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 🟢 약국A │ 100.64.0.10 │ 온라인 │ 5개 │ 1분전 │
|
||||
│ 🔴 약국B │ 100.64.0.11 │ 오프라인│ - │ 30분전 │
|
||||
│ 🟢 약국C │ 100.64.0.12 │ 온라인 │ 3개 │ 2분전 │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 3. 통합 VM 관리 페이지
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 🔧 통합 VM 관리 │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 📍 약국A (100.64.0.10) - 온라인 │
|
||||
│ ├── 🖥️ VM101: Kiosk-1 [실행중] [VNC] [정지] │
|
||||
│ ├── 🖥️ VM102: POS-System [정지됨] [시작] [VNC] │
|
||||
│ └── 🖥️ VM103: Backup [실행중] [VNC] [정지] │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 📍 약국C (100.64.0.12) - 온라인 │
|
||||
│ ├── 🖥️ VM201: Server [실행중] [VNC] [정지] │
|
||||
│ └── 🖥️ VM202: Kiosk [실행중] [VNC] [정지] │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 🔧 구현 단계
|
||||
|
||||
### Phase 1: 기반 시스템 확장 (1-2주)
|
||||
1. **데이터베이스 스키마 확장**
|
||||
- 다중 호스트 테이블 생성
|
||||
- 기존 데이터 마이그레이션
|
||||
|
||||
2. **Proxmox 클라이언트 다중화**
|
||||
- 호스트별 클라이언트 풀 관리
|
||||
- 연결 관리 및 로드밸런싱
|
||||
|
||||
### Phase 2: 호스트 관리 시스템 (2-3주)
|
||||
1. **호스트 등록/관리 기능**
|
||||
- CRUD API 구현
|
||||
- 관리 UI 개발
|
||||
|
||||
2. **상태 모니터링**
|
||||
- 헬스체크 시스템
|
||||
- 알림 기능
|
||||
|
||||
### Phase 3: 통합 VM 관리 (2-3주)
|
||||
1. **다중 호스트 VM 조회**
|
||||
- 통합 대시보드
|
||||
- 검색/필터링 기능
|
||||
|
||||
2. **배치 작업 시스템**
|
||||
- 일괄 작업 API
|
||||
- 스케줄링 기능
|
||||
|
||||
### Phase 4: 고도화 기능 (2-3주)
|
||||
1. **성능 최적화**
|
||||
- 캐싱 시스템
|
||||
- 비동기 처리
|
||||
|
||||
2. **보안 강화**
|
||||
- 접근 권한 관리
|
||||
- 감사 로그
|
||||
|
||||
## 🚨 고려사항
|
||||
|
||||
### 기술적 도전과제
|
||||
1. **네트워크 지연**
|
||||
- Headscale VPN을 통한 다중 호스트 접근 시 지연 가능성
|
||||
- 연결 타임아웃 및 재시도 로직 필요
|
||||
|
||||
2. **확장성**
|
||||
- 100개 호스트 동시 관리 시 리소스 사용량
|
||||
- 동시 VNC 세션 수 제한 고려
|
||||
|
||||
3. **장애 처리**
|
||||
- 개별 호스트 오프라인 시 graceful degradation
|
||||
- 부분 장애 상황에서의 서비스 연속성
|
||||
|
||||
### 보안 고려사항
|
||||
1. **인증 정보 관리**
|
||||
- 각 Proxmox 호스트별 패스워드 암호화 저장
|
||||
- API 토큰 순환 정책
|
||||
|
||||
2. **네트워크 보안**
|
||||
- Headscale 네트워크 내부 통신 암호화
|
||||
- VNC 세션 보안 강화
|
||||
|
||||
## 📈 성공 지표
|
||||
|
||||
### 기능적 지표
|
||||
- ✅ 100개 Proxmox 호스트 동시 관리
|
||||
- ✅ 1000개 이상 VM 통합 관리
|
||||
- ✅ 동시 VNC 세션 50개 이상 지원
|
||||
- ✅ 호스트 상태 확인 응답 시간 < 5초
|
||||
|
||||
### 성능 지표
|
||||
- ✅ 대시보드 로딩 시간 < 3초
|
||||
- ✅ VM 목록 조회 시간 < 5초
|
||||
- ✅ VNC 연결 설정 시간 < 10초
|
||||
- ✅ 시스템 가용성 99.5% 이상
|
||||
|
||||
## 🎯 결론
|
||||
|
||||
이 다중 Proxmox 호스트 관리 시스템을 통해 약국별로 분산된 IT 인프라를 중앙에서 효율적으로 관리할 수 있게 됩니다. Headscale 네트워크를 활용한 안전한 연결과 통합 관리 인터페이스를 제공하여 운영 효율성을 크게 향상시킬 수 있을 것입니다.
|
||||
|
||||
---
|
||||
|
||||
*이 기획서는 현재 단일 Proxmox 호스트 관리 시스템을 다중 호스트 환경으로 확장하기 위한 상세 계획을 담고 있습니다. 단계적 구현을 통해 안정적이고 확장 가능한 시스템을 구축할 수 있을 것입니다.*
|
||||
128
README.md
128
README.md
@@ -1,6 +1,71 @@
|
||||
# 🚀 Headscale + Headplane Docker Setup
|
||||
# 🏥 PharmQ Headscale Network - Live Production
|
||||
|
||||
Tailscale을 완전히 대체하는 자체 호스팅 솔루션
|
||||
pharmq.kr 도메인을 사용하는 Headscale VPN 네트워크 구축 완료
|
||||
|
||||
## 🚀 클라이언트 자동 등록
|
||||
|
||||
### 한 줄 설치 (권장)
|
||||
```bash
|
||||
curl -fsSL https://git.0bin.in/thug0bin/headscale-tailscale-replacement/raw/branch/live/pharmq-headscale-production/register-client-pharmq-live.sh | bash
|
||||
```
|
||||
|
||||
### 다운로드 후 설치
|
||||
```bash
|
||||
curl -fsSL https://git.0bin.in/thug0bin/headscale-tailscale-replacement/raw/branch/live/pharmq-headscale-production/register-client-pharmq-live.sh -o register-client.sh
|
||||
chmod +x register-client.sh
|
||||
./register-client.sh
|
||||
```
|
||||
|
||||
### 📋 스크립트 파일 직접 다운로드
|
||||
- [register-client-pharmq-live.sh](./register-client-pharmq-live.sh) - 클라이언트 자동 등록 스크립트
|
||||
|
||||
## 🌐 서비스 주소
|
||||
|
||||
### 메인 서비스
|
||||
- **Headscale 서버**: http://head.pharmq.kr:8070
|
||||
- **관리자 대시보드**: http://head.pharmq.kr:5001
|
||||
|
||||
### 개발/테스트 (내부용)
|
||||
- **Headscale**: http://192.168.0.100:8070
|
||||
- **farmq-admin**: http://192.168.0.100:5001
|
||||
|
||||
## 📊 네트워크 정보
|
||||
|
||||
- **네트워크 대역**: 100.64.0.0/10
|
||||
- **IPv6 대역**: fd7a:115c:a1e0::/48
|
||||
- **Magic DNS**: headscale.local
|
||||
- **기본 사용자**: default
|
||||
|
||||
## 🔧 관리자 도구
|
||||
|
||||
### farmq-admin 웹 GUI
|
||||
- 사용자 관리
|
||||
- 머신/노드 관리
|
||||
- 실시간 네트워크 모니터링
|
||||
- Headscale CLI API 래퍼
|
||||
|
||||
### 주요 기능
|
||||
- ✅ Docker 기반 Headscale 서버
|
||||
- ✅ 웹 기반 관리 인터페이스
|
||||
- ✅ 자동 클라이언트 등록 스크립트
|
||||
- ✅ 1년 유효 재사용 가능 preauth key
|
||||
- ✅ Magic DNS 지원
|
||||
|
||||
## 📋 클라이언트 등록 과정
|
||||
|
||||
1. **스크립트 실행**: 위 curl 명령어 실행
|
||||
2. **Tailscale 자동 설치**: 시스템에 맞게 설치
|
||||
3. **Headscale 서버 연결**: head.pharmq.kr 연결
|
||||
4. **자동 인증**: preauth key로 즉시 승인
|
||||
5. **네트워크 참여**: Tailscale IP 할당 완료
|
||||
|
||||
## 🛠️ 기술 스택
|
||||
|
||||
- **Headscale**: v0.26.1 (Docker)
|
||||
- **farmq-admin**: Flask + SQLAlchemy
|
||||
- **Database**: SQLite3
|
||||
- **Frontend**: HTML/CSS/JavaScript
|
||||
- **Network**: Tailscale protocol
|
||||
|
||||
## 📁 파일 구조
|
||||
```
|
||||
@@ -138,19 +203,19 @@ git push origin main
|
||||
### 빠른 설치 (권장)
|
||||
```bash
|
||||
# 일반 사용자 계정
|
||||
curl -fsSL https://git.0bin.in/thug0bin/headscale-tailscale-replacement/raw/branch/feature/working-headscale-setup/quick-install.sh | sudo bash
|
||||
curl -fsSL https://git.0bin.in/thug0bin/headscale-tailscale-replacement/raw/branch/live/pharmq-headscale-production/quick-install.sh | sudo bash
|
||||
|
||||
# root 계정 (Proxmox 등)
|
||||
curl -fsSL https://git.0bin.in/thug0bin/headscale-tailscale-replacement/raw/branch/feature/working-headscale-setup/quick-install.sh | bash
|
||||
curl -fsSL https://git.0bin.in/thug0bin/headscale-tailscale-replacement/raw/branch/live/pharmq-headscale-production/quick-install.sh | bash
|
||||
```
|
||||
|
||||
### 기존 Tailscale 연결이 있는 경우 (강제 재등록)
|
||||
```bash
|
||||
# 일반 사용자
|
||||
curl -fsSL https://git.0bin.in/thug0bin/headscale-tailscale-replacement/raw/branch/feature/working-headscale-setup/quick-install.sh | sudo bash -s -- --force
|
||||
curl -fsSL https://git.0bin.in/thug0bin/headscale-tailscale-replacement/raw/branch/live/pharmq-headscale-production/quick-install.sh | sudo bash -s -- --force
|
||||
|
||||
# root 계정
|
||||
curl -fsSL https://git.0bin.in/thug0bin/headscale-tailscale-replacement/raw/branch/feature/working-headscale-setup/quick-install.sh | bash -s -- --force
|
||||
curl -fsSL https://git.0bin.in/thug0bin/headscale-tailscale-replacement/raw/branch/live/pharmq-headscale-production/quick-install.sh | bash -s -- --force
|
||||
```
|
||||
|
||||
### 지원 OS
|
||||
@@ -167,19 +232,19 @@ Windows PC에서 **한 번의 복사 붙여넣기**로 팜큐 네트워크 연
|
||||
### 기본 설치 (권장) - 인코딩 문제 해결됨
|
||||
```powershell
|
||||
# 관리자 PowerShell에서 복사 붙여넣기 (English version - 한글 깨짐 해결)
|
||||
iex ((New-Object System.Net.WebClient).DownloadString('https://git.0bin.in/thug0bin/headscale-tailscale-replacement/raw/branch/feature/working-headscale-setup/farmq-install-en.ps1'))
|
||||
iex ((New-Object System.Net.WebClient).DownloadString('https://git.0bin.in/thug0bin/headscale-tailscale-replacement/raw/branch/live/pharmq-headscale-production/farmq-install-en.ps1'))
|
||||
```
|
||||
|
||||
### 기존 Tailscale 있는 경우 (강제 재등록)
|
||||
```powershell
|
||||
# 기존 연결을 자동으로 해제하고 재등록 (English version)
|
||||
$Force = $true; iex ((New-Object System.Net.WebClient).DownloadString('https://git.0bin.in/thug0bin/headscale-tailscale-replacement/raw/branch/feature/working-headscale-setup/farmq-install-en.ps1'))
|
||||
$Force = $true; iex ((New-Object System.Net.WebClient).DownloadString('https://git.0bin.in/thug0bin/headscale-tailscale-replacement/raw/branch/live/pharmq-headscale-production/farmq-install-en.ps1'))
|
||||
```
|
||||
|
||||
### 한글 버전 (인코딩 문제 발생 가능)
|
||||
```powershell
|
||||
# 한글이 깨져 보일 수 있음 - 위 English 버전 권장
|
||||
iex ((New-Object System.Net.WebClient).DownloadString('https://git.0bin.in/thug0bin/headscale-tailscale-replacement/raw/branch/feature/working-headscale-setup/farmq-install.ps1'))
|
||||
iex ((New-Object System.Net.WebClient).DownloadString('https://git.0bin.in/thug0bin/headscale-tailscale-replacement/raw/branch/live/pharmq-headscale-production/farmq-install.ps1'))
|
||||
```
|
||||
|
||||
### 실행 방법
|
||||
@@ -190,7 +255,48 @@ iex ((New-Object System.Net.WebClient).DownloadString('https://git.0bin.in/thug0
|
||||
|
||||
### Windows 자동 처리 기능
|
||||
- ✅ **Tailscale 자동 다운로드** 및 설치
|
||||
- ✅ **관리자 권한** 자동 확인
|
||||
- ✅ **관리자 권한** 자동 확인
|
||||
- ✅ **기존 연결 스마트 처리** (Linux와 동일)
|
||||
- ✅ **Windows Defender 방화벽** 자동 설정
|
||||
- ✅ **네트워크 연결 테스트** 및 확인
|
||||
- ✅ **네트워크 연결 테스트** 및 확인
|
||||
|
||||
## 💻 개발 환경 설정 (code-server)
|
||||
|
||||
팜큐 네트워크에 연결된 서버에서 **웹 기반 VS Code 개발 환경**을 빠르게 구축:
|
||||
|
||||
### 한 줄 설치 (권장)
|
||||
```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
|
||||
```
|
||||
|
||||
### 무인 설치 (비밀번호 환경변수 설정)
|
||||
```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
|
||||
```
|
||||
|
||||
### 자동 설치 기능
|
||||
- ✅ **code-server 자동 설치** (미설치 시)
|
||||
- ✅ **설정 파일 자동 생성** 및 구성
|
||||
- ✅ **기존 프로세스 정리** (중복 실행 방지)
|
||||
- ✅ **0.0.0.0 바인딩** (외부 접속 가능)
|
||||
- ✅ **nohup 백그라운드 실행** (세션 종료 후에도 유지)
|
||||
|
||||
### 설치 후 접속
|
||||
```bash
|
||||
# 브라우저에서 접속
|
||||
http://<서버IP>:8080
|
||||
|
||||
# 로그 확인
|
||||
tail -f ~/code-server.log
|
||||
```
|
||||
|
||||
### 보안 권장사항
|
||||
- 🔒 **역프록시 사용**: Caddy 또는 Nginx로 HTTPS 설정
|
||||
- 🔒 **강력한 비밀번호**: 복잡한 비밀번호 사용
|
||||
- 🔒 **방화벽 설정**: 필요한 IP만 접근 허용
|
||||
- 🔒 **VPN 접속**: 팜큐 네트워크 내부에서만 접속
|
||||
230
VNC-WebSocket-Proxy-Solution.md
Normal file
230
VNC-WebSocket-Proxy-Solution.md
Normal file
@@ -0,0 +1,230 @@
|
||||
# VNC WebSocket 연결 문제 및 프록시 솔루션
|
||||
|
||||
## 🚨 현재 문제 상황
|
||||
|
||||
### WebSocket 1006 연결 실패 원인
|
||||
```
|
||||
WebSocket connection to 'wss://pve7.0bin.in/api2/json/nodes/pve7/qemu/102/vncwebsocket...' failed:
|
||||
Failed when connecting: Connection closed (code: 1006)
|
||||
```
|
||||
|
||||
**WebSocket Error Code 1006**는 "Abnormal Closure"를 의미하며, 연결이 설정되기도 전에 종료되었음을 나타냅니다.
|
||||
|
||||
## 🔍 근본 원인 분석
|
||||
|
||||
### 1. 브라우저 보안 정책 (Mixed Content)
|
||||
- **현재 상황**: HTTPS 웹사이트 (`pqadmin.0bin.in`)에서 자체 서명 인증서를 사용하는 WebSocket 연결 시도
|
||||
- **브라우저 제한**: Chrome, Firefox 등 모던 브라우저는 HTTPS 페이지에서 신뢰할 수 없는 SSL 인증서로의 WebSocket 연결을 자동 차단
|
||||
- **에러 발생**: 브라우저가 연결 시도 자체를 거부하여 1006 에러 발생
|
||||
|
||||
### 2. SSL/TLS 인증서 신뢰 문제
|
||||
```bash
|
||||
# Proxmox 서버의 자체 서명 인증서
|
||||
Subject: CN=Proxmox Virtual Environment
|
||||
Issuer: PVE Cluster Manager CA
|
||||
```
|
||||
- **자체 서명 인증서**: `pve7.0bin.in`이 공인 CA가 아닌 자체 서명 인증서 사용
|
||||
- **브라우저 불신**: 사용자가 명시적으로 인증서를 신뢰하지 않으면 WebSocket 연결 거부
|
||||
|
||||
### 3. CORS (Cross-Origin Resource Sharing) 정책
|
||||
- **Origin 불일치**: `pqadmin.0bin.in`에서 `pve7.0bin.in`으로의 크로스 오리진 WebSocket 연결
|
||||
- **Preflight 검사**: 브라우저가 연결 전 OPTIONS 요청을 보내지만 Proxmox가 적절한 CORS 헤더를 반환하지 않을 가능성
|
||||
|
||||
## 💡 해결책 1: 역방향 프록시 (Reverse Proxy)
|
||||
|
||||
### 구현 방식: nginx를 통한 WebSocket 프록시
|
||||
|
||||
```nginx
|
||||
# /etc/nginx/sites-available/pqadmin-vnc
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name pqadmin.0bin.in;
|
||||
|
||||
ssl_certificate /path/to/ssl/cert.pem;
|
||||
ssl_certificate_key /path/to/ssl/key.pem;
|
||||
|
||||
# VNC WebSocket 프록시
|
||||
location /vnc-proxy/ {
|
||||
# WebSocket 업그레이드 헤더
|
||||
proxy_pass https://pve7.0bin.in/api2/json/nodes/pve7/qemu/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $proxy_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# SSL 검증 비활성화 (자체 서명 인증서 때문)
|
||||
proxy_ssl_verify off;
|
||||
proxy_ssl_server_name on;
|
||||
|
||||
# 타임아웃 설정
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 장점
|
||||
- ✅ **같은 도메인**: `pqladmin.0bin.in/vnc-proxy/`로 WebSocket 연결하여 CORS 문제 해결
|
||||
- ✅ **신뢰할 수 있는 SSL**: nginx가 공인 인증서를 사용하여 브라우저 신뢰 확보
|
||||
- ✅ **투명한 프록시**: 클라이언트는 Proxmox 직접 연결과 동일하게 작동
|
||||
|
||||
### 구현 단계
|
||||
```bash
|
||||
# 1. nginx 설정 파일 생성
|
||||
sudo nano /etc/nginx/sites-available/pqadmin-vnc
|
||||
|
||||
# 2. 심볼릭 링크 생성
|
||||
sudo ln -s /etc/nginx/sites-available/pqadmin-vnc /etc/nginx/sites-enabled/
|
||||
|
||||
# 3. nginx 설정 테스트
|
||||
sudo nginx -t
|
||||
|
||||
# 4. nginx 재시작
|
||||
sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
### 클라이언트 코드 수정
|
||||
```javascript
|
||||
// 기존: 직접 Proxmox 연결
|
||||
// const websocketUrl = 'wss://pve7.0bin.in/api2/json/nodes/pve7/qemu/102/vncwebsocket?...'
|
||||
|
||||
// 변경: nginx 프록시를 통한 연결
|
||||
const websocketUrl = 'wss://pqladmin.0bin.in/vnc-proxy/102/vncwebsocket?...'
|
||||
```
|
||||
|
||||
## 💡 해결책 2: WebSocket 프록시 서버
|
||||
|
||||
### 구현 방식: Node.js/Python WebSocket 중계 서버
|
||||
|
||||
```python
|
||||
# vnc_websocket_proxy.py
|
||||
import asyncio
|
||||
import websockets
|
||||
import ssl
|
||||
import json
|
||||
from urllib.parse import parse_qs
|
||||
|
||||
class VNCWebSocketProxy:
|
||||
def __init__(self):
|
||||
self.proxmox_host = "pve7.0bin.in"
|
||||
self.proxmox_port = 443
|
||||
|
||||
async def proxy_websocket(self, websocket, path):
|
||||
# 클라이언트 요청 파싱
|
||||
query_params = parse_qs(path.split('?', 1)[1] if '?' in path else '')
|
||||
vmid = query_params.get('vmid', [''])[0]
|
||||
ticket = query_params.get('vncticket', [''])[0]
|
||||
|
||||
# Proxmox WebSocket URL 생성
|
||||
proxmox_url = f"wss://{self.proxmox_host}:{self.proxmox_port}/api2/json/nodes/pve7/qemu/{vmid}/vncwebsocket?port=5900&vncticket={ticket}"
|
||||
|
||||
# SSL 컨텍스트 (자체 서명 인증서 허용)
|
||||
ssl_context = ssl.create_default_context()
|
||||
ssl_context.check_hostname = False
|
||||
ssl_context.verify_mode = ssl.CERT_NONE
|
||||
|
||||
try:
|
||||
# Proxmox WebSocket 연결
|
||||
async with websockets.connect(proxmox_url, ssl=ssl_context) as proxmox_ws:
|
||||
# 양방향 데이터 중계
|
||||
await asyncio.gather(
|
||||
self.forward_messages(websocket, proxmox_ws),
|
||||
self.forward_messages(proxmox_ws, websocket)
|
||||
)
|
||||
except Exception as e:
|
||||
await websocket.send(json.dumps({"error": f"Proxmox connection failed: {str(e)}"}))
|
||||
|
||||
async def forward_messages(self, source, destination):
|
||||
try:
|
||||
async for message in source:
|
||||
await destination.send(message)
|
||||
except websockets.exceptions.ConnectionClosed:
|
||||
pass
|
||||
|
||||
# 프록시 서버 시작
|
||||
if __name__ == "__main__":
|
||||
proxy = VNCWebSocketProxy()
|
||||
start_server = websockets.serve(proxy.proxy_websocket, "0.0.0.0", 8765)
|
||||
asyncio.get_event_loop().run_until_complete(start_server)
|
||||
asyncio.get_event_loop().run_forever()
|
||||
```
|
||||
|
||||
### 장점
|
||||
- ✅ **완전한 제어**: WebSocket 연결 과정을 완전히 제어 가능
|
||||
- ✅ **에러 처리**: 연결 실패 시 상세한 에러 메시지 제공
|
||||
- ✅ **로깅**: 모든 WebSocket 트래픽 로깅 및 디버깅 가능
|
||||
- ✅ **인증 확장**: 추가적인 인증 로직 구현 가능
|
||||
|
||||
### 구현 단계
|
||||
```bash
|
||||
# 1. 의존성 설치
|
||||
pip install websockets asyncio
|
||||
|
||||
# 2. 프록시 서버 실행
|
||||
python vnc_websocket_proxy.py
|
||||
|
||||
# 3. systemd 서비스 등록
|
||||
sudo systemctl enable vnc-websocket-proxy.service
|
||||
```
|
||||
|
||||
### 클라이언트 코드 수정
|
||||
```javascript
|
||||
// WebSocket 프록시 서버로 연결
|
||||
const websocketUrl = 'ws://localhost:8765/vnc-proxy?vmid=102&vncticket=' + encodeURIComponent(ticket);
|
||||
```
|
||||
|
||||
## 📊 솔루션 비교
|
||||
|
||||
| 기준 | 역방향 프록시 (nginx) | WebSocket 프록시 서버 |
|
||||
|------|----------------------|---------------------|
|
||||
| **구현 복잡도** | 🟡 중간 | 🔴 높음 |
|
||||
| **성능** | 🟢 우수 | 🟡 양호 |
|
||||
| **유지보수** | 🟢 쉬움 | 🟡 보통 |
|
||||
| **확장성** | 🟡 제한적 | 🟢 우수 |
|
||||
| **디버깅** | 🟡 제한적 | 🟢 우수 |
|
||||
| **보안** | 🟢 안전 | 🟡 주의 필요 |
|
||||
| **리소스 사용량** | 🟢 낮음 | 🟡 보통 |
|
||||
|
||||
## 🎯 추천 솔루션
|
||||
|
||||
### 단기 해결책: 역방향 프록시 (nginx)
|
||||
1. **빠른 구현**: 기존 nginx 설정에 location 블록만 추가
|
||||
2. **안정성**: nginx의 검증된 WebSocket 프록시 기능 활용
|
||||
3. **보안**: 공인 SSL 인증서로 브라우저 신뢰 확보
|
||||
|
||||
### 장기 해결책: 하이브리드 접근
|
||||
1. **nginx 프록시**: 기본적인 WebSocket 중계
|
||||
2. **커스텀 미들웨어**: 인증, 로깅, 모니터링 기능 추가
|
||||
3. **로드 밸런싱**: 다중 Proxmox 호스트 지원
|
||||
|
||||
## 🚀 구현 우선순위
|
||||
|
||||
### Phase 1: nginx 역방향 프록시 구현
|
||||
```bash
|
||||
# 1. nginx 설정 파일 작성
|
||||
# 2. SSL 인증서 설정
|
||||
# 3. WebSocket 프록시 규칙 추가
|
||||
# 4. 클라이언트 URL 변경
|
||||
```
|
||||
|
||||
### Phase 2: 에러 처리 및 모니터링 강화
|
||||
```bash
|
||||
# 1. 연결 실패 시 재시도 로직
|
||||
# 2. WebSocket 상태 모니터링
|
||||
# 3. 로그 수집 및 분석
|
||||
```
|
||||
|
||||
### Phase 3: 다중 호스트 지원
|
||||
```bash
|
||||
# 1. 동적 백엔드 라우팅
|
||||
# 2. 헬스체크 구현
|
||||
# 3. 로드 밸런싱 설정
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**결론**: WebSocket 1006 에러는 브라우저 보안 정책과 인증서 신뢰 문제가 주요 원인입니다. nginx 역방향 프록시를 통해 이 문제를 근본적으로 해결할 수 있으며, 이는 가장 실용적이고 안정적인 솔루션입니다.
|
||||
@@ -35,7 +35,7 @@ services:
|
||||
volumes:
|
||||
- ./headplane-config:/etc/headplane
|
||||
ports:
|
||||
- "3000:3000" # Headplane Web UI
|
||||
- "3001:3000" # Headplane Web UI (외부:3001, 내부:3000)
|
||||
depends_on:
|
||||
- headscale
|
||||
networks:
|
||||
|
||||
128
docs/code-server.sh
Normal file
128
docs/code-server.sh
Normal 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로 바꾸세요."
|
||||
190
farmq-admin/Stable_PVE_Authentication_Strategy.md
Normal file
190
farmq-admin/Stable_PVE_Authentication_Strategy.md
Normal file
@@ -0,0 +1,190 @@
|
||||
# 안정적인 PVE 인증 정보 전달 전략
|
||||
## 브라우저 WebSocket VNC 연결 개선 방안
|
||||
|
||||
### 🚨 현재 문제 상황
|
||||
|
||||
**간헐적 연결 실패 원인 분석:**
|
||||
- 브라우저에서 PVE 인증 쿠키가 WebSocket 연결 시 불안정하게 전달됨
|
||||
- NPM 리버스 프록시에서 인증 헤더 전달 불일치
|
||||
- Proxmox 세션 만료 및 브라우저 쿠키 상태 변화
|
||||
- CORS/보안 정책으로 인한 쿠키 차단
|
||||
|
||||
**Claude Code 환경 성공 요인:**
|
||||
```python
|
||||
headers = {'Cookie': f'PVEAuthCookie={client.ticket}'}
|
||||
async with websockets.connect(websocket_url, ssl=ssl_context, additional_headers=headers)
|
||||
```
|
||||
→ **명시적 인증 헤더 전달로 100% 안정적 연결**
|
||||
|
||||
### 🎯 안정적인 인증 전략 옵션
|
||||
|
||||
#### 방법 1: Flask 백엔드 인증 프록시 (권장 ⭐⭐⭐⭐⭐)
|
||||
|
||||
**개념:**
|
||||
- Flask가 Proxmox 인증을 대신 처리
|
||||
- 브라우저는 Flask 세션만 관리
|
||||
- Flask가 WebSocket을 Proxmox로 프록시
|
||||
|
||||
**장점:**
|
||||
- ✅ 브라우저 보안 정책 우회
|
||||
- ✅ 인증 상태 중앙 관리
|
||||
- ✅ 세션 만료 자동 갱신 가능
|
||||
- ✅ NPM 설정 변경 불필요
|
||||
|
||||
**구현 구조:**
|
||||
```
|
||||
브라우저 WebSocket → Flask WebSocket 프록시 → Proxmox VNC WebSocket
|
||||
↑ (PVE 인증 헤더 자동 추가)
|
||||
```
|
||||
|
||||
#### 방법 2: Flask API를 통한 인증 토큰 전달 (보통 ⭐⭐⭐)
|
||||
|
||||
**개념:**
|
||||
- Flask API로 PVE 인증 정보 조회
|
||||
- JavaScript에서 받아서 WebSocket 연결 시 사용
|
||||
|
||||
**장점:**
|
||||
- ✅ 기존 코드 수정 최소화
|
||||
- ✅ 명시적 인증 제어
|
||||
|
||||
**단점:**
|
||||
- ❌ 브라우저 JavaScript에 인증 정보 노출
|
||||
- ❌ noVNC 라이브러리 제약 (커스텀 헤더 지원 제한)
|
||||
|
||||
#### 방법 3: NPM 설정 개선 (복잡함 ⭐⭐)
|
||||
|
||||
**개념:**
|
||||
- Nginx 설정으로 WebSocket 인증 헤더 자동 추가
|
||||
|
||||
**단점:**
|
||||
- ❌ NPM 설정 복잡도 증가
|
||||
- ❌ 여전히 브라우저 보안 정책 제약
|
||||
- ❌ 디버깅 어려움
|
||||
|
||||
### 🏆 권장 솔루션: Flask WebSocket 프록시
|
||||
|
||||
#### 아키텍처 설계
|
||||
```
|
||||
┌─────────────┐ WebSocket ┌─────────────┐ WebSocket+Auth ┌─────────────┐
|
||||
│ 브라우저 │ ────────────────→ │ Flask │ ───────────────────→ │ Proxmox VE │
|
||||
│ (noVNC) │ │ Proxy │ │ VNC Server │
|
||||
└─────────────┘ └─────────────┘ └─────────────┘
|
||||
↑
|
||||
자동 PVE 인증
|
||||
헤더 추가 처리
|
||||
```
|
||||
|
||||
#### 핵심 컴포넌트
|
||||
|
||||
**1. Flask WebSocket 프록시 엔드포인트**
|
||||
```python
|
||||
# 새로운 엔드포인트: /vnc/<vm_id>/proxy
|
||||
@app.websocket('/vnc/<int:vm_id>/proxy')
|
||||
async def vnc_websocket_proxy(vm_id):
|
||||
# 1. Flask 세션 검증
|
||||
# 2. Proxmox 인증 정보 준비
|
||||
# 3. Proxmox VNC WebSocket 연결 (PVE 쿠키 포함)
|
||||
# 4. 브라우저 ↔ Proxmox 간 데이터 양방향 중계
|
||||
```
|
||||
|
||||
**2. 브라우저 WebSocket URL 변경**
|
||||
```javascript
|
||||
// 기존 (직접 Proxmox 연결)
|
||||
const websocketUrl = 'wss://pve7.0bin.in:443/api2/json/nodes/pve7/qemu/102/vncwebsocket?...'
|
||||
|
||||
// 새로운 (Flask 프록시 경유)
|
||||
const websocketUrl = 'wss://farmq.0bin.in/vnc/102/proxy'
|
||||
```
|
||||
|
||||
### 🔧 현재 코드 개선 계획
|
||||
|
||||
#### Phase 1: Flask WebSocket 프록시 구현
|
||||
```python
|
||||
# app.py에 추가할 함수들
|
||||
async def create_proxmox_vnc_connection(vm_id):
|
||||
"""Proxmox VNC WebSocket 연결 생성 (인증 헤더 포함)"""
|
||||
|
||||
async def proxy_websocket_data(browser_ws, proxmox_ws):
|
||||
"""브라우저와 Proxmox 간 WebSocket 데이터 중계"""
|
||||
|
||||
@app.websocket('/vnc/<int:vm_id>/proxy')
|
||||
async def vnc_websocket_proxy(vm_id):
|
||||
"""VNC WebSocket 프록시 메인 핸들러"""
|
||||
```
|
||||
|
||||
#### Phase 2: 브라우저 클라이언트 수정
|
||||
```javascript
|
||||
// vnc_simple.html 수정 계획
|
||||
// 1. WebSocket URL을 Flask 프록시로 변경
|
||||
// 2. VNC 티켓 생성 로직 제거 (Flask에서 처리)
|
||||
// 3. 연결 상태 관리 개선
|
||||
```
|
||||
|
||||
#### Phase 3: 세션 관리 강화
|
||||
```python
|
||||
# 세션 만료 감지 및 자동 갱신
|
||||
# Proxmox 인증 상태 모니터링
|
||||
# 오류 상황 처리 및 복구
|
||||
```
|
||||
|
||||
### 🎯 구현 우선순위
|
||||
|
||||
**즉시 구현 (High Priority):**
|
||||
- [ ] Flask WebSocket 라이브러리 추가 (flask-socketio 또는 quart)
|
||||
- [ ] VNC WebSocket 프록시 기본 구조 구현
|
||||
- [ ] 브라우저 WebSocket URL 변경
|
||||
|
||||
**단계적 개선 (Medium Priority):**
|
||||
- [ ] 세션 만료 자동 처리
|
||||
- [ ] 연결 상태 모니터링
|
||||
- [ ] 오류 복구 메커니즘
|
||||
|
||||
**최적화 (Low Priority):**
|
||||
- [ ] 성능 튜닝 (연결 풀링, 캐싱)
|
||||
- [ ] 로깅 및 모니터링 강화
|
||||
- [ ] 다중 VM 동시 연결 지원
|
||||
|
||||
### 🔒 보안 고려사항
|
||||
|
||||
**인증 보안:**
|
||||
- Flask 세션으로 사용자 권한 검증
|
||||
- Proxmox 인증 정보는 서버 메모리에만 보관
|
||||
- 브라우저에 민감 정보 노출 방지
|
||||
|
||||
**네트워크 보안:**
|
||||
- Flask ↔ Proxmox 통신은 내부 네트워크
|
||||
- SSL/TLS 종단간 암호화 유지
|
||||
- CORS 정책 적절한 설정
|
||||
|
||||
### 📊 예상 효과
|
||||
|
||||
**안정성 개선:**
|
||||
- ✅ 인증 실패로 인한 연결 오류 완전 제거
|
||||
- ✅ 세션 만료 자동 처리
|
||||
- ✅ 네트워크 오류 복구 능력 향상
|
||||
|
||||
**사용자 경험:**
|
||||
- ✅ 일관된 연결 성공률
|
||||
- ✅ 빠른 연결 속도 (중간 단계 제거)
|
||||
- ✅ 오류 상황 사용자 친화적 처리
|
||||
|
||||
**유지보수성:**
|
||||
- ✅ 중앙집중식 인증 관리
|
||||
- ✅ 디버깅 용이성 향상
|
||||
- ✅ 코드 복잡도 감소
|
||||
|
||||
### 🚀 다음 단계
|
||||
|
||||
1. **Flask WebSocket 라이브러리 선택 및 설치**
|
||||
2. **간단한 프록시 프로토타입 구현**
|
||||
3. **기본 연결 테스트**
|
||||
4. **브라우저 클라이언트 수정**
|
||||
5. **통합 테스트 및 검증**
|
||||
|
||||
**⚠️ 주의사항:**
|
||||
이 변경은 아키텍처 수준의 개선이므로 충분한 테스트와 백업 계획이 필요합니다.
|
||||
|
||||
---
|
||||
|
||||
**💡 핵심 메시지:**
|
||||
Claude Code 환경에서 증명된 "명시적 PVE 인증 헤더 전달" 방식을 Flask 프록시를 통해 브라우저 환경에서도 안정적으로 구현하자는 것이 이 전략의 핵심입니다.
|
||||
249
farmq-admin/VNC_Implementation_Technical_Documentation.md
Normal file
249
farmq-admin/VNC_Implementation_Technical_Documentation.md
Normal file
@@ -0,0 +1,249 @@
|
||||
# VNC 웹소켓 구현 기술 문서
|
||||
## Proxmox API를 이용한 noVNC 통합 가이드
|
||||
|
||||
### 📋 개요
|
||||
이 문서는 Proxmox VE API를 활용하여 웹 브라우저에서 직접 가상머신에 VNC 접속할 수 있는 시스템의 기술적 구현 내용을 설명합니다. Flask 백엔드와 noVNC 클라이언트를 통해 브라우저에서 직접 VM 콘솔에 접근할 수 있도록 구현되었습니다.
|
||||
|
||||
### 🏗️ 시스템 아키텍처
|
||||
|
||||
```
|
||||
[웹 브라우저]
|
||||
↓ HTTPS
|
||||
[NPM 리버스 프록시]
|
||||
↓ HTTP
|
||||
[Flask 애플리케이션]
|
||||
↓ HTTPS API
|
||||
[Proxmox VE 서버]
|
||||
↓ WebSocket (WSS)
|
||||
[VM VNC 서버]
|
||||
```
|
||||
|
||||
### 🔧 핵심 구성요소
|
||||
|
||||
#### 1. Proxmox API 클라이언트 (`utils/proxmox_client.py`)
|
||||
|
||||
**주요 기능:**
|
||||
- Proxmox VE API 인증 및 세션 관리
|
||||
- VNC 티켓 생성 및 WebSocket URL 생성
|
||||
- VM 상태 관리 (시작/정지/상태확인)
|
||||
|
||||
**핵심 구현:**
|
||||
```python
|
||||
def get_vnc_ticket(self, node: str, vmid: int) -> Optional[Dict]:
|
||||
"""VNC 접속 티켓 생성"""
|
||||
data = {
|
||||
'websocket': '1',
|
||||
'generate-password': '1' # 자동 패스워드 생성
|
||||
}
|
||||
response = self.session.post(
|
||||
f"{self.base_url}/nodes/{node}/qemu/{vmid}/vncproxy",
|
||||
data=data,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
vnc_data = response.json()['data']
|
||||
encoded_ticket = quote_plus(vnc_data['ticket'])
|
||||
vnc_data['websocket_url'] = f"wss://{self.host}:443/api2/json/nodes/{node}/qemu/{vmid}/vncwebsocket?port={vnc_data['port']}&vncticket={encoded_ticket}"
|
||||
return vnc_data
|
||||
```
|
||||
|
||||
**인증 방식:**
|
||||
- 세션 쿠키 방식 (PVEAuthCookie)
|
||||
- CSRF 토큰 헤더 (CSRFPreventionToken)
|
||||
- API 토큰 방식 지원
|
||||
|
||||
#### 2. Flask 웹 애플리케이션 (`app.py`)
|
||||
|
||||
**주요 엔드포인트:**
|
||||
- `/vnc/<int:vm_id>`: VNC 클라이언트 페이지 렌더링
|
||||
- API 엔드포인트들을 통한 VM 관리
|
||||
|
||||
**로깅 시스템:**
|
||||
```python
|
||||
def setup_logging():
|
||||
if not os.path.exists('logs'):
|
||||
os.makedirs('logs')
|
||||
|
||||
file_handler = RotatingFileHandler(
|
||||
'logs/farmq-admin.log',
|
||||
maxBytes=10*1024*1024,
|
||||
backupCount=5
|
||||
)
|
||||
```
|
||||
|
||||
#### 3. noVNC 클라이언트 (`templates/vnc_simple.html`)
|
||||
|
||||
**핵심 기능:**
|
||||
- WebSocket을 통한 VNC 프로토콜 처리
|
||||
- 자동 리사이징 및 스케일링
|
||||
- HTML 엔티티 디코딩 (패스워드 처리)
|
||||
|
||||
**WebSocket 연결 코드:**
|
||||
```javascript
|
||||
function connectVNC() {
|
||||
const rfb = new RFB(document.getElementById('screen'), websocketUrl, {
|
||||
credentials: {
|
||||
password: decodeHtmlEntities('{{ vnc_data.password }}')
|
||||
}
|
||||
});
|
||||
|
||||
rfb.addEventListener("connect", () => {
|
||||
console.log("VNC 연결 성공");
|
||||
resizeScreen();
|
||||
});
|
||||
|
||||
rfb.addEventListener("disconnect", (e) => {
|
||||
console.log(`VNC 연결 종료: ${e.detail.clean ? '정상' : '비정상'}`);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**HTML 엔티티 디코딩:**
|
||||
```javascript
|
||||
function decodeHtmlEntities(text) {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.innerHTML = text;
|
||||
return textarea.value;
|
||||
}
|
||||
```
|
||||
|
||||
### 🔐 보안 및 인증
|
||||
|
||||
#### VNC 인증 플로우
|
||||
1. **티켓 요청**: Flask → Proxmox API (`/vncproxy`)
|
||||
2. **티켓 생성**: Proxmox가 일회용 VNC 티켓 및 패스워드 생성
|
||||
3. **WebSocket 연결**: 브라우저 → Proxmox VNC WebSocket
|
||||
4. **VNC 인증**: 생성된 패스워드로 VNC 서버 인증
|
||||
|
||||
#### 보안 설정
|
||||
```python
|
||||
# SSL 검증 무시 (내부 네트워크)
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
self.session.verify = False
|
||||
|
||||
# WebSocket URL에 티켓 인코딩
|
||||
encoded_ticket = quote_plus(vnc_data['ticket'])
|
||||
```
|
||||
|
||||
### 🌐 네트워크 구성
|
||||
|
||||
#### NPM (Nginx Proxy Manager) 설정
|
||||
- **외부 도메인**: `https://pve7.0bin.in`
|
||||
- **내부 주소**: `https://192.168.0.5:8006`
|
||||
- **WebSocket 지원**: Upgrade 헤더 프록시 필요
|
||||
|
||||
#### WebSocket 프록시 요구사항
|
||||
```nginx
|
||||
# WebSocket 업그레이드 헤더
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
```
|
||||
|
||||
### ⚡ 성능 최적화
|
||||
|
||||
#### Canvas 자동 리사이징
|
||||
```javascript
|
||||
function resizeScreen() {
|
||||
const canvas = document.querySelector('#screen canvas');
|
||||
if (canvas) {
|
||||
const container = document.getElementById('screen');
|
||||
const scaleX = container.clientWidth / canvas.width;
|
||||
const scaleY = container.clientHeight / canvas.height;
|
||||
const scale = Math.min(scaleX, scaleY, 1);
|
||||
|
||||
canvas.style.transform = `scale(${scale})`;
|
||||
canvas.style.transformOrigin = 'top left';
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 연결 상태 관리
|
||||
- 자동 재연결 로직
|
||||
- 연결 품질 모니터링
|
||||
- 에러 처리 및 사용자 피드백
|
||||
|
||||
### 🐛 문제 해결 가이드
|
||||
|
||||
#### 일반적인 문제들
|
||||
|
||||
**1. WebSocket 1006 에러 (비정상 연결 종료)**
|
||||
- **원인**: NPM 프록시 WebSocket 설정 부족
|
||||
- **해결**: WebSocket 업그레이드 헤더 설정 확인
|
||||
|
||||
**2. VNC 인증 실패 (HTTP 401)**
|
||||
- **원인**: 잘못된 티켓 또는 패스워드
|
||||
- **해결**: `generate-password: 1` 설정 확인
|
||||
|
||||
**3. 빈 화면 또는 검은 화면**
|
||||
- **원인**: Canvas 리사이징 문제
|
||||
- **해결**: `resizeScreen()` 함수 호출 확인
|
||||
|
||||
**4. HTML 엔티티 문제**
|
||||
- **원인**: 패스워드의 특수문자 인코딩
|
||||
- **해결**: `decodeHtmlEntities()` 함수 적용
|
||||
|
||||
#### 디버깅 도구
|
||||
|
||||
**1. 서버 사이드 테스트**
|
||||
```bash
|
||||
cd /srv/headscale-setup/farmq-admin
|
||||
python test_vnc_websocket.py
|
||||
```
|
||||
|
||||
**2. 로그 확인**
|
||||
```bash
|
||||
tail -f logs/farmq-admin.log
|
||||
```
|
||||
|
||||
**3. 브라우저 개발자 도구**
|
||||
- Network 탭: WebSocket 연결 상태 확인
|
||||
- Console 탭: JavaScript 에러 확인
|
||||
|
||||
### 📊 모니터링 및 로깅
|
||||
|
||||
#### 로그 레벨
|
||||
- **INFO**: 정상 동작 로그
|
||||
- **WARNING**: 경고사항
|
||||
- **ERROR**: 오류 발생
|
||||
- **DEBUG**: 상세 디버깅 정보
|
||||
|
||||
#### 중요 로그 포인트
|
||||
- VNC 티켓 생성 성공/실패
|
||||
- WebSocket 연결 시도
|
||||
- VNC 인증 결과
|
||||
- 연결 종료 사유
|
||||
|
||||
### 🔄 배포 및 유지보수
|
||||
|
||||
#### 배포 체크리스트
|
||||
- [ ] Proxmox API 연결 테스트
|
||||
- [ ] Flask 애플리케이션 시작 확인
|
||||
- [ ] NPM 프록시 설정 검증
|
||||
- [ ] WebSocket 연결 테스트
|
||||
- [ ] VNC 클라이언트 동작 확인
|
||||
|
||||
#### 백업 및 복구
|
||||
```bash
|
||||
# Git 커밋 상태 확인
|
||||
git log --oneline -10
|
||||
|
||||
# 안정된 버전으로 롤백
|
||||
git reset --hard 1dc09101cc7afdf09ca3b8cbbc4f95e21bb5746f
|
||||
```
|
||||
|
||||
### 📚 참고 자료
|
||||
|
||||
- [Proxmox VE API 문서](https://pve.proxmox.com/pve-docs/api-viewer/)
|
||||
- [noVNC 프로젝트](https://github.com/novnc/noVNC)
|
||||
- [WebSocket RFC 6455](https://tools.ietf.org/html/rfc6455)
|
||||
- [VNC 프로토콜 스펙](https://tools.ietf.org/html/rfc6143)
|
||||
|
||||
### 🏷️ 버전 정보
|
||||
- **프로젝트**: FarmQ Admin VNC Integration
|
||||
- **마지막 업데이트**: 2024년
|
||||
- **안정 버전**: commit `1dc09101cc7afdf09ca3b8cbbc4f95e21bb5746f`
|
||||
- **Python**: 3.x
|
||||
- **Flask**: 최신 안정 버전
|
||||
- **noVNC**: 최신 버전
|
||||
161
farmq-admin/VNC_Network_Architecture_Issue.md
Normal file
161
farmq-admin/VNC_Network_Architecture_Issue.md
Normal file
@@ -0,0 +1,161 @@
|
||||
# VNC 네트워크 아키텍처 문제 분석 및 해결방안
|
||||
|
||||
## 🔍 현재 상황 분석
|
||||
|
||||
### 네트워크 구조
|
||||
```
|
||||
[외부 사용자 PC]
|
||||
↓ (인터넷)
|
||||
[pqadmin.0bin.in] (Let's Encrypt SSL + 리버스 프록시)
|
||||
↓ (로컬/VPN)
|
||||
[Headscale 서버 - Flask Admin]
|
||||
↓ (Headscale VPN: 100.64.x.x)
|
||||
[Proxmox Hosts]
|
||||
- pve7.0bin.in (443)
|
||||
- 100.64.0.6:8006 (Healthport PVE)
|
||||
```
|
||||
|
||||
### 문제 상황
|
||||
1. **Flask 서버**: Headscale VPN 내부에서 실행 중
|
||||
2. **외부 사용자**: `pqadmin.0bin.in`을 통해 Flask Admin에 접속
|
||||
3. **VNC WebSocket URL**: `wss://100.64.0.6:8006/...` (Headscale VPN 내부 IP)
|
||||
4. **접속 실패**: 외부 사용자는 100.64.x.x 대역에 접근 불가
|
||||
|
||||
## ❌ 핵심 문제
|
||||
|
||||
### 네트워크 분리 문제
|
||||
- **Flask Admin**: 인터넷에서 접근 가능 (`pqadmin.0bin.in`)
|
||||
- **Proxmox VNC**: Headscale VPN 내부에서만 접근 가능 (`100.64.x.x`)
|
||||
- **사용자**: 두 네트워크를 동시에 접근할 수 없음
|
||||
|
||||
### 현재 WebSocket 연결 방식
|
||||
```javascript
|
||||
// 문제가 되는 현재 방식
|
||||
WebSocket URL: wss://100.64.0.6:8006/api2/json/nodes/pev/qemu/103/vncwebsocket
|
||||
```
|
||||
|
||||
## 💡 해결방안
|
||||
|
||||
### 1. WebSocket 프록시 구현 (권장)
|
||||
|
||||
#### 구조
|
||||
```
|
||||
[외부 사용자] → [pqadmin.0bin.in] → [Flask 서버] → [Proxmox VNC]
|
||||
↓ ↓ ↓ ↓
|
||||
wss://pqadmin.0bin.in/vnc-proxy/{session_id} → wss://100.64.0.6:8006/...
|
||||
```
|
||||
|
||||
#### 구현 방법
|
||||
1. **Flask에서 WebSocket 프록시 서버 구현**
|
||||
- Socket.IO 또는 웹소켓 라이브러리 사용
|
||||
- 외부 → Flask → Proxmox 중계 역할
|
||||
|
||||
2. **URL 변경**
|
||||
```javascript
|
||||
// 변경 전
|
||||
wss://100.64.0.6:8006/api2/json/nodes/pev/qemu/103/vncwebsocket
|
||||
|
||||
// 변경 후
|
||||
wss://pqadmin.0bin.in/vnc-proxy/session_id_here
|
||||
```
|
||||
|
||||
### 2. Nginx 리버스 프록시 확장
|
||||
|
||||
#### Nginx 설정 추가
|
||||
```nginx
|
||||
# VNC WebSocket 프록시
|
||||
location /vnc-ws/ {
|
||||
proxy_pass https://100.64.0.6:8006/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_ssl_verify off;
|
||||
}
|
||||
```
|
||||
|
||||
#### URL 변경
|
||||
```javascript
|
||||
// 변경 전
|
||||
wss://100.64.0.6:8006/api2/json/nodes/pev/qemu/103/vncwebsocket
|
||||
|
||||
// 변경 후
|
||||
wss://pqadmin.0bin.in/vnc-ws/api2/json/nodes/pev/qemu/103/vncwebsocket
|
||||
```
|
||||
|
||||
### 3. VPN 클라이언트 요구 (비권장)
|
||||
|
||||
#### 방법
|
||||
- 모든 사용자가 Headscale VPN 클라이언트 설치
|
||||
- 100.64.x.x 대역 직접 접근 가능
|
||||
|
||||
#### 단점
|
||||
- 사용자 부담 증가
|
||||
- 관리 복잡성
|
||||
- 보안 위험
|
||||
|
||||
## 🎯 권장 솔루션: WebSocket 프록시
|
||||
|
||||
### 장점
|
||||
1. **사용자 친화적**: 별도 설치 없이 웹브라우저로 접근
|
||||
2. **보안**: Headscale VPN은 내부 트래픽만 처리
|
||||
3. **확장성**: 다수의 Proxmox 호스트 지원
|
||||
4. **SSL 인증서**: Let's Encrypt로 안전한 연결
|
||||
|
||||
### 구현 우선순위
|
||||
1. **1단계**: Flask Socket.IO WebSocket 프록시 구현
|
||||
2. **2단계**: 세션 관리 및 인증 강화
|
||||
3. **3단계**: 다중 Proxmox 호스트 지원 완성
|
||||
4. **4단계**: 성능 최적화 및 모니터링
|
||||
|
||||
## 🔧 기술 스택
|
||||
|
||||
### 현재 사용 중
|
||||
- **Flask**: Web 서버
|
||||
- **noVNC**: 브라우저 VNC 클라이언트
|
||||
- **Headscale**: VPN 서버
|
||||
- **Nginx**: 리버스 프록시
|
||||
- **Let's Encrypt**: SSL 인증서
|
||||
|
||||
### 추가 필요
|
||||
- **Flask-SocketIO**: WebSocket 프록시 구현
|
||||
- **python-websockets**: WebSocket 클라이언트 (Proxmox 연결용)
|
||||
|
||||
## 📋 구현 단계
|
||||
|
||||
### Phase 1: WebSocket 프록시 서버
|
||||
1. Flask-SocketIO 설치 및 설정
|
||||
2. VNC WebSocket 프록시 핸들러 구현
|
||||
3. 세션 관리 및 인증 연동
|
||||
|
||||
### Phase 2: URL 라우팅 변경
|
||||
1. VNC 연결 URL 생성 로직 수정
|
||||
2. 프록시 경로로 WebSocket URL 변경
|
||||
3. noVNC 클라이언트 연결 테스트
|
||||
|
||||
### Phase 3: 다중 호스트 지원
|
||||
1. 호스트별 프록시 라우팅
|
||||
2. 동적 Proxmox 호스트 추가
|
||||
3. 로드 밸런싱 고려
|
||||
|
||||
## ⚠️ 고려사항
|
||||
|
||||
### 성능
|
||||
- WebSocket 프록시로 인한 지연 시간 증가
|
||||
- 동시 연결 수 제한
|
||||
- 서버 리소스 사용량 증가
|
||||
|
||||
### 보안
|
||||
- VNC 트래픽 암호화 상태 유지
|
||||
- 세션 만료 및 권한 관리
|
||||
- DDoS 방어 메커니즘
|
||||
|
||||
### 확장성
|
||||
- 다수 사용자 동시 접속
|
||||
- Proxmox 호스트 동적 추가
|
||||
- 클러스터 환경 지원
|
||||
|
||||
---
|
||||
|
||||
**결론**: WebSocket 프록시를 통해 외부 사용자가 안전하게 내부 Proxmox VNC에 접근할 수 있도록 하는 것이 가장 현실적인 해결책입니다.
|
||||
207
farmq-admin/VNC_WebSocket_Connection_Issue_Resolution.md
Normal file
207
farmq-admin/VNC_WebSocket_Connection_Issue_Resolution.md
Normal file
@@ -0,0 +1,207 @@
|
||||
# VNC WebSocket 연결 문제 해결 문서
|
||||
## Proxmox VE API 기반 VNC 접속 문제 진단 및 해결
|
||||
|
||||
### 🚨 발생한 문제
|
||||
|
||||
**증상:**
|
||||
- 브라우저에서 VNC 접속 시 WebSocket 1006 에러 (비정상 연결 종료)
|
||||
- Proxmox 로그에 `TASK ERROR: connection timed out` 발생
|
||||
- noVNC 클라이언트에서 검은 화면만 표시
|
||||
- HTTP 401 Unauthorized 에러 발생
|
||||
|
||||
**영향:**
|
||||
- 웹 브라우저를 통한 VM 콘솔 접속 불가능
|
||||
- 사용자가 VM에 직접 접근할 수 없는 상황
|
||||
|
||||
### 🔍 문제 진단 과정
|
||||
|
||||
#### 1단계: 초기 상황 파악
|
||||
```bash
|
||||
# VM 상태 확인
|
||||
VM 102 상태: running
|
||||
VM 실행시간: 1463275초 (정상 실행 중)
|
||||
VM PID: 3482
|
||||
VNC 포트: N/A ← 문제 발견
|
||||
```
|
||||
|
||||
#### 2단계: VM 설정 상세 조회
|
||||
```bash
|
||||
# Proxmox API로 VM 설정 확인
|
||||
GET /api2/json/nodes/pve7/qemu/102/config
|
||||
|
||||
결과:
|
||||
args: -vnc 0.0.0.0:77 ← 문제의 근본 원인 발견
|
||||
```
|
||||
|
||||
**💡 핵심 발견:**
|
||||
VM에 커스텀 VNC 설정 `-vnc 0.0.0.0:77`이 설정되어 있어서:
|
||||
- VM은 포트 5977 (5900 + 77)에서 VNC 서비스 제공
|
||||
- Proxmox VNC 프록시는 표준 포트 5900 기대
|
||||
- 포트 불일치로 인한 연결 실패
|
||||
|
||||
### 🛠️ 해결 과정
|
||||
|
||||
#### 1단계: VM 설정 수정 (실제 Proxmox 서버 설정 변경)
|
||||
|
||||
```python
|
||||
# Python 코드로 실제 Proxmox VM 설정 수정
|
||||
PUT /api2/json/nodes/pve7/qemu/102/config
|
||||
data = {'args': ''} # VNC 커스텀 설정 제거
|
||||
|
||||
결과: ✅ VNC args 제거 성공
|
||||
```
|
||||
|
||||
**⚠️ 중요:** 이는 Python 설정이 아닌 **실제 Proxmox 서버의 VM 102 설정을 API를 통해 수정**한 것입니다.
|
||||
|
||||
#### 2단계: VM 재시작 (설정 적용)
|
||||
|
||||
```python
|
||||
# VM 정지
|
||||
POST /api2/json/nodes/pve7/qemu/102/status/stop
|
||||
결과: VM 상태가 'stopped'로 변경 확인
|
||||
|
||||
# VM 시작
|
||||
POST /api2/json/nodes/pve7/qemu/102/status/start
|
||||
결과: VM 상태가 'running'으로 변경 확인
|
||||
```
|
||||
|
||||
#### 3단계: VNC 프록시 연결 테스트
|
||||
|
||||
```python
|
||||
# VNC 티켓 생성
|
||||
POST /api2/json/nodes/pve7/qemu/102/vncproxy
|
||||
data = {
|
||||
'websocket': '1',
|
||||
'generate-password': '1'
|
||||
}
|
||||
|
||||
결과:
|
||||
✅ 포트: 5900 (표준 포트로 정상화)
|
||||
✅ 패스워드: 자동 생성됨
|
||||
✅ WebSocket URL: 정상 생성
|
||||
```
|
||||
|
||||
#### 4단계: 인증 문제 해결
|
||||
|
||||
**문제:** WebSocket 연결 시 HTTP 401 Unauthorized
|
||||
|
||||
**근본 원인:** Proxmox VNC WebSocket은 인증이 필요하지만 브라우저와 달리 수동으로 인증 헤더를 전달해야 함
|
||||
|
||||
**해결:** PVE 인증 쿠키를 WebSocket 헤더에 추가
|
||||
|
||||
```python
|
||||
# 기존 (실패) - 인증 정보 없음
|
||||
async with websockets.connect(websocket_url, ssl=ssl_context)
|
||||
# 결과: HTTP 401 Unauthorized
|
||||
|
||||
# 수정 (성공) - PVE 인증 쿠키 추가
|
||||
headers = {'Cookie': f'PVEAuthCookie={client.ticket}'}
|
||||
async with websockets.connect(
|
||||
websocket_url,
|
||||
ssl=ssl_context,
|
||||
additional_headers=headers # 핵심: 인증 헤더 추가
|
||||
)
|
||||
# 결과: ✅ WebSocket 연결 성공
|
||||
```
|
||||
|
||||
**🔑 핵심 포인트:**
|
||||
- `client.ticket`은 Proxmox 로그인 시 받은 인증 티켓
|
||||
- VNC 티켓(`vncticket`)과는 별개의 **세션 인증 쿠키**
|
||||
- 브라우저는 자동으로 쿠키를 전송하지만, Python WebSocket은 수동으로 헤더에 추가해야 함
|
||||
|
||||
### ✅ 해결 결과
|
||||
|
||||
**최종 테스트 성공:**
|
||||
```
|
||||
🎉 VNC WebSocket 연결 및 프로토콜 핸드셰이크 성공!
|
||||
- WebSocket 연결: ✅ 성공
|
||||
- VNC 프로토콜 버전: RFB 003.008
|
||||
- 클라이언트 응답: ✅ 완료
|
||||
```
|
||||
|
||||
### 📋 변경된 설정 요약
|
||||
|
||||
| 구분 | 변경 전 | 변경 후 |
|
||||
|------|---------|---------|
|
||||
| **VM args 설정** | `-vnc 0.0.0.0:77` | `''` (빈 값) |
|
||||
| **VNC 포트** | 5977 (5900+77) | 5900 (표준) |
|
||||
| **Proxmox 프록시** | 포트 불일치 오류 | 정상 연결 |
|
||||
| **WebSocket 인증** | 인증 헤더 누락 | PVE 쿠키 추가 |
|
||||
|
||||
### 🔧 적용된 수정 사항
|
||||
|
||||
#### 1. Proxmox 서버 VM 설정 (실제 서버 변경)
|
||||
```bash
|
||||
# 변경 전
|
||||
VM 102 설정: args = "-vnc 0.0.0.0:77"
|
||||
|
||||
# 변경 후
|
||||
VM 102 설정: args = ""
|
||||
```
|
||||
|
||||
#### 2. WebSocket 테스트 코드 (`test_vnc_websocket.py`)
|
||||
```python
|
||||
# 추가된 인증 헤더
|
||||
headers = {'Cookie': f'PVEAuthCookie={client.ticket}'}
|
||||
async with websockets.connect(
|
||||
websocket_url,
|
||||
ssl=ssl_context,
|
||||
additional_headers=headers
|
||||
)
|
||||
```
|
||||
|
||||
### 🚀 브라우저 클라이언트 적용 방안
|
||||
|
||||
이제 서버 사이드 연결이 성공했으므로, 브라우저의 noVNC 클라이언트도 같은 방식으로 수정 가능:
|
||||
|
||||
**💡 핵심 인사이트:** Claude Code 환경에서 성공한 이유는 **PVE 인증 쿠키를 WebSocket 헤더에 명시적으로 추가**했기 때문
|
||||
|
||||
#### 브라우저에서 해결해야 할 사항:
|
||||
|
||||
1. **WebSocket 연결 시 PVE 인증 정보 전달**
|
||||
```javascript
|
||||
// 현재 브라우저 코드 (인증 정보 없음)
|
||||
rfb = new RFB(document.getElementById('screen'), websocketUrl,
|
||||
{ credentials: { password: vncPassword } });
|
||||
|
||||
// 필요한 수정: PVE 세션 쿠키 또는 인증 헤더 추가 방법 구현
|
||||
```
|
||||
|
||||
2. **NPM 리버스 프록시 WebSocket 업그레이드 헤더 설정**
|
||||
- WebSocket 연결을 위한 Upgrade 헤더 전달
|
||||
- Cookie 헤더의 올바른 프록시 설정
|
||||
|
||||
3. **브라우저 보안 정책 (CORS, Mixed Content) 검토**
|
||||
- HTTPS → WSS 프로토콜 일관성
|
||||
- Same-Origin Policy 또는 적절한 CORS 설정
|
||||
|
||||
**🎯 가장 중요한 발견:**
|
||||
서버 환경에서는 `additional_headers={'Cookie': f'PVEAuthCookie={client.ticket}'}`로 해결되었으므로, 브라우저에서도 동일한 인증 정보 전달 방식이 필요합니다.
|
||||
|
||||
### 📊 문제 해결 체크리스트
|
||||
|
||||
- [x] VM 커스텀 VNC 설정 제거
|
||||
- [x] VM 재시작으로 설정 적용
|
||||
- [x] VNC 티켓 생성 확인 (포트 5900)
|
||||
- [x] WebSocket 인증 헤더 추가
|
||||
- [x] VNC 프로토콜 핸드셰이크 성공
|
||||
- [x] 서버 환경에서 완전한 연결 확인
|
||||
- [ ] 브라우저 클라이언트 적용 (향후 작업)
|
||||
- [ ] NPM 프록시 WebSocket 설정 개선 (향후 작업)
|
||||
|
||||
### 🎯 핵심 교훈
|
||||
|
||||
1. **VM 설정 충돌 주의**: 커스텀 VNC 설정이 Proxmox 표준 프록시와 충돌할 수 있음
|
||||
2. **포트 일관성 중요**: VM VNC 포트와 프록시 기대 포트가 일치해야 함
|
||||
3. **인증 정보 전달**: WebSocket 연결 시 적절한 인증 헤더 필수
|
||||
4. **API 기반 설정 변경**: Proxmox API로 실시간 VM 설정 수정 가능
|
||||
|
||||
### 📅 해결 완료 시점
|
||||
- **문제 발견**: Proxmox 로그 타임아웃 에러
|
||||
- **원인 분석**: VM 커스텀 VNC 설정 충돌
|
||||
- **해결 완료**: 2024년 (Claude Code 환경에서 완전한 VNC WebSocket 연결 성공)
|
||||
- **테스트 결과**: VNC 프로토콜 핸드셰이크까지 완료
|
||||
|
||||
---
|
||||
|
||||
**💡 참고**: 이 문서는 실제 Proxmox 서버의 VM 설정을 변경한 내용을 기록한 것입니다. Python 코드는 Proxmox API 호출을 위한 클라이언트 역할만 수행했으며, 실제 변경은 Proxmox 서버에서 발생했습니다.
|
||||
1039
farmq-admin/app.py
1039
farmq-admin/app.py
File diff suppressed because it is too large
Load Diff
@@ -10,14 +10,14 @@ class Config:
|
||||
SECRET_KEY = os.environ.get('SECRET_KEY') or 'farmq-secret-key-change-in-production'
|
||||
|
||||
# 데이터베이스 설정 (기존 Headscale SQLite DB 사용)
|
||||
DATABASE_PATH = '/srv/headscale-setup/data/db.sqlite'
|
||||
DATABASE_PATH = '/srv/headscale-tailscale-replacement/data/db.sqlite'
|
||||
SQLALCHEMY_DATABASE_URI = f'sqlite:///{DATABASE_PATH}'
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||
|
||||
# 기존 Headplane 연동 설정
|
||||
HEADPLANE_URL = os.environ.get('HEADPLANE_URL') or 'http://localhost:3000'
|
||||
HEADSCALE_URL = os.environ.get('HEADSCALE_URL') or 'http://localhost:8070'
|
||||
HEADSCALE_API_KEY = os.environ.get('HEADSCALE_API_KEY') or '8qRr1IB.tV95CmA0fLaCiGGIgBfeoN9daHceFkzI'
|
||||
HEADSCALE_API_KEY = os.environ.get('HEADSCALE_API_KEY') or '0HHiZBM.lULHUaFbCQPQqU7-3tUCqFuB9JeueS9r'
|
||||
|
||||
# 모니터링 설정
|
||||
MONITORING_INTERVAL = 30 # 30초마다 데이터 수집
|
||||
|
||||
8
farmq-admin/flask-venv/bin/websockets
Executable file
8
farmq-admin/flask-venv/bin/websockets
Executable file
@@ -0,0 +1,8 @@
|
||||
#!/srv/headscale-setup/farmq-admin/flask-venv/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from websockets.cli import main
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(main())
|
||||
@@ -42,31 +42,51 @@ class JSONType(TypeDecorator):
|
||||
class PharmacyInfo(FarmqBase):
|
||||
"""약국 정보 테이블 - Headscale과 독립적"""
|
||||
__tablename__ = 'pharmacies'
|
||||
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
|
||||
|
||||
# 약국 코드 (핵심 식별자) - P001~P999
|
||||
pharmacy_code = Column(String(10), unique=True)
|
||||
|
||||
# Headscale 연결 정보 (느슨한 결합)
|
||||
headscale_user_name = Column(String(255)) # users.name 참조 (외래키 제약조건 없음)
|
||||
headscale_user_id = Column(Integer) # users.id 참조 (외래키 제약조건 없음)
|
||||
|
||||
|
||||
# 약국 기본 정보
|
||||
pharmacy_name = Column(String(255), nullable=False)
|
||||
business_number = Column(String(20))
|
||||
manager_name = Column(String(100))
|
||||
manager_name = Column(String(100)) # deprecated - use owner_name
|
||||
phone = Column(String(20))
|
||||
address = Column(Text)
|
||||
|
||||
# 기술적 정보
|
||||
|
||||
# 대표자 정보 (신규)
|
||||
owner_name = Column(String(100))
|
||||
owner_license = Column(String(50))
|
||||
owner_phone = Column(String(20))
|
||||
owner_email = Column(String(100))
|
||||
|
||||
# 요양기관 정보 (신규)
|
||||
institution_code = Column(String(20))
|
||||
institution_type = Column(String(20))
|
||||
|
||||
# 운영 정보 (신규)
|
||||
opening_date = Column(DateTime)
|
||||
business_hours = Column(Text)
|
||||
|
||||
# API 포트 (신규)
|
||||
api_port = Column(Integer, default=8082)
|
||||
|
||||
# 기술적 정보 (deprecated - pharmacy_servers로 이동)
|
||||
proxmox_host = Column(String(255))
|
||||
proxmox_username = Column(String(100))
|
||||
proxmox_api_token = Column(Text) # 암호화 권장
|
||||
tailscale_ip = Column(String(45)) # IPv4/IPv6 지원
|
||||
|
||||
tailscale_ip = Column(String(45)) # IPv4/IPv6 지원 (deprecated)
|
||||
|
||||
# 상태 관리
|
||||
status = Column(String(20), default='active') # active, inactive, maintenance
|
||||
last_sync = Column(DateTime) # 마지막 동기화 시간
|
||||
notes = Column(Text) # 관리 메모
|
||||
|
||||
|
||||
# 타임스탬프
|
||||
created_at = Column(DateTime, default=datetime.now)
|
||||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
@@ -78,6 +98,7 @@ class PharmacyInfo(FarmqBase):
|
||||
"""딕셔너리로 변환"""
|
||||
return {
|
||||
'id': self.id,
|
||||
'pharmacy_code': self.pharmacy_code,
|
||||
'headscale_user_name': self.headscale_user_name,
|
||||
'headscale_user_id': self.headscale_user_id,
|
||||
'pharmacy_name': self.pharmacy_name,
|
||||
@@ -85,6 +106,15 @@ class PharmacyInfo(FarmqBase):
|
||||
'manager_name': self.manager_name,
|
||||
'phone': self.phone,
|
||||
'address': self.address,
|
||||
'owner_name': self.owner_name,
|
||||
'owner_license': self.owner_license,
|
||||
'owner_phone': self.owner_phone,
|
||||
'owner_email': self.owner_email,
|
||||
'institution_code': self.institution_code,
|
||||
'institution_type': self.institution_type,
|
||||
'opening_date': self.opening_date.isoformat() if self.opening_date else None,
|
||||
'business_hours': self.business_hours,
|
||||
'api_port': self.api_port,
|
||||
'proxmox_host': self.proxmox_host,
|
||||
'tailscale_ip': self.tailscale_ip,
|
||||
'status': self.status,
|
||||
@@ -179,6 +209,97 @@ class MachineProfile(FarmqBase):
|
||||
}
|
||||
|
||||
|
||||
class PharmacyServer(FarmqBase):
|
||||
"""약국 서버 테이블 - 약국과 서버 분리"""
|
||||
__tablename__ = 'pharmacy_servers'
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
|
||||
# 약국 연결
|
||||
pharmacy_id = Column(Integer, nullable=False)
|
||||
pharmacy_code = Column(String(10), nullable=False)
|
||||
|
||||
# Headscale 노드 연결
|
||||
headscale_node_id = Column(Integer, unique=True)
|
||||
headscale_user_id = Column(Integer)
|
||||
|
||||
# 네트워크 정보
|
||||
vpn_ip = Column(String(45), nullable=False)
|
||||
api_port = Column(Integer, default=8082)
|
||||
is_online = Column(Boolean, default=False)
|
||||
last_seen_at = Column(DateTime)
|
||||
|
||||
# 서버 역할
|
||||
server_role = Column(String(20), default='primary') # primary, backup, test
|
||||
is_active = Column(Boolean, default=True)
|
||||
|
||||
# 하드웨어 정보
|
||||
hostname = Column(String(255))
|
||||
machine_name = Column(String(255))
|
||||
serial_number = Column(String(100))
|
||||
|
||||
cpu_model = Column(String(255))
|
||||
cpu_cores = Column(Integer)
|
||||
cpu_threads = Column(Integer)
|
||||
ram_gb = Column(Integer)
|
||||
storage_type = Column(String(50))
|
||||
storage_gb = Column(Integer)
|
||||
gpu_model = Column(String(255))
|
||||
gpu_memory_gb = Column(Integer)
|
||||
network_interfaces = Column(JSONType)
|
||||
|
||||
os_type = Column(String(50))
|
||||
os_version = Column(String(100))
|
||||
tailscale_version = Column(String(50))
|
||||
installed_software = Column(JSONType)
|
||||
|
||||
# 관리 정보
|
||||
status = Column(String(20), default='active')
|
||||
location = Column(String(255))
|
||||
purchase_date = Column(DateTime)
|
||||
warranty_expires = Column(DateTime)
|
||||
last_maintenance = Column(DateTime)
|
||||
|
||||
# 베이스라인 메트릭
|
||||
baseline_cpu_temp = Column(Float)
|
||||
baseline_cpu_usage = Column(Float)
|
||||
baseline_memory_usage = Column(Float)
|
||||
|
||||
# 메타데이터
|
||||
notes = Column(Text)
|
||||
created_at = Column(DateTime, default=datetime.now)
|
||||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<PharmacyServer(id={self.id}, pharmacy_code='{self.pharmacy_code}', vpn_ip='{self.vpn_ip}')>"
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
'id': self.id,
|
||||
'pharmacy_id': self.pharmacy_id,
|
||||
'pharmacy_code': self.pharmacy_code,
|
||||
'headscale_node_id': self.headscale_node_id,
|
||||
'vpn_ip': self.vpn_ip,
|
||||
'api_port': self.api_port,
|
||||
'is_online': self.is_online,
|
||||
'last_seen_at': self.last_seen_at.isoformat() if self.last_seen_at else None,
|
||||
'server_role': self.server_role,
|
||||
'is_active': self.is_active,
|
||||
'hostname': self.hostname,
|
||||
'machine_name': self.machine_name,
|
||||
'cpu_model': self.cpu_model,
|
||||
'cpu_cores': self.cpu_cores,
|
||||
'ram_gb': self.ram_gb,
|
||||
'storage_gb': self.storage_gb,
|
||||
'os_type': self.os_type,
|
||||
'os_version': self.os_version,
|
||||
'status': self.status,
|
||||
'location': self.location,
|
||||
'created_at': self.created_at.isoformat(),
|
||||
'updated_at': self.updated_at.isoformat()
|
||||
}
|
||||
|
||||
|
||||
class MonitoringMetrics(FarmqBase):
|
||||
"""실시간 모니터링 메트릭스 - 시계열 데이터"""
|
||||
__tablename__ = 'monitoring_metrics'
|
||||
@@ -432,6 +553,7 @@ class FarmqDatabaseManager:
|
||||
if machine:
|
||||
# 기존 머신 업데이트
|
||||
machine.hostname = headscale_node_data.get('hostname')
|
||||
machine.machine_name = headscale_node_data.get('given_name') or headscale_node_data.get('hostname')
|
||||
machine.tailscale_ip = headscale_node_data.get('ipv4')
|
||||
machine.tailscale_status = 'online' if headscale_node_data.get('is_online') else 'offline'
|
||||
machine.last_seen = datetime.now()
|
||||
@@ -442,7 +564,7 @@ class FarmqDatabaseManager:
|
||||
headscale_node_id=headscale_node_data.get('id'),
|
||||
headscale_machine_key=headscale_node_data.get('machine_key'),
|
||||
hostname=headscale_node_data.get('hostname'),
|
||||
machine_name=headscale_node_data.get('hostname'),
|
||||
machine_name=headscale_node_data.get('given_name') or headscale_node_data.get('hostname'),
|
||||
tailscale_ip=headscale_node_data.get('ipv4'),
|
||||
tailscale_status='online' if headscale_node_data.get('is_online') else 'offline',
|
||||
last_seen=datetime.now()
|
||||
|
||||
@@ -212,6 +212,11 @@
|
||||
<i class="fas fa-chart-pie text-warning"></i> 매출 대시보드
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint and 'pbs' in request.endpoint %}active{% endif %}" href="{{ url_for('pbs_monitoring') }}">
|
||||
<i class="fas fa-server text-info"></i> PBS 백업 서버
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="#">
|
||||
<i class="fas fa-chart-line"></i> 모니터링
|
||||
@@ -233,6 +238,11 @@
|
||||
<i class="fas fa-external-link-alt"></i> Medivault
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="https://git.0bin.in/thug0bin/headscale-tailscale-replacement/src/branch/feature/working-headscale-setup" target="_blank">
|
||||
<i class="fab fa-git-alt"></i> Git Repository
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="#" onclick="refreshData()">
|
||||
<i class="fas fa-sync-alt"></i> 데이터 새로고침
|
||||
|
||||
@@ -112,8 +112,36 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
<div>
|
||||
<strong>{{ machine_data.machine_name or machine_data.hostname }}</strong>
|
||||
<div class="small text-muted">{{ machine_data.hostname }}</div>
|
||||
<div class="d-flex align-items-center">
|
||||
<strong id="machineName-{{ machine_data.id }}">{{ machine_data.machine_name or machine_data.hostname }}</strong>
|
||||
<button class="btn btn-sm btn-outline-primary ms-2"
|
||||
onclick="showRenameModal({{ machine_data.id }}, '{{ machine_data.machine_name or machine_data.hostname }}')"
|
||||
title="머신 이름 변경">
|
||||
<i class="fas fa-edit"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="small text-success">
|
||||
<i class="fas fa-link"></i> <code id="magicDns-{{ machine_data.id }}">{{ machine_data.machine_name or machine_data.hostname }}.headscale.local</code>
|
||||
{% set machine_name = (machine_data.machine_name or machine_data.hostname).lower() %}
|
||||
{% if 'pbs' in machine_name %}
|
||||
<button class="btn btn-sm btn-outline-info ms-1" onclick="copyToClipboard('{{ machine_data.machine_name or machine_data.hostname }}.headscale.local')" title="Magic DNS 주소 복사 (PBS 서버 - 포트 8007 자동 추가)">
|
||||
<i class="fas fa-copy"></i><small class="ms-1">:8007</small>
|
||||
</button>
|
||||
{% elif 'pve' in machine_name %}
|
||||
<button class="btn btn-sm btn-outline-warning ms-1" onclick="copyToClipboard('{{ machine_data.machine_name or machine_data.hostname }}.headscale.local')" title="Magic DNS 주소 복사 (Proxmox VE - 포트 8006 자동 추가)">
|
||||
<i class="fas fa-copy"></i><small class="ms-1">:8006</small>
|
||||
</button>
|
||||
{% else %}
|
||||
<button class="btn btn-sm btn-outline-secondary ms-1" onclick="copyToClipboard('{{ machine_data.machine_name or machine_data.hostname }}.headscale.local')" title="Magic DNS 주소 복사">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if machine_data.hostname != (machine_data.machine_name or machine_data.hostname) %}
|
||||
<div class="small text-muted">
|
||||
<i class="fas fa-server"></i> OS: {{ machine_data.hostname }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="small">
|
||||
<i class="fas fa-user"></i> {{ machine_data.headscale_user_name or '미지정' }}
|
||||
</div>
|
||||
@@ -440,9 +468,174 @@ function deleteNode(nodeId, nodeName) {
|
||||
});
|
||||
}
|
||||
|
||||
// 스마트 포트 추가 기능
|
||||
function addSmartPort(address) {
|
||||
const lowerAddress = address.toLowerCase();
|
||||
|
||||
// PBS가 포함된 경우 :8007 포트 추가
|
||||
if (lowerAddress.includes('pbs')) {
|
||||
return address + ':8007';
|
||||
}
|
||||
|
||||
// PVE가 포함된 경우 :8006 포트 추가
|
||||
if (lowerAddress.includes('pve')) {
|
||||
return address + ':8006';
|
||||
}
|
||||
|
||||
// 기타 경우는 그대로 반환
|
||||
return address;
|
||||
}
|
||||
|
||||
// Magic DNS 주소 클립보드 복사 기능 (스마트 포트 지원)
|
||||
function copyToClipboard(text) {
|
||||
// 스마트 포트 추가 로직 적용
|
||||
const enhancedText = addSmartPort(text);
|
||||
|
||||
navigator.clipboard.writeText(enhancedText).then(() => {
|
||||
// 포트가 추가되었는지 확인하여 메시지 표시
|
||||
if (enhancedText !== text) {
|
||||
const port = enhancedText.split(':')[1];
|
||||
showToast(`Magic DNS 주소가 복사되었습니다 (포트 ${port} 자동 추가): ${enhancedText}`, 'success');
|
||||
} else {
|
||||
showToast(`Magic DNS 주소가 복사되었습니다: ${enhancedText}`, 'success');
|
||||
}
|
||||
}).catch(err => {
|
||||
console.error('복사 실패:', err);
|
||||
showToast('복사에 실패했습니다.', 'danger');
|
||||
});
|
||||
}
|
||||
|
||||
// 머신 이름 변경 모달 표시
|
||||
function showRenameModal(machineId, currentName) {
|
||||
document.getElementById('renameModal').dataset.machineId = machineId;
|
||||
document.getElementById('currentMachineName').textContent = currentName;
|
||||
document.getElementById('newMachineName').value = currentName;
|
||||
|
||||
// 모달 표시
|
||||
const modal = new bootstrap.Modal(document.getElementById('renameModal'));
|
||||
modal.show();
|
||||
}
|
||||
|
||||
// 머신 이름 변경 실행
|
||||
function renameMachine() {
|
||||
const modal = document.getElementById('renameModal');
|
||||
const machineId = modal.dataset.machineId;
|
||||
const newName = document.getElementById('newMachineName').value.trim();
|
||||
|
||||
if (!newName) {
|
||||
showToast('새로운 이름을 입력해주세요.', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
// 이름 유효성 검사
|
||||
const namePattern = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
|
||||
if (!namePattern.test(newName)) {
|
||||
showToast('이름은 소문자, 숫자, 하이픈(-)만 사용 가능하며, 하이픈으로 시작하거나 끝날 수 없습니다.', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
// 로딩 상태 표시
|
||||
const submitBtn = document.getElementById('renameSubmitBtn');
|
||||
const originalText = submitBtn.innerHTML;
|
||||
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 변경 중...';
|
||||
submitBtn.disabled = true;
|
||||
|
||||
// API 호출
|
||||
fetch(`/api/machines/${machineId}/rename`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ new_name: newName })
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
showToast(data.message, 'success');
|
||||
|
||||
// UI 업데이트
|
||||
document.getElementById(`machineName-${machineId}`).textContent = data.new_name;
|
||||
document.getElementById(`magicDns-${machineId}`).textContent = data.new_magic_dns;
|
||||
|
||||
// 모달 닫기
|
||||
bootstrap.Modal.getInstance(document.getElementById('renameModal')).hide();
|
||||
} else {
|
||||
showToast(data.error || '이름 변경에 실패했습니다.', 'danger');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('머신 이름 변경 오류:', error);
|
||||
showToast('서버 오류가 발생했습니다.', 'danger');
|
||||
})
|
||||
.finally(() => {
|
||||
// 로딩 상태 해제
|
||||
submitBtn.innerHTML = originalText;
|
||||
submitBtn.disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
// 초기 카운터 설정
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
filterMachines();
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- 머신 이름 변경 모달 -->
|
||||
<div class="modal fade" id="renameModal" tabindex="-1" aria-labelledby="renameModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="renameModalLabel">
|
||||
<i class="fas fa-edit"></i> 머신 이름 변경
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">현재 이름</label>
|
||||
<div class="form-control-plaintext">
|
||||
<strong id="currentMachineName"></strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="newMachineName" class="form-label">새로운 이름</label>
|
||||
<input type="text" class="form-control" id="newMachineName"
|
||||
placeholder="새로운 머신 이름을 입력하세요"
|
||||
pattern="^[a-z0-9]([a-z0-9-]*[a-z0-9])?$"
|
||||
oninput="updatePreview()">
|
||||
<div class="form-text">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
소문자, 숫자, 하이픈(-)만 사용 가능하며, 하이픈으로 시작하거나 끝날 수 없습니다.
|
||||
</div>
|
||||
</div>
|
||||
<div class="alert alert-info">
|
||||
<i class="fas fa-magic"></i>
|
||||
<strong>Magic DNS 주소:</strong>
|
||||
<span id="previewMagicDns">새이름.headscale.local</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">취소</button>
|
||||
<button type="button" class="btn btn-primary" id="renameSubmitBtn" onclick="renameMachine()">
|
||||
<i class="fas fa-save"></i> 변경
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Magic DNS 미리보기 업데이트
|
||||
function updatePreview() {
|
||||
const newName = document.getElementById('newMachineName').value.trim();
|
||||
const preview = document.getElementById('previewMagicDns');
|
||||
|
||||
if (newName) {
|
||||
preview.textContent = `${newName}.headscale.local`;
|
||||
} else {
|
||||
preview.textContent = '새이름.headscale.local';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
1330
farmq-admin/templates/pbs/monitoring.html
Normal file
1330
farmq-admin/templates/pbs/monitoring.html
Normal file
File diff suppressed because it is too large
Load Diff
@@ -77,12 +77,34 @@
|
||||
<div class="col-12">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2><i class="fas fa-desktop"></i> Proxmox VM 관리</h2>
|
||||
<div>
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<!-- 호스트 선택 드롭다운 -->
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-outline-secondary dropdown-toggle" type="button" id="hostSelector" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<i class="fas fa-server"></i> {{ current_host_name or '호스트 선택' }}
|
||||
</button>
|
||||
<ul class="dropdown-menu" aria-labelledby="hostSelector">
|
||||
{% for host_key, host_info in available_hosts.items() %}
|
||||
<li>
|
||||
<a class="dropdown-item {% if host_key == current_host_key %}active{% endif %}"
|
||||
href="/vms?host={{ host_key }}"
|
||||
onclick="changeHost('{{ host_key }}')">
|
||||
<i class="fas fa-server me-2"></i>
|
||||
<strong>{{ host_info.name }}</strong><br>
|
||||
<small class="text-muted">{{ host_info.host }}{% if host_info.port != 443 %}:{{ host_info.port }}{% endif %}</small>
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<button id="refresh-btn" class="btn btn-outline-primary">
|
||||
<i class="fas fa-sync-alt"></i> 새로고침
|
||||
</button>
|
||||
<span class="text-muted ms-3">
|
||||
<i class="fas fa-server"></i> {{ host }}
|
||||
|
||||
<span class="text-muted">
|
||||
<i class="fas fa-network-wired"></i>
|
||||
<small>{{ current_host_info.host }}{% if current_host_info.port != 443 %}:{{ current_host_info.port }}{% endif %}</small>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -271,7 +293,8 @@
|
||||
body: JSON.stringify({
|
||||
node: node,
|
||||
vmid: vmid,
|
||||
vm_name: vmName
|
||||
vm_name: vmName,
|
||||
host: new URLSearchParams(window.location.search).get('host') || 'pve7.0bin.in'
|
||||
})
|
||||
});
|
||||
|
||||
@@ -312,7 +335,8 @@
|
||||
},
|
||||
body: JSON.stringify({
|
||||
node: node,
|
||||
vmid: vmid
|
||||
vmid: vmid,
|
||||
host: new URLSearchParams(window.location.search).get('host') || 'pve7.0bin.in'
|
||||
})
|
||||
});
|
||||
|
||||
@@ -345,7 +369,8 @@
|
||||
},
|
||||
body: JSON.stringify({
|
||||
node: node,
|
||||
vmid: vmid
|
||||
vmid: vmid,
|
||||
host: new URLSearchParams(window.location.search).get('host') || 'pve7.0bin.in'
|
||||
})
|
||||
});
|
||||
|
||||
@@ -375,6 +400,13 @@
|
||||
location.reload();
|
||||
};
|
||||
|
||||
// 호스트 변경
|
||||
function changeHost(hostKey) {
|
||||
showSpinner();
|
||||
showToast('호스트 변경', `${hostKey} 호스트로 연결 중...`, 'info');
|
||||
// URL을 통해 페이지 이동 (이미 href에 설정되어 있음)
|
||||
};
|
||||
|
||||
// 스피너 표시/숨김
|
||||
function showSpinner() {
|
||||
document.querySelector('.loading-spinner').style.display = 'block';
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
flex: 1;
|
||||
position: relative;
|
||||
background: #000;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.vnc-status {
|
||||
@@ -58,6 +59,10 @@
|
||||
#vnc-canvas {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: #000;
|
||||
display: none;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.connection-info {
|
||||
@@ -85,6 +90,9 @@
|
||||
<button id="ctrl-alt-del-btn" class="btn btn-sm btn-warning btn-vnc">
|
||||
<i class="fas fa-keyboard"></i> Ctrl+Alt+Del
|
||||
</button>
|
||||
<button id="wake-screen-btn" class="btn btn-sm btn-info btn-vnc">
|
||||
<i class="fas fa-eye"></i> 화면 활성화
|
||||
</button>
|
||||
<button id="disconnect-btn" class="btn btn-sm btn-danger btn-vnc">
|
||||
<i class="fas fa-times"></i> 연결종료
|
||||
</button>
|
||||
@@ -112,14 +120,23 @@
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||
|
||||
<script type="module">
|
||||
// noVNC 라이브러리 import
|
||||
import RFB from 'https://cdn.jsdelivr.net/npm/@novnc/novnc/core/rfb.js';
|
||||
// Proxmox와 동일한 noVNC 정적 파일 사용
|
||||
import RFB from '/static/novnc/core/rfb.js';
|
||||
|
||||
// HTML 엔티티 디코딩 함수
|
||||
function decodeHtmlEntities(text) {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.innerHTML = text;
|
||||
return textarea.value;
|
||||
}
|
||||
|
||||
// VNC 연결 설정 (HTML 엔티티 디코딩)
|
||||
const rawWebsocketUrl = '{{ websocket_url|safe }}';
|
||||
const websocketUrl = rawWebsocketUrl.replace(/&/g, '&');
|
||||
const websocketUrl = decodeHtmlEntities(rawWebsocketUrl);
|
||||
const vmName = '{{ vm_name }}';
|
||||
const vncPassword = '{{ password }}';
|
||||
const rawPassword = '{{ password|safe }}';
|
||||
const vncPassword = decodeHtmlEntities(rawPassword);
|
||||
const vmStatus = '{{ vm_status }}';
|
||||
|
||||
let rfb;
|
||||
let isConnected = false;
|
||||
@@ -133,7 +150,9 @@
|
||||
try {
|
||||
console.log('Raw WebSocket URL:', rawWebsocketUrl);
|
||||
console.log('Decoded WebSocket URL:', websocketUrl);
|
||||
console.log('VNC Password:', vncPassword);
|
||||
console.log('Raw Password:', rawPassword);
|
||||
console.log('Decoded VNC Password:', vncPassword);
|
||||
console.log('VM Status:', vmStatus);
|
||||
console.log('RFB 클래스 사용 가능:', !!RFB);
|
||||
|
||||
// URL 유효성 검사
|
||||
@@ -144,7 +163,9 @@
|
||||
// RFB 객체 생성
|
||||
rfb = new RFB(canvas, websocketUrl, {
|
||||
credentials: { password: vncPassword },
|
||||
shared: true
|
||||
shared: true,
|
||||
repeaterID: '',
|
||||
wsProtocols: ['binary']
|
||||
});
|
||||
|
||||
// 이벤트 리스너 등록
|
||||
@@ -152,10 +173,23 @@
|
||||
rfb.addEventListener('disconnect', onDisconnected);
|
||||
rfb.addEventListener('credentialsrequired', onCredentialsRequired);
|
||||
rfb.addEventListener('securityfailure', onSecurityFailure);
|
||||
rfb.addEventListener('desktopname', onDesktopName);
|
||||
rfb.addEventListener('clipboard', onClipboard);
|
||||
|
||||
// 화면 크기 자동 조정
|
||||
// 화면 설정 - noVNC가 자동으로 관리하도록 설정
|
||||
rfb.scaleViewport = true;
|
||||
rfb.clipViewport = false;
|
||||
rfb.resizeSession = false;
|
||||
rfb.viewOnly = false;
|
||||
rfb.focusOnClick = true;
|
||||
|
||||
console.log('RFB 객체 생성 완료, 설정:', {
|
||||
scaleViewport: rfb.scaleViewport,
|
||||
clipViewport: rfb.clipViewport,
|
||||
resizeSession: rfb.resizeSession,
|
||||
viewOnly: rfb.viewOnly,
|
||||
focusOnClick: rfb.focusOnClick
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('VNC 연결 오류:', error);
|
||||
@@ -169,6 +203,33 @@
|
||||
isConnected = true;
|
||||
statusDiv.style.display = 'none';
|
||||
canvas.style.display = 'block';
|
||||
|
||||
// 즉시 Canvas 크기 강제 조정
|
||||
setTimeout(() => {
|
||||
forceCanvasResize();
|
||||
}, 100);
|
||||
|
||||
// Canvas 크기 조정
|
||||
setTimeout(() => {
|
||||
resizeCanvas();
|
||||
}, 200);
|
||||
|
||||
// Windows 화면 절전모드 해제 시도
|
||||
setTimeout(() => {
|
||||
if (rfb) {
|
||||
console.log('화면 절전모드 해제 시도...');
|
||||
|
||||
// 키보드 입력으로 화면 활성화 (마우스 이벤트는 제거)
|
||||
setTimeout(() => {
|
||||
try {
|
||||
rfb.sendKey(0x0020, '', true); // Space 키 누름
|
||||
setTimeout(() => rfb.sendKey(0x0020, '', false), 50); // Space 키 뗌
|
||||
} catch(e) {
|
||||
console.log('키보드 이벤트 전송 실패:', e.message);
|
||||
}
|
||||
}, 200);
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// 연결 해제
|
||||
@@ -195,6 +256,66 @@
|
||||
showStatus('🔒 보안 실패', 'VNC 보안 인증에 실패했습니다: ' + (e.detail.reason || 'Unknown'), 'danger');
|
||||
}
|
||||
|
||||
// 데스크탑 이름 수신
|
||||
function onDesktopName(e) {
|
||||
console.log('VNC 데스크탑 이름:', e.detail.name);
|
||||
|
||||
// 강제로 Canvas 크기 조정
|
||||
setTimeout(() => {
|
||||
forceCanvasResize();
|
||||
}, 100);
|
||||
|
||||
// 화면 크기 정보 수집
|
||||
setTimeout(() => {
|
||||
const canvas = document.getElementById('vnc-canvas');
|
||||
console.log('Canvas dimensions:', canvas.width, 'x', canvas.height);
|
||||
console.log('Canvas client size:', canvas.clientWidth, 'x', canvas.clientHeight);
|
||||
}, 500);
|
||||
}
|
||||
|
||||
// 강제 Canvas 크기 조정
|
||||
function forceCanvasResize() {
|
||||
if (!rfb || !isConnected) return;
|
||||
|
||||
const container = document.querySelector('.vnc-screen');
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
|
||||
console.log('컨테이너 크기:', containerRect.width, 'x', containerRect.height);
|
||||
|
||||
// Canvas를 컨테이너 크기에 맞게 설정
|
||||
canvas.width = containerRect.width;
|
||||
canvas.height = containerRect.height;
|
||||
canvas.style.width = containerRect.width + 'px';
|
||||
canvas.style.height = containerRect.height + 'px';
|
||||
|
||||
console.log('Canvas 크기 강제 설정:', canvas.width, 'x', canvas.height);
|
||||
|
||||
// noVNC 스케일링 재설정
|
||||
rfb.scaleViewport = true;
|
||||
rfb.resizeSession = false;
|
||||
|
||||
// 화면 새로고침 시도
|
||||
setTimeout(() => {
|
||||
if (rfb) {
|
||||
try {
|
||||
// 마우스 클릭으로 화면 새로고침 유도
|
||||
canvas.dispatchEvent(new MouseEvent('click', {
|
||||
clientX: canvas.width / 2,
|
||||
clientY: canvas.height / 2,
|
||||
bubbles: true
|
||||
}));
|
||||
} catch(e) {
|
||||
console.log('화면 새로고침 실패:', e.message);
|
||||
}
|
||||
}
|
||||
}, 200);
|
||||
}
|
||||
|
||||
// 클립보드 이벤트
|
||||
function onClipboard(e) {
|
||||
console.log('VNC 클립보드:', e.detail.text);
|
||||
}
|
||||
|
||||
// 상태 표시
|
||||
function showStatus(title, message, type = 'info') {
|
||||
statusDiv.innerHTML = `
|
||||
@@ -207,6 +328,35 @@
|
||||
statusDiv.style.display = 'block';
|
||||
}
|
||||
|
||||
// Canvas 크기 조정
|
||||
function resizeCanvas() {
|
||||
if (rfb && isConnected) {
|
||||
console.log('Canvas 크기 조정 시작');
|
||||
|
||||
// Canvas 표시
|
||||
canvas.style.display = 'block';
|
||||
|
||||
// noVNC가 자동으로 Canvas 크기를 관리하도록 설정
|
||||
rfb.scaleViewport = true;
|
||||
rfb.clipViewport = false;
|
||||
rfb.resizeSession = false;
|
||||
|
||||
console.log('Canvas 설정 완료:', {
|
||||
scaleViewport: rfb.scaleViewport,
|
||||
clipViewport: rfb.clipViewport,
|
||||
resizeSession: rfb.resizeSession
|
||||
});
|
||||
|
||||
// 포커스 활성화
|
||||
setTimeout(() => {
|
||||
if (rfb) {
|
||||
rfb.focus();
|
||||
console.log('VNC 화면 포커스 완료');
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
|
||||
// 전체화면
|
||||
document.getElementById('fullscreen-btn').onclick = function() {
|
||||
if (document.fullscreenElement) {
|
||||
@@ -229,7 +379,70 @@
|
||||
// Ctrl+Alt+Del 전송
|
||||
document.getElementById('ctrl-alt-del-btn').onclick = function() {
|
||||
if (rfb && isConnected) {
|
||||
console.log('Ctrl+Alt+Del 전송 시도...');
|
||||
rfb.sendCtrlAltDel();
|
||||
|
||||
// 화면 활성화 시도
|
||||
setTimeout(() => {
|
||||
if (rfb) {
|
||||
rfb.focus();
|
||||
|
||||
// 키보드 입력으로 화면 활성화
|
||||
try {
|
||||
rfb.sendKey(0xFF0D, '', true); // Enter 키 누름
|
||||
setTimeout(() => rfb.sendKey(0xFF0D, '', false), 50); // Enter 키 뗌
|
||||
setTimeout(() => {
|
||||
rfb.sendKey(0x0020, '', true); // Space 키 누름
|
||||
setTimeout(() => rfb.sendKey(0x0020, '', false), 50); // Space 키 뗌
|
||||
}, 100);
|
||||
} catch(e) {
|
||||
console.log('Ctrl+Alt+Del 후 키보드 이벤트 실패:', e.message);
|
||||
}
|
||||
|
||||
console.log('화면 활성화 시도 완료');
|
||||
}
|
||||
}, 500);
|
||||
} else {
|
||||
console.log('VNC 연결되지 않음 - Ctrl+Alt+Del 실패');
|
||||
}
|
||||
};
|
||||
|
||||
// 화면 활성화
|
||||
document.getElementById('wake-screen-btn').onclick = function() {
|
||||
if (rfb && isConnected) {
|
||||
console.log('화면 활성화 버튼 클릭 - 절전모드 해제 시도');
|
||||
|
||||
// 먼저 Canvas 크기 강제 조정
|
||||
forceCanvasResize();
|
||||
|
||||
// 키보드 입력으로 화면 활성화
|
||||
setTimeout(() => {
|
||||
try {
|
||||
rfb.sendKey(0x0020, '', true); // Space 누름
|
||||
setTimeout(() => rfb.sendKey(0x0020, '', false), 50); // Space 뗌
|
||||
setTimeout(() => {
|
||||
rfb.sendKey(0xFF0D, '', true); // Enter 누름
|
||||
setTimeout(() => rfb.sendKey(0xFF0D, '', false), 50); // Enter 뗌
|
||||
}, 100);
|
||||
setTimeout(() => {
|
||||
rfb.sendKey(0x0020, '', true); // Space 누름 (다시)
|
||||
setTimeout(() => rfb.sendKey(0x0020, '', false), 50); // Space 뗌
|
||||
}, 200);
|
||||
} catch(e) {
|
||||
console.log('화면 활성화 키보드 이벤트 실패:', e.message);
|
||||
}
|
||||
}, 300);
|
||||
|
||||
// 3. Canvas 포커스
|
||||
setTimeout(() => {
|
||||
rfb.focus();
|
||||
const canvas = document.getElementById('vnc-canvas');
|
||||
canvas.click();
|
||||
}, 500);
|
||||
|
||||
console.log('화면 활성화 시퀀스 완료');
|
||||
} else {
|
||||
console.log('VNC 연결되지 않음 - 화면 활성화 실패');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -244,6 +457,12 @@
|
||||
// 페이지 로드 후 연결 시작 (ES6 모듈은 즉시 사용 가능)
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
console.log('DOM 로드 완료, VNC 연결 시작...');
|
||||
|
||||
// VM 상태에 따른 안내
|
||||
if (vmStatus !== 'running') {
|
||||
showStatus('⚠️ VM 상태 확인', `VM이 현재 ${vmStatus} 상태입니다. 화면이 나타나지 않을 수 있습니다.`, 'warning');
|
||||
}
|
||||
|
||||
setTimeout(connectVNC, 1000);
|
||||
});
|
||||
|
||||
@@ -253,6 +472,16 @@
|
||||
rfb.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
// 창 크기 변경 시 Canvas 크기 조정
|
||||
window.addEventListener('resize', function() {
|
||||
if (isConnected) {
|
||||
setTimeout(() => {
|
||||
forceCanvasResize();
|
||||
resizeCanvas();
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
242
farmq-admin/templates/vnc_proxy.html
Normal file
242
farmq-admin/templates/vnc_proxy.html
Normal file
@@ -0,0 +1,242 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ vm_name }} - VNC 콘솔 (프록시)</title>
|
||||
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
background-color: dimgrey;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
html {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#top_bar {
|
||||
background-color: #6e84a3;
|
||||
color: white;
|
||||
font: bold 12px Helvetica;
|
||||
padding: 6px 5px 4px 5px;
|
||||
border-bottom: 1px outset;
|
||||
}
|
||||
#status {
|
||||
text-align: center;
|
||||
}
|
||||
#sendCtrlAltDelButton {
|
||||
position: fixed;
|
||||
top: 0px;
|
||||
right: 120px;
|
||||
border: 1px outset;
|
||||
padding: 5px 5px 4px 5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#connectButton {
|
||||
position: fixed;
|
||||
top: 0px;
|
||||
right: 0px;
|
||||
border: 1px outset;
|
||||
padding: 5px 5px 4px 5px;
|
||||
cursor: pointer;
|
||||
background-color: #28a745;
|
||||
color: white;
|
||||
}
|
||||
|
||||
#screen {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #ff6b6b;
|
||||
background-color: #ffe6e6;
|
||||
padding: 10px;
|
||||
margin: 10px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #ff6b6b;
|
||||
}
|
||||
|
||||
.success {
|
||||
color: #28a745;
|
||||
background-color: #e6ffe6;
|
||||
padding: 10px;
|
||||
margin: 10px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #28a745;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="top_bar">
|
||||
<div id="status">로딩 중...</div>
|
||||
<div id="sendCtrlAltDelButton">Ctrl+Alt+Del 전송</div>
|
||||
<div id="connectButton">VNC 연결</div>
|
||||
</div>
|
||||
<div id="screen">
|
||||
<div id="connectionMessages"></div>
|
||||
</div>
|
||||
|
||||
<!-- Socket.IO 클라이언트 -->
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.7.2/socket.io.js"></script>
|
||||
|
||||
<!-- noVNC 라이브러리 -->
|
||||
<script type="module" crossorigin="anonymous">
|
||||
import RFB from '/static/novnc/core/rfb.js';
|
||||
|
||||
let rfb;
|
||||
let desktopName;
|
||||
let socket;
|
||||
|
||||
// 상태 표시 함수
|
||||
function status(text) {
|
||||
document.getElementById('status').textContent = text;
|
||||
}
|
||||
|
||||
function showMessage(message, type = 'info') {
|
||||
const messagesDiv = document.getElementById('connectionMessages');
|
||||
const messageDiv = document.createElement('div');
|
||||
messageDiv.className = type;
|
||||
messageDiv.textContent = message;
|
||||
messagesDiv.appendChild(messageDiv);
|
||||
|
||||
// 5초 후 메시지 제거
|
||||
setTimeout(() => {
|
||||
if (messageDiv.parentNode) {
|
||||
messageDiv.parentNode.removeChild(messageDiv);
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// noVNC 이벤트 핸들러
|
||||
function connectedToServer(e) {
|
||||
status("연결됨");
|
||||
showMessage("VNC 서버에 성공적으로 연결되었습니다.", 'success');
|
||||
}
|
||||
|
||||
function disconnectedFromServer(e) {
|
||||
if (e.detail.clean) {
|
||||
status("연결 종료");
|
||||
showMessage("VNC 연결이 정상적으로 종료되었습니다.", 'info');
|
||||
} else {
|
||||
status("연결 실패");
|
||||
showMessage("VNC 연결이 예기치 않게 종료되었습니다.", 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function credentialsAreRequired(e) {
|
||||
status("인증 필요");
|
||||
showMessage("VNC 서버에서 추가 인증을 요구합니다.", 'error');
|
||||
}
|
||||
|
||||
function updateDesktopName(e) {
|
||||
desktopName = e.detail.name;
|
||||
status("연결됨: " + desktopName);
|
||||
}
|
||||
|
||||
function onSecurityFailure(e) {
|
||||
status("보안 인증 실패");
|
||||
showMessage("VNC 보안 인증에 실패했습니다: " + e.detail.reason, 'error');
|
||||
}
|
||||
|
||||
// Socket.IO 연결 및 이벤트 핸들러
|
||||
function initSocketIO() {
|
||||
socket = io();
|
||||
|
||||
socket.on('connect', function() {
|
||||
console.log('Socket.IO 연결됨');
|
||||
status("서버 연결됨 - VNC 연결 대기 중");
|
||||
});
|
||||
|
||||
socket.on('disconnect', function() {
|
||||
console.log('Socket.IO 연결 종료');
|
||||
status("서버 연결 종료");
|
||||
});
|
||||
|
||||
socket.on('vnc_ready', function(data) {
|
||||
console.log('VNC 연결 준비 완료:', data);
|
||||
status("VNC 연결 중...");
|
||||
|
||||
// noVNC로 직접 연결 (테스트용)
|
||||
try {
|
||||
const websocketUrl = data.websocket_url;
|
||||
const password = data.password;
|
||||
|
||||
console.log('WebSocket URL:', websocketUrl);
|
||||
console.log('VNC Password:', password);
|
||||
|
||||
rfb = new RFB(document.getElementById('screen'), websocketUrl,
|
||||
{ credentials: { password: password } });
|
||||
|
||||
// 이벤트 리스너 등록
|
||||
rfb.addEventListener("connect", connectedToServer);
|
||||
rfb.addEventListener("disconnect", disconnectedFromServer);
|
||||
rfb.addEventListener("credentialsrequired", credentialsAreRequired);
|
||||
rfb.addEventListener("desktopname", updateDesktopName);
|
||||
rfb.addEventListener("securityfailure", onSecurityFailure);
|
||||
|
||||
showMessage("VNC 연결을 시도 중입니다...", 'info');
|
||||
|
||||
} catch (error) {
|
||||
console.error('VNC 연결 오류:', error);
|
||||
showMessage("VNC 연결 중 오류가 발생했습니다: " + error.message, 'error');
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('vnc_error', function(data) {
|
||||
console.error('VNC 프록시 오류:', data);
|
||||
status("VNC 연결 실패");
|
||||
showMessage("VNC 연결 실패: " + data.error, 'error');
|
||||
});
|
||||
}
|
||||
|
||||
// VNC 연결 시작
|
||||
function connectVNC() {
|
||||
if (socket && socket.connected) {
|
||||
showMessage("VNC 연결을 요청 중입니다...", 'info');
|
||||
status("VNC 연결 요청 중...");
|
||||
|
||||
socket.emit('vnc_connect', {
|
||||
vm_id: {{ vmid }},
|
||||
node: '{{ node }}',
|
||||
vm_name: '{{ vm_name }}'
|
||||
});
|
||||
} else {
|
||||
showMessage("서버 연결이 필요합니다. 잠시 후 다시 시도해주세요.", 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Ctrl+Alt+Del 전송
|
||||
function sendCtrlAltDel() {
|
||||
if (rfb) {
|
||||
rfb.sendCtrlAltDel();
|
||||
showMessage("Ctrl+Alt+Del을 전송했습니다.", 'info');
|
||||
}
|
||||
}
|
||||
|
||||
// 이벤트 리스너 설정
|
||||
document.getElementById('connectButton').onclick = connectVNC;
|
||||
document.getElementById('sendCtrlAltDelButton').onclick = sendCtrlAltDel;
|
||||
|
||||
// 페이지 로드 시 Socket.IO 초기화
|
||||
window.addEventListener('load', function() {
|
||||
initSocketIO();
|
||||
});
|
||||
|
||||
// 페이지 언로드 시 연결 정리
|
||||
window.addEventListener('beforeunload', function() {
|
||||
if (rfb) {
|
||||
rfb.disconnect();
|
||||
}
|
||||
if (socket) {
|
||||
socket.disconnect();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
331
farmq-admin/templates/vnc_simple.html
Normal file
331
farmq-admin/templates/vnc_simple.html
Normal file
@@ -0,0 +1,331 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ vm_name }} - VNC 콘솔</title>
|
||||
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
background-color: dimgrey;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
html {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#top_bar {
|
||||
background-color: #6e84a3;
|
||||
color: white;
|
||||
font: bold 12px Helvetica;
|
||||
padding: 6px 5px 4px 5px;
|
||||
border-bottom: 1px outset;
|
||||
}
|
||||
#status {
|
||||
text-align: center;
|
||||
}
|
||||
#sendCtrlAltDelButton {
|
||||
position: fixed;
|
||||
top: 0px;
|
||||
right: 120px;
|
||||
border: 1px outset;
|
||||
padding: 5px 5px 4px 5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#refreshTicketButton {
|
||||
position: fixed;
|
||||
top: 0px;
|
||||
right: 0px;
|
||||
border: 1px outset;
|
||||
padding: 5px 5px 4px 5px;
|
||||
cursor: pointer;
|
||||
background-color: #5bc0de;
|
||||
}
|
||||
|
||||
#screen {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script type="module" crossorigin="anonymous">
|
||||
// RFB holds the API to connect and communicate with a VNC server
|
||||
import RFB from '/static/novnc/core/rfb.js';
|
||||
|
||||
let rfb;
|
||||
let desktopName;
|
||||
|
||||
// HTML 엔티티 디코딩 함수
|
||||
function decodeHtmlEntities(text) {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.innerHTML = text;
|
||||
return textarea.value;
|
||||
}
|
||||
|
||||
// When this function is called we have
|
||||
// successfully connected to a server
|
||||
function connectedToServer(e) {
|
||||
status("Connected to " + desktopName);
|
||||
}
|
||||
|
||||
// This function is called when we are disconnected
|
||||
function disconnectedFromServer(e) {
|
||||
console.log('🔌 VNC 연결 해제 상세 정보:', e.detail);
|
||||
|
||||
if (e.detail.clean) {
|
||||
status("연결이 정상적으로 종료되었습니다");
|
||||
} else {
|
||||
const reason = e.detail.reason || 'Unknown';
|
||||
console.error('❌ VNC 연결 실패 상세:', {
|
||||
code: e.detail.code,
|
||||
reason: e.detail.reason,
|
||||
wasClean: e.detail.clean
|
||||
});
|
||||
|
||||
// WebSocket 에러 코드별 메시지
|
||||
const errorMessages = {
|
||||
1006: 'WebSocket 서버에 연결할 수 없습니다. SSL 인증서를 확인하세요.',
|
||||
1000: '정상적으로 연결이 종료되었습니다.',
|
||||
1002: '프로토콜 오류가 발생했습니다.',
|
||||
1003: '지원하지 않는 데이터를 받았습니다.',
|
||||
1009: '메시지가 너무 큽니다.',
|
||||
1011: '서버에서 예상치 못한 오류가 발생했습니다.'
|
||||
};
|
||||
|
||||
const userFriendlyMessage = errorMessages[e.detail.code] || `알 수 없는 오류 (코드: ${e.detail.code})`;
|
||||
status(`❌ ${userFriendlyMessage}`);
|
||||
|
||||
// SSL 인증서 문제일 가능성이 높은 경우 SSL 도움말 페이지로 이동
|
||||
if (e.detail.code === 1006 || !e.detail.clean) {
|
||||
setTimeout(() => {
|
||||
const sessionId = window.location.pathname.split('/').pop();
|
||||
window.location.href = `/vnc/${sessionId}/ssl-help`;
|
||||
}, 5000); // 5초 후 이동하여 사용자가 메시지를 읽을 시간 제공
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// When this function is called, the server requires
|
||||
// credentials to authenticate
|
||||
function credentialsAreRequired(e) {
|
||||
console.log('VNC 인증 정보가 필요합니다. 티켓을 새로고침합니다...');
|
||||
refreshVNCTicket();
|
||||
}
|
||||
|
||||
// VNC 보안 실패 시 티켓 새로고침
|
||||
function onSecurityFailure(e) {
|
||||
console.log('VNC 보안 인증 실패:', e.detail);
|
||||
status("Authentication failed - refreshing ticket...");
|
||||
refreshVNCTicket();
|
||||
}
|
||||
|
||||
// VNC 티켓 새로고침
|
||||
function refreshVNCTicket() {
|
||||
const sessionId = window.location.pathname.split('/').pop();
|
||||
|
||||
fetch(`/api/vnc/refresh/${sessionId}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
console.log('✅ 새 VNC 티켓 받음, 재연결 시도...');
|
||||
status("Got new ticket - reconnecting...");
|
||||
|
||||
// 기존 연결 종료
|
||||
if (rfb) {
|
||||
rfb.disconnect();
|
||||
}
|
||||
|
||||
// 잠시 후 새 티켓으로 재연결
|
||||
setTimeout(() => {
|
||||
connectWithNewTicket(data.websocket_url, data.password);
|
||||
}, 1000);
|
||||
} else {
|
||||
status("Failed to refresh ticket: " + (data.error || 'Unknown error'));
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('티켓 새로고침 실패:', error);
|
||||
status("Failed to refresh ticket");
|
||||
});
|
||||
}
|
||||
|
||||
// 새 티켓으로 재연결
|
||||
function connectWithNewTicket(newWebsocketUrl, newPassword) {
|
||||
console.log('새 티켓으로 VNC 재연결 시도...');
|
||||
status("Reconnecting with new ticket...");
|
||||
|
||||
const decodedUrl = decodeHtmlEntities(newWebsocketUrl);
|
||||
const decodedPassword = decodeHtmlEntities(newPassword);
|
||||
|
||||
console.log('새 WebSocket URL:', decodedUrl);
|
||||
console.log('새 VNC Password:', decodedPassword);
|
||||
|
||||
// 새 RFB 연결 생성
|
||||
rfb = new RFB(document.getElementById('screen'), decodedUrl,
|
||||
{ credentials: { password: decodedPassword } });
|
||||
|
||||
// 이벤트 리스너 재등록
|
||||
rfb.addEventListener("connect", connectedToServer);
|
||||
rfb.addEventListener("disconnect", disconnectedFromServer);
|
||||
rfb.addEventListener("credentialsrequired", credentialsAreRequired);
|
||||
rfb.addEventListener("desktopname", updateDesktopName);
|
||||
rfb.addEventListener("securityfailure", onSecurityFailure);
|
||||
|
||||
// 설정 적용
|
||||
rfb.viewOnly = false;
|
||||
rfb.scaleViewport = true;
|
||||
}
|
||||
|
||||
// When this function is called we have received
|
||||
// a desktop name from the server
|
||||
function updateDesktopName(e) {
|
||||
desktopName = e.detail.name;
|
||||
}
|
||||
|
||||
// Since most operating systems will catch Ctrl+Alt+Del
|
||||
// before they get a chance to be intercepted by the browser,
|
||||
// we provide a way to emulate this key sequence.
|
||||
function sendCtrlAltDel() {
|
||||
rfb.sendCtrlAltDel();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Show a status text in the top bar
|
||||
function status(text) {
|
||||
document.getElementById('status').textContent = text;
|
||||
}
|
||||
|
||||
document.getElementById('sendCtrlAltDelButton').onclick = sendCtrlAltDel;
|
||||
document.getElementById('refreshTicketButton').onclick = function() {
|
||||
refreshVNCTicket();
|
||||
};
|
||||
|
||||
// VNC 연결 정보 (서버에서 전달)
|
||||
const rawWebsocketUrl = '{{ websocket_url|safe }}';
|
||||
const websocketUrl = decodeHtmlEntities(rawWebsocketUrl);
|
||||
const rawPassword = '{{ password|safe }}';
|
||||
const vncPassword = decodeHtmlEntities(rawPassword);
|
||||
|
||||
console.log('WebSocket URL:', websocketUrl);
|
||||
console.log('VNC Password:', vncPassword);
|
||||
|
||||
// WebSocket URL 유효성 검사
|
||||
function validateWebSocketUrl(url) {
|
||||
if (!url || typeof url !== 'string') {
|
||||
console.error('❌ WebSocket URL이 비어있습니다');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!url.startsWith('wss://') && !url.startsWith('ws://')) {
|
||||
console.error('❌ 올바르지 않은 WebSocket URL 프로토콜:', url);
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log('✅ WebSocket URL 유효성 검사 통과');
|
||||
return true;
|
||||
}
|
||||
|
||||
// VNC 연결 함수
|
||||
function connectToVNC() {
|
||||
if (!validateWebSocketUrl(websocketUrl)) {
|
||||
status("❌ 잘못된 WebSocket URL입니다");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!vncPassword || vncPassword.trim() === '') {
|
||||
status("❌ VNC 패스워드가 없습니다");
|
||||
return;
|
||||
}
|
||||
|
||||
status("Connecting to VM...");
|
||||
console.log('🔄 VNC 연결 시도 시작...');
|
||||
|
||||
try {
|
||||
// WebSocket 연결 직접 테스트
|
||||
console.log('🧪 WebSocket 연결 직접 테스트...');
|
||||
const testWS = new WebSocket(websocketUrl);
|
||||
|
||||
testWS.onopen = function(event) {
|
||||
console.log('✅ WebSocket 연결 테스트 성공');
|
||||
testWS.close();
|
||||
|
||||
// WebSocket이 연결되면 RFB 객체 생성
|
||||
createRFBConnection();
|
||||
};
|
||||
|
||||
testWS.onerror = function(error) {
|
||||
console.error('❌ WebSocket 연결 테스트 실패:', error);
|
||||
status('❌ WebSocket 서버에 연결할 수 없습니다');
|
||||
|
||||
// 대안: 티켓 새로고침 시도
|
||||
setTimeout(() => {
|
||||
status('🔄 새 티켓으로 재시도 중...');
|
||||
refreshVNCTicket();
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
testWS.onclose = function(event) {
|
||||
console.log('🔌 WebSocket 테스트 연결 종료:', event.code, event.reason);
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ WebSocket 테스트 실패:', error);
|
||||
status(`❌ 연결 초기화 실패: ${error.message}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// RFB 연결 생성 함수
|
||||
function createRFBConnection() {
|
||||
try {
|
||||
console.log('🔄 RFB 객체 생성 시작...');
|
||||
|
||||
// Creating a new RFB object will start a new connection
|
||||
rfb = new RFB(document.getElementById('screen'), websocketUrl,
|
||||
{ credentials: { password: vncPassword } });
|
||||
|
||||
console.log('✅ RFB 객체 생성 완료');
|
||||
|
||||
// Add listeners to important events from the RFB module
|
||||
rfb.addEventListener("connect", connectedToServer);
|
||||
rfb.addEventListener("disconnect", disconnectedFromServer);
|
||||
rfb.addEventListener("credentialsrequired", credentialsAreRequired);
|
||||
rfb.addEventListener("desktopname", updateDesktopName);
|
||||
rfb.addEventListener("securityfailure", onSecurityFailure);
|
||||
|
||||
// Set parameters that can be changed on an active connection
|
||||
rfb.viewOnly = false;
|
||||
rfb.scaleViewport = true;
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ RFB 객체 생성 실패:', error);
|
||||
status(`❌ VNC 클라이언트 초기화 실패: ${error.message}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 연결 시작
|
||||
connectToVNC();
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="top_bar">
|
||||
<div id="status">Loading</div>
|
||||
<div id="sendCtrlAltDelButton">Send CtrlAltDel</div>
|
||||
<div id="refreshTicketButton">🔄 Refresh</div>
|
||||
</div>
|
||||
<div id="screen">
|
||||
<!-- This is where the remote screen will appear -->
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
291
farmq-admin/templates/vnc_ssl_help.html
Normal file
291
farmq-admin/templates/vnc_ssl_help.html
Normal file
@@ -0,0 +1,291 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SSL 인증서 문제 해결 - {{ vm_name }}</title>
|
||||
|
||||
<!-- Bootstrap CSS -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||
|
||||
<style>
|
||||
body {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
}
|
||||
|
||||
.ssl-help-container {
|
||||
background: white;
|
||||
border-radius: 15px;
|
||||
box-shadow: 0 20px 40px rgba(0,0,0,0.1);
|
||||
margin: 50px auto;
|
||||
max-width: 800px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ssl-help-header {
|
||||
background: linear-gradient(135deg, #ff6b6b, #ffd93d);
|
||||
color: white;
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ssl-help-body {
|
||||
padding: 30px;
|
||||
}
|
||||
|
||||
.step-card {
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 20px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.step-header {
|
||||
background: #f8f9fa;
|
||||
padding: 15px 20px;
|
||||
font-weight: bold;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.step-number {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 15px;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.step-content {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.url-box {
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 5px;
|
||||
padding: 10px;
|
||||
font-family: monospace;
|
||||
word-break: break-all;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.btn-copy {
|
||||
margin-left: 10px;
|
||||
padding: 5px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.warning-box {
|
||||
background: #fff3cd;
|
||||
border: 1px solid #ffeaa7;
|
||||
border-radius: 5px;
|
||||
padding: 15px;
|
||||
margin: 15px 0;
|
||||
}
|
||||
|
||||
.success-box {
|
||||
background: #d4edda;
|
||||
border: 1px solid #c3e6cb;
|
||||
border-radius: 5px;
|
||||
padding: 15px;
|
||||
margin: 15px 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="ssl-help-container">
|
||||
<div class="ssl-help-header">
|
||||
<i class="fas fa-shield-alt fa-3x mb-3"></i>
|
||||
<h2>SSL 인증서 신뢰 설정이 필요합니다</h2>
|
||||
<p class="mb-0">{{ vm_name }} VNC 연결을 위해 Proxmox 서버의 SSL 인증서를 신뢰해야 합니다.</p>
|
||||
</div>
|
||||
|
||||
<div class="ssl-help-body">
|
||||
<div class="warning-box">
|
||||
<i class="fas fa-exclamation-triangle text-warning me-2"></i>
|
||||
<strong>연결 실패 원인:</strong> 브라우저가 Proxmox 서버의 자체 서명된 SSL 인증서를 신뢰하지 않아 WebSocket 연결이 차단되고 있습니다.
|
||||
</div>
|
||||
|
||||
<div class="step-card">
|
||||
<div class="step-header">
|
||||
<div class="step-number">1</div>
|
||||
SSL 인증서 신뢰 설정
|
||||
</div>
|
||||
<div class="step-content">
|
||||
<p>아래 링크를 <strong>새 탭</strong>에서 열어 Proxmox 서버의 SSL 인증서를 신뢰하도록 설정하세요:</p>
|
||||
|
||||
<div class="url-box">
|
||||
<a href="https://{{ proxmox_host }}:{{ proxmox_port }}" target="_blank" id="proxmoxUrl">
|
||||
https://{{ proxmox_host }}:{{ proxmox_port }}
|
||||
</a>
|
||||
<button class="btn btn-sm btn-outline-secondary btn-copy" onclick="copyUrl()">
|
||||
<i class="fas fa-copy"></i> 복사
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<h6><i class="fas fa-info-circle text-info me-2"></i>브라우저별 설정 방법:</h6>
|
||||
<ul class="list-unstyled ms-3">
|
||||
<li><strong>Chrome/Edge:</strong> "고급" → "{{ proxmox_host }}(으)로 이동(안전하지 않음)" 클릭</li>
|
||||
<li><strong>Firefox:</strong> "고급" → "위험을 감수하고 계속" 클릭</li>
|
||||
<li><strong>Safari:</strong> "세부 정보 표시" → "웹 사이트 방문" 클릭</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="step-card">
|
||||
<div class="step-header">
|
||||
<div class="step-number">2</div>
|
||||
Proxmox 웹 인터페이스 확인
|
||||
</div>
|
||||
<div class="step-content">
|
||||
<p>링크를 클릭하면 Proxmox VE 웹 인터페이스가 표시됩니다. 로그인할 필요는 없으며, 페이지가 정상적으로 로드되면 SSL 인증서 신뢰 설정이 완료된 것입니다.</p>
|
||||
|
||||
<div class="success-box">
|
||||
<i class="fas fa-check-circle text-success me-2"></i>
|
||||
<strong>성공 확인:</strong> Proxmox 로그인 페이지가 보이면 인증서 신뢰 설정이 완료되었습니다.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="step-card">
|
||||
<div class="step-header">
|
||||
<div class="step-number">3</div>
|
||||
VNC 연결 재시도
|
||||
</div>
|
||||
<div class="step-content">
|
||||
<p>SSL 인증서 신뢰 설정이 완료되면, 아래 버튼을 클릭하여 VNC 연결을 다시 시도하세요:</p>
|
||||
|
||||
<div class="text-center mt-4">
|
||||
<button class="btn btn-primary btn-lg" onclick="retryVncConnection()">
|
||||
<i class="fas fa-desktop me-2"></i>VNC 연결 재시도
|
||||
</button>
|
||||
|
||||
<button class="btn btn-outline-secondary btn-lg ms-3" onclick="testWebSocket()">
|
||||
<i class="fas fa-wifi me-2"></i>연결 테스트
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="connectionStatus" class="mt-3"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="step-card">
|
||||
<div class="step-header">
|
||||
<div class="step-number">4</div>
|
||||
문제 해결
|
||||
</div>
|
||||
<div class="step-content">
|
||||
<p>위 단계를 완료했는데도 연결이 되지 않는다면:</p>
|
||||
<ul>
|
||||
<li>브라우저를 완전히 닫고 다시 열어보세요</li>
|
||||
<li>시크릿/인코그니토 모드에서 시도해보세요</li>
|
||||
<li>다른 브라우저를 사용해보세요</li>
|
||||
<li>방화벽이나 프록시 설정을 확인하세요</li>
|
||||
</ul>
|
||||
|
||||
<div class="text-center mt-3">
|
||||
<a href="/vms?host={{ host_key }}" class="btn btn-outline-dark">
|
||||
<i class="fas fa-arrow-left me-2"></i>VM 목록으로 돌아가기
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bootstrap JS -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
|
||||
<script>
|
||||
// URL 복사 함수
|
||||
function copyUrl() {
|
||||
const url = document.getElementById('proxmoxUrl').href;
|
||||
navigator.clipboard.writeText(url).then(() => {
|
||||
alert('URL이 클립보드에 복사되었습니다.');
|
||||
});
|
||||
}
|
||||
|
||||
// VNC 연결 재시도
|
||||
function retryVncConnection() {
|
||||
const sessionId = '{{ session_id }}';
|
||||
window.location.href = `/vnc/${sessionId}`;
|
||||
}
|
||||
|
||||
// WebSocket 연결 테스트
|
||||
function testWebSocket() {
|
||||
const statusDiv = document.getElementById('connectionStatus');
|
||||
statusDiv.innerHTML = '<div class="text-center"><i class="fas fa-spinner fa-spin"></i> 연결 테스트 중...</div>';
|
||||
|
||||
try {
|
||||
const websocketUrl = '{{ websocket_url }}';
|
||||
const ws = new WebSocket(websocketUrl);
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
ws.close();
|
||||
statusDiv.innerHTML = `
|
||||
<div class="alert alert-warning">
|
||||
<i class="fas fa-exclamation-triangle me-2"></i>
|
||||
<strong>연결 시간 초과</strong><br>
|
||||
SSL 인증서 신뢰 설정을 먼저 완료해주세요.
|
||||
</div>`;
|
||||
}, 5000);
|
||||
|
||||
ws.onopen = function() {
|
||||
clearTimeout(timeout);
|
||||
ws.close();
|
||||
statusDiv.innerHTML = `
|
||||
<div class="alert alert-success">
|
||||
<i class="fas fa-check-circle me-2"></i>
|
||||
<strong>연결 성공!</strong><br>
|
||||
VNC 연결 재시도 버튼을 클릭하세요.
|
||||
</div>`;
|
||||
};
|
||||
|
||||
ws.onerror = function() {
|
||||
clearTimeout(timeout);
|
||||
statusDiv.innerHTML = `
|
||||
<div class="alert alert-danger">
|
||||
<i class="fas fa-times-circle me-2"></i>
|
||||
<strong>연결 실패</strong><br>
|
||||
SSL 인증서 신뢰 설정이 필요합니다.
|
||||
</div>`;
|
||||
};
|
||||
|
||||
ws.onclose = function(event) {
|
||||
if (event.code !== 1000) {
|
||||
clearTimeout(timeout);
|
||||
statusDiv.innerHTML = `
|
||||
<div class="alert alert-warning">
|
||||
<i class="fas fa-exclamation-triangle me-2"></i>
|
||||
<strong>연결 종료</strong><br>
|
||||
SSL 인증서를 신뢰한 후 다시 시도해주세요.
|
||||
</div>`;
|
||||
}
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
statusDiv.innerHTML = `
|
||||
<div class="alert alert-danger">
|
||||
<i class="fas fa-times-circle me-2"></i>
|
||||
<strong>테스트 오류</strong><br>
|
||||
${error.message}
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
103
farmq-admin/test_multiple_proxmox.py
Normal file
103
farmq-admin/test_multiple_proxmox.py
Normal file
@@ -0,0 +1,103 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
다중 Proxmox 서버 접속 테스트
|
||||
"""
|
||||
|
||||
from utils.proxmox_client import ProxmoxClient
|
||||
import json
|
||||
|
||||
# Proxmox 서버 설정
|
||||
PROXMOX_HOSTS = {
|
||||
'pve7.0bin.in': {
|
||||
'host': 'pve7.0bin.in',
|
||||
'username': 'root@pam',
|
||||
'password': 'trajet6640',
|
||||
'port': 443,
|
||||
'name': 'PVE 7.0 (Main)'
|
||||
},
|
||||
'healthport_pve': {
|
||||
'host': '100.64.0.6',
|
||||
'username': 'root@pam',
|
||||
'password': 'healthport',
|
||||
'port': 8006,
|
||||
'name': 'Healthport PVE'
|
||||
}
|
||||
}
|
||||
|
||||
def test_proxmox_connection(host_key, host_config):
|
||||
"""Proxmox 서버 연결 테스트"""
|
||||
print(f"\n{'='*50}")
|
||||
print(f"🔍 Testing connection to: {host_config['name']}")
|
||||
print(f"📡 Host: {host_config['host']}")
|
||||
print(f"👤 Username: {host_config['username']}")
|
||||
print(f"{'='*50}")
|
||||
|
||||
try:
|
||||
# ProxmoxClient 생성 및 로그인 시도
|
||||
client = ProxmoxClient(
|
||||
host_config['host'],
|
||||
host_config['username'],
|
||||
host_config['password'],
|
||||
port=host_config['port']
|
||||
)
|
||||
|
||||
print("🔐 Attempting login...")
|
||||
if client.login():
|
||||
print("✅ Login successful!")
|
||||
|
||||
# VM 목록 가져오기 시도
|
||||
print("📋 Fetching VM list...")
|
||||
vms = client.get_vm_list()
|
||||
|
||||
if vms:
|
||||
print(f"✅ Found {len(vms)} VMs:")
|
||||
for vm in vms[:5]: # 처음 5개만 표시
|
||||
print(f" • VM {vm.get('vmid', 'N/A')}: {vm.get('name', 'Unknown')} ({vm.get('status', 'unknown')})")
|
||||
if len(vms) > 5:
|
||||
print(f" ... and {len(vms) - 5} more VMs")
|
||||
else:
|
||||
print("⚠️ No VMs found or unable to fetch VM list")
|
||||
|
||||
return True
|
||||
|
||||
else:
|
||||
print("❌ Login failed!")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Connection error: {e}")
|
||||
return False
|
||||
|
||||
def main():
|
||||
"""메인 테스트 함수"""
|
||||
print("🚀 Multiple Proxmox Server Connection Test")
|
||||
print("=" * 60)
|
||||
|
||||
results = {}
|
||||
|
||||
for host_key, host_config in PROXMOX_HOSTS.items():
|
||||
success = test_proxmox_connection(host_key, host_config)
|
||||
results[host_key] = {
|
||||
'success': success,
|
||||
'config': host_config
|
||||
}
|
||||
|
||||
# 결과 요약
|
||||
print(f"\n{'='*60}")
|
||||
print("📊 TEST RESULTS SUMMARY")
|
||||
print(f"{'='*60}")
|
||||
|
||||
for host_key, result in results.items():
|
||||
status = "✅ SUCCESS" if result['success'] else "❌ FAILED"
|
||||
print(f"{result['config']['name']}: {status}")
|
||||
|
||||
successful_hosts = [k for k, v in results.items() if v['success']]
|
||||
print(f"\n🎯 {len(successful_hosts)}/{len(PROXMOX_HOSTS)} hosts connected successfully")
|
||||
|
||||
if successful_hosts:
|
||||
print("\n✅ Ready for multi-host implementation!")
|
||||
else:
|
||||
print("\n⚠️ No hosts connected. Check network and credentials.")
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
141
farmq-admin/test_vnc_websocket.py
Normal file
141
farmq-admin/test_vnc_websocket.py
Normal file
@@ -0,0 +1,141 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
VNC WebSocket 연결 테스트 스크립트
|
||||
Claude Code 환경에서 직접 테스트
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import websockets
|
||||
import ssl
|
||||
import json
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Flask app.py와 동일한 경로에서 import
|
||||
sys.path.append('/srv/headscale-setup/farmq-admin')
|
||||
from utils.proxmox_client import ProxmoxClient
|
||||
|
||||
# 설정
|
||||
PROXMOX_HOST = "pve7.0bin.in"
|
||||
PROXMOX_USERNAME = "root@pam"
|
||||
PROXMOX_PASSWORD = "trajet6640"
|
||||
VM_ID = 102
|
||||
NODE_NAME = 'pve7'
|
||||
|
||||
async def test_vnc_websocket():
|
||||
"""VNC WebSocket 연결 테스트"""
|
||||
print("=" * 60)
|
||||
print("🧪 Claude Code에서 VNC WebSocket 연결 테스트")
|
||||
print("=" * 60)
|
||||
|
||||
# 1. Proxmox 클라이언트 생성 및 로그인
|
||||
print("1️⃣ Proxmox 로그인 중...")
|
||||
client = ProxmoxClient(PROXMOX_HOST, PROXMOX_USERNAME, PROXMOX_PASSWORD)
|
||||
|
||||
if not client.login():
|
||||
print("❌ Proxmox 로그인 실패")
|
||||
return
|
||||
print("✅ Proxmox 로그인 성공")
|
||||
|
||||
# 2. VM 상태 확인
|
||||
print("2️⃣ VM 상태 확인 중...")
|
||||
vm_status = client.get_vm_status(NODE_NAME, VM_ID)
|
||||
print(f"🔍 VM {VM_ID} 상태: {vm_status.get('status', 'unknown')}")
|
||||
|
||||
if vm_status.get('status') != 'running':
|
||||
print(f"❌ VM이 실행 중이 아닙니다: {vm_status.get('status')}")
|
||||
return
|
||||
|
||||
# 3. VNC 티켓 생성
|
||||
print("3️⃣ VNC 티켓 생성 중...")
|
||||
vnc_data = client.get_vnc_ticket(NODE_NAME, VM_ID)
|
||||
|
||||
if not vnc_data:
|
||||
print("❌ VNC 티켓 생성 실패")
|
||||
return
|
||||
|
||||
print("✅ VNC 티켓 생성 성공!")
|
||||
print(f" - WebSocket URL: {vnc_data['websocket_url']}")
|
||||
print(f" - VNC 패스워드: {vnc_data.get('password', 'N/A')}")
|
||||
print(f" - 포트: {vnc_data.get('port', 'N/A')}")
|
||||
|
||||
# 4. WebSocket 연결 테스트
|
||||
print("4️⃣ WebSocket 연결 테스트...")
|
||||
websocket_url = vnc_data['websocket_url']
|
||||
|
||||
# SSL 컨텍스트 설정 (자체 서명 인증서 허용)
|
||||
ssl_context = ssl.create_default_context()
|
||||
ssl_context.check_hostname = False
|
||||
ssl_context.verify_mode = ssl.CERT_NONE
|
||||
|
||||
try:
|
||||
# WebSocket 연결 시도
|
||||
print(f"🔌 연결 시도: {websocket_url}")
|
||||
|
||||
# Proxmox 인증 쿠키를 헤더로 추가
|
||||
headers = {
|
||||
'Cookie': f'PVEAuthCookie={client.ticket}'
|
||||
}
|
||||
|
||||
print(f"🔐 인증 쿠키 추가: PVEAuthCookie={client.ticket[:50]}...")
|
||||
|
||||
# WebSocket 연결 시 인증 헤더 추가 (다른 방식)
|
||||
async with websockets.connect(
|
||||
websocket_url,
|
||||
ssl=ssl_context,
|
||||
additional_headers=headers
|
||||
) as websocket:
|
||||
print("✅ WebSocket 연결 성공!")
|
||||
|
||||
# VNC 프로토콜 초기 메시지 받기
|
||||
try:
|
||||
initial_message = await asyncio.wait_for(websocket.recv(), timeout=5.0)
|
||||
print(f"📨 초기 메시지 수신 ({len(initial_message)} bytes)")
|
||||
|
||||
# VNC 프로토콜 버전 확인
|
||||
if isinstance(initial_message, bytes) and initial_message.startswith(b'RFB'):
|
||||
version = initial_message.decode('ascii').strip()
|
||||
print(f"🔗 VNC 프로토콜 버전: {version}")
|
||||
|
||||
# 클라이언트 버전 응답
|
||||
await websocket.send(b"RFB 003.008\n")
|
||||
print("📤 클라이언트 버전 응답 완료")
|
||||
|
||||
print("🎉 VNC WebSocket 연결 및 프로토콜 핸드셰이크 성공!")
|
||||
return True
|
||||
else:
|
||||
print(f"❓ 예상과 다른 초기 메시지: {initial_message[:50]}...")
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
print("⏰ 초기 메시지 수신 타임아웃 - 연결은 성공했지만 VNC 서버 응답 없음")
|
||||
return True # 연결 자체는 성공
|
||||
|
||||
except websockets.exceptions.ConnectionClosed as e:
|
||||
print(f"❌ WebSocket 연결 종료: 코드={e.code}, 이유={e.reason}")
|
||||
return False
|
||||
|
||||
except websockets.exceptions.WebSocketException as e:
|
||||
print(f"❌ WebSocket 예외: {e}")
|
||||
return False
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
print("❌ WebSocket 연결 타임아웃")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 예상치 못한 오류: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return False
|
||||
|
||||
if __name__ == "__main__":
|
||||
result = asyncio.run(test_vnc_websocket())
|
||||
|
||||
print("=" * 60)
|
||||
if result:
|
||||
print("🎉 테스트 성공! WebSocket 연결이 Claude Code 환경에서 작동합니다.")
|
||||
print("💡 브라우저에서 문제가 있다면 브라우저 보안 정책 문제일 가능성이 높습니다.")
|
||||
else:
|
||||
print("❌ 테스트 실패! WebSocket 연결에 문제가 있습니다.")
|
||||
print("🔍 Proxmox 서버나 네트워크 설정을 확인해야 합니다.")
|
||||
print("=" * 60)
|
||||
@@ -28,7 +28,7 @@ def init_databases(headscale_db_uri: str, farmq_db_uri: str = None):
|
||||
|
||||
# FARMQ 전용 데이터베이스 (외래키 제약조건 없음)
|
||||
if farmq_db_uri is None:
|
||||
farmq_db_uri = "sqlite:///farmq-admin/farmq.sqlite"
|
||||
farmq_db_uri = "sqlite:///farmq.db"
|
||||
|
||||
farmq_manager = create_farmq_database_manager(farmq_db_uri)
|
||||
print(f"✅ FARMQ 데이터베이스 초기화: {farmq_db_uri}")
|
||||
@@ -340,7 +340,7 @@ def get_all_machines_with_details() -> List[Dict[str, Any]]:
|
||||
machine_data = {
|
||||
'id': node.id,
|
||||
'hostname': node.hostname,
|
||||
'machine_name': node.hostname, # 표시용 이름
|
||||
'machine_name': node.given_name or node.hostname, # Magic DNS용 이름 (given_name 우선)
|
||||
'tailscale_ip': node.ipv4,
|
||||
'ipv6': node.ipv6,
|
||||
'headscale_user_name': node.user.name if node.user else '미지정',
|
||||
|
||||
@@ -13,12 +13,19 @@ from typing import Dict, List, Optional, Tuple
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
|
||||
class ProxmoxClient:
|
||||
def __init__(self, host: str, username: str = "root@pam", password: str = "", api_token: str = ""):
|
||||
self.host = host
|
||||
def __init__(self, host: str, username: str = "root@pam", password: str = "", api_token: str = "", port: int = 8006):
|
||||
# 호스트에서 포트가 포함된 경우 분리
|
||||
if ':' in host:
|
||||
self.host, port_str = host.split(':')
|
||||
self.port = int(port_str)
|
||||
else:
|
||||
self.host = host
|
||||
self.port = port
|
||||
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.api_token = api_token
|
||||
self.base_url = f"https://{host}:443/api2/json"
|
||||
self.base_url = f"https://{self.host}:{self.port}/api2/json"
|
||||
self.session = requests.Session()
|
||||
self.session.verify = False
|
||||
self.ticket = None
|
||||
@@ -106,26 +113,46 @@ class ProxmoxClient:
|
||||
def get_vnc_ticket(self, node: str, vmid: int) -> Optional[Dict]:
|
||||
"""VNC 접속 티켓 생성"""
|
||||
try:
|
||||
# 먼저 VM 상태 확인
|
||||
vm_status = self.get_vm_status(node, vmid)
|
||||
print(f"🔍 VM {vmid} 상태: {vm_status}")
|
||||
|
||||
if vm_status.get('status') != 'running':
|
||||
print(f"❌ VM {vmid}이 실행중이 아닙니다. 현재 상태: {vm_status.get('status', 'unknown')}")
|
||||
return None
|
||||
|
||||
data = {
|
||||
'websocket': '1',
|
||||
'generate-password': '1' # 패스워드 생성 활성화
|
||||
}
|
||||
|
||||
print(f"🔄 VNC 티켓 요청: {self.base_url}/nodes/{node}/qemu/{vmid}/vncproxy")
|
||||
|
||||
response = self.session.post(
|
||||
f"{self.base_url}/nodes/{node}/qemu/{vmid}/vncproxy",
|
||||
data=data,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
print(f"📡 VNC 티켓 응답 상태: {response.status_code}")
|
||||
print(f"📄 VNC 티켓 응답 내용: {response.text}")
|
||||
|
||||
if response.status_code == 200:
|
||||
vnc_data = response.json()['data']
|
||||
print(f"✅ VNC 티켓 생성 성공: {vnc_data}")
|
||||
|
||||
# WebSocket URL 생성
|
||||
# WebSocket URL 생성 (동적 포트 및 CSRF 토큰 포함)
|
||||
encoded_ticket = quote_plus(vnc_data['ticket'])
|
||||
vnc_data['websocket_url'] = f"wss://{self.host}:443/api2/json/nodes/{node}/qemu/{vmid}/vncwebsocket?port={vnc_data['port']}&vncticket={encoded_ticket}"
|
||||
# Proxmox 세션 쿠키도 함께 포함 (CSRFPreventionToken도 필요할 수 있음)
|
||||
csrf_token = getattr(self, 'csrf_token', None)
|
||||
if csrf_token:
|
||||
vnc_data['websocket_url'] = f"wss://{self.host}:{self.port}/api2/json/nodes/{node}/qemu/{vmid}/vncwebsocket?port={vnc_data['port']}&vncticket={encoded_ticket}&CSRFPreventionToken={csrf_token}"
|
||||
else:
|
||||
vnc_data['websocket_url'] = f"wss://{self.host}:{self.port}/api2/json/nodes/{node}/qemu/{vmid}/vncwebsocket?port={vnc_data['port']}&vncticket={encoded_ticket}"
|
||||
|
||||
# 디버깅 정보 추가
|
||||
print(f"🔗 WebSocket URL: {vnc_data['websocket_url']}")
|
||||
print(f"🔑 VNC Password: {vnc_data.get('password', 'N/A')}")
|
||||
return vnc_data
|
||||
else:
|
||||
print(f"❌ VNC 티켓 생성 HTTP 오류: {response.status_code}")
|
||||
|
||||
168
farmq-admin/utils/vnc_proxy.py
Normal file
168
farmq-admin/utils/vnc_proxy.py
Normal file
@@ -0,0 +1,168 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
VNC WebSocket 프록시
|
||||
브라우저와 Proxmox VNC 서버 간 WebSocket 연결을 중계하며
|
||||
PVE 인증을 자동으로 처리합니다.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import websockets
|
||||
import ssl
|
||||
import logging
|
||||
from utils.proxmox_client import ProxmoxClient
|
||||
|
||||
# 로깅 설정
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class VNCWebSocketProxy:
|
||||
def __init__(self, proxmox_host, proxmox_username, proxmox_password):
|
||||
self.proxmox_host = proxmox_host
|
||||
self.proxmox_username = proxmox_username
|
||||
self.proxmox_password = proxmox_password
|
||||
self.proxmox_client = None
|
||||
|
||||
async def create_proxmox_client(self):
|
||||
"""Proxmox 클라이언트 생성 및 로그인"""
|
||||
if not self.proxmox_client:
|
||||
self.proxmox_client = ProxmoxClient(
|
||||
self.proxmox_host,
|
||||
self.proxmox_username,
|
||||
self.proxmox_password
|
||||
)
|
||||
|
||||
if not self.proxmox_client.login():
|
||||
logger.error("Proxmox 로그인 실패")
|
||||
return False
|
||||
|
||||
logger.info("Proxmox 로그인 성공")
|
||||
return True
|
||||
|
||||
async def get_vnc_connection_info(self, node, vm_id):
|
||||
"""VNC 연결 정보 생성"""
|
||||
if not await self.create_proxmox_client():
|
||||
return None
|
||||
|
||||
# VM 상태 확인
|
||||
vm_status = self.proxmox_client.get_vm_status(node, vm_id)
|
||||
if vm_status.get('status') != 'running':
|
||||
logger.error(f"VM {vm_id}가 실행 중이 아님: {vm_status.get('status')}")
|
||||
return None
|
||||
|
||||
# VNC 티켓 생성
|
||||
vnc_data = self.proxmox_client.get_vnc_ticket(node, vm_id)
|
||||
if not vnc_data:
|
||||
logger.error(f"VM {vm_id} VNC 티켓 생성 실패")
|
||||
return None
|
||||
|
||||
# WebSocket 연결 정보 준비
|
||||
connection_info = {
|
||||
'websocket_url': vnc_data['websocket_url'],
|
||||
'password': vnc_data['password'],
|
||||
'auth_headers': {
|
||||
'Cookie': f'PVEAuthCookie={self.proxmox_client.ticket}'
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(f"VM {vm_id} VNC 연결 정보 생성 완료")
|
||||
return connection_info
|
||||
|
||||
async def create_proxmox_websocket(self, connection_info):
|
||||
"""Proxmox VNC WebSocket 연결 생성"""
|
||||
ssl_context = ssl.create_default_context()
|
||||
ssl_context.check_hostname = False
|
||||
ssl_context.verify_mode = ssl.CERT_NONE
|
||||
|
||||
try:
|
||||
websocket = await websockets.connect(
|
||||
connection_info['websocket_url'],
|
||||
ssl=ssl_context,
|
||||
additional_headers=connection_info['auth_headers']
|
||||
)
|
||||
logger.info("Proxmox VNC WebSocket 연결 성공")
|
||||
return websocket
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Proxmox VNC WebSocket 연결 실패: {e}")
|
||||
return None
|
||||
|
||||
async def proxy_data(self, browser_ws, proxmox_ws):
|
||||
"""브라우저와 Proxmox 간 WebSocket 데이터 양방향 중계"""
|
||||
async def forward_browser_to_proxmox():
|
||||
"""브라우저 → Proxmox 데이터 전달"""
|
||||
try:
|
||||
async for message in browser_ws:
|
||||
await proxmox_ws.send(message)
|
||||
logger.debug(f"브라우저 → Proxmox: {len(message)} bytes")
|
||||
except websockets.exceptions.ConnectionClosed:
|
||||
logger.info("브라우저 연결 종료")
|
||||
except Exception as e:
|
||||
logger.error(f"브라우저 → Proxmox 전달 오류: {e}")
|
||||
|
||||
async def forward_proxmox_to_browser():
|
||||
"""Proxmox → 브라우저 데이터 전달"""
|
||||
try:
|
||||
async for message in proxmox_ws:
|
||||
await browser_ws.send(message)
|
||||
logger.debug(f"Proxmox → 브라우저: {len(message)} bytes")
|
||||
except websockets.exceptions.ConnectionClosed:
|
||||
logger.info("Proxmox 연결 종료")
|
||||
except Exception as e:
|
||||
logger.error(f"Proxmox → 브라우저 전달 오류: {e}")
|
||||
|
||||
# 양방향 데이터 전달을 병렬로 실행
|
||||
await asyncio.gather(
|
||||
forward_browser_to_proxmox(),
|
||||
forward_proxmox_to_browser(),
|
||||
return_exceptions=True
|
||||
)
|
||||
|
||||
async def handle_vnc_proxy(self, browser_websocket, node, vm_id):
|
||||
"""VNC 프록시 메인 핸들러"""
|
||||
logger.info(f"VNC 프록시 시작: VM {vm_id}")
|
||||
|
||||
try:
|
||||
# 1. Proxmox VNC 연결 정보 생성
|
||||
connection_info = await self.get_vnc_connection_info(node, vm_id)
|
||||
if not connection_info:
|
||||
await browser_websocket.send("ERROR: VNC 연결 정보 생성 실패")
|
||||
return False
|
||||
|
||||
# 2. Proxmox VNC WebSocket 연결
|
||||
proxmox_websocket = await self.create_proxmox_websocket(connection_info)
|
||||
if not proxmox_websocket:
|
||||
await browser_websocket.send("ERROR: Proxmox VNC 연결 실패")
|
||||
return False
|
||||
|
||||
# 3. 연결 성공 알림
|
||||
logger.info(f"VM {vm_id} VNC 프록시 연결 완료")
|
||||
|
||||
# 4. 데이터 중계 시작
|
||||
await self.proxy_data(browser_websocket, proxmox_websocket)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"VNC 프록시 처리 오류: {e}")
|
||||
try:
|
||||
await browser_websocket.send(f"ERROR: {str(e)}")
|
||||
except:
|
||||
pass
|
||||
return False
|
||||
|
||||
finally:
|
||||
logger.info(f"VNC 프록시 종료: VM {vm_id}")
|
||||
|
||||
|
||||
# 전역 VNC 프록시 인스턴스 (설정값은 app.py에서 주입)
|
||||
vnc_proxy_instance = None
|
||||
|
||||
def init_vnc_proxy(proxmox_host, proxmox_username, proxmox_password):
|
||||
"""VNC 프록시 인스턴스 초기화"""
|
||||
global vnc_proxy_instance
|
||||
vnc_proxy_instance = VNCWebSocketProxy(proxmox_host, proxmox_username, proxmox_password)
|
||||
logger.info("VNC WebSocket 프록시 초기화 완료")
|
||||
|
||||
def get_vnc_proxy():
|
||||
"""VNC 프록시 인스턴스 반환"""
|
||||
global vnc_proxy_instance
|
||||
return vnc_proxy_instance
|
||||
@@ -4,8 +4,8 @@
|
||||
|
||||
param(
|
||||
[switch]$Force,
|
||||
[string]$HeadscaleServer = "https://head.0bin.in",
|
||||
[string]$PreAuthKey = "8b3df41d37cb158ea39f41fc32c9af46e761de817ad06038",
|
||||
[string]$HeadscaleServer = "http://head.pharmq.kr",
|
||||
[string]$PreAuthKey = "b46923995afeaec90e588168f2e1bf99801775e8657ce003",
|
||||
[string]$FarmqNetwork = "100.64.0.0/10"
|
||||
)
|
||||
|
||||
@@ -100,62 +100,88 @@ function Test-Requirements {
|
||||
function Install-Tailscale {
|
||||
Write-Status "Checking Tailscale installation..."
|
||||
|
||||
# Check existing installation
|
||||
# Check existing installation (PATH or the default install directory).
|
||||
# IMPORTANT: a prior run may have installed Tailscale via the .exe (NSIS) even
|
||||
# though it wasn't on PATH. Re-installing over it with the MSI fails with
|
||||
# error 1603. So if the binary already exists, skip installation entirely and
|
||||
# just make sure it's on PATH for this session.
|
||||
$tailscaleExe = "C:\Program Files\Tailscale\tailscale.exe"
|
||||
$tailscalePath = Get-Command "tailscale" -ErrorAction SilentlyContinue
|
||||
if ($tailscalePath) {
|
||||
$version = & tailscale version 2>$null | Select-Object -First 1
|
||||
Write-Info "Tailscale is already installed."
|
||||
Write-Info "Current version: $version"
|
||||
if ($tailscalePath -or (Test-Path $tailscaleExe)) {
|
||||
Write-Info "Tailscale is already installed. Skipping installation."
|
||||
if (-not $tailscalePath -and ($env:Path -notlike "*Tailscale*")) {
|
||||
$env:Path = "$env:Path;C:\Program Files\Tailscale"
|
||||
}
|
||||
$version = & $tailscaleExe version 2>$null | Select-Object -First 1
|
||||
if ($version) { Write-Info "Current version: $version" }
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
Write-Info "Installing Tailscale for Windows..."
|
||||
|
||||
# Get latest Tailscale version
|
||||
try {
|
||||
Write-Status "Getting latest Tailscale version..."
|
||||
$latestRelease = Invoke-RestMethod -Uri "https://api.github.com/repos/tailscale/tailscale/releases/latest" -UseBasicParsing
|
||||
$version = $latestRelease.tag_name.TrimStart('v')
|
||||
Write-Info "Latest version: $version"
|
||||
}
|
||||
catch {
|
||||
Write-Warning "Failed to get latest version, using fallback"
|
||||
$version = "1.86.2"
|
||||
}
|
||||
|
||||
# Temporary download path
|
||||
$tempPath = "$env:TEMP\tailscale-setup-$version.exe"
|
||||
$downloadUrl = "https://pkgs.tailscale.com/stable/tailscale-setup-$version.exe"
|
||||
|
||||
|
||||
# Use the official MSI from the 'latest' stable channel and install it with
|
||||
# msiexec /quiet. The previous approach (tailscale-setup-latest.exe /S) is the
|
||||
# NSIS GUI installer; its silent switch does NOT reliably register the
|
||||
# 'Tailscale' service or drop tailscale.exe before the script continues, which
|
||||
# caused "service not found" / "executable not found" right after install.
|
||||
# The MSI installs the service synchronously and is the supported unattended path.
|
||||
# NOTE: GitHub's "latest release" tag and pkgs.tailscale.com/stable can be out
|
||||
# of sync, so we use the version-less 'latest' alias which always exists.
|
||||
$downloadUrl = "https://pkgs.tailscale.com/stable/tailscale-setup-latest-amd64.msi"
|
||||
$tempPath = "$env:TEMP\tailscale-setup-latest.msi"
|
||||
$logPath = "$env:TEMP\tailscale-install.log"
|
||||
|
||||
try {
|
||||
Write-Status "Downloading Tailscale from: $downloadUrl"
|
||||
Invoke-WebRequest -Uri $downloadUrl -OutFile $tempPath -UseBasicParsing
|
||||
|
||||
|
||||
Write-Status "Installing Tailscale... (please wait)"
|
||||
Start-Process -FilePath $tempPath -ArgumentList "/S" -Wait
|
||||
|
||||
# Refresh PATH environment variable
|
||||
$env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User")
|
||||
|
||||
# Verify installation
|
||||
Start-Sleep -Seconds 3
|
||||
$tailscaleInstalled = Get-Command "tailscale" -ErrorAction SilentlyContinue
|
||||
if (-not $tailscaleInstalled) {
|
||||
# Try direct path
|
||||
$tailscaleExe = "C:\Program Files\Tailscale\tailscale.exe"
|
||||
$proc = Start-Process -FilePath "msiexec.exe" `
|
||||
-ArgumentList "/i", "`"$tempPath`"", "/quiet", "/norestart", "/l*v", "`"$logPath`"" `
|
||||
-Wait -PassThru
|
||||
# 0 = success, 3010 = success but reboot required
|
||||
if ($proc.ExitCode -ne 0 -and $proc.ExitCode -ne 3010) {
|
||||
# 1603 etc. often means a conflicting/partial install already exists.
|
||||
# If the binary is nonetheless present, treat it as installed and move on;
|
||||
# otherwise surface the failure with the log path.
|
||||
if (Test-Path $tailscaleExe) {
|
||||
# Add Tailscale to PATH
|
||||
$currentPath = [Environment]::GetEnvironmentVariable("Path", "Machine")
|
||||
if ($currentPath -notlike "*Tailscale*") {
|
||||
[Environment]::SetEnvironmentVariable("Path", "$currentPath;C:\Program Files\Tailscale", "Machine")
|
||||
$env:Path = "$env:Path;C:\Program Files\Tailscale"
|
||||
}
|
||||
Write-Warning "msiexec exit code $($proc.ExitCode), but Tailscale is already present. Continuing."
|
||||
} else {
|
||||
throw "msiexec returned exit code $($proc.ExitCode). See log: $logPath"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# Refresh PATH environment variable
|
||||
$env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User")
|
||||
|
||||
# Make sure the install dir is on PATH for this session
|
||||
if (Test-Path $tailscaleExe) {
|
||||
$currentPath = [Environment]::GetEnvironmentVariable("Path", "Machine")
|
||||
if ($currentPath -notlike "*Tailscale*") {
|
||||
[Environment]::SetEnvironmentVariable("Path", "$currentPath;C:\Program Files\Tailscale", "Machine")
|
||||
}
|
||||
if ($env:Path -notlike "*Tailscale*") {
|
||||
$env:Path = "$env:Path;C:\Program Files\Tailscale"
|
||||
}
|
||||
}
|
||||
|
||||
# Wait for tailscale.exe to actually appear (install can lag a few seconds)
|
||||
Write-Status "Waiting for Tailscale to be ready..."
|
||||
$ready = $false
|
||||
for ($i = 0; $i -lt 15; $i++) {
|
||||
if ((Get-Command "tailscale" -ErrorAction SilentlyContinue) -or (Test-Path $tailscaleExe)) {
|
||||
$ready = $true
|
||||
break
|
||||
}
|
||||
Start-Sleep -Seconds 2
|
||||
}
|
||||
if (-not $ready) {
|
||||
throw "Tailscale executable did not appear after installation. See log: $logPath"
|
||||
}
|
||||
|
||||
Remove-Item $tempPath -Force -ErrorAction SilentlyContinue
|
||||
Write-Success "Tailscale installation completed"
|
||||
|
||||
|
||||
}
|
||||
catch {
|
||||
Write-Error "Tailscale installation failed: $($_.Exception.Message)"
|
||||
@@ -170,8 +196,15 @@ function Start-TailscaleService {
|
||||
Write-Status "Starting Tailscale service..."
|
||||
|
||||
try {
|
||||
# Start Tailscale service
|
||||
$service = Get-Service -Name "Tailscale" -ErrorAction SilentlyContinue
|
||||
# The MSI registers a service named "Tailscale"; it may take a few seconds
|
||||
# to appear, so poll for it before giving up.
|
||||
$service = $null
|
||||
for ($i = 0; $i -lt 15; $i++) {
|
||||
$service = Get-Service -Name "Tailscale" -ErrorAction SilentlyContinue
|
||||
if ($service) { break }
|
||||
Start-Sleep -Seconds 2
|
||||
}
|
||||
|
||||
if ($service) {
|
||||
if ($service.Status -ne "Running") {
|
||||
Start-Service -Name "Tailscale"
|
||||
@@ -179,7 +212,19 @@ function Start-TailscaleService {
|
||||
}
|
||||
Write-Success "Tailscale service is running."
|
||||
} else {
|
||||
# Fall back to launching tailscaled directly if the service is missing
|
||||
Write-Warning "Tailscale service not found. Attempting manual start..."
|
||||
$tailscaled = "C:\Program Files\Tailscale\tailscaled.exe"
|
||||
if (Test-Path $tailscaled) {
|
||||
Start-Process -FilePath $tailscaled -ArgumentList "install" -Wait -ErrorAction SilentlyContinue
|
||||
Start-Sleep -Seconds 3
|
||||
$service = Get-Service -Name "Tailscale" -ErrorAction SilentlyContinue
|
||||
if ($service -and $service.Status -ne "Running") {
|
||||
Start-Service -Name "Tailscale" -ErrorAction SilentlyContinue
|
||||
Start-Sleep -Seconds 3
|
||||
}
|
||||
if ($service) { Write-Success "Tailscale service is running." }
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
@@ -476,7 +521,7 @@ if ($args -contains "--help" -or $args -contains "-h") {
|
||||
Write-Host ""
|
||||
Write-Host "Options:"
|
||||
Write-Host " -Force Force disconnect existing connection and re-register"
|
||||
Write-Host " -HeadscaleServer Server address (default: https://head.0bin.in)"
|
||||
Write-Host " -HeadscaleServer Server address (default: http://head.pharmq.kr)"
|
||||
Write-Host ""
|
||||
Write-Host "Examples:"
|
||||
Write-Host " # Force re-registration"
|
||||
|
||||
@@ -8,8 +8,8 @@ $OutputEncoding = [Console]::OutputEncoding = [Text.UTF8Encoding]::UTF8
|
||||
|
||||
param(
|
||||
[switch]$Force,
|
||||
[string]$HeadscaleServer = "https://head.0bin.in",
|
||||
[string]$PreAuthKey = "8b3df41d37cb158ea39f41fc32c9af46e761de817ad06038",
|
||||
[string]$HeadscaleServer = "http://head.pharmq.kr",
|
||||
[string]$PreAuthKey = "b46923995afeaec90e588168f2e1bf99801775e8657ce003",
|
||||
[string]$FarmqNetwork = "100.64.0.0/10"
|
||||
)
|
||||
|
||||
@@ -106,62 +106,86 @@ function Test-Requirements {
|
||||
function Install-Tailscale {
|
||||
Write-Status "Tailscale 클라이언트 확인 중..."
|
||||
|
||||
# 기존 설치 확인
|
||||
# 기존 설치 확인 (PATH 또는 기본 설치 경로).
|
||||
# 중요: 이전 실행에서 .exe(NSIS)로 이미 설치됐는데 PATH엔 안 잡혀 있을 수
|
||||
# 있다. 그 위에 MSI로 덮어쓰면 오류 1603으로 실패한다. 그래서 바이너리가
|
||||
# 이미 있으면 설치를 통째로 건너뛰고 이번 세션 PATH에만 추가한다.
|
||||
$tailscaleExe = "C:\Program Files\Tailscale\tailscale.exe"
|
||||
$tailscalePath = Get-Command "tailscale" -ErrorAction SilentlyContinue
|
||||
if ($tailscalePath) {
|
||||
$version = & tailscale version 2>$null | Select-Object -First 1
|
||||
Write-Info "Tailscale이 이미 설치되어 있습니다."
|
||||
Write-Info "현재 버전: $version"
|
||||
if ($tailscalePath -or (Test-Path $tailscaleExe)) {
|
||||
Write-Info "Tailscale이 이미 설치되어 있습니다. 설치를 건너뜁니다."
|
||||
if (-not $tailscalePath -and ($env:Path -notlike "*Tailscale*")) {
|
||||
$env:Path = "$env:Path;C:\Program Files\Tailscale"
|
||||
}
|
||||
$version = & $tailscaleExe version 2>$null | Select-Object -First 1
|
||||
if ($version) { Write-Info "현재 버전: $version" }
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
Write-Info "Windows용 Tailscale 설치 중..."
|
||||
|
||||
# 최신 Tailscale 버전 확인
|
||||
try {
|
||||
Write-Status "최신 Tailscale 버전 확인 중..."
|
||||
$latestRelease = Invoke-RestMethod -Uri "https://api.github.com/repos/tailscale/tailscale/releases/latest" -UseBasicParsing
|
||||
$version = $latestRelease.tag_name.TrimStart('v')
|
||||
Write-Info "최신 버전: $version"
|
||||
}
|
||||
catch {
|
||||
Write-Warning "최신 버전 확인 실패, 기본 버전 사용"
|
||||
$version = "1.86.2"
|
||||
}
|
||||
|
||||
# 임시 다운로드 경로
|
||||
$tempPath = "$env:TEMP\tailscale-setup-$version.exe"
|
||||
$downloadUrl = "https://pkgs.tailscale.com/stable/tailscale-setup-$version.exe"
|
||||
|
||||
|
||||
# 공식 stable 채널의 'latest' MSI를 msiexec /quiet 로 설치한다.
|
||||
# 이전 방식(tailscale-setup-latest.exe /S)은 NSIS GUI 인스톨러로, 사일런트
|
||||
# 스위치가 'Tailscale' 서비스나 tailscale.exe 를 스크립트 진행 전에 확실히
|
||||
# 설치하지 못해 설치 직후 "service not found" / "executable not found" 오류가
|
||||
# 발생했다. MSI는 서비스를 동기적으로 설치하는 공식 무인 설치 경로다.
|
||||
# 주의: GitHub "최신 릴리스" 태그와 stable 채널 버전이 어긋날 수 있으므로
|
||||
# 버전 없는 'latest' 별칭을 사용한다(항상 존재함).
|
||||
$downloadUrl = "https://pkgs.tailscale.com/stable/tailscale-setup-latest-amd64.msi"
|
||||
$tempPath = "$env:TEMP\tailscale-setup-latest.msi"
|
||||
$logPath = "$env:TEMP\tailscale-install.log"
|
||||
|
||||
try {
|
||||
Write-Status "Tailscale 다운로드 중: $downloadUrl"
|
||||
Invoke-WebRequest -Uri $downloadUrl -OutFile $tempPath -UseBasicParsing
|
||||
|
||||
|
||||
Write-Status "Tailscale 설치 중... (잠시 기다려주세요)"
|
||||
Start-Process -FilePath $tempPath -ArgumentList "/S" -Wait
|
||||
|
||||
# PATH 환경변수 새로고침
|
||||
$env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User")
|
||||
|
||||
# 설치 확인
|
||||
Start-Sleep -Seconds 3
|
||||
$tailscaleInstalled = Get-Command "tailscale" -ErrorAction SilentlyContinue
|
||||
if (-not $tailscaleInstalled) {
|
||||
# 직접 경로 시도
|
||||
$tailscaleExe = "C:\Program Files\Tailscale\tailscale.exe"
|
||||
$proc = Start-Process -FilePath "msiexec.exe" `
|
||||
-ArgumentList "/i", "`"$tempPath`"", "/quiet", "/norestart", "/l*v", "`"$logPath`"" `
|
||||
-Wait -PassThru
|
||||
# 0 = 성공, 3010 = 성공(재부팅 필요)
|
||||
if ($proc.ExitCode -ne 0 -and $proc.ExitCode -ne 3010) {
|
||||
# 1603 등은 충돌하는/불완전한 기존 설치가 있을 때 자주 난다.
|
||||
# 그래도 바이너리가 있으면 설치된 것으로 보고 계속 진행하고,
|
||||
# 없으면 로그 경로와 함께 실패를 알린다.
|
||||
if (Test-Path $tailscaleExe) {
|
||||
# PATH에 Tailscale 경로 추가
|
||||
$currentPath = [Environment]::GetEnvironmentVariable("Path", "Machine")
|
||||
if ($currentPath -notlike "*Tailscale*") {
|
||||
[Environment]::SetEnvironmentVariable("Path", "$currentPath;C:\Program Files\Tailscale", "Machine")
|
||||
$env:Path = "$env:Path;C:\Program Files\Tailscale"
|
||||
}
|
||||
Write-Warning "msiexec 종료 코드 $($proc.ExitCode) 이지만 Tailscale이 이미 존재합니다. 계속 진행합니다."
|
||||
} else {
|
||||
throw "msiexec 종료 코드 $($proc.ExitCode). 로그: $logPath"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# PATH 환경변수 새로고침
|
||||
$env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User")
|
||||
|
||||
# 이번 세션 PATH에 설치 경로 보장
|
||||
if (Test-Path $tailscaleExe) {
|
||||
$currentPath = [Environment]::GetEnvironmentVariable("Path", "Machine")
|
||||
if ($currentPath -notlike "*Tailscale*") {
|
||||
[Environment]::SetEnvironmentVariable("Path", "$currentPath;C:\Program Files\Tailscale", "Machine")
|
||||
}
|
||||
if ($env:Path -notlike "*Tailscale*") {
|
||||
$env:Path = "$env:Path;C:\Program Files\Tailscale"
|
||||
}
|
||||
}
|
||||
|
||||
# tailscale.exe 가 실제로 나타날 때까지 대기 (설치가 몇 초 지연될 수 있음)
|
||||
Write-Status "Tailscale 준비 대기 중..."
|
||||
$ready = $false
|
||||
for ($i = 0; $i -lt 15; $i++) {
|
||||
if ((Get-Command "tailscale" -ErrorAction SilentlyContinue) -or (Test-Path $tailscaleExe)) {
|
||||
$ready = $true
|
||||
break
|
||||
}
|
||||
Start-Sleep -Seconds 2
|
||||
}
|
||||
if (-not $ready) {
|
||||
throw "설치 후 Tailscale 실행 파일을 찾을 수 없습니다. 로그: $logPath"
|
||||
}
|
||||
|
||||
Remove-Item $tempPath -Force -ErrorAction SilentlyContinue
|
||||
Write-Success "Tailscale 설치 완료"
|
||||
|
||||
|
||||
}
|
||||
catch {
|
||||
Write-Error "Tailscale 설치 실패: $($_.Exception.Message)"
|
||||
@@ -176,8 +200,14 @@ function Start-TailscaleService {
|
||||
Write-Status "Tailscale 서비스 시작 중..."
|
||||
|
||||
try {
|
||||
# Tailscale 서비스 시작
|
||||
$service = Get-Service -Name "Tailscale" -ErrorAction SilentlyContinue
|
||||
# MSI가 "Tailscale" 서비스를 등록한다. 등록까지 몇 초 걸릴 수 있어 폴링한다.
|
||||
$service = $null
|
||||
for ($i = 0; $i -lt 15; $i++) {
|
||||
$service = Get-Service -Name "Tailscale" -ErrorAction SilentlyContinue
|
||||
if ($service) { break }
|
||||
Start-Sleep -Seconds 2
|
||||
}
|
||||
|
||||
if ($service) {
|
||||
if ($service.Status -ne "Running") {
|
||||
Start-Service -Name "Tailscale"
|
||||
@@ -185,7 +215,19 @@ function Start-TailscaleService {
|
||||
}
|
||||
Write-Success "Tailscale 서비스가 실행 중입니다."
|
||||
} else {
|
||||
# 서비스가 없으면 tailscaled 로 직접 서비스 설치 시도
|
||||
Write-Warning "Tailscale 서비스를 찾을 수 없습니다. 수동 시작을 시도합니다."
|
||||
$tailscaled = "C:\Program Files\Tailscale\tailscaled.exe"
|
||||
if (Test-Path $tailscaled) {
|
||||
Start-Process -FilePath $tailscaled -ArgumentList "install" -Wait -ErrorAction SilentlyContinue
|
||||
Start-Sleep -Seconds 3
|
||||
$service = Get-Service -Name "Tailscale" -ErrorAction SilentlyContinue
|
||||
if ($service -and $service.Status -ne "Running") {
|
||||
Start-Service -Name "Tailscale" -ErrorAction SilentlyContinue
|
||||
Start-Sleep -Seconds 3
|
||||
}
|
||||
if ($service) { Write-Success "Tailscale 서비스가 실행 중입니다." }
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
@@ -482,7 +524,7 @@ if ($args -contains "--help" -or $args -contains "-h") {
|
||||
Write-Host ""
|
||||
Write-Host "옵션:"
|
||||
Write-Host " -Force 기존 연결을 강제로 해제하고 재등록"
|
||||
Write-Host " -HeadscaleServer 서버 주소 (기본값: https://head.0bin.in)"
|
||||
Write-Host " -HeadscaleServer 서버 주소 (기본값: http://head.pharmq.kr)"
|
||||
Write-Host ""
|
||||
Write-Host "예시:"
|
||||
Write-Host " # 강제 재등록"
|
||||
|
||||
309
giteamd.md
Normal file
309
giteamd.md
Normal file
@@ -0,0 +1,309 @@
|
||||
# Gitea 리포지토리 생성 및 푸시 가이드
|
||||
|
||||
## 🏠 서버 정보
|
||||
|
||||
- **Gitea 서버**: `git.0bin.in`
|
||||
- **사용자명**: `thug0bin`
|
||||
- **이메일**: `thug0bin@gmail.com`
|
||||
- **액세스 토큰**: `d83f70b219c6028199a498fb94009f4c1debc9a9`
|
||||
|
||||
## 🚀 새 리포지토리 생성 및 푸시 과정
|
||||
|
||||
### 1. 로컬 Git 리포지토리 초기화
|
||||
|
||||
```bash
|
||||
# 프로젝트 디렉토리로 이동
|
||||
cd /path/to/your/project
|
||||
|
||||
# Git 초기화
|
||||
git init
|
||||
|
||||
# .gitignore 파일 생성 (필요시)
|
||||
cat > .gitignore << 'EOF'
|
||||
# Dependencies
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
build/
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
env/
|
||||
venv/
|
||||
|
||||
# Database
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
EOF
|
||||
```
|
||||
|
||||
### 2. Git 사용자 설정 확인
|
||||
|
||||
```bash
|
||||
# Git 사용자 정보 확인
|
||||
git config --list | grep -E "user"
|
||||
|
||||
# 설정되지 않은 경우 설정
|
||||
git config --global user.name "시골약사"
|
||||
git config --global user.email "thug0bin@gmail.com"
|
||||
```
|
||||
|
||||
### 3. 첫 번째 커밋
|
||||
|
||||
```bash
|
||||
# 모든 파일 스테이징
|
||||
git add .
|
||||
|
||||
# 첫 커밋 (상세한 커밋 메시지 예시)
|
||||
git commit -m "$(cat <<'EOF'
|
||||
Initial commit: [프로젝트명]
|
||||
|
||||
✨ [주요 기능 설명]
|
||||
- 기능 1
|
||||
- 기능 2
|
||||
- 기능 3
|
||||
|
||||
🛠️ 기술 스택:
|
||||
- 사용된 기술들 나열
|
||||
|
||||
🔧 주요 구성:
|
||||
- 프로젝트 구조 설명
|
||||
|
||||
🤖 Generated with [Claude Code](https://claude.ai/code)
|
||||
|
||||
Co-Authored-By: Claude <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
### 4. 원격 리포지토리 연결 및 푸시
|
||||
|
||||
```bash
|
||||
# 원격 리포지토리 추가 (리포지토리명을 실제 이름으로 변경)
|
||||
git remote add origin https://thug0bin:d83f70b219c6028199a498fb94009f4c1debc9a9@git.0bin.in/thug0bin/[REPOSITORY_NAME].git
|
||||
|
||||
# 브랜치를 main으로 변경
|
||||
git branch -M main
|
||||
|
||||
# 원격 리포지토리로 푸시
|
||||
git push -u origin main
|
||||
```
|
||||
|
||||
## 📝 리포지토리명 네이밍 규칙
|
||||
|
||||
### 권장 네이밍 패턴:
|
||||
- **프론트엔드 프로젝트**: `project-name-frontend`
|
||||
- **백엔드 프로젝트**: `project-name-backend`
|
||||
- **풀스택 프로젝트**: `project-name-fullstack`
|
||||
- **도구/유틸리티**: `tool-name-utils`
|
||||
- **문서/가이드**: `project-name-docs`
|
||||
|
||||
### 예시:
|
||||
- `figma-admin-dashboard` ✅
|
||||
- `anipharm-api-server` ✅
|
||||
- `inventory-management-system` ✅
|
||||
- `member-portal-frontend` ✅
|
||||
|
||||
## 🔄 기존 리포지토리에 추가 커밋
|
||||
|
||||
```bash
|
||||
# 변경사항 확인
|
||||
git status
|
||||
|
||||
# 변경된 파일 스테이징
|
||||
git add .
|
||||
|
||||
# 또는 특정 파일만 스테이징
|
||||
git add path/to/specific/file
|
||||
|
||||
# 커밋
|
||||
git commit -m "커밋 메시지"
|
||||
|
||||
# 푸시
|
||||
git push origin main
|
||||
```
|
||||
|
||||
## 🌿 브랜치 작업
|
||||
|
||||
```bash
|
||||
# 새 브랜치 생성 및 전환
|
||||
git checkout -b feature/new-feature
|
||||
|
||||
# 브랜치에서 작업 후 커밋
|
||||
git add .
|
||||
git commit -m "Feature: 새로운 기능 추가"
|
||||
|
||||
# 브랜치 푸시
|
||||
git push -u origin feature/new-feature
|
||||
|
||||
# main 브랜치로 돌아가기
|
||||
git checkout main
|
||||
|
||||
# 브랜치 병합 (필요시)
|
||||
git merge feature/new-feature
|
||||
```
|
||||
|
||||
## 🛠️ 자주 사용하는 Git 명령어
|
||||
|
||||
```bash
|
||||
# 현재 상태 확인
|
||||
git status
|
||||
|
||||
# 변경 내역 확인
|
||||
git diff
|
||||
|
||||
# 커밋 히스토리 확인
|
||||
git log --oneline
|
||||
|
||||
# 원격 리포지토리 정보 확인
|
||||
git remote -v
|
||||
|
||||
# 특정 포트 프로세스 종료 (개발 서버 관련)
|
||||
lsof -ti:PORT_NUMBER | xargs -r kill -9
|
||||
```
|
||||
|
||||
## 🔧 포트 관리 스크립트
|
||||
|
||||
```bash
|
||||
# 특정 포트 종료 함수 추가 (bashrc에 추가 가능)
|
||||
killport() {
|
||||
if [ -z "$1" ]; then
|
||||
echo "Usage: killport <port_number>"
|
||||
return 1
|
||||
fi
|
||||
lsof -ti:$1 | xargs -r kill -9
|
||||
echo "Killed processes on port $1"
|
||||
}
|
||||
|
||||
# 사용 예시
|
||||
# killport 7738
|
||||
# killport 5000
|
||||
```
|
||||
|
||||
## 📋 VS Code 워크스페이스 설정
|
||||
|
||||
여러 리포지토리를 동시에 관리하려면 워크스페이스 파일을 생성하세요:
|
||||
|
||||
```json
|
||||
{
|
||||
"folders": [
|
||||
{
|
||||
"name": "Main Repository",
|
||||
"path": "."
|
||||
},
|
||||
{
|
||||
"name": "New Project",
|
||||
"path": "./new-project-folder"
|
||||
}
|
||||
],
|
||||
"settings": {
|
||||
"git.enableSmartCommit": true,
|
||||
"git.confirmSync": false,
|
||||
"git.autofetch": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 🚨 문제 해결
|
||||
|
||||
### 1. 인증 실패
|
||||
```bash
|
||||
# 토큰이 만료된 경우, 새 토큰으로 원격 URL 업데이트
|
||||
git remote set-url origin https://thug0bin:NEW_TOKEN@git.0bin.in/thug0bin/repo-name.git
|
||||
```
|
||||
|
||||
### 2. 푸시 거부
|
||||
```bash
|
||||
# 원격 변경사항을 먼저 가져오기
|
||||
git pull origin main --rebase
|
||||
|
||||
# 충돌 해결 후 푸시
|
||||
git push origin main
|
||||
```
|
||||
|
||||
### 3. 대용량 파일 문제
|
||||
```bash
|
||||
# Git LFS 설정 (필요시)
|
||||
git lfs install
|
||||
git lfs track "*.zip"
|
||||
git lfs track "*.gz"
|
||||
git add .gitattributes
|
||||
```
|
||||
|
||||
## 📊 커밋 메시지 템플릿
|
||||
|
||||
### 기본 템플릿:
|
||||
```
|
||||
타입: 간단한 설명
|
||||
|
||||
상세한 설명 (선택사항)
|
||||
|
||||
🤖 Generated with [Claude Code](https://claude.ai/code)
|
||||
|
||||
Co-Authored-By: Claude <noreply@anthropic.com>
|
||||
```
|
||||
|
||||
### 타입별 예시:
|
||||
- `✨ feat: 새로운 기능 추가`
|
||||
- `🐛 fix: 버그 수정`
|
||||
- `📝 docs: 문서 업데이트`
|
||||
- `🎨 style: 코드 포맷팅`
|
||||
- `♻️ refactor: 코드 리팩토링`
|
||||
- `⚡ perf: 성능 개선`
|
||||
- `✅ test: 테스트 추가`
|
||||
- `🔧 chore: 빌드 설정 변경`
|
||||
|
||||
## 🔗 유용한 링크
|
||||
|
||||
- **Gitea 웹 인터페이스**: https://git.0bin.in/
|
||||
- **내 리포지토리 목록**: https://git.0bin.in/thug0bin
|
||||
- **새 리포지토리 생성**: https://git.0bin.in/repo/create
|
||||
|
||||
## 💡 팁과 모범 사례
|
||||
|
||||
1. **정기적인 커밋**: 작은 단위로 자주 커밋하세요
|
||||
2. **의미있는 커밋 메시지**: 변경 사항을 명확히 설명하세요
|
||||
3. **브랜치 활용**: 기능별로 브랜치를 나누어 작업하세요
|
||||
4. **.gitignore 활용**: 불필요한 파일은 제외하세요
|
||||
5. **문서화**: README.md와 같은 문서를 항상 업데이트하세요
|
||||
|
||||
---
|
||||
|
||||
**작성일**: 2025년 7월 29일
|
||||
**마지막 업데이트**: 토큰 및 서버 정보 최신화
|
||||
**참고**: 이 가이드는 재사용 가능하도록 작성되었습니다. 새 프로젝트마다 참고하세요.
|
||||
|
||||
> 💡 **중요**: 액세스 토큰은 보안이 중요한 정보입니다. 공개 저장소에 업로드하지 마세요!
|
||||
@@ -11,8 +11,8 @@ set -e
|
||||
# ================================
|
||||
# 설정 (필요시 수정)
|
||||
# ================================
|
||||
HEADSCALE_SERVER="https://head.0bin.in" # Headscale 서버 주소
|
||||
PREAUTH_KEY="8b3df41d37cb158ea39f41fc32c9af46e761de817ad06038" # 7일간 재사용 가능한 키
|
||||
HEADSCALE_SERVER="http://head.pharmq.kr" # Headscale 서버 주소
|
||||
PREAUTH_KEY="b46923995afeaec90e588168f2e1bf99801775e8657ce003" # 7일간 재사용 가능한 키
|
||||
FARMQ_NETWORK="100.64.0.0/10" # 팜큐 네트워크 대역
|
||||
|
||||
# 명령행 옵션 처리
|
||||
@@ -354,7 +354,7 @@ register_headscale() {
|
||||
# 수동 등록 모드
|
||||
print_info "다음 명령을 실행하여 수동 등록하세요:"
|
||||
echo ""
|
||||
echo "tailscale up --login-server=\"$HEADSCALE_SERVER\" --authkey=\"$PREAUTH_KEY\""
|
||||
echo "tailscale up --login-server=\"$HEADSCALE_SERVER\" --authkey=\"$PREAUTH_KEY\" --accept-routes --accept-dns=false"
|
||||
echo ""
|
||||
|
||||
# 등록 URL 시도
|
||||
@@ -403,6 +403,15 @@ verify_connection() {
|
||||
# 연결된 노드 확인
|
||||
print_info "네트워크 상태:"
|
||||
tailscale status | head -10
|
||||
|
||||
# 외부 DNS 해석 테스트
|
||||
print_status "외부 DNS 해석 테스트 중..."
|
||||
if ping -c 1 -W 5 google.com >/dev/null 2>&1; then
|
||||
print_success "외부 DNS 해석 정상! (google.com)"
|
||||
else
|
||||
print_warning "외부 DNS 해석 실패. 수동 확인이 필요할 수 있습니다."
|
||||
print_info "문제 해결: resolvectl status 명령으로 DNS 상태를 확인하세요."
|
||||
fi
|
||||
}
|
||||
|
||||
# ================================
|
||||
@@ -435,6 +444,42 @@ configure_firewall() {
|
||||
print_success "방화벽 설정 완료"
|
||||
}
|
||||
|
||||
# ================================
|
||||
# DNS Fallback 설정 (외부 도메인 해석 보장)
|
||||
# ================================
|
||||
configure_dns_fallback() {
|
||||
print_status "DNS Fallback 설정 중..."
|
||||
|
||||
# systemd-resolved가 있는 경우에만 설정
|
||||
if systemctl is-active --quiet systemd-resolved 2>/dev/null; then
|
||||
# Fallback DNS 설정 파일 생성
|
||||
mkdir -p /etc/systemd/resolved.conf.d
|
||||
cat > /etc/systemd/resolved.conf.d/headscale-fallback.conf << 'DNSEOF'
|
||||
# Headscale MagicDNS Fallback 설정
|
||||
# MagicDNS(100.100.100.100) 실패 시 외부 DNS로 폴백
|
||||
[Resolve]
|
||||
FallbackDNS=1.1.1.1 8.8.8.8 168.126.63.1
|
||||
DNSEOF
|
||||
|
||||
# systemd-resolved 재시작
|
||||
systemctl restart systemd-resolved 2>/dev/null || true
|
||||
print_success "DNS Fallback 설정 완료 (1.1.1.1, 8.8.8.8, 168.126.63.1)"
|
||||
else
|
||||
print_info "systemd-resolved가 없습니다. Fallback DNS 설정을 건너뜁니다."
|
||||
|
||||
# /etc/resolv.conf 직접 수정 (비-systemd 시스템용)
|
||||
if [ -f /etc/resolv.conf ] && ! grep -q "1.1.1.1" /etc/resolv.conf 2>/dev/null; then
|
||||
print_info "resolv.conf에 백업 DNS 추가..."
|
||||
# 기존 내용 백업
|
||||
cp /etc/resolv.conf /etc/resolv.conf.backup.$(date +%Y%m%d) 2>/dev/null || true
|
||||
# nameserver 추가 (끝에)
|
||||
echo "# Fallback DNS for Headscale" >> /etc/resolv.conf
|
||||
echo "nameserver 1.1.1.1" >> /etc/resolv.conf
|
||||
echo "nameserver 8.8.8.8" >> /etc/resolv.conf
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# ================================
|
||||
# 정리 작업
|
||||
# ================================
|
||||
@@ -503,6 +548,7 @@ main() {
|
||||
|
||||
# 사후 설정
|
||||
configure_firewall
|
||||
configure_dns_fallback
|
||||
verify_connection
|
||||
|
||||
# 정리 및 완료
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
#!/bin/bash
|
||||
|
||||
# 팜큐(FARMQ) Headscale 클라이언트 등록 스크립트
|
||||
# 사용법: ./register-client.sh
|
||||
# 팜큐(FARMQ) Headscale 클라이언트 등록 스크립트 - LIVE 서버용
|
||||
# 사용법: ./register-client-pharmq-live.sh
|
||||
# 대상 서버: head.pharmq.kr (Live Production)
|
||||
|
||||
set -e
|
||||
|
||||
# 설정
|
||||
HEADSCALE_SERVER="https://head.0bin.in"
|
||||
PREAUTH_KEY="fc4f2dc55ee00c5352823d156129b9ce2df4db02f1d76a21"
|
||||
HEADSCALE_SERVER="http://head.pharmq.kr"
|
||||
PREAUTH_KEY="b46923995afeaec90e588168f2e1bf99801775e8657ce003"
|
||||
|
||||
# 색상 출력 함수
|
||||
print_status() {
|
||||
@@ -87,7 +88,11 @@ disconnect_existing() {
|
||||
local current_status=$(tailscale status --json 2>/dev/null || echo "{}")
|
||||
if echo "$current_status" | grep -q '"BackendState":"Running"'; then
|
||||
print_status "기존 Tailscale 연결을 해제합니다..."
|
||||
sudo tailscale logout || true
|
||||
if [ "$EUID" -eq 0 ]; then
|
||||
tailscale logout || true
|
||||
else
|
||||
sudo tailscale logout || true
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
}
|
||||
@@ -98,11 +103,19 @@ register_to_headscale() {
|
||||
print_info "서버: $HEADSCALE_SERVER"
|
||||
|
||||
# Tailscale을 Headscale 서버로 설정하고 등록
|
||||
sudo tailscale up \
|
||||
--login-server="$HEADSCALE_SERVER" \
|
||||
--authkey="$PREAUTH_KEY" \
|
||||
--accept-routes \
|
||||
--accept-dns=false
|
||||
if [ "$EUID" -eq 0 ]; then
|
||||
tailscale up \
|
||||
--login-server="$HEADSCALE_SERVER" \
|
||||
--authkey="$PREAUTH_KEY" \
|
||||
--accept-routes \
|
||||
--accept-dns=false
|
||||
else
|
||||
sudo tailscale up \
|
||||
--login-server="$HEADSCALE_SERVER" \
|
||||
--authkey="$PREAUTH_KEY" \
|
||||
--accept-routes \
|
||||
--accept-dns=false
|
||||
fi
|
||||
}
|
||||
|
||||
# 연결 상태 확인
|
||||
@@ -138,10 +151,15 @@ main() {
|
||||
echo "=========================================="
|
||||
|
||||
# 루트 권한 확인
|
||||
if [[ $EUID -ne 0 ]] && ! sudo -n true 2>/dev/null; then
|
||||
print_error "이 스크립트는 sudo 권한이 필요합니다."
|
||||
if [[ $EUID -ne 0 ]] && ! command -v sudo &> /dev/null; then
|
||||
print_error "이 스크립트는 root 권한 또는 sudo가 필요합니다."
|
||||
print_info "root로 실행하거나 sudo를 설치한 후 다시 시도하세요."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ $EUID -ne 0 ]] && ! sudo -n true 2>/dev/null; then
|
||||
print_info "sudo 권한이 필요합니다. 비밀번호를 입력해주세요."
|
||||
fi
|
||||
|
||||
# 단계별 실행
|
||||
install_tailscale
|
||||
388
setup_doc/LXC_Caddysetup.md
Normal file
388
setup_doc/LXC_Caddysetup.md
Normal file
@@ -0,0 +1,388 @@
|
||||
# LXC Caddy에서 호스트 Tailscale 네트워크 연결 가이드
|
||||
|
||||
## 📅 작성일
|
||||
2025년 9월 22일
|
||||
|
||||
## 🎯 개요
|
||||
호스트에 Tailscale이 설치된 환경에서 LXC 컨테이너의 Caddy가 Tailscale 네트워크에 접근할 수 있도록 설정하는 완전한 가이드입니다.
|
||||
|
||||
## 🏗️ 시스템 구성
|
||||
|
||||
### 환경 정보
|
||||
- **호스트**: Proxmox VE (Tailscale 설치됨)
|
||||
- **LXC 컨테이너**: Caddy 웹서버 (ID: 103)
|
||||
- **목표**: LXC Caddy에서 Tailscale 네트워크의 서버들에 리버스 프록시
|
||||
|
||||
### 네트워크 구조
|
||||
```
|
||||
인터넷 → 도메인(*.pharmq.kr) → Caddy(LXC 103) → 호스트(라우팅) → Tailscale 네트워크
|
||||
```
|
||||
|
||||
## ✅ 전제 조건
|
||||
|
||||
### 1. 호스트 Tailscale 설치 확인
|
||||
```bash
|
||||
# 호스트에서 Tailscale 상태 확인
|
||||
tailscale status
|
||||
|
||||
# 출력 예시:
|
||||
# 100.64.0.3 pve-p2 myuser linux -
|
||||
# 100.64.0.6 pve-hp myuser linux -
|
||||
# 100.64.0.11 pve-p1 myuser linux -
|
||||
```
|
||||
|
||||
### 2. LXC 컨테이너 정보 확인
|
||||
```bash
|
||||
# LXC 네트워크 정보 확인
|
||||
pct exec 103 -- ip addr show eth0
|
||||
|
||||
# 출력 예시:
|
||||
# inet 192.168.0.19/24 brd 192.168.0.255 scope global dynamic eth0
|
||||
```
|
||||
|
||||
## 🔧 설정 단계
|
||||
|
||||
### 1단계: 호스트에서 IP 포워딩 활성화
|
||||
|
||||
```bash
|
||||
# IP 포워딩 활성화 (임시)
|
||||
echo 1 > /proc/sys/net/ipv4/ip_forward
|
||||
|
||||
# IP 포워딩 영구 활성화
|
||||
echo 'net.ipv4.ip_forward=1' >> /etc/sysctl.conf
|
||||
sysctl -p
|
||||
```
|
||||
|
||||
**확인:**
|
||||
```bash
|
||||
cat /proc/sys/net/ipv4/ip_forward
|
||||
# 출력: 1
|
||||
```
|
||||
|
||||
### 2단계: LXC에서 Tailscale 네트워크로 라우팅 추가
|
||||
|
||||
```bash
|
||||
# LXC에 라우팅 규칙 추가 (임시)
|
||||
pct exec 103 -- ip route add 100.64.0.0/10 via 192.168.0.200
|
||||
|
||||
# 라우팅 확인
|
||||
pct exec 103 -- ip route | grep 100.64
|
||||
# 출력: 100.64.0.0/10 via 192.168.0.200 dev eth0
|
||||
```
|
||||
|
||||
**영구 라우팅 설정:**
|
||||
```bash
|
||||
# LXC 내부에서 설정
|
||||
pct exec 103 -- bash -c 'echo "100.64.0.0/10 via 192.168.0.200" >> /etc/systemd/network/10-eth0.network'
|
||||
|
||||
# 또는 /etc/network/interfaces 사용 (Debian 기반)
|
||||
pct exec 103 -- bash -c 'echo "up ip route add 100.64.0.0/10 via 192.168.0.200" >> /etc/network/interfaces'
|
||||
```
|
||||
|
||||
### 3단계: iptables MASQUERADE 설정 (핵심!)
|
||||
|
||||
**⚠️ 중요: 이 단계가 없으면 일부 Tailscale 노드 연결이 실패할 수 있습니다.**
|
||||
|
||||
```bash
|
||||
# LXC에서 Tailscale 네트워크로의 트래픽에 MASQUERADE 적용
|
||||
iptables -t nat -A POSTROUTING -s 192.168.0.19 -d 100.64.0.0/10 -j MASQUERADE
|
||||
|
||||
# MASQUERADE 규칙 확인
|
||||
iptables -t nat -L POSTROUTING -v -n | grep 100.64
|
||||
```
|
||||
|
||||
**영구 iptables 설정:**
|
||||
```bash
|
||||
# iptables-persistent 설치 (Debian/Ubuntu)
|
||||
apt-get install iptables-persistent
|
||||
|
||||
# 현재 규칙 저장
|
||||
iptables-save > /etc/iptables/rules.v4
|
||||
|
||||
# 또는 systemd 서비스로 영구화
|
||||
cat > /etc/systemd/system/lxc-tailscale-nat.service << 'EOF'
|
||||
[Unit]
|
||||
Description=LXC Tailscale NAT Rules
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/sbin/iptables -t nat -A POSTROUTING -s 192.168.0.19 -d 100.64.0.0/10 -j MASQUERADE
|
||||
RemainAfterExit=yes
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
systemctl enable lxc-tailscale-nat.service
|
||||
```
|
||||
|
||||
### 4단계: 연결 테스트
|
||||
|
||||
```bash
|
||||
# LXC에서 Tailscale 네트워크 ping 테스트
|
||||
pct exec 103 -- ping -c 2 100.64.0.3
|
||||
|
||||
# 출력 예시:
|
||||
# PING 100.64.0.3 (100.64.0.3) 56(84) bytes of data.
|
||||
# 64 bytes from 100.64.0.3: icmp_seq=1 ttl=64 time=0.038 ms
|
||||
# 64 bytes from 100.64.0.3: icmp_seq=2 ttl=64 time=0.047 ms
|
||||
```
|
||||
|
||||
### 5단계: Caddyfile 설정
|
||||
|
||||
**호스트 기준 Tailscale IP 매핑 확인:**
|
||||
```bash
|
||||
# 호스트에서 Tailscale 노드 목록 확인
|
||||
tailscale status
|
||||
|
||||
# 예시 출력:
|
||||
# 100.64.0.3 pve-p2 myuser linux -
|
||||
# 100.64.0.6 pve-hp myuser linux -
|
||||
# 100.64.0.11 pve-p1 myuser linux -
|
||||
# 100.64.0.12 pve7 myuser linux -
|
||||
```
|
||||
|
||||
**Caddyfile 설정 예시:**
|
||||
```caddyfile
|
||||
{
|
||||
email admin@pharmq.kr
|
||||
acme_dns cloudflare YOUR_CLOUDFLARE_TOKEN
|
||||
}
|
||||
|
||||
# 와일드카드 인증서
|
||||
*.pharmq.kr {
|
||||
tls {
|
||||
dns cloudflare YOUR_CLOUDFLARE_TOKEN
|
||||
}
|
||||
respond "Wildcard domain: {host} - SSL ready!" 200
|
||||
}
|
||||
|
||||
# PVE 노드들 (호스트 기준 Tailscale 네트워크)
|
||||
p2.pharmq.kr {
|
||||
reverse_proxy https://100.64.0.3:8006 {
|
||||
transport http {
|
||||
tls_insecure_skip_verify
|
||||
}
|
||||
}
|
||||
tls {
|
||||
dns cloudflare YOUR_CLOUDFLARE_TOKEN
|
||||
}
|
||||
}
|
||||
|
||||
hp.pharmq.kr {
|
||||
reverse_proxy https://100.64.0.6:8006 {
|
||||
transport http {
|
||||
tls_insecure_skip_verify
|
||||
}
|
||||
}
|
||||
tls {
|
||||
dns cloudflare YOUR_CLOUDFLARE_TOKEN
|
||||
}
|
||||
}
|
||||
|
||||
p1.pharmq.kr {
|
||||
reverse_proxy https://100.64.0.11:8006 {
|
||||
transport http {
|
||||
tls_insecure_skip_verify
|
||||
}
|
||||
}
|
||||
tls {
|
||||
dns cloudflare YOUR_CLOUDFLARE_TOKEN
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 6단계: Caddy 설정 적용
|
||||
|
||||
```bash
|
||||
# Caddyfile 문법 검증
|
||||
pct exec 103 -- caddy validate --config /etc/caddy/Caddyfile
|
||||
|
||||
# Caddy 설정 다시 로드
|
||||
pct exec 103 -- systemctl reload caddy
|
||||
|
||||
# Caddy 상태 확인
|
||||
pct exec 103 -- systemctl status caddy
|
||||
```
|
||||
|
||||
### 7단계: 최종 테스트
|
||||
|
||||
```bash
|
||||
# 외부에서 도메인 접근 테스트
|
||||
curl -I https://p2.pharmq.kr
|
||||
|
||||
# 성공 예시 응답:
|
||||
# HTTP/2 501
|
||||
# server: pve-api-daemon/3.0
|
||||
# via: 1.1 Caddy
|
||||
```
|
||||
|
||||
## 🔍 문제 해결
|
||||
|
||||
### 문제 1: LXC에서 Tailscale IP 접근 불가
|
||||
|
||||
**증상:**
|
||||
```bash
|
||||
pct exec 103 -- ping 100.64.0.3
|
||||
# PING 100.64.0.3 (100.64.0.3) 56(84) bytes of data.
|
||||
# --- 100.64.0.3 ping statistics ---
|
||||
# 2 packets transmitted, 0 received, 100% packet loss
|
||||
```
|
||||
|
||||
**해결책:**
|
||||
```bash
|
||||
# 1. 호스트 IP 포워딩 확인
|
||||
cat /proc/sys/net/ipv4/ip_forward
|
||||
# 0이면 활성화 필요
|
||||
|
||||
# 2. LXC 라우팅 규칙 확인
|
||||
pct exec 103 -- ip route | grep 100.64
|
||||
# 없으면 라우팅 규칙 추가 필요
|
||||
|
||||
# 3. 호스트 Tailscale 상태 확인
|
||||
tailscale status
|
||||
# 대상 노드가 활성 상태인지 확인
|
||||
```
|
||||
|
||||
### 문제 2: Caddy에서 502 Bad Gateway
|
||||
|
||||
**증상:**
|
||||
```bash
|
||||
curl -I https://p2.pharmq.kr
|
||||
# HTTP/2 502
|
||||
```
|
||||
|
||||
**해결책:**
|
||||
```bash
|
||||
# 1. LXC에서 직접 연결 테스트
|
||||
pct exec 103 -- curl -I --connect-timeout 5 https://100.64.0.3:8006
|
||||
|
||||
# 2. Caddy 로그 확인
|
||||
pct exec 103 -- journalctl -u caddy --since "1 minute ago"
|
||||
|
||||
# 3. 대상 서버 응답 확인
|
||||
curl -I --connect-timeout 5 https://100.64.0.3:8006
|
||||
```
|
||||
|
||||
### 문제 3: SSL 인증서 오류
|
||||
|
||||
**증상:**
|
||||
```
|
||||
transport http {
|
||||
dial_timeout 10s
|
||||
}
|
||||
```
|
||||
|
||||
**해결책:**
|
||||
```caddyfile
|
||||
# HTTPS 백엔드의 경우 SSL 검증 무시 추가
|
||||
reverse_proxy https://100.64.0.3:8006 {
|
||||
transport http {
|
||||
tls_insecure_skip_verify
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 📋 체크리스트
|
||||
|
||||
### 설정 전 확인사항
|
||||
- [ ] 호스트에 Tailscale 설치 및 활성화됨
|
||||
- [ ] LXC 컨테이너 네트워크 설정 확인
|
||||
- [ ] 대상 Tailscale 노드들이 활성 상태
|
||||
|
||||
### 설정 단계 체크리스트
|
||||
- [ ] 호스트 IP 포워딩 활성화
|
||||
- [ ] LXC에서 Tailscale 네트워크 라우팅 추가
|
||||
- [ ] LXC에서 Tailscale IP로 ping 성공
|
||||
- [ ] Caddyfile에 올바른 Tailscale IP 설정
|
||||
- [ ] HTTPS 백엔드에 `tls_insecure_skip_verify` 추가
|
||||
- [ ] Caddy 설정 검증 및 리로드
|
||||
- [ ] 외부에서 도메인 접근 테스트 성공
|
||||
|
||||
## 🎯 핵심 포인트
|
||||
|
||||
### 1. LXC에 Tailscale 설치 불필요
|
||||
- **잘못된 접근**: LXC에 Tailscale 직접 설치
|
||||
- **올바른 접근**: 호스트 Tailscale을 통한 라우팅
|
||||
|
||||
### 2. 네트워크 라우팅이 핵심
|
||||
```bash
|
||||
# 이 명령어가 모든 것을 해결합니다
|
||||
pct exec 103 -- ip route add 100.64.0.0/10 via 192.168.0.200
|
||||
```
|
||||
|
||||
### 3. 호스트 기준 IP 사용
|
||||
- LXC 내부 Tailscale 상태가 아닌 **호스트 Tailscale 상태** 기준으로 IP 설정
|
||||
|
||||
### 4. HTTPS 백엔드 처리
|
||||
```caddyfile
|
||||
# PVE와 같은 HTTPS 백엔드의 경우 필수
|
||||
transport http {
|
||||
tls_insecure_skip_verify
|
||||
}
|
||||
```
|
||||
|
||||
## 🚀 확장 가능성
|
||||
|
||||
### 다른 서비스 추가
|
||||
```caddyfile
|
||||
# 새로운 Tailscale 노드 추가 예시
|
||||
newservice.pharmq.kr {
|
||||
reverse_proxy http://100.64.0.X:PORT
|
||||
tls {
|
||||
dns cloudflare YOUR_TOKEN
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 자동화 스크립트
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# setup-lxc-tailscale-routing.sh
|
||||
|
||||
LXC_ID="103"
|
||||
HOST_IP="192.168.0.200"
|
||||
TAILSCALE_NETWORK="100.64.0.0/10"
|
||||
|
||||
# IP 포워딩 활성화
|
||||
echo 1 > /proc/sys/net/ipv4/ip_forward
|
||||
|
||||
# LXC 라우팅 추가
|
||||
pct exec $LXC_ID -- ip route add $TAILSCALE_NETWORK via $HOST_IP
|
||||
|
||||
echo "✅ LXC Tailscale 라우팅 설정 완료"
|
||||
```
|
||||
|
||||
## 📝 유지보수
|
||||
|
||||
### 정기 점검 항목
|
||||
1. **Tailscale 연결 상태**: `tailscale status`
|
||||
2. **LXC 라우팅 상태**: `pct exec 103 -- ip route | grep 100.64`
|
||||
3. **Caddy 도메인 목록**: `pct exec 103 -- journalctl -u caddy | grep domains`
|
||||
|
||||
### 재부팅 후 복구
|
||||
```bash
|
||||
# 호스트 재부팅 후 실행할 명령어들 (3단계 모두 필수!)
|
||||
echo 1 > /proc/sys/net/ipv4/ip_forward
|
||||
pct exec 103 -- ip route add 100.64.0.0/10 via 192.168.0.200
|
||||
iptables -t nat -A POSTROUTING -s 192.168.0.19 -d 100.64.0.0/10 -j MASQUERADE
|
||||
```
|
||||
|
||||
## 🎉 결론
|
||||
|
||||
이 방법을 사용하면:
|
||||
- ✅ **LXC에 Tailscale 설치 불필요**
|
||||
- ✅ **간단한 네트워크 라우팅으로 해결**
|
||||
- ✅ **호스트 Tailscale 리소스 효율적 활용**
|
||||
- ✅ **SSL 인증서 자동 관리**
|
||||
- ✅ **확장성 및 유지보수성 우수**
|
||||
|
||||
**핵심 원리**: 호스트가 이미 Tailscale 네트워크에 연결되어 있다면, LXC는 **라우팅 + MASQUERADE**를 통해 호스트를 경유하여 접근할 수 있습니다!
|
||||
|
||||
**⚠️ 중요 교훈**: MASQUERADE 없이는 일부 Tailscale 노드 연결이 실패할 수 있습니다. 반드시 3단계 모두 필요합니다!
|
||||
|
||||
---
|
||||
**작성자**: Claude Code Assistant
|
||||
**파일 위치**: `/srv/pq_setup/LXC_Caddy_with_Host_Tailscale_Setup_Guide.md`
|
||||
**최종 업데이트**: 2025년 9월 22일
|
||||
56
전체적인_아키텍쳐.md
Normal file
56
전체적인_아키텍쳐.md
Normal file
@@ -0,0 +1,56 @@
|
||||
|
||||
[클라우드플레어]
|
||||
|
||||
Lets's 인증서로 DNS-01 (클라우드플레어 api)
|
||||
|
||||
Caddy로 인증서 내려줌
|
||||
*.pharmq.kr 와일드 카드 인증서
|
||||
|
||||
|
||||
|
||||
[ISP KT]
|
||||
|
||||
라우터 192.168.0.1 (게이트웨이)
|
||||
Caddy 는 192.168.
|
||||
|
||||
proxmox호스트
|
||||
192.168.0.200
|
||||
|
||||
그아래 VM으로
|
||||
Ubuntu VM 104번
|
||||
|
||||
192.168.0.100
|
||||
여기 100번 우분투 안에 Docker로 Headscale구성되어있음
|
||||
|
||||
|
||||
|
||||
LXC로 LXC 103번
|
||||
|
||||
192.168.0.19
|
||||
Debian이 있고 거기에 Caddy가 셋팅 (docker인지는 모르겠어 그냥 설치된건지)
|
||||
|
||||
|
||||
|
||||
|
||||
Caddy LXC에서
|
||||
라우팅 테이블로 192.168.0.200으로 빠져나와서
|
||||
|
||||
192.168.0.200 역시 Headscale이 설치되서 node 포함되어있음 설치되서 100.xxxxx대역으로 다른 tailscale망에 접속된 Proxmox HOST들과 통신간으
|
||||
|
||||
|
||||
우리는 각 지역에porxmox host를
|
||||
|
||||
pve1.pharmq.kr
|
||||
pve2.pharmq.kr
|
||||
|
||||
등으로 접속하기위해서 이러한 구성을 했어
|
||||
|
||||
각각에 타지역에 PC들은 headscale node가 되기때문에
|
||||
|
||||
|
||||
각지역에 proxmox host는 라우터 아래 있거나 lte 망 아래 있더라도,
|
||||
|
||||
|
||||
ssl인증서 발급받은채로 외부에서 접속가능해
|
||||
|
||||
여기서 핵심은 magic dns처럼 외부 에서 다른 PC가 proxmox node들에 접속할때는 별도로 headscale노드에 포함되지 않아도 된다는거야
|
||||
Reference in New Issue
Block a user