역할
- 인증
- http → https 리다이렉트
- 보안 헤더
- 도메인 맵핑
- 웹소켓 업그레이드
인증
개요
- 토큰이 생성될 디렉토리를 준비한다.
- 특정 요청에 토큰을 정적으로 응답할 준비한다.
- cetbot 을 webroot 방식으로 실행하면 토큰이 생성된다.
- let's encrypt 가 요청하고 토큰을 응답받아 확인하고 인증한다.
- 매일 자동 갱신한다.
디렉토리 준비
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-tos | Let’s Encrypt 서비스 약관 자동 동의 |
| -m | 인증 관련 알림을 받을 이메일 주소 |
| --non-interactive | 사용자 입력 없이 자동 진행 (스크립트나 cron에서 사용) |
| --staging | Let's Encrypt의 테스트 환경을 사용하여 인증서 발급 (실제 발급이 아닌 테스트) |
| --dry-run | 실제 인증서 발급 없이 인증 과정을 시뮬레이션 (실제 요청 없이 테스트만 진행) |
확인
sudo certbot certificates
갱신
sudo crontab -e
0 0 * * * certbot renew --deploy-hook "pm2 restart reverse_proxy"
| 구분 | 설명 |
|---|
| sudo crontab -e | root 권한으로 cron 작업을 편집한다. |
| 0 0 * * * | 매일 0시 0분에 실행한다. (분 시 일 월 요일 순서) |
| certbot renew | 인증서 만료일을 확인하고, 30일 이하로 남았으면 자동 갱신한다. |
| --deploy-hook | 인증서가 실제로 갱신된 경우에만 뒤의 명령을 실행한다. |
| pm2 restart appName | pm2에서 실행 중인 서버앱을 재시작한다. |
가져오기
sudo chown -R root:root /etc/letsencrypt
sudo find /etc/letsencrypt -type d -exec chmod 755 {} \;
sudo getent group ssl-cert >/dev/null || sudo groupadd ssl-cert
sudo usermod -aG ssl-cert sayyesdoit
sudo chgrp ssl-cert /etc/letsencrypt/archive/certificates/privkey*.pem
sudo chgrp ssl-cert /etc/letsencrypt/archive/certificates/fullchain*.pem
sudo chmod 640 /etc/letsencrypt/archive/certificates/privkey*.pem
sudo chmod 640 /etc/letsencrypt/archive/certificates/fullchain*.pem
newgrp ssl-cert
namei -l /etc/letsencrypt/live/certificates/privkey.pem
sudo -u sayyesdoit head -n1 /etc/letsencrypt/live/certificates/privkey.pem
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';
const routes = {
'example.com': 'http://127.0.0.1:5001',
'api.example.com': 'http://127.0.0.1:5002',
};
const credentials = {
key: fs.readFileSync('/etc/letsencrypt/live/certificates/privkey.pem'),
cert: fs.readFileSync('/etc/letsencrypt/live/certificates/fullchain.pem'),
};
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'));
});
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('Strict-Transport-Security', 'max-age=63072000; includeSubDomains; preload');
});
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)');
});
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)');
});