20 Commits

Author SHA1 Message Date
176c6bb1c2 Update documentation for PowerShell encoding fix
- Update README.md with English PowerShell commands
- Update WINDOWS_QUICK_START.md with encoding issue solution
- Add FAQ section for Korean character encoding problems
- Recommend farmq-install-en.ps1 to prevent character display issues

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-11 01:01:36 +09:00
b7c621f294 Fix PowerShell encoding issues: Add English-only version
- Add farmq-install-en.ps1 with UTF-8 encoding support
- Remove Korean characters to prevent PowerShell encoding issues
- Use ASCII-only status indicators ([*], [+], [!])
- Maintain all functionality while ensuring compatibility

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-11 01:00:28 +09:00
09cdb088af Add Windows installation section to README and create quick start guide
- Add Windows PowerShell one-click installation to main README
- Create comprehensive Windows quick start guide (WINDOWS_QUICK_START.md)
- Provide copy-paste friendly commands for easy execution
- Include step-by-step instructions with screenshots example
- Add FAQ section for common PowerShell execution issues
- Include troubleshooting guide for Windows-specific problems
- Add useful post-installation commands reference

Features:
- Copy-paste optimized command formatting
- Clear 3-step installation process
- Administrator PowerShell access instructions
- Force reinstall option for existing Tailscale installations
- Windows Defender firewall handling
- Network connectivity verification

🚀 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-11 00:55:31 +09:00
ea11f92070 Add Windows PowerShell one-click installation script
- Complete PowerShell script with colorful output and error handling
- Auto-detect existing Tailscale installations
- Smart handling of existing connections (like Linux version)
- Administrator privilege checking with clear instructions
- Automatic Tailscale download and silent installation
- Windows Defender firewall configuration
- Network connectivity testing and verification
- Comprehensive final status report

Features:
- One-line web execution support
- Force reinstall option (-Force parameter)
- Detailed system information display
- Graceful error handling and cleanup
- Windows-native user experience

🚀 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-11 00:54:13 +09:00
f69ee95443 Add support for root account execution without sudo
- Update script comments and documentation for root vs regular users
- Improve error message for missing root privileges
- Add separate command examples for root accounts (Proxmox, containers, etc.)
- Update README and installation guide with both sudo and non-sudo examples

🚀 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-11 00:44:44 +09:00
591173d1cf Enhanced script to handle existing Tailscale connections
- Add automatic detection of existing Tailscale/Headscale connections
- Implement smart server comparison to avoid unnecessary re-registration
- Add --force option for mandatory re-registration
- Improve user interaction for terminal vs pipe execution
- Add better logout verification with retry logic
- Update documentation with force registration examples
- Change default behavior to auto-register (Y) instead of skip (N)

🚀 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-11 00:42:38 +09:00
7e32632186 Add one-click client installation section to README
- Add quick installation commands for new servers
- Include support for multiple Linux distributions
- Maintain original README structure and content
- Add curl/wget installation methods

🚀 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-11 00:37:05 +09:00
522d39d3df Add comprehensive quick installation guide and update server URLs
- Complete installation guide with examples and troubleshooting
- Update Headscale server URL to https://head.0bin.in
- Add support information and network configuration details
- Include colored terminal output examples

🚀 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-11 00:33:04 +09:00
8bd6b1f400 Add one-click installation script for Headscale clients
- Complete automated script for Tailscale + Headscale registration
- Support for Ubuntu, Debian, CentOS, RHEL, Rocky, Fedora, Arch
- Universal binary fallback for unsupported distros
- Automatic firewall configuration
- Network connectivity verification
- Fix Headscale health check (nc -> headscale version)
- Add comprehensive error handling and colored output

🚀 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-11 00:30:47 +09:00
53c1f45e02 🚀 Add complete client registration system for FARMQ Headscale
## New Features:
- **register-client.sh**: Automated client registration script
  - Auto-detects OS (Ubuntu/CentOS/macOS)
  - Installs Tailscale automatically
  - Registers to https://head.0bin.in with pre-auth key
  - Verifies connection and displays status

- **create-preauth-key.sh**: Pre-auth key management script
  - Creates users and pre-auth keys with custom expiration
  - Supports reusable keys for multiple devices
  - Provides ready-to-use registration commands
  - Example: `./create-preauth-key.sh pharmacy1 7d`

- **CLIENT_SETUP_GUIDE.md**: Complete installation guide
  - Automated and manual installation instructions
  - Cross-platform support (Linux/macOS/Windows/Mobile)
  - Troubleshooting section
  - Key management for admins

## Pharmacy Page Fix:
- Fix machine count display in pharmacy management page
- Update get_all_pharmacies_with_stats() to use actual Headscale Node data
- Show correct online/offline machine counts per pharmacy
- Fixed: "0대" → "2대 online" for proper machine statistics

## Key Benefits:
- **One-line registration**: `sudo ./register-client.sh`
- **Pre-auth keys work once, connect forever** - answers user's question
- **Reusable keys** for multiple devices per pharmacy
- **Cross-platform** support for all major operating systems

Current active keys:
- myuser: fc4f2dc55ee00c5352823d156129b9ce2df4db02f1d76a21
- pharmacy1: 5c15b41ea8b135dbed42455ad1a9a0cf0352b100defd241c (7d validity)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-09 18:23:04 +09:00
1ea11a6a3c 🔧 Fix machine connectivity and pharmacy display issues
- Fix database initialization to use correct Headscale DB path
- Implement proper data synchronization between Headscale and FARMQ
- Resolve timezone comparison error in machine online status detection
- Update machine listing to use actual Headscale Node data instead of MachineProfile
- Add proper pharmacy-to-machine mapping display
- Show both technical Headscale usernames and actual pharmacy business names
- Fix machine offline status display - now correctly shows online machines
- Add humanize_datetime utility function for better timestamp display

All machines now correctly display:
- Online status (matching actual Headscale status)
- Technical username (myuser)
- Actual pharmacy name (세종온누리약국)
- Manager name and business details

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-09 17:56:38 +09:00
ca61a89739 🏥 Add complete FARMQ Admin Flask application
## Features
- 한국어 Flask 관리 인터페이스 with Bootstrap 5
- Headscale과 분리된 독립 데이터베이스 구조
- 약국 관리 시스템 (pharmacy management)
- 머신 모니터링 및 상태 관리
- 실시간 대시보드 with 통계 및 알림
- Headscale 사용자명과 약국명 분리 관리

## Database Architecture
- 별도 FARMQ SQLite DB (farmq.sqlite)
- Headscale DB와 외래키 충돌 방지
- 느슨한 결합 설계 (ID 참조만 사용)

## UI Components
- 반응형 대시보드 with 실시간 통계
- 약국별 머신 상태 모니터링
- 한국어 지역화 및 사용자 친화적 인터페이스
- 머신 온라인/오프라인 상태 표시 (24시간 타임아웃)

## API Endpoints
- `/api/sync/machines` - Headscale 머신 동기화
- `/api/sync/users` - Headscale 사용자 동기화
- `/api/pharmacy/<id>/update` - 약국 정보 업데이트
- 대시보드 통계 및 알림 API

## Problem Resolution
- Fixed foreign key conflicts preventing Windows client connections
- Resolved machine online status detection with proper timeout handling
- Separated technical Headscale usernames from business pharmacy names

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-09 17:44:56 +09:00
9155bf5479 📝 Update .gitignore to exclude Python virtual environments and cache files
Add comprehensive Python gitignore patterns:
- Virtual environments (venv/, env/, .venv/)
- Python cache files (__pycache__/, *.pyc)
- Distribution files (dist/, build/, *.egg-info/)
- Testing artifacts (.pytest_cache/, .coverage)
- Development tools (.mypy_cache/, .tox/)

Prevents accidental commit of:
- Virtual environment directories
- Python bytecode cache
- Build artifacts
- IDE and testing files

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-09 15:53:11 +09:00
92091bfe88 🗃️ Add comprehensive SQLAlchemy models for Headscale database
Core Models (based on actual DB schema analysis):
- User: Headscale users with relationships
- Node: Connected machines with detailed host info
- PreAuthKey: Pre-authentication keys with validation
- ApiKey: API authentication keys with expiration
- Policy: ACL policies (JSON format)

Extended Models for FARMQ:
- PharmacyInfo: Pharmacy details (name, business number, contact)
- MachineSpecs: Hardware specifications per machine
- MonitoringData: Real-time monitoring metrics

Features:
- Complete database relationships and foreign keys
- JSON type handling for complex data structures
- Timezone-aware datetime handling
- Helper methods (is_online, is_expired, is_valid)
- Database utility functions
- Comprehensive test suite with actual data validation

Test Results:  All models working with live Headscale SQLite DB
- 1 User: myuser
- 1 Node: 0bin-Ubuntu-VM (100.64.0.1)
- 1 API Key: 8qRr1IB (valid until Dec 2025)
- 1 Pre-auth Key: reusable, valid
- Extended tables created and tested successfully

Ready for FARMQ pharmacy management system integration.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-09 15:47:16 +09:00
247b9dbee7 🏥 Add FARMQ Headplane customization business plan
Comprehensive business plan for customizing Headplane for FARMQ:
- Company overview: 100 pharmacies with Proxmox infrastructure
- Current system analysis: Working Headscale + Headplane setup
- Enhancement requirements: Pharmacy info, machine specs, monitoring
- Database schema design: pharmacy_info, machine_specs, monitoring_data
- Implementation approaches: Fork vs separate system vs plugin
- Technical specifications: React frontend + API backend + Proxmox integration
- Development roadmap: 4-5 weeks phased implementation
- Success metrics and cost analysis

Ready for pharmacy management system development based on existing Headplane.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-09 15:34:12 +09:00
c3963fc26c 📋 Add Headplane Korean localization plan
- Comprehensive analysis of current Headplane i18n status
- 4 different implementation approaches with pros/cons
- Phase-by-phase implementation plan (browser extension → env vars → react-i18next)
- Detailed Korean translation mappings for UI elements
- Implementation examples and code snippets
- Progress checklist for tracking localization work

Ready to start with browser extension approach for immediate Korean UI.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-09 15:24:04 +09:00
6e8a7b81fb 📚 Update documentation with Headplane UI access and configuration
- Update INSTALLATION_GUIDE.md:
  * Add Headplane configuration section with proper config.yaml
  * Update docker-compose.yml example with simplified environment variables
  * Add Headplane login instructions and API key information
  * Update troubleshooting section for cookie_secret validation errors
- Update CLIENT_CONNECTION_TEST.md:
  * Add Headplane web UI access information
  * Include external access URL (192.168.0.151:3000/admin/)
  * Add login credentials and API key details
- Update start.sh:
  * Include Headplane UI URLs in installation summary
  * Add external access information for network environments

All documentation now reflects the working Headplane UI configuration
and provides complete setup instructions for both local and external access.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-09 15:12:54 +09:00
3c6723bcde 🔧 Fix Headplane UI configuration and enable external access
- Update docker-compose.yml: simplify environment variables
- Fix headplane-config/config.yaml with proper configuration:
  * Add required server section with correct cookie_secret (32 chars)
  * Configure headscale connection with API key
  * Enable external access via 192.168.0.151:3000/admin/
- Resolve configuration validation errors
- Headplane UI now fully accessible and functional

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-09 15:10:56 +09:00
76e727fb99 📚 Add comprehensive documentation for Headscale setup and testing
 새로운 문서 추가:
- INSTALLATION_GUIDE.md: 완전한 설치 가이드
  * Docker Compose 설정 상세 설명
  * 최신 Headscale 설정 형식 적용
  * 포트 충돌 해결 방법
  * 단계별 설치 과정
  * 문제 해결 가이드

- CLIENT_CONNECTION_TEST.md: 클라이언트 연결 테스트 가이드
  * Tailscale 클라이언트 설치 과정
  * Headscale 서버 연결 방법
  * 네트워크 인터페이스 확인
  * 성능 테스트 및 모니터링
  * 다중 플랫폼 연결 방법

🔧 기술적 세부사항:
- 포트 8070 사용으로 충돌 방지
- IPv4/IPv6 듀얼 스택 지원
- Pre-auth 키 기반 자동 인증
- Magic DNS 설정 포함
- Docker 헬스체크 개선

📊 검증된 기능:
-  VPN 터널 구성 (100.64.0.1/32)
-  실시간 노드 관리
-  0% 패킷 손실 확인
-  WireGuard 암호화 적용

🎯 사용자 가이드:
- 초보자도 쉽게 따라할 수 있는 단계별 안내
- 문제 상황별 해결 방법 제시
- 성능 테스트 및 모니터링 방법
- 다중 클라이언트 연결 가이드

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-09 14:46:29 +09:00
52cc779bca 🎉 Working Headscale Setup with Docker Complete
 주요 성과:
- Headscale 서버 정상 작동 (포트 8070)
- 포트 충돌 문제 해결 (8080 → 8070)
- 최신 Headscale 설정 형식 적용
- 사용자 생성 및 Pre-auth 키 생성 성공
- Docker Compose 완전 설정

🔧 수정된 구성:
- docker-compose.yml: 포트 매핑 및 헬스체크 개선
- config/config.yaml: 최신 DNS 및 Policy 설정 적용
- .env.example: 8070 포트로 업데이트
- README.md: 올바른 접속 정보 및 명령어
- start.sh: 향상된 설치 스크립트

📊 성공한 기능들:
-  Headscale API: http://localhost:8070
-  사용자 생성: myuser (ID: 1)
-  API 키 생성: 8qRr1IB.tV95CmA0fLaCiGGIgBfeoN9daHceFkzI
-  Pre-auth 키: fc4f2dc55ee00c5352823d156129b9ce2df4db02f1d76a21
-  SQLite 데이터베이스 설정

🚧 진행 중:
- Headplane UI 설정 (설정 파일 문제로 보류)
- 클라이언트 연결 테스트 준비 완료

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-09 14:34:56 +09:00
39 changed files with 9523 additions and 25 deletions

View File

@@ -2,7 +2,7 @@
HEADSCALE_API_KEY=your_api_key_here HEADSCALE_API_KEY=your_api_key_here
# Server configuration # Server configuration
SERVER_URL=http://localhost:8080 SERVER_URL=http://localhost:8070
LISTEN_ADDR=0.0.0.0:8080 LISTEN_ADDR=0.0.0.0:8080
# Database (SQLite by default) # Database (SQLite by default)

97
.gitignore vendored
View File

@@ -62,3 +62,100 @@ temp/
# Docker Compose override files # Docker Compose override files
docker-compose.override.yml docker-compose.override.yml
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
pip-wheel-metadata/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
*.manifest
*.spec
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
# Virtual environments
venv/
env/
ENV/
env.bak/
venv.bak/
.venv/
# Jupyter Notebook
.ipynb_checkpoints
# pyenv
.python-version
# pipenv
Pipfile.lock
# PEP 582
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/

301
CLIENT_CONNECTION_TEST.md Normal file
View File

