NodeJS Reverse Proxy

이승훈·2025년 9월 24일

NodeJS

역할

  1. 인증
  2. http → https 리다이렉트
  3. 보안 헤더
  4. 도메인 맵핑
  5. 웹소켓 업그레이드

인증

개요

  1. 토큰이 생성될 디렉토리를 준비한다.
  2. 특정 요청에 토큰을 정적으로 응답할 준비한다.
  3. cetbot 을 webroot 방식으로 실행하면 토큰이 생성된다.
  4. let's encrypt 가 요청하고 토큰을 응답받아 확인하고 인증한다.
  5. 매일 자동 갱신한다.

디렉토리 준비

mkdir -p reverse_proxy/.well-known/acme-challenge

응답 준비

cd reverse_proxy
npm init -y
vi index.js
import express from "express";
import http from 'http';

const app = express();

app.use(
  "/.well-known/acme-challenge",
  express.static(".well-known/acme-challenge")
);

http.createServer(app).listen(3000, () => {
  console.log("reverse_proxy server running on :80");
});

테스트

sudo apt install certbot -y
sudo certbot certonly --webroot -w ~/reverse_proxy -d example.com -d api.example.com --cert-name=certificates --agree-tos -m you@example.com --non-interactive --staging --dry-run

인증 받기

sudo apt install certbot -y
sudo certbot certonly --webroot -w ~/reverse_proxy -d example.com -d api.example.com --cert-name=certificates --agree-tos -m you@example.com --non-interactive
옵션설명
--webroot인증 방식: webroot 사용
-w인증 파일을 저장할 웹루트 디렉토리 지정
-d인증받을 도메인 지정 (여러 번 사용 가능)
--cert-name인증서 라벨(이름) 지정 (미지정 시 첫 번째 도메인으로 설정됨)
--agree-tosLet’s Encrypt 서비스 약관 자동 동의
-m인증 관련 알림을 받을 이메일 주소
--non-interactive사용자 입력 없이 자동 진행 (스크립트나 cron에서 사용)
--stagingLet's Encrypt의 테스트 환경을 사용하여 인증서 발급 (실제 발급이 아닌 테스트)
--dry-run실제 인증서 발급 없이 인증 과정을 시뮬레이션 (실제 요청 없이 테스트만 진행)

확인

sudo certbot certificates

갱신

sudo crontab -e
0 0 * * * certbot renew --deploy-hook "pm2 restart reverse_proxy"
구분설명
sudo crontab -eroot 권한으로 cron 작업을 편집한다.
0 0 * * *매일 0시 0분에 실행한다. (분 시 일 월 요일 순서)
certbot renew인증서 만료일을 확인하고, 30일 이하로 남았으면 자동 갱신한다.
--deploy-hook인증서가 실제로 갱신된 경우에만 뒤의 명령을 실행한다.
pm2 restart appNamepm2에서 실행 중인 서버앱을 재시작한다.

가져오기

# 0) 전체 원복 (소유자를 사용자로 바꿔둔 것 되돌리기)
sudo chown -R root:root /etc/letsencrypt

# 1) 디렉터리는 모두 통과 가능(검색 가능)하게
sudo find /etc/letsencrypt -type d -exec chmod 755 {} \;

# 2) ssl-cert 그룹 준비 + 사용자 추가
sudo getent group ssl-cert >/dev/null || sudo groupadd ssl-cert
sudo usermod -aG ssl-cert sayyesdoit

# 3) 키/인증서 파일에 '그룹 소유권' 부여 (원본=archive에 적용)
sudo chgrp ssl-cert /etc/letsencrypt/archive/certificates/privkey*.pem
sudo chgrp ssl-cert /etc/letsencrypt/archive/certificates/fullchain*.pem

# 4) 키/인증서 파일에 '권한' 부여 (필수)
#    - privkey*: 640 (u=rw,g=r,o=)
#    - fullchain*: 640 권장 (조금 느슨하게 하려면 644도 가능)
sudo chmod 640 /etc/letsencrypt/archive/certificates/privkey*.pem
sudo chmod 640 /etc/letsencrypt/archive/certificates/fullchain*.pem
#  (fullchain을 644로 하려면: sudo chmod 644 .../fullchain*.pem)

# 5) 현재 셸에 그룹 반영 (또는 로그아웃/로그인/재부팅)
newgrp ssl-cert

# 6) 검증
namei -l /etc/letsencrypt/live/certificates/privkey.pem
sudo -u sayyesdoit head -n1 /etc/letsencrypt/live/certificates/privkey.pem
# "-----BEGIN PRIVATE KEY-----" 가 보여야 정상
import fs from 'fs';
const credentials = {
  key: fs.readFileSync('/etc/letsencrypt/live/example.com/privkey.pem'),
  cert: fs.readFileSync('/etc/letsencrypt/live/example.com/fullchain.pem'),
};

삭제 (참고)

sudo certbot delete

리다이렉팅

app.use((req, res) => {
  const host = req.hostname || '';
  const location = `https://${host}${req.url || '/'}`;
  res.redirect(308, location);
});

보안 헤더

