Экспресс не будет обслуживать acme -вызов статического файла без расширения

#node.js #express #lets-encrypt

#node.js #экспресс #позволяет зашифровать

Вопрос:

Я изо всех сил пытаюсь подтвердить вызов Let’s Encrypt на моем сервере. Я правильно настроил свой сервер, и он обслуживает файлы, как и ожидалось, за исключением случаев, когда у них нет расширений (что относится к вызовам SSL).

app.use("/.well-known", express.static(path.join(__dirname, ".well-known"), { dotfiles: "allow" }) );

Например:

  • .well-known/acme-challenge/test.txt работает, как ожидалось
  • .well-known/acme-challenge/test не будет работать (ресурс не найден)

Что я делаю не так? Спасибо

Комментарии:

1. вы когда-нибудь выясняли ответ на это?

2. К сожалению, нет. Я выбрал обратный прокси (traefik), который обрабатывает проверку SSL для всех моих контейнеров и их соответствующих поддоменов. Мой сервер express теперь прослушивает только http-запросы. Работает хорошо после завершения настройки.

3. Я понял это и вскоре опубликую длинный ответ на этот вопрос. Мое решение использует docker, но оно должно работать и для вашего случая.

4. Извините, что мой ответ занял целую вечность, но дайте мне знать, что вы думаете @jodoox

5. Извините за отложенный ответ. Я думаю, что лучше всего было бы здесь следовать руководству (моя конфигурация довольно нестандартная). Этот dev.to/paularah /… кажется, довольно коротким. Подойдет любой обратный прокси-сервер (nginx caddy …), Я выбрал Traefik, потому что он бесплатный, OSS, обладает хорошей производительностью, и я обнаружил, что возможность автоматического обнаружения контейнера docker очень полезна (после настройки прокси-сервера вам нужно будет всего лишь добавить несколько меток в свой docker-compose.yml , чтобы сделатьлюбая новая услуга, доступная с SSL)

Ответ №1:

Я хочу задокументировать свое решение этой проблемы с помощью docker, чтобы автоматизировать процесс использования certbot для получения / установки сертификатов. Если вы не используете docker, просто перейдите к server.js , но убедитесь, что вы понимаете сценарий оболочки и то, как он вызывает команду certonly.

Сначала я рекомендую вам настроить файл docker compose следующим образом:

docker-compose.yml

 version: "3.9"
services:
  main_website:
    image: yourimage:latest
    build: 
      context: ./
      target: dev
    ports:
      - "443:443" #- Expose SSL Port for HTTPS
      - "80:8080" # - Public Main Website Vue
      - "8000:8000" # - Vue Dev
      - "8001:8001" #- Vue UI
    volumes:
      - "./web:/usr/src/app"
      - "./server:/usr/src/server"
      - "./server/crontab:/etc/crontabs/root"
      - "./certbot:/var/www/certbot/:ro"
    entrypoint: ["/bin/sh","-c"] #-c stands for running sh in string mode so I can run npm install and run start:dev at the same time
    command: #-Note that when vue (from npm run serve runs its package json has an amp; at the end which makes it run in the background allowing the other server to also run)
      - "npm install amp;amp; npm run build amp;amp; npm run serve amp;amp; npm run ui amp;amp; cd /usr/src/server amp;amp; npm install amp;amp; npm run start:dev"
    env_file:
     - db_dev.env
     - stripe_dev.env
    environment:
      NODE_ENV: "dev"
    depends_on:
      - db_dev
      - db_live
      - stripe
      - cerbot
    container_name: website_and_api_trading_tools_software
    healthcheck:
      test: "curl --fail http://localhost:8080/ || exit 1"
      interval: 20s
      timeout: 10s
      retries: 3
      start_period: 30s
  db_dev:
    image: mysql:latest
    command: --group_concat_max_len=1844674407370954752
    volumes:
      - ./db_dev_data:/var/lib/mysql
    restart: always
    container_name: db_dev_trading_tools_software
    env_file:
     - db_dev.env
  db_live:
    image: mysql:latest
    command: --group_concat_max_len=1844674407370954752
    volumes:
      - ./db_live_data:/var/lib/mysql
    restart: always
    container_name: db_live_trading_tools_software
    env_file:
     - db_live.env
  phpmyadmin:
    depends_on:
      - db_live
      - db_dev
    container_name: phpmyadmin_trading_tools_software
    image: phpmyadmin/phpmyadmin
    environment:
      PMA_ARBITRARY: 1
      UPLOAD_LIMIT: 25000000
    restart: always
    ports:
      - "8081:80"
  stripe:
    container_name: stripe_trading_tools_software
    image: stripe/stripe-cli:latest
    restart: always
    entrypoint: ["/bin/sh","-c"]
    env_file:
     - stripe_dev.env
    command: 
      - "stripe listen --api-key ${STRIPE_TEST_API_KEY} --forward-to main_website:8080/webhook --format JSON"
  certbot:
    container_name: certbot_tradingtools_software
    image: certbot/certbot
    volumes:
      - "./certbot/www/:/var/www/certbot/:rw"
      - "./certbot/conf/:/etc/letsencrypt/:rw"
  

