commit bd414adf6e51951318ec1df0a500a78f1232b719 Author: dihwang Date: Mon Apr 13 14:05:41 2026 +0900 feat: 새로운 htpasswd 관리 애플리케이션 VaultKeeper 구현 Co-authored-by: aider (gemini/gemini-2.5-flash) diff --git a/deployment.md b/deployment.md new file mode 100644 index 0000000..67af368 --- /dev/null +++ b/deployment.md @@ -0,0 +1,96 @@ +# VaultKeeper 배포 가이드 + +이 문서는 VaultKeeper 애플리케이션의 빌드, 초기 설정 및 Nginx 연동 방법을 안내합니다. + +## 1. 백엔드 빌드 + +Go 바이너리를 가볍게 빌드하려면 다음 명령어를 사용합니다. 이 명령어는 디버그 정보를 제거하고 심볼 테이블을 생략하여 최종 바이너리 크기를 줄입니다. + +```bash +go build -o vaultkeeper -ldflags "-s -w" main.go +``` + +빌드가 완료되면 현재 디렉토리에 `vaultkeeper` 실행 파일이 생성됩니다. + +## 2. `.htpasswd` 파일 경로 설정 + +VaultKeeper는 `.htpasswd` 파일의 경로를 환경 변수 `HTPASSWD_PATH`를 통해 설정합니다. +애플리케이션을 실행하기 전에 이 환경 변수를 설정해야 합니다. + +예시: +```bash +export HTPASSWD_PATH="/etc/nginx/.htpasswd" +``` +또는 +```bash +export HTPASSWD_PATH="/usr/local/etc/htpasswd" +``` + +**주의:** 지정된 경로에 Nginx가 실제로 사용하는 `.htpasswd` 파일이 있어야 합니다. 쓰기 권한도 필요합니다. + +## 3. 최초 관리자 계정 생성 (터미널) + +VaultKeeper는 자체 로그인 로직을 포함하지 않으며, Nginx `auth_basic` 모듈에 인증을 위임합니다. +웹 UI에 접근하려면 Nginx를 통해 인증할 수 있는 계정이 필요합니다. +최초 관리자 계정은 VaultKeeper가 실행되기 전에 터미널에서 `htpasswd` 명령어를 사용하여 생성해야 합니다. + +`.htpasswd` 파일이 아직 없는 경우: +```bash +htpasswd -c /etc/nginx/.htpasswd adminuser +``` +`adminuser`를 원하는 관리자 사용자 이름으로 바꾸세요. 이 명령어는 `/etc/nginx/.htpasswd` 파일을 생성하고 `adminuser`를 추가합니다. + +`.htpasswd` 파일이 이미 있는 경우 (새 사용자를 추가하거나 기존 사용자의 비밀번호를 변경): +```bash +htpasswd /etc/nginx/.htpasswd anotheruser +``` +`-c` 옵션을 사용하면 기존 파일이 덮어쓰여지므로 **절대 이미 존재하는 파일에 `-c` 옵션을 다시 사용하지 마세요.** + +## 4. VaultKeeper 실행 + +환경 변수 설정 및 `.htpasswd` 파일 준비가 완료되면, 빌드된 VaultKeeper 바이너리를 실행합니다. + +```bash +./vaultkeeper +``` + +기본적으로 VaultKeeper는 8000번 포트에서 수신 대기합니다. + +## 5. Nginx 설정 예시 + +Nginx의 `auth_basic` 모듈 뒤에서 VaultKeeper를 실행하여 접근을 제한할 수 있습니다. +다음은 Nginx 설정 파일 (`/etc/nginx/nginx.conf` 또는 `conf.d` 내부 파일)에 추가할 수 있는 `location` 블록 예시입니다. + +이 설정은 `/vaultkeeper/` 경로로 들어오는 요청을 VaultKeeper 백엔드(8000번 포트)로 프록시하고, +`auth_basic`을 통해 `.htpasswd` 파일로 인증을 요구합니다. + +```nginx +server { + listen 80; + server_name your_domain.com; # 도메인 또는 IP 주소로 변경 + + location /vaultkeeper/ { + auth_basic "Restricted Access"; + auth_basic_user_file /etc/nginx/.htpasswd; # .htpasswd 파일 경로를 정확히 지정 + + proxy_pass http://127.0.0.1:8000/; + proxy_set_header Host $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_redirect off; + } + + # 다른 Nginx 설정... +} +``` + +Nginx 설정을 변경한 후에는 반드시 Nginx를 재로드하거나 재시작하여 변경사항을 적용해야 합니다. + +```bash +sudo nginx -t # 설정 파일 유효성 검사 +sudo systemctl reload nginx # Nginx 재로드 +``` + +이제 `http://your_domain.com/vaultkeeper/` (또는 `http://your_server_ip/vaultkeeper/`)로 접근하여 VaultKeeper 웹 UI를 사용할 수 있습니다. +접근 시 Nginx가 사용자 이름과 비밀번호를 요청할 것입니다. diff --git a/index.html b/index.html new file mode 100644 index 0000000..8a5e9a3 --- /dev/null +++ b/index.html @@ -0,0 +1,174 @@ + + + + + + VaultKeeper - Nginx .htpasswd 관리 + + + + +
+
+