npm install http-proxy -y
import httpProxy from 'http-proxy';
const proxy = httpProxy.createProxyServer({
  changeOrigin: true,
  xfwd: true
});
proxy.on('proxyRes', (proxyRes, req, res) => {
  delete proxyRes.headers['x-powered-by'];
  res.setHeader('X-Content-Type-Options', 'nosniff');
  res.setHeader('X-Frame-Options', 'DENY');
  res.setHeader('Referrer-Policy', 'no-referrer');
  res.setHeader('Content-Security-Policy', "default-src 'self'");
  res.setHeader('Strict-Transport-Security', 'max-age=63072000; includeSubDomains; preload');
});
proxy.on('error', (err, req, res) => {
  if (!res.headersSent) {
    res.writeHead(502, { 'Content-Type': 'text/plain' });
  }
  res.end('Bad gateway: ' + err.message);
});

도메인 매핑

const routes = {
  'example.com': 'http://localhost:5001',
  'api.example.com': 'http://localhost:5002',
};
const httpsServer = https.createServer(credentials, (req, res) => {
  const rawHost = req.headers.host || '';
  const host = rawHost.split(':')[0].toLowerCase();
  const target = routes[host];

  if (!target) {
    res.writeHead(403, { 'Content-Type': 'text/plain' });
    res.end('Access Denied');
    return;
  }

  req.setTimeout(30_000, () => {
    if (!res.headersSent) {
      res.writeHead(408, { 'Content-Type': 'text/plain' });
    }
    res.end('Request Timeout');
    try { req.destroy(); } catch {}
  });

  proxy.web(req, res, { target });
});

웹소켓 업그레이드

httpsServer.on('upgrade', (req, socket, head) => {
  const rawHost = req.headers.host || '';
  const host = rawHost.split(':')[0].toLowerCase();
  const target = routes[host];
  if (!target) {
    socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
    socket.destroy();
    return;
  }
  proxy.ws(req, socket, head, { target });
});

httpsServer.listen(443, () => {
  console.log('HTTPS :443 (reverse proxy)');
});

종합

npm install http-proxy
import express from 'express';
import http from 'http';
import https from 'https';
import fs from 'fs';
import httpProxy from 'http-proxy';

// 1) 도메인 → 백엔드 매핑
const routes = {
  'example.com': 'http://127.0.0.1:5001',
  'api.example.com': 'http://127.0.0.1:5002',
};

// 2) 인증서
const credentials = {
  key: fs.readFileSync('/etc/letsencrypt/live/certificates/privkey.pem'),
  cert: fs.readFileSync('/etc/letsencrypt/live/certificates/fullchain.pem'),
};

// 3) 프록시 인스턴스 (기본 안전)
const proxy = httpProxy.createProxyServer({
  changeOrigin: true,
  xfwd: true,
});

// 공통 에러 처리
proxy.on('error', (err, req, res) => {
  if (!res.headersSent) {
    res.writeHead(502, { 'Content-Type': 'text/plain' });
  }
  res.end('Bad gateway: ' + (err?.message || 'proxy error'));
});

// 보안 헤더(너무 빡센 CSP는 지양 — 필요 시 개별 서비스에서 설정 권장)
proxy.on('proxyRes', (proxyRes, req, res) => {
  delete proxyRes.headers['x-powered-by'];
  res.setHeader('X-Content-Type-Options', 'nosniff');
  res.setHeader('X-Frame-Options', 'DENY');
  res.setHeader('Referrer-Policy', 'no-referrer');
  // 최소 권장: CSP는 report-only 혹은 느슨하게 시작 권장
  // res.setHeader('Content-Security-Policy', "default-src 'self' https: data: blob:; img-src * data: blob:; media-src *; object-src 'none'; frame-ancestors 'none'");
  res.setHeader('Strict-Transport-Security', 'max-age=63072000; includeSubDomains; preload');
});

// 4) HTTP(80): ACME + HTTPS로 리다이렉트
const app = express();
app.use('/.well-known/acme-challenge', express.static('.well-known/acme-challenge'));
app.use((req, res) => {
  const host = req.hostname || '';
  const url = `https://${host}${req.url || '/'}`;
  res.redirect(308, url);
});
http.createServer(app).listen(80, () => {
  console.log('HTTP :80 (ACME + redirect → HTTPS)');
});

// 5) HTTPS(443): 리버스 프록시
const httpsServer = https.createServer(credentials, (req, res) => {
  const rawHost = req.headers.host || '';
  const host = rawHost.split(':')[0].toLowerCase();
  const target = routes[host];

  if (!target) {
    res.writeHead(403, { 'Content-Type': 'text/plain' });
    res.end('Access Denied');
    return;
  }

  req.setTimeout(30_000, () => {
    if (!res.headersSent) {
      res.writeHead(408, { 'Content-Type': 'text/plain' });
    }
    res.end('Request Timeout');
    try { req.destroy(); } catch {}
  });

  proxy.web(req, res, { target });
});

// 6) WebSocket 업그레이드
httpsServer.on('upgrade', (req, socket, head) => {
  const rawHost = req.headers.host || '';
  const host = rawHost.split(':')[0].toLowerCase();
  const target = routes[host];
  if (!target) {
    socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
    socket.destroy();
    return;
  }
  proxy.ws(req, socket, head, { target });
});

httpsServer.listen(443, () => {
  console.log('HTTPS :443 (reverse proxy)');
});
profile
안녕하세요!

0개의 댓글