@@ -0,0 +1,301 @@
# 🔗 Tailscale 클라이언트 연결 및 테스트 가이드
## 📋 테스트 개요
- **목적**: Headscale 서버에 Tailscale 클라이언트 연결 및 VPN 기능 검증
- **환경**: Ubuntu 24.04 LTS, Tailscale 1.86.2
- **서버**: Headscale (http://localhost:8070)
## 🛠️ 사전 준비사항
- Headscale 서버가 정상 작동 중 (8070 포트)
- 사용자 및 Pre-auth 키 생성 완료
- 테스트할 클라이언트 장치 준비
## 📊 기본 정보 확인
### Headscale 서버 상태
```bash
# API 헬스 체크
curl -s http://localhost:8070/health
# 응답: {"status":"pass"}
# 컨테이너 상태 확인
docker-compose ps
# STATUS: Up (healthy 또는 running)
```
### 사용자 및 키 정보
```bash
# 사용자 목록
docker-compose exec headscale headscale users list
# 결과: myuser (ID: 1)
# Pre-auth 키 확인
echo "Pre-auth Key: fc4f2dc55ee00c5352823d156129b9ce2df4db02f1d76a21"
```
## 🚀 Tailscale 클라이언트 설치
### Ubuntu/Debian 설치
```bash
# 공식 설치 스크립트 사용
curl -fsSL https://tailscale.com/install.sh | sh
# 설치 확인
tailscale version
# 결과: 1.86.2
```
### 설치 후 서비스 상태 확인
```bash
# Tailscale 데몬 상태 확인
sudo systemctl status tailscaled
# Active: active (running)
# Tailscale 명령어 확인
which tailscale
# /usr/bin/tailscale
```
## 🔗 Headscale 서버 연결
### 연결 명령어 실행
```bash
# Pre-auth 키를 사용한 자동 연결
tailscale up --login-server=http://localhost:8070 --authkey=fc4f2dc55ee00c5352823d156129b9ce2df4db02f1d76a21
```
### 연결 성공 확인
```bash
# 연결 상태 확인
tailscale status
```
**성공적인 출력 예시:**
```
100.64.0.1 0bin-ubuntu-vm myuser linux -
```
## 📡 네트워크 인터페이스 확인
### Tailscale 인터페이스 생성 확인
```bash
# tailscale0 인터페이스 확인
ip addr show tailscale0
```
**출력 결과:**
```
214: tailscale0: <POINTOPOINT,MULTICAST,NOARP,UP,LOWER_UP> mtu 1280 qdisc pfifo_fast state UNKNOWN group default qlen 500
link/none
inet 100.64.0.1/32 scope global tailscale0
valid_lft forever preferred_lft forever
inet6 fd7a:115c:a1e0::1/128 scope global
valid_lft forever preferred_lft forever
inet6 fe80::a49:8d96:4244:2fcf/64 scope link stable-privacy
valid_lft forever preferred_lft forever
```
### IP 주소 할당 확인
- **IPv4**: `100.64.0.1/32`
- **IPv6**: `fd7a:115c:a1e0::1/128`
- **링크로컬**: `fe80::a49:8d96:4244:2fcf/64`
## 🌐 Headscale 서버에서 노드 확인
### 연결된 노드 목록 확인
```bash
docker-compose exec headscale headscale nodes list
```
**출력 결과:**
```
ID | Hostname | Name | MachineKey | NodeKey | User | IP addresses | Ephemeral | Last seen | Expiration | Connected | Expired
1 | 0bin-Ubuntu-VM | 0bin-ubuntu-vm| [rzOhs] | [SbpbT] | myuser | 100.64.0.1, fd7a:115c:a1e0::1| false | 2025-09-09 05:42:25 | N/A | online | no
```
### 노드 세부 정보
- **ID**: 1
- **호스트명**: 0bin-Ubuntu-VM
- **노드명**: 0bin-ubuntu-vm
- **사용자**: myuser
- **IP 주소**: 100.64.0.1 (IPv4), fd7a:115c:a1e0::1 (IPv6)
- **상태**: online
- **임시 노드**: false
- **만료**: 없음
## 🧪 연결 테스트
### 1. 자기 자신 핑 테스트
```bash
# IPv4 핑 테스트
ping -c 3 100.64.0.1
```
**성공 결과:**
```
PING 100.64.0.1 (100.64.0.1) 56(84) bytes of data.
64 bytes from 100.64.0.1: icmp_seq=1 ttl=64 time=0.032 ms
64 bytes from 100.64.0.1: icmp_seq=2 ttl=64 time=0.044 ms
64 bytes from 100.64.0.1: icmp_seq=3 ttl=64 time=0.050 ms
--- 100.64.0.1 ping statistics ---
3 packets transmitted, 3 received, 0% packet loss, time 2080ms
rtt min/avg/max/mdev = 0.032/0.042/0.050/0.007 ms
```
### 2. IPv6 핑 테스트
```bash
# IPv6 핑 테스트
ping6 -c 3 fd7a:115c:a1e0::1
```
### 3. DNS 확인 (Magic DNS)
```bash
# Magic DNS 테스트 (설정된 경우)
nslookup 0bin-ubuntu-vm.headscale.local
```
## 📋 추가 클라이언트 연결 방법
### 다른 장치에서 연결하기
#### Windows
```cmd
# PowerShell 또는 Command Prompt에서
tailscale up --login-server=http://YOUR_SERVER_IP:8070 --authkey=fc4f2dc55ee00c5352823d156129b9ce2df4db02f1d76a21
```
#### macOS
```bash
# Terminal에서
sudo tailscale up --login-server=http://YOUR_SERVER_IP:8070 --authkey=fc4f2dc55ee00c5352823d156129b9ce2df4db02f1d76a21
```
#### 다른 Linux 장치
```bash
# 동일한 명령어 사용
tailscale up --login-server=http://YOUR_SERVER_IP:8070 --authkey=fc4f2dc55ee00c5352823d156129b9ce2df4db02f1d76a21
```
### 새로운 Pre-auth 키 생성 (필요시)
```bash
# 새로운 24시간 유효 키 생성
docker-compose exec headscale headscale preauthkeys create --user 1 --reusable --expiration 24h
```
## 🔍 모니터링 및 관리
### 실시간 연결 상태 모니터링
```bash
# 실시간 로그 확인
docker-compose logs -f headscale
# Tailscale 상태 지속 확인
watch -n 5 'tailscale status'
```
### 네트워크 트래픽 모니터링
```bash
# tailscale0 인터페이스 트래픽 확인
iftop -i tailscale0
# 또는 간단한 통계
ip -s link show tailscale0
```
## 🚨 문제 해결
### 연결 실패 시 체크리스트
#### 1. Headscale 서버 상태 확인
```bash
curl -f http://localhost:8070/health || echo "Headscale not responding"
```
#### 2. 방화벽 설정 확인
```bash
# 8070 포트 오픈 확인
sudo ufw status | grep 8070
# 필요시 포트 개방
sudo ufw allow 8070
```
#### 3. Pre-auth 키 유효성 확인
```bash
# 키 목록 확인
docker-compose exec headscale headscale preauthkeys list
```
#### 4. Tailscale 서비스 재시작
```bash
sudo systemctl restart tailscaled
```
### 연결 해제 및 재연결
```bash
# 연결 해제
tailscale down
# 재연결
tailscale up --login-server=http://localhost:8070 --authkey=fc4f2dc55ee00c5352823d156129b9ce2df4db02f1d76a21
```
## 📊 성능 테스트
### 대역폭 테스트 (2개 이상 클라이언트 연결 시)
```bash
# iperf3 설치
sudo apt install iperf3
# 서버 모드 (첫 번째 클라이언트)
iperf3 -s
# 클라이언트 모드 (두 번째 클라이언트)
iperf3 -c 100.64.0.1
```
### 지연시간 테스트
```bash
# 지속적인 핑 테스트
ping -i 0.1 100.64.0.1
```
## 🎯 테스트 결과 요약
### ✅ 성공적으로 확인된 기능
1. **클라이언트 설치**: Tailscale 1.86.2 설치 완료
2. **서버 연결**: Pre-auth 키를 통한 자동 인증 성공
3. **IP 할당**: IPv4(100.64.0.1), IPv6(fd7a:115c:a1e0::1) 정상 할당
4. **네트워크 통신**: 핑 테스트 성공 (0% 패킷 손실)
5. **인터페이스 생성**: tailscale0 인터페이스 정상 생성
6. **서버 인식**: Headscale에서 노드 정상 인식
### 📈 네트워크 성능
- **핑 지연시간**: 평균 0.042ms (로컬)
- **패킷 손실**: 0%
- **MTU**: 1280 bytes
- **상태**: UNKNOWN (정상 동작)
### 🔒 보안 확인사항
- **암호화**: WireGuard 프로토콜 사용
- **인증**: Pre-auth 키 기반 자동 인증
- **키 관리**: 24시간 만료, 재사용 가능 설정
## 🚀 결론
Headscale 서버와 Tailscale 클라이언트 간의 연결이 완벽하게 성공했습니다.
**주요 성과:**
- ✅ VPN 터널 구성 완료
- ✅ IP 주소 자동 할당 성공
- ✅ 실시간 통신 확인
- ✅ Headscale 관리 인터페이스 정상 동작
- ✅ Headplane 웹 UI 외부 접속 성공
### 🌐 완전한 관리 환경 구축
- **Headscale API**: http://localhost:8070 (명령줄 관리)
- **Headplane UI**: http://192.168.0.151:3000/admin/ (웹 관리)
- **로그인 API Key**: `8qRr1IB.tV95CmA0fLaCiGGIgBfeoN9daHceFkzI`
이제 **Tailscale을 완전히 대체**할 수 있는 자체 호스팅 VPN 솔루션이 구축되었습니다!

193
CLIENT_SETUP_GUIDE.md Normal file
View File

@@ -0,0 +1,193 @@
# 팜큐(FARMQ) Headscale 클라이언트 설치 가이드
## 🏥 개요
팜큐 네트워크에 PC를 연결하기 위한 간편한 설치 가이드입니다.
## 📋 Pre-auth Key 정보
### ✅ Pre-auth Key 특징:
- **1회 등록**: 한 번 사용하면 해당 머신이 영구적으로 네트워크에 등록됩니다
- **자동 재연결**: 재부팅 후에도 자동으로 연결됩니다
- **재사용 가능**: 동일한 key로 여러 머신을 등록할 수 있습니다 (설정에 따라)
### 🔑 현재 유효한 Key:
- **myuser**: `fc4f2dc55ee00c5352823d156129b9ce2df4db02f1d76a21` (재사용 가능)
- **pharmacy1**: `5c15b41ea8b135dbed42455ad1a9a0cf0352b100defd241c` (7일 유효, 재사용 가능)
## 🚀 자동 설치 (권장)
### Linux/Ubuntu 시스템
1. **스크립트 다운로드**:
```bash
wget https://head.0bin.in/register-client.sh
chmod +x register-client.sh
```
2. **스크립트 실행**:
```bash
sudo ./register-client.sh
```
### 수동으로 Pre-auth Key 업데이트
스크립트의 Pre-auth Key를 업데이트하려면:
```bash
# 스크립트 편집
nano register-client.sh
# PREAUTH_KEY 값을 새로운 key로 변경
PREAUTH_KEY="새로운키값"
```
## 🔧 수동 설치
### 1. Tailscale 설치
#### Ubuntu/Debian:
```bash
curl -fsSL https://tailscale.com/install.sh | sh
```
#### CentOS/RHEL:
```bash
curl -fsSL https://tailscale.com/install.sh | sh
```
#### macOS:
```bash
# Homebrew 사용
brew install --cask tailscale
# 또는 직접 다운로드
# https://tailscale.com/download/mac
```
#### Windows:
1. https://tailscale.com/download/windows 에서 다운로드
2. 설치 후 아래 명령어를 관리자 권한 PowerShell에서 실행
### 2. Headscale 서버에 등록
#### Linux/macOS:
```bash
sudo tailscale up \
--login-server="https://head.0bin.in" \
--authkey="fc4f2dc55ee00c5352823d156129b9ce2df4db02f1d76a21" \
--accept-routes \
--accept-dns=false
```
#### Windows (PowerShell 관리자 권한):
```powershell
tailscale up `
--login-server="https://head.0bin.in" `
--authkey="fc4f2dc55ee00c5352823d156129b9ce2df4db02f1d76a21" `
--accept-routes `
--accept-dns=false
```
## 📊 연결 확인
### 연결 상태 확인:
```bash
tailscale status
```
### IP 주소 확인:
```bash
tailscale ip -4
```
### 네트워크 테스트:
```bash
# 다른 팜큐 머신으로 핑 테스트
ping 100.64.0.1
```
## 🔑 관리자용 - Pre-auth Key 생성
### 새로운 약국용 Key 생성:
1. **스크립트 사용** (권장):
```bash
./create-preauth-key.sh pharmacy2 30d
```
2. **수동 생성**:
```bash
# 사용자 생성 (필요시)
docker exec headscale headscale users create pharmacy2
# Pre-auth key 생성
docker exec headscale headscale preauthkeys create \
-u 2 --expiration 30d --reusable
```
### Key 관리 명령어:
```bash
# 사용자 목록 확인
docker exec headscale headscale users list
# Pre-auth key 목록 확인 (사용자 ID 필요)
docker exec headscale headscale preauthkeys list -u 1
# 만료된 key 삭제
docker exec headscale headscale preauthkeys expire -k <key_id>
```
## 🛠️ 문제해결
### 연결 안됨:
1. **방화벽 확인**: 8080, 443 포트가 열려있는지 확인
2. **DNS 확인**: `https://head.0bin.in` 접근 가능한지 확인
3. **Key 유효성**: Pre-auth key가 만료되지 않았는지 확인
### 기존 연결 해제:
```bash
sudo tailscale logout
```
### 완전 재설정:
```bash
sudo tailscale logout
sudo tailscale up --login-server="https://head.0bin.in" --authkey="새로운키"
```
## 📱 모바일 설정
### Android/iOS:
1. Tailscale 앱 설치
2. "Use a different server" 선택
3. 서버 URL: `https://head.0bin.in`
4. Pre-auth key 입력 (위 key 중 하나 사용)
## 🔐 보안 참고사항
- Pre-auth key는 민감한 정보입니다. 공유 시 주의하세요
- Key가 만료되기 전에 새로운 key를 생성하세요
- 불필요한 key는 정기적으로 만료시키세요
- 각 약국별로 별도의 사용자와 key를 사용하는 것을 권장합니다
## 📞 지원
문제가 발생하면 다음 정보를 포함하여 문의하세요:
- 운영체제 정보
- `tailscale status` 출력
- 에러 메시지
- 사용한 Pre-auth key (마지막 8자리만)
---
## 📋 요약
1. **간편 설치**: `register-client.sh` 스크립트 실행
2. **수동 설치**: Tailscale 설치 → `tailscale up` 명령어 실행
3. **연결 확인**: `tailscale status` 및 `tailscale ip` 확인
4. **문제 시**: 재부팅 또는 logout 후 재연결
**서버**: https://head.0bin.in
**기본 Key**: `fc4f2dc55ee00c5352823d156129b9ce2df4db02f1d76a21`

View File

@@ -0,0 +1,349 @@
# 🏥 팜큐(FARMQ) Headplane 커스터마이징 기획서
## 📋 프로젝트 개요
### 회사 현황
- **회사명**: 팜큐(FARMQ)
- **사업 규모**: 약국 100개소
- **인프라**: 각 약국마다 Proxmox 호스트 PC 납품
- **관리 대상**: 약국별 VM 환경 원격 관리
- **네트워크**: WireGuard 기반 Headscale + Headplane 전환 완료
### 현재 환경
-**Headscale 서버**: http://192.168.0.151:8070 (정상 운영)
-**Headplane UI**: http://192.168.0.151:3000/admin/ (정상 운영)
-**VPN 네트워크**: 100.64.0.0/10 대역 할당
-**기본 기능**: Machine/User 관리 가능
### 개선 목표
현재 기본 Headplane UI를 **팜큐 전용 약국 관리 시스템**으로 확장
## 🎯 요구사항 분석
### 1. 데이터 확장 요구사항
#### 현재 User 테이블 구조
```
User | Role | Created At | Last Seen
myuser | Unmanaged | 2025.9.9 | Connected
```
#### 확장 요구사항
```
User | 약국명 | 사업자번호 | Role | Created At | Last Seen
myuser | 서울약국 | 123-45-67890 | Unmanaged | 2025.9.9 | Connected
```
#### Machine 정보 확장 요구사항
- **현재**: Machine 이름만 관리
- **확장 후**:
- 약국명 연동
- PC 사양 정보 (CPU, RAM, Storage)
- Proxmox 호스트 정보
- 실시간 하드웨어 모니터링
### 2. 모니터링 요구사항
- **Proxmox API 연동**: CPU 온도, 사용률, VM 상태
- **실시간 대시보드**: 약국별 시스템 현황
- **알림 시스템**: 장애 발생 시 실시간 알림
## 🏗️ 아키텍처 설계
### 시스템 구조
```
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ Headplane UI │───►│ Custom Backend │───►│ Proxmox Hosts │
│ (Frontend) │ │ (API Server) │ │ (100개 약국) │
└─────────────────┘ └──────────────────┘ └─────────────────┘
│ │ │
│ ▼ │
│ ┌──────────────────┐ │
└─────────────►│ Headscale DB │◄────────────┘
│ (Extended) │
└──────────────────┘
```
### 데이터베이스 확장 설계
#### 새로운 테이블: `pharmacy_info`
```sql
CREATE TABLE pharmacy_info (
id SERIAL PRIMARY KEY,
user_id VARCHAR(255) UNIQUE REFERENCES users(name),
pharmacy_name VARCHAR(255) NOT NULL,
business_number VARCHAR(20),
address TEXT,
phone VARCHAR(20),
manager_name VARCHAR(100),
proxmox_host VARCHAR(255),
proxmox_api_token TEXT,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
```
#### 새로운 테이블: `machine_specs`
```sql
CREATE TABLE machine_specs (
id SERIAL PRIMARY KEY,
machine_id BIGINT REFERENCES machines(id),
pharmacy_id INTEGER REFERENCES pharmacy_info(id),
cpu_model VARCHAR(255),
cpu_cores INTEGER,
ram_gb INTEGER,
storage_gb INTEGER,
gpu_model VARCHAR(255),
last_updated TIMESTAMP DEFAULT NOW()
);
```
#### 새로운 테이블: `monitoring_data`
```sql
CREATE TABLE monitoring_data (
id SERIAL PRIMARY KEY,
machine_id BIGINT REFERENCES machines(id),
cpu_usage DECIMAL(5,2),
memory_usage DECIMAL(5,2),
disk_usage DECIMAL(5,2),
cpu_temperature INTEGER,
network_rx_bytes BIGINT,
network_tx_bytes BIGINT,
vm_count INTEGER,
vm_running INTEGER,
collected_at TIMESTAMP DEFAULT NOW()
);
```
## 🛠️ 구현 방안
### 방안 1: Headplane 포크 + 직접 수정 (권장)
**장점**: 완전한 커스터마이징 가능
**단점**: 업스트림 업데이트 반영 어려움
**개발 기간**: 2-3주
#### 구현 단계:
1. **포크 및 개발환경 구성**
2. **데이터베이스 스키마 확장**
3. **API 엔드포인트 추가**
4. **UI 컴포넌트 커스터마이징**
5. **Proxmox API 연동 모듈 개발**
6. **모니터링 대시보드 구현**
### 방안 2: 별도 관리 시스템 + Headplane 연동
**장점**: 기존 Headplane 유지, 독립적 개발
**단점**: 시스템 분리로 복잡도 증가
**개발 기간**: 3-4주
### 방안 3: Headplane 플러그인 시스템 구축
**장점**: 모듈러 구조, 확장성 좋음
**단점**: 플러그인 시스템 자체 개발 필요
**개발 기간**: 4-5주
## 📋 세부 기능 명세
### 1. 약국 정보 관리
#### 1-1. 약국 등록/수정 화면
```
┌─────────────────────────────────────┐
│ 약국 정보 등록 │
├─────────────────────────────────────┤
│ 약국명: [서울중앙약국 ] │
│ 사업자번호: [123-45-67890 ] │
│ 주소: [서울시 강남구... ] │
│ 전화번호: [02-1234-5678 ] │
│ 담당자: [홍길동 ] │
│ Proxmox 호스트: [192.168.1.100 ] │
│ API 토큰: [********************] │
│ │
│ [저장] [취소] │
└─────────────────────────────────────┘
```
#### 1-2. 사용자 테이블 확장
```
User | 약국명 | 사업자번호 | 전화번호 | Role | Last Seen
pharmacy1| 서울중앙약국 | 123-45-67890 | 02-1234-5678 | Active | 5분 전
pharmacy2| 부산해운약국 | 987-65-43210 | 051-9876-5432| Active | 1시간 전
```
### 2. 머신 정보 확장
#### 2-1. 머신 상세 정보 화면
```
┌─────────────────────────────────────┐
│ 머신 상세 정보: pharmacy1-main │
├─────────────────────────────────────┤
│ 약국명: 서울중앙약국 │
│ IP 주소: 100.64.0.15 │
│ 마지막 접속: 2분 전 │
│ │
│ 하드웨어 정보: │
│ CPU: Intel i7-12700 (12코어) │
│ RAM: 32GB │
│ Storage: 1TB NVMe SSD │
│ │
│ 실시간 모니터링: │
│ CPU 사용률: ████████░░ 80% │
│ CPU 온도: 65°C │
│ 메모리 사용률: ██████░░░░ 60% │
│ 디스크 사용률: ███░░░░░░░ 30% │
│ │
│ VM 상태: │
│ 총 VM: 5개 | 실행중: 4개 | 정지: 1개│
└─────────────────────────────────────┘
```
### 3. 통합 대시보드
#### 3-1. 메인 대시보드 레이아웃
```
┌────────────────────────────────────────────────────────────┐
│ 팜큐 약국 관리 시스템 [사용자: admin] │
├────────────────────────────────────────────────────────────┤
│ 📊 전체 현황 │
│ ┌──────────┬──────────┬──────────┬──────────────────────┐ │
│ │총 약국 수 │온라인 │오프라인 │평균 CPU 온도 │ │
│ │ 100 │ 95 │ 5 │ 62°C │ │
│ └──────────┴──────────┴──────────┴──────────────────────┘ │
│ │
│ 🚨 알림 │
│ • 부산해운약국: CPU 온도 85°C (경고) │
│ • 대구중앙약국: 디스크 사용률 95% (위험) │
│ │
│ 📈 약국별 상태 │
│ ┌─────────────┬────────┬────────┬────────┬──────────────┐ │
│ │약국명 │상태 │CPU온도 │메모리 │마지막 접속 │ │
│ ├─────────────┼────────┼────────┼────────┼──────────────┤ │
│ │서울중앙약국 │🟢 온라인│ 65°C │ 80% │ 2분 전 │ │
│ │부산해운약국 │🟡 경고 │ 85°C │ 60% │ 5분 전 │ │
│ │대구중앙약국 │🔴 위험 │ 70°C │ 95% │ 10분 전 │ │
│ └─────────────┴────────┴────────┴────────┴──────────────┘ │
└────────────────────────────────────────────────────────────┘
```
### 4. Proxmox API 연동
#### 4-1. 데이터 수집 프로세스
```python
# proxmox_monitor.py
class ProxmoxMonitor:
def collect_host_info(self, proxmox_host, api_token):
"""Proxmox 호스트 정보 수집"""
# CPU 온도, 사용률
# 메모리 사용률
# 디스크 사용률
# VM 상태 및 개수
# 네트워크 트래픽
def collect_vm_info(self, proxmox_host, vm_id):
"""개별 VM 정보 수집"""
# VM 상태 (running, stopped)
# 리소스 할당량
# 실제 리소스 사용량
```
## 🔧 기술 스택
### Frontend (Headplane 기반)
- **React 19.1.0** + TypeScript
- **Tailwind CSS** (스타일링)
- **React Router** (라우팅)
- **Chart.js** (모니터링 차트)
- **Socket.io Client** (실시간 업데이트)
### Backend (확장 API 서버)
- **Node.js** + Express.js 또는 **Python FastAPI**
- **PostgreSQL** (확장된 데이터베이스)
- **Socket.io** (실시간 통신)
- **Proxmox API Client**
- **Cron Jobs** (주기적 데이터 수집)
### 모니터링 스택
- **Proxmox API** (호스트 정보)
- **Glances API** (시스템 메트릭)
- **InfluxDB** (시계열 데이터 저장)
- **Redis** (캐싱 및 실시간 데이터)
## 📅 개발 로드맵
### Phase 1: 기반 구축 (1주)
- [ ] Headplane 포크 및 개발환경 구성
- [ ] 데이터베이스 스키마 설계 및 확장
- [ ] 기본 약국 정보 CRUD API 구현
- [ ] UI 기본 레이아웃 수정
### Phase 2: 핵심 기능 (2주)
- [ ] 사용자 테이블에 약국 정보 연동
- [ ] 머신 정보 확장 및 UI 개선
- [ ] Proxmox API 연동 모듈 개발
- [ ] 기본 모니터링 대시보드 구현
### Phase 3: 고도화 (1주)
- [ ] 실시간 모니터링 구현
- [ ] 알림 시스템 구축
- [ ] 통합 대시보드 완성
- [ ] 성능 최적화 및 테스트
### Phase 4: 배포 및 운영 (1주)
- [ ] 프로덕션 환경 배포
- [ ] 100개 약국 데이터 마이그레이션
- [ ] 운영 매뉴얼 작성
- [ ] 사용자 교육 및 피드백 수집
## 💰 예상 비용 및 리소스
### 개발 리소스
- **개발 기간**: 4-5주
- **개발자**: 풀스택 개발자 1명
- **디자이너**: UI/UX 디자이너 0.5명
### 인프라 비용
- **확장 서버**: 추가 서버 인스턴스 (모니터링 데이터 처리)
- **데이터베이스**: PostgreSQL + InfluxDB
- **스토리지**: 시계열 데이터 저장용
## 🚀 시작하기
### 1단계: 요구사항 확정
- [ ] 약국 정보 필드 최종 확정
- [ ] 모니터링 지표 우선순위 결정
- [ ] UI/UX 디자인 컨셉 결정
### 2단계: 개발환경 구성
- [ ] Headplane 포크 및 클론
- [ ] 로컬 개발환경 세팅
- [ ] 테스트용 Proxmox 호스트 준비
### 3단계: 프로토타입 개발
- [ ] 약국 정보 등록 화면 구현
- [ ] 기본 모니터링 기능 구현
- [ ] 초기 버전 데모
## 📊 성공 지표
### 기능적 지표
- [ ] 100개 약국 정보 완전 등록
- [ ] 실시간 모니터링 정확도 95% 이상
- [ ] 알림 반응 시간 1분 이내
- [ ] 시스템 가용성 99.5% 이상
### 사용성 지표
- [ ] 관리자 업무 효율 50% 향상
- [ ] 장애 발견 시간 80% 단축
- [ ] 사용자 만족도 4.5/5.0 이상
## 📝 체크리스트
### 즉시 검토 필요사항
- [ ] 현재 Headscale DB 구조 분석
- [ ] Proxmox API 접근 권한 확인
- [ ] 약국별 네트워크 접근성 테스트
- [ ] 추가 하드웨어 리소스 요구사항 검토
### 장기적 고려사항
- [ ] 확장성: 약국 수 증가 대비
- [ ] 보안: 약국 데이터 보호
- [ ] 백업: 중요 데이터 백업 전략
- [ ] 업데이트: 원활한 시스템 업데이트 방안
---
**📅 작성일**: 2025-09-09
**👤 작성자**: Claude Code Assistant
**🏢 대상**: 팜큐(FARMQ) - 약국 IT 인프라 관리 시스템

View File

@@ -0,0 +1,320 @@
# 📋 Headplane UI 한글화 계획서
## 🔍 현재 상황 분석
### Headplane 국제화 현황
- ❌ i18n 라이브러리 미사용 (react-i18next, next-intl 등 없음)
- ❌ 모든 UI 텍스트가 컴포넌트에 하드코딩
- ❌ 언어 설정 옵션 없음
- ❌ GitHub에 다국어 지원 요청이나 논의 없음
### 기술 스택
- ✅ React 19.1.0 + TypeScript
- ✅ Vite 빌드 시스템
- ✅ Docker 멀티스테이지 빌드
- ✅ pnpm 패키지 매니저
### 현재 접속 정보
- **로컬**: http://localhost:3000/admin/
- **외부**: http://192.168.0.151:3000/admin/
- **API Key**: `8qRr1IB.tV95CmA0fLaCiGGIgBfeoN9daHceFkzI`
## 🎯 한글화 목표
### 우선순위 1: 핵심 UI 요소
- [ ] 상단 헤더 "Headplane" → "헤드플레인"
- [ ] 네비게이션 메뉴 (Machines → 장치 관리, Users → 사용자 관리, Settings → 설정 등)
- [ ] 로그인 페이지 텍스트 (API Key → API 키, Sign In → 로그인)
- [ ] 메인 대시보드 라벨들
### 우선순위 2: 상세 페이지
- [ ] 테이블 헤더 (Name → 이름, IP Address → IP 주소, Status → 상태 등)
- [ ] 버튼 텍스트 (Create → 생성, Delete → 삭제, Edit → 편집 등)
- [ ] 폼 라벨 및 플레이스홀더
### 우선순위 3: 메시지 및 알림
- [ ] 에러 메시지
- [ ] 성공/확인 메시지
- [ ] 도움말 텍스트
## 🛠️ 구현 방안별 비교
### 방안 1: 환경 변수 기반 (권장 - 단기)
**난이도**: ⭐⭐
**소요 시간**: 2-3시간
**장점**:
- 빠른 구현 가능
- 기존 코드 최소 변경
- Docker 환경변수로 쉽게 제어
- 현재 환경에서 즉시 적용 가능
**단점**:
- 제한적인 유연성
- 텍스트별 개별 환경변수 필요
**구현 방법**:
```typescript
// app/config/texts.ts 생성
export const UI_TEXTS = {
app_title: process.env.HEADPLANE_TITLE || 'Headplane',
nav: {
machines: process.env.HEADPLANE_NAV_MACHINES || 'Machines',
users: process.env.HEADPLANE_NAV_USERS || 'Users',
settings: process.env.HEADPLANE_NAV_SETTINGS || 'Settings'
}
};
```
### 방안 2: react-i18next 도입 (권장 - 장기)
**난이도**: ⭐⭐⭐⭐
**소요 시간**: 1-2일
**장점**:
- 완전한 국제화 지원
- 언어 전환 기능
- 표준적인 접근 방식
- 향후 확장성 좋음
**단점**:
- 모든 컴포넌트 수정 필요
- 라이브러리 의존성 추가
- 상당한 코드 변경 필요
**구현 구조**:
```
public/locales/
├── en/
│ └── translation.json
└── ko/
└── translation.json
```
### 방안 3: 브라우저 확장프로그램 (즉시 적용)
**난이도**: ⭐
**소요 시간**: 30분
**장점**:
- 즉시 적용 가능
- 코드 수정 불필요
- 테스트 목적으로 최적
**단점**:
- 개인 환경에서만 동작
- 동적 콘텐츠 한계
- 일시적 해결책
### 방안 4: 포크 후 하드코딩 수정
**난이도**: ⭐⭐⭐
**소요 시간**: 4-6시간
**장점**:
- 완전한 제어
- 즉시 적용 가능
**단점**:
- 업스트림 업데이트 어려움
- 유지보수 부담
## 📅 단계별 실행 계획
### Phase 1: 즉시 적용 (오늘)
#### 1-1. 브라우저 확장프로그램 제작
- [ ] Tampermonkey 스크립트 작성
- [ ] 핵심 UI 요소 번역 매핑
- [ ] 테스트 및 검증
#### 1-2. 브라우저 확장 스크립트 예시
```javascript
// ==UserScript==
// @name Headplane 한글화
// @match http://192.168.0.151:3000/*
// @match http://localhost:3000/*
// ==/UserScript==
const translations = {
'Headplane': '헤드플레인',
'Machines': '장치 관리',
'Users': '사용자 관리',
'Settings': '설정',
'API Key': 'API 키',
'Sign In': '로그인',
'Welcome to Headplane': '헤드플레인에 오신 것을 환영합니다',
'Enter an API key to authenticate': 'API 키를 입력하여 인증해주세요'
};
function translatePage() {
document.querySelectorAll('*').forEach(element => {
if (element.children.length === 0 && element.textContent.trim()) {
const text = element.textContent.trim();
if (translations[text]) {
element.textContent = translations[text];
}
}
});
}
// 페이지 로드 시 번역 적용
translatePage();
// 동적 콘텐츠 변경 감지 및 번역 적용
new MutationObserver(translatePage).observe(document.body, {
childList: true,
subtree: true
});
```
### Phase 2: 임시 해결책 (1-2일 내)
#### 2-1. 환경 변수 기반 커스터마이징
- [ ] 텍스트 상수 파일 생성 (`app/config/texts.ts`)
- [ ] 주요 컴포넌트에 텍스트 상수 적용
- [ ] Docker Compose 환경변수 설정
- [ ] 커스텀 Docker 이미지 빌드
- [ ] 컨테이너 재배포 및 테스트
#### 2-2. Docker 환경변수 설정 예시
```yaml
# docker-compose.yml에 추가
services:
headplane:
environment:
- TZ=Asia/Seoul
- HEADSCALE_URL=http://headscale:8080
- HEADSCALE_API_KEY=${HEADSCALE_API_KEY}
# 한글화 환경변수
- HEADPLANE_TITLE=헤드플레인
- HEADPLANE_NAV_MACHINES=장치 관리
- HEADPLANE_NAV_USERS=사용자 관리
- HEADPLANE_NAV_SETTINGS=설정
- HEADPLANE_LOGIN_TITLE=로그인
- HEADPLANE_API_KEY_LABEL=API 키
```
### Phase 3: 완전한 해결책 (1주일 내)
#### 3-1. react-i18next 라이브러리 도입
- [ ] GitHub에서 tale/headplane 포크
- [ ] 로컬 개발 환경 구성
- [ ] react-i18next 라이브러리 설치
- [ ] i18n 설정 및 번역 파일 구조 생성
- [ ] 주요 컴포넌트 국제화 적용
- [ ] 언어 전환 UI 추가
- [ ] 커스텀 Docker 이미지 빌드 및 배포
#### 3-2. 번역 파일 구조 예시
```json
// public/locales/ko/translation.json
{
"app": {
"title": "헤드플레인",
"description": "헤드스케일 관리 웹 인터페이스"
},
"navigation": {
"machines": "장치 관리",
"users": "사용자 관리",
"settings": "설정",
"accessControl": "접근 제어",
"dns": "DNS"
},
"login": {
"title": "헤드플레인에 오신 것을 환영합니다",
"description": "API 키를 입력하여 헤드플레인에 인증해주세요.",
"apiKeyLabel": "API 키",
"apiKeyPlaceholder": "API 키를 입력해주세요",
"signInButton": "로그인",
"helpText": "터미널에서 'headscale apikeys create' 명령을 실행하여 API 키를 생성할 수 있습니다."
},
"machines": {
"title": "장치 관리",
"description": "테일넷에 연결된 장치들을 관리합니다.",
"tableHeaders": {
"name": "이름",
"addresses": "주소",
"version": "버전",
"lastSeen": "마지막 접속"
}
},
"users": {
"title": "사용자 관리",
"description": "헤드스케일 사용자들을 관리합니다."
}
}
```
## 🎯 권장 실행 방안
### 즉시 실행: 방안 3 (브라우저 확장프로그램)
현재 사용 중인 환경에서 바로 한글 UI를 확인할 수 있도록 **Tampermonkey 스크립트**부터 시작
### 단기 목표: 방안 1 (환경 변수 기반)
Docker 환경변수를 통한 텍스트 커스터마이징으로 **안정적인 한글화** 구현
### 장기 목표: 방안 2 (react-i18next 도입)
완전한 국제화 지원을 위한 **표준적인 다국어 지원** 구현
## 📊 예상 번역 범위
### 핵심 번역 대상 (총 약 50-80개 텍스트)
#### 공통 UI 요소
- Headplane → 헤드플레인
- API Key → API 키
- Sign In → 로그인
- Settings → 설정
- Profile → 프로필
- Logout → 로그아웃
#### 네비게이션 메뉴
- Machines → 장치 관리
- Users → 사용자 관리
- Access Control → 접근 제어
- DNS → DNS 설정
#### 테이블 및 폼 요소
- Name → 이름
- IP Address → IP 주소
- Status → 상태
- Version → 버전
- Last Seen → 마지막 접속
- Create → 생성
- Delete → 삭제
- Edit → 편집
## 🚀 시작하기
### 1단계: 브라우저 확장프로그램 설치
1. Chrome/Edge에서 Tampermonkey 확장프로그램 설치
2. 위의 스크립트 코드를 새 스크립트로 생성
3. http://192.168.0.151:3000/admin/ 접속하여 한글화 확인
### 2단계: 환경변수 기반 구현 준비
1. tale/headplane 저장소 포크
2. 로컬 개발환경 구성
3. 텍스트 상수 파일 생성 및 적용
## 📝 진행 상황 체크리스트
### Phase 1: 브라우저 확장프로그램
- [ ] Tampermonkey 설치 및 스크립트 작성
- [ ] 로그인 페이지 한글화 테스트
- [ ] 메인 대시보드 한글화 테스트
- [ ] 번역 품질 검증
### Phase 2: 환경변수 기반
- [ ] 프로젝트 포크 및 클론
- [ ] 개발 환경 구성
- [ ] 텍스트 상수 파일 구조 설계
- [ ] 주요 컴포넌트 수정
- [ ] Docker 이미지 빌드 테스트
- [ ] 프로덕션 배포
### Phase 3: react-i18next 도입
- [ ] i18n 라이브러리 설치 및 설정
- [ ] 번역 파일 구조 생성
- [ ] 컴포넌트별 국제화 적용
- [ ] 언어 전환 UI 구현
- [ ] 전체 테스트 및 검증
## 🔗 관련 링크
- **Headplane GitHub**: https://github.com/tale/headplane
- **React i18next 문서**: https://react.i18next.com/
- **현재 Headplane UI**: http://192.168.0.151:3000/admin/
## 📅 생성일: 2025-09-09
## 👤 작성자: Claude Code Assistant

370
INSTALLATION_GUIDE.md Normal file
View File

@@ -0,0 +1,370 @@
# 🚀 Headscale + Headplane 완전 설치 가이드
## 📋 프로젝트 개요
- **목표**: Tailscale을 완전히 대체하는 자체 호스팅 VPN 솔루션 구축
- **기술 스택**: Docker, Docker Compose, Headscale, Headplane
- **환경**: Ubuntu 24.04 LTS, Docker 27.2.0
## 🛠️ 사전 요구사항
- Docker 및 Docker Compose 설치
- 8070, 3000 포트 사용 가능
- root 권한 또는 sudo 권한
## 📁 프로젝트 구조
```
headscale-setup/
├── docker-compose.yml # Docker Compose 설정
├── .env # 환경변수 (API 키 포함)
├── .env.example # 환경변수 템플릿
├── config/
│ └── config.yaml # Headscale 최신 설정
├── headplane-config/
│ └── config.yaml # Headplane 설정
├── data/ # SQLite 데이터베이스 (자동 생성)
├── run/ # 런타임 파일 (자동 생성)
└── start.sh # 자동 설치 스크립트
```
## 🔧 상세 설치 과정
### 1단계: 환경 준비
```bash
# 작업 디렉토리 생성
mkdir -p headscale-setup
cd headscale-setup
# 필요한 하위 디렉토리 생성
mkdir -p config data run headplane-config
```
### 2단계: Docker Compose 설정
#### docker-compose.yml 작성
```yaml
version: '3.8'
services:
headscale:
image: headscale/headscale:latest
container_name: headscale
restart: unless-stopped
command: serve
environment:
- TZ=Asia/Seoul
volumes:
- ./config:/etc/headscale
- ./data:/var/lib/headscale
- ./run:/var/run/headscale
ports:
- "8070:8080" # 외부:내부 (포트 충돌 방지)
- "9090:9090" # 메트릭스
networks:
- headscale-net
healthcheck:
test: ["CMD-SHELL", "nc -z localhost 8080 || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
headplane:
image: ghcr.io/tale/headplane:latest
container_name: headplane
restart: unless-stopped
environment:
- TZ=Asia/Seoul
- HEADSCALE_URL=http://headscale:8080
- HEADSCALE_API_KEY=${HEADSCALE_API_KEY}
volumes:
- ./headplane-config:/etc/headplane
ports:
- "3000:3000"
depends_on:
- headscale
networks:
- headscale-net
networks:
headscale-net:
driver: bridge
```
### 3단계: Headscale 설정 파일
#### config/config.yaml (최신 형식)
```yaml
---
server_url: http://localhost:8070
listen_addr: 0.0.0.0:8080
metrics_listen_addr: 0.0.0.0:9090
private_key_path: /var/lib/headscale/private.key
noise:
private_key_path: /var/lib/headscale/noise_private.key
# 최신 형식: prefixes 사용
prefixes:
v4: 100.64.0.0/10
v6: fd7a:115c:a1e0::/48
derp:
server:
enabled: false
urls:
- https://controlplane.tailscale.com/derpmap/default
disable_check_updates: false
ephemeral_node_inactivity_timeout: 30m
database:
type: sqlite3
sqlite:
path: /var/lib/headscale/db.sqlite
# 최신 DNS 설정 형식
dns:
override_local_dns: true
nameservers:
global:
- 1.1.1.1
- 8.8.8.8
search_domains: []
magic_dns: true
base_domain: headscale.local
# 최신 정책 설정
policy:
path: ""
log:
format: text
level: info
unix_socket: /var/run/headscale/headscale.sock
unix_socket_permission: "0770"
logtail:
enabled: false
randomize_client_port: false
# 간소화된 OIDC 설정
oidc:
only_start_if_oidc_is_available: false
issuer: ""
client_id: ""
client_secret: ""
scope: ["openid", "profile", "email"]
extra_params: {}
allowed_domains: []
allowed_users: []
```
### 4단계: Headplane 설정 파일
#### headplane-config/config.yaml
```yaml
headscale:
url: http://headscale:8080
api_key: YOUR_API_KEY_HERE # 자동 생성됨
config_strict: false
server:
host: 0.0.0.0
port: 3000
cookie_secret: headscale-ui-secret-32-chars-key # 정확히 32자
cookie_secure: false
settings:
title: "Headscale 관리 패널"
favicon_url: ""
custom_css: ""
```
**중요 설정사항:**
- `cookie_secret`: 정확히 32자여야 함 (설정 검증 오류 방지)
- `config_strict: false`: 설정 검증 완화
- `api_key`: 설치 시 자동 생성되어 교체됨
- 설정 파일은 환경변수보다 우선순위가 높음
### 5단계: 환경변수 설정
#### .env.example
```bash
# Headscale API Key (설치 후 자동 생성됨)
HEADSCALE_API_KEY=your_api_key_here
# 서버 설정
SERVER_URL=http://localhost:8070
LISTEN_ADDR=0.0.0.0:8080
# 데이터베이스 (SQLite 기본)
DB_TYPE=sqlite3
DB_PATH=/var/lib/headscale/db.sqlite
# Magic DNS
MAGIC_DNS=true
BASE_DOMAIN=headscale.local
# 네트워크 설정
IP_PREFIXES=100.64.0.0/10
# 시간대
TZ=Asia/Seoul
```
### 5단계: 설치 실행
#### 환경변수 파일 복사
```bash
cp .env.example .env
```
#### 자동 설치 스크립트 실행
```bash
chmod +x start.sh
./start.sh
```
#### 또는 수동 설치
```bash
# 1. Headscale 시작
docker-compose up -d headscale
# 2. API 키 생성 (약 30초 대기 후)
sleep 30
API_KEY=$(docker-compose exec -T headscale headscale apikeys create)
echo "Generated API Key: $API_KEY"
# 3. .env 파일에 API 키 입력
sed -i "s/HEADSCALE_API_KEY=your_api_key_here/HEADSCALE_API_KEY=$API_KEY/" .env
# 4. Headplane 시작
docker-compose up -d headplane
```
## 🎯 중요한 설정 변경사항
### 포트 충돌 해결
- **기존**: 8080:8080 (충돌 발생)
- **변경**: 8070:8080 (외부 8070 포트 사용)
### 최신 Headscale 설정 형식 적용
- `ip_prefixes``prefixes` (v4/v6 분리)
- `dns_config``dns` (구조 변경)
- `acl_policy_path``policy.path`
- OIDC `strip_email_domain` 제거
### Docker 헬스체크 개선
- `curl``nc` (netcat 사용)
- Headplane 의존성 조건 완화
## 🔍 설치 확인 및 검증
### 1. 컨테이너 상태 확인
```bash
docker-compose ps
```
### 2. Headscale API 테스트
```bash
curl -s http://localhost:8070/health
# 응답: {"status":"pass"}
```
### 3. 로그 확인
```bash
docker-compose logs headscale
docker-compose logs headplane
```
### 4. 사용자 생성
```bash
docker-compose exec headscale headscale users create myuser
```
### 5. 사용자 목록 확인
```bash
docker-compose exec headscale headscale users list
```
### 6. Pre-auth 키 생성
```bash
docker-compose exec headscale headscale preauthkeys create --user 1 --reusable --expiration 24h
```
## 🚨 문제 해결
### 포트 충돌 문제
```bash
# 8080 포트 사용 중인 프로세스 확인
lsof -i :8080
# 포트를 8070으로 변경하여 해결
```
### Headplane 설정 파일 문제
```bash
# cookie_secret 길이 오류 시 (정확히 32자 필요)
echo "headscale-ui-secret-32-chars-key" | wc -c # 32자 확인
# 설정 파일 재검증
docker-compose logs headplane --tail 10
# 컨테이너 재시작으로 설정 재로드
docker-compose restart headplane
```
### 헬스체크 실패
```bash
# wget 대신 netcat 사용
# CMD-SHELL을 사용하여 호환성 개선
```
## 📊 최종 설치 결과
### 접속 정보
- **Headscale API**: http://localhost:8070
- **Headplane UI**: http://localhost:3000/admin/ (로그인 페이지)
- **외부 접속**: http://192.168.0.151:3000/admin/ (네트워크 설정에 따라)
- **메트릭스**: http://localhost:9090
### 생성된 정보
- **사용자**: myuser (ID: 1)
- **API 키**: 8qRr1IB.tV95CmA0fLaCiGGIgBfeoN9daHceFkzI (자동 생성됨)
- **Pre-auth 키**: fc4f2dc55ee00c5352823d156129b9ce2df4db02f1d76a21 (24시간 유효, 재사용 가능)
### 🔑 Headplane 로그인
1. 브라우저에서 http://localhost:3000/admin/ 또는 http://192.168.0.151:3000/admin/ 접속
2. **API Key** 필드에 입력: `8qRr1IB.tV95CmA0fLaCiGGIgBfeoN9daHceFkzI`
3. **Sign In** 버튼 클릭
### 네트워크 설정
- **IPv4**: 100.64.0.0/10
- **IPv6**: fd7a:115c:a1e0::/48
- **Magic DNS**: headscale.local
## 🔄 Git 관리
### 브랜치 전략
```bash
# 기능 브랜치 생성
git checkout -b feature/working-headscale-setup
# 변경사항 커밋
git add .
git commit -m "🎉 Working Headscale Setup Complete"
# 원격 저장소 푸시
git push -u origin feature/working-headscale-setup
```
## 📈 다음 단계
1. Tailscale 클라이언트 연결 테스트
2. HTTPS/TLS 인증서 구성
3. Headplane 한글화 작업
4. ACL 보안 규칙 설정
5. 백업 및 모니터링 구성
## 🎉 결론
Headscale과 Headplane을 사용한 완전한 자체 호스팅 VPN 솔루션이 성공적으로 구축되었습니다. 이제 Tailscale을 완전히 대체할 수 있는 환경이 준비되었습니다.

View File

@@ -0,0 +1,458 @@
# Proxmox VNC WebSocket 직접 연결 해결 방안
## 🎯 목표
Flask 웹 애플리케이션에서 Proxmox VNC WebSocket에 **직접 연결**하여 브라우저 내에서 seamless한 VNC 경험 제공
## 🔍 현재 문제 상황
### 1. 문제 증상
- Flask에서 VNC 버튼 클릭 시 `"Unsupported"... is not valid JSON` 오류 발생
- noVNC 클라이언트가 Proxmox WebSocket 서버에 연결 실패
- HTTP 501 응답 (nginx에서 WebSocket 요청 거부)
### 2. 근본 원인 분석
```
[브라우저] --> [Flask:5002] --> [Proxmox:443] --> [실제 WebSocket:5900]
❌ CORS 이슈 ❌ nginx 차단 ✅ VM VNC 서버
```
**주요 차단 요인:**
- Proxmox nginx가 외부 WebSocket 연결 차단
- CORS (Cross-Origin Resource Sharing) 정책
- SSL/TLS 인증서 불일치
- WebSocket Upgrade 헤더 처리 문제
## 💡 해결 방안 (3가지 접근법)
## 방안 1: Flask WebSocket 프록시 서버 (권장)
### 개념도
```
[브라우저] <--WebSocket--> [Flask Proxy] <--WebSocket--> [Proxmox VNC]
ws://localhost:5002/ws/vnc/vm123 wss://pve7:443/api2/...
```
### 구현 방법
#### 1.1 Flask-SocketIO 기반 프록시
```python
# requirements.txt에 추가
flask-socketio==5.3.6
python-socketio==5.8.0
websockets==11.0.3
# app.py 수정
from flask_socketio import SocketIO, emit
import websockets
import asyncio
import ssl
socketio = SocketIO(app, cors_allowed_origins="*")
@socketio.on('vnc_connect')
def handle_vnc_connect(data):
"""VNC WebSocket 프록시 연결"""
vm_id = data['vm_id']
node = data['node']
# Proxmox VNC 티켓 생성
client = ProxmoxClient(PROXMOX_HOST, PROXMOX_USERNAME, PROXMOX_PASSWORD)
vnc_data = client.get_vnc_ticket(node, vm_id)
if vnc_data:
# 백그라운드에서 프록시 실행
socketio.start_background_task(proxy_vnc_connection,
request.sid,
vnc_data['websocket_url'])
def proxy_vnc_connection(session_id, proxmox_ws_url):
"""Proxmox VNC WebSocket과 브라우저 간 프록시"""
asyncio.run(proxy_websocket(session_id, proxmox_ws_url))
async def proxy_websocket(session_id, proxmox_ws_url):
"""비동기 WebSocket 프록시"""
try:
# SSL 컨텍스트 설정 (인증서 검증 무시)
ssl_context = ssl.create_default_context()
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE
# Proxmox WebSocket 연결
async with websockets.connect(
proxmox_ws_url,
ssl=ssl_context,
extra_headers={'Origin': f'https://{PROXMOX_HOST}'}
) as proxmox_ws:
@socketio.on('vnc_data', namespace=f'/vnc/{session_id}')
def forward_to_proxmox(data):
asyncio.create_task(proxmox_ws.send(data))
# Proxmox → 브라우저
async for message from proxmox_ws:
socketio.emit('vnc_data', message,
room=session_id,
namespace=f'/vnc/{session_id}')
except Exception as e:
socketio.emit('vnc_error', {'error': str(e)}, room=session_id)
```
#### 1.2 noVNC 클라이언트 수정
```html
<!-- templates/vnc_console.html -->
<script src="/static/js/socket.io.min.js"></script>
<script>
// Flask-SocketIO 연결
const socket = io();
// VNC 연결 설정
socket.emit('vnc_connect', {
vm_id: {{ vmid }},
node: '{{ node }}'
});
// noVNC WebSocket URL을 Flask 프록시로 변경
const websocketUrl = `ws://localhost:5002/socket.io/`;
// RFB 연결
const rfb = new RFB(canvas, websocketUrl, {
wsProtocols: ['base64']
});
// 데이터 중계 설정
socket.on('vnc_data', function(data) {
// Proxmox에서 온 데이터를 noVNC로 전달
rfb._sock.rQshiftBytes(data.length);
});
rfb.addEventListener('connect', function() {
// noVNC에서 보낸 데이터를 Proxmox로 전달
rfb._sock.on('message', function(data) {
socket.emit('vnc_data', data);
});
});
</script>
```
### 장점
- ✅ 완전한 제어 가능
- ✅ CORS 문제 해결
- ✅ Flask 애플리케이션 내 통합
- ✅ 추가 인증/로깅 가능
### 단점
- ❌ 구현 복잡성 높음
- ❌ 성능 오버헤드 존재
- ❌ WebSocket 프로토콜 깊이 이해 필요
---
## 방안 2: Nginx 리버스 프록시 설정
### 개념도
```
[브라우저] --> [Nginx Proxy] --> [Proxmox WebSocket]
ws://proxy/vnc/vm123 wss://pve7:443/api2/...
```
### 구현 방법
#### 2.1 별도 Nginx 프록시 서버 설정
```nginx
# /etc/nginx/sites-available/proxmox-vnc-proxy
server {
listen 8080;
server_name localhost;
# WebSocket 프록시 설정
location /vnc/ {
# URL 경로에서 VM 정보 추출: /vnc/pve7/100
# pve7 = node, 100 = vmid
rewrite ^/vnc/([^/]+)/(\d+)$ /api2/json/nodes/$1/qemu/$2/vncwebsocket break;
proxy_pass https://pve7.0bin.in:443;
proxy_http_version 1.1;
# WebSocket 업그레이드 헤더
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host pve7.0bin.in;
proxy_set_header Origin https://pve7.0bin.in;
# 인증 헤더 전달 (VNC 티켓 포함)
proxy_set_header Authorization $http_authorization;
# SSL 설정
proxy_ssl_verify off;
proxy_ssl_server_name on;
# 타임아웃 설정 (VNC 세션용)
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 86400s; # 24시간
# 버퍼링 비활성화 (실시간 데이터용)
proxy_buffering off;
proxy_cache off;
}
# CORS 헤더 추가
location / {
add_header Access-Control-Allow-Origin *;
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS";
add_header Access-Control-Allow-Headers "Origin, Content-Type, Accept, Authorization";
if ($request_method = 'OPTIONS') {
return 204;
}
}
}
```
#### 2.2 Docker Compose로 Nginx 프록시 실행
```yaml
# docker-compose.vnc-proxy.yml
version: '3.8'
services:
vnc-proxy:
image: nginx:alpine
ports:
- "8080:80"
volumes:
- ./nginx-vnc-proxy.conf:/etc/nginx/conf.d/default.conf
restart: unless-stopped
networks:
- farmq-network
networks:
farmq-network:
external: true
```
#### 2.3 Flask 코드 수정
```python
# VNC WebSocket URL을 프록시 서버로 변경
@app.route('/api/vm/vnc', methods=['POST'])
def api_vm_vnc():
# ... (기존 코드)
# 프록시 서버를 통한 WebSocket URL 생성
proxy_ws_url = f"ws://localhost:8080/vnc/{node}/{vmid}?port={vnc_data['port']}&vncticket={vnc_data['ticket']}"
vnc_sessions[session_id] = {
'websocket_url': proxy_ws_url,
# ...
}
```
### 장점
- ✅ Flask 코드 변경 최소화
- ✅ 높은 성능 (Nginx 네이티브)
- ✅ 확장성 우수
- ✅ SSL 종료 지원
### 단점
- ❌ 추가 인프라 필요
- ❌ Nginx 설정 복잡성
- ❌ 디버깅 어려움
---
## 방안 3: Proxmox 서버 설정 변경
### 개념도
```
[브라우저] ----직접연결----> [Proxmox WebSocket (수정됨)]
wss://pve7:443/api2/json/...
```
### 구현 방법
#### 3.1 Proxmox Nginx 설정 수정
```bash
# Proxmox 서버에 SSH 접속
ssh root@pve7.0bin.in
# nginx 설정 백업
cp /etc/nginx/sites-available/proxmox /etc/nginx/sites-available/proxmox.backup
# nginx 설정 편집
nano /etc/nginx/sites-available/proxmox
```
```nginx
# /etc/nginx/sites-available/proxmox 수정
server {
listen 443 ssl http2;
server_name pve7.0bin.in;
# ... 기존 설정 유지 ...
# CORS 헤더 추가 (VNC WebSocket용)
location /api2/json/nodes/*/qemu/*/vncwebsocket {
# CORS 헤더
add_header Access-Control-Allow-Origin "http://localhost:5002";
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS";
add_header Access-Control-Allow-Headers "Origin, Content-Type, Accept, Authorization";
add_header Access-Control-Allow-Credentials true;
# Preflight 요청 처리
if ($request_method = 'OPTIONS') {
return 204;
}
# 기존 proxy_pass 설정 유지
proxy_pass http://localhost:8006;
proxy_http_version 1.1;
# WebSocket 업그레이드 헤더
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $http_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;
# 타임아웃 설정
proxy_connect_timeout 60s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
# 버퍼링 비활성화
proxy_buffering off;
proxy_cache off;
}
# ... 나머지 설정 ...
}
```
#### 3.2 Proxmox nginx 재시작
```bash
# 설정 검증
nginx -t
# nginx 재시작
systemctl restart nginx
# 방화벽 확인 (필요시)
iptables -L -n | grep 8006
```
#### 3.3 Flask noVNC 클라이언트 수정
```javascript
// 직접 Proxmox WebSocket 연결
const websocketUrl = '{{ websocket_url }}'; // wss://pve7.0bin.in:443/api2/json/...
// CORS 설정으로 직접 연결 가능
const rfb = new RFB(canvas, websocketUrl, {
credentials: { password: '' }, // VNC 티켓으로 인증
shared: true
});
```
### 장점
- ✅ 가장 직접적인 해결책
- ✅ 최고 성능
- ✅ 추가 인프라 불필요
### 단점
- ❌ Proxmox 서버 수정 필요
- ❌ 업데이트 시 설정 초기화 위험
- ❌ 보안 정책 변경
---
## 🛠️ 구현 우선순위 및 권장사항
### 1단계: Flask WebSocket 프록시 (권장)
**기간**: 2-3일
**난이도**: 중상
```bash
# 구현 단계
1. Flask-SocketIO 설치 및 설정
2. VNC WebSocket 프록시 함수 구현
3. noVNC 클라이언트 수정
4. 테스트 및 디버깅
```
### 2단계: Nginx 프록시 (백업 방안)
**기간**: 1-2일
**난이도**: 중
```bash
# 구현 단계
1. nginx 프록시 설정 작성
2. Docker Compose로 프록시 실행
3. Flask WebSocket URL 수정
4. 연결 테스트
```
### 3단계: Proxmox 설정 변경 (최후 수단)
**기간**: 1일
**난이도**: 하 (위험도: 상)
```bash
# 주의사항
- Proxmox 서버 직접 수정
- 백업 필수
- 업데이트 시 재설정 필요
```
## 🔧 즉시 구현 가능한 임시 해결책
현재 리다이렉트 방식을 개선하여 사용성 향상:
```python
# app.py - 자동 팝업 + 인증 정보 전달
@app.route('/vnc/<session_id>')
def vnc_console(session_id):
# Proxmox 자동 로그인 URL 생성
client = ProxmoxClient(PROXMOX_HOST, PROXMOX_USERNAME, PROXMOX_PASSWORD)
login_ticket = client.ticket
proxmox_url = f"https://{PROXMOX_HOST}:443/?console=kvm&vmid={vmid}&node={node}&PVEAuthCookie={login_ticket}"
return render_template('vnc_auto_redirect.html',
proxmox_url=proxmox_url,
auto_open=True)
```
## 📋 구현 체크리스트
### Flask WebSocket 프록시 구현
- [ ] Flask-SocketIO 설치 및 기본 설정
- [ ] Proxmox WebSocket 연결 테스트
- [ ] 비동기 프록시 함수 구현
- [ ] noVNC 클라이언트 SocketIO 연동
- [ ] 에러 처리 및 재연결 로직
- [ ] 성능 테스트 및 최적화
### 테스트 시나리오
- [ ] 단일 VM VNC 연결 테스트
- [ ] 동시 다중 VNC 연결 테스트
- [ ] 네트워크 끊김 시 재연결 테스트
- [ ] 브라우저별 호환성 테스트
- [ ] 모바일 디바이스 테스트
### 운영 준비
- [ ] 로깅 및 모니터링 설정
- [ ] 에러 알림 시스템 구축
- [ ] 백업 연결 방법 준비
- [ ] 사용자 매뉴얼 작성
---
## 💡 결론
**권장 접근법**: Flask WebSocket 프록시 (방안 1)
1. **완전한 제어**: Flask 애플리케이션 내에서 모든 것을 제어
2. **확장성**: 향후 추가 기능 (녹화, 공유 등) 쉽게 추가 가능
3. **안정성**: Proxmox 서버 수정 없이 구현
4. **통합성**: 기존 Flask 인증/권한 시스템과 완벽 통합
**다음 단계**: Flask-SocketIO 기반 WebSocket 프록시 구현 시작

247
QUICK_INSTALL_GUIDE.md Normal file
View File

@@ -0,0 +1,247 @@
# 🚀 팜큐 Headscale 원클릭 설치 가이드
새로운 리눅스 서버를 팜큐 네트워크에 **한 번의 명령**으로 등록하는 방법입니다.
## 🎯 원클릭 설치 명령어
### 방법 1: curl 사용 (권장)
```bash
# 일반 사용자 계정에서
curl -fsSL https://git.0bin.in/thug0bin/headscale-tailscale-replacement/raw/branch/feature/working-headscale-setup/quick-install.sh | sudo bash
# root 계정에서 (Proxmox, Docker 컨테이너 등)
curl -fsSL https://git.0bin.in/thug0bin/headscale-tailscale-replacement/raw/branch/feature/working-headscale-setup/quick-install.sh | bash
```
### 방법 2: wget 사용
```bash
# 일반 사용자 계정에서
wget -qO- https://git.0bin.in/thug0bin/headscale-tailscale-replacement/raw/branch/feature/working-headscale-setup/quick-install.sh | sudo bash
# root 계정에서
wget -qO- https://git.0bin.in/thug0bin/headscale-tailscale-replacement/raw/branch/feature/working-headscale-setup/quick-install.sh | bash
```
### 방법 3: 스크립트 다운로드 후 실행
```bash
wget https://git.0bin.in/thug0bin/headscale-tailscale-replacement/raw/branch/feature/working-headscale-setup/quick-install.sh
chmod +x quick-install.sh
sudo ./quick-install.sh
```
### 방법 4: 기존 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
# root 계정에서 (Proxmox 등)
curl -fsSL https://git.0bin.in/thug0bin/headscale-tailscale-replacement/raw/branch/feature/working-headscale-setup/quick-install.sh | bash -s -- --force
```
### 방법 5: 스크립트 옵션 확인
```bash
# 사용 가능한 옵션 보기
curl -fsSL https://git.0bin.in/thug0bin/headscale-tailscale-replacement/raw/branch/feature/working-headscale-setup/quick-install.sh | bash -s -- --help
```
## ✨ 자동으로 수행되는 작업
### 1. 🔍 시스템 분석
- 운영체제 자동 감지 (Ubuntu, Debian, CentOS, RHEL, Rocky, Fedora, Arch)
- 시스템 요구사항 확인
- 네트워크 연결 상태 점검
### 2. 📦 Tailscale 설치
- **Ubuntu/Debian**: APT 리포지토리 추가 및 설치
- **CentOS/RHEL/Rocky**: YUM/DNF 리포지토리 추가 및 설치
- **Fedora**: DNF 패키지 관리자로 설치
- **Arch Linux**: Pacman으로 설치
- **기타 배포판**: Universal Binary 직접 다운로드
### 3. 🔧 서비스 설정
- systemd 서비스 자동 등록
- tailscaled 데몬 시작 및 활성화
- 서비스 상태 확인 및 오류 처리
### 4. 🌐 Headscale 등록
- Pre-auth Key를 사용한 자동 등록
- 팜큐 Headscale 서버 (`https://head.0bin.in`)에 연결
- DNS 및 라우팅 설정 자동 적용
### 5. 🔒 방화벽 설정
- UFW (Ubuntu/Debian) 자동 설정
- firewalld (CentOS/RHEL/Fedora) 자동 설정
- Tailscale 포트 (41641/UDP) 자동 허용
### 6. ✅ 연결 검증
- IP 주소 할당 확인
- 네트워크 연결 테스트
- 다른 노드와의 통신 확인
## 🖥️ 지원하는 운영체제
| OS | 버전 | 설치 방법 | 상태 |
|---|---|---|---|
| **Ubuntu** | 18.04+ | APT Repository | ✅ |
| **Debian** | 10+ | APT Repository | ✅ |
| **CentOS** | 7, 8, 9 | YUM/DNF Repository | ✅ |
| **RHEL** | 7, 8, 9 | YUM/DNF Repository | ✅ |
| **Rocky Linux** | 8, 9 | DNF Repository | ✅ |
| **AlmaLinux** | 8, 9 | DNF Repository | ✅ |
| **Fedora** | 35+ | DNF Package | ✅ |
| **Arch Linux** | Rolling | Pacman Package | ✅ |
| **기타 배포판** | - | Universal Binary | ⚠️ |
## 📋 설치 예시 출력
```bash
$ curl -fsSL https://git.0bin.in/.../quick-install.sh | sudo bash
============================================
팜큐(FARMQ) Headscale 원클릭 설치
============================================
📋 감지된 OS: ubuntu 22.04 (jammy)
🔧 시스템 요구사항 확인 중...
✅ 시스템 요구사항 확인 완료
🔧 Tailscale 클라이언트 설치 중...
📋 Ubuntu/Debian용 Tailscale 설치 중...
✅ Tailscale 설치 완료
📋 설치된 버전: 1.52.1
🔧 Tailscale 서비스 시작 중...
✅ Tailscaled 서비스가 실행 중입니다.
🔧 Headscale 서버에 등록 중...
📋 Headscale 서버: https://head.0bin.in
📋 Pre-auth Key: 8b3df41d***************
🔧 등록 명령 실행 중...
✅ Headscale 등록 성공!
🔧 방화벽 설정 확인 중...
📋 UFW 방화벽 감지됨
📋 Tailscale 트래픽 허용 중...
✅ 방화벽 설정 완료
🔧 연결 상태 확인 중...
✅ Headscale 네트워크 연결 완료!
📋 할당된 IPv4: 100.64.0.5
📋 할당된 IPv6: fd7a:115c:a1e0::5
🔧 네트워크 연결 테스트 중...
✅ 팜큐 네트워크(100.64.0.0/10) 연결 정상!
============================================
팜큐 Headscale 설치 완료!
============================================
🎉 설치가 성공적으로 완료되었습니다!
📋 시스템 정보:
호스트명: pharmacy-server-01
Tailscale IP: 100.64.0.5
OS: ubuntu 22.04
Headscale 서버: https://head.0bin.in
🔧 유용한 명령어:
tailscale status # 연결 상태 확인
tailscale ip # 할당된 IP 확인
tailscale ping <node> # 다른 노드와 연결 테스트
tailscale logout # 네트워크에서 해제
🌐 팜큐 관리자 페이지:
http://192.168.0.151:5002
http://192.168.0.151:5002/vms (VM 관리)
============================================
설치 완료 - 팜큐 네트워크를 사용할 수 있습니다!
============================================
```
## 🔧 설치 후 확인 명령어
### 연결 상태 확인
```bash
tailscale status
```
### 할당된 IP 주소 확인
```bash
tailscale ip
```
### 네트워크 테스트
```bash
# 다른 노드로 ping 테스트
tailscale ping 100.64.0.1
# 또는 노드명으로 테스트
tailscale ping desktop-emjd1dc
```
### 서비스 상태 확인
```bash
systemctl status tailscaled
journalctl -u tailscaled -f # 실시간 로그
```
## 🚨 문제해결
### 1. 설치 중 권한 오류
```bash
# 해결방법: sudo 권한으로 실행
sudo curl -fsSL https://git.0bin.in/.../quick-install.sh | sudo bash
```
### 2. 네트워크 연결 실패
```bash
# 방화벽 상태 확인
sudo ufw status
sudo firewall-cmd --list-all
# 수동 포트 허용
sudo ufw allow 41641/udp
sudo firewall-cmd --add-port=41641/udp --permanent
```
### 3. Pre-auth Key 만료
```bash
# 새로운 키 생성이 필요한 경우
# Headscale 서버에서 실행:
docker exec headscale headscale preauthkeys create --user 1 --expiration 7d --reusable
```
### 4. 수동 등록 필요시
```bash
# 자동 등록 실패 시 수동 실행
tailscale up --login-server="https://head.0bin.in" --authkey="YOUR_KEY_HERE"
```
## 📊 현재 네트워크 정보
- **Headscale 서버**: https://head.0bin.in
- **Flask 관리 페이지**: http://192.168.0.151:5002
- **네트워크 대역**: 100.64.0.0/10
- **Pre-auth Key 유효기간**: 7일 (재사용 가능)
## 🔄 기존 서버 업데이트
이미 등록된 서버에서 스크립트를 다시 실행하면:
1. 기존 연결 감지
2. 사용자 확인 후 재등록 옵션 제공
3. 또는 기존 연결 유지
## 📞 지원
문제가 발생하면 다음 정보와 함께 연락주세요:
1. **OS 정보**: `cat /etc/os-release`
2. **Tailscale 버전**: `tailscale version`
3. **오류 로그**: `journalctl -u tailscaled --no-pager`
4. **네트워크 상태**: `tailscale status`
---
**🎯 목표**: 새로운 서버를 30초 만에 팜큐 네트워크에 연결!

View File

@@ -52,7 +52,7 @@ docker-compose up -d headplane
``` ```
## 📋 접속 정보 ## 📋 접속 정보
- **Headscale API**: http://localhost:8080 - **Headscale API**: http://localhost:8070
- **Headplane UI**: http://localhost:3000 - **Headplane UI**: http://localhost:3000
## 👤 사용자 관리 ## 👤 사용자 관리
@@ -130,3 +130,67 @@ git add .
git commit -m "Update: 설명" git commit -m "Update: 설명"
git push origin main 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
# root 계정 (Proxmox 등)
curl -fsSL https://git.0bin.in/thug0bin/headscale-tailscale-replacement/raw/branch/feature/working-headscale-setup/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
# root 계정
curl -fsSL https://git.0bin.in/thug0bin/headscale-tailscale-replacement/raw/branch/feature/working-headscale-setup/quick-install.sh | bash -s -- --force
```
### 지원 OS
- Ubuntu, Debian, CentOS, RHEL, Rocky Linux, Fedora, Arch Linux
- 자동 Tailscale 설치 + Headscale 등록
- 방화벽 자동 설정 + 연결 검증
**30초 만에 팜큐 네트워크 연결 완료!** 🎉
## 🪟 Windows 원클릭 등록
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'))
```
### 기존 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'))
```
### 한글 버전 (인코딩 문제 발생 가능)
```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'))
```
### 실행 방법
1. **Windows 키 + X****"Windows PowerShell(관리자)"** 클릭
2. 위 명령어 **복사 → 붙여넣기 → Enter**
3. 자동 설치 진행 (2-3분)
4. 팜큐 네트워크 연결 완료! 🎉
### Windows 자동 처리 기능
-**Tailscale 자동 다운로드** 및 설치
-**관리자 권한** 자동 확인
-**기존 연결 스마트 처리** (Linux와 동일)
-**Windows Defender 방화벽** 자동 설정
- ✅ **네트워크 연결 테스트** 및 확인

View File

@@ -0,0 +1,126 @@
# Headscale 데이터베이스 외래키 제약조건 문제 해결 기록
## 🔍 문제 상황
### 발생한 오류
```
backend error: handling register with auth key: registering node: failed register(save) node in the database: SQL logic error: foreign key mismatch - "pharmacy_info" referencing "users" (1)
```
### 증상
- Windows Tailscale 클라이언트 연결 시 Google SSO 로그인만 표시됨
- `tailscale up --login-server` 명령어 실행 시 위 오류 발생
- Headscale 컨테이너 로그에서 foreign key mismatch 오류 지속 발생
### 원인 분석
1. **Flask Admin 앱 개발 과정에서 추가된 커스텀 테이블들이 Headscale 스키마와 충돌**
- `pharmacy_info` 테이블이 `users` 테이블을 참조하는 외래키 제약조건 설정
- `machine_specs`, `monitoring_data` 테이블도 유사한 외래키 제약조건 존재
2. **Headscale이 자체 사용자 관리 스키마를 가지고 있어 외부 테이블의 외래키 참조 거부**
- Headscale은 내부적으로 `users`, `machines`, `nodes` 등의 테이블을 관리
- 외부에서 추가된 테이블이 이들을 참조할 때 스키마 불일치 발생
## 🛠️ 해결 과정
### 1단계: 문제 테이블 식별
```python
# 문제가 되는 테이블들 확인
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE '%pharmacy%'")
problem_tables = cursor.fetchall()
# 결과: ['pharmacy_info', 'machine_specs', 'monitoring_data']
```
### 2단계: 데이터베이스 정리 스크립트 실행
```bash
python3 clean-database.py
```
#### 스크립트 수행 작업:
1. **백업 생성**: `db.sqlite.clean_backup.20250909_170759`
2. **외래키 제약조건 비활성화**: `PRAGMA foreign_keys = OFF`
3. **문제 테이블 제거**:
- `DROP TABLE IF EXISTS pharmacy_info`
- `DROP TABLE IF EXISTS machine_specs`
- `DROP TABLE IF EXISTS monitoring_data`
4. **변경사항 커밋 및 무결성 검사**
### 3단계: Headscale 서비스 재시작
```bash
cd /srv/headscale-setup && docker-compose restart headscale
```
## ✅ 해결 결과
### Before (문제 상황):
```
2025-09-09T17:07:02+09:00 FTL Migration failed: SQL logic error: foreign key mismatch - "pharmacy_info" referencing "users" (1)
```
### After (해결 후):
```
2025-09-09T17:08:42+09:00 INF Using policy manager version: 2
2025-09-09T17:08:42+09:00 INF Starting Headscale commit=474ea236d0c6d393dbcf7baa98da240ad20c1b66 version=0.26.1
2025-09-09T17:08:46+09:00 INF node has connected, mapSession: 0xc000172600, chan: 0xc000286d90 node=DESKTOP-EMJD1DC node.id=2
2025-09-09T17:08:48+09:00 INF node has connected, mapSession: 0xc00021b680, chan: 0xc000251180 node=0bin-Ubuntu-VM node.id=1
```
### 성공 지표:
- ✅ Headscale 정상 시작
- ✅ Windows 클라이언트 (DESKTOP-EMJD1DC) 연결 성공
- ✅ Ubuntu VM 클라이언트 (0bin-Ubuntu-VM) 연결 유지
- ✅ Health check 통과: `{"status":"pass"}`
## 📚 교훈 및 베스트 프랙티스
### 1. Headscale과 커스텀 애플리케이션 분리 원칙
```
❌ 잘못된 접근: Headscale DB에 직접 외래키 제약조건으로 연결
✅ 올바른 접근: 별도 데이터베이스 또는 느슨한 결합 방식 사용
```
### 2. 외래키 제약조건 설계 시 고려사항
- **Headscale 스키마 독립성 유지**
- **별도 데이터베이스 사용** 또는 **ID 참조만 사용** (외래키 제약조건 없이)
- **마이그레이션 전 스키마 호환성 검증**
### 3. 향후 개발 가이드라인
```python
# 권장하지 않음
pharmacy_info = Table('pharmacy_info',
Column('user_id', Integer, ForeignKey('users.id')) # ❌
)
# 권장 방법
pharmacy_info = Table('pharmacy_info',
Column('headscale_user_id', Integer) # ✅ 단순 참조, 제약조건 없음
)
```
### 4. 데이터베이스 백업 중요성
- 모든 스키마 변경 전 백업 생성 필수
- 백업 파일명에 타임스탬프 포함으로 버전 관리
- 롤백 절차 사전 준비
## 🔧 복구 방법 (필요시)
만약 문제가 재발하거나 백업에서 복원해야 할 경우:
```bash
# 백업에서 복원
cp /srv/headscale-setup/data/db.sqlite.clean_backup.20250909_170759 /srv/headscale-setup/data/db.sqlite
# 컨테이너 재시작
cd /srv/headscale-setup && docker-compose restart headscale
```
## 📝 관련 파일들
- **해결 스크립트**: `/srv/headscale-setup/clean-database.py`
- **백업 파일**: `/srv/headscale-setup/data/db.sqlite.clean_backup.20250909_170759`
- **Docker Compose**: `/srv/headscale-setup/docker-compose.yml`
- **로그 위치**: `docker logs headscale`
---
*문제 해결일: 2025-09-09*
*해결 소요시간: ~30분*
*영향 범위: Windows/Ubuntu 클라이언트 연결 복구*

View File

@@ -0,0 +1,455 @@
# 🪟 Windows용 팜큐 Headscale 원클릭 설치 패키지 기획서
## 🎯 목표
Windows 클라이언트에서 **단 한 번의 실행**으로 Tailscale 설치부터 팜큐 Headscale 네트워크 연결까지 완전 자동화
## 🔍 현재 Windows 상황 분석
### 기존 연결된 Windows 클라이언트들
```
100.79.125.82 upharm-1 thug0bin@ windows offline
100.76.226.63 upharm thug0bin@ windows offline
100.93.4.146 prox-win10-kiosk thug0bin@ windows offline
100.109.121.8 desktop-06t3j0m thug0bin@ windows offline
100.70.5.37 desktop-9a1aurp thug0bin@ windows offline
100.126.213.6 desktop-m445evd thug0bin@ windows offline
```
### Windows 특성
- **관리자 권한** 필요 (UAC)
- **PowerShell** 스크립트 실행 정책
- **GUI 설치 마법사** 선호
- **레지스트리** 설정 관리
- **서비스** 자동 시작 설정
## 💡 Windows 설치 패키지 방안 (5가지)
## 방안 1: PowerShell 원클릭 스크립트 (권장)
### 개념도
```
[사용자] → [PowerShell 스크립트] → [Tailscale MSI 설치] → [Headscale 등록] → [완료]
우클릭 "관리자로 실행" 자동 다운로드/설치 자동 서버 설정
```
### 구현 방법
#### 1.1 PowerShell 스크립트 생성
```powershell
# farmq-headscale-installer.ps1
param(
[switch]$Force,
[string]$HeadscaleServer = "https://head.0bin.in",
[string]$PreAuthKey = "8b3df41d37cb158ea39f41fc32c9af46e761de817ad06038"
)
# 관리자 권한 확인
if (-NOT ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] "Administrator")) {
Write-Host "❌ 관리자 권한이 필요합니다." -ForegroundColor Red
Write-Host "우클릭 → '관리자로 실행'을 사용해주세요." -ForegroundColor Yellow
pause
exit 1
}
Write-Host "🚀 팜큐 Headscale Windows 설치 시작..." -ForegroundColor Green
```
#### 1.2 웹 실행 방법
```powershell
# 관리자 PowerShell에서
iex ((New-Object System.Net.WebClient).DownloadString('https://git.0bin.in/.../farmq-install.ps1'))
# 또는 강제 재등록
iex ((New-Object System.Net.WebClient).DownloadString('https://git.0bin.in/.../farmq-install.ps1?force=1'))
```
### 장점
- ✅ 단일 명령어로 실행 가능
- ✅ 기존 Linux 스크립트와 유사한 UX
- ✅ 웹에서 바로 실행 가능
- ✅ 버전 관리 용이
### 단점
- ❌ PowerShell 실행 정책 문제
- ❌ 일반 사용자에게 복잡함
- ❌ UAC 프롬프트 필요
---
## 방안 2: MSI 설치 패키지 (GUI 방식)
### 개념도
```
[사용자] → [farmq-headscale-installer.msi 실행] → [설치 마법사] → [완료]
더블클릭 GUI 단계별 진행
```
### 구현 방법
#### 2.1 WiX Toolset으로 MSI 제작
```xml
<!-- farmq-installer.wxs -->
<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi">
<Product Id="*" Name="팜큐 Headscale 클라이언트" Language="1042"
Version="1.0.0" Manufacturer="팜큐" UpgradeCode="...">
<Package InstallerVersion="200" Compressed="yes" InstallScope="perMachine"/>
<!-- Tailscale MSI 번들링 -->
<Directory Id="TARGETDIR" Name="SourceDir">
<Directory Id="ProgramFilesFolder">
<Directory Id="INSTALLFOLDER" Name="FarmQ Headscale"/>
</Directory>
</Directory>
<!-- 사용자 정의 액션 -->
<CustomAction Id="InstallTailscale"
FileKey="TailscaleMSI"
ExeCommand="msiexec /i tailscale-windows.msi /quiet"/>
<CustomAction Id="RegisterHeadscale"
FileKey="RegisterScript"
ExeCommand="powershell.exe -ExecutionPolicy Bypass -File register-headscale.ps1"/>
</Product>
</Wix>
```
#### 2.2 설치 마법사 UI
```
┌─────────────────────────────────┐
│ 팜큐 Headscale 클라이언트 설치 │
├─────────────────────────────────┤
│ [ ] 기본 설치 (권장) │
│ [ ] 기존 연결 해제 후 재설치 │
│ │
│ 서버: https://head.0bin.in │
│ 네트워크: 100.64.0.0/10 │
│ │
│ [< 이전] [다음 >] [취소] │
└─────────────────────────────────┘
```
### 장점
- ✅ Windows 사용자에게 친숙한 GUI
- ✅ 제어판에서 제거 가능
- ✅ 디지털 서명 가능
- ✅ 그룹 정책 배포 가능
### 단점
- ❌ 개발 복잡도 높음
- ❌ 코드 서명 인증서 필요
- ❌ 업데이트 배포 복잡
---
## 방안 3: 실행 파일 (EXE) + 내장 리소스
### 개념도
```
[사용자] → [farmq-installer.exe 실행] → [콘솔/GUI 선택] → [완료]
단일 실행 파일 모든 리소스 내장
```
### 구현 방법
#### 3.1 Go/C#으로 네이티브 실행파일
```go
// farmq-installer.go
package main
import (
"embed"
"fmt"
"os"
"os/exec"
)
//go:embed resources/tailscale-windows.msi
//go:embed resources/register-script.ps1
var resources embed.FS
func main() {
fmt.Println("🚀 팜큐 Headscale Windows 설치")
// 관리자 권한 확인
if !isAdmin() {
fmt.Println("❌ 관리자 권한으로 다시 실행해주세요")
return
}
// Tailscale MSI 추출 및 설치
installTailscale()
// Headscale 등록
registerHeadscale()
}
```
#### 3.2 배포 형태
- **farmq-installer.exe** (단일 파일, ~50MB)
- Tailscale MSI 포함
- PowerShell 스크립트 포함
- 모든 의존성 내장
### 장점
- ✅ 단일 파일로 배포 간편
- ✅ 오프라인 설치 가능
- ✅ 의존성 문제 없음
- ✅ 콘솔/GUI 하이브리드 가능
### 단점
- ❌ 파일 크기 큼 (50MB+)
- ❌ 네이티브 개발 필요
- ❌ Tailscale 업데이트 시 재빌드
---
## 방안 4: 웹 기반 설치 (브라우저)
### 개념도
```
[사용자] → [웹페이지 방문] → [원클릭 다운로드] → [자동 실행] → [완료]
설치 가이드 페이지 맞춤형 설치파일 브라우저 실행
```
### 구현 방법
#### 4.1 웹 설치 페이지
```html
<!-- https://install.farmq.network -->
<!DOCTYPE html>
<html>
<head>
<title>팜큐 Headscale Windows 설치</title>
</head>
<body>
<h1>🪟 Windows용 팜큐 네트워크 설치</h1>
<div class="install-options">
<button onclick="downloadInstaller('basic')">
🚀 기본 설치
</button>
<button onclick="downloadInstaller('force')">
🔄 강제 재설치
</button>
</div>
<script>
function downloadInstaller(type) {
const url = `https://install.farmq.network/download?type=${type}`;
window.location.href = url;
}
</script>
</body>
</html>
```
#### 4.2 동적 설치파일 생성
```python
# Flask 서버에서 실시간 생성
@app.route('/download')
def download_installer():
install_type = request.args.get('type', 'basic')
# 사용자 맞춤형 PowerShell 스크립트 생성
script = generate_powershell_script(
force=install_type=='force',
preauth_key=get_current_preauth_key(),
server_url="https://head.0bin.in"
)
response = make_response(script)
response.headers['Content-Type'] = 'application/octet-stream'
response.headers['Content-Disposition'] = 'attachment; filename=farmq-install.ps1'
return response
```
### 장점
- ✅ 최신 설정 항상 반영
- ✅ 사용자별 맞춤 설치
- ✅ 통계 수집 가능
- ✅ 웹 기반으로 접근성 좋음
### 단점
- ❌ 인터넷 연결 필수
- ❌ 웹 서버 인프라 필요
- ❌ 브라우저 보안 정책 제약
---
## 방안 5: 배치 파일 (BAT) 스크립트
### 개념도
```
[사용자] → [farmq-install.bat 실행] → [Windows CMD 명령] → [완료]
우클릭 "관리자로 실행" 전통적인 배치 방식
```
### 구현 방법
#### 5.1 배치 스크립트
```batch
@echo off
:: farmq-install.bat
title 팜큐 Headscale Windows 설치
:: 관리자 권한 확인
net session >nul 2>&1
if %errorLevel% neq 0 (
echo ❌ 관리자 권한이 필요합니다.
echo 우클릭으로 "관리자로 실행"해주세요.
pause
exit /b 1
)
echo 🚀 팜큐 Headscale Windows 설치 시작...
:: Tailscale 다운로드 및 설치
echo 📦 Tailscale 다운로드 중...
powershell -Command "Invoke-WebRequest -Uri 'https://pkgs.tailscale.com/stable/tailscale-setup.exe' -OutFile 'tailscale-setup.exe'"
echo 🔧 Tailscale 설치 중...
tailscale-setup.exe /S
:: Headscale 등록
echo 🌐 Headscale 서버 등록 중...
"C:\Program Files\Tailscale\tailscale.exe" up --login-server=https://head.0bin.in --authkey=8b3df41d37cb158ea39f41fc32c9af46e761de817ad06038
echo ✅ 설치 완료!
pause
```
### 장점
- ✅ 개발 간단
- ✅ Windows 네이티브 지원
- ✅ 의존성 없음
- ✅ 디버깅 용이
### 단점
- ❌ 기능 제한적
- ❌ 에러 처리 복잡
- ❌ 사용자 경험 떨어짐
---
## 🎯 권장 구현 우선순위
### 1단계: PowerShell 스크립트 (즉시 구현 가능)
**기간**: 1-2일
**난이도**: 하
```powershell
# 사용자 실행 방법
# 1. 관리자 PowerShell 열기
# 2. 다음 명령 실행
iex ((New-Object System.Net.WebClient).DownloadString('https://git.0bin.in/.../farmq-install.ps1'))
```
### 2단계: 실행 파일 (EXE) 방식
**기간**: 3-5일
**난이도**: 중
- Go언어로 크로스 컴파일
- 단일 실행파일로 배포
- GUI 옵션 포함
### 3단계: MSI 설치 패키지
**기간**: 1주일
**난이도**: 상
- 전문적인 설치 경험
- 제어판 등록
- 그룹 정책 배포 지원
## 📋 PowerShell 스크립트 구현 명세서
### 기본 기능
```powershell
# 1. 시스템 확인
- Windows 버전 체크 (Windows 10/11 지원)
- 관리자 권한 확인
- 인터넷 연결 확인
# 2. Tailscale 설치
- 기존 설치 확인
- 최신 버전 다운로드
- 자동 설치 (Silent Install)
- 서비스 시작
# 3. Headscale 등록
- 기존 연결 확인 해제
- Pre-auth key로 자동 등록
- 연결 상태 확인
# 4. 방화벽 설정
- Windows Defender 예외 추가
- 필요한 포트 허용
# 5. 완료 확인
- IP 주소 할당 확인
- 네트워크 연결 테스트
- 상태 출력
```
### 사용자 시나리오
```
1. 약국 직원이 새 PC 설정
2. 관리자 PowerShell 실행
3. 원클릭 명령어 붙여넣기
4. 자동 설치 진행 (2-3분)
5. 팜큐 네트워크 연결 완료
```
## 🔧 즉시 구현 가능한 MVP
### 파일 구조
```
farmq-windows-installer/
├── farmq-install.ps1 # 메인 설치 스크립트
├── modules/
│ ├── system-check.ps1 # 시스템 확인
│ ├── tailscale-installer.ps1 # Tailscale 설치
│ ├── headscale-register.ps1 # Headscale 등록
│ └── network-verify.ps1 # 네트워크 확인
├── resources/
│ └── farmq-logo.ico # 아이콘
└── README-windows.md # Windows 설치 가이드
```
### 웹 실행 명령어
```powershell
# 기본 설치
Set-ExecutionPolicy Bypass -Scope Process -Force; iex ((New-Object System.Net.WebClient).DownloadString('https://git.0bin.in/thug0bin/headscale-tailscale-replacement/raw/branch/main/farmq-install.ps1'))
# 강제 재설치
Set-ExecutionPolicy Bypass -Scope Process -Force; iex ((New-Object System.Net.WebClient).DownloadString('https://git.0bin.in/thug0bin/headscale-tailscale-replacement/raw/branch/main/farmq-install.ps1?force=1'))
```
## 📊 예상 사용 통계
### 대상 사용자
- **팜큐 약국**: ~100개 약국 × 2-3대 PC = 200-300대
- **신규 PC**: 월 10-20대 추가
- **재설치**: 월 5-10건
### 성공 지표
-**설치 성공률**: 95% 이상
-**설치 시간**: 3분 이내
-**사용자 만족도**: 5점 만점 4점 이상
-**지원 요청**: 월 5건 이하
## 🚀 결론 및 추천
**즉시 구현 권장**: PowerShell 원클릭 스크립트
1. **개발 용이성** ⭐⭐⭐⭐⭐
2. **사용자 편의성** ⭐⭐⭐⭐
3. **유지보수성** ⭐⭐⭐⭐⭐
4. **배포 편의성** ⭐⭐⭐⭐⭐
**다음 단계**: PowerShell 스크립트 구현 → EXE 파일 → MSI 패키지 순으로 단계적 발전
---
**목표**: "Linux처럼 Windows에서도 한 줄 명령어로 팜큐 네트워크 연결!" 🎯

183
WINDOWS_QUICK_START.md Normal file
View File

@@ -0,0 +1,183 @@
# 🪟 Windows 팜큐 네트워크 빠른 시작 가이드
Windows PC를 팜큐 네트워크에 **30초만에** 연결하는 방법입니다.
## 🎯 복사 붙여넣기 전용 명령어
### 📋 기본 설치 (가장 많이 사용) - 인코딩 문제 해결됨 ✅
**복사할 명령어 (권장 - English 버전):**
```powershell
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'))
```
### 📋 강제 재설치 (기존 Tailscale이 있는 경우)
**복사할 명령어 (권장 - English 버전):**
```powershell
$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'))
```
### 📋 한글 버전 (인코딩 문제 발생 가능)
**한글이 깨져서 나올 수 있습니다 - 위 English 버전을 사용하세요:**
```powershell
# 기본 설치 (한글 깨짐 가능)
iex ((New-Object System.Net.WebClient).DownloadString('https://git.0bin.in/thug0bin/headscale-tailscale-replacement/raw/branch/feature/working-headscale-setup/farmq-install.ps1'))
# 강제 재설치 (한글 깨짐 가능)
$ForceInstall = $true; iex ((New-Object System.Net.WebClient).DownloadString('https://git.0bin.in/thug0bin/headscale-tailscale-replacement/raw/branch/feature/working-headscale-setup/farmq-install.ps1'))
```
## 🚀 실행 방법 (3단계)
### 1단계: 관리자 PowerShell 열기
- **Windows 10/11**: `Windows 키 + X``Windows PowerShell(관리자)` 클릭
- **또는**: 시작 메뉴 → `PowerShell` 검색 → 우클릭 → `관리자로 실행`
### 2단계: 명령어 붙여넣기
- 위의 **기본 설치 명령어**를 복사
- PowerShell에 붙여넣기 (우클릭 또는 `Ctrl+V`)
- `Enter` 키 누르기
### 3단계: 자동 설치 완료 대기
- 2-3분 기다리기 ⏰
- 완료 메시지 확인 ✅
- 팜큐 네트워크 사용 시작! 🎉
## 📺 실행 화면 예시
```powershell
PS C:\WINDOWS\system32> 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'))
============================================
FARMQ Headscale Windows One-Click Installation
============================================
[*] Checking system requirements...
[+] System requirements check completed
[*] Checking Tailscale installation...
[i] Installing Tailscale for Windows...
[*] Downloading Tailscale...
[*] Installing Tailscale... (please wait)
[+] Tailscale installation completed
[*] Starting Tailscale service...
[+] Tailscale service is running.
[*] Registering with Headscale server...
[i] Headscale Server: https://head.0bin.in
[i] Pre-auth Key: 8b3df41d***************
[*] Executing registration command...
[+] Headscale registration successful!
[*] Configuring firewall settings...
[+] Firewall configuration completed
[*] Verifying network connection...
[+] Headscale network connection completed!
[i] Assigned IPv4: 100.64.0.15
[i] Assigned IPv6: fd7a:115c:a1e0::15
[*] Testing network connectivity...
[+] FARMQ network (100.64.0.0/10) connection successful!
============================================
FARMQ Headscale Windows Installation Complete!
============================================
Installation completed successfully!
System Information:
Computer Name: PHARMACY-PC01
Tailscale IP: 100.64.0.15
OS: Windows 10.0
Headscale Server: https://head.0bin.in
```
## ❓ 자주 묻는 질문 (FAQ)
### Q: "실행 정책" 오류가 나와요
**A: 다음 명령을 먼저 실행하세요:**
```powershell
Set-ExecutionPolicy Bypass -Scope Process -Force
```
### Q: 관리자 권한이 없다고 나와요
**A: PowerShell을 관리자로 다시 실행하세요:**
- `Windows 키 + X``Windows PowerShell(관리자)`
### Q: 이미 Tailscale이 설치되어 있어요
**A: 강제 재설치 명령어를 사용하세요 (English 버전):**
```powershell
$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'))
```
### Q: 한글이 깨져서 나와요
**A: English 버전을 사용하세요 (인코딩 문제 해결됨):**
```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-en.ps1'))
```
### Q: 설치 후 어떻게 확인하나요?
**A: PowerShell에서 다음 명령으로 확인:**
```powershell
tailscale status
tailscale ip
```
## 🔧 유용한 명령어
설치 완료 후 PowerShell에서 사용할 수 있는 명령어들:
```powershell
# 연결 상태 확인
tailscale status
# 내 IP 주소 확인
tailscale ip
# 다른 컴퓨터와 연결 테스트
tailscale ping 100.64.0.1
# 네트워크에서 나가기
tailscale logout
```
## 🏥 팜큐 관리 페이지 접속
설치 완료 후 브라우저에서 접속:
- **메인 관리 페이지**: http://192.168.0.151:5002
- **VM 관리**: http://192.168.0.151:5002/vms
- **약국 관리**: http://192.168.0.151:5002/pharmacy
## 🆘 문제 해결
### 연결이 안될 때
1. **Windows 방화벽** 확인
2. **백신 프로그램** 예외 설정
3. **회사 네트워크 정책** 확인
### 완전 삭제 후 재설치
```powershell
# Tailscale 완전 제거
tailscale logout
# 제어판에서 Tailscale 제거
# 다시 설치 스크립트 실행
```
---
## 📞 지원
문제가 발생하면 다음 정보와 함께 연락주세요:
1. **Windows 버전**: `winver` 명령으로 확인
2. **PowerShell 버전**: `$PSVersionTable` 명령으로 확인
3. **오류 메시지**: 스크린샷 또는 텍스트 복사
4. **컴퓨터 이름**: `$env:COMPUTERNAME` 명령으로 확인
**🎯 목표 달성: 복사 → 붙여넣기 → 30초 후 팜큐 네트워크 연결!** 🚀

View File

@@ -1,5 +1,5 @@
--- ---
server_url: http://localhost:8080 server_url: http://localhost:8070
listen_addr: 0.0.0.0:8080 listen_addr: 0.0.0.0:8080
metrics_listen_addr: 0.0.0.0:9090 metrics_listen_addr: 0.0.0.0:9090
@@ -7,8 +7,9 @@ private_key_path: /var/lib/headscale/private.key
noise: noise:
private_key_path: /var/lib/headscale/noise_private.key private_key_path: /var/lib/headscale/noise_private.key
ip_prefixes: prefixes:
- 100.64.0.0/10 v4: 100.64.0.0/10
v6: fd7a:115c:a1e0::/48
derp: derp:
server: server:
@@ -18,6 +19,7 @@ derp:
disable_check_updates: false disable_check_updates: false
ephemeral_node_inactivity_timeout: 30m ephemeral_node_inactivity_timeout: 30m
database: database:
type: sqlite3 type: sqlite3
sqlite: sqlite:
@@ -38,17 +40,21 @@ log:
format: text format: text
level: info level: info
acl_policy_path: "" # Updated DNS configuration format
dns:
dns_config:
override_local_dns: true override_local_dns: true
nameservers: nameservers:
global:
- 1.1.1.1 - 1.1.1.1
- 8.8.8.8 - 8.8.8.8
domains: [] search_domains: []
magic_dns: true magic_dns: true
base_domain: headscale.local base_domain: headscale.local
# Updated policy path
policy:
path: ""
unix_socket: /var/run/headscale/headscale.sock unix_socket: /var/run/headscale/headscale.sock
unix_socket_permission: "0770" unix_socket_permission: "0770"
@@ -57,8 +63,9 @@ logtail:
randomize_client_port: false randomize_client_port: false
# Simplified OIDC configuration (removed deprecated keys)
oidc: oidc:
only_start_if_oidc_is_available: true only_start_if_oidc_is_available: false
issuer: "" issuer: ""
client_id: "" client_id: ""
client_secret: "" client_secret: ""
@@ -66,4 +73,3 @@ oidc:
extra_params: {} extra_params: {}
allowed_domains: [] allowed_domains: []
allowed_users: [] allowed_users: []
strip_email_domain: true

167
create-preauth-key.sh Executable file
View File

@@ -0,0 +1,167 @@
#!/bin/bash
# 팜큐(FARMQ) Pre-auth Key 생성 스크립트
# 사용법: ./create-preauth-key.sh [사용자명] [유효기간(시간)]
set -e
# 기본 설정
DEFAULT_USER="myuser"
DEFAULT_EXPIRY="24h" # 24시간
# 사용법 출력
usage() {
echo "사용법: $0 [사용자명] [유효기간]"
echo ""
echo "예시:"
echo " $0 # myuser 사용자, 24시간 유효"
echo " $0 pharmacy1 # pharmacy1 사용자, 24시간 유효"
echo " $0 pharmacy1 7d # pharmacy1 사용자, 7일 유효"
echo " $0 pharmacy1 1h # pharmacy1 사용자, 1시간 유효"
echo ""
echo "유효기간 형식: 1h, 24h, 7d, 30d 등"
}
# 색상 출력 함수
print_status() {
echo -e "\n🔧 $1"
}
print_success() {
echo -e "\n✅ $1"
}
print_error() {
echo -e "\n❌ $1"
}
print_info() {
echo -e "\n📋 $1"
}
# 사용자 존재 확인
check_user_exists() {
local username=$1
print_status "사용자 '$username' 확인 중..."
if docker exec headscale headscale users list | grep -q "$username"; then
print_info "사용자 '$username'이 존재합니다."
return 0
else
print_error "사용자 '$username'이 존재하지 않습니다."
print_info "사용자 생성 중..."
if docker exec headscale headscale users create "$username"; then
print_success "사용자 '$username'이 생성되었습니다."
else
print_error "사용자 생성에 실패했습니다."
exit 1
fi
fi
}
# 사용자 ID 가져오기
get_user_id() {
local username=$1
local user_id=$(docker exec headscale headscale users list | grep "$username" | awk '{print $1}')
if [[ -n "$user_id" ]]; then
echo $user_id
else
print_error "사용자 ID를 찾을 수 없습니다."
exit 1
fi
}
# Pre-auth key 생성
create_preauth_key() {
local username=$1
local expiry=$2
print_status "Pre-auth key 생성 중..."
print_info "사용자: $username"
print_info "유효기간: $expiry"
local user_id=$(get_user_id "$username")
print_info "사용자 ID: $user_id"
# Pre-auth key 생성 (재사용 가능, 임시 아님)
local preauth_output=$(docker exec headscale headscale preauthkeys create \
-u "$user_id" \
--expiration "$expiry" \
--reusable)
if [[ $? -eq 0 ]]; then
# Key 값 추출
local preauth_key=$(echo "$preauth_output" | grep -o '[a-f0-9]\{48\}')
if [[ -n "$preauth_key" ]]; then
print_success "Pre-auth key가 생성되었습니다!"
print_info "Key: $preauth_key"
# 클라이언트 등록 스크립트에 추가할 명령어 출력
echo ""
echo "=========================================="
echo "📋 클라이언트에서 사용할 명령어:"
echo "=========================================="
echo ""
echo "Linux/macOS:"
echo "sudo tailscale up \\"
echo " --login-server=\"https://head.0bin.in\" \\"
echo " --authkey=\"$preauth_key\" \\"
echo " --accept-routes \\"
echo " --accept-dns=false"
echo ""
echo "=========================================="
echo "📋 등록 스크립트 업데이트:"
echo "=========================================="
echo ""
echo "register-client.sh 파일의 PREAUTH_KEY 값을 다음으로 업데이트하세요:"
echo "PREAUTH_KEY=\"$preauth_key\""
echo ""
return 0
fi
fi
print_error "Pre-auth key 생성에 실패했습니다."
exit 1
}
# 기존 Pre-auth key 목록 표시
list_existing_keys() {
local username=$1
local user_id=$(get_user_id "$username")
print_info "기존 Pre-auth key 목록 (사용자: $username):"
docker exec headscale headscale preauthkeys list -u "$user_id"
}
# 메인 함수
main() {
local username="${1:-$DEFAULT_USER}"
local expiry="${2:-$DEFAULT_EXPIRY}"
echo "=========================================="
echo " 🔑 팜큐(FARMQ) Pre-auth Key 생성"
echo "=========================================="
# 도움말 요청 확인
if [[ "$1" == "-h" ]] || [[ "$1" == "--help" ]]; then
usage
exit 0
fi
check_user_exists "$username"
list_existing_keys "$username"
create_preauth_key "$username" "$expiry"
echo ""
print_success "완료!"
print_info "이 key는 $expiry 동안 유효하며, 여러 번 사용할 수 있습니다."
}
# 스크립트 실행
main "$@"

View File

@@ -5,7 +5,7 @@ services:
image: headscale/headscale:latest image: headscale/headscale:latest
container_name: headscale container_name: headscale
restart: unless-stopped restart: unless-stopped
command: headscale serve command: serve
environment: environment:
- TZ=Asia/Seoul - TZ=Asia/Seoul
volumes: volumes:
@@ -13,16 +13,16 @@ services:
- ./data:/var/lib/headscale - ./data:/var/lib/headscale
- ./run:/var/run/headscale - ./run:/var/run/headscale
ports: ports:
- "8080:8080" # Headscale HTTP API - "8070:8080" # Headscale HTTP API (외부:내부)
- "9090:9090" # Metrics (optional) - "9090:9090" # Metrics (optional)
networks: networks:
- headscale-net - headscale-net
healthcheck: healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"] test: ["CMD", "/ko-app/headscale", "version"]
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 3 retries: 3
start_period: 40s start_period: 30s
headplane: headplane:
image: ghcr.io/tale/headplane:latest image: ghcr.io/tale/headplane:latest
@@ -32,11 +32,12 @@ services:
- TZ=Asia/Seoul - TZ=Asia/Seoul
- HEADSCALE_URL=http://headscale:8080 - HEADSCALE_URL=http://headscale:8080
- HEADSCALE_API_KEY=${HEADSCALE_API_KEY} - HEADSCALE_API_KEY=${HEADSCALE_API_KEY}
volumes:
- ./headplane-config:/etc/headplane
ports: ports:
- "3000:3000" # Headplane Web UI - "3000:3000" # Headplane Web UI
depends_on: depends_on:
headscale: - headscale
condition: service_healthy
networks: networks:
- headscale-net - headscale-net

241
farmq-admin/app.py Normal file
View File

@@ -0,0 +1,241 @@
#!/usr/bin/env python3
"""
팜큐 약국 관리 시스템 - Flask 애플리케이션
Headscale + Headplane 고도화 관리자 페이지
"""
from flask import Flask, render_template, jsonify, request, redirect, url_for
import os
from datetime import datetime
from config import config
from utils.database_new import (
init_databases, get_farmq_session,
get_dashboard_stats, get_all_pharmacies_with_stats, get_all_machines_with_details,
get_machine_detail, get_pharmacy_detail, get_active_alerts,
sync_machines_from_headscale, sync_users_from_headscale
)
def create_app(config_name=None):
"""Flask 애플리케이션 팩토리"""
app = Flask(__name__)
# 설정 로드
config_name = config_name or os.environ.get('FLASK_ENV', 'default')
app.config.from_object(config[config_name])
# 데이터베이스 초기화
init_databases(
headscale_db_uri='sqlite:////srv/headscale-setup/data/db.sqlite',
farmq_db_uri='sqlite:///farmq.db'
)
# 데이터 동기화 실행
sync_users_from_headscale()
sync_machines_from_headscale()
# 메인 대시보드
@app.route('/')
def dashboard():
"""메인 대시보드"""
try:
# 새로운 통합 통계 함수 사용
stats = get_dashboard_stats()
stats['alerts'] = get_active_alerts()[:5] # 최신 5개만
stats['performance'] = {'status': 'good', 'summary': '모든 시스템이 정상 작동 중입니다.'}
# 약국별 상태 (상위 10개)
pharmacies = get_all_pharmacies_with_stats()[:10]
return render_template('dashboard/index.html',
stats=stats,
pharmacies=pharmacies)
except Exception as e:
print(f"❌ Dashboard error: {e}")
return render_template('error.html', error=str(e)), 500
# 약국 관리
@app.route('/pharmacy')
def pharmacy_list():
"""약국 목록"""
try:
pharmacies = get_all_pharmacies_with_stats()
return render_template('pharmacy/list.html', pharmacies=pharmacies)
except Exception as e:
return render_template('error.html', error=str(e)), 500
@app.route('/pharmacy/<int:pharmacy_id>')
def pharmacy_detail(pharmacy_id):
"""약국 상세 정보"""
try:
detail_data = get_pharmacy_detail(pharmacy_id)
if not detail_data:
return render_template('error.html', error='약국을 찾을 수 없습니다.'), 404
return render_template('pharmacy/detail.html',
pharmacy=detail_data['pharmacy'],
machines=detail_data['machines'])
except Exception as e:
print(f"❌ Pharmacy detail error: {e}")
return render_template('error.html', error=str(e)), 500
# 머신 관리
@app.route('/machines')
def machine_list():
"""머신 목록"""
try:
machines = get_all_machines_with_details()
return render_template('machines/list.html', machines=machines)
except Exception as e:
print(f"❌ Machine list error: {e}")
return render_template('error.html', error=str(e)), 500
@app.route('/machines/<int:machine_id>')
def machine_detail(machine_id):
"""머신 상세 정보"""
try:
print(f"🔍 Getting details for machine ID: {machine_id}")
details = get_machine_detail(machine_id)
if not details:
print(f"❌ No details found for machine ID: {machine_id}")
return render_template('error.html', error='머신을 찾을 수 없습니다.'), 404
hostname = details.get('hostname', 'Unknown')
print(f"✅ Rendering detail page for machine: {hostname}")
return render_template('machines/detail.html', machine=details)
except Exception as e:
print(f"❌ Error in machine_detail route: {e}")
import traceback
traceback.print_exc()
return render_template('error.html', error=f'머신 상세 정보 로드 중 오류: {str(e)}'), 500
# API 엔드포인트
@app.route('/api/dashboard/stats')
def api_dashboard_stats():
"""대시보드 통계 API"""
try:
stats = get_dashboard_stats()
stats['performance'] = {'status': 'good', 'summary': '모든 시스템이 정상 작동 중입니다.'}
return jsonify(stats)
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/alerts')
def api_alerts():
"""실시간 알림 API"""
try:
alerts = get_active_alerts()
return jsonify(alerts)
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/machines/<int:machine_id>/monitoring')
def api_machine_monitoring(machine_id):
"""머신 모니터링 데이터 API"""
try:
details = get_machine_detail(machine_id)
if not details:
return jsonify({'error': '머신을 찾을 수 없습니다.'}), 404
# 최근 모니터링 데이터 반환
metrics_history = details.get('metrics_history', [])
return jsonify(metrics_history[:20]) # 최근 20개 데이터
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/sync/machines')
def api_sync_machines():
"""Headscale에서 머신 정보 동기화 API"""
try:
result = sync_machines_from_headscale()
return jsonify(result)
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/sync/users')
def api_sync_users():
"""Headscale에서 사용자 정보 동기화 API"""
try:
result = sync_users_from_headscale()
return jsonify(result)
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/pharmacy/<int:pharmacy_id>/update', methods=['PUT'])
def api_update_pharmacy(pharmacy_id):
"""약국 정보 업데이트 API"""
try:
from utils.database_new import get_farmq_session
from models.farmq_models import PharmacyInfo
data = request.get_json()
session = get_farmq_session()
try:
pharmacy = session.query(PharmacyInfo).filter(
PharmacyInfo.id == pharmacy_id
).first()
if not pharmacy:
return jsonify({'error': '약국을 찾을 수 없습니다.'}), 404
# 업데이트 가능한 필드들
if 'pharmacy_name' in data:
pharmacy.pharmacy_name = data['pharmacy_name']
if 'business_number' in data:
pharmacy.business_number = data['business_number']
if 'manager_name' in data:
pharmacy.manager_name = data['manager_name']
if 'phone' in data:
pharmacy.phone = data['phone']
if 'address' in data:
pharmacy.address = data['address']
pharmacy.updated_at = datetime.now()
session.commit()
return jsonify({
'message': '약국 정보가 업데이트되었습니다.',
'pharmacy': pharmacy.to_dict()
})
finally:
session.close()
except Exception as e:
return jsonify({'error': str(e)}), 500
# 에러 핸들러
@app.errorhandler(404)
def not_found_error(error):
return render_template('error.html',
error='페이지를 찾을 수 없습니다.',
error_code=404), 404
@app.errorhandler(500)
def internal_error(error):
return render_template('error.html',
error='내부 서버 오류가 발생했습니다.',
error_code=500), 500
return app
# 개발 서버 실행
if __name__ == '__main__':
app = create_app()
# 개발 환경에서만 실행
if app.config.get('DEBUG'):
print("🚀 Starting FARMQ Admin System...")
print(f"📊 Dashboard: http://localhost:5001")
print(f"🏥 Pharmacy Management: http://localhost:5001/pharmacy")
print(f"💻 Machine Management: http://localhost:5001/machines")
print("" * 60)
app.run(
host='0.0.0.0',
port=5001,
debug=True,
use_reloader=True
)

56
farmq-admin/config.py Normal file
View File

@@ -0,0 +1,56 @@
"""
Flask 애플리케이션 설정
"""
import os
from pathlib import Path
class Config:
"""기본 설정"""
# Flask 기본 설정
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'
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'
# 모니터링 설정
MONITORING_INTERVAL = 30 # 30초마다 데이터 수집
MAX_MONITORING_RECORDS = 1000 # 최대 저장 레코드 수
# UI 설정
APP_TITLE = '팜큐 약국 관리 시스템'
ITEMS_PER_PAGE = 20 # 페이지당 아이템 수
# Proxmox 설정 (추후 확장)
PROXMOX_DEFAULT_PORT = 8006
PROXMOX_VERIFY_SSL = False
class DevelopmentConfig(Config):
"""개발 환경 설정"""
DEBUG = True
TESTING = False
class ProductionConfig(Config):
"""프로덕션 환경 설정"""
DEBUG = False
TESTING = False
class TestingConfig(Config):
"""테스트 환경 설정"""
DEBUG = True
TESTING = True
SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:'
# 환경별 설정 매핑
config = {
'development': DevelopmentConfig,
'production': ProductionConfig,
'testing': TestingConfig,
'default': DevelopmentConfig
}

View File

@@ -0,0 +1,34 @@
"""
모델 패키지 초기화
Headscale 호환성 모델과 FARMQ 전용 모델 분리
"""
# Headscale 읽기 전용 모델 (외래키 제약조건 없음)
from .headscale_models import (
User, Node, PreAuthKey, ApiKey, Policy,
Base, create_all_tables
)
# FARMQ 전용 모델 (별도 데이터베이스)
from .farmq_models import (
PharmacyInfo, MachineProfile, MonitoringMetrics, SystemAlert,
FarmqDatabaseManager, create_farmq_database_manager,
FarmqBase
)
# 하위 호환성을 위한 별칭
MachineSpecs = MachineProfile # 기존 이름과의 호환성
MonitoringData = MonitoringMetrics # 기존 이름과의 호환성
__all__ = [
# Headscale 모델
'User', 'Node', 'PreAuthKey', 'ApiKey', 'Policy',
'Base', 'create_all_tables',
# FARMQ 모델
'PharmacyInfo', 'MachineProfile', 'MonitoringMetrics', 'SystemAlert',
'FarmqDatabaseManager', 'create_farmq_database_manager', 'FarmqBase',
# 하위 호환성 별칭
'MachineSpecs', 'MonitoringData'
]

View File

@@ -0,0 +1,510 @@
"""
FARMQ 독립적인 모델 설계
Headscale과 충돌하지 않는 별도 데이터베이스 사용
설계 원칙:
1. 별도 데이터베이스 사용 (farmq.sqlite)
2. Headscale 테이블과 직접적인 외래키 제약조건 제거
3. 느슨한 결합: ID 참조만 사용 (외래키 제약조건 없음)
4. 능동적 대응: 데이터 무결성을 애플리케이션 레벨에서 관리
"""
from datetime import datetime, timedelta
from typing import Optional, List, Dict, Any
import json
from sqlalchemy import (
Column, Integer, String, DateTime, Boolean, Text, Float,
Index, UniqueConstraint, create_engine
)
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker, Session
from sqlalchemy.types import TypeDecorator, TEXT
# FARMQ 전용 Base 클래스
FarmqBase = declarative_base()
class JSONType(TypeDecorator):
"""Custom JSON type for SQLAlchemy"""
impl = TEXT
def process_bind_param(self, value, dialect):
if value is not None:
return json.dumps(value)
return value
def process_result_value(self, value, dialect):
if value is not None:
return json.loads(value)
return value
class PharmacyInfo(FarmqBase):
"""약국 정보 테이블 - Headscale과 독립적"""
__tablename__ = 'pharmacies'
id = Column(Integer, primary_key=True, autoincrement=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))
phone = Column(String(20))
address = Column(Text)
# 기술적 정보
proxmox_host = Column(String(255))
proxmox_username = Column(String(100))
proxmox_api_token = Column(Text) # 암호화 권장
tailscale_ip = Column(String(45)) # IPv4/IPv6 지원
# 상태 관리
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)
def __repr__(self):
return f"<PharmacyInfo(id={self.id}, name='{self.pharmacy_name}', status='{self.status}')>"
def to_dict(self) -> Dict[str, Any]:
"""딕셔너리로 변환"""
return {
'id': self.id,
'headscale_user_name': self.headscale_user_name,
'headscale_user_id': self.headscale_user_id,
'pharmacy_name': self.pharmacy_name,
'business_number': self.business_number,
'manager_name': self.manager_name,
'phone': self.phone,
'address': self.address,
'proxmox_host': self.proxmox_host,
'tailscale_ip': self.tailscale_ip,
'status': self.status,
'last_sync': self.last_sync.isoformat() if self.last_sync else None,
'created_at': self.created_at.isoformat(),
'updated_at': self.updated_at.isoformat()
}
class MachineProfile(FarmqBase):
"""머신 프로필 테이블 - 하드웨어 스펙 및 구성"""
__tablename__ = 'machine_profiles'
id = Column(Integer, primary_key=True, autoincrement=True)
# Headscale 연결 정보 (느슨한 결합)
headscale_node_id = Column(Integer) # nodes.id 참조 (외래키 제약조건 없음)
headscale_machine_key = Column(String(255)) # nodes.machine_key 참조
pharmacy_id = Column(Integer) # pharmacies.id 참조 (외래키 제약조건 없음)
# 머신 식별 정보
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)) # SSD, HDD, NVMe
storage_gb = Column(Integer)
gpu_model = Column(String(255))
gpu_memory_gb = Column(Integer)
# 네트워크 정보
network_interfaces = Column(JSONType) # 네트워크 인터페이스 목록
tailscale_ip = Column(String(45))
tailscale_status = Column(String(20), default='unknown') # online, offline, unknown
# 운영체제 및 소프트웨어
os_type = Column(String(50)) # Windows, Linux, etc.
os_version = Column(String(100))
tailscale_version = Column(String(50))
installed_software = Column(JSONType) # 설치된 소프트웨어 목록
# 상태 및 관리
status = Column(String(20), default='active') # active, maintenance, retired
location = Column(String(255)) # 물리적 위치
purchase_date = Column(DateTime)
warranty_expires = Column(DateTime)
last_maintenance = Column(DateTime)
# 성능 기준선
baseline_cpu_temp = Column(Float) # 정상 CPU 온도 기준
baseline_cpu_usage = Column(Float) # 정상 CPU 사용률 기준
baseline_memory_usage = Column(Float) # 정상 메모리 사용률 기준
# 타임스탬프
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
last_seen = Column(DateTime) # 마지막 활동 시간
def __repr__(self):
return f"<MachineProfile(id={self.id}, hostname='{self.hostname}', cpu='{self.cpu_model}')>"
def is_online(self, timeout_minutes: int = 10) -> bool:
"""온라인 상태 확인"""
if not self.last_seen:
return False
return (datetime.now() - self.last_seen).total_seconds() < (timeout_minutes * 60)
def to_dict(self) -> Dict[str, Any]:
return {
'id': self.id,
'headscale_node_id': self.headscale_node_id,
'pharmacy_id': self.pharmacy_id,
'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,
'tailscale_ip': self.tailscale_ip,
'tailscale_status': self.tailscale_status,
'os_type': self.os_type,
'os_version': self.os_version,
'status': self.status,
'is_online': self.is_online(),
'created_at': self.created_at.isoformat(),
'last_seen': self.last_seen.isoformat() if self.last_seen else None
}
class MonitoringMetrics(FarmqBase):
"""실시간 모니터링 메트릭스 - 시계열 데이터"""
__tablename__ = 'monitoring_metrics'
__table_args__ = (
Index('idx_machine_timestamp', 'machine_profile_id', 'collected_at'),
Index('idx_collected_at', 'collected_at'),
)
id = Column(Integer, primary_key=True, autoincrement=True)
# 연결 정보
machine_profile_id = Column(Integer) # machine_profiles.id 참조 (외래키 제약조건 없음)
headscale_node_id = Column(Integer) # 빠른 조회를 위한 중복 저장
# 시스템 메트릭스
cpu_usage_percent = Column(Float) # CPU 사용률
memory_usage_percent = Column(Float) # 메모리 사용률
memory_used_gb = Column(Float) # 사용 중인 메모리 (GB)
memory_total_gb = Column(Float) # 총 메모리 (GB)
# 스토리지 메트릭스
disk_usage_percent = Column(Float) # 디스크 사용률
disk_used_gb = Column(Float) # 사용 중인 디스크 (GB)
disk_total_gb = Column(Float) # 총 디스크 (GB)
disk_io_read_mb = Column(Float) # 디스크 읽기 (MB/s)
disk_io_write_mb = Column(Float) # 디스크 쓰기 (MB/s)
# 온도 및 전력
cpu_temperature = Column(Float) # CPU 온도 (섭씨)
gpu_temperature = Column(Float) # GPU 온도 (섭씨)
system_temperature = Column(Float) # 시스템 온도
power_consumption_watts = Column(Float) # 전력 소모 (와트)
# 네트워크 메트릭스
network_rx_bytes_sec = Column(Integer) # 네트워크 수신 (bytes/sec)
network_tx_bytes_sec = Column(Integer) # 네트워크 송신 (bytes/sec)
network_latency_ms = Column(Float) # 네트워크 지연시간 (ms)
# 프로세스 및 서비스
process_count = Column(Integer) # 실행 중인 프로세스 수
service_status = Column(JSONType) # 중요 서비스 상태
# 가상머신 관련 (Proxmox)
vm_count_total = Column(Integer) # 총 VM 수
vm_count_running = Column(Integer) # 실행 중인 VM 수
vm_count_stopped = Column(Integer) # 중지된 VM 수
# 상태 및 알림
alert_level = Column(String(10), default='normal') # normal, warning, critical
alert_message = Column(Text) # 알림 메시지
# 타임스탬프
collected_at = Column(DateTime, default=datetime.now)
def __repr__(self):
return f"<MonitoringMetrics(machine_id={self.machine_profile_id}, cpu={self.cpu_usage_percent}%, collected_at='{self.collected_at}')>"
def to_dict(self) -> Dict[str, Any]:
return {
'id': self.id,
'machine_profile_id': self.machine_profile_id,
'cpu_usage_percent': self.cpu_usage_percent,
'memory_usage_percent': self.memory_usage_percent,
'disk_usage_percent': self.disk_usage_percent,
'cpu_temperature': self.cpu_temperature,
'network_rx_bytes_sec': self.network_rx_bytes_sec,
'network_tx_bytes_sec': self.network_tx_bytes_sec,
'alert_level': self.alert_level,
'collected_at': self.collected_at.isoformat()
}
def get_alert_status(self) -> Dict[str, Any]:
"""알림 상태 및 메시지 반환"""
alerts = []
# CPU 온도 체크
if self.cpu_temperature and self.cpu_temperature > 80:
alerts.append({'type': 'temperature', 'message': f'CPU 온도 높음: {self.cpu_temperature}°C'})
# CPU 사용률 체크
if self.cpu_usage_percent and self.cpu_usage_percent > 90:
alerts.append({'type': 'cpu', 'message': f'CPU 사용률 높음: {self.cpu_usage_percent}%'})
# 메모리 사용률 체크
if self.memory_usage_percent and self.memory_usage_percent > 85:
alerts.append({'type': 'memory', 'message': f'메모리 사용률 높음: {self.memory_usage_percent}%'})
# 디스크 사용률 체크
if self.disk_usage_percent and self.disk_usage_percent > 90:
alerts.append({'type': 'disk', 'message': f'디스크 사용률 높음: {self.disk_usage_percent}%'})
return {
'level': 'critical' if any(alert['type'] in ['temperature', 'cpu'] for alert in alerts) else
'warning' if alerts else 'normal',
'alerts': alerts,
'count': len(alerts)
}
class SystemAlert(FarmqBase):
"""시스템 알림 테이블"""
__tablename__ = 'system_alerts'
__table_args__ = (
Index('idx_alert_status', 'status'),
Index('idx_alert_created', 'created_at'),
Index('idx_alert_severity', 'severity'),
)
id = Column(Integer, primary_key=True, autoincrement=True)
# 연결 정보
machine_profile_id = Column(Integer) # machine_profiles.id 참조
pharmacy_id = Column(Integer) # pharmacies.id 참조
# 알림 정보
alert_type = Column(String(50)) # cpu, memory, disk, temperature, network, service
severity = Column(String(10)) # low, medium, high, critical
title = Column(String(255)) # 알림 제목
message = Column(Text) # 알림 상세 메시지
# 메트릭스 값
current_value = Column(Float) # 현재 값
threshold_value = Column(Float) # 임계값
unit = Column(String(10)) # 단위 (%, GB, °C, etc.)
# 상태 관리
status = Column(String(20), default='active') # active, acknowledged, resolved
acknowledged_by = Column(String(100)) # 확인한 사용자
acknowledged_at = Column(DateTime) # 확인 시간
resolved_at = Column(DateTime) # 해결 시간
# 반복 방지
fingerprint = Column(String(255)) # 중복 알림 방지용 핑거프린트
occurrence_count = Column(Integer, default=1) # 발생 횟수
first_occurred = Column(DateTime, default=datetime.now) # 최초 발생 시간
last_occurred = Column(DateTime, default=datetime.now) # 최근 발생 시간
# 타임스탬프
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
def __repr__(self):
return f"<SystemAlert(id={self.id}, type='{self.alert_type}', severity='{self.severity}', status='{self.status}')>"
def acknowledge(self, user: str = 'system'):
"""알림 확인 처리"""
self.status = 'acknowledged'
self.acknowledged_by = user
self.acknowledged_at = datetime.now()
def resolve(self):
"""알림 해결 처리"""
self.status = 'resolved'
self.resolved_at = datetime.now()
def to_dict(self) -> Dict[str, Any]:
return {
'id': self.id,
'machine_profile_id': self.machine_profile_id,
'pharmacy_id': self.pharmacy_id,
'alert_type': self.alert_type,
'severity': self.severity,
'title': self.title,
'message': self.message,
'current_value': self.current_value,
'threshold_value': self.threshold_value,
'unit': self.unit,
'status': self.status,
'occurrence_count': self.occurrence_count,
'created_at': self.created_at.isoformat(),
'last_occurred': self.last_occurred.isoformat()
}
# ==========================================
# Database Manager Class
# ==========================================
class FarmqDatabaseManager:
"""FARMQ 데이터베이스 관리 클래스"""
def __init__(self, database_url: str = "sqlite:///farmq-admin/farmq.sqlite"):
self.database_url = database_url
self.engine = create_engine(database_url, echo=False)
self.SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=self.engine)
self._create_tables()
def _create_tables(self):
"""테이블 생성"""
FarmqBase.metadata.create_all(self.engine)
def get_session(self) -> Session:
"""세션 생성"""
return self.SessionLocal()
def close_session(self, session: Session):
"""세션 종료"""
session.close()
# ==========================================
# Pharmacy Management
# ==========================================
def get_pharmacy_by_headscale_user(self, headscale_user_name: str) -> Optional[PharmacyInfo]:
"""Headscale 사용자명으로 약국 정보 조회"""
session = self.get_session()
try:
return session.query(PharmacyInfo).filter(
PharmacyInfo.headscale_user_name == headscale_user_name
).first()
finally:
session.close()
def create_or_update_pharmacy(self, pharmacy_data: Dict[str, Any]) -> PharmacyInfo:
"""약국 정보 생성 또는 업데이트"""
session = self.get_session()
try:
pharmacy = session.query(PharmacyInfo).filter(
PharmacyInfo.headscale_user_name == pharmacy_data.get('headscale_user_name')
).first()
if pharmacy:
# 업데이트
for key, value in pharmacy_data.items():
if hasattr(pharmacy, key):
setattr(pharmacy, key, value)
pharmacy.updated_at = datetime.now()
else:
# 생성
pharmacy = PharmacyInfo(**pharmacy_data)
session.add(pharmacy)
session.commit()
session.refresh(pharmacy)
return pharmacy
finally:
session.close()
# ==========================================
# Machine Management
# ==========================================
def sync_machine_from_headscale(self, headscale_node_data: Dict[str, Any]) -> MachineProfile:
"""Headscale 노드 데이터로 머신 프로필 동기화"""
session = self.get_session()
try:
machine = session.query(MachineProfile).filter(
MachineProfile.headscale_node_id == headscale_node_data.get('id')
).first()
if machine:
# 기존 머신 업데이트
machine.hostname = 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()
machine.updated_at = datetime.now()
else:
# 새 머신 생성
machine = MachineProfile(
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'),
tailscale_ip=headscale_node_data.get('ipv4'),
tailscale_status='online' if headscale_node_data.get('is_online') else 'offline',
last_seen=datetime.now()
)
session.add(machine)
session.commit()
session.refresh(machine)
return machine
finally:
session.close()
def get_machine_stats(self) -> Dict[str, int]:
"""머신 통계 조회"""
session = self.get_session()
try:
total = session.query(MachineProfile).count()
online = session.query(MachineProfile).filter(
MachineProfile.tailscale_status == 'online'
).count()
return {
'total': total,
'online': online,
'offline': total - online
}
finally:
session.close()
# ==========================================
# Factory Function
# ==========================================
def create_farmq_database_manager(database_url: str = None) -> FarmqDatabaseManager:
"""FARMQ 데이터베이스 매니저 생성"""
if database_url is None:
database_url = "sqlite:///farmq.sqlite"
return FarmqDatabaseManager(database_url)
if __name__ == "__main__":
# 테스트 실행
manager = create_farmq_database_manager()
print("✅ FARMQ 데이터베이스 매니저 생성 완료")
print(f"📊 데이터베이스 URL: {manager.database_url}")
# 테스트 데이터 생성
test_pharmacy = {
'headscale_user_name': 'test-pharmacy',
'pharmacy_name': '테스트 약국',
'business_number': '123-45-67890',
'manager_name': '김약사',
'phone': '02-1234-5678',
'address': '서울특별시 강남구 테스트동 123',
'proxmox_host': '192.168.1.100'
}
pharmacy = manager.create_or_update_pharmacy(test_pharmacy)
print(f"✅ 테스트 약국 생성: {pharmacy}")
stats = manager.get_machine_stats()
print(f"📈 머신 통계: {stats}")

View File

@@ -0,0 +1,385 @@
"""
Headscale SQLite Database Models for SQLAlchemy
Based on actual schema analysis of Headscale v0.23.0
Generated from: /var/lib/headscale/db.sqlite
Schema Analysis Date: 2025-09-09
"""
from datetime import datetime, timedelta
from typing import Optional, List
import json
from sqlalchemy import (
Column, Integer, String, DateTime, Boolean, Text,
ForeignKey, LargeBinary, Index, UniqueConstraint
)
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import relationship, Session
from sqlalchemy.types import TypeDecorator, TEXT
Base = declarative_base()
class JSONType(TypeDecorator):
"""Custom JSON type for SQLAlchemy that handles JSON serialization"""
impl = TEXT
def process_bind_param(self, value, dialect):
if value is not None:
return json.dumps(value)
return value
def process_result_value(self, value, dialect):
if value is not None:
return json.loads(value)
return value
class Migration(Base):
"""Migration tracking table"""
__tablename__ = 'migrations'
id = Column(String, primary_key=True)
def __repr__(self):
return f"<Migration(id='{self.id}')>"
class User(Base):
"""Headscale Users table
Represents individual users/namespaces in the Headscale network.
Each user can have multiple nodes (machines) associated with them.
"""
__tablename__ = 'users'
__table_args__ = (
Index('idx_users_deleted_at', 'deleted_at'),
Index('idx_provider_identifier', 'provider_identifier',
postgresql_where="provider_identifier IS NOT NULL"),
Index('idx_name_provider_identifier', 'name', 'provider_identifier'),
Index('idx_name_no_provider_identifier', 'name',
postgresql_where="provider_identifier IS NULL"),
)
id = Column(Integer, primary_key=True, autoincrement=True)
created_at = Column(DateTime)
updated_at = Column(DateTime)
deleted_at = Column(DateTime) # Soft delete
name = Column(String) # User identifier (e.g., "myuser")
display_name = Column(String) # Human-readable display name
email = Column(String) # User email address
provider_identifier = Column(String) # External auth provider ID
provider = Column(String) # Auth provider name (OIDC, etc.)
profile_pic_url = Column(String) # Profile picture URL
# Relationships
nodes = relationship("Node", back_populates="user", cascade="all, delete-orphan")
pre_auth_keys = relationship("PreAuthKey", back_populates="user")
def __repr__(self):
return f"<User(id={self.id}, name='{self.name}', display_name='{self.display_name}')>"
def is_deleted(self) -> bool:
"""Check if user is soft-deleted"""
return self.deleted_at is not None
class Node(Base):
"""Headscale Nodes (Machines) table
Represents individual devices/machines connected to the Tailnet.
Each node belongs to a user and has various networking attributes.
"""
__tablename__ = 'nodes'
id = Column(Integer, primary_key=True, autoincrement=True)
machine_key = Column(String) # Machine's public key
node_key = Column(String) # Node's network key
disco_key = Column(String) # Discovery key for peer-to-peer connections
endpoints = Column(JSONType) # List of network endpoints (JSON array)
host_info = Column(JSONType) # Detailed host information (JSON object)
ipv4 = Column(String) # Assigned IPv4 address (e.g., "100.64.0.1")
ipv6 = Column(String) # Assigned IPv6 address
hostname = Column(String) # Machine hostname
given_name = Column(String) # User-assigned machine name
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'))
register_method = Column(String) # Registration method (e.g., "authkey")
forced_tags = Column(JSONType) # Tags forced on this node (JSON array)
auth_key_id = Column(Integer, ForeignKey('pre_auth_keys.id'))
expiry = Column(DateTime) # Node expiration date
last_seen = Column(DateTime) # Last activity timestamp
approved_routes = Column(JSONType) # Approved subnet routes (JSON array)
created_at = Column(DateTime)
updated_at = Column(DateTime)
deleted_at = Column(DateTime) # Soft delete
# Relationships
user = relationship("User", back_populates="nodes")
auth_key = relationship("PreAuthKey")
def __repr__(self):
return f"<Node(id={self.id}, hostname='{self.hostname}', ipv4='{self.ipv4}', user_id={self.user_id})>"
def is_online(self, timeout_minutes: int = 1440) -> bool: # 24 hours timeout like Tailscale
"""Check if node is considered online based on last_seen"""
if not self.last_seen:
return False
# Handle timezone-aware datetime properly
now = datetime.now()
last_seen = self.last_seen
# Convert both to naive datetime to avoid timezone issues
if last_seen.tzinfo is not None:
last_seen = last_seen.replace(tzinfo=None)
if now.tzinfo is not None:
now = now.replace(tzinfo=None)
try:
time_diff_seconds = (now - last_seen).total_seconds()
# Consider online if last seen within timeout_minutes
is_recent = time_diff_seconds < (timeout_minutes * 60)
return is_recent
except TypeError as e:
# Fallback: just check if we have a recent timestamp
return True
def get_host_info(self) -> dict:
"""Get parsed host information"""
return self.host_info or {}
def get_endpoints(self) -> List[str]:
"""Get list of network endpoints"""
return self.endpoints or []
def get_forced_tags(self) -> List[str]:
"""Get list of forced tags"""
return self.forced_tags or []
def get_approved_routes(self) -> List[str]:
"""Get list of approved routes"""
return self.approved_routes or []
class PreAuthKey(Base):
"""Pre-authentication keys table
Keys used for automatic node registration without manual approval.
"""
__tablename__ = 'pre_auth_keys'
id = Column(Integer, primary_key=True, autoincrement=True)
key = Column(String) # The actual pre-auth key string
user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'))
reusable = Column(Boolean) # Can be used multiple times
ephemeral = Column(Boolean, default=False) # Temporary key
used = Column(Boolean, default=False) # Has been used
tags = Column(JSONType) # Tags to apply to nodes using this key
created_at = Column(DateTime)
expiration = Column(DateTime) # When the key expires
# Relationships
user = relationship("User", back_populates="pre_auth_keys")
def __repr__(self):
return f"<PreAuthKey(id={self.id}, key='{self.key[:8]}...', user_id={self.user_id}, reusable={self.reusable})>"
def is_expired(self) -> bool:
"""Check if the pre-auth key is expired"""
if not self.expiration:
return False
now = datetime.now()
expiration = self.expiration
# Handle timezone-aware datetime
if expiration.tzinfo is not None and now.tzinfo is None:
from datetime import timezone
now = now.replace(tzinfo=timezone.utc)
elif expiration.tzinfo is not None:
expiration = expiration.replace(tzinfo=None)
try:
return now > expiration
except TypeError:
return False
def is_valid(self) -> bool:
"""Check if the key is still valid for use"""
if self.is_expired():
return False
if self.used and not self.reusable:
return False
return True
def get_tags(self) -> List[str]:
"""Get list of tags for this key"""
return self.tags or []
class ApiKey(Base):
"""API Keys table
Keys used for API authentication to the Headscale server.
"""
__tablename__ = 'api_keys'
__table_args__ = (
Index('idx_api_keys_prefix', 'prefix', unique=True),
)
id = Column(Integer, primary_key=True, autoincrement=True)
prefix = Column(String) # Key prefix for identification (e.g., "8qRr1IB")
hash = Column(LargeBinary) # Hashed key value
created_at = Column(DateTime)
expiration = Column(DateTime) # When the key expires
last_seen = Column(DateTime) # Last time key was used
def __repr__(self):
return f"<ApiKey(id={self.id}, prefix='{self.prefix}', created_at='{self.created_at}')>"
def is_expired(self) -> bool:
"""Check if the API key is expired"""
if not self.expiration:
return False
now = datetime.now()
expiration = self.expiration
# Handle timezone-aware datetime
if expiration.tzinfo is not None and now.tzinfo is None:
from datetime import timezone
now = now.replace(tzinfo=timezone.utc)
elif expiration.tzinfo is not None:
expiration = expiration.replace(tzinfo=None)
try:
return now > expiration
except TypeError:
return False
class Policy(Base):
"""ACL Policies table
Stores Access Control List policies in JSON format.
"""
__tablename__ = 'policies'
__table_args__ = (
Index('idx_policies_deleted_at', 'deleted_at'),
)
id = Column(Integer, primary_key=True, autoincrement=True)
created_at = Column(DateTime)
updated_at = Column(DateTime)
deleted_at = Column(DateTime) # Soft delete
data = Column(Text) # JSON policy data
def __repr__(self):
return f"<Policy(id={self.id}, created_at='{self.created_at}')>"
def get_policy_data(self) -> dict:
"""Parse and return policy data as dictionary"""
try:
return json.loads(self.data) if self.data else {}
except json.JSONDecodeError:
return {}
# ==========================================
# Extended Models for FARMQ Customization
# ==========================================
# ==========================================
# Extended Models for FARMQ Customization - DEPRECATED
# ==========================================
#
# 주의: 이 모델들은 Headscale과 외래키 충돌을 일으키므로 더 이상 사용하지 않습니다.
# 대신 farmq_models.py의 독립적인 모델들을 사용하세요.
#
# 마이그레이션 가이드:
# - PharmacyInfo -> farmq_models.PharmacyInfo
# - MachineSpecs -> farmq_models.MachineProfile
# - MonitoringData -> farmq_models.MonitoringMetrics
#
# 이 클래스들은 하위 호환성을 위해 유지되지만 실제 테이블은 생성되지 않습니다.
# ==========================================
# Database Helper Functions
# ==========================================
def create_all_tables(engine):
"""Create all tables in the database"""
Base.metadata.create_all(engine)
def get_active_nodes(session: Session) -> List[Node]:
"""Get all non-deleted nodes"""
return session.query(Node).filter(Node.deleted_at.is_(None)).all()
def get_online_nodes(session: Session, timeout_minutes: int = 5) -> List[Node]:
"""Get nodes that are currently online"""
cutoff_time = datetime.now() - timedelta(minutes=timeout_minutes)
return session.query(Node).filter(
Node.deleted_at.is_(None),
Node.last_seen > cutoff_time
).all()
def get_user_with_pharmacy_info(session: Session, user_name: str):
"""Get user with associated pharmacy information"""
return session.query(User).join(PharmacyInfo).filter(User.name == user_name).first()
def get_node_with_specs_and_monitoring(session: Session, node_id: int):
"""Get node with hardware specs and latest monitoring data"""
return session.query(Node)\
.outerjoin(MachineSpecs)\
.outerjoin(MonitoringData)\
.filter(Node.id == node_id)\
.first()
# ==========================================
# Usage Example
# ==========================================
if __name__ == "__main__":
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
# SQLite connection to Headscale database
DATABASE_URL = "sqlite:///data/db.sqlite"
engine = create_engine(DATABASE_URL)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# Create extended tables (if needed)
create_all_tables(engine)
# Example usage
session = SessionLocal()
try:
# Get all users
users = session.query(User).all()
print("=== Users ===")
for user in users:
print(f" {user}")
# Get all nodes
nodes = session.query(Node).all()
print("\n=== Nodes ===")
for node in nodes:
print(f" {node}")
print(f" Online: {node.is_online()}")
print(f" Host Info: {node.get_host_info().get('Hostname', 'Unknown')}")
# Get all API keys
api_keys = session.query(ApiKey).all()
print("\n=== API Keys ===")
for key in api_keys:
print(f" {key}")
print(f" Expired: {key.is_expired()}")
finally:
session.close()

View File

@@ -0,0 +1,8 @@
Flask==3.0.0
SQLAlchemy==2.0.23
Jinja2==3.1.2
Flask-Login==0.6.3
APScheduler==3.10.4
requests==2.31.0
python-dateutil==2.8.2
humanize==4.8.0

View File

@@ -0,0 +1,341 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}팜큐 약국 관리 시스템{% endblock %}</title>
<!-- Bootstrap 5 CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- Font Awesome -->
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
<!-- Chart.js -->
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<!-- Custom CSS -->
<style>
:root {
--farmq-primary: #2c5282;
--farmq-secondary: #4299e1;
--farmq-success: #48bb78;
--farmq-warning: #ed8936;
--farmq-danger: #f56565;
--farmq-light: #f7fafc;
--farmq-dark: #2d3748;
}
body {
background-color: var(--farmq-light);
font-family: 'Noto Sans KR', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.navbar-brand {
font-weight: bold;
color: var(--farmq-primary) !important;
}
.sidebar {
min-height: calc(100vh - 56px);
background: linear-gradient(180deg, var(--farmq-primary) 0%, var(--farmq-secondary) 100%);
}
.sidebar .nav-link {
color: rgba(255, 255, 255, 0.8);
border-radius: 8px;
margin: 2px 0;
transition: all 0.3s ease;
}
.sidebar .nav-link:hover,
.sidebar .nav-link.active {
background-color: rgba(255, 255, 255, 0.1);
color: white;
transform: translateX(5px);
}
.main-content {
padding: 2rem;
}
.card {
border: none;
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
border-radius: 12px;
transition: transform 0.2s ease-in-out;
}
.card:hover {
transform: translateY(-2px);
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
}
.stat-card {
background: linear-gradient(135deg, var(--farmq-primary) 0%, var(--farmq-secondary) 100%);
color: white;
}
.stat-card .card-body {
padding: 2rem;
}
.stat-number {
font-size: 2.5rem;
font-weight: bold;
line-height: 1;
}
.stat-label {
font-size: 0.9rem;
opacity: 0.9;
}
.alert-item {
border-left: 4px solid;
border-radius: 0 8px 8px 0;
margin-bottom: 0.5rem;
}
.alert-warning {
border-left-color: var(--farmq-warning);
}
.alert-danger {
border-left-color: var(--farmq-danger);
}
.status-online {
color: var(--farmq-success);
}
.status-offline {
color: var(--farmq-danger);
}
.status-warning {
color: var(--farmq-warning);
}
.footer {
background-color: var(--farmq-dark);
color: white;
text-align: center;
padding: 1rem;
margin-top: 3rem;
}
/* 반응형 디자인 */
@media (max-width: 768px) {
.sidebar {
min-height: auto;
}
.main-content {
padding: 1rem;
}
.stat-number {
font-size: 1.8rem;
}
}
</style>
{% block extra_css %}{% endblock %}
</head>
<body>
<!-- 상단 네비게이션 -->
<nav class="navbar navbar-expand-lg navbar-light bg-white shadow-sm">
<div class="container-fluid">
<a class="navbar-brand" href="{{ url_for('dashboard') }}">
<i class="fas fa-hospital"></i> 팜큐 약국 관리 시스템
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav ms-auto">
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
<i class="fas fa-user-circle"></i> 관리자
</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="#"><i class="fas fa-cog"></i> 설정</a></li>
<li><a class="dropdown-item" href="#"><i class="fas fa-question-circle"></i> 도움말</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="#"><i class="fas fa-sign-out-alt"></i> 로그아웃</a></li>
</ul>
</li>
</ul>
</div>
</div>
</nav>
<div class="container-fluid">
<div class="row">
<!-- 사이드바 -->
<nav class="col-md-3 col-lg-2 d-md-block sidebar collapse">
<div class="position-sticky pt-3">
<ul class="nav flex-column">
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'dashboard' %}active{% endif %}" href="{{ url_for('dashboard') }}">
<i class="fas fa-tachometer-alt"></i> 대시보드
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint and 'pharmacy' in request.endpoint %}active{% endif %}" href="{{ url_for('pharmacy_list') }}">
<i class="fas fa-store"></i> 약국 관리
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint and 'machine' in request.endpoint %}active{% endif %}" href="{{ url_for('machine_list') }}">
<i class="fas fa-desktop"></i> 머신 관리
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#">
<i class="fas fa-users"></i> 사용자 관리
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#">
<i class="fas fa-chart-line"></i> 모니터링
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#">
<i class="fas fa-cog"></i> 설정
</a>
</li>
</ul>
<hr class="my-3" style="border-color: rgba(255,255,255,0.2);">
<!-- 빠른 링크 -->
<ul class="nav flex-column">
<li class="nav-item">
<a class="nav-link" href="http://localhost:3000/admin/" target="_blank">
<i class="fas fa-external-link-alt"></i> Headplane UI
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#" onclick="refreshData()">
<i class="fas fa-sync-alt"></i> 데이터 새로고침
</a>
</li>
</ul>
</div>
</nav>
<!-- 메인 콘텐츠 -->
<main class="col-md-9 ms-sm-auto col-lg-10 main-content">
{% block breadcrumb %}{% endblock %}
<!-- 알림 메시지 -->
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</main>
</div>
</div>
<!-- 푸터 -->
<footer class="footer">
<div class="container">
<span>&copy; 2025 팜큐(FARMQ). Powered by Flask + Headscale</span>
</div>
</footer>
<!-- Bootstrap 5 JS -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<!-- 공통 JavaScript -->
<script>
// 데이터 새로고침
function refreshData() {
location.reload();
}
// 실시간 업데이트 (5초마다)
setInterval(function() {
// 현재 페이지가 대시보드인 경우 실시간 업데이트
if (window.location.pathname === '/') {
updateDashboardStats();
}
}, 5000);
function updateDashboardStats() {
fetch('/api/dashboard/stats')
.then(response => response.json())
.then(data => {
// 통계 업데이트
document.getElementById('total-pharmacies').textContent = data.total_pharmacies;
document.getElementById('online-machines').textContent = data.online_machines;
document.getElementById('offline-machines').textContent = data.offline_machines;
document.getElementById('avg-temp').textContent = data.avg_cpu_temp + '°C';
})
.catch(error => console.error('Stats update failed:', error));
}
// 차트 생성 함수
function createDoughnutChart(elementId, value, label, color) {
const ctx = document.getElementById(elementId);
if (!ctx) return;
new Chart(ctx, {
type: 'doughnut',
data: {
datasets: [{
data: [value, 100 - value],
backgroundColor: [color, '#e2e8f0'],
borderWidth: 0
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
cutout: '75%',
plugins: {
legend: {
display: false
}
}
}
});
}
// 토스트 알림 표시
function showToast(message, type = 'info') {
const toastHtml = `
<div class="toast align-items-center text-bg-${type} border-0" role="alert">
<div class="d-flex">
<div class="toast-body">${message}</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
</div>
</div>
`;
const toastContainer = document.getElementById('toast-container');
if (toastContainer) {
toastContainer.insertAdjacentHTML('beforeend', toastHtml);
const toast = new bootstrap.Toast(toastContainer.lastElementChild);
toast.show();
}
}
</script>
{% block extra_js %}{% endblock %}
<!-- 토스트 컨테이너 -->
<div id="toast-container" class="toast-container position-fixed top-0 end-0 p-3" style="z-index: 1200;"></div>
</body>
</html>

View File

@@ -0,0 +1,277 @@
{% extends "base.html" %}
{% block title %}대시보드 - 팜큐 약국 관리 시스템{% endblock %}
{% block breadcrumb %}
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item active">
<i class="fas fa-tachometer-alt"></i> 대시보드
</li>
</ol>
</nav>
{% endblock %}
{% block content %}
<div class="row mb-4">
<div class="col-12">
<h1 class="h2 mb-0">
<i class="fas fa-tachometer-alt text-primary"></i>
대시보드
</h1>
<p class="text-muted">팜큐 약국 네트워크 전체 현황</p>
</div>
</div>
<!-- 통계 카드 -->
<div class="row mb-4">
<div class="col-lg-3 col-md-6 mb-3">
<div class="card stat-card">
<div class="card-body text-center">
<div class="stat-number" id="total-pharmacies">{{ stats.total_pharmacies }}</div>
<div class="stat-label">
<i class="fas fa-store"></i> 총 약국 수
</div>
</div>
</div>
</div>
<div class="col-lg-3 col-md-6 mb-3">
<div class="card" style="background: linear-gradient(135deg, #48bb78 0%, #38a169 100%); color: white;">
<div class="card-body text-center">
<div class="stat-number" id="online-machines">{{ stats.online_machines }}</div>
<div class="stat-label">
<i class="fas fa-circle text-success"></i> 온라인 머신
</div>
</div>
</div>
</div>
<div class="col-lg-3 col-md-6 mb-3">
<div class="card" style="background: linear-gradient(135deg, #f56565 0%, #e53e3e 100%); color: white;">
<div class="card-body text-center">
<div class="stat-number" id="offline-machines">{{ stats.offline_machines }}</div>
<div class="stat-label">
<i class="fas fa-circle text-danger"></i> 오프라인 머신
</div>
</div>
</div>
</div>
<div class="col-lg-3 col-md-6 mb-3">
<div class="card" style="background: linear-gradient(135deg, #ed8936 0%, #dd6b20 100%); color: white;">
<div class="card-body text-center">
<div class="stat-number" id="avg-temp">{{ stats.avg_cpu_temp }}°C</div>
<div class="stat-label">
<i class="fas fa-thermometer-half"></i> 평균 CPU 온도
</div>
</div>
</div>
</div>
</div>
<div class="row">
<!-- 실시간 알림 -->
<div class="col-lg-6 mb-4">
<div class="card h-100">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">
<i class="fas fa-exclamation-triangle text-warning"></i> 실시간 알림
</h5>
<span class="badge bg-primary">{{ stats.alerts|length }}</span>
</div>
<div class="card-body">
{% if stats.alerts %}
{% for alert in stats.alerts %}
<div class="alert-item p-3 mb-2 bg-light {% if alert.type == 'warning' %}alert-warning{% elif alert.type == 'danger' %}alert-danger{% endif %}">
<div class="d-flex justify-content-between align-items-center">
<div>
<strong>
{% if alert.level == 'high_temperature' %}
<i class="fas fa-thermometer-full text-danger"></i>
{% elif alert.level == 'high_disk' %}
<i class="fas fa-hdd text-warning"></i>
{% else %}
<i class="fas fa-exclamation-triangle"></i>
{% endif %}
{{ alert.machine.hostname }}
</strong>
<div class="small text-muted">{{ alert.message }}</div>
</div>
<div class="text-end">
<span class="badge bg-{{ alert.type }}">
{{ alert.value }}{% if alert.level == 'high_temperature' %}°C{% else %}%{% endif %}
</span>
</div>
</div>
</div>
{% endfor %}
{% else %}
<div class="text-center text-muted py-4">
<i class="fas fa-check-circle fa-3x mb-3 text-success"></i>
<p>모든 시스템이 정상 작동 중입니다.</p>
</div>
{% endif %}
</div>
</div>
</div>
<!-- 성능 차트 -->
<div class="col-lg-6 mb-4">
<div class="card h-100">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-chart-pie text-info"></i> 전체 성능 현황
</h5>
</div>
<div class="card-body">
<div class="row text-center">
<div class="col-6 mb-3">
<div class="position-relative">
<canvas id="cpuChart" width="100" height="100"></canvas>
<div class="position-absolute top-50 start-50 translate-middle">
<div class="fw-bold">{{ "%.1f"|format(stats.avg_cpu_temp) }}°C</div>
<div class="small text-muted">CPU</div>
</div>
</div>
</div>
<div class="col-6 mb-3">
<div class="position-relative">
<canvas id="memoryChart" width="100" height="100"></canvas>
<div class="position-absolute top-50 start-50 translate-middle">
<div class="fw-bold">75.0%</div>
<div class="small text-muted">메모리</div>
</div>
</div>
</div>
<div class="col-6">
<div class="position-relative">
<canvas id="diskChart" width="100" height="100"></canvas>
<div class="position-absolute top-50 start-50 translate-middle">
<div class="fw-bold">60.0%</div>
<div class="small text-muted">디스크</div>
</div>
</div>
</div>
<div class="col-6">
<div class="text-center">
<div class="display-4">🌡️</div>
<div class="fw-bold">{{ "%.1f"|format(stats.avg_cpu_temp) }}°C</div>
<div class="small text-muted">평균 온도</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 약국별 상태 -->
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">
<i class="fas fa-store text-primary"></i> 약국별 상태
</h5>
<a href="{{ url_for('pharmacy_list') }}" class="btn btn-outline-primary btn-sm">
<i class="fas fa-list"></i> 전체 보기
</a>
</div>
<div class="card-body">
{% if pharmacies %}
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>약국명</th>
<th>Headscale 사용자</th>
<th>사업자번호</th>
<th>연결된 머신</th>
<th>온라인 상태</th>
<th>액션</th>
</tr>
</thead>
<tbody>
{% for pharmacy_data in pharmacies %}
<tr>
<td>
<strong>{{ pharmacy_data.pharmacy_name }}</strong><br>
<small class="text-muted">{{ pharmacy_data.manager_name }}</small>
</td>
<td>
<code class="text-primary">{{ pharmacy_data.headscale_user_name }}</code>
</td>
<td>{{ pharmacy_data.business_number }}</td>
<td>
<span class="badge bg-info">{{ pharmacy_data.machine_count }}대</span>
</td>
<td>
<div class="d-flex align-items-center">
<div class="progress me-2" style="width: 100px; height: 8px;">
<div class="progress-bar bg-success"
style="width: {{ (pharmacy_data.online_count / pharmacy_data.machine_count * 100) if pharmacy_data.machine_count > 0 else 0 }}%"></div>
</div>
<small>{{ pharmacy_data.online_count }}/{{ pharmacy_data.machine_count }}</small>
</div>
</td>
<td>
<div class="btn-group btn-group-sm">
<a href="{{ url_for('pharmacy_detail', pharmacy_id=pharmacy_data.id) }}"
class="btn btn-outline-primary">상세</a>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center text-muted py-4">
<i class="fas fa-store fa-3x mb-3"></i>
<p>등록된 약국이 없습니다.</p>
<a href="{{ url_for('pharmacy_list') }}" class="btn btn-primary">
<i class="fas fa-plus"></i> 약국 등록하기
</a>
</div>
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
// 성능 차트 생성
document.addEventListener('DOMContentLoaded', function() {
createDoughnutChart('cpuChart', {{ stats.avg_cpu_temp }}, '온도', '#3b82f6');
createDoughnutChart('memoryChart', 75, '메모리', '#10b981');
createDoughnutChart('diskChart', 60, '디스크', '#f59e0b');
});
// 실시간 알림 업데이트
function updateAlerts() {
fetch('/api/alerts')
.then(response => response.json())
.then(alerts => {
// 알림 개수 업데이트
const alertBadge = document.querySelector('.card-header .badge');
if (alertBadge) {
alertBadge.textContent = alerts.length;
}
// 새로운 알림이 있으면 토스트 표시
alerts.forEach(alert => {
if (!document.querySelector(`[data-machine-id="${alert.machine.id}"]`)) {
showToast(`${alert.machine.hostname}: ${alert.message}`, alert.type);
}
});
})
.catch(error => console.error('Alert update failed:', error));
}
// 알림 업데이트 (30초마다)
setInterval(updateAlerts, 30000);
</script>
{% endblock %}

View File

@@ -0,0 +1,39 @@
{% extends "base.html" %}
{% block title %}오류 - 팜큐 약국 관리 시스템{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-6">
<div class="card">
<div class="card-body text-center py-5">
<div class="mb-4">
{% if error_code == 404 %}
<i class="fas fa-search fa-5x text-warning mb-3"></i>
<h1 class="display-4">404</h1>
<h4>페이지를 찾을 수 없습니다</h4>
{% elif error_code == 500 %}
<i class="fas fa-exclamation-triangle fa-5x text-danger mb-3"></i>
<h1 class="display-4">500</h1>
<h4>내부 서버 오류</h4>
{% else %}
<i class="fas fa-times-circle fa-5x text-danger mb-3"></i>
<h4>오류가 발생했습니다</h4>
{% endif %}
</div>
<p class="text-muted mb-4">{{ error }}</p>
<div class="d-grid gap-2 d-md-block">
<a href="{{ url_for('dashboard') }}" class="btn btn-primary">
<i class="fas fa-home"></i> 대시보드로 돌아가기
</a>
<button onclick="history.back()" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left"></i> 이전 페이지
</button>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,388 @@
{% extends "base.html" %}
{% block title %}머신 상세 정보 - 팜큐 약국 관리 시스템{% endblock %}
{% block breadcrumb %}
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{{ url_for('dashboard') }}">대시보드</a></li>
<li class="breadcrumb-item"><a href="{{ url_for('machine_list') }}">머신 관리</a></li>
<li class="breadcrumb-item active">{{ machine.given_name or machine.hostname }}</li>
</ol>
</nav>
{% endblock %}
{% block content %}
<!-- 머신 정보 헤더 -->
<div class="row mb-4">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center">
<div class="me-3">
{% if is_online %}
<i class="fas fa-desktop fa-3x text-success"></i>
{% else %}
<i class="fas fa-desktop fa-3x text-muted"></i>
{% endif %}
</div>
<div>
<h1 class="h2 mb-0">{{ machine.given_name or machine.hostname }}</h1>
<p class="text-muted mb-1">{{ machine.hostname }}</p>
<div class="d-flex gap-2 align-items-center">
{% if is_online %}
<span class="badge bg-success">
<i class="fas fa-circle"></i> 온라인
</span>
{% else %}
<span class="badge bg-danger">
<i class="fas fa-circle"></i> 오프라인
</span>
{% endif %}
<small class="text-muted">마지막 접속: {{ last_seen_humanized }}</small>
</div>
</div>
</div>
<div class="d-flex gap-2">
<button class="btn btn-outline-secondary" onclick="refreshMachineDetail()">
<i class="fas fa-sync-alt"></i> 새로고침
</button>
{% if is_online %}
<button class="btn btn-outline-warning">
<i class="fas fa-redo"></i> 재시작
</button>
{% endif %}
<button class="btn btn-outline-info" onclick="showMonitoringModal()">
<i class="fas fa-chart-line"></i> 실시간 모니터링
</button>
</div>
</div>
</div>
</div>
<!-- 기본 정보 및 네트워크 -->
<div class="row mb-4">
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h5 class="mb-0"><i class="fas fa-info-circle"></i> 기본 정보</h5>
</div>
<div class="card-body">
<table class="table table-borderless">
<tr>
<th width="30%">머신 ID</th>
<td>{{ machine.id }}</td>
</tr>
<tr>
<th>호스트명</th>
<td>{{ machine.hostname }}</td>
</tr>
<tr>
<th>표시 이름</th>
<td>{{ machine.given_name or '미설정' }}</td>
</tr>
<tr>
<th>사용자</th>
<td>
{% if machine.user %}
<span class="badge bg-primary">{{ machine.user.name }}</span>
{% else %}
<span class="text-muted">미지정</span>
{% endif %}
</td>
</tr>
<tr>
<th>등록 방식</th>
<td>{{ machine.register_method or '알 수 없음' }}</td>
</tr>
<tr>
<th>등록일</th>
<td>{{ machine.created_at.strftime('%Y년 %m월 %d일 %H:%M') if machine.created_at else '알 수 없음' }}</td>
</tr>
</table>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h5 class="mb-0"><i class="fas fa-network-wired"></i> 네트워크 정보</h5>
</div>
<div class="card-body">
<table class="table table-borderless">
<tr>
<th width="30%">IPv4 주소</th>
<td><code>{{ machine.ipv4 }}</code></td>
</tr>
{% if machine.ipv6 %}
<tr>
<th>IPv6 주소</th>
<td><code class="small">{{ machine.ipv6 }}</code></td>
</tr>
{% endif %}
<tr>
<th>엔드포인트</th>
<td>
{% if machine.get_endpoints() %}
<div class="small">
{% for endpoint in machine.get_endpoints()[:3] %}
<div><code>{{ endpoint }}</code></div>
{% endfor %}
{% if machine.get_endpoints()|length > 3 %}
<div class="text-muted">... 및 {{ machine.get_endpoints()|length - 3 }}개 더</div>
{% endif %}
</div>
{% else %}
<span class="text-muted">없음</span>
{% endif %}
</td>
</tr>
<tr>
<th>마지막 접속</th>
<td>
{% if machine.last_seen %}
{{ machine.last_seen.strftime('%Y-%m-%d %H:%M:%S') }}
<br><small class="text-muted">{{ last_seen_humanized }}</small>
{% else %}
<span class="text-muted">알 수 없음</span>
{% endif %}
</td>
</tr>
</table>
</div>
</div>
</div>
</div>
<!-- 하드웨어 사양 -->
{% if specs %}
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0"><i class="fas fa-microchip"></i> 하드웨어 사양</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-3">
<div class="text-center">
<i class="fas fa-microchip fa-2x text-primary mb-2"></i>
<h6>CPU</h6>
<p class="mb-1">{{ specs.cpu_model }}</p>
<small class="text-muted">{{ specs.cpu_cores }}코어</small>
</div>
</div>
<div class="col-md-3">
<div class="text-center">
<i class="fas fa-memory fa-2x text-success mb-2"></i>
<h6>메모리</h6>
<p class="mb-1">{{ specs.ram_gb }}GB</p>
<small class="text-muted">RAM</small>
</div>
</div>
<div class="col-md-3">
<div class="text-center">
<i class="fas fa-hdd fa-2x text-warning mb-2"></i>
<h6>저장소</h6>
<p class="mb-1">{{ specs.storage_gb }}GB</p>
<small class="text-muted">디스크</small>
</div>
</div>
<div class="col-md-3">
<div class="text-center">
<i class="fas fa-wifi fa-2x text-info mb-2"></i>
<h6>네트워크</h6>
<p class="mb-1">{{ specs.network_speed }}Mbps</p>
<small class="text-muted">{{ specs.os_info or '알 수 없음' }}</small>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endif %}
<!-- 현재 상태 모니터링 -->
{% if latest_monitoring %}
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0"><i class="fas fa-chart-line"></i> 현재 상태</h5>
<small class="text-muted">최종 업데이트: {{ latest_monitoring.collected_at.strftime('%Y-%m-%d %H:%M:%S') }}</small>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-3">
<div class="text-center">
<canvas id="cpuChart" width="100" height="100"></canvas>
<h6 class="mt-2">CPU 사용률</h6>
<span class="h4">{{ "%.1f"|format(latest_monitoring.cpu_usage|float) }}%</span>
</div>
</div>
<div class="col-md-3">
<div class="text-center">
<canvas id="memoryChart" width="100" height="100"></canvas>
<h6 class="mt-2">메모리 사용률</h6>
<span class="h4">{{ "%.1f"|format(latest_monitoring.memory_usage|float) }}%</span>
</div>
</div>
<div class="col-md-3">
<div class="text-center">
<canvas id="diskChart" width="100" height="100"></canvas>
<h6 class="mt-2">디스크 사용률</h6>
<span class="h4">{{ "%.1f"|format(latest_monitoring.disk_usage|float) }}%</span>
</div>
</div>
<div class="col-md-3">
<div class="text-center">
<div class="mb-2">
<i class="fas fa-thermometer-half fa-3x
{% if latest_monitoring.cpu_temperature > 80 %}text-danger
{% elif latest_monitoring.cpu_temperature > 70 %}text-warning
{% else %}text-success{% endif %}"></i>
</div>
<h6>CPU 온도</h6>
<span class="h4
{% if latest_monitoring.cpu_temperature > 80 %}text-danger
{% elif latest_monitoring.cpu_temperature > 70 %}text-warning
{% else %}text-success{% endif %}">
{{ latest_monitoring.cpu_temperature }}°C
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endif %}
<!-- 소속 약국 정보 -->
{% if pharmacy %}
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0"><i class="fas fa-store"></i> 소속 약국</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<h6>{{ pharmacy.pharmacy_name }}</h6>
<p class="text-muted">{{ pharmacy.address or '주소 미등록' }}</p>
</div>
<div class="col-md-6">
<div class="d-flex justify-content-between">
<div>
<strong>담당자:</strong> {{ pharmacy.manager_name or '미등록' }}
</div>
<div>
<strong>연락처:</strong> {{ pharmacy.phone or '미등록' }}
</div>
</div>
<div class="mt-2">
<a href="{{ url_for('pharmacy_detail', pharmacy_id=pharmacy.id) }}"
class="btn btn-outline-primary btn-sm">
<i class="fas fa-eye"></i> 약국 상세 보기
</a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endif %}
<!-- 실시간 모니터링 모달 -->
<div class="modal fade" id="monitoringModal" tabindex="-1">
<div class="modal-dialog modal-xl">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="fas fa-chart-line"></i> 실시간 모니터링 - {{ machine.hostname }}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="row">
<div class="col-md-6">
<canvas id="realtimeCpuChart"></canvas>
</div>
<div class="col-md-6">
<canvas id="realtimeMemoryChart"></canvas>
</div>
</div>
<div class="mt-3 text-center">
<div id="monitoringStatus" class="alert alert-info">
실시간 데이터를 불러오는 중...
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">닫기</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
let monitoringModal;
document.addEventListener('DOMContentLoaded', function() {
monitoringModal = new bootstrap.Modal(document.getElementById('monitoringModal'));
{% if latest_monitoring %}
// 도넛 차트 생성
createDoughnutChart('cpuChart', {{ latest_monitoring.cpu_usage|float }}, 'CPU', '#007bff');
createDoughnutChart('memoryChart', {{ latest_monitoring.memory_usage|float }}, 'Memory', '#28a745');
createDoughnutChart('diskChart', {{ latest_monitoring.disk_usage|float }}, 'Disk', '#ffc107');
{% endif %}
});
function refreshMachineDetail() {
showToast('머신 정보를 새로고침 중...', 'info');
setTimeout(() => {
location.reload();
}, 1000);
}
function showMonitoringModal() {
monitoringModal.show();
loadRealtimeData();
}
function loadRealtimeData() {
fetch(`/api/machines/{{ machine.id }}/monitoring`)
.then(response => response.json())
.then(data => {
document.getElementById('monitoringStatus').innerHTML =
`<i class="fas fa-check-circle"></i> 최근 ${data.length}개 데이터 포인트 로드됨`;
document.getElementById('monitoringStatus').className = 'alert alert-success';
// 실시간 차트 업데이트 (구현 예정)
console.log('Monitoring data:', data);
})
.catch(error => {
document.getElementById('monitoringStatus').innerHTML =
`<i class="fas fa-exclamation-triangle"></i> 데이터 로드 실패: ${error.message}`;
document.getElementById('monitoringStatus').className = 'alert alert-danger';
});
}
// 10초마다 현재 상태 업데이트
setInterval(() => {
if ({{ machine.id }}) {
updateCurrentStatus({{ machine.id }});
}
}, 10000);
function updateCurrentStatus(machineId) {
// 실시간 상태 업데이트 구현 (향후)
}
</script>
{% endblock %}

View File

@@ -0,0 +1,397 @@
{% extends "base.html" %}
{% block title %}머신 관리 - 팜큐 약국 관리 시스템{% endblock %}
{% block breadcrumb %}
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{{ url_for('dashboard') }}">대시보드</a></li>
<li class="breadcrumb-item active">머신 관리</li>
</ol>
</nav>
{% endblock %}
{% block content %}
<div class="row mb-4">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center">
<div>
<h1 class="h2 mb-0">
<i class="fas fa-desktop text-primary"></i>
머신 관리
</h1>
<p class="text-muted">연결된 모든 머신의 상태 및 하드웨어 정보</p>
</div>
<div class="d-flex gap-2">
<button class="btn btn-outline-secondary" onclick="refreshMachineList()">
<i class="fas fa-sync-alt"></i> 새로고침
</button>
<div class="btn-group" role="group">
<input type="radio" class="btn-check" name="viewMode" id="listView" autocomplete="off" checked>
<label class="btn btn-outline-primary" for="listView">
<i class="fas fa-list"></i> 목록
</label>
<input type="radio" class="btn-check" name="viewMode" id="cardView" autocomplete="off">
<label class="btn btn-outline-primary" for="cardView">
<i class="fas fa-th-large"></i> 카드
</label>
</div>
</div>
</div>
</div>
</div>
<!-- 필터 및 검색 -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-body">
<div class="row align-items-center">
<div class="col-md-3 mb-2">
<div class="input-group">
<span class="input-group-text"><i class="fas fa-search"></i></span>
<input type="text" class="form-control" id="searchMachine" placeholder="머신 검색...">
</div>
</div>
<div class="col-md-2 mb-2">
<select class="form-select" id="filterStatus">
<option value="">전체 상태</option>
<option value="online">온라인</option>
<option value="offline">오프라인</option>
</select>
</div>
<div class="col-md-2 mb-2">
<select class="form-select" id="filterPharmacy">
<option value="">전체 약국</option>
<!-- 약국 목록은 동적으로 로드 -->
</select>
</div>
<div class="col-md-3 mb-2">
<div class="d-flex gap-2">
<span class="badge bg-success">온라인: <span id="onlineCount">0</span></span>
<span class="badge bg-danger">오프라인: <span id="offlineCount">0</span></span>
<span class="badge bg-secondary">전체: <span id="totalCount">0</span></span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 머신 목록 (테이블 뷰) -->
<div id="listView" class="machine-view">
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-body">
{% if machines %}
<div class="table-responsive">
<table class="table table-hover">
<thead class="table-light">
<tr>
<th>머신 정보</th>
<th>네트워크</th>
<th>하드웨어</th>
<th>상태</th>
<th>소속 약국</th>
<th>액션</th>
</tr>
</thead>
<tbody>
{% for machine_data in machines %}
<tr class="machine-row" data-status="{% if machine_data.is_online %}online{% else %}offline{% endif %}">
<td>
<div class="d-flex align-items-center">
<div class="me-3">
{% if machine_data.is_online %}
<i class="fas fa-desktop fa-2x text-success"></i>
{% else %}
<i class="fas fa-desktop fa-2x text-muted"></i>
{% endif %}
</div>
<div>
<strong>{{ machine_data.machine_name or machine_data.hostname }}</strong>
<div class="small text-muted">{{ machine_data.hostname }}</div>
<div class="small">
<i class="fas fa-user"></i> {{ machine_data.headscale_user_name or '미지정' }}
</div>
</div>
</div>
</td>
<td>
<div>
<code class="small">{{ machine_data.tailscale_ip }}</code>
</div>
{% if machine_data.ipv6 %}
<div>
<code class="small text-muted">{{ machine_data.ipv6 }}</code>
</div>
{% endif %}
<div class="small text-muted">
엔드포인트: 0개
</div>
</td>
<td>
{% if machine_data.specs %}
<div class="small">
<div><i class="fas fa-microchip"></i> {{ machine_data.specs.cpu_model[:20] }}{% if machine_data.specs.cpu_model|length > 20 %}...{% endif %}</div>
<div><i class="fas fa-memory"></i> {{ machine_data.specs.ram_gb }}GB RAM</div>
<div><i class="fas fa-hdd"></i> {{ machine_data.specs.storage_gb }}GB</div>
</div>
{% else %}
<span class="text-muted small">정보 없음</span>
{% endif %}
</td>
<td>
<div class="d-flex flex-column">
{% if machine_data.is_online %}
<span class="badge bg-success mb-1">
<i class="fas fa-circle"></i> 온라인
</span>
{% else %}
<span class="badge bg-danger mb-1">
<i class="fas fa-circle"></i> 오프라인
</span>
{% endif %}
{% if machine_data.latest_monitoring %}
<div class="small">
<div>CPU: {{ machine_data.latest_monitoring.cpu_usage }}%</div>
<div>온도: {{ machine_data.latest_monitoring.cpu_temperature }}°C</div>
</div>
{% endif %}
</div>
</td>
<td>
{% if machine_data.pharmacy %}
<div>
<strong>{{ machine_data.pharmacy.pharmacy_name }}</strong>
<div class="small text-muted">{{ machine_data.pharmacy.manager_name }}</div>
</div>
{% else %}
<span class="text-muted">미지정</span>
{% endif %}
</td>
<td>
<div class="btn-group btn-group-sm">
<a href="{{ url_for('machine_detail', machine_id=machine_data.id) }}"
class="btn btn-outline-primary" title="상세 정보">
<i class="fas fa-eye"></i>
</a>
<button class="btn btn-outline-info"
onclick="showMonitoring({{ machine_data.id }})" title="모니터링">
<i class="fas fa-chart-line"></i>
</button>
{% if machine_data.is_online %}
<button class="btn btn-outline-warning" title="재시작">
<i class="fas fa-redo"></i>
</button>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center text-muted py-5">
<i class="fas fa-desktop fa-4x mb-4 text-secondary"></i>
<h4>연결된 머신이 없습니다</h4>
<p class="mb-4">아직 등록된 머신이 없습니다. Headscale에 머신을 연결해주세요.</p>
<a href="http://localhost:3000/admin/" target="_blank" class="btn btn-primary">
<i class="fas fa-external-link-alt"></i> Headplane에서 머신 등록
</a>
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
<!-- 머신 목록 (카드 뷰) -->
<div id="cardView" class="machine-view d-none">
<div class="row">
{% for machine_data in machines %}
<div class="col-lg-4 col-md-6 mb-4">
<div class="card h-100 machine-card" data-status="{% if machine_data.is_online %}online{% else %}offline{% endif %}">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-3">
<div>
<h5 class="card-title mb-1">{{ machine_data.machine_name or machine_data.hostname }}</h5>
<p class="card-text text-muted small">{{ machine_data.hostname }}</p>
</div>
{% if machine_data.is_online %}
<span class="badge bg-success">온라인</span>
{% else %}
<span class="badge bg-danger">오프라인</span>
{% endif %}
</div>
<div class="mb-3">
<div class="small mb-2">
<i class="fas fa-network-wired"></i> {{ machine_data.tailscale_ip }}
</div>
{% if machine_data.pharmacy %}
<div class="small mb-2">
<i class="fas fa-store"></i> {{ machine_data.pharmacy.pharmacy_name }}
</div>
{% endif %}
<div class="small">
<i class="fas fa-clock"></i> {{ machine_data.last_seen_humanized }}
</div>
</div>
{% if machine_data.specs %}
<div class="mb-3">
<hr>
<div class="row text-center">
<div class="col-4">
<div class="small text-muted">CPU</div>
<div class="small">{{ machine_data.specs.cpu_cores }}코어</div>
</div>
<div class="col-4">
<div class="small text-muted">RAM</div>
<div class="small">{{ machine_data.specs.ram_gb }}GB</div>
</div>
<div class="col-4">
<div class="small text-muted">Storage</div>
<div class="small">{{ machine_data.specs.storage_gb }}GB</div>
</div>
</div>
</div>
{% endif %}
{% if machine_data.latest_monitoring %}
<div class="mb-3">
<div class="row">
<div class="col-6">
<div class="small text-muted">CPU 사용률</div>
<div class="progress" style="height: 6px;">
<div class="progress-bar bg-primary"
style="width: {{ machine_data.latest_monitoring.cpu_usage }}%"></div>
</div>
<div class="small">{{ machine_data.latest_monitoring.cpu_usage }}%</div>
</div>
<div class="col-6">
<div class="small text-muted">온도</div>
<div class="text-center">
<span class="h6 {% if machine_data.latest_monitoring.cpu_temperature > 80 %}text-danger{% elif machine_data.latest_monitoring.cpu_temperature > 70 %}text-warning{% else %}text-success{% endif %}">
{{ machine_data.latest_monitoring.cpu_temperature }}°C
</span>
</div>
</div>
</div>
</div>
{% endif %}
</div>
<div class="card-footer bg-transparent">
<div class="d-grid gap-2 d-md-block">
<a href="{{ url_for('machine_detail', machine_id=machine_data.id) }}"
class="btn btn-outline-primary btn-sm">
<i class="fas fa-eye"></i> 상세
</a>
<button class="btn btn-outline-info btn-sm"
onclick="showMonitoring({{ machine_data.id }})">
<i class="fas fa-chart-line"></i> 모니터링
</button>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
// 뷰 모드 전환
document.querySelectorAll('input[name="viewMode"]').forEach(radio => {
radio.addEventListener('change', function() {
document.querySelectorAll('.machine-view').forEach(view => {
view.classList.add('d-none');
});
if (this.id === 'listView') {
document.getElementById('listView').classList.remove('d-none');
} else {
document.getElementById('cardView').classList.remove('d-none');
}
});
});
// 머신 검색
document.getElementById('searchMachine').addEventListener('input', function() {
const searchTerm = this.value.toLowerCase();
filterMachines();
});
// 상태 필터
document.getElementById('filterStatus').addEventListener('change', function() {
filterMachines();
});
function filterMachines() {
const searchTerm = document.getElementById('searchMachine').value.toLowerCase();
const statusFilter = document.getElementById('filterStatus').value;
let visibleCount = 0;
let onlineCount = 0;
let offlineCount = 0;
document.querySelectorAll('.machine-row, .machine-card').forEach(element => {
const machineText = element.textContent.toLowerCase();
const machineStatus = element.dataset.status;
let showElement = true;
// 검색어 필터
if (searchTerm && !machineText.includes(searchTerm)) {
showElement = false;
}
// 상태 필터
if (statusFilter && machineStatus !== statusFilter) {
showElement = false;
}
if (showElement) {
element.style.display = '';
visibleCount++;
if (machineStatus === 'online') onlineCount++;
else offlineCount++;
} else {
element.style.display = 'none';
}
});
// 카운터 업데이트
document.getElementById('onlineCount').textContent = onlineCount;
document.getElementById('offlineCount').textContent = offlineCount;
document.getElementById('totalCount').textContent = visibleCount;
}
// 모니터링 모달
function showMonitoring(machineId) {
// TODO: 모니터링 모달 구현
showToast(`머신 ${machineId} 모니터링 기능 준비 중`, 'info');
}
// 머신 목록 새로고침
function refreshMachineList() {
showToast('머신 목록을 새로고침 중...', 'info');
setTimeout(() => {
location.reload();
}, 1000);
}
// 초기 카운터 설정
document.addEventListener('DOMContentLoaded', function() {
filterMachines();
});
</script>
{% endblock %}

View File

@@ -0,0 +1,238 @@
{% extends "base.html" %}
{% block title %}약국 관리 - 팜큐 약국 관리 시스템{% endblock %}
{% block breadcrumb %}
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{{ url_for('dashboard') }}">대시보드</a></li>
<li class="breadcrumb-item active">약국 관리</li>
</ol>
</nav>
{% endblock %}
{% block content %}
<div class="row mb-4">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center">
<div>
<h1 class="h2 mb-0">
<i class="fas fa-store text-primary"></i>
약국 관리
</h1>
<p class="text-muted">등록된 약국 정보 및 연결 상태 관리</p>
</div>
<button class="btn btn-primary" onclick="showAddModal()">
<i class="fas fa-plus"></i> 새 약국 등록
</button>
</div>
</div>
</div>
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-body">
{% if pharmacies %}
<div class="table-responsive">
<table class="table table-hover">
<thead class="table-light">
<tr>
<th>약국 정보</th>
<th>담당자</th>
<th>연결된 머신</th>
<th>네트워크 상태</th>
<th>액션</th>
</tr>
</thead>
<tbody>
{% for pharmacy_data in pharmacies %}
<tr>
<td>
<div>
<strong class="d-block">{{ pharmacy_data.pharmacy_name }}</strong>
<small class="text-muted">{{ pharmacy_data.business_number }}</small>
<div class="small mt-1">
<code class="text-primary">{{ pharmacy_data.headscale_user_name }}</code>
</div>
</div>
<div class="small text-muted mt-1">
<i class="fas fa-map-marker-alt"></i> {{ pharmacy_data.address or '주소 미등록' }}
</div>
</td>
<td>
<div>
<strong>{{ pharmacy_data.manager_name or '미등록' }}</strong>
</div>
<div class="small text-muted">
<i class="fas fa-phone"></i> {{ pharmacy_data.phone or '연락처 미등록' }}
</div>
</td>
<td>
<div class="d-flex align-items-center">
<span class="badge bg-info me-2">{{ pharmacy_data.machine_count }}대</span>
<div class="progress" style="width: 60px; height: 8px;">
<div class="progress-bar bg-success"
style="width: {{ (pharmacy_data.online_count / pharmacy_data.machine_count * 100) if pharmacy_data.machine_count > 0 else 0 }}%"
title="{{ pharmacy_data.online_count }}/{{ pharmacy_data.machine_count }} 온라인"></div>
</div>
</div>
<div class="small text-muted">
온라인: {{ pharmacy_data.online_count }} / 오프라인: {{ pharmacy_data.offline_count }}
</div>
</td>
<td>
{% if pharmacy_data.online_count == pharmacy_data.machine_count and pharmacy_data.machine_count > 0 %}
<span class="badge bg-success">
<i class="fas fa-check-circle"></i> 모든 머신 온라인
</span>
{% elif pharmacy_data.online_count > 0 %}
<span class="badge bg-warning">
<i class="fas fa-exclamation-triangle"></i> 부분적 연결
</span>
{% elif pharmacy_data.machine_count > 0 %}
<span class="badge bg-danger">
<i class="fas fa-times-circle"></i> 전체 오프라인
</span>
{% else %}
<span class="badge bg-secondary">
<i class="fas fa-question-circle"></i> 머신 없음
</span>
{% endif %}
</td>
<td>
<div class="btn-group btn-group-sm">
<a href="{{ url_for('pharmacy_detail', pharmacy_id=pharmacy_data.id) }}"
class="btn btn-outline-primary" title="상세 정보">
<i class="fas fa-eye"></i>
</a>
<button class="btn btn-outline-warning"
onclick="showEditModal({{ pharmacy_data.id }})" title="수정">
<i class="fas fa-edit"></i>
</button>
<button class="btn btn-outline-info" title="모니터링">
<i class="fas fa-chart-line"></i>
</button>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center text-muted py-5">
<i class="fas fa-store fa-4x mb-4 text-secondary"></i>
<h4>등록된 약국이 없습니다</h4>
<p class="mb-4">첫 번째 약국을 등록하여 시작해보세요.</p>
<button class="btn btn-primary btn-lg" onclick="showAddModal()">
<i class="fas fa-plus"></i> 첫 번째 약국 등록
</button>
</div>
{% endif %}
</div>
</div>
</div>
</div>
<!-- 약국 등록/수정 모달 -->
<div class="modal fade" id="pharmacyModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="pharmacyModalTitle">
<i class="fas fa-store"></i> 약국 정보
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form id="pharmacyForm">
<div class="modal-body">
<div class="row">
<div class="col-md-6 mb-3">
<label for="pharmacy_name" class="form-label">약국명 <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="pharmacy_name" required>
</div>
<div class="col-md-6 mb-3">
<label for="business_number" class="form-label">사업자번호</label>
<input type="text" class="form-control" id="business_number" placeholder="000-00-00000">
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label for="manager_name" class="form-label">담당자명</label>
<input type="text" class="form-control" id="manager_name">
</div>
<div class="col-md-6 mb-3">
<label for="phone" class="form-label">전화번호</label>
<input type="tel" class="form-control" id="phone" placeholder="000-0000-0000">
</div>
</div>
<div class="mb-3">
<label for="address" class="form-label">주소</label>
<textarea class="form-control" id="address" rows="2"></textarea>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label for="proxmox_host" class="form-label">Proxmox 호스트 IP</label>
<input type="text" class="form-control" id="proxmox_host" placeholder="192.168.1.100">
</div>
<div class="col-md-6 mb-3">
<label for="user_id" class="form-label">연결된 사용자 ID</label>
<input type="text" class="form-control" id="user_id" placeholder="user1">
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">취소</button>
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i> 저장
</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
let pharmacyModal;
document.addEventListener('DOMContentLoaded', function() {
pharmacyModal = new bootstrap.Modal(document.getElementById('pharmacyModal'));
});
function showAddModal() {
document.getElementById('pharmacyModalTitle').innerHTML =
'<i class="fas fa-plus"></i> 새 약국 등록';
document.getElementById('pharmacyForm').reset();
pharmacyModal.show();
}
function showEditModal(pharmacyId) {
document.getElementById('pharmacyModalTitle').innerHTML =
'<i class="fas fa-edit"></i> 약국 정보 수정';
// TODO: 기존 데이터를 로드하여 폼에 채우기
// fetch(`/api/pharmacy/${pharmacyId}`)
pharmacyModal.show();
}
document.getElementById('pharmacyForm').addEventListener('submit', function(e) {
e.preventDefault();
const formData = new FormData(this);
const data = Object.fromEntries(formData);
// TODO: API를 통한 약국 정보 저장
showToast('약국 정보가 저장되었습니다.', 'success');
pharmacyModal.hide();
// 페이지 새로고침 (임시)
setTimeout(() => location.reload(), 1000);
});
// 테이블 정렬 및 검색 기능 추가 (향후)
</script>
{% endblock %}

View File

@@ -0,0 +1 @@
# Utils package

View File

@@ -0,0 +1,240 @@
"""
데이터베이스 연결 및 유틸리티 함수
"""
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, scoped_session
from models import Base, User, Node, PharmacyInfo, MachineSpecs, MonitoringData
from datetime import datetime, timedelta
import humanize
from typing import List, Optional
# 글로벌 세션 관리
db_session = scoped_session(sessionmaker())
def init_database(database_url: str):
"""데이터베이스 초기화"""
engine = create_engine(database_url, echo=False)
db_session.configure(bind=engine)
Base.metadata.bind = engine
# 확장 테이블 생성 (기존 테이블은 건드리지 않음)
try:
Base.metadata.create_all(engine)
print("✅ Database initialized successfully")
except Exception as e:
print(f"❌ Database initialization failed: {e}")
return engine
def get_session():
"""데이터베이스 세션 반환"""
return db_session
def close_session():
"""데이터베이스 세션 종료"""
db_session.remove()
# 약국 관련 유틸리티 함수
def get_pharmacy_count() -> int:
"""총 약국 수 반환"""
session = get_session()
return session.query(PharmacyInfo).count()
def get_pharmacy_with_stats(pharmacy_id: int) -> Optional[dict]:
"""약국 정보와 통계 반환"""
session = get_session()
pharmacy = session.query(PharmacyInfo).filter_by(id=pharmacy_id).first()
if not pharmacy:
return None
# 연결된 머신 수
machine_count = session.query(Node).join(User).filter(User.name == pharmacy.user_id).count()
# 온라인 머신 수
online_count = session.query(Node).join(User).filter(
User.name == pharmacy.user_id,
Node.last_seen > datetime.now() - timedelta(minutes=5)
).count()
return {
'pharmacy': pharmacy,
'machine_count': machine_count,
'online_count': online_count,
'offline_count': machine_count - online_count
}
def get_all_pharmacies_with_stats() -> List[dict]:
"""모든 약국 정보와 통계 반환"""
session = get_session()
pharmacies = session.query(PharmacyInfo).all()
result = []
for pharmacy in pharmacies:
stats = get_pharmacy_with_stats(pharmacy.id)
if stats:
result.append(stats)
return result
# 머신 관련 유틸리티 함수
def get_online_machines_count() -> int:
"""온라인 머신 수 반환"""
session = get_session()
cutoff_time = datetime.now() - timedelta(minutes=5)
return session.query(Node).filter(Node.last_seen > cutoff_time).count()
def get_offline_machines_count() -> int:
"""오프라인 머신 수 반환"""
session = get_session()
total_machines = session.query(Node).count()
online_machines = get_online_machines_count()
return total_machines - online_machines
def get_machine_with_details(machine_id: int) -> Optional[dict]:
"""머신 상세 정보 반환 (하드웨어 사양, 모니터링 데이터 포함)"""
session = get_session()
try:
machine = session.query(Node).filter_by(id=machine_id).first()
if not machine:
return None
# 하드웨어 사양
specs = session.query(MachineSpecs).filter_by(machine_id=machine_id).first()
# 최신 모니터링 데이터
latest_monitoring = session.query(MonitoringData).filter_by(
machine_id=machine_id
).order_by(MonitoringData.collected_at.desc()).first()
# 약국 정보 (specs가 있고 pharmacy_id가 있는 경우)
pharmacy = None
if specs and hasattr(specs, 'pharmacy_id') and specs.pharmacy_id:
pharmacy = session.query(PharmacyInfo).filter_by(id=specs.pharmacy_id).first()
# is_online 상태 확인
try:
is_online = machine.is_online() if hasattr(machine, 'is_online') else False
except:
# last_seen이 최근 5분 이내인지 확인
if machine.last_seen:
from datetime import datetime, timedelta
is_online = machine.last_seen > (datetime.now() - timedelta(minutes=5))
else:
is_online = False
result = {
'machine': machine,
'specs': specs,
'latest_monitoring': latest_monitoring,
'pharmacy': pharmacy,
'is_online': is_online,
'last_seen_humanized': humanize_datetime(machine.last_seen)
}
return result
except Exception as e:
print(f"❌ Error in get_machine_with_details: {e}")
import traceback
traceback.print_exc()
return None
# 모니터링 관련 유틸리티 함수
def get_average_cpu_temperature() -> float:
"""평균 CPU 온도 반환"""
session = get_session()
# 최근 5분 내 데이터만 사용
cutoff_time = datetime.now() - timedelta(minutes=5)
result = session.query(MonitoringData).filter(
MonitoringData.collected_at > cutoff_time,
MonitoringData.cpu_temperature.isnot(None)
).all()
if not result:
return 0.0
temperatures = [r.cpu_temperature for r in result if r.cpu_temperature]
return sum(temperatures) / len(temperatures) if temperatures else 0.0
def get_active_alerts() -> List[dict]:
"""활성 알림 목록 반환"""
session = get_session()
alerts = []
# CPU 온도 경고 (80도 이상)
high_temp_machines = session.query(MonitoringData, Node).join(Node).filter(
MonitoringData.cpu_temperature > 80,
MonitoringData.collected_at > datetime.now() - timedelta(minutes=5)
).all()
for monitoring, machine in high_temp_machines:
alerts.append({
'type': 'warning',
'level': 'high_temperature',
'machine': machine,
'message': f'{machine.hostname}: CPU 온도 {monitoring.cpu_temperature}°C',
'value': monitoring.cpu_temperature
})
# 디스크 사용률 경고 (90% 이상)
high_disk_machines = session.query(MonitoringData, Node).join(Node).filter(
MonitoringData.disk_usage > 90,
MonitoringData.collected_at > datetime.now() - timedelta(minutes=5)
).all()
for monitoring, machine in high_disk_machines:
alerts.append({
'type': 'danger',
'level': 'high_disk',
'machine': machine,
'message': f'{machine.hostname}: 디스크 사용률 {monitoring.disk_usage}%',
'value': float(monitoring.disk_usage)
})
return alerts
# 유틸리티 헬퍼 함수
def humanize_datetime(dt) -> str:
"""datetime을 사람이 읽기 쉬운 형태로 변환"""
if not dt:
return '알 수 없음'
try:
# 한국어 설정
humanize.i18n.activate('ko_KR')
return humanize.naturaltime(dt)
except:
# 한국어 로케일이 없으면 영어로 fallback
return humanize.naturaltime(dt)
def get_performance_summary() -> dict:
"""전체 성능 요약 반환"""
session = get_session()
cutoff_time = datetime.now() - timedelta(minutes=5)
recent_data = session.query(MonitoringData).filter(
MonitoringData.collected_at > cutoff_time
).all()
if not recent_data:
return {
'avg_cpu': 0,
'avg_memory': 0,
'avg_disk': 0,
'avg_temperature': 0
}
cpu_values = [float(d.cpu_usage) for d in recent_data if d.cpu_usage]
memory_values = [float(d.memory_usage) for d in recent_data if d.memory_usage]
disk_values = [float(d.disk_usage) for d in recent_data if d.disk_usage]
temp_values = [d.cpu_temperature for d in recent_data if d.cpu_temperature]
return {
'avg_cpu': sum(cpu_values) / len(cpu_values) if cpu_values else 0,
'avg_memory': sum(memory_values) / len(memory_values) if memory_values else 0,
'avg_disk': sum(disk_values) / len(disk_values) if disk_values else 0,
'avg_temperature': sum(temp_values) / len(temp_values) if temp_values else 0
}

View File

@@ -0,0 +1,610 @@
"""
새로운 데이터베이스 유틸리티 - Headscale과 분리된 FARMQ 전용
외래키 제약조건 없이 능동적으로 데이터를 관리
"""
import os
from typing import Optional, List, Dict, Any
from datetime import datetime, timedelta
from sqlalchemy import create_engine, text, and_, or_, desc
from sqlalchemy.orm import sessionmaker, Session
from models.farmq_models import (
PharmacyInfo, MachineProfile, MonitoringMetrics, SystemAlert,
FarmqDatabaseManager, create_farmq_database_manager,
FarmqBase
)
from models.headscale_models import User, Node, PreAuthKey, ApiKey
# 전역 데이터베이스 매니저들
farmq_manager: Optional[FarmqDatabaseManager] = None
headscale_engine = None
headscale_session_maker = None
def init_databases(headscale_db_uri: str, farmq_db_uri: str = None):
"""두 개의 데이터베이스 초기화"""
global farmq_manager, headscale_engine, headscale_session_maker
# FARMQ 전용 데이터베이스 (외래키 제약조건 없음)
if farmq_db_uri is None:
farmq_db_uri = "sqlite:///farmq-admin/farmq.sqlite"
farmq_manager = create_farmq_database_manager(farmq_db_uri)
print(f"✅ FARMQ 데이터베이스 초기화: {farmq_db_uri}")
# Headscale 읽기 전용 데이터베이스
headscale_engine = create_engine(headscale_db_uri, echo=False)
headscale_session_maker = sessionmaker(bind=headscale_engine)
print(f"✅ Headscale 데이터베이스 연결: {headscale_db_uri}")
def get_farmq_session() -> Session:
"""FARMQ 데이터베이스 세션 가져오기"""
if farmq_manager is None:
raise RuntimeError("FARMQ database not initialized")
return farmq_manager.get_session()
def get_headscale_session() -> Session:
"""Headscale 데이터베이스 세션 가져오기 (읽기 전용)"""
if headscale_session_maker is None:
raise RuntimeError("Headscale database not initialized")
return headscale_session_maker()
def close_session(session: Session):
"""세션 종료"""
if session:
session.close()
# ==========================================
# Dashboard Statistics
# ==========================================
def get_dashboard_stats() -> Dict[str, Any]:
"""대시보드 통계 조회"""
farmq_session = get_farmq_session()
headscale_session = get_headscale_session()
try:
# 약국 수
total_pharmacies = farmq_session.query(PharmacyInfo).filter(
PharmacyInfo.status == 'active'
).count()
# 머신 상태
total_machines = farmq_session.query(MachineProfile).filter(
MachineProfile.status == 'active'
).count()
online_machines = farmq_session.query(MachineProfile).filter(
MachineProfile.status == 'active',
MachineProfile.tailscale_status == 'online'
).count()
offline_machines = total_machines - online_machines
# 최근 알림 수
recent_alerts = farmq_session.query(SystemAlert).filter(
SystemAlert.status == 'active',
SystemAlert.created_at > (datetime.now() - timedelta(hours=24))
).count()
# 평균 CPU 온도 (최근 1시간)
cutoff_time = datetime.now() - timedelta(hours=1)
avg_temp_result = farmq_session.query(
MonitoringMetrics.cpu_temperature
).filter(
MonitoringMetrics.collected_at > cutoff_time,
MonitoringMetrics.cpu_temperature.isnot(None)
).all()
avg_cpu_temp = 0
if avg_temp_result:
temps = [temp[0] for temp in avg_temp_result if temp[0] is not None]
avg_cpu_temp = sum(temps) / len(temps) if temps else 0
return {
'total_pharmacies': total_pharmacies,
'total_machines': total_machines,
'online_machines': online_machines,
'offline_machines': offline_machines,
'recent_alerts': recent_alerts,
'avg_cpu_temp': round(avg_cpu_temp, 1)
}
finally:
close_session(farmq_session)
close_session(headscale_session)
# ==========================================
# Pharmacy Management
# ==========================================
def get_all_pharmacies_with_stats() -> List[Dict[str, Any]]:
"""모든 약국과 통계 정보 조회 - Headscale Node 데이터 사용"""
farmq_session = get_farmq_session()
headscale_session = get_headscale_session()
try:
pharmacies = farmq_session.query(PharmacyInfo).filter(
PharmacyInfo.status == 'active'
).all()
result = []
for pharmacy in pharmacies:
# Headscale에서 해당 사용자의 머신 수 조회
user_machines = headscale_session.query(Node).join(User).filter(
User.name == pharmacy.headscale_user_name,
Node.deleted_at.is_(None)
).all()
machine_count = len(user_machines)
# 온라인 머신 수 계산 (24시간 timeout)
online_count = 0
for machine in user_machines:
if machine.last_seen:
try:
from datetime import timezone
if machine.last_seen.tzinfo is not None:
cutoff_time = datetime.now(timezone.utc) - timedelta(hours=24)
else:
cutoff_time = datetime.now() - timedelta(hours=24)
if machine.last_seen > cutoff_time:
online_count += 1
except Exception:
online_count += 1 # 타임존 에러 시 온라인으로 간주
# 활성 알림 수 (현재는 0으로 설정, 나중에 구현)
alert_count = 0
pharmacy_data = pharmacy.to_dict()
pharmacy_data.update({
'machine_count': machine_count,
'online_count': online_count,
'offline_count': machine_count - online_count,
'alert_count': alert_count
})
result.append(pharmacy_data)
return result
finally:
close_session(farmq_session)
close_session(headscale_session)
def get_pharmacy_detail(pharmacy_id: int) -> Optional[Dict[str, Any]]:
"""약국 상세 정보 조회"""
farmq_session = get_farmq_session()
try:
pharmacy = farmq_session.query(PharmacyInfo).filter(
PharmacyInfo.id == pharmacy_id
).first()
if not pharmacy:
return None
# 약국의 머신들 조회
machines = farmq_session.query(MachineProfile).filter(
MachineProfile.pharmacy_id == pharmacy_id,
MachineProfile.status == 'active'
).all()
machine_list = []
for machine in machines:
machine_data = machine.to_dict()
# 최근 모니터링 데이터
latest_metrics = farmq_session.query(MonitoringMetrics).filter(
MonitoringMetrics.machine_profile_id == machine.id
).order_by(desc(MonitoringMetrics.collected_at)).first()
if latest_metrics:
machine_data['latest_metrics'] = latest_metrics.to_dict()
machine_list.append(machine_data)
return {
'pharmacy': pharmacy.to_dict(),
'machines': machine_list
}
finally:
close_session(farmq_session)
# ==========================================
# Machine Management
# ==========================================
def get_all_machines_with_details() -> List[Dict[str, Any]]:
"""모든 머신 상세 정보 조회 - Headscale Node 데이터 사용"""
headscale_session = get_headscale_session()
farmq_session = get_farmq_session()
try:
# Headscale에서 모든 노드 조회
nodes = headscale_session.query(Node).filter(
Node.deleted_at.is_(None)
).all()
result = []
for node in nodes:
# 기본 머신 정보
machine_data = {
'id': node.id,
'hostname': node.hostname,
'machine_name': node.hostname, # 표시용 이름
'tailscale_ip': node.ipv4,
'ipv6': node.ipv6,
'headscale_user_name': node.user.name if node.user else '미지정',
'user_id': node.user_id,
'last_seen': node.last_seen,
'created_at': node.created_at,
'updated_at': node.updated_at
}
# 온라인 상태 확인 (24시간 timeout)
if node.last_seen:
try:
from datetime import timezone
# node.last_seen이 timezone-aware인지 확인
if node.last_seen.tzinfo is not None:
cutoff_time = datetime.now(timezone.utc) - timedelta(hours=24)
else:
cutoff_time = datetime.now() - timedelta(hours=24)
machine_data['is_online'] = node.last_seen > cutoff_time
except Exception as e:
# 타임존 비교 에러가 발생하면 기본적으로 온라인으로 가정
print(f"Timezone comparison error for {node.hostname}: {e}")
machine_data['is_online'] = True
else:
machine_data['is_online'] = False
# 사용자 이름으로 약국 정보 찾기
machine_data['pharmacy'] = None
if node.user:
pharmacy = farmq_session.query(PharmacyInfo).filter(
PharmacyInfo.headscale_user_name == node.user.name
).first()
if pharmacy:
machine_data['pharmacy'] = {
'id': pharmacy.id,
'pharmacy_name': pharmacy.pharmacy_name,
'manager_name': pharmacy.manager_name,
'business_number': pharmacy.business_number
}
# 마지막 접속 시간을 사람이 읽기 쉬운 형태로
machine_data['last_seen_humanized'] = humanize_datetime(node.last_seen)
# 하드웨어 사양 및 모니터링 데이터는 나중에 추가 예정
machine_data['specs'] = None
machine_data['latest_monitoring'] = None
result.append(machine_data)
return result
finally:
close_session(farmq_session)
close_session(headscale_session)
def get_machine_detail(machine_id: int) -> Optional[Dict[str, Any]]:
"""머신 상세 정보 조회"""
farmq_session = get_farmq_session()
try:
machine = farmq_session.query(MachineProfile).filter(
MachineProfile.id == machine_id
).first()
if not machine:
return None
machine_data = machine.to_dict()
# 약국 정보
if machine.pharmacy_id:
pharmacy = farmq_session.query(PharmacyInfo).filter(
PharmacyInfo.id == machine.pharmacy_id
).first()
if pharmacy:
machine_data['pharmacy'] = pharmacy.to_dict()
# 최근 모니터링 데이터 (24시간)
cutoff_time = datetime.now() - timedelta(hours=24)
metrics = farmq_session.query(MonitoringMetrics).filter(
MonitoringMetrics.machine_profile_id == machine_id,
MonitoringMetrics.collected_at > cutoff_time
).order_by(desc(MonitoringMetrics.collected_at)).limit(100).all()
machine_data['metrics_history'] = [metric.to_dict() for metric in metrics]
# 최신 메트릭스
if metrics:
latest = metrics[0]
machine_data['latest_metrics'] = latest.to_dict()
machine_data['alerts'] = latest.get_alert_status()
# 활성 알림들
active_alerts = farmq_session.query(SystemAlert).filter(
SystemAlert.machine_profile_id == machine_id,
SystemAlert.status == 'active'
).order_by(desc(SystemAlert.created_at)).limit(10).all()
machine_data['active_alerts'] = [alert.to_dict() for alert in active_alerts]
return machine_data
finally:
close_session(farmq_session)
# ==========================================
# Headscale Synchronization
# ==========================================
def sync_machines_from_headscale() -> Dict[str, int]:
"""Headscale에서 머신 정보 동기화"""
farmq_session = get_farmq_session()
headscale_session = get_headscale_session()
try:
# Headscale에서 모든 노드 조회
nodes = headscale_session.query(Node).filter(
Node.deleted_at.is_(None)
).all()
synced = 0
created = 0
for node in nodes:
# FARMQ 데이터베이스에서 해당 머신 찾기
machine = farmq_session.query(MachineProfile).filter(
MachineProfile.headscale_node_id == node.id
).first()
if machine:
# 기존 머신 업데이트
is_online = node.is_online()
status = 'online' if is_online else 'offline'
machine.hostname = node.hostname
machine.tailscale_ip = node.ipv4
machine.tailscale_status = status
machine.last_seen = node.last_seen or datetime.now()
machine.updated_at = datetime.now()
synced += 1
else:
# 새 머신 생성
machine = MachineProfile(
headscale_node_id=node.id,
headscale_machine_key=node.machine_key,
hostname=node.hostname or 'unknown',
machine_name=node.given_name or node.hostname or 'unknown',
tailscale_ip=node.ipv4,
tailscale_status='online' if node.is_online() else 'offline',
os_type='unknown',
status='active',
last_seen=node.last_seen or datetime.now()
)
farmq_session.add(machine)
created += 1
farmq_session.commit()
return {
'total_nodes': len(nodes),
'synced': synced,
'created': created
}
finally:
close_session(farmq_session)
close_session(headscale_session)
def sync_users_from_headscale() -> Dict[str, int]:
"""Headscale에서 사용자 정보 동기화"""
farmq_session = get_farmq_session()
headscale_session = get_headscale_session()
try:
# Headscale에서 모든 사용자 조회
users = headscale_session.query(User).filter(
User.deleted_at.is_(None)
).all()
synced = 0
created = 0
for user in users:
# FARMQ 데이터베이스에서 해당 약국 찾기
pharmacy = farmq_session.query(PharmacyInfo).filter(
PharmacyInfo.headscale_user_name == user.name
).first()
if pharmacy:
# 기존 약국 업데이트
pharmacy.headscale_user_id = user.id
# 약국명이 사용자명과 같으면 더 나은 이름으로 업데이트
if pharmacy.pharmacy_name == user.name:
if user.display_name and user.display_name != user.name:
pharmacy.pharmacy_name = user.display_name
else:
pharmacy.pharmacy_name = f"{user.name} 약국" # 더 나은 기본 이름
# 기본값들이 None인 경우 업데이트
if not pharmacy.business_number or pharmacy.business_number == "None":
pharmacy.business_number = "000-00-00000"
if not pharmacy.manager_name or pharmacy.manager_name == "None":
pharmacy.manager_name = "관리자"
pharmacy.last_sync = datetime.now()
pharmacy.updated_at = datetime.now()
synced += 1
else:
# 새 약국 생성 (기본 정보로)
pharmacy_name = user.display_name if user.display_name else f"{user.name} 약국"
pharmacy = PharmacyInfo(
headscale_user_name=user.name,
headscale_user_id=user.id,
pharmacy_name=pharmacy_name,
business_number="000-00-00000", # 기본 사업자번호
manager_name="관리자",
status='active',
last_sync=datetime.now()
)
farmq_session.add(pharmacy)
created += 1
farmq_session.commit()
return {
'total_users': len(users),
'synced': synced,
'created': created
}
finally:
close_session(farmq_session)
close_session(headscale_session)
# ==========================================
# Alert Management
# ==========================================
def get_active_alerts(limit: int = 50) -> List[Dict[str, Any]]:
"""활성 알림 조회"""
farmq_session = get_farmq_session()
try:
alerts = farmq_session.query(SystemAlert).filter(
SystemAlert.status == 'active'
).order_by(desc(SystemAlert.created_at)).limit(limit).all()
return [alert.to_dict() for alert in alerts]
finally:
close_session(farmq_session)
def create_alert(machine_profile_id: int, alert_data: Dict[str, Any]) -> SystemAlert:
"""새로운 알림 생성"""
farmq_session = get_farmq_session()
try:
# 중복 알림 확인
fingerprint = f"{machine_profile_id}_{alert_data.get('alert_type')}_{alert_data.get('current_value')}"
existing_alert = farmq_session.query(SystemAlert).filter(
SystemAlert.fingerprint == fingerprint,
SystemAlert.status == 'active'
).first()
if existing_alert:
# 기존 알림 업데이트
existing_alert.occurrence_count += 1
existing_alert.last_occurred = datetime.now()
existing_alert.updated_at = datetime.now()
farmq_session.commit()
return existing_alert
else:
# 새 알림 생성
alert = SystemAlert(
machine_profile_id=machine_profile_id,
fingerprint=fingerprint,
**alert_data
)
farmq_session.add(alert)
farmq_session.commit()
farmq_session.refresh(alert)
return alert
finally:
close_session(farmq_session)
# ==========================================
# Backward Compatibility Functions
# ==========================================
def get_pharmacy_count() -> int:
"""약국 수 조회 (하위 호환성)"""
stats = get_dashboard_stats()
return stats['total_pharmacies']
def get_online_machines_count() -> int:
"""온라인 머신 수 조회 (하위 호환성)"""
stats = get_dashboard_stats()
return stats['online_machines']
def get_offline_machines_count() -> int:
"""오프라인 머신 수 조회 (하위 호환성)"""
stats = get_dashboard_stats()
return stats['offline_machines']
def get_average_cpu_temperature() -> float:
"""평균 CPU 온도 조회 (하위 호환성)"""
stats = get_dashboard_stats()
return stats['avg_cpu_temp']
def humanize_datetime(dt) -> str:
"""datetime을 사람이 읽기 쉬운 형태로 변환"""
if not dt:
return '알 수 없음'
try:
import humanize
# 한국어 설정 시도
try:
humanize.i18n.activate('ko_KR')
except:
pass
return humanize.naturaltime(dt)
except ImportError:
# humanize가 없으면 기본 형식 사용
if isinstance(dt, str):
return dt
return dt.strftime('%Y-%m-%d %H:%M:%S')
def get_machine_with_details(machine_id: int) -> Optional[Dict[str, Any]]:
"""머신 상세 정보 조회 (하위 호환성)"""
return get_machine_detail(machine_id)
def get_performance_summary() -> Dict[str, Any]:
"""성능 요약 조회"""
return {
'status': 'good',
'summary': '모든 시스템이 정상 작동 중입니다.'
}
# ==========================================
# 초기화 함수
# ==========================================
def init_database(headscale_db_uri: str):
"""데이터베이스 초기화 (하위 호환성)"""
# FARMQ 데이터베이스는 자동으로 생성
farmq_db_uri = "sqlite:///farmq-admin/farmq.sqlite"
# 디렉토리 생성
os.makedirs("farmq-admin", exist_ok=True)
init_databases(headscale_db_uri, farmq_db_uri)
# 초기 동기화 실행
try:
print("🔄 Headscale에서 데이터 동기화 중...")
user_sync = sync_users_from_headscale()
machine_sync = sync_machines_from_headscale()
print(f"✅ 사용자 동기화: {user_sync}")
print(f"✅ 머신 동기화: {machine_sync}")
except Exception as e:
print(f"⚠️ 동기화 중 오류 발생: {e}")
def get_session():
"""FARMQ 세션 가져오기 (하위 호환성)"""
return get_farmq_session()
def close_session(session=None):
"""세션 종료 (하위 호환성)"""
if session:
session.close()
# 새로운 구조에서는 각 함수가 자체적으로 세션을 관리하므로 여기서는 아무것도 하지 않음

481
farmq-install-en.ps1 Normal file
View File

@@ -0,0 +1,481 @@
# FARMQ Headscale Windows One-Click Installation Script
# Usage: Run in Administrator PowerShell
# iex ((New-Object System.Net.WebClient).DownloadString('https://git.0bin.in/.../farmq-install-en.ps1'))
param(
[switch]$Force,
[string]$HeadscaleServer = "https://head.0bin.in",
[string]$PreAuthKey = "8b3df41d37cb158ea39f41fc32c9af46e761de817ad06038",
[string]$FarmqNetwork = "100.64.0.0/10"
)
# Set console to support Unicode characters
$PSDefaultParameterValues['*:Encoding'] = 'utf8'
if ($PSVersionTable.PSVersion.Major -ge 6) {
$PSDefaultParameterValues['Out-File:Encoding'] = 'utf8'
}
# ================================
# Color Output Functions
# ================================
function Write-Header {
param([string]$Text)
Write-Host ""
Write-Host "============================================" -ForegroundColor Magenta
Write-Host $Text -ForegroundColor White
Write-Host "============================================" -ForegroundColor Magenta
Write-Host ""
}
function Write-Status {
param([string]$Message)
Write-Host ""
Write-Host "[*] $Message" -ForegroundColor Blue
}
function Write-Success {
param([string]$Message)
Write-Host ""
Write-Host "[+] $Message" -ForegroundColor Green
}
function Write-Error {
param([string]$Message)
Write-Host ""
Write-Host "[!] ERROR: $Message" -ForegroundColor Red
}
function Write-Warning {
param([string]$Message)
Write-Host ""
Write-Host "[!] WARNING: $Message" -ForegroundColor Yellow
}
function Write-Info {
param([string]$Message)
Write-Host ""
Write-Host "[i] $Message" -ForegroundColor Cyan
}
# ================================
# System Requirements Check
# ================================
function Test-Requirements {
Write-Status "Checking system requirements..."
# Check administrator privileges
$currentUser = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = New-Object Security.Principal.WindowsPrincipal($currentUser)
if (-NOT $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
Write-Error "This script requires administrator privileges."
Write-Info "Please restart using one of these methods:"
Write-Info "1. Windows Key + X -> 'Windows PowerShell (Admin)'"
Write-Info "2. Right-click PowerShell -> 'Run as Administrator'"
Write-Host ""
Read-Host "Press any key to exit..."
exit 1
}
# Check Windows version
$osVersion = [System.Environment]::OSVersion.Version
if ($osVersion.Major -lt 10) {
Write-Warning "Windows 10 or later recommended. Current: Windows $($osVersion.Major).$($osVersion.Minor)"
}
# Check internet connection
try {
Test-Connection "8.8.8.8" -Count 1 -Quiet | Out-Null
}
catch {
Write-Warning "Please check your internet connection."
}
Write-Success "System requirements check completed"
}
# ================================
# Install Tailscale
# ================================
function Install-Tailscale {
Write-Status "Checking Tailscale installation..."
# Check existing installation
$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"
return
}
Write-Info "Installing Tailscale for Windows..."
# Temporary download path
$tempPath = "$env:TEMP\tailscale-setup.exe"
$downloadUrl = "https://pkgs.tailscale.com/stable/tailscale-setup.exe"
try {
Write-Status "Downloading Tailscale..."
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"
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"
}
}
}
Remove-Item $tempPath -Force -ErrorAction SilentlyContinue
Write-Success "Tailscale installation completed"
}
catch {
Write-Error "Tailscale installation failed: $($_.Exception.Message)"
throw
}
}
# ================================
# Start Tailscale Service
# ================================
function Start-TailscaleService {
Write-Status "Starting Tailscale service..."
try {
# Start Tailscale service
$service = Get-Service -Name "Tailscale" -ErrorAction SilentlyContinue
if ($service) {
if ($service.Status -ne "Running") {
Start-Service -Name "Tailscale"
Start-Sleep -Seconds 3
}
Write-Success "Tailscale service is running."
} else {
Write-Warning "Tailscale service not found. Attempting manual start..."
}
}
catch {
Write-Warning "Failed to start service: $($_.Exception.Message)"
}
}
# ================================
# Register with Headscale
# ================================
function Register-Headscale {
Write-Status "Registering with Headscale server..."
# Find Tailscale executable path
$tailscaleCmd = Get-Command "tailscale" -ErrorAction SilentlyContinue
if (-not $tailscaleCmd) {
$tailscaleExe = "C:\Program Files\Tailscale\tailscale.exe"
if (Test-Path $tailscaleExe) {
$tailscaleCmd = @{Source = $tailscaleExe}
} else {
Write-Error "Tailscale executable not found."
return $false
}
}
$tailscalePath = $tailscaleCmd.Source
# Check existing connection
try {
$status = & $tailscalePath status 2>$null
if ($LASTEXITCODE -eq 0 -and $status) {
Write-Warning "Already connected to Tailscale/Headscale network."
# Show current connection status
Write-Info "Current connection status:"
$status | Select-Object -First 5 | ForEach-Object { Write-Host " $_" -ForegroundColor Gray }
# Check force registration option
if ($Force) {
Write-Warning "Force registration option is enabled."
Write-Info "Disconnecting existing connection and re-registering..."
} else {
$response = Read-Host "Disconnect existing connection and register with FARMQ Headscale? (Y/n)"
if ($response -eq "" -or $response -match "^[Yy]") {
Write-Info "Disconnecting existing connection..."
} else {
Write-Info "Skipping registration."
return $true
}
}
# Disconnect existing connection
try {
& $tailscalePath logout 2>$null
Start-Sleep -Seconds 3
Write-Success "Existing connection disconnected."
}
catch {
Write-Warning "Error during disconnection, but continuing..."
}
}
}
catch {
# Not connected (normal)
}
Write-Info "Headscale Server: $HeadscaleServer"
Write-Info "Pre-auth Key: $($PreAuthKey.Substring(0,8))***************"
# Attempt Headscale registration
Write-Status "Executing registration command..."
try {
$arguments = @(
"up",
"--login-server=$HeadscaleServer",
"--authkey=$PreAuthKey",
"--accept-routes",
"--accept-dns=false"
)
& $tailscalePath $arguments
if ($LASTEXITCODE -eq 0) {
Write-Success "Headscale registration successful!"
return $true
} else {
Write-Error "Automatic registration failed."
Write-Info "Manual registration command:"
Write-Host "tailscale up --login-server=`"$HeadscaleServer`" --authkey=`"$PreAuthKey`""
return $false
}
}
catch {
Write-Error "Registration error: $($_.Exception.Message)"
return $false
}
}
# ================================
# Configure Firewall
# ================================
function Configure-Firewall {
Write-Status "Configuring firewall settings..."
try {
# Add Windows Defender firewall exception
$ruleName = "Tailscale-FARMQ"
$existingRule = Get-NetFirewallRule -DisplayName $ruleName -ErrorAction SilentlyContinue
if (-not $existingRule) {
New-NetFirewallRule -DisplayName $ruleName -Direction Inbound -Protocol UDP -LocalPort 41641 -Action Allow -Profile Any | Out-Null
New-NetFirewallRule -DisplayName "$ruleName-Outbound" -Direction Outbound -Protocol UDP -LocalPort 41641 -Action Allow -Profile Any | Out-Null
Write-Info "Windows Defender firewall exceptions added."
}
Write-Success "Firewall configuration completed"
}
catch {
Write-Warning "Firewall configuration error: $($_.Exception.Message)"
Write-Info "Please manually allow Tailscale through Windows firewall."
}
}
# ================================
# Verify Connection
# ================================
function Test-Connection {
Write-Status "Verifying network connection..."
Start-Sleep -Seconds 5
# Find Tailscale executable path
$tailscaleCmd = Get-Command "tailscale" -ErrorAction SilentlyContinue
if (-not $tailscaleCmd) {
$tailscaleExe = "C:\Program Files\Tailscale\tailscale.exe"
if (Test-Path $tailscaleExe) {
$tailscaleCmd = @{Source = $tailscaleExe}
} else {
Write-Error "Tailscale executable not found."
return
}
}
$tailscalePath = $tailscaleCmd.Source
try {
$status = & $tailscalePath status 2>$null
if ($LASTEXITCODE -ne 0 -or -not $status) {
Write-Error "Tailscale connection issue detected."
return
}
# Get IP addresses
$ipv4 = & $tailscalePath ip -4 2>$null
$ipv6 = & $tailscalePath ip -6 2>$null
Write-Success "Headscale network connection completed!"
Write-Info "Assigned IPv4: $(if($ipv4){$ipv4}else{'N/A'})"
Write-Info "Assigned IPv6: $(if($ipv6){$ipv6}else{'N/A'})"
# Network connectivity test
Write-Status "Testing network connectivity..."
try {
Test-Connection "100.64.0.1" -Count 2 -Quiet | Out-Null
Write-Success "FARMQ network ($FarmqNetwork) connection successful!"
}
catch {
Write-Warning "Network test failed. Please check firewall settings."
}
# Show connected nodes
Write-Info "Network status:"
$status | Select-Object -First 10 | ForEach-Object {
Write-Host " $_" -ForegroundColor Gray
}
}
catch {
Write-Error "Connection verification failed: $($_.Exception.Message)"
}
}
# ================================
# Cleanup
# ================================
function Complete-Installation {
Write-Status "Completing installation..."
# Clean temporary files
Get-ChildItem "$env:TEMP\tailscale*" -ErrorAction SilentlyContinue | Remove-Item -Force -ErrorAction SilentlyContinue
Write-Success "Cleanup completed"
}
# ================================
# Show Final Information
# ================================
function Show-FinalInfo {
Write-Header "FARMQ Headscale Windows Installation Complete!"
# System information
$computerName = $env:COMPUTERNAME
$tailscaleCmd = Get-Command "tailscale" -ErrorAction SilentlyContinue
if (-not $tailscaleCmd) {
$tailscaleExe = "C:\Program Files\Tailscale\tailscale.exe"
if (Test-Path $tailscaleExe) {
$tailscaleCmd = @{Source = $tailscaleExe}
}
}
if ($tailscaleCmd) {
$tailscaleIP = & $tailscaleCmd.Source ip -4 2>$null
}
$osVersion = [System.Environment]::OSVersion.Version
Write-Host "Installation completed successfully!" -ForegroundColor Green
Write-Host ""
Write-Host "System Information:" -ForegroundColor Cyan
Write-Host " Computer Name: $computerName"
Write-Host " Tailscale IP: $(if($tailscaleIP){$tailscaleIP}else{'N/A'})"
Write-Host " OS: Windows $($osVersion.Major).$($osVersion.Minor)"
Write-Host " Headscale Server: $HeadscaleServer"
Write-Host ""
Write-Host "Useful Commands:" -ForegroundColor Yellow
Write-Host " tailscale status # Check connection status"
Write-Host " tailscale ip # Show assigned IP"
Write-Host " tailscale ping <node> # Test connection to other nodes"
Write-Host " tailscale logout # Disconnect from network"
Write-Host ""
Write-Host "FARMQ Management Pages:" -ForegroundColor Magenta
Write-Host " http://192.168.0.151:5002"
Write-Host " http://192.168.0.151:5002/vms (VM Management)"
Write-Host ""
Write-Host "If you encounter issues, check:" -ForegroundColor White
Write-Host " 1. Windows Firewall settings"
Write-Host " 2. Antivirus software exceptions"
Write-Host " 3. Corporate network policies"
Write-Header "Installation Complete - You can now use FARMQ network!"
}
# ================================
# Main Function
# ================================
function Main {
# Stop on errors
$ErrorActionPreference = "Stop"
Write-Header "FARMQ Headscale Windows One-Click Installation"
try {
# Installation process
Test-Requirements
Install-Tailscale
Start-TailscaleService
$registerSuccess = Register-Headscale
if ($registerSuccess) {
Configure-Firewall
Test-Connection
Complete-Installation
Show-FinalInfo
} else {
Write-Warning "Registration failed but Tailscale is installed."
Write-Info "Please complete registration manually."
}
}
catch {
Write-Error "Installation error occurred: $($_.Exception.Message)"
Write-Info "If the problem persists, please contact administrator."
Write-Host ""
Read-Host "Press any key to exit..."
exit 1
}
}
# ================================
# Script Execution
# ================================
# Handle parameters
if ($args -contains "--help" -or $args -contains "-h") {
Write-Host "FARMQ Headscale Windows Installation Script"
Write-Host ""
Write-Host "Usage:"
Write-Host " iex ((New-Object System.Net.WebClient).DownloadString('https://git.0bin.in/.../farmq-install-en.ps1'))"
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 ""
Write-Host "Examples:"
Write-Host " # Force re-registration"
Write-Host " `$Force = `$true; iex ((New-Object System.Net.WebClient).DownloadString('https://git.0bin.in/.../farmq-install-en.ps1'))"
exit 0
}
# Handle Force parameter from URL or variable
if ($MyInvocation.MyCommand.Path -like "*force=1*" -or (Get-Variable -Name "ForceInstall" -ErrorAction SilentlyContinue)) {
$Force = $true
}
# Execute main function
Main

