기본적인건 됐고 HTTPS를 적용해볼까?

HanSH·2024년 3월 26일

NestJS

목록 보기
23/29

지금까지 해온것은 SSL(Secure Sockets Layer)이 지원되지 않는 기본 HTTP였습니다. 이제 SSL을 추가하여 보안이 강화된 HTTPS를 사용해보도록 합시다!

왜 HTTPS인가?

가끔 웹사이트를 들어가보면 아래의 사진을 볼 수 있습니다.

사이트에 대한 연결이 안전하지 않다는 것인데요, 이와 같은 UNSAFE를 보여주는데는 몇 가지 이유가 있습니다.

  • SSL 인증서가 발급되었지만, 신뢰되지 않는 경우
  • http 연결을 강제로 https로 보여주는 경우

일단 위의 제 서버에서는 신뢰되지 않는 인증서여서 그렇습니다.

위의 사진은 연결이 안전한 경우, 즉 신뢰받는 인증서를 사용한 경우입니다.

왜 위와 같이 인증서를 요구하는 것일까요?

HTTP vs HTTPS

일단 기본적으로 TCP/IP를 기반으로 합니다! UDP는 HTTPS와 HTTP를 직접 지원하지 않아요!

먼저 HTTP와 HTTPS의 7-layer 구조 차이를 알아둡시다.

HTTP - 하이퍼 텍스트 전송 프로토콜

http는 기본적으로 아래와 같은 구조를 가집니다.

암호화 따위는 개나 줘버린 단순한 평문을 보내죠. 따라서 누군가가 정보를 탈취할 수 있는 가능성이 높습니다. ← 서버에 데이터를 줄 때, 서버에서 데이터를 받을 때 모두 가능합니다.
탈취한 데이터를 수정하여 서버에 전달하여도 문제가 없죠.
따라서 중요한 데이터를 전송하는데는 사용하면 안됩니다.

위의 단점을 해결하기 위해 보안이 추가된 HTTPS가 등장하게 됩니다.

HTTPS - HTTP + SSL(Secure Socket Layer)

https는 https와 달리 security 계층이 추가됩니다. 이로 인해 암호화된 데이터를 송수신하게 되고, http의 데이터 탈취 및 수정 문제에서 벗어날 수 있게 되었습니다!
대신 보안을 추가하다보니 성능이 조금 낮아졌지만요.

이렇게 해도 로컬에서 정보가 털리면 답이 없습니다

이때 SSL 인증서라는게 등장하게 되는데...

SSL 인증서

  1. 수신자와 송신자가 같은 키를 사용하는 대칭키로부터 시작하여
  2. 수신자는 비밀 키를 사용하고 송신자는 수신자의 공개키를 이용하여 암호화를 하는 비대칭키를 거쳐
  3. 현재 인증기관에서 인증서를 발급하는 하이브리드 암호화 방식을 사용하고 있습니다.

그리고 SSL의 동작 방식은 아래와 같습니다.

복잡하지만 "인증기관이 발급한 인증서를 이용하여 암호화 및 복호화를 하는구나!" 하면 됩니다.

여기서 저희는 Let's Encrypt를 이용하여 인증서를 발급받고 사용하는 목표로 하였습니다.

알아둬야 할 사항

  1. iptime 공유기의 자체 ddns인 iptime.org 도메인을 쓰는 경우에는 SSL 인증서 발급 자체가 되지 않습니다. iptime 도메인의 경우에는 CAA 값이 아래와 같이 설정되어있어 사용이 불가능합니다.
0 issuewild ; // 전역으로 설정
0 issue ;

Duck DNS를 사용해보아요! 얘도 무료인데다가 ip가 바뀌어도 바뀐 ip를 업데이트를 해주는 스크립트도 있기 때문에 큰 문제는 되지 않아요. CAA까지 지원되니

  1. nginx로 ssl 설정을 했더라도 nestjs에서 https로 동작하지 않습니다! 공식 문서를 참고하여 직접 key와 cert를 등록 해줘야 해요!

  2. Let's Encrypt로 CA 인증서를 발급받으면 무료로 발급받을 수 있지만 90일 제한이 있습니다. 스크립트를 더 추가하여 재발급 받을 수 있어요.

  3. 일부 트러블 슈팅 빼고는 이 벨로그를 참고했어요!

기존의 설정들을 수정

