در آخرین شب، Nginx را جلوی API می‌گذاریم و HTTPS را در همان ورودی پایان می‌دهیم. هدف فقط روشن‌شدن قفل مرورگر نیست؛ باید بدانیم درخواست از چه مسیری می‌آید، کدام هدر قابل اعتماد است و گواهی چگونه بررسی می‌شود. خروجی مورد انتظار، مجموعه‌ای محلی است که فقط درگاه‌های Nginx را منتشر می‌کند، نوشتن را با کلید محافظت می‌کند و داده را روی volume نگه می‌دارد.

مسیر درخواست و اطلاعات اصلی آن#

پراکسی معکوس درخواست کلاینت را می‌گیرد و به برنامهٔ پشت خود می‌فرستد. در این آزمایشگاه، مسیر Client → Nginx → Uvicorn/FastAPI است. داخل شبکهٔ Compose، نام سرویس api به آدرس کانتینر برنامه تبدیل می‌شود. از داخل Nginx نباید به 127.0.0.1:8000 وصل شویم؛ آن نشانی خود کانتینر Nginx است، نه کانتینر API.

پوشهٔ ~/python-backend/night-12 را بسازید و فایل‌های برنامه، وابستگی، Dockerfile و dockerignore شب یازدهم را بیاورید. پایگاه یا کلید خصوصی کپی نکنید. به importهای main.py، نوع Request را اضافه و مسیر زیر را تعریف کنید. این مسیر برای مشاهدهٔ تفسیر درخواست است؛ اگر برنامه را عمومی کردید، نیاز به باقی‌ماندن چنین اطلاعات تشخیصی را جدا ارزیابی کنید.

from fastapi import Request


@app.get("/request-info")
def read_request_info(request: Request):
    return {
        "client": request.client.host if request.client else None,
        "scheme": request.url.scheme,
        "host": request.headers.get("host"),
    }

Nginx نام میزبان را در Host، آدرس فرستنده را در X-Forwarded-For و پروتکل بیرونی را در X-Forwarded-Proto به برنامه می‌دهد. چون در این توپولوژی Nginx تنها لبهٔ ورودی است، مقدار آدرس فرستنده را از اتصال خودش می‌نویسیم و هدر ادعایی کلاینت را به زنجیره اضافه نمی‌کنیم. توپولوژی دارای چند پراکسی معتبر به سیاست جداگانهٔ اعتماد نیاز دارد.

مرحلهٔ نخست با HTTP#

ابتدا فایل nginx.conf زیر را بسازید. proxy_pass مقصد داخلی را مشخص می‌کند و $http_host نام میزبان همراه درگاه درخواست را حفظ می‌کند. این نمونه فقط روی loopback میزبان منتشر می‌شود و نام‌های ورودی را به‌عنوان قرارداد عمومی سایت اعتبارسنجی نمی‌کند. برای سرویس عمومی، نام‌های مجاز و رفتار میزبان ناشناس باید صریح طراحی شوند.

events {}
http {
    server {
        listen 8080;
        server_name inventory.local;
        location / {
            proxy_pass http://api:8000;
            proxy_set_header Host $http_host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $remote_addr;
            proxy_set_header X-Forwarded-Proto $scheme;
        }
    }
}

فایل compose.yaml کامل زیر را بسازید. API دیگر ports ندارد. expose شرح درگاه داخلی است و نقش فایروال ندارد؛ سرویس‌های همان شبکه می‌توانند به درگاه گوش‌دهنده برسند. در این شبکهٔ اختصاصی فقط API و پراکسی را قرار می‌دهیم. اعتماد * به هدرهای پراکسی تنها با همین مرز محدود قابل قبول است؛ در شبکهٔ مشترک باید فرستنده‌های قابل اعتماد را محدود کنید.