483
farmq-install.ps1 Normal file
View File

@@ -0,0 +1,483 @@
# 팜큐(FARMQ) Headscale Windows 원클릭 설치 스크립트
# 사용법: 관리자 PowerShell에서 실행
# iex ((New-Object System.Net.WebClient).DownloadString('https://git.0bin.in/.../farmq-install.ps1'))
param(
[switch]$Force,
[string]$HeadscaleServer = "https://head.0bin.in",
[string]$PreAuthKey = "8b3df41d37cb158ea39f41fc32c9af46e761de817ad06038",
[string]$FarmqNetwork = "100.64.0.0/10"
)
# ================================
# 색상 출력 함수
# ================================
function Write-ColorOutput {
param(
[string]$Message,
[string]$ForegroundColor = "White"
)
Write-Host $Message -ForegroundColor $ForegroundColor
}
function Write-Header {
param([string]$Text)
Write-Host ""
Write-Host "============================================" -ForegroundColor Magenta
Write-Host $Text -ForegroundColor White
Write-Host "============================================" -ForegroundColor Magenta
Write-Host ""
}
function Write-Status {
param([string]$Message)
Write-Host ""
Write-ColorOutput "🔧 $Message" "Blue"
}
function Write-Success {
param([string]$Message)
Write-Host ""
Write-ColorOutput "$Message" "Green"
}
function Write-Error {
param([string]$Message)
Write-Host ""
Write-ColorOutput "$Message" "Red"
}
function Write-Warning {
param([string]$Message)
Write-Host ""
Write-ColorOutput "⚠️ $Message" "Yellow"
}
function Write-Info {
param([string]$Message)
Write-Host ""
Write-ColorOutput "📋 $Message" "Cyan"
}
# ================================
# 시스템 요구사항 확인
# ================================
function Test-Requirements {
Write-Status "시스템 요구사항 확인 중..."
# 관리자 권한 확인
$currentUser = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = New-Object Security.Principal.WindowsPrincipal($currentUser)
if (-NOT $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
Write-Error "이 스크립트는 관리자 권한으로 실행해야 합니다."
Write-Info "다음 방법으로 다시 실행해주세요:"
Write-Info "1. Windows 키 + X → 'Windows PowerShell(관리자)' 클릭"
Write-Info "2. 스크립트 명령어 다시 실행"
Write-Host ""
Read-Host "아무 키나 누르세요..."
exit 1
}
# Windows 버전 확인
$osVersion = [System.Environment]::OSVersion.Version
if ($osVersion.Major -lt 10) {
Write-Warning "Windows 10 이상을 권장합니다. 현재: Windows $($osVersion.Major).$($osVersion.Minor)"
}
# 인터넷 연결 확인
try {
Test-Connection "8.8.8.8" -Count 1 -Quiet | Out-Null
}
catch {
Write-Warning "인터넷 연결을 확인해주세요."
}
Write-Success "시스템 요구사항 확인 완료"
}
# ================================
# Tailscale 설치
# ================================
function Install-Tailscale {
Write-Status "Tailscale 클라이언트 확인 중..."
# 기존 설치 확인
$tailscalePath = Get-Command "tailscale" -ErrorAction SilentlyContinue
if ($tailscalePath) {
$version = & tailscale version 2>$null | Select-Object -First 1
Write-Info "Tailscale이 이미 설치되어 있습니다."
Write-Info "현재 버전: $version"
return
}
Write-Info "Windows용 Tailscale 설치 중..."
# 임시 다운로드 경로
$tempPath = "$env:TEMP\tailscale-setup.exe"
$downloadUrl = "https://pkgs.tailscale.com/stable/tailscale-setup.exe"
try {
Write-Status "Tailscale 다운로드 중..."
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"
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"
}
}
}
Remove-Item $tempPath -Force -ErrorAction SilentlyContinue
Write-Success "Tailscale 설치 완료"
}
catch {
Write-Error "Tailscale 설치 실패: $($_.Exception.Message)"
throw
}
}
# ================================
# Tailscale 서비스 시작
# ================================
function Start-TailscaleService {
Write-Status "Tailscale 서비스 시작 중..."
try {
# Tailscale 서비스 시작
$service = Get-Service -Name "Tailscale" -ErrorAction SilentlyContinue
if ($service) {
if ($service.Status -ne "Running") {
Start-Service -Name "Tailscale"
Start-Sleep -Seconds 3
}
Write-Success "Tailscale 서비스가 실행 중입니다."
} else {
Write-Warning "Tailscale 서비스를 찾을 수 없습니다. 수동 시작을 시도합니다."
}
}
catch {
Write-Warning "서비스 시작에 실패했습니다: $($_.Exception.Message)"
}
}
# ================================
# Headscale 등록
# ================================
function Register-Headscale {
Write-Status "Headscale 서버에 등록 중..."
# Tailscale 실행파일 경로 확인
$tailscaleCmd = Get-Command "tailscale" -ErrorAction SilentlyContinue
if (-not $tailscaleCmd) {
$tailscaleExe = "C:\Program Files\Tailscale\tailscale.exe"
if (Test-Path $tailscaleExe) {
$tailscaleCmd = @{Source = $tailscaleExe}
} else {
Write-Error "Tailscale 실행파일을 찾을 수 없습니다."
return $false
}
}
$tailscalePath = $tailscaleCmd.Source
# 기존 연결 확인
try {
$status = & $tailscalePath status 2>$null
if ($LASTEXITCODE -eq 0 -and $status) {
Write-Warning "이미 Tailscale/Headscale에 연결되어 있습니다."
# 현재 연결 상태 표시
Write-Info "현재 연결 상태:"
$status | Select-Object -First 5 | ForEach-Object { Write-Host " $_" }
# 강제 등록 옵션 확인
if ($Force) {
Write-Warning "강제 재등록 옵션이 활성화되었습니다."
Write-Info "기존 연결을 해제하고 재등록합니다..."
} else {
$response = Read-Host "기존 연결을 해제하고 팜큐 Headscale로 등록하시겠습니까? (Y/n)"
if ($response -eq "" -or $response -match "^[Yy]") {
Write-Info "기존 연결을 해제합니다..."
} else {
Write-Info "등록을 건너뜁니다."
return $true
}
}
# 기존 연결 해제
try {
& $tailscalePath logout 2>$null
Start-Sleep -Seconds 3
Write-Success "기존 연결이 해제되었습니다."
}
catch {
Write-Warning "연결 해제 중 오류가 발생했지만 계속 진행합니다."
}
}
}
catch {
# 연결되어 있지 않음 (정상)
}
Write-Info "Headscale 서버: $HeadscaleServer"
Write-Info "Pre-auth Key: $($PreAuthKey.Substring(0,8))***************"
# Headscale 등록 시도
Write-Status "등록 명령 실행 중..."
try {
$arguments = @(
"up",
"--login-server=$HeadscaleServer",
"--authkey=$PreAuthKey",
"--accept-routes",
"--accept-dns=false"
)
& $tailscalePath $arguments
if ($LASTEXITCODE -eq 0) {
Write-Success "Headscale 등록 성공!"
return $true
} else {
Write-Error "자동 등록에 실패했습니다."
Write-Info "수동 등록 명령어:"
Write-Host "tailscale up --login-server=`"$HeadscaleServer`" --authkey=`"$PreAuthKey`""
return $false
}
}
catch {
Write-Error "등록 중 오류 발생: $($_.Exception.Message)"
return $false
}
}
# ================================
# 방화벽 설정
# ================================
function Configure-Firewall {
Write-Status "방화벽 설정 확인 중..."
try {
# Windows Defender 방화벽 예외 추가
$ruleName = "Tailscale-FarmQ"
$existingRule = Get-NetFirewallRule -DisplayName $ruleName -ErrorAction SilentlyContinue
if (-not $existingRule) {
New-NetFirewallRule -DisplayName $ruleName -Direction Inbound -Protocol UDP -LocalPort 41641 -Action Allow -Profile Any | Out-Null
New-NetFirewallRule -DisplayName "$ruleName-Outbound" -Direction Outbound -Protocol UDP -LocalPort 41641 -Action Allow -Profile Any | Out-Null
Write-Info "Windows Defender 방화벽 예외를 추가했습니다."
}
Write-Success "방화벽 설정 완료"
}
catch {
Write-Warning "방화벽 설정 중 오류 발생: $($_.Exception.Message)"
Write-Info "수동으로 방화벽에서 Tailscale을 허용해주세요."
}
}
# ================================
# 연결 상태 확인
# ================================
function Test-Connection {
Write-Status "연결 상태 확인 중..."
Start-Sleep -Seconds 5
# Tailscale 실행파일 경로 확인
$tailscaleCmd = Get-Command "tailscale" -ErrorAction SilentlyContinue
if (-not $tailscaleCmd) {
$tailscaleExe = "C:\Program Files\Tailscale\tailscale.exe"
if (Test-Path $tailscaleExe) {
$tailscaleCmd = @{Source = $tailscaleExe}
} else {
Write-Error "Tailscale 실행파일을 찾을 수 없습니다."
return
}
}
$tailscalePath = $tailscaleCmd.Source
try {
$status = & $tailscalePath status 2>$null
if ($LASTEXITCODE -ne 0 -or -not $status) {
Write-Error "Tailscale 연결에 문제가 있습니다."
return
}
# IP 주소 확인
$ipv4 = & $tailscalePath ip -4 2>$null
$ipv6 = & $tailscalePath ip -6 2>$null
Write-Success "Headscale 네트워크 연결 완료!"
Write-Info "할당된 IPv4: $(if($ipv4){$ipv4}else{'N/A'})"
Write-Info "할당된 IPv6: $(if($ipv6){$ipv6}else{'N/A'})"
# 네트워크 테스트
Write-Status "네트워크 연결 테스트 중..."
try {
Test-Connection "100.64.0.1" -Count 2 -Quiet | Out-Null
Write-Success "팜큐 네트워크($FarmqNetwork) 연결 정상!"
}
catch {
Write-Warning "네트워크 테스트 실패. 방화벽을 확인해주세요."
}
# 연결된 노드 확인
Write-Info "네트워크 상태:"
$status | Select-Object -First 10 | ForEach-Object {
Write-Host " $_" -ForegroundColor Gray
}
}
catch {
Write-Error "연결 상태 확인 실패: $($_.Exception.Message)"
}
}
# ================================
# 정리 작업
# ================================
function Complete-Installation {
Write-Status "설치 완료 작업 중..."
# 임시 파일 정리
Get-ChildItem "$env:TEMP\tailscale*" -ErrorAction SilentlyContinue | Remove-Item -Force -ErrorAction SilentlyContinue
Write-Success "정리 작업 완료"
}
# ================================
# 최종 정보 출력
# ================================
function Show-FinalInfo {
Write-Header "팜큐 Headscale Windows 설치 완료!"
# 시스템 정보
$computerName = $env:COMPUTERNAME
$tailscaleCmd = Get-Command "tailscale" -ErrorAction SilentlyContinue
if (-not $tailscaleCmd) {
$tailscaleExe = "C:\Program Files\Tailscale\tailscale.exe"
if (Test-Path $tailscaleExe) {
$tailscaleCmd = @{Source = $tailscaleExe}
}
}
if ($tailscaleCmd) {
$tailscaleIP = & $tailscaleCmd.Source ip -4 2>$null
}
$osVersion = [System.Environment]::OSVersion.Version
Write-ColorOutput "🎉 설치가 성공적으로 완료되었습니다!" "Green"
Write-Host ""
Write-ColorOutput "📋 시스템 정보:" "Cyan"
Write-Host " 컴퓨터명: $computerName"
Write-Host " Tailscale IP: $(if($tailscaleIP){$tailscaleIP}else{'N/A'})"
Write-Host " OS: Windows $($osVersion.Major).$($osVersion.Minor)"
Write-Host " Headscale 서버: $HeadscaleServer"
Write-Host ""
Write-ColorOutput "🔧 유용한 명령어:" "Yellow"
Write-Host " tailscale status # 연결 상태 확인"
Write-Host " tailscale ip # 할당된 IP 확인"
Write-Host " tailscale ping <node> # 다른 노드와 연결 테스트"
Write-Host " tailscale logout # 네트워크에서 해제"
Write-Host ""
Write-ColorOutput "🌐 팜큐 관리자 페이지:" "Magenta"
Write-Host " http://192.168.0.151:5002"
Write-Host " http://192.168.0.151:5002/vms (VM 관리)"
Write-Host ""
Write-ColorOutput "문제가 있을 경우 다음을 확인하세요:" "White"
Write-Host " 1. Windows 방화벽 설정"
Write-Host " 2. 바이러스 백신 프로그램 예외 설정"
Write-Host " 3. 회사 네트워크 정책 확인"
Write-Header "설치 완료 - 팜큐 네트워크를 사용할 수 있습니다!"
}
# ================================
# 메인 함수
# ================================
function Main {
# 에러 발생 시 중단
$ErrorActionPreference = "Stop"
Write-Header "팜큐(FARMQ) Headscale Windows 원클릭 설치"
try {
# 설치 과정
Test-Requirements
Install-Tailscale
Start-TailscaleService
$registerSuccess = Register-Headscale
if ($registerSuccess) {
Configure-Firewall
Test-Connection
Complete-Installation
Show-FinalInfo
} else {
Write-Warning "등록에 실패했지만 Tailscale은 설치되었습니다."
Write-Info "수동으로 등록을 완료해주세요."
}
}
catch {
Write-Error "설치 중 오류가 발생했습니다: $($_.Exception.Message)"
Write-Info "문제가 지속되면 관리자에게 문의하세요."
Write-Host ""
Read-Host "아무 키나 누르세요..."
exit 1
}
}
# ================================
# 스크립트 실행
# ================================
# 파라미터 처리
if ($args -contains "--help" -or $args -contains "-h") {
Write-Host "팜큐 Headscale Windows 설치 스크립트"
Write-Host ""
Write-Host "사용법:"
Write-Host " iex ((New-Object System.Net.WebClient).DownloadString('https://git.0bin.in/.../farmq-install.ps1'))"
Write-Host ""
Write-Host "옵션:"
Write-Host " -Force 기존 연결을 강제로 해제하고 재등록"
Write-Host " -HeadscaleServer 서버 주소 (기본값: https://head.0bin.in)"
Write-Host ""
Write-Host "예시:"
Write-Host " # 강제 재등록"
Write-Host " iex ((New-Object System.Net.WebClient).DownloadString('https://git.0bin.in/.../farmq-install.ps1?force=1'))"
exit 0
}
# Force 파라미터 URL에서 처리
if ($MyInvocation.MyCommand.Path -like "*force=1*") {
$Force = $true
}
# 메인 함수 실행
Main

