정글에서 온 개발자

TIL 2/4 Nginx,Apache없이 Certbot 적용하기 (with Golang) 본문

TIL

TIL 2/4 Nginx,Apache없이 Certbot 적용하기 (with Golang)

dev-diver 2025. 2. 5. 23:01

https 통신이 아닌 경우

계기

기존에 네트워크 지식이 없을 때는 certbot을 이용하면서도 내가 만들어놓은 WAS가 80 번을 listen하게 한 상태로 nginx-proxy 도커를 설치해서 proxy를 해왔다. 나중에는 갱신까지 포함된 docker를 쓰면서 신세계라고 생각하기도 했다.

Go로 짠 프로그램으로 Nginx, Apache 같은 Web Server를 대체할 수도 있다는 사실을 알게 된 뒤로는 (사실 같은 원리로 node.js로도 이런 설정을 할 수 있지만 느릴 수 있다.) 굳이 Nginx를 안 써도 되겠다는 생각이 들었다.

마침 이번에 아버지 홈페이지를 만들어드리면서 '주의 요함' 이 뜨길래 이를 해결하고자 도전해 봤다.

배경 지식

HTTPS

암호화 통신을 시작하기 위한 public key가 필요하고, 이 public key에 믿을만한 제 3자 (CA)가 다시 한 번 자신의 private key로 서명을 한 것이 인증서이다.

암호화 통신을 위한 public key는 클라이언트에서 https 통신을 시작할 때 필요하다. 이 public key가 위조됐다면(그런데 누군가와 연결이 잘 됐다면), 클라이언트는 자신이 의도한 서버가 아닌 중간자 (middle man) 와 통신하고 있을 수 있다. 이 위조를 막기 위해 pubic key에 서명을 해서 제 3자가 보증해주는 것이다.

브라우저는 서버에서 제공하는 인증서의 유효성을 검사 (CA의 public key로 인증서를 디코딩하고 내용을 확인함) 해준다.

브라우저의 확인

요약하면

  1. https 통신을 위해서는 인증서와, 이를 디코딩하기 위한 public key (보통 CA의)가 필요하다.
  2. 따라서 CA에게서 인증서를 발급받아야 한다.

인증서는 자기가 직접 만들 수도 있지만 (Self Signed Certificate) 브라우저는 이 인증서를 안전하지 않아고 표시할 것이기 때문에 발급 받는게 좋다.

CA

인증기관(CA;Certificate Authority) 는 인증서를 만들 당시 domain에 요청 했을 때의  public key에 서명을 한다. 이후에 같은 도메인에서는 해당 public key만 유효하기 때문에 도메인을 통한 접속이 정상적인 서버와 이루어졌음을 보증한다.(도메인 검증;DV;Domain Validation)

돈을 내는 인증기관들은 추가적으로 회사에 대한 법적 신원 보증 등을 해주기도 한다. (EV; Extended Validation) 

아버지 홈페이지에서는 EV까지는 필요 없기 때문에 무료 CA 중 가장 유명한 Let's Encrypt에서 발급을 하기로 했다.

Certbot

Let's Encrypt를 선택한 이유는, 해당 CA로부터의 발급을 자동화해주는 certbot이 있기 때문이다. 도메인이 있으면 발급을 자동으로 도와주고, 갱신 기한이 있지만 주기적 갱신도 할 수 있게 해준다.

개발

기존 web server

var domain = "내 도메인"

func main() {
	http.HandleFunc("/", handleRequest)
    
    err := http.ListenAndServe(":80", nil)
    if err != nil {
        fmt.Printf("Error starting server: %v\n", err)
    }
}

func handleRequest(w http.ResponseWriter, r *http.Request) {
	var target *url.URL

	if strings.Contains(r.Host, domain) {
		target = &url.URL{
			Scheme: "http",
			Host:   "localhost:3000",
		}
	}

	if target == nil {
		log.Printf("Invalid request: Host=%s", r.Host)
		http.Error(w, "Target URL is nil", http.StatusInternalServerError)
		return
	}

	proxy := httputil.NewSingleHostReverseProxy(target)
	proxy.ServeHTTP(w, r)
}