ssl 적용을 하려고 하니 설정을 조금 바꿔야 했습니다.
1. docker-compose 파일
2. nginx.conf 파일
3. start, stop 스크립트 수정

각각을 살펴보도록 하죠!

0. 먼저 DNS를 설정하자

로그인 후 도메인을 발급받읍시다.

이후 install - linux cron을 누르고 domain을 선택하면 스크립트가 하나 나오는데, 이를 복사합시다.

echo url="https://www.duckdns.org/update?domains=<domain>&token=6e8ba*****85a77&ip=" | curl -k -o ~/duckdns/duck.log -K -

이후 crontab으로 매 5분마다 ip를 업데이트 하도록 설정합시다.

*/5 * * * * ~/duckdns/duck.sh >/dev/null 2>&1

1. docker-compose를 수정하자

먼저 db와 certbot/nginx를 분리하였습니다.
완성된 프로젝트의 디렉토리 트리 구조는 아래와 같습니다. 필수 데이터를 제외한 다른 데이터는 넣지 않았으니 참고해주세요!

.
├── compose
│   ├── docker-compose.blue.yml
│   ├── docker-compose.db.yml
│   ├── docker-compose.green.yml
│   └── docker-compose.yml
├── db
├── nginx
│   ├── nginx.blue.conf
│   ├── nginx.conf
│   ├── nginx.default.conf
│   └── nginx.green.conf
├── data
│   └── certbot
│       ├── conf
│       │   └── live
│       │       ├── README
│       │       └── rgback.duckdns.org-0001
│       │           ├── fullchain.pem -> ../../archive/rgback.duckdns.org-0001/fullchain7.pem
│       │           ├── privkey.pem -> ../../archive/rgback.duckdns.org-0001/privkey7.pem
│       │           └── README
│       │
│       └── www
├── deploy.sh
├── start.sh
├── stop.sh
└── init-letsencrypt.sh

docker-compose.yml을 docker-compose.yml과 docker-compose.db.yml 2개로 나눴습니다. ssl 인증서를 발급받을 때 데이터베이스는 켤 필요가 없으니까요. docker-compose.db.yml은 변한게 없으니 넘어갈게요.

# docker-compose.yml
...
services:
  dev_nginx:
    container_name: dev-nginx
    image: nginx
    restart: unless-stopped
    ports: 
      - "80:80"
      - "443:443" # ① HTTPS 연결을 위한 443 포트 오픈
    volumes:
      - ../nginx/nginx.conf:/etc/nginx/nginx.conf
      - ../nginx/log/error.log:/var/log/nginx/error.log
      - ../nginx/log/access.log:/var/log/nginx/access.log
      - ../data/certbot/conf:/etc/letsencrypt # SSL 인증서를 발급받은 경로를 추가
      - ../data/certbot/www:/var/www/certbot # SSL 인증서를 발급받은 경로를 추가
    networks:
      - rgback_dev
    # command: "/bin/sh -c 'while :; do sleep 6h & wait $${!}; nginx -s reload; done & nginx -g \"daemon off;\"'" 
    # 이 부분은 인증서를 전부 발급받은 후 주석 해제

  dev_certbot: # SSL 인증서 자동 갱신 지원
    container_name: dev-certbot
    image: certbot/certbot
    restart: unless-stopped
    volumes:
      - ../data/certbot/conf:/etc/letsencrypt # SSL 인증서를 발급받은 경로를 추가
      - ../data/certbot/www:/var/www/certbot # SSL 인증서를 발급받은 경로를 추가
    # entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'"
    # 이 부분은 인증서를 전부 발급받은 후 주석 해제
...

2. start, stop 스크립트를 수정하자.

database → nestjs → nginx 순서로 실행하도록 하였어요. 기존 순서는 nginx, database → nestjs 였는데 ssl을 적용해보니 nestjs를 먼저 실행하고 nginx를 실행하는게 좋다고 생각되었어요.
그리고 서버를 종료할때는 nestjs → database, nginx로 하는게 좋다고 판단했어요. nestjs가 전부 종료되기 전에 nginx를 종료해버리면 처리중이던 모든 요청을 강제로 끊어버리기에 순서를 위와 같이 변경했어요.

요약하면 시작은

docker compose -p ${DOCKER_APP_NAME} -f compose/docker-compose.db.yml up -d
docker compose -p ${DOCKER_APP_NAME}-${FLAG} -f compose/docker-compose.${FLAG}.yml up -d
docker compose -p ${DOCKER_APP_NAME} -f compose/docker-compose.yml up -d