services:
  api:
    build: .
    expose:
      - "8000"
    environment:
      DATABASE_PATH: /data/inventory.db
      LOG_LEVEL: INFO
      INVENTORY_API_KEY: ${INVENTORY_API_KEY:?INVENTORY_API_KEY must be set}
    volumes:
      - inventory-data:/data
    command:
      - uvicorn
      - main:app
      - --host=0.0.0.0
      - --port=8000
      - --proxy-headers
      - --forwarded-allow-ips=*
    healthcheck:
      test:
        - CMD
        - python
        - -c
        - "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=2)"
      interval: 10s
      timeout: 3s
      retries: 3
      start_period: 5s
    restart: unless-stopped
  proxy:
    image: nginx:alpine
    ports:
      - "127.0.0.1:8080:8080"
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
    depends_on:
      api:
        condition: service_healthy
    restart: unless-stopped
volumes:
  inventory-data:

پس از تنظیم کلید محیطی مانند شب قبل، docker compose config --quiet و docker compose up --build --detach را اجرا کنید. مقدار scheme در درخواست زیر باید http باشد و میزبان نام درخواستی را نشان دهد. آدرس کلاینت بسته به شبکهٔ Docker متفاوت است؛ عدد نمونهٔ منبع را به‌عنوان آدرس ثابت انتظار نداشته باشید.

curl -H 'Host: inventory.local:8080' http://127.0.0.1:8080/request-info
curl --connect-timeout 2 http://127.0.0.1:8000/health

فرمان دوم باید شکست اتصال بدهد، به شرط آنکه سرویس تمرین قبلی روی درگاه 8000 باقی نمانده باشد. موفقیت تصادفی آن را به API جدید نسبت ندهید. خواندن /health از درگاه 8080 باید موفق باشد. depends_on در شروع منتظر سلامت API می‌ماند؛ این تنظیم، پایش دائمی وابستگی و اصلاح خودکار همهٔ خرابی‌های بعدی نیست.

پایان TLS در پراکسی#

TLS سازوکاری برای رمزنگاری و احراز هویت ارتباط است؛ HTTPS یعنی HTTP روی TLS. در TLS termination، ارتباط رمز‌شده در Nginx پایان می‌یابد و ادامهٔ مسیر به FastAPI در شبکهٔ خصوصی با HTTP است. بنابراین خود برنامه باید از هدر معتبر بفهمد پروتکل بیرونی HTTPS بوده است. این چیدمان، مسیر داخلی را رمز نمی‌کند؛ خصوصی‌بودن شبکهٔ داخلی بخشی از فرض آزمایشگاه است.

با OpenSSL روی میزبان، گواهی خودامضای هفت‌روزه بسازید. این دستور فایل کلید و گواهی آزمایشی را ایجاد می‌کند؛ اگر فایل هم‌نام مهم دارید، مسیر تازه انتخاب کنید. گزینهٔ subjectAltName نام و آدرس قابل بررسی را مشخص می‌کند. کلید بدون گذرواژه ساخته می‌شود تا Nginx خودکار آغاز شود و باید با مجوز فایل حفاظت شود.

mkdir -p certs
openssl req -x509 -newkey rsa:2048 -nodes -days 7 \
  -keyout certs/inventory.key -out certs/inventory.crt \
  -subj '/CN=inventory.local' \
  -addext 'subjectAltName=DNS:inventory.local,IP:127.0.0.1'
chmod 600 certs/inventory.key
openssl x509 -in certs/inventory.crt -noout -subject -issuer -dates