VaultKeeper

+

Nginx .htpasswd 파일 관리

+ + +
+

새 사용자 추가

+
+
+ + +
+
+ + +
+ +
+
+
+ + +
+

현재 사용자 목록

+
+
    + +
+ +
+
+
+
+
+ + + + diff --git a/main.go b/main.go new file mode 100644 index 0000000..808aca9 --- /dev/null +++ b/main.go @@ -0,0 +1,196 @@ +package main + +import ( + "bufio" + "encoding/json" + "fmt" + "log" + "net/http" + "os" + "os/exec" + "regexp" + "strings" + "sync" +) + +// User는 .htpasswd 파일의 사용자 정보를 나타냅니다. +type User struct { + Username string `json:"username"` +} + +// AddUserRequest는 새 사용자 추가 요청에 사용됩니다. +type AddUserRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +var ( + htpasswdPath string + htpasswdMutex sync.Mutex // htpasswd 명령 실행 시 동시성 문제를 방지하기 위한 뮤텍스 + usernameRegex *regexp.Regexp // 사용자 이름 유효성 검사를 위한 정규식 +) + +func init() { + // HTPASSWD_PATH 환경 변수 설정 + htpasswdPath = os.Getenv("HTPASSWD_PATH") + if htpasswdPath == "" { + log.Fatal("환경 변수 HTPASSWD_PATH가 설정되지 않았습니다.") + } + + // 사용자 이름 유효성 검사 정규식 초기화: 영문, 숫자, 하이픈, 언더스코어만 허용 + usernameRegex = regexp.MustCompile("^[a-zA-Z0-9_-]+$") +} + +func main() { + mux := http.NewServeMux() + + // 정적 파일(index.html) 제공 + mux.HandleFunc("/", serveFrontend) + + // API 엔드포인트 + mux.HandleFunc("/api/users", handleUsers) + mux.HandleFunc("/api/users/", handleDeleteUser) // DELETE 요청 처리 + + log.Println("VaultKeeper 서버가 8000 포트에서 시작됩니다.") + log.Printf("HTPASSWD_PATH: %s\n", htpasswdPath) + log.Fatal(http.ListenAndServe(":8000", mux)) +} + +// serveFrontend는 index.html 파일을 제공합니다. +func serveFrontend(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/" || r.URL.Path == "/index.html" { + http.ServeFile(w, r, "index.html") + return + } + http.NotFound(w, r) +} + +// handleUsers는 GET /api/users 및 POST /api/users 요청을 처리합니다. +func handleUsers(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + getUsers(w, r) + case http.MethodPost: + addUser(w, r) + default: + http.Error(w, "허용되지 않는 메소드입니다.", http.StatusMethodNotAllowed) + } +} + +// getUsers는 .htpasswd 파일에서 사용자 목록을 읽어 JSON으로 반환합니다. +func getUsers(w http.ResponseWriter, r *http.Request) { + htpasswdMutex.Lock() + defer htpasswdMutex.Unlock() + + file, err := os.Open(htpasswdPath) + if os.IsNotExist(err) { + // 파일이 없으면 빈 목록 반환 (처음 생성 시) + json.NewEncoder(w).Encode([]User{}) + return + } + if err != nil { + log.Printf("Error opening .htpasswd file: %v", err) + http.Error(w, "사용자 파일을 읽을 수 없습니다.", http.StatusInternalServerError) + return + } + defer file.Close() + + var users []User + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := scanner.Text() + if len(line) > 0 && !strings.HasPrefix(line, "#") { // 주석 및 빈 줄 무시 + parts := strings.SplitN(line, ":", 2) + if len(parts) > 0 { + users = append(users, User{Username: parts[0]}) + } + } + } + + if err := scanner.Err(); err != nil { + log.Printf("Error scanning .htpasswd file: %v", err) + http.Error(w, "사용자 파일을 스캔할 수 없습니다.", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(users) +} + +// addUser는 새 사용자를 .htpasswd 파일에 추가합니다. +func addUser(w http.ResponseWriter, r *http.Request) { + var req AddUserRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "유효하지 않은 요청 본문입니다.", http.StatusBadRequest) + return + } + + // 사용자 이름 유효성 검사 + if !usernameRegex.MatchString(req.Username) { + http.Error(w, "사용자 이름은 영문, 숫자, 하이픈, 언더스코어만 포함할 수 있습니다.", http.StatusBadRequest) + return + } + if req.Username == "" || req.Password == "" { + http.Error(w, "사용자 이름과 비밀번호는 필수입니다.", http.StatusBadRequest) + return + } + + htpasswdMutex.Lock() + defer htpasswdMutex.Unlock() + + // htpasswd -b + // 파일이 없으면 -c 옵션이 필요하지만, 첫 생성은 터미널에서 하도록 가이드하므로 여기서는 -b만 사용. + // -b는 파일이 없으면 생성하고, 있으면 추가/수정합니다. + cmd := exec.Command("htpasswd", "-b", htpasswdPath, req.Username, req.Password) + output, err := cmd.CombinedOutput() + if err != nil { + log.Printf("Error executing htpasswd -b: %v, Output: %s", err, output) + http.Error(w, fmt.Sprintf("사용자 추가 실패: %s", strings.TrimSpace(string(output))), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]string{"message": "사용자가 성공적으로 추가되었습니다."}) +} + +// handleDeleteUser는 DELETE /api/users/{username} 요청을 처리합니다. +func handleDeleteUser(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + http.Error(w, "허용되지 않는 메소드입니다.", http.StatusMethodNotAllowed) + return + } + + username := strings.TrimPrefix(r.URL.Path, "/api/users/") + if username == "" { + http.Error(w, "사용자 이름을 지정해야 합니다.", http.StatusBadRequest) + return + } + + // 사용자 이름 유효성 검사 (삭제도 동일하게 적용) + if !usernameRegex.MatchString(username) { + http.Error(w, "유효하지 않은 사용자 이름 형식입니다.", http.StatusBadRequest) + return + } + + htpasswdMutex.Lock() + defer htpasswdMutex.Unlock() + + // htpasswd -D + cmd := exec.Command("htpasswd", "-D", htpasswdPath, username) + output, err := cmd.CombinedOutput() + if err != nil { + // htpasswd -D는 사용자가 존재하지 않을 경우에도 에러를 반환합니다. + // 그러나 사용자 삭제 요청 자체는 성공했다고 간주할 수 있습니다 (해당 사용자가 없는 상태이므로). + // 실제 htpasswd의 에러 메시지를 확인하여 사용자 부재인지 다른 에러인지 구분할 수 있으나, + // 간단하게 사용자 부재 메시지가 아니라면 서버 에러로 처리합니다. + if strings.Contains(strings.ToLower(string(output)), "not found") { + http.Error(w, fmt.Sprintf("사용자 '%s'를 찾을 수 없습니다.", username), http.StatusNotFound) + return + } + log.Printf("Error executing htpasswd -D: %v, Output: %s", err, output) + http.Error(w, fmt.Sprintf("사용자 삭제 실패: %s", strings.TrimSpace(string(output))), http.StatusInternalServerError) + return + } + + json.NewEncoder(w).Encode(map[string]string{"message": fmt.Sprintf("사용자 '%s'가 성공적으로 삭제되었습니다.", username)}) +}