7d6d36d401
Co-authored-by: aider (gemini/gemini-2.5-flash) <aider@aider.chat>
269 lines
9.0 KiB
Go
269 lines
9.0 KiB
Go
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"`
|
|
}
|
|
|
|
// LoginRequest는 로그인 요청에 사용됩니다.
|
|
type LoginRequest struct {
|
|
Username string `json:"username"`
|
|
Password string `json:"password"`
|
|
}
|
|
|
|
// Config는 애플리케이션 설정을 나타냅니다.
|
|
type Config struct {
|
|
HtpasswdPath string `json:"htpasswdPath"`
|
|
Port int `json:"port"`
|
|
}
|
|
|
|
var (
|
|
htpasswdPath string
|
|
appPort int // 애플리케이션 포트
|
|
htpasswdMutex sync.Mutex // htpasswd 명령 실행 시 동시성 문제를 방지하기 위한 뮤텍스
|
|
usernameRegex *regexp.Regexp // 사용자 이름 유효성 검사를 위한 정규식
|
|
)
|
|
|
|
func init() {
|
|
// config.json 파일에서 설정 로드
|
|
configData, err := os.ReadFile("config.json")
|
|
if err != nil {
|
|
log.Fatalf("config.json 파일을 읽을 수 없습니다: %v", err)
|
|
}
|
|
|
|
var cfg Config
|
|
if err := json.Unmarshal(configData, &cfg); err != nil {
|
|
log.Fatalf("config.json 파싱 오류: %v", err)
|
|
}
|
|
|
|
htpasswdPath = cfg.HtpasswdPath
|
|
appPort = cfg.Port
|
|
|
|
if htpasswdPath == "" {
|
|
log.Fatal("config.json에 'htpasswdPath'가 설정되지 않았습니다.")
|
|
}
|
|
if appPort == 0 {
|
|
appPort = 8000 // 기본 포트
|
|
log.Printf("config.json에 'port'가 설정되지 않아 기본값 %d번 포트를 사용합니다.\n", appPort)
|
|
}
|
|
|
|
// 사용자 이름 유효성 검사 정규식 초기화: 영문, 숫자, 하이픈, 언더스코어만 허용
|
|
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 요청 처리
|
|
mux.HandleFunc("/api/login", handleLogin) // 로그인 요청 처리
|
|
|
|
log.Printf("VaultKeeper 서버가 %d 포트에서 시작됩니다.\n", appPort)
|
|
log.Printf("HTPASSWD_PATH: %s\n", htpasswdPath)
|
|
log.Fatal(http.ListenAndServe(fmt.Sprintf(":%d", appPort), 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 <file> <username> <password>
|
|
// 파일이 없으면 -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 <file> <username>
|
|
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)})
|
|
}
|
|
|
|
// handleLogin은 사용자 로그인을 처리합니다.
|
|
func handleLogin(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "허용되지 않는 메소드입니다.", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
var req LoginRequest
|
|
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 -v <file> <username> <password> 명령어를 사용하여 비밀번호 확인
|
|
// exit code 0이면 성공, 0이 아니면 실패
|
|
cmd := exec.Command("htpasswd", "-v", htpasswdPath, req.Username, req.Password)
|
|
output, err := cmd.CombinedOutput()
|
|
|
|
if err != nil {
|
|
// htpasswd -v는 비밀번호가 일치하지 않거나 사용자 이름을 찾을 수 없으면 에러를 반환합니다.
|
|
log.Printf("Login verification failed for user '%s': %v, Output: %s", req.Username, err, output)
|
|
http.Error(w, "사용자 이름 또는 비밀번호가 올바르지 않습니다.", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]string{"message": "로그인 성공"})
|
|
}
|