종료는

docker compose -p ${DOCKER_APP_NAME}-${LAST} -f "${COMPOSE_PATH}docker-compose.${LAST}.yml" down
docker compose -p ${DOCKER_APP_NAME} -f "${COMPOSE_PATH}docker-compose.db.yml" down
docker compose -p ${DOCKER_APP_NAME} -f "${COMPOSE_PATH}docker-compose.yml" down

으로 하였어요.

3. nginx config 수정

80번 포트로 접속할 때 443번 포트로 다이렉트되게 했어요. 프론트도 기본적으로 SSL 접속을 할 것으로 예상했기 때문이에요.

수정된 내용은 아래와 같습니다.

  server {
    listen 80;
	
    // 인증서 발급을 위한 nginx endpoint
    location /.well-known/acme-challenge/ {
      root /var/www/certbot;
    }

    location / {
      return 301 https://$host$request_uri;
    }
  }

  server {
    listen 443 ssl;
    server_name rgback.duckdns.org;
	
    //  ======== SSL 인증 관련 nginx config ========
    ssl_certificate /etc/letsencrypt/live/rgback.duckdns.org-0001/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/rgback.duckdns.org-0001/privkey.pem;
    include /etc/letsencrypt/options-ssl-nginx.conf;
    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
    //  ===========================================

    location / {
      proxy_pass https://dev_backend; # 서버 1로 요청 프록시
      proxy_set_header Host $host; # 클라이언트의 호스트 설정
      proxy_set_header Connection ""; # upstream서버를 사용하겠다 지정(⭐중요)
    }
  }

/.well-known/acme-challenge/ 이 부분은 Let's Encrypt 사이트에 따르면,

Let’s Encrypt는 ACME 클라이언트에 토큰을 주고 ACME 클라이언트는 http://<YOUR_DOMAIN>/.well-known/acme-challenge/<TOKEN> 의 웹서버에 파일을 저장합니다.

라고 하네요. 443 포트는 인증서 유효성을 검사하지 않는데 80번 포트에서는 인증서 검사를 해야하나봅니다.

server {
     listen 80;

     server_name [구매한 도메인];

     location /.well-known/acme-challenge/ {
             allow all;
             root /var/www/certbot;
     } 
}

4. 인증서를 발급받아보자.

인증서를 한번에 발급받을 수 있는 좋은 스크립트가 있어 이를 가져와 사용합시다.

curl -L https://raw.githubusercontent.com/wmnnd/nginx-certbot/master/init-letsencrypt.sh > init-letsencrypt.sh

여기서 주의 사항이 몇 가지 있는데

  1. docker compose 파일의 이름이 다르면 -f 플래그로 파일 이름을 넣어줘야 합니다.
  2. service 이름이 다르다면 service 이름도 전부 바꿔줘야 합니다.
  3. docker-compose가 되지 않는다면 docker compose로 바꿔줘야 합니다.

이를 전부 수정한 스크립트는 아래와 같습니다.

#!/bin/bash

if ! [ -x "$(command -v docker compose)" ]; then
  echo 'Error: docker compose is not installed.' >&2
  exit 1
fi

domains=(rgback.duckdns.org)
rsa_key_size=4096
data_path="./data/certbot"
email="gkstkdgus821@gmail.com" # Adding a valid address is strongly recommended
staging=1 # Set to 1 if you're testing your setup to avoid hitting request limits
docker_certbot_service_name="dev_certbot"
docker_nginx_service_name="dev_nginx"
docker_certbot_container_name="dev-certbot"
docker_nginx_container_name="dev-nginx"
docker_compose_path="compose/docker-compose.yml"

if [ -d "$data_path" ]; then
  read -p "Existing data found for $domains. Continue and replace existing certificate? (y/N) " decision
  if [ "$decision" != "Y" ] && [ "$decision" != "y" ]; then
    exit
  fi
fi


if [ ! -e "$data_path/conf/options-ssl-nginx.conf" ] || [ ! -e "$data_path/conf/ssl-dhparams.pem" ]; then
  echo "### Downloading recommended TLS parameters ..."
  mkdir -p "$data_path/conf"
  curl -s https://raw.githubusercontent.com/certbot/certbot/master/certbot-nginx/certbot_nginx/_internal/tls_configs/options-ssl-nginx.conf > "$data_path/conf/options-ssl-nginx.conf"
  curl -s https://raw.githubusercontent.com/certbot/certbot/master/certbot/certbot/ssl-dhparams.pem > "$data_path/conf/ssl-dhparams.pem"
  echo