View File

@@ -0,0 +1,15 @@
headscale:
url: http://headscale:8080
api_key: 8qRr1IB.tV95CmA0fLaCiGGIgBfeoN9daHceFkzI
config_strict: false
server:
host: 0.0.0.0
port: 3000
cookie_secret: headscale-ui-secret-32-chars-key
cookie_secure: false
settings:
title: "Headscale 관리 패널"
favicon_url: ""
custom_css: ""

451
headscale_models.py Normal file
View File

@@ -0,0 +1,451 @@
"""
Headscale SQLite Database Models for SQLAlchemy
Based on actual schema analysis of Headscale v0.23.0
Generated from: /var/lib/headscale/db.sqlite
Schema Analysis Date: 2025-09-09
"""
from datetime import datetime, timedelta
from typing import Optional, List
import json
from sqlalchemy import (
Column, Integer, String, DateTime, Boolean, Text,
ForeignKey, LargeBinary, Index, UniqueConstraint
)
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import relationship, Session
from sqlalchemy.types import TypeDecorator, TEXT
Base = declarative_base()
class JSONType(TypeDecorator):
"""Custom JSON type for SQLAlchemy that handles JSON serialization"""
impl = TEXT
def process_bind_param(self, value, dialect):
if value is not None:
return json.dumps(value)
return value
def process_result_value(self, value, dialect):
if value is not None:
return json.loads(value)
return value
class Migration(Base):
"""Migration tracking table"""
__tablename__ = 'migrations'
id = Column(String, primary_key=True)
def __repr__(self):
return f"<Migration(id='{self.id}')>"
class User(Base):
"""Headscale Users table
Represents individual users/namespaces in the Headscale network.
Each user can have multiple nodes (machines) associated with them.
"""
__tablename__ = 'users'
__table_args__ = (
Index('idx_users_deleted_at', 'deleted_at'),
Index('idx_provider_identifier', 'provider_identifier',
postgresql_where="provider_identifier IS NOT NULL"),
Index('idx_name_provider_identifier', 'name', 'provider_identifier'),
Index('idx_name_no_provider_identifier', 'name',
postgresql_where="provider_identifier IS NULL"),
)
id = Column(Integer, primary_key=True, autoincrement=True)
created_at = Column(DateTime)
updated_at = Column(DateTime)
deleted_at = Column(DateTime) # Soft delete
name = Column(String) # User identifier (e.g., "myuser")
display_name = Column(String) # Human-readable display name
email = Column(String) # User email address
provider_identifier = Column(String) # External auth provider ID
provider = Column(String) # Auth provider name (OIDC, etc.)
profile_pic_url = Column(String) # Profile picture URL
# Relationships
nodes = relationship("Node", back_populates="user", cascade="all, delete-orphan")
pre_auth_keys = relationship("PreAuthKey", back_populates="user")
def __repr__(self):
return f"<User(id={self.id}, name='{self.name}', display_name='{self.display_name}')>"
def is_deleted(self) -> bool:
"""Check if user is soft-deleted"""
return self.deleted_at is not None
class Node(Base):
"""Headscale Nodes (Machines) table
Represents individual devices/machines connected to the Tailnet.
Each node belongs to a user and has various networking attributes.
"""
__tablename__ = 'nodes'
id = Column(Integer, primary_key=True, autoincrement=True)
machine_key = Column(String) # Machine's public key
node_key = Column(String) # Node's network key
disco_key = Column(String) # Discovery key for peer-to-peer connections
endpoints = Column(JSONType) # List of network endpoints (JSON array)
host_info = Column(JSONType) # Detailed host information (JSON object)
ipv4 = Column(String) # Assigned IPv4 address (e.g., "100.64.0.1")
ipv6 = Column(String) # Assigned IPv6 address
hostname = Column(String) # Machine hostname
given_name = Column(String) # User-assigned machine name
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'))
register_method = Column(String) # Registration method (e.g., "authkey")
forced_tags = Column(JSONType) # Tags forced on this node (JSON array)
auth_key_id = Column(Integer, ForeignKey('pre_auth_keys.id'))
expiry = Column(DateTime) # Node expiration date
last_seen = Column(DateTime) # Last activity timestamp
approved_routes = Column(JSONType) # Approved subnet routes (JSON array)
created_at = Column(DateTime)
updated_at = Column(DateTime)
deleted_at = Column(DateTime) # Soft delete
# Relationships
user = relationship("User", back_populates="nodes")
auth_key = relationship("PreAuthKey")
def __repr__(self):
return f"<Node(id={self.id}, hostname='{self.hostname}', ipv4='{self.ipv4}', user_id={self.user_id})>"
def is_online(self, timeout_minutes: int = 5) -> bool:
"""Check if node is considered online based on last_seen"""
if not self.last_seen:
return False
# Handle timezone-aware datetime
now = datetime.now()
last_seen = self.last_seen
# If last_seen is timezone-aware, make now timezone-aware too
if last_seen.tzinfo is not None and now.tzinfo is None:
from datetime import timezone
now = now.replace(tzinfo=timezone.utc)
# If last_seen is naive, make it naive too
elif last_seen.tzinfo is not None and now.tzinfo is None:
last_seen = last_seen.replace(tzinfo=None)
try:
return (now - last_seen).total_seconds() < (timeout_minutes * 60)
except TypeError:
# Fallback: just check if we have a recent timestamp
return True
def get_host_info(self) -> dict:
"""Get parsed host information"""
return self.host_info or {}
def get_endpoints(self) -> List[str]:
"""Get list of network endpoints"""
return self.endpoints or []
def get_forced_tags(self) -> List[str]:
"""Get list of forced tags"""
return self.forced_tags or []
def get_approved_routes(self) -> List[str]:
"""Get list of approved routes"""
return self.approved_routes or []
class PreAuthKey(Base):
"""Pre-authentication keys table
Keys used for automatic node registration without manual approval.
"""
__tablename__ = 'pre_auth_keys'
id = Column(Integer, primary_key=True, autoincrement=True)
key = Column(String) # The actual pre-auth key string
user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'))
reusable = Column(Boolean) # Can be used multiple times
ephemeral = Column(Boolean, default=False) # Temporary key
used = Column(Boolean, default=False) # Has been used
tags = Column(JSONType) # Tags to apply to nodes using this key
created_at = Column(DateTime)
expiration = Column(DateTime) # When the key expires
# Relationships
user = relationship("User", back_populates="pre_auth_keys")
def __repr__(self):
return f"<PreAuthKey(id={self.id}, key='{self.key[:8]}...', user_id={self.user_id}, reusable={self.reusable})>"
def is_expired(self) -> bool:
"""Check if the pre-auth key is expired"""
if not self.expiration:
return False
now = datetime.now()
expiration = self.expiration
# Handle timezone-aware datetime
if expiration.tzinfo is not None and now.tzinfo is None:
from datetime import timezone
now = now.replace(tzinfo=timezone.utc)
elif expiration.tzinfo is not None:
expiration = expiration.replace(tzinfo=None)
try:
return now > expiration
except TypeError:
return False
def is_valid(self) -> bool:
"""Check if the key is still valid for use"""
if self.is_expired():
return False
if self.used and not self.reusable:
return False
return True
def get_tags(self) -> List[str]:
"""Get list of tags for this key"""
return self.tags or []
class ApiKey(Base):
"""API Keys table
Keys used for API authentication to the Headscale server.
"""
__tablename__ = 'api_keys'
__table_args__ = (
Index('idx_api_keys_prefix', 'prefix', unique=True),
)
id = Column(Integer, primary_key=True, autoincrement=True)
prefix = Column(String) # Key prefix for identification (e.g., "8qRr1IB")
hash = Column(LargeBinary) # Hashed key value
created_at = Column(DateTime)
expiration = Column(DateTime) # When the key expires
last_seen = Column(DateTime) # Last time key was used
def __repr__(self):
return f"<ApiKey(id={self.id}, prefix='{self.prefix}', created_at='{self.created_at}')>"
def is_expired(self) -> bool:
"""Check if the API key is expired"""
if not self.expiration:
return False
now = datetime.now()
expiration = self.expiration
# Handle timezone-aware datetime
if expiration.tzinfo is not None and now.tzinfo is None:
from datetime import timezone
now = now.replace(tzinfo=timezone.utc)
elif expiration.tzinfo is not None:
expiration = expiration.replace(tzinfo=None)
try:
return now > expiration
except TypeError:
return False
class Policy(Base):
"""ACL Policies table
Stores Access Control List policies in JSON format.
"""
__tablename__ = 'policies'
__table_args__ = (
Index('idx_policies_deleted_at', 'deleted_at'),
)
id = Column(Integer, primary_key=True, autoincrement=True)
created_at = Column(DateTime)
updated_at = Column(DateTime)
deleted_at = Column(DateTime) # Soft delete
data = Column(Text) # JSON policy data
def __repr__(self):
return f"<Policy(id={self.id}, created_at='{self.created_at}')>"
def get_policy_data(self) -> dict:
"""Parse and return policy data as dictionary"""
try:
return json.loads(self.data) if self.data else {}
except json.JSONDecodeError:
return {}
# ==========================================
# Extended Models for FARMQ Customization
# ==========================================
class PharmacyInfo(Base):
"""Extended table for pharmacy information
This extends the base Headscale functionality to store
pharmacy-specific information for FARMQ management.
"""
__tablename__ = 'pharmacy_info'
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(String, ForeignKey('users.name'), unique=True) # Link to users.name
pharmacy_name = Column(String, nullable=False) # 약국명
business_number = Column(String(20)) # 사업자번호
address = Column(Text) # 주소
phone = Column(String(20)) # 전화번호
manager_name = Column(String(100)) # 담당자명
proxmox_host = Column(String(255)) # Proxmox 호스트 IP
proxmox_api_token = Column(Text) # Proxmox API 토큰
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
def __repr__(self):
return f"<PharmacyInfo(id={self.id}, name='{self.pharmacy_name}', business_number='{self.business_number}')>"
class MachineSpecs(Base):
"""Extended table for machine specifications
Stores detailed hardware specifications for each machine/node.
"""
__tablename__ = 'machine_specs'
id = Column(Integer, primary_key=True, autoincrement=True)
machine_id = Column(Integer, ForeignKey('nodes.id'), nullable=False)
pharmacy_id = Column(Integer, ForeignKey('pharmacy_info.id'))
cpu_model = Column(String(255)) # CPU 모델명
cpu_cores = Column(Integer) # CPU 코어 수
ram_gb = Column(Integer) # RAM 용량 (GB)
storage_gb = Column(Integer) # 스토리지 용량 (GB)
gpu_model = Column(String(255)) # GPU 모델명
last_updated = Column(DateTime, default=datetime.now, onupdate=datetime.now)
# Relationships
machine = relationship("Node")
pharmacy = relationship("PharmacyInfo")
def __repr__(self):
return f"<MachineSpecs(id={self.id}, cpu='{self.cpu_model}', ram={self.ram_gb}GB)>"
class MonitoringData(Base):
"""Real-time monitoring data table
Stores time-series monitoring data collected from Proxmox hosts.
"""
__tablename__ = 'monitoring_data'
id = Column(Integer, primary_key=True, autoincrement=True)
machine_id = Column(Integer, ForeignKey('nodes.id'), nullable=False)
cpu_usage = Column(String(5)) # CPU 사용률 (예: "75.50")
memory_usage = Column(String(5)) # 메모리 사용률
disk_usage = Column(String(5)) # 디스크 사용률
cpu_temperature = Column(Integer) # CPU 온도 (섭씨)
network_rx_bytes = Column(Integer) # 네트워크 수신 바이트
network_tx_bytes = Column(Integer) # 네트워크 송신 바이트
vm_count = Column(Integer) # 총 VM 개수
vm_running = Column(Integer) # 실행중인 VM 개수
collected_at = Column(DateTime, default=datetime.now)
# Relationships
machine = relationship("Node")
def __repr__(self):
return f"<MonitoringData(machine_id={self.machine_id}, cpu={self.cpu_usage}%, temp={self.cpu_temperature}°C)>"
def get_cpu_usage_float(self) -> float:
"""CPU 사용률을 float로 반환"""
try:
return float(self.cpu_usage) if self.cpu_usage else 0.0
except ValueError:
return 0.0
# ==========================================
# Database Helper Functions
# ==========================================
def create_all_tables(engine):
"""Create all tables in the database"""
Base.metadata.create_all(engine)
def get_active_nodes(session: Session) -> List[Node]:
"""Get all non-deleted nodes"""
return session.query(Node).filter(Node.deleted_at.is_(None)).all()
def get_online_nodes(session: Session, timeout_minutes: int = 5) -> List[Node]:
"""Get nodes that are currently online"""
cutoff_time = datetime.now() - timedelta(minutes=timeout_minutes)
return session.query(Node).filter(
Node.deleted_at.is_(None),
Node.last_seen > cutoff_time
).all()
def get_user_with_pharmacy_info(session: Session, user_name: str):
"""Get user with associated pharmacy information"""
return session.query(User).join(PharmacyInfo).filter(User.name == user_name).first()
def get_node_with_specs_and_monitoring(session: Session, node_id: int):
"""Get node with hardware specs and latest monitoring data"""
return session.query(Node)\
.outerjoin(MachineSpecs)\
.outerjoin(MonitoringData)\
.filter(Node.id == node_id)\
.first()
# ==========================================
# Usage Example
# ==========================================
if __name__ == "__main__":
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
# SQLite connection to Headscale database
DATABASE_URL = "sqlite:///data/db.sqlite"
engine = create_engine(DATABASE_URL)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# Create extended tables (if needed)
create_all_tables(engine)
# Example usage
session = SessionLocal()
try:
# Get all users
users = session.query(User).all()
print("=== Users ===")
for user in users:
print(f" {user}")
# Get all nodes
nodes = session.query(Node).all()
print("\n=== Nodes ===")
for node in nodes:
print(f" {node}")
print(f" Online: {node.is_online()}")
print(f" Host Info: {node.get_host_info().get('Hostname', 'Unknown')}")
# Get all API keys
api_keys = session.query(ApiKey).all()
print("\n=== API Keys ===")
for key in api_keys:
print(f" {key}")
print(f" Expired: {key.is_expired()}")
finally:
session.close()