Для этого файла следует отметить, что я создал том в контейнере main_website. У этого будет доступ к файлам, которые потребуются для проверки webroot из certbot, а также будут сохранены сертификаты позже непосредственно в ‘conf’, который будет здесь.

 - "./certbot:/var/www/certbot/:ro"
  

Ниже перечислены тома примечаний для контейнера certbot. /var/www/certbot Путь — это место, куда будут отправляться файлы webroot, которые будут создавать файлы .well-known/acme-challenge/randomfilejiberishhere , а /etc/letsencrypt путь — это место, где размещаются фактические сертификаты.

 volumes:
      - "./certbot/www/:/var/www/certbot/:rw"
      - "./certbot/conf/:/etc/letsencrypt/:rw"
  

Note that I add :rw at the end of the volume path to enable read/write and I put :ro to enable read only for the main_website container as a good practice to follow. This step is optional.

The next thing to note, is you will want a healthcheck on your main_website to be sure it’s up and running before we start creating webroot checks with certbot against it.

 healthcheck:
      test: "curl --fail http://localhost:8080/ || exit 1"
      interval: 20s
      timeout: 10s
      retries: 3
      start_period: 30s
  

This will every 20 seconds attempt to curl into the website from within the container until it gets a valid response. When you docker compose up, just add the —wait flag, like this: docker compose up --wait (this will wait for the health check to finish before moving onto the next step)

Note that with this setup the certbot container and main_website essentially have access to the same volume.

Next, I recommend creating a shell script like the following.

certbot.sh

 #!/usr/bin/env bash
#@JA - To use this file pass as an argument the domain name of interest attempting to renew or create.
#https://unix.stackexchange.com/questions/84381/how-to-compare-two-dates-in-a-shell (refer to string comparison technique for dates)
date  "%Y%m%d" > ./certbot/last-cert-run.txt
docker compose run --rm certbot certonly --webroot --webroot-path /var/www/certbot/ -d $1 -d $2 --non-interactive --agree-tos -m youremailhere@gmail.com --dry-run -v --expand
if [ $? -eq 0 ] 
then
    echo "Dry run succesful, attempting to create real certificate and/or automatically renew by doing so..."

    #@JA - The rate limit is 5 certificates created per week.  For this reason, this command should only run if it has been one week since the last run.
    #@JA - Read (if it exists) the date from the last-successful-cert-run.txt file.  If it has been at least a week since then, then we are allowed to run.

    #@JA - The date command is different on Linux vs Mac, so we need to identify the system before using it.
    unameOut="$(uname -s)"
    case "${unameOut}" in
        Linux*)     machine=Linux;;
        Darwin*)    machine=Mac;;
        CYGWIN*)    machine=Cygwin;;
        MINGW*)     machine=MinGw;;
        *)          machine="UNKNOWN:${unameOut}"
    esac
    echo "machine=$machine"

    current_date=$(date  "%Y%m%d")
    last_date=$(cat ./certbot/last-successful-cert-run.txt)
    
    if [ "$machine" = "Darwin" ];then
        one_week_from_last_date=$(date -jf "%Y%m%d" -v  7d "$last_date"  "%Y%m%d")
    else
        one_week_from_last_date=$(date  "%Y%m%d" -d "$last_date  7 days")
    fi

    echo "last_date=$last_date"
    echo "current_date=$current_date"
    echo "one_week_from_last_date=$one_week_from_last_date"

    #@JA - This will renew every 7 days, well within the rate limit.
    if [ $current_date -ge $one_week_from_last_date ];then
        echo "Time to renew certificate..."
        docker compose run --rm certbot certonly --webroot --webroot-path /var/www/certbot/ -d $1 -d $2 --non-interactive --agree-tos -m youremailhere@gmail.com -v --expand
        date  "%Y%m%d" > ./certbot/last-successful-cert-run.txt
        exit 0
    else
        echo "Not time to renew certificate yet"
        exit 0
    fi    
 else
     echo "Certbot dry run failed."
     exit 1
 fi
  