fi

echo "### Creating dummy certificate for $domains ..."
path="/etc/letsencrypt/live/$domains"
mkdir -p "$data_path/conf/live/$domains"
docker compose -f $docker_compose_path run --rm --entrypoint "\
  openssl req -x509 -nodes -newkey rsa:$rsa_key_size -days 1\
    -keyout '$path/privkey.pem' \
    -out '$path/fullchain.pem' \
    -subj '/CN=localhost'" $docker_certbot_service_name
echo


echo "### Starting nginx ..."
docker compose -f $docker_compose_path up --force-recreate -d $docker_nginx_service_name
echo

echo "### Deleting dummy certificate for $domains ..."
docker compose -f $docker_compose_path run --rm --entrypoint "\
  rm -Rf /etc/letsencrypt/live/$domains && \
  rm -Rf /etc/letsencrypt/archive/$domains && \
  rm -Rf /etc/letsencrypt/renewal/$domains.conf" $docker_certbot_service_name
echo


echo "### Requesting Let's Encrypt certificate for $domains ..."
#Join $domains to -d args
domain_args=""
for domain in "${domains[@]}"; do
  domain_args="$domain_args -d $domain"
done

# Select appropriate email arg
case "$email" in
  "") email_arg="--register-unsafely-without-email" ;;
  *) email_arg="--email $email" ;;
esac

# Enable staging mode if needed
if [ $staging != "0" ]; then staging_arg="--staging"; fi

docker compose -f $docker_compose_path run --rm --entrypoint "\
  certbot certonly --webroot -w /var/www/certbot \
    $staging_arg \
    $email_arg \
    $domain_args \
    --rsa-key-size $rsa_key_size \
    --agree-tos \
    --force-renewal" $docker_certbot_service_name
echo

echo "### Reloading nginx ..."
docker compose -f $docker_compose_path exec $docker_nginx_container_name nginx -s reload

여기서 dev_nginx는 service name, dev-nginx는 container name입니다. 이에 맞게 설정해주세요!

이후 init-letsencrypt.sh를 실행합시다. sudo 권한으로 실행해줘야 해요!

마지막 nginx is not running은 신경 쓰시지 않아도 됩니다.
위의 내용대로 수정했다면 크게 문제 없이 실행이 될 것이라 생각합니다! 저같은 경우에는 rgback.duckdns.org 디렉토리가 아닌 rgback.duckdns.org-0001 디렉토리에 저장이 되었네요. 이에 맞게 nginx config를 수정해주어야 합니다.

ssl_certificate /etc/letsencrypt/live/<PEM_PATH>/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/<PEM_PATH>/privkey.pem;

이 부분에서 PEM_PATH 부분의 경로를 저장된 디렉토리의 경로로 수정해줍시다! 저의 경우에는 rgback.duckdns.org-0001겠네요.

여기서 nestjs를 실행해보고 정상적으로 접속이 된다면 staging을 0으로 변경해도 무방합니다! 현재 dev 서버에 ssl을 적용하는 코드가 없어서... 지금은 안정하지 않은 연결이라고 떠요.

5. nestjs ssl 설정

docs를 보면 매우 간단하게 설정을 할 수 있습니다! 저희는 express를 사용하기때문에 아래와 같이 설정을 하면 돼요.

const httpsOptions = {
  key: fs.readFileSync('./secrets/private-key.pem'),
  cert: fs.readFileSync('./secrets/public-certificate.pem'),
};
const app = await NestFactory.create(AppModule, {
  httpsOptions,
});
await app.listen(3000);

물론 이 경우에는 docker compose 파일에서 volumes를 지정해야겠죠?


결론

이제 성공적으로 ssl 인증서를 발급받고 HTTPS 통신을 할수 있게 되었습니다! 몇 시간 삽질했지만 적어놓은 내용이 전부입니다.
개발중인 코드에 pem 파일 volumes로 받아와서 넣고 정상적인 ssl 인증서로 동작하는 것만 보면 됩니다!

profile
저는 말하는 싹 난 감자입니다

0개의 댓글