519
quick-install.sh Executable file
View File

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

162
register-client.sh Executable file
View File

@@ -0,0 +1,162 @@
#!/bin/bash
# 팜큐(FARMQ) Headscale 클라이언트 등록 스크립트
# 사용법: ./register-client.sh
set -e
# 설정
HEADSCALE_SERVER="https://head.0bin.in"
PREAUTH_KEY="fc4f2dc55ee00c5352823d156129b9ce2df4db02f1d76a21"
# 색상 출력 함수
print_status() {
echo -e "\n🔧 $1"
}
print_success() {
echo -e "\n✅ $1"
}
print_error() {
echo -e "\n❌ $1"
}
print_info() {
echo -e "\n📋 $1"
}
# 운영체제 감지
detect_os() {
if [[ "$OSTYPE" == "linux-gnu"* ]]; then
if command -v apt &> /dev/null; then
OS="ubuntu"
elif command -v yum &> /dev/null; then
OS="centos"
else
OS="linux"
fi
elif [[ "$OSTYPE" == "darwin"* ]]; then
OS="macos"
elif [[ "$OSTYPE" == "msys" ]] || [[ "$OSTYPE" == "cygwin" ]]; then
OS="windows"
else
OS="unknown"
fi
echo $OS
}
# Tailscale 설치 확인 및 설치
install_tailscale() {
OS=$(detect_os)
if command -v tailscale &> /dev/null; then
print_info "Tailscale이 이미 설치되어 있습니다."
return 0
fi
print_status "Tailscale 설치 중..."
case $OS in
"ubuntu")
curl -fsSL https://tailscale.com/install.sh | sh
;;
"centos")
curl -fsSL https://tailscale.com/install.sh | sh
;;
"macos")
echo "macOS용 Tailscale을 다운로드합니다."
echo "다음 URL에서 수동으로 설치하세요: https://tailscale.com/download/mac"
exit 1
;;
"windows")
echo "Windows용 Tailscale을 다운로드합니다."
echo "다음 URL에서 수동으로 설치하세요: https://tailscale.com/download/windows"
exit 1
;;
*)
print_error "지원되지 않는 운영체제입니다: $OSTYPE"
exit 1
;;
esac
}
# 기존 Tailscale 연결 해제
disconnect_existing() {
if tailscale status --json &> /dev/null; then
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
fi
fi
}
# Headscale에 등록
register_to_headscale() {
print_status "팜큐 Headscale 서버에 등록 중..."
print_info "서버: $HEADSCALE_SERVER"
# Tailscale을 Headscale 서버로 설정하고 등록
sudo tailscale up \
--login-server="$HEADSCALE_SERVER" \
--authkey="$PREAUTH_KEY" \
--accept-routes \
--accept-dns=false
}
# 연결 상태 확인
check_connection() {
print_status "연결 상태 확인 중..."
# 잠시 대기
sleep 3
# 상태 확인
if tailscale status &> /dev/null; then
local tailscale_ip=$(tailscale ip -4 2>/dev/null || echo "")
if [[ -n "$tailscale_ip" ]]; then
print_success "성공적으로 연결되었습니다!"
print_info "할당된 IP: $tailscale_ip"
print_info "네트워크 상태:"
tailscale status
return 0
fi
fi
print_error "연결에 실패했습니다."
print_info "수동으로 상태를 확인해보세요: tailscale status"
return 1
}
# 메인 함수
main() {
echo "=========================================="
echo " 🏥 팜큐(FARMQ) Headscale 클라이언트 등록"
echo "=========================================="
# 루트 권한 확인
if [[ $EUID -ne 0 ]] && ! sudo -n true 2>/dev/null; then
print_error "이 스크립트는 sudo 권한이 필요합니다."
exit 1
fi
# 단계별 실행
install_tailscale
disconnect_existing
register_to_headscale
if check_connection; then
print_success "🎉 등록 완료!"
print_info "이제 팜큐 네트워크에 연결되었습니다."
print_info "문제가 있으면 관리자에게 문의하세요."
else
print_error "등록 과정에서 문제가 발생했습니다."
exit 1
fi
}
# 스크립트 실행
main "$@"

