정글에서 온 개발자
TIL 2/4 Nginx,Apache없이 Certbot 적용하기 (with Golang) 본문
계기
기존에 네트워크 지식이 없을 때는 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로 인증서를 디코딩하고 내용을 확인함) 해준다.
요약하면
- https 통신을 위해서는 인증서와, 이를 디코딩하기 위한 public key (보통 CA의)가 필요하다.
- 따라서 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 다.
과정은 요약하면 다음과 같다.
- 요청자 (Certbot)가 CA (Let's Encrypt) 에 SSL 인증서 발급을 요청함.
- CA는 도메인의 특정 위치 (/.well-known/acme-challenge/ )에 어떤 파일 (token)을 만들라고 함
- certbot이 경로로 접근할 수 있는 곳에 파일을 만듬
- CA가 http://도메인/.well-known/acme-challenge/{token}으로 요청을 보내 파일이 존재하는지 확인
- 파일이 잘 인증되면 인증서를 발급함
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가 너무 무거우면 빌드와 갱신 스크립트가 들어있는 형태까지라도!
'TIL' 카테고리의 다른 글
동시성 vs 병렬성 (0) | 2025.03.31 |
---|---|
TIL 2/16 sqld 2과목 제 2장 SQL 활용 (0) | 2025.02.16 |
1/21 TIL 버전은 어떤 기준으로 올라갈까? 시맨틱 버저닝 (0) | 2025.01.22 |
1/20 TIL C++ emplace_back() (0) | 2025.01.20 |
1/20 TIL 이 쿼리가 왜 빨라졌을까? (0) | 2025.01.20 |