Чтобы использовать этот скрипт, вы вызываете его следующим certbot.sh domain1.com anotherdomain.com образом. Если вы хотите добавить больше доменов, просто продолжайте добавлять их, но вам придется изменить сценарий, чтобы добавить дополнительные команды -d для каждого домена в —dry-run версии команды certbot и не —dry-run версии.

Certbot разрешает только 5 неудачных попыток проверки, прежде чем вы будете заблокированы на час

По этой причине вы ВСЕГДА должны сначала выполнить тест —dry-run, чтобы убедиться, что все настроено, и ТОЛЬКО если это пройдет, попробуйте выполнить настоящий.

Certbot также позволяет выдавать только 5 сертификатов в неделю!

По этой причине я сохраняю дату в текстовом файле, когда он в последний раз выполнялся успешно, и запускаю только фактическую команду для создания сертификата, если это необходимо. Обратите внимание, что мне пришлось добавить код для вычисления разницы в дате на 7 дней вперед в зависимости от того, запускаете ли вы этот сценарий оболочки из системы на базе Mac или Linux, поскольку команда date отличается.

Наконец, я НЕ использую certbot renew, потому что certonly по сути делает то же самое здесь.

Server.js

 const express = require("express");
const cors = require("cors");
const logger = require('./logger');//require the logger folder index.js file which will load DEV or PROD versions of logging.
const stripe = require('stripe');
const http = require('http')
const https = require('https');
const fs = require('fs');

const certBotPath = '/var/www/certbot/';
const credentialsPath = certBotPath 'conf/live/';
logger.info(`credentialsPath=${credentialsPath}`);

//@JA - Array to hold all the known credentials found.
var credentials_array = [];
try{
    fs.readdirSync(credentialsPath).map( domain_directory => {
        // logger.info("Reading directories for SSL credentials...");
        // logger.info(domain_directory);

        if(domain_directory!="README"){ //@JA - We ignore the README file
            const privateKey = fs.readFileSync(credentialsPath domain_directory '/privkey.pem', 'utf8');
            const certificate = fs.readFileSync(credentialsPath domain_directory '/cert.pem', 'utf8');
            const ca = fs.readFileSync(credentialsPath domain_directory '/chain.pem', 'utf8'); 
            
            const credentials = {
                key: privateKey,
                cert: certificate,
                ca: ca,
                domain: domain_directory
            }
            logger.info(`credential object added.. ${domain_directory}`);
            credentials_array.push(credentials);
        }
        
    });
}catch{
    credentials = {};//@JA - Just give a wrong credentials object so it works.
    credentials_array.push(credentials);
}
logger.info("Finished finding credentials...");

const vueBuildPath = '/usr/src/app/build/';//@JA - Path to the vue build files

const app = express();

//@JA - We need a manual check for https because SOME http routes we do NOT want to forward to https, like /.well-known/acme-challenge/:fileid for certbot
var httpsCheck = function (req, res, next) {
    if(req.connection.encrypted){
        logger.info(req.url);
        logger.info("SSL Detected, continue to next middleware...!");
        next();//Then continue on as normal.
    }else{
        logger.info("SSL NOT DETECTED!!!")
        logger.info(req.url);
        if(!req.url.includes("/.well-known/acme-challenge/")){
            logger.info("Forcing permanent redirect to port 443 secure...");
            res.writeHead(301, { "Location": "https://"   req.headers['host']   req.url });
            res.end();
        }else{
            next();//@JA - Let it pass as normal in this case since it's the challenges.
        }
    }
}
app.use(httpsCheck);

app.use(express.static(vueBuildPath));
app.use(express.static(certBotPath));

var corsOptions = {
  origin: "http://localhost:8080"
};
app.use(cors(corsOptions));

// // parse requests of content-type - application/json (@JA - I added the extra ability to get the rawBody so it plays nice with certain webHooks from stripe)
app.use(express.json({
    limit: '5mb',
    verify: (req, res, buf) => {
      req.rawBody = buf.toString();
    }
}));

// parse requests of content-type - application/x-www-form-urlencoded
app.use(express.urlencoded({ extended: true }));