View File

@@ -67,8 +67,9 @@ echo ""
echo "🎉 설치 완료!" echo "🎉 설치 완료!"
echo "" echo ""
echo "📋 접속 정보:" echo "📋 접속 정보:"
echo " - Headscale API: http://localhost:8080" echo " - Headscale API: http://localhost:8070"
echo " - Headplane UI: http://localhost:3000" echo " - Headplane UI: http://localhost:3000/admin/"
echo " - 외부 접속: http://192.168.0.151:3000/admin/"
echo " - API 키: $API_KEY" echo " - API 키: $API_KEY"
echo "" echo ""
echo "📖 다음 단계:" echo "📖 다음 단계:"
@@ -79,7 +80,7 @@ echo " 2. Pre-auth 키 생성:"
echo " docker-compose exec headscale headscale preauthkeys create --user myuser --reusable --expiration 24h" echo " docker-compose exec headscale headscale preauthkeys create --user myuser --reusable --expiration 24h"
echo "" echo ""
echo " 3. 클라이언트 연결:" echo " 3. 클라이언트 연결:"
echo " tailscale up --login-server=http://localhost:8080" echo " tailscale up --login-server=http://localhost:8070"
echo "" echo ""
echo "📊 상태 확인:" echo "📊 상태 확인:"
echo " docker-compose ps" echo " docker-compose ps"

