درس ۴۷ از ۴۷ · سرور و استقرار

سرور وب salam serve: از یک پوشه تا چند دامنه

salam serve دو کار می‌کند: یک پوشه را برای توسعه روی HTTP سرو می‌کند، یا از یک پرونده‌ی پیکربندی چند سایت واقعی را با میزبان مجازی، مسیر، هدایت، حافظه‌ی نهان و پشتیبان PHP اجرا می‌کند. این درس هر دو حالت را کامل می‌گوید، بعد سراغ چند دامنه و چند زیردامنه می‌رود، اتصال PHP و پایتون و گو را نشان می‌دهد و در پایان HTTPS و استقرار تولیدی را جمع می‌کند.

salam serve دقیقاً چیست؟

سرور وب در خود سلام نوشته شده است: بسته‌ی net/httpd یک سرور HTTP با انتخاب سایت از سرآیند Host، قاعده‌های مسیر، هدایت، صفحه‌ی خطا، حافظه‌ی نهان، گزارش دسترسی و پشتیبان فست‌سی‌جی‌آی است. فرمان salam serve فقط یک پوسته‌ی نازک دور همین بسته است؛ برای همین هر چیزی که در این درس می‌بینید از داخل یک برنامه‌ی سلام هم قابل استفاده است.

دو حالت دارد که نباید با هم اشتباه شوند:

حالتفرمانبرای چه
سرو یک پوشهsalam serve [dir]پیش‌نمایش سریع یک سایت ثابت، آزمایش خروجی salam web و salam page build، توسعه‌ی محلی
چند سایت از پیکربندیsalam serve --config=web.salamچند دامنه، چند زیردامنه، مسیرها، هدایت، حافظه‌ی نهان، گزارش دسترسی، PHP و FastCGI
فقط بررسی پیکربندیsalam serve --config=web.salam --testپیش از استقرار یا در CI؛ بدون باز کردن هیچ درگاهی

حالت اول عمداً ساده است: نه TLS دارد، نه هدر ETag، نه بازه‌ی بایت و نه قاعده‌ی مسیر. حالت دوم همان چیزی است که برای سایت واقعی می‌خواهید. اگر فقط یک پوشه‌ی ثابت دارید و به هیچ قاعده‌ای نیاز ندارید، حالت اول کافی است؛ ولی همان را هم می‌توانید با یک پیکربندی کوچک به حالت دوم ببرید و حافظه‌ی نهان و ETag و بلاک‌شدن پرونده‌های نقطه‌ای را بگیرید.

مرزها را از اول بدانید. در این نسخه salam serve نه TLS دارد، نه proxy_pass، نه CGI و نه سوکت یونیکس برای FastCGI. HTTPS را یک پایان‌دهنده‌ی TLS مثل Caddy یا nginx انجام می‌دهد و برنامه‌های HTTP پایتون و گو و نود هم پشت همان قرار می‌گیرند. جزئیاتش در بخش‌های HTTPS و پایتون و گو است.

حالت ساده: سرو کردن یک پوشه

بدون هیچ پرونده‌ای، همین فرمان پوشه‌ی جاری را روی http://127.0.0.1:8000/ سرو می‌کند:

salam serve

پوشه را هم می‌توانید بدهید و درگاه و نشانی را عوض کنید:

salam serve dist
salam serve dist --port=8080
salam serve dist --host=0.0.0.0 --port=8080

پیش‌فرض 127.0.0.1:8000 است؛ یعنی فقط از همان رایانه دیده می‌شود. برای دیدن از گوشی یا شبکه‌ی محلی --host=0.0.0.0 بدهید، اما بدانید این حالت پرونده‌های نقطه‌ای را هم سرو می‌کند.

  • برای هر پوشه index.html را می‌فرستد. اگر نبود، فهرست پوشه را به شکل HTML نشان می‌دهد.
  • فقط index.html را می‌شناسد؛ فهرست index دلخواه و صفحه‌ی ۴۰۴ سفارشی ندارد.
  • فایل‌ها را در حافظه نگه می‌دارد و وقتی فایل عوض شود دوباره می‌خواند؛ برای پیش‌نمایش سریع خوب است.
  • هدر ETag، Last-Modified، پاسخ ۳۰۴ و بازه‌ی بایت (Range) ندارد. این‌ها را حالت پیکربندی دارد.
  • پرونده‌های نقطه‌ای مثل .env را سرو می‌کند. مسیرهای .. را بیرون از پوشه نمی‌برد، اما پوشه‌ای که راز دارد را با این حالت روی شبکه نگذارید.
  • گزارش دسترسی به شکل GET /path -> 200 در خروجی چاپ می‌شود.
  • فایل‌ها را دوباره نمی‌سازد و تغییرها را نمی‌بیند؛ بعد از هر تغییر دوباره بسازید و صفحه را تازه کنید.

ساختن سایت با سلام و سرو کردنش