// certbot SSL checking webroot directory
// app.use('/.well-known', express.static(certBotPath));
app.get('/.well-known/acme-challenge/:fileid', function(req, res){
    res.sendFile(certBotPath "www/.well-known/acme-challenge/" req.params.fileid)
})

// Sequelize Database setups
const db = require("./models");
const { info } = require("console");
if(process.env.NODE_ENV=="dev"){
    db.sequelize.sync({force:true})
    .then(() => {
        logger.info("Droped and re-synced db.");
    })
    .catch((err) => {
        logger.error("Failed to sync db: "   err.message);
    });
}else{
    db.sequelize.sync({alter:true})
    .then(() => {
        logger.info("Altered amp; Synced db.");
    })
    .catch((err) => {
        logger.error("Failed to sync db: "   err.message);
    });
}

// Routes
// @JA - Main vue application route at /
app.get(["/","/optimizer"], (req, res) => {
  //res.json({ message: "Welcome to tradingtools.software applications." });
  res.sendFile(vueBuildPath   "index.html");
});

// This is your Stripe CLI webhook secret for testing your endpoint locally.
const endpointSecret = process.env.WEBHOOK_SIGNING_SECRET;
//logger.info(`Stripe endpointSecret=${endpointSecret}`);

//Stripe Webhook
app.post('/webhook', (request, response) => {
    logger.info("Stripe WebHook detected!");

    const sig = request.headers['stripe-signature'];
    
    let event;
    try {
        event = stripe.webhooks.constructEvent(request.rawBody, sig, endpointSecret); //@JA - Had to modify this to take the rawBody since this is what was needed.
    } catch (err) {
        response.status(400).send(`Webhook Error: ${err.message}`);
        return;
    }

    logger.info(`event_type=${event.type}`);

    // Handle the event
    switch (event.type) {
        case 'payment_intent.succeeded':
            const paymentIntent = event.data.object;
            // Then define and call a function to handle the event payment_intent.succeeded
            //logger.info(JSON.stringify(paymentIntent));
            //logger.info("Payment_intent.succeeded!");
        break;
            // ... handle other event types
        default:
        console.log(`Unhandled event type ${event.type}`);
    }

    // Return a 200 response to acknowledge receipt of the event
    response.send();
});

require("./routes/user.routes")(app); //@JA - Includes all the API Routes for User

const httpServer = http.createServer(app);
httpServer.listen(8080, () =>{
    logger.info(`HTTP Server running on port 8080 via 80 docker-compose, partial redirect active, using app for routes but will forward all 80 port traffic to 443 except for .well-known route`);
});

//@JA - TODO - Make this use first credentials it finds and add anymore as contexts.

logger.info(`using credentials for: ${credentials_array[0].domain} as default`);


const httpsServer = https.createServer(credentials_array[0], app);
if(credentials_array.length>1){
    for(let i=0;i<credentials_array.length;i  ){
        logger.info(`Adding httpsServer context for ${credentials_array[i].domain}`);
        httpsServer.addContext(credentials_array[i].domain,credentials_array[i]);//@JA - Domain is stored in the credentials object for convinience.
    }
}

httpsServer.listen(443, () => {
    logger.info(`'HTTPS Server running on port 443!'`);
});
  

Наконец, хотя большая часть кода, который я вставил, не имеет отношения к этому, я укажу на соответствующие разделы, чтобы заставить это работать.

 const http = require('http')
const https = require('https');
  

Вы должны включить это выше, так как вам нужно будет запустить ОБА сервера. Проверка webroot НЕ будет работать с SSL-версией каталога! По этой причине мне пришлось сделать несколько обходных путей, чтобы серверу никогда не приходилось завершать работу.

 const certBotPath = '/var/www/certbot/';
const credentialsPath = certBotPath 'conf/live/';
  

Это пути к фактическому сертификату, и веб-корень certbot проверяет, используете ли вы ту же информацию о томе из docker-compose, что и я.

 var credentials_array = [];
try{
    fs.readdirSync(credentialsPath).map( domain_directory => {
        // logger.info("Reading directories for SSL credentials...");
        // logger.info(domain_directory);

        if(domain_directory!="README"){ //@JA - We ignore the README file
            const privateKey = fs.readFileSync(credentialsPath domain_directory '/privkey.pem', 'utf8');
            const certificate = fs.readFileSync(credentialsPath domain_directory '/cert.pem', 'utf8');
            const ca = fs.readFileSync(credentialsPath domain_directory '/chain.pem', 'utf8'); 
            
            const credentials = {
                key: privateKey,
                cert: certificate,
                ca: ca,
                domain: domain_directory
            }
            logger.info(`credential object added.. ${domain_directory}`);
            credentials_array.push(credentials);
        }
        
    });
}catch{
    credentials = {};//@JA - Just give a wrong credentials object so it works.
    credentials_array.push(credentials);
}
  