284
test_headscale_models.py Normal file
View File

@@ -0,0 +1,284 @@
#!/usr/bin/env python3
"""
Headscale Database Model Test Script
테스트를 위해 실제 SQLite DB에 연결하여 데이터 조회
"""
import sys
import os
from datetime import datetime, timedelta
from pathlib import Path
# Add current directory to path for importing models
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
try:
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker
from headscale_models import (
User, Node, PreAuthKey, ApiKey, Policy,
PharmacyInfo, MachineSpecs, MonitoringData,
create_all_tables
)
print("✅ SQLAlchemy models imported successfully")
except ImportError as e:
print(f"❌ Failed to import models: {e}")
print("💡 Install required packages: pip install sqlalchemy")
sys.exit(1)
def test_database_connection():
"""데이터베이스 연결 테스트"""
db_path = Path("data/db.sqlite")
if not db_path.exists():
print(f"❌ Database file not found: {db_path}")
return None
DATABASE_URL = f"sqlite:///{db_path}"
print(f"🔗 Connecting to: {DATABASE_URL}")
try:
engine = create_engine(DATABASE_URL)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
session = SessionLocal()
# Test connection with a simple query
result = session.execute(text("SELECT COUNT(*) FROM users")).scalar()
print(f"✅ Database connection successful. User count: {result}")
return session, engine
except Exception as e:
print(f"❌ Database connection failed: {e}")
return None, None
def test_user_model(session):
"""User 모델 테스트"""
print("\n" + "="*50)
print("📊 TESTING USER MODEL")
print("="*50)
users = session.query(User).all()
print(f"📋 Total users: {len(users)}")
for user in users:
print(f"\n👤 {user}")
print(f" - Created: {user.created_at}")
print(f" - Display Name: {user.display_name or 'Not set'}")
print(f" - Email: {user.email or 'Not set'}")
print(f" - Deleted: {user.is_deleted()}")
print(f" - Nodes Count: {len(user.nodes)}")
def test_node_model(session):
"""Node 모델 테스트"""
print("\n" + "="*50)
print("💻 TESTING NODE MODEL")
print("="*50)
nodes = session.query(Node).all()
print(f"📋 Total nodes: {len(nodes)}")
for node in nodes:
print(f"\n🖥️ {node}")
print(f" - Given Name: {node.given_name}")
print(f" - User: {node.user.name if node.user else 'None'}")
print(f" - Online: {'🟢 Yes' if node.is_online() else '🔴 No'}")
print(f" - Last Seen: {node.last_seen}")
print(f" - Endpoints: {len(node.get_endpoints())} endpoint(s)")
# Host info details
host_info = node.get_host_info()
if host_info:
print(f" - OS: {host_info.get('OS', 'Unknown')} {host_info.get('OSVersion', '')}")
print(f" - Hostname: {host_info.get('Hostname', 'Unknown')}")
print(f" - Machine: {host_info.get('Machine', 'Unknown')}")
def test_api_key_model(session):
"""API Key 모델 테스트"""
print("\n" + "="*50)
print("🔑 TESTING API KEY MODEL")
print("="*50)
api_keys = session.query(ApiKey).all()
print(f"📋 Total API keys: {len(api_keys)}")
for key in api_keys:
print(f"\n🔐 {key}")
print(f" - Expired: {'❌ Yes' if key.is_expired() else '✅ No'}")
print(f" - Created: {key.created_at}")
print(f" - Expires: {key.expiration}")
print(f" - Last Used: {key.last_seen or 'Never'}")
def test_pre_auth_key_model(session):
"""Pre-Auth Key 모델 테스트"""
print("\n" + "="*50)
print("🎫 TESTING PRE-AUTH KEY MODEL")
print("="*50)
pre_auth_keys = session.query(PreAuthKey).all()
print(f"📋 Total pre-auth keys: {len(pre_auth_keys)}")
for key in pre_auth_keys:
print(f"\n🎟️ {key}")
print(f" - User: {key.user.name if key.user else 'None'}")
print(f" - Reusable: {'✅ Yes' if key.reusable else '❌ No'}")
print(f" - Used: {'✅ Yes' if key.used else '❌ No'}")
print(f" - Valid: {'✅ Yes' if key.is_valid() else '❌ No'}")
print(f" - Expires: {key.expiration}")
print(f" - Tags: {key.get_tags()}")
def test_policy_model(session):
"""Policy 모델 테스트"""
print("\n" + "="*50)
print("📜 TESTING POLICY MODEL")
print("="*50)
policies = session.query(Policy).all()
print(f"📋 Total policies: {len(policies)}")
for policy in policies:
print(f"\n📄 {policy}")
policy_data = policy.get_policy_data()
if policy_data:
print(f" - ACL Rules: {len(policy_data.get('acls', []))}")
print(f" - Groups: {len(policy_data.get('groups', {}))}")
def create_sample_extended_data(session, engine):
"""확장 테이블용 샘플 데이터 생성"""
print("\n" + "="*50)
print("🏥 CREATING SAMPLE PHARMACY DATA")
print("="*50)
# Create extended tables
create_all_tables(engine)
# Get first user
user = session.query(User).first()
if not user:
print("❌ No users found. Cannot create pharmacy info.")
return
# Check if pharmacy info already exists
existing_pharmacy = session.query(PharmacyInfo).filter_by(user_id=user.name).first()
if existing_pharmacy:
print(f" Pharmacy info already exists for user '{user.name}'")
return
# Create pharmacy info
pharmacy = PharmacyInfo(
user_id=user.name,
pharmacy_name="서울중앙약국",
business_number="123-45-67890",
address="서울시 강남구 테헤란로 123",
phone="02-1234-5678",
manager_name="홍길동",
proxmox_host="192.168.1.100",
proxmox_api_token="sample_token_here"
)
session.add(pharmacy)
# Get first node
node = session.query(Node).first()
if node:
# Create machine specs
specs = MachineSpecs(
machine_id=node.id,
pharmacy_id=1, # Will be set properly after pharmacy is committed
cpu_model="Intel Core i7-12700",
cpu_cores=12,
ram_gb=32,
storage_gb=1000,
gpu_model="NVIDIA GTX 1660"
)
session.add(specs)
# Create monitoring data
monitoring = MonitoringData(
machine_id=node.id,
cpu_usage="75.50",
memory_usage="60.25",
disk_usage="45.00",
cpu_temperature=65,
network_rx_bytes=1024000,
network_tx_bytes=512000,
vm_count=5,
vm_running=4
)
session.add(monitoring)
try:
session.commit()
print("✅ Sample extended data created successfully")
except Exception as e:
session.rollback()
print(f"❌ Failed to create sample data: {e}")
def test_extended_models(session):
"""확장된 모델 테스트"""
print("\n" + "="*50)
print("🏥 TESTING EXTENDED MODELS (FARMQ)")
print("="*50)
# Test pharmacy info
pharmacies = session.query(PharmacyInfo).all()
print(f"🏪 Total pharmacies: {len(pharmacies)}")
for pharmacy in pharmacies:
print(f" - {pharmacy}")
# Test machine specs
specs = session.query(MachineSpecs).all()
print(f"⚙️ Total machine specs: {len(specs)}")
for spec in specs:
print(f" - {spec}")
# Test monitoring data
monitoring = session.query(MonitoringData).all()
print(f"📊 Total monitoring records: {len(monitoring)}")
for monitor in monitoring:
print(f" - {monitor}")
def main():
"""메인 테스트 함수"""
print("🧪 HEADSCALE DATABASE MODEL TEST")
print("=" * 60)
# Connect to database
session, engine = test_database_connection()
if not session:
return
try:
# Test core models
test_user_model(session)
test_node_model(session)
test_api_key_model(session)
test_pre_auth_key_model(session)
test_policy_model(session)
# Create sample extended data (if needed)
create_sample_extended_data(session, engine)
# Test extended models
test_extended_models(session)
print("\n" + "="*60)
print("🎉 ALL TESTS COMPLETED SUCCESSFULLY!")
print("="*60)
except Exception as e:
print(f"\n❌ Test failed with error: {e}")
import traceback
traceback.print_exc()
finally:
session.close()
if __name__ == "__main__":
main()