صفحه‌های سلام به HTML ساخته می‌شوند. یک پروژه‌ی صفحه با salam web به یک صفحه‌ی کامل، و یک پوشه با salam page build به یک HTML برای هر صفحه تبدیل می‌شود. خروجی را در پوشه‌ای مثل dist بگذارید و همان را سرو کنید:

mkdir -p dist
salam web --output=dist/index.html
salam serve dist --port=8080

برای سایت چندصفحه‌ای، salam page build هر صفحه را به فایل HTML خودش می‌نویسد و فایل‌های CSS و JS کنارش می‌آیند. اگر صفحه‌ها را با --split ساخته باشید، خروجی جداگانه‌ی CSS و JS هم دارید که می‌توانید برایشان حافظه‌ی نهان بگذارید. صفحه‌های ثابت همان‌طور که هستند کامل‌اند؛ برای بخش‌های پویا به اتصال به خود سلام بروید.

خود سایت سلام هم برای پیش‌نمایش خروجی درست همین کار را می‌کند: salam serve website/dist/fa.

پیکربندی: از یک سایت تا چند سایت

پیکربندی یک پرونده‌ی متنی با همان قالب بلوکی سلام است، اما برنامه‌ی سلام نیست: داده است، نه کد. یک تنظیم name = value است، یک بلوک با name: باز و با end بسته می‌شود، توضیح با // یا /* ... */ می‌آید، و هر تنظیم و مقدارش در یک خط می‌ماند. رشته‌ها میان دو نقل‌قول‌اند؛ برای مقداری که بک‌اسلش دارد، مثل عبارت باقاعده، از پشت‌نقل‌قول استفاده کنید: `~ \.php$`.

server:
    log: access = "/var/log/salam/access.log" end
    site:
        listen = 80
        names = "example.ir www.example.ir"
        root = "/srv/example/public"
        index = "index.html index.htm"
        error page = 404 "404.html"
        location "/assets/": cache = 30d end
        location "= /health": return = 200 "ok" end
        location "/old/": redirect = "https://www.example.ir$uri" code = 301 end
    end
end

ریشه‌ی همه‌چیز بلوک server: است. داخلش بلوک log: برای گزارش دسترسی و بلوک‌های site: برای هر سایت می‌آید. هر سایت یک root دارد و می‌تواند چند بلوک location داشته باشد. بلوک‌ها را می‌توان یک‌خطی هم نوشت: location "= /health": return = 200 "ok" end.

سایت با سرآیند Host انتخاب می‌شود. نام دقیق بر الگوی ستاره‌دار می‌چربد، الگوی بلندتر بر الگوی کوتاه‌تر، و درخواستی که هیچ سایتی آن را ادعا نکرده به سایتی می‌رسد که default = true دارد، وگرنه به اولین سایت آن درگاه. هر سایت اگر listen نداشته باشد روی درگاه ۸۰ می‌نشیند؛ این نکته را در پیکربندی چندسایتی فراموش نکنید.

پیش از هر استقرار، پرونده را بررسی کنید:

salam serve --config=web.salam --test

خروجی موفق یک خط است: configuration is ok: 3 site(s), 2 listener(s) و کد خروج ۰. اگر ایرادی باشد، هر مشکل با file:line:column چاپ می‌شود و کد خروج ۱ است. اجرای واقعی با خطای پیکربندی شروع نمی‌شود و کد خروج ۲ می‌دهد. --test بدون --config هم خطا می‌دهد.

مرجع تنظیم‌ها

این جدول همه‌ی تنظیم‌های پیکربندی است. ستون «کجا» می‌گوید تنظیم در سطح سایت، در سطح مسیر، یا هر دو معنا دارد.