기존 코드는 위처럼 80번을 Listen 하고,  내가 지정한 domain으로 요청이 들어오면, localhost의 3000번으로 연결이 되도록 설정하는 간단한 코드였다.

443 Listen

https 로 연결하려면 443번을 Listen 하게 하고, 여기에 인증서와 private key를 넘겨줘야 한다.

err := http.ListenAndServeTLS(":443", certFile, keyFile, nil)
if err != nil {
    log.Fatalf("Error starting HTTPS server: %v\n", err)
}

private key는 인증서의 본문인 public key와 짝을 이뤄서, client가 public key로 암호화한 걸 복호화할 대 사용한다.

그런데 go에서 http.Listen을 두번 하면 앞쪽 코드가 blocking을 하기 때문에 둘 중 하나를 go 쓰레드로 돌려줘야 한다.

go func() {
    fmt.Println("Starting server on :80")
    err := http.ListenAndServe(":80", nil)
    if err != nil {
        fmt.Printf("Error starting server: %v\n", err)
    }
}()


fmt.Println("Starting HTTPS server on :443")
err := http.ListenAndServeTLS(":443", certFile, keyFile, nil)
if err != nil {
    log.Fatalf("Error starting HTTPS server: %v\n", err)
}

go의 너무 편한 점이다.

python이나 node.js에 서는 이를 구현하기 위해서 복잡한 코드를 짜서 비동기를 구현해야 하고, 이를 또 멀티 코어가 이용하도록 하려면 훨씬 복잡하다.  express같은 프레임워크를 사용하더라도 엄밀한 의미에서의 동시 listen은 하지 않고 event loop에 등록되어 번갈아 polling을 할 뿐이다.

HTTP-01 Challenge

발급기관은 해당 도메인을 발급요청한 서버가 컨트롤하고 있는지 체크한 뒤 인증서를 발급해준다. 
ACME(Automated Certificate Management Environment) 프로토콜 challenge를 이용할 수 있는데,  그 중 하나가 http-01 challenge 다.