Этот код попытается прочитать путь к учетным данным, чтобы узнать, есть ли какие-либо существующие сертификаты, созданные без учета файла README, который всегда будет присутствовать. Если будут найдены какие-либо сертификаты домена, он добавит его в массив, поскольку вы можете добавить несколько сертификатов для каждого экземпляра https-сервера, используя .addContext объект https, как вы увидите позже.

Каталог, который он создает при успешном создании сертификатов, выглядит примерно так, чтобы вы понимали, что делает мой код. Я просто размыл свой домен по соображениям безопасности.

введите описание изображения здесь

 var httpsCheck = function (req, res, next) {
    if(req.connection.encrypted){
        logger.info(req.url);
        logger.info("SSL Detected, continue to next middleware...!");
        next();//Then continue on as normal.
    }else{
        logger.info("SSL NOT DETECTED!!!")
        logger.info(req.url);
        if(!req.url.includes("/.well-known/acme-challenge/")){
            logger.info("Forcing permanent redirect to port 443 secure...");
            res.writeHead(301, { "Location": "https://"   req.headers['host']   req.url });
            res.end();
        }else{
            next();//@JA - Let it pass as normal in this case since it's the challenges.
        }
    }
}
app.use(httpsCheck);
  

Приведенный выше код является промежуточным программным обеспечением, которое перехватывает каждый запрос к веб-сайту и, если обнаруживает его SSL, позволяет перейти к следующему промежуточному программному обеспечению с помощью команды next() . Если он обнаружит, что он НЕ зашифрован, то он считывает URL-адрес, и если он видит, что это запрос, /.well-known/acme-challenge/ тогда он позволит ему продолжить с помощью команды next (), если это НЕ так, он перенаправит запрос на версию SSL (https) этого пути, чтобывы сохраняете возможности перенаправления.

 app.get('/.well-known/acme-challenge/:fileid', function(req, res){
    res.sendFile(certBotPath "www/.well-known/acme-challenge/" req.params.fileid)
})
  

Этот код будет выполняться всякий раз, когда используется путь к веб-корню /.well-known/acme-challenge, если используется, и я отправляю файл, который он ищет, используя известные пути к файлам в экземпляре контейнера main_website docker . Это необходимо для прохождения всех проверок!

 const httpServer = http.createServer(app);
httpServer.listen(8080, () =>{
    logger.info(`HTTP Server running on port 8080 via 80 docker-compose, partial redirect active, using app for routes but will forward all 80 port traffic to 443 except for .well-known route`);
});
  

Это создает http-сервер для доступа к порту 80 и использует приложение, поэтому оно все равно будет работать с маршрутизацией, которую мы указали ранее. В моем случае я прослушиваю порт 8080, потому что мой файл docker compose маршрутизирует с 8080 на 80.

 const httpsServer = https.createServer(credentials_array[0], app);
if(credentials_array.length>1){
    for(let i=0;i<credentials_array.length;i  ){
        logger.info(`Adding httpsServer context for ${credentials_array[i].domain}`);
        httpsServer.addContext(credentials_array[i].domain,credentials_array[i]);//@JA - Domain is stored in the credentials object for convinience.
    }
}

httpsServer.listen(443, () => {
    logger.info(`'HTTPS Server running on port 443!'`);
});
  

Наконец, вы создаете httpsServer. Я по умолчанию использую первый credentialObject, который он может найти, а затем передаю его приложению, чтобы он мог получить доступ ко всем другим маршрутам в обычном режиме. Если найдены другие учетные данные, он будет использовать функцию .addContext для их добавления по мере необходимости.

Затем вы просто прослушиваете порт 443, как обычно, для SSL-сервера.


В заключение, это полностью рабочая настройка с официальным файлом certbot docker и перенаправлением SSL для всех файлов, кроме тех, которые необходимы для проверки webroot. Прилагаемый shellscript можно легко запустить, отправив нужный домен и запустив cronjob, чтобы периодически проверять, нужны ли новые сертификаты, и каждый раз помещать их в один и тот же каталог.

Я надеюсь, что это поможет кому-то еще, пытающемуся сделать это, поскольку это было большой работой, чтобы разобраться.