تنظیمکجامعنا
listen = 80سایتدرگاه، به‌دلخواه با نشانی: listen = "127.0.0.1:8080". می‌توان تکرارش کرد تا یک سایت روی چند درگاه باشد. بدون آن، درگاه ۸۰.
names = "a.ir www.a.ir"سایتنام میزبان‌ها با فاصله. *.a.ir هر میزبانی را که به .a.ir ختم شود می‌گیرد، حتی چند سطح زیردامنه.
default = trueسایتسایت پیش‌فرض آن درگاه برای نام‌هایی که هیچ سایتی ادعا نکرده است.
root = "/srv/site"سایت و مسیرپوشه‌ای که مسیر درخواست به آن چسبانده می‌شود. root مسیر، جای root سایت را می‌گیرد.
alias = "/srv/files"فقط مسیر پیشوندیجای پیشوند مطابق را می‌گیرد: با location "/dl/": alias = "/srv/files" end مسیر /dl/a.zip می‌شود /srv/files/a.zip. با = و ~ کار نمی‌کند.
index = "index.html index.htm"سایت و مسیرفایل‌هایی که برای یک پوشه به ترتیب امتحان می‌شوند. پیش‌فرض index.html.
return = 200 "ok"سایت و مسیربی‌درنگ با یک وضعیت و متن اختیاری پاسخ می‌دهد. برای health check و صفحه‌های نگهداری خوب است.
redirect = "https://$host$request_uri"سایت و مسیرهدایت می‌کند؛ code کد را عوض می‌کند (پیش‌فرض ۳۰۱). متغیرها: $host، $uri، $args، $request_uri و $scheme. $scheme همیشه http است.
code = 302سایت و مسیرکد هدایت را از ۱۰۰ تا ۵۹۹ تعیین می‌کند.
cache = 30dسایت و مسیرهمراه فایل‌های ایستا Cache-Control: public, max-age=... می‌فرستد. یکاها: s، m، h، d، w، y.
header = "Name" "value"سایت و مسیریک هدر پاسخ اضافه می‌کند؛ دقیقاً دو رشته. می‌شود تکرارش کرد. هدرهای سایت روی همه‌ی پاسخ‌ها و هدرهای مسیر روی پاسخ‌های همان مسیر اعمال می‌شوند؛ اگر هم‌نام باشند، هدری که بعدتر در پرونده آمده برنده است.
error page = 404 "404.html"سایت و مسیرفایلی نسبت به root که با همان وضعیت خطا فرستاده می‌شود. کد از ۴۰۰ تا ۵۹۹.
fastcgi_pass = "127.0.0.1:9000"سایت و مسیردرخواست‌های منطبق را به یک پشتیبان FastCGI مثل php-fpm می‌سپارد. فقط نشانی TCP؛ سوکت یونیکس پشتیبانی نمی‌شود.
fastcgi_index = "index.php"سایت و مسیراسکریپتی که برای مسیر تمام‌شده با / استفاده می‌شود. پیش‌فرض index.php.
fastcgi_timeout = 30sسایت و مسیرسقف زمان یک درخواست تا پشتیبان؛ پیش‌فرض ۳۰ ثانیه. کل تبادل را می‌پوشاند.
fastcgi_param = "NAME" "value"سایت و مسیریک متغیر CGI اضافه یا عوض می‌کند؛ می‌شود تکرارش کرد. متغیرهای $uri و $header:Name را می‌شناسد.
log: access = "..."سطح سرورگزارش دسترسی در یک فایل، یا "stdout" و "stderr". اگر باز نشود سرور بالا نمی‌آید.

قاعده‌های مسیر و ترتیب انتخاب

داخل هر سایت، مسیر درخواست مثل nginx با بلوک‌های location مقایسه می‌شود. چهار شکل دارید:

  • location "= /health": فقط همین مسیر، بدون آزمایش چیز دیگر. سریع‌ترین و قطعی‌ترین.
  • location "/assets/": هر مسیری که با این پیشوند شروع شود؛ بلندترین پیشوند برنده است.
  • location "^~ /static/": پیشوندی که اگر برنده شود، جلوی آزمایش عبارت‌های باقاعده را می‌گیرد.
  • location "~ \.php$" و "~* \.png$": عبارت باقاعده؛ دومی به بزرگی و کوچکی حروف حساس نیست. به ترتیب پرونده آزمایش می‌شوند و اولین مطابقت برنده است.

ترتیب کلی این است: اول تطابق دقیق؛ بعد بلندترین پیشوند؛ اگر آن پیشوند ^~ بود همین‌جا تمام؛ وگرنه عبارت‌های باقاعده به ترتیب پرونده؛ و اگر هیچ‌کدام نخورد، همان بلندترین پیشوند. مسیر پیش از مقایسه URL-decode و پاک‌سازی می‌شود؛ .. نمی‌تواند از root بالا برود و مسیری با %00 پاسخ ۴۰۰ می‌گیرد.

چند رفتار که در عمل مهم‌اند:

  • فقط فایل‌های ایستا به GET و HEAD پاسخ می‌دهند؛ روش دیگر ۴۰۵ با هدر Allow می‌گیرد. مسیر FastCGI به همه‌ی روش‌ها پاسخ می‌دهد، چون تصمیمش با برنامه است.
  • فایل‌ها با ETag و Last-Modified می‌روند؛ درخواست تازه ۳۰۴ بدون بدنه می‌گیرد و Range با ۲۰۶ همان بخش را. بازه‌ی بیرون از فایل ۴۱۶ است. چند بازه در یک درخواست با کل فایل پاسخ داده می‌شود.
  • پوشه‌ای که با / نیامده باشد با ۳۰۱ به شکل اسلش‌دار هدایت می‌شود.
  • هر بخشی از مسیر که با نقطه شروع شود، مثل .env یا .git، ۴۰۴ می‌گیرد؛ تنها استثنا .well-known است، برای ACME و تأیید دامنه.
  • برای فایل، مسیر همیشه نسبت به root مؤثر خوانده می‌شود؛ alias فقط پیشوند مسیر را با یک پوشه عوض می‌کند.

چند دامنه و چند سایت مختلف روی یک سرور

همه‌ی سایت‌ها در یک پرونده می‌آیند و یک فرایند salam serve همه را اجرا می‌کند. هر سایت نام‌ها، ریشه و قاعده‌های خودش را دارد:

server:
    log: access = "/var/log/salam/access.log" end

    site:
        listen = 80
        names = "example.ir www.example.ir"
        root = "/srv/example/public"
        location "/assets/": cache = 30d end
        location "= /health": return = 200 "ok" end
    end

    site:
        listen = 80
        names = "shop.example.ir"
        root = "/srv/shop/public"
        location "/media/": cache = 7d end
    end

    site:
        listen = 80
        names = "blog.example.ir"
        root = "/srv/blog"
        default = true
    end
end

نکته‌های این پیکربندی:

  • هر سه سایت روی درگاه ۸۰ هستند، ولی یک شنونده ساخته می‌شود؛ انتخاب با Host است. درگاه‌های متفاوت شنونده‌ی جدا می‌سازند.
  • دو نام یک سایت را می‌توانید در یک رشته بیاورید: "example.ir www.example.ir". DNS هر دو باید به همین سرور برسد.
  • سایت سوم default = true است، پس درخواست با نام ناشناخته به آن می‌رسد. این الگوی خوبی برای صفحه‌ی «سایت پیدا نشد» یا سایت اصلی است. بیش از یک default روی یک درگاه نگذارید.
  • یک نام تکراری روی یک درگاه خطای پیکربندی است: name 'x' is used by two sites on port 80. اگر همان نام را روی درگاه دیگر بخواهید، اشکالی ندارد.

اگر چند دامنه‌ی کاملاً متفاوت باید یک پروژه و یک ریشه‌ی یکسان را نشان دهند، همه را در names یک سایت بیاورید: names = "a.ir www.a.ir b.ir c.ir". DNS همه باید به همین سرور برسد و محتوا یکی است. اگر هر دامنه محتوای خودش را می‌خواهد، برای هر کدام سایت جدا بسازید.

هدایت دامنه‌ی بدون www به www

سطح سایت، redirect روی همه‌ی درخواست‌های آن سایت اجرا می‌شود؛ پس دامنه‌ی بدون www را یک سایت جدا بدهید و سایت اصلی را جدا:

    site:
        listen = 80
        names = "example.ir"
        redirect = "https://www.example.ir$request_uri"
    end

    site:
        listen = 80
        names = "www.example.ir"
        root = "/srv/example/public"
    end

سایت هدایتگر به root نیاز ندارد، چون سطح سایت پیش از هر مسیری هدایت می‌کند. چون TLS را یک پراکسی جلوتر انجام می‌دهد، مقصد را صریح https:// نوشته‌ایم؛ $scheme در سلام همیشه http است. برای آزمایش محلی، Host را دستی بدهید:

curl -H "Host: shop.example.ir" http://127.0.0.1:8080/

روی رایانه‌ی خودتان می‌توانید این نام‌ها را در /etc/hosts (یا فایل hosts ویندوز) به 127.0.0.1 بدهید و مرورگر را باز کنید.

چند زیردامنه به یک پروژه‌ی یکسان

اگر همه‌ی زیردامنه‌ها همان کد و همان ریشه را دارند، یک سایت با همه‌ی نام‌ها بسازید:

    site:
        listen = 80
        names = "app.example.ir *.app.example.ir"
        root = "/srv/app/public"
    end

الگوی *.app.example.ir به هر میزبانی می‌خورد که به .app.example.ir ختم شود؛ حتی a.b.app.example.ir. اگر فقط یک سطح زیردامنه می‌خواهید، نام‌ها را یکی‌یکی بنویسید یا در برنامه بررسی کنید. DNS هم باید *.app.example.ir را به سرور بدهد.

حالا برنامه با سرآیند Host می‌فهمد کاربر کدام زیردامنه را باز کرده است. برای سایت ثابت، همان محتوا در همه می‌آید؛ اگر محتوای هر زیردامنه فرق دارد، دو راه دارید:

  • یک ریشه، محتوای پویا: برنامه‌ی سلام (یا PHP) از Host استفاده می‌کند و محتوای همان زیردامنه را می‌سازد. برای این کار یک سایت با نام‌های ستاره‌دار کافی است.
  • ریشه‌ی جدا برای هر کدام: برای هر زیردامنه یک بلوک site: جدا با root خودش بسازید. نام دقیق بر ستاره می‌چربد، پس می‌توانید یک سایت ستاره‌دار برای بقیه داشته باشید و یکی دو زیردامنه‌ی خاص را جدا کنید:
    site:
        listen = 80
        names = "admin.app.example.ir"
        root = "/srv/admin/public"
    end

    site:
        listen = 80
        names = "*.app.example.ir"
        root = "/srv/app/public"
        default = true
    end

در این مثال admin ریشه‌ی خودش را دارد و بقیه‌ی زیردامنه‌ها از پروژه‌ی اصلی می‌آیند. برای گواهی TLS این چند نام، یا گواهی wildcard بگیرید یا در Caddy هر نام را جدا تعریف کنید؛ salam serve خودش گواهی نمی‌گیرد.