과정은 요약하면 다음과 같다.

  1. 요청자 (Certbot)가 CA (Let's Encrypt) 에 SSL 인증서 발급을 요청함.
  2. CA는 도메인의 특정 위치 (/.well-known/acme-challenge/ )에 어떤 파일 (token)을 만들라고 함
  3. certbot이 경로로 접근할 수 있는 곳에 파일을 만듬
  4. CA가 http://도메인/.well-known/acme-challenge/{token}으로 요청을 보내 파일이 존재하는지 확인 
  5. 파일이 잘 인증되면 인증서를 발급함

4번 때문에 http는 미리 구성되어 있어야 함을 알 수 있다.
여기에 challenge 경로로 접근 했을 때 파일을 제공할 수 있도록 따로 만든 /cert  폴더에서 challenge 파일들을 제공하도록 구성했다.

http.HandleFunc("/.well-known/acme-challenge/", handleChallenge)

func handleChallenge(w http.ResponseWriter, r *http.Request) {
	certFilePath := filepath.Join("cert", r.URL.Path)
	http.ServeFile(w, r, certFilePath)
}

다음과 같이 --webroot 옵션으로 웹루트 방식으로 challenge를 설정하고  -w 옵션으로 challenge가 진행될 경로를 지정해줄 수 있다.

certbot certonly --webroot -w /cert -d example.com

웹루트 방식이 아닌 standalone 방식을 이용하면 웹 서버 없이도 인증을 쉽게 진행할 수 있다.

조금 번거롭지만 웹루트 방식으로 진행한 이유는, 웹 서버를 죽이지 않고 '갱신'도 진행하고 싶기 때문이다. 이 방식에서는 웹서버가 이미 80번 포트를 쓰고 있어, 똑같이 80번을 쓰는 standalone방식을 사용할 수 없다.

인증서 제공

아래와 같이 certFile, privateKeyFile을 read해 https에 넘겨줄 수 있다.

certFile := filepath.Join("/etc", "letsencrypt", "live", domain, "fullchain.pem")
keyFile := filepath.Join("/etc", "letsencrypt", "live", domain, "privkey.pem")

if _, err := os.Stat(certFile); os.IsNotExist(err) {
    fmt.Println("⚠️ HTTPS 인증서가 없습니다. HTTPS 서버를 실행하지 않습니다.")
    select {}
}

if _, err := os.Stat(keyFile); os.IsNotExist(err) {
    fmt.Println("⚠️ HTTPS 개인 키가 없습니다. HTTPS 서버를 실행하지 않습니다.")
    select {}
}

최종 코드

아래 코드로 certbot 실행시 인증을 위한 서버 제공,  해당 인증서를 이용한 https 통신까지 하는 웹 서버를 만들 수 있다.

const domain = "내 도메인"

func handleRequest(w http.ResponseWriter, r *http.Request) {
	var target *url.URL

	if strings.Contains(r.Host, domain) {
		target = &url.URL{
			Scheme: "http",
			Host:   "localhost:3000",
		}
	}

	if target == nil {
		log.Printf("Invalid request: Host=%s", r.Host)
		http.Error(w, "Target URL is nil", http.StatusInternalServerError)
		return
	}

	proxy := httputil.NewSingleHostReverseProxy(target)

	proxy.ServeHTTP(w, r)
}

func handleChallenge(w http.ResponseWriter, r *http.Request) {
	certFilePath := filepath.Join("cert", r.URL.Path)
	http.ServeFile(w, r, certFilePath)
}

func makeCertFolder() {
	if _, err := os.Stat("cert"); os.IsNotExist(err) {
		err := os.Mkdir("cert", 0755)
		if err != nil {
			fmt.Printf("cert 폴더 생성 중 오류 발생: %v\n", err)
		}
	}
}

func main() {

	makeCertFolder()

	http.HandleFunc("/.well-known/acme-challenge/", handleChallenge)
	http.HandleFunc("/", handleRequest)

	go func() {
		fmt.Println("Starting server on :80")
		err := http.ListenAndServe(":80", nil)
		if err != nil {
			fmt.Printf("Error starting server: %v\n", err)
		}
	}()

	certFile := filepath.Join("/etc", "letsencrypt", "live", domain, "fullchain.pem")
	keyFile := filepath.Join("/etc", "letsencrypt", "live", domain, "privkey.pem")

	if _, err := os.Stat(certFile); os.IsNotExist(err) {
		fmt.Println("⚠️ HTTPS 인증서가 없습니다. HTTPS 서버를 실행하지 않습니다.")
		select {}
	}

	if _, err := os.Stat(keyFile); os.IsNotExist(err) {
		fmt.Println("⚠️ HTTPS 개인 키가 없습니다. HTTPS 서버를 실행하지 않습니다.")
		select {}
	}

	fmt.Println("Starting HTTPS server on :443")
	err := http.ListenAndServeTLS(":443", certFile, keyFile, nil)
	if err != nil {
		fmt.Fprintln(os.Stderr, err)
		log.Fatalf("Error starting HTTPS server: %v\n", err)
	}

}

계획

서비스를 만들 때마다 certbot 으로 인증을 받고, 이를 위해 nginx를 설정하는 게 번거로웠는데 위 코드를 certbot과 함께 docker로 잘 감싸면 빠르게 https를 제공할 수 있을 것 같다.

내가 쓰기 위해서라도 간단하게 docker로 구성해봐야겠다. docker가 너무 무거우면 빌드와 갱신 스크립트가 들어있는 형태까지라도!