کلید خصوصی را در Git ثبت نکنید و الگوی certs/*.key را در .gitignore این پروژهٔ تمرینی بگذارید. فایل گواهی عمومی است ولی کلید باید محرمانه بماند. در تصویر رسمی Nginx مورد استفاده، فرایند master باید بتواند فایل میزبان را از mount فقط‌خواندنی بخواند؛ اگر Docker بدون ریشه یا نگاشت کاربر دارید، مجوزها را با همان محیط سازگار کنید.

اکنون کل محتوای nginx.conf را جایگزین کنید. نشانی بازهدایت ثابت است تا از Host دلخواه کلاینت برای ساخت مقصد استفاده نشود. درگاه 8443 قرارداد همین آزمایشگاه است؛ سرویس عمومی معمولاً روی 443 قرار می‌گیرد و گواهی صادرشده از مرجع مورد اعتماد و تمدید منظم لازم دارد.

events {}
http {
    server {
        listen 8080;
        server_name inventory.local;
        return 301 https://inventory.local:8443$request_uri;
    }
    server {
        listen 8443 ssl;
        server_name inventory.local;
        ssl_certificate /etc/nginx/tls/inventory.crt;
        ssl_certificate_key /etc/nginx/tls/inventory.key;
        ssl_protocols TLSv1.2 TLSv1.3;
        location / {
            proxy_pass http://api:8000;
            proxy_set_header Host $http_host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $remote_addr;
            proxy_set_header X-Forwarded-Proto https;
        }
    }
}

در سرویس proxy از Compose، درگاه 127.0.0.1:8443:8443 و mount گواهی ./certs:/etc/nginx/tls:ro را به فهرست‌های موجود اضافه کنید. تنظیم‌های دیگر و volume پایگاه را حفظ کنید. سپس پیکربندی Nginx را بررسی و سرویس‌ها را با تنظیم تازه بازسازی کنید. بازراه‌اندازی پراکسی اتصال‌های فعال آن را قطع می‌کند؛ در این آزمایشگاه فقط درخواست‌های تمرینی داریم.

docker compose run --rm --no-deps proxy nginx -t
docker compose up --detach --force-recreate
curl --cacert certs/inventory.crt \
  --resolve inventory.local:8443:127.0.0.1 \
  https://inventory.local:8443/request-info
curl --head --resolve inventory.local:8080:127.0.0.1 \
  http://inventory.local:8080/health

در HTTPS، مقدار scheme باید https و میزبان inventory.local:8443 باشد. در HTTP کد 301 و مقصد HTTPS انتظار می‌رود. --resolve فقط نام آزمایشگاهی را برای همین فراخوانی به loopback می‌برد. با --cacert به گواهی همین آزمایش اعتماد مشخص داده‌ایم؛ برخلاف --insecure، بررسی گواهی را خاموش نمی‌کنیم. گزینهٔ insecure رمزنگاری را حذف نمی‌کند، اما بررسی هویت طرف مقابل را کنار می‌گذارد.

برای دیدن مذاکرهٔ TLS از openssl s_client -connect 127.0.0.1:8443 -servername inventory.local -CAfile certs/inventory.crt -verify_return_error -brief </dev/null استفاده کنید. باید نسخهٔ مجاز TLS و نتیجهٔ بررسی گواهی را ببینید. خودامضابودن با نامعتبر بودن رمزنگاری یکی نیست؛ مسئلهٔ اصلی، شیوهٔ اعتماد کلاینت به هویت ارائه‌شده است. برای دامنهٔ عمومی از مرجع معتبر یا مرجع داخلی مدیریت‌شده استفاده کنید.

تمرین نهایی مجموعه#

از مسیر HTTPS یک POST با کلید صحیح بفرستید و 201 را بررسی کنید. کلید اشتباه باید 401 بدهد و شمار داده را تغییر ندهد. /docs را از پراکسی باز کنید و پس از docker compose down و آغاز دوباره، ماندگاری رکورد را بسنجید. درگاه مستقیم API همچنان باید بسته باشد. همهٔ درخواست‌ها را با اعتماد صریح به گواهی آزمایشگاه بفرستید؛ خطای گواهی را با خطای کلید اشتباه نگیرید.

اگر بازهدایت برنامه به HTTP بود، هم X-Forwarded-Proto در Nginx و هم اعتماد Uvicorn را بررسی کنید. اگر داده ناپدید شد، مسیر پایگاه و volume پروژه را ببینید. اگر اتصال داخلی برقرار نشد، نام سرویس و گوش‌دادن Uvicorn روی 0.0.0.0 را بررسی کنید. این ترتیب، هر نشانه را به لایهٔ مسئول وصل می‌کند و مانع تغییر تصادفی چند تنظیم هم‌زمان می‌شود.

منابع#