اتصال به PHP با php-fpm

PHP از طریق FastCGI به سلام وصل می‌شود. اول php-fpm را روی یک درگاه TCP بشنوید، چون سوکت یونیکس پشتیبانی نمی‌شود. در فایل pool، مثلاً /etc/php/8.3/fpm/pool.d/www.conf:

listen = 127.0.0.1:9000
; اگر فقط از همین سرور وصل می‌شوید:
listen.allowed_clients = 127.0.0.1

بعد php-fpm را دوباره راه بیندازید. حالا یک سایت PHP با فایل‌های واقعی:

server:
    site:
        listen = 80
        names = "php.example.ir www.php.example.ir"
        root = "/srv/php/public"
        index = "index.html"

        location "= /":
            fastcgi_pass = "127.0.0.1:9000"
            fastcgi_index = "index.php"
            fastcgi_timeout = 30s
            fastcgi_param = "APP_ENV" "production"
        end

        location "~ \.php(/|$)":
            fastcgi_pass = "127.0.0.1:9000"
            fastcgi_timeout = 30s
            fastcgi_param = "APP_ENV" "production"
        end

        location "/assets/":
            cache = 30d
        end
    end
end

مسیر / با تطابق دقیق به php-fpm می‌رود و فایل index.php را اجرا می‌کند. عبارت ~ \.php(/|$) هر مسیری را که به .php ختم شود یا بعد از آن بخش اضافه داشته باشد می‌گیرد، پس /index.php/a/b هم با PATH_INFO=/a/b اجرا می‌شود و هم فایل PHP به‌صورت متن خام بیرون نمی‌رود. بقیه‌ی فایل‌های ایستا از root خوانده می‌شوند.

هرگز فایل PHP را در فهرست index نگذارید. اگر index = "index.php" بدهید و مسیری به FastCGI نرود، سلام همان فایل را به‌عنوان فایل ایستا می‌فرستد و کد PHP لو می‌رود. فهرست index را روی index.html بگذارید، مسیر / را با تطابق دقیق به FastCGI بدهید و فقط فایل‌های ورودی PHP را زیر root نگه دارید.

سلام این متغیرهای CGI را خودش پر می‌کند: GATEWAY_INTERFACE، SERVER_SOFTWARE، SERVER_PROTOCOL، REQUEST_METHOD، REQUEST_URI، SCRIPT_NAME، SCRIPT_FILENAME، PATH_INFO، DOCUMENT_ROOT، DOCUMENT_URI، QUERY_STRING، CONTENT_TYPE، CONTENT_LENGTH، SERVER_NAME، SERVER_PORT، REMOTE_ADDR، REDIRECT_STATUS و HTTP_* برای هدرهای رایج. fastcgi_param یکی دیگر را اضافه یا عوض می‌کند و $uri، $request_uri، $args، $document_root، $script_name، $server_port، $remote_addr و $header:Name را می‌فهمد.

محدودیت مهم برای فریم‌ورک‌ها. سلام برای FastCGI دنبال یک فایل واقعی زیر root می‌گردد؛ مسیری که به هیچ فایلی نرسد، پیش از رسیدن به PHP پاسخ ۴۰۴ می‌گیرد. یعنی /index.php، /index.php/a/b و فایل‌های واقعی کار می‌کنند، اما آدرس زیبای /login در Laravel یا وردپرس با پیوند یکتا بدون یک پراکسی جلوتر کار نمی‌کند. اگر پیوند یکتای بدون index.php می‌خواهید، Caddy یا nginx را جلو بگذارید و بازنویسی را آن‌جا انجام دهید؛ یا فعلاً آدرس‌ها را با index.php بدهید. گزینه‌ی سوم، نگه‌داشتن پیوند ساده‌ی ?p=123 در وردپرس است.

پاسخ همان چیزی است که PHP می‌فرستد: خط Status: وضعیت را تعیین می‌کند، بقیه‌ی هدرها همان‌طور بیرون می‌روند (چند Set-Cookie هم) و بدنه بایت‌به‌بایت رد می‌شود. اگر پشتیبان در دسترس نباشد یا نیمه‌کار بمیرد، پاسخ ۵۰۲ است. هر درخواست اتصال خودش را باز می‌کند و REMOTE_ADDR به‌طور پیش‌فرض 127.0.0.1 است؛ پشت پراکسی آن را با fastcgi_param = "REMOTE_ADDR" "$header:X-Real-IP" عوض کنید و به برنامه بگویید TLS در جلو تمام شده است.

پایتون، گو، نود و بقیه‌ی زبان‌ها

اینجا باید صریح باشم: salam serve پراکسی معکوس HTTP ندارد و فقط فایل ایستا و FastCGI می‌فهمد. پس برای پایتون و گو و نود دو راه واقعی دارید.

راه اول و پیشنهادی: یک پراکسی معکوس جلوتر

برنامه‌ی پایتون یا گو را روی یک پورت محلی اجرا کنید و Caddy را جلو بگذارید. این‌گونه TLS و WebSocket و بازنویسی مسیر هم یک‌جا حل می‌شود. نمونه‌ی Caddyfile وقتی سلام هم بخشی از سایت را سرو می‌کند:

example.ir {
    encode gzip

    handle /api/* {
        reverse_proxy 127.0.0.1:8001
    }

    handle {
        reverse_proxy 127.0.0.1:8080
    }
}

اینجا 8001 برنامه‌ی پایتون یا گو و 8080 همان salam serve است. اگر بخش سلام را ندارید، فقط همان reverse_proxy به برنامه را نگه دارید. Caddy گواهی Let's Encrypt را خودش می‌گیرد و Host را دست‌نخورده رد می‌کند.

راه دوم: FastCGI با یک فایل ورودی

اگر برنامه‌تان FastCGI حرف می‌زند، می‌توانید مستقیم وصلش کنید. ترفند کار این است که یک فایل خالی به نام نقطه‌ی اتصال زیر root بگذارید تا سلام آن را «اسکریپت» ببیند و بقیه‌ی مسیر را به PATH_INFO بدهد. مثلاً فایل خالی /srv/app/api و این مسیر:

        location "/api":
            fastcgi_pass = "127.0.0.1:9100"
            fastcgi_timeout = 60s
        end

حالا /api/users به پشتیبان می‌رسد: SCRIPT_NAME=/api، PATH_INFO=/users. یک استثنای ریز: چون fastcgi_index برای PHP طراحی شده، درخواست دقیقاً /api/ به /api/index.php تبدیل می‌شود؛ از /api یا /api/... استفاده کنید. برای پایتون، uWSGI می‌تواند همین پروتکل را روی TCP حرف بزند:

pip install uwsgi
uwsgi --fastcgi-socket 127.0.0.1:9100 \
      --wsgi-file /srv/app/app.py \
      --callable app \
      --processes 4

و کوچک‌ترین برنامه‌ی WSGI که مسیر را برمی‌گرداند:

def app(environ, start_response):
    path = environ.get("PATH_INFO", "/")
    body = ("hello from python: " + path + "\n").encode()
    start_response("200 OK", [
        ("Content-Type", "text/plain; charset=utf-8"),
        ("Content-Length", str(len(body))),
    ])
    return [body]

برای گو، بسته‌ی net/http/fcgi یک سرور FastCGI می‌دهد. توجه کنید که این بسته در نسخه‌های تازه‌ی گو «منسوخ» علامت خورده اما هنوز کار می‌کند:

package main

import (
    "fmt"
    "net"
    "net/http"
    "net/http/fcgi"
)

func main() {
    ln, err := net.Listen("tcp", "127.0.0.1:9100")
    if err != nil {
        panic(err)
    }
    h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, "hello from go: %s\n", r.URL.Path)
    })
    if err := fcgi.Serve(ln, h); err != nil {
        panic(err)
    }
}

برای نود هم اگر بسته‌ای مثل node-fastcgi دارید همین الگو جواب می‌دهد؛ وگرنه راه اول را بردارید. محدودیت‌ها را جدی بگیرید: سلام مسیر را با فایل ورودی تطبیق می‌دهد، هر درخواست یک اتصال تازه باز می‌کند، سوکت یونیکس ندارد و همه‌ی درخواست‌های آن مسیر به یک پشتیبان می‌روند. برای نرمال‌ترین استقرار، پراکسی جلوتر بهتر است.

اتصال به خود سلام و سازگاری

سازگاری سلام با salam serve از دو جهت است. اول این‌که پیکربندی همان قالب بلوکی سلام است و با تجزیه‌گر net/httpd/conf خوانده می‌شود: بلوک‌ها با end (یا پایان) بسته می‌شوند، توضیح‌ها همان // هستند و خطاها با شماره‌ی خط و ستون گزارش می‌شوند. دوم این‌که خود سرور یک بسته‌ی کتابخانه‌ی استاندارد است، پس می‌توانید آن را داخل برنامه‌ی سلام راه بیندازید:

serve-embed.salam
واردسازی شبکه.سرور وب

تابع ریشه:
    اگر سرور وب.بررسی("web.salam") != ۰:
        برگشت ۱
    پایان
    اگر وارونه سرور وب.بارگذاری پرونده("web.salam"):
        برگشت ۲
    پایان
    برگشت سرور وب.اجرا()
پایان

این برنامه دقیقاً کاری را می‌کند که salam serve --config=web.salam می‌کند: پرونده را بررسی و بار می‌کند و اجرا را شروع می‌کند. برای تست پیکربندی بدون اجرا، httpd.Check (یا سرور وب.بررسی) همان --test است؛ برای پرسیدن این‌که یک میزبان یا مسیر به کدام سایت می‌رسد، SiteFor، LocationFor، SiteCount و SiteNames را دارید و هیچ درگاهی باز نمی‌شود.

سه الگوی رایج:

  • سایت ثابت ساخته‌شده با سلام: salam web یا salam page build، بعد salam serve dist. این ساده‌ترین و سریع‌ترین حالت است.
  • برنامه‌ی پویا با net/http: برنامه‌ی سلام خودش سرور را روی یک پورت راه می‌اندازد و می‌تواند فایل‌های ایستا را هم با router.Static بدهد. این برنامه را مستقیم اجرا کنید یا پشت Caddy بگذارید؛ salam serve آن را در خودش mount نمی‌کند چون پراکسی معکوس ندارد.
  • ترکیب ثابت و PHP: salam serve هم فایل ایستا می‌دهد هم PHP را به php-fpm می‌سپارد؛ اگر برنامه‌ی پایتون یا گو هم دارید، Caddy بخش /api را به آن و بقیه را به salam می‌دهد.

یک تفاوت ظریف: کلیدهای پیکربندی انگلیسی می‌مانند، مثل کلیدهای JSON، حتی اگر API فارسی باشد. نام تابع‌ها و نوع‌های net.httpd نام فارسی هم دارند (بررسی، بارگذاری پرونده، اجرا، توقف)، ولی پیکربندی داده است و با کلید ثابت خوانده می‌شود.

HTTPS و قرار گرفتن پشت Caddy یا nginx

در این نسخه listen = 443 tls خوانده می‌شود اما با پیام tls is not available yet رد می‌شود. پس TLS را به یک پایان‌دهنده بسپارید. کوتاه‌ترین راه Caddy است:

example.ir, www.example.ir {
    reverse_proxy 127.0.0.1:8080
}

این سه کار را با هم می‌کند: گواهی را می‌گیرد و تمدید می‌کند، HTTP را به HTTPS هدایت می‌کند و درخواست را با Host درست به سلام می‌دهد. اگر nginx را ترجیح می‌دهید، همان proxy_pass http://127.0.0.1:8080; و proxy_set_header Host $host; کافی است.

پشت پراکسی دو نکته را تنظیم کنید. برای PHP، REMOTE_ADDR را از هدر پراکسی بگیرید و به برنامه بگویید TLS تمام شده است:

            fastcgi_param = "REMOTE_ADDR" "$header:X-Real-IP"
            fastcgi_param = "HTTPS" "on"
            fastcgi_param = "HTTP_X_FORWARDED_PROTO" "https"

برای فایل‌های ایستا، salam گزارش دسترسی را با میزبان و مسیر می‌نویسد اما نشانی طرف مقابل را ندارد؛ اگر به آن نیاز دارید، لاگ Caddy یا nginx را نگه دارید. و یادتان باشد هدایت سطح سایت با $scheme همیشه http می‌سازد، پس مقصد https را صریح بنویسید.

خطاها، عیب‌یابی و بررسی پیش از استقرار

هر چیزی که ممکن است در پرونده غلط باشد هنگام بارکردن پیدا می‌شود، نه در اولین درخواست. نمونه‌ی خروجی --test برای یک پرونده‌ی خراب:

web.salam:3:18: port must be a number from 1 to 65535
web.salam:6:9: header needs two strings: the name and the value
web.salam:7:9: '[' is not a valid regular expression
web.salam:8:13: unknown setting 'returx' in a location (did you mean 'return'?)
web.salam:2:5: root '/srv/does-not-exist' is not a directory
configuration is not valid
پیام یا نشانهمعنا و راه‌حل
root '...' is not a directoryپوشه هنگام بارکردن باید وجود داشته باشد. مسیر را درست کنید یا اول بسازید.
name '...' is used by two sites on port ...دو سایت یک نام روی یک درگاه دارند. یکی را عوض کنید یا درگاه را جدا کنید.
site number N has no names and is not the default siteهر سایت یا باید names داشته باشد یا default = true باشد.
a location path must start with '/'مسیر پیشوندی باید با اسلش شروع شود. برای عبارت باقاعده از ~ استفاده کنید.
fastcgi_pass cannot read a unix socket yetphp-fpm را روی TCP بگذارید: listen = 127.0.0.1:9000.
tls is not available yetدر سلام TLS نگذارید؛ Caddy یا nginx را جلو بگذارید.
پاسخ ۵۰۲پشتیبان FastCGI در دسترس نیست، نشانی اشتباه است یا پشتیبان نیمه‌کار مرده است. اتصال TCP و لاگ php-fpm را ببینید.
پاسخ ۴۰۴ برای مسیر PHP بدون پسوندسلام فایل اسکریپت را پیدا نکرده است. آدرس را با index.php بدهید یا پراکسی جلوتر با بازنویسی بگذارید.
پاسخ ۴۰۵روش غیر از GET و HEAD روی فایل ایستا. Allow را ببینید؛ برای API از FastCGI یا برنامه‌ی سلام استفاده کنید.
پاسخ ۴۰۴ برای .env و پرونده‌های نقطه‌ایحالت پیکربندی آن‌ها را می‌بندد؛ تنها استثنا .well-known است. رازها را بیرون از root بگذارید.

چند نکته‌ی عیب‌یابی که وقت زیاد می‌گیرند:

  • --test فقط پیکربندی و وجود root و alias را بررسی می‌کند؛ سالم بودن php-fpm، وجود اسکریپت برای هر درخواست و باز بودن درگاه را نمی‌سنجد.
  • اگر سرور بالا نمی‌آید و پیام cannot listen on ... است، درگاه اشغال است. با lsof -i :80 یا ss -ltnp ببینید چه چیزی آن را گرفته است.
  • اگر پیکربندی را عوض کردید، سرور در حال اجرا سایت‌های قبلی را نگه می‌دارد؛ دوباره راه بیندازید.
  • نام میزبان به حروف کوچک تبدیل می‌شود و درگاه و نقطه‌ی پایانی از آن حذف می‌شوند؛ WWW.Example.IR. همان www.example.ir است.
  • لاگ دسترسی فقط زمان، میزبان، روش، مسیر و وضعیت را دارد؛ IP و زمان پاسخ در آن نیست. اگر این‌ها را می‌خواهید، لاگ پراکسی جلوتر را نگه دارید.

استقرار تولیدی و چک‌لیست

برای یک سرور لینوکسی، سرور را با systemd بالا نگه دارید. یک واحد ساده:

[Unit]
Description=Salam web server
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=www-data
Group=www-data
WorkingDirectory=/srv
ExecStart=/usr/local/bin/salam serve --config=/srv/web.salam
Restart=on-failure
RestartSec=2
AmbientCapabilities=CAP_NET_BIND_SERVICE

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now salam-web
sudo systemctl status salam-web
sudo journalctl -u salam-web -f

اگر درگاه ۸۰ را به کاربر غیرریشه می‌دهید، AmbientCapabilities بالا کافی است؛ یا سرور را روی 127.0.0.1:8080 بگذارید و Caddy را روی ۸۰ و ۴۴۳. کاربر سرویس باید به rootها و مسیر لاگ دسترسی خواندن (و اگر برنامه می‌نویسد، نوشتن) داشته باشد.

چک‌لیست کوتاه پیش از انتشار:

  • salam serve --config=web.salam --test را در CI یا پیش از هر استقرار اجرا کنید؛ کد خروج باید ۰ باشد.
  • پرونده‌ی راز، .env، .git و نسخه‌های پشتیبان را بیرون از root بگذارید. حالت پیکربندی پرونده‌های نقطه‌ای را می‌بندد، اما بهترین کار این است که اصلاً آن‌جا نباشند.
  • برای دارایی‌های نسخه‌دار (CSS و JS با هش) cache = 30d یا بیشتر بگذارید؛ برای HTML حافظه‌ی نهان نگذارید.
  • یک location "= /health": return = 200 "ok" end برای probe بگذارید.
  • گزارش دسترسی را با logrotate بچرخانید؛ سلام خودش چرخش ندارد.
  • HTTPS را Caddy یا nginx انجام دهد و X-Forwarded-Proto را به برنامه برساند.
  • هر سایت یک default معنادار داشته باشد؛ سایت پیش‌فرض بی‌صاحب باعث می‌شود دامنه‌ی اشتباه هم پاسخ ۲۰۰ بگیرد.
  • اگر FastCGI دارید، سقف fastcgi_timeout را با زمان واقعی پاسخ برنامه تنظیم کنید و به یاد داشته باشید هر درخواست یک اتصال تازه می‌سازد.

کدام راه را انتخاب کنم؟

نیازراه پیشنهادی
پیش‌نمایش سریع یک پوشه در توسعهsalam serve dir
سایت ثابت واقعی با حافظه‌ی نهان و صفحه‌ی ۴۰۴حالت پیکربندی با یک site: و cache
چند دامنه روی یک سروریک پرونده با چند site: و names جدا
چند زیردامنه روی یک پروژهیک site: با نام‌های ستاره‌دار و یک root؛ زیردامنه‌های خاص را با سایت دقیق جدا کنید
PHPfastcgi_pass به php-fpm روی TCP
پایتون، گو، نودCaddy یا nginx به‌عنوان پراکسی معکوس؛ FastCGI فقط اگر برنامه پشتیبانی کند
برنامه‌ی پویا با خود سلامبرنامه‌ی net/http را مستقیم اجرا کنید یا پشت Caddy بگذارید
HTTPSCaddy یا nginx؛ salam serve خودش گواهی نمی‌گیرد
WebSocketدر برنامه‌ی سلام با net/websocket؛ salam serve پراکسی WebSocket نیست

در پایان، salam serve یک سرور کوچک و خوانا برای سایت‌های ثابت و FastCGI است، نه جایگزین کامل nginx. برای یک سایت ثابت یا چند دامنه‌ی ثابت با PHP، همه‌چیز در یک فرمان و یک پرونده‌ی متنی جمع می‌شود. برای برنامه‌های HTTP دیگر و TLS، یک پراکسی ساده جلوتر بگذارید و بگذارید هر ابزار کار خودش را بکند.