20-bo‘lim

Amaliy loyiha - o'z HTTP serveringiz

Xom soketlar ustida ishlaydigan HTTP/1.1 serveri: so'rovni ajratish, marshrutlash, statik fayllar, xatolar va doimiy ulanish.

🕑 12 daqiqa o‘qish 📄 1 057 so‘z 👁 0 marta ko‘rilgan
Ushbu bo‘lim mundarijasi
  1. 1-qism: so'rovni o'qish
  2. 2-qism: javob
  3. 3-qism: marshrutlash va statik fayllar
  4. 4-qism: server
  5. Serverni ishga tushiramiz
  6. Doimiy ulanish ishlayaptimi?
  7. Tana bo'laklab kelsa-chi?
  8. Xulosa
  9. Darslik yakunida

Endi hamma o'rganganimizni birlashtiramiz. Hech qanday freymvork ishlatmaymiz - faqat socket.

Yakunda serveringiz quyidagilarni bajaradi:

ImkoniyatQaysi bo'limdan
Bayt oqimidan so'rovni ajratish8 - TCP
Content-Length bilan tanani o'qish12 - HTTP
Marshrutlash va holat kodlari12 - HTTP
Statik fayllar va yo'l xavfsizligi17 - tashxis
Doimiy ulanish13 - HTTP versiyalari
Xatolarni ushlash8 - ishonchlilik

1-qism: so'rovni o'qish #

Eng muhim qism shu. 8-bo'limda ko'rgandik: TCP - bayt oqimi, recv bir marta chaqirilsa butun so'rov kelishiga ishonib bo'lmaydi.

Python
import os
import socket
import threading


class Sorov:
    def __init__(self, usul, yol, versiya, sarlavhalar, tana):
        self.usul = usul
        self.yol = yol
        self.versiya = versiya
        self.sarlavhalar = sarlavhalar
        self.tana = tana

    def sarlavha(self, nom, standart=None):
        return self.sarlavhalar.get(nom.lower(), standart)

    def tirik_qolsinmi(self):
        ulanish = self.sarlavha("connection", "").lower()
        if self.versiya == "HTTP/1.1":
            return ulanish != "close"
        return ulanish == "keep-alive"


class SorovOqiuvchi:
    """Bayt oqimidan to'liq so'rovni ajratib oladi (8-bo'lim)."""

    CHEGARA = 64 * 1024

    def __init__(self, ulanish):
        self.ulanish = ulanish
        self.bufer = b""

    def _yana_oqi(self):
        bolak = self.ulanish.recv(4096)
        if not bolak:
            return False
        self.bufer += bolak
        if len(self.bufer) > self.CHEGARA:
            raise ValueError("so'rov juda katta")
        return True

    def oqi(self):
        while b"\r\n\r\n" not in self.bufer:
            if not self._yana_oqi():
                return None
        bosh, qolgan = self.bufer.split(b"\r\n\r\n", 1)
        qatorlar = bosh.decode("latin-1").split("\r\n")

        qismlar = qatorlar[0].split(" ")
        if len(qismlar) != 3:
            raise ValueError("noto'g'ri so'rov qatori")
        usul, yol, versiya = qismlar

        sarlavhalar = {}
        for q in qatorlar[1:]:
            if ":" not in q:
                raise ValueError("noto'g'ri sarlavha")
            nom, qiymat = q.split(":", 1)
            sarlavhalar[nom.strip().lower()] = qiymat.strip()

        uzunlik = int(sarlavhalar.get("content-length", 0))
        while len(qolgan) < uzunlik:
            self.bufer = qolgan
            if not self._yana_oqi():
                return None
            qolgan = self.bufer
        tana, self.bufer = qolgan[:uzunlik], qolgan[uzunlik:]
        return Sorov(usul, yol, versiya, sarlavhalar, tana)
CHEGARA nima uchun kerak?

oqi() ichidagi halqa \r\n\r\n kelguncha o'qiydi. Agar mijoz uni hech qachon yubormasa, halqa cheksiz davom etadi va bufer xotirani to'ldiradi.

Bu haqiqiy hujum turi - sekin so'rov yuborib, serverning resurslarini band qilish.

Shuning uchun ikki chegara majburiy:

ChegaraQanday qo'yiladi
HajmCHEGARA - buferdagi baytlar soni
Vaqtsettimeout - soket darajasida

Ikkinchisini serverni qurgan joyda qo'yamiz.

Diqqat qiling: self.bufer so'rovdan keyin qolgan baytlarni saqlaydi. Bu doimiy ulanish uchun zarur: bitta recv da ikkita so'rov kelgan bo'lishi mumkin.

2-qism: javob #

Python
IZOHLAR = {
    200: "OK", 204: "No Content", 400: "Bad Request",
    403: "Forbidden", 404: "Not Found",
    405: "Method Not Allowed", 500: "Internal Server Error",
}


class Javob:
    def __init__(self, kod=200, tana=b"", tur="text/plain; charset=utf-8"):
        if isinstance(tana, str):
            tana = tana.encode()
        self.kod = kod
        self.tana = tana
        self.sarlavhalar = {"Content-Type": tur}

    def baytlar(self, tirik):
        izoh = IZOHLAR.get(self.kod, "Unknown")
        self.sarlavhalar["Content-Length"] = str(len(self.tana))
        self.sarlavhalar["Connection"] = "keep-alive" if tirik else "close"
        qatorlar = [f"HTTP/1.1 {self.kod} {izoh}"]
        for nom, qiymat in self.sarlavhalar.items():
            qatorlar.append(f"{nom}: {qiymat}")
        bosh = "\r\n".join(qatorlar) + "\r\n\r\n"
        return bosh.encode() + self.tana

Content-Length har doim len(self.tana) dan olinadi - qo'lda yozilmaydi. 12-bo'limdagi ogohlantirishni eslang: noto'g'ri uzunlik so'rovni ajratish zaifligiga olib keladi.

3-qism: marshrutlash va statik fayllar #

Python
class Marshrutlovchi:
    def __init__(self):
        self.qoidalar = {}

    def qosh(self, usul, yol, ishlovchi):
        self.qoidalar[(usul, yol)] = ishlovchi

    def top(self, sorov):
        ishlovchi = self.qoidalar.get((sorov.usul, sorov.yol))
        if ishlovchi:
            return ishlovchi
        # Yo'l bor, lekin usul boshqa bo'lsa - 405
        for usul, yol in self.qoidalar:
            if yol == sorov.yol:
                return lambda s: Javob(405, "Usul qo'llab-quvvatlanmaydi")
        return None


def statik_ishlovchi(ildiz):
    def ishlovchi(sorov):
        nisbiy = sorov.yol[len("/statik/"):]
        # XAVFSIZLIK: yo'lni normallashtirib, ildizdan chiqmasligini
        # tekshiramiz - aks holda ../../etc/passwd o'qib ketiladi
        toliq = os.path.realpath(os.path.join(ildiz, nisbiy))
        if not toliq.startswith(os.path.realpath(ildiz) + os.sep):
            return Javob(403, "Ruxsat yo'q")
        if not os.path.isfile(toliq):
            return Javob(404, "Fayl topilmadi")
        with open(toliq, "rb") as f:
            return Javob(200, f.read(), "text/plain; charset=utf-8")
    return ishlovchi
Statik fayl berish - eng ko'p xato qilinadigan joy

Sodda yechim shunday ko'rinadi:

Python
toliq = ildiz + sorov.yol[len("/statik/"):]
open(toliq, "rb")

Bu jiddiy zaiflik. /statik/../../etc/passwd so'ralsa, server tizim fayllarini o'qib berib yuboradi.

To'g'ri himoya ikki qadamdan iborat:

QadamNima qiladi
os.path.realpath.. va ramziy havolalarni yechadi
startswith(ildiz + os.sep)Natija ildiz ichida qolganini tekshiradi

os.sep ni qo'shish ham muhim: usiz /var/www-maxfiy yo'li /var/www tekshiruvidan o'tib ketadi.

Faqat satrdan .. ni olib tashlash yetarli emas - kodlangan shakllari (%2e%2e) va ramziy havolalar qoladi.

4-qism: server #

Python
class Server:
    def __init__(self, port, marshrutlovchi, statik=None):
        self.marshrutlovchi = marshrutlovchi
        self.statik = statik
        self.soket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        self.soket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        self.soket.bind(("127.0.0.1", port))
        self.soket.listen(8)
        self.jurnal = []

    def ulanishni_ishla(self, ulanish):
        oquvchi = SorovOqiuvchi(ulanish)
        try:
            while True:
                try:
                    sorov = oquvchi.oqi()
                except ValueError:
                    ulanish.sendall(Javob(400, "Buzuq so'rov").baytlar(False))
                    return
                if sorov is None:
                    return

                if self.statik and sorov.yol.startswith("/statik/"):
                    ishlovchi = self.statik
                else:
                    ishlovchi = self.marshrutlovchi.top(sorov)

                if ishlovchi is None:
                    javob = Javob(404, "Sahifa topilmadi")
                else:
                    try:
                        javob = ishlovchi(sorov)
                    except Exception:
                        javob = Javob(500, "Ichki xato")

                tirik = sorov.tirik_qolsinmi()
                self.jurnal.append(f"{sorov.usul} {sorov.yol} -> {javob.kod}")
                ulanish.sendall(javob.baytlar(tirik))
                if not tirik:
                    return
        finally:
            ulanish.close()

    def nechta_ishla(self, nechta):
        for _ in range(nechta):
            ulanish, _ = self.soket.accept()
            self.ulanishni_ishla(ulanish)

    def yop(self):
        self.soket.close()
Ishlovchi xatosi butun serverni yiqitmasligi kerak

ishlovchi(sorov) chaqiruvi try ichida turibdi. Sabab oddiy: sizning marshrut kodingizda xato bo'lishi mumkin.

Usiz bitta ZeroDivisionError butun serverni to'xtatardi - va qolgan barcha foydalanuvchilar xizmatsiz qolardi.

Bu yerda except Exception o'rinli: biz aniq bir xatoni emas, "ishlovchidagi har qanday nosozlikni" ushlayapmiz va uni 500 ga aylantiryapmiz.

Ishlab chiqarishda bu yerda xatoni jurnalga yozish ham shart - aks holda nosozlik jimgina yo'qoladi.

Serverni ishga tushiramiz #

Python
import tempfile

PORT = 9980
ildiz = tempfile.mkdtemp()
with open(os.path.join(ildiz, "salom.txt"), "w") as f:
    f.write("statik fayl mazmuni")

m = Marshrutlovchi()
m.qosh("GET", "/", lambda s: Javob(200, "Bosh sahifa"))
m.qosh("GET", "/salom", lambda s: Javob(200, "Salom, tarmoq!"))
m.qosh("POST", "/echo", lambda s: Javob(200, s.tana))
m.qosh("GET", "/xato", lambda s: 1 / 0)

server = Server(PORT, m, statik_ishlovchi(ildiz))
oqim = threading.Thread(target=server.nechta_ishla, args=(7,), daemon=True)
oqim.start()


def sorov_yubor(xom):
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    s.settimeout(5)
    s.connect(("127.0.0.1", PORT))
    s.sendall(xom)
    javob = b""
    while True:
        b = s.recv(4096)
        if not b:
            break
        javob += b
    s.close()
    bosh, tana = javob.split(b"\r\n\r\n", 1)
    return bosh.decode().split("\r\n")[0], tana.decode()


SINOVLAR = [
    ("bosh sahifa", b"GET / HTTP/1.1\r\nHost: x\r\n"
                    b"Connection: close\r\n\r\n"),
    ("mavjud yo'l", b"GET /salom HTTP/1.1\r\nHost: x\r\n"
                    b"Connection: close\r\n\r\n"),
    ("yo'q sahifa", b"GET /yoq HTTP/1.1\r\nHost: x\r\n"
                    b"Connection: close\r\n\r\n"),
    ("noto'g'ri usul", b"DELETE /salom HTTP/1.1\r\nHost: x\r\n"
                       b"Connection: close\r\n\r\n"),
    ("POST tana bilan", b"POST /echo HTTP/1.1\r\nHost: x\r\n"
                        b"Content-Length: 11\r\nConnection: close\r\n\r\n"
                        b"salom tana!"),
    ("statik fayl", b"GET /statik/salom.txt HTTP/1.1\r\nHost: x\r\n"
                    b"Connection: close\r\n\r\n"),
    ("yo'ldan chiqish", b"GET /statik/../../etc/passwd HTTP/1.1\r\n"
                        b"Host: x\r\nConnection: close\r\n\r\n"),
]

for nom, xom in SINOVLAR:
    holat, tana = sorov_yubor(xom)
    print(f"{nom:<18} {holat:<26} {tana[:28]}")

oqim.join()
server.yop()

print()
print("server jurnali:")
for q in server.jurnal:
    print("  ", q)
Natija
bosh sahifa        HTTP/1.1 200 OK            Bosh sahifa
mavjud yo'l        HTTP/1.1 200 OK            Salom, tarmoq!
yo'q sahifa        HTTP/1.1 404 Not Found     Sahifa topilmadi
noto'g'ri usul     HTTP/1.1 405 Method Not Allowed Usul qo'llab-quvvatlanmaydi
POST tana bilan    HTTP/1.1 200 OK            salom tana!
statik fayl        HTTP/1.1 200 OK            statik fayl mazmuni
yo'ldan chiqish    HTTP/1.1 403 Forbidden     Ruxsat yo'q

server jurnali:
   GET / -> 200
   GET /salom -> 200
   GET /yoq -> 404
   DELETE /salom -> 405
   POST /echo -> 200
   GET /statik/salom.txt -> 200
   GET /statik/../../etc/passwd -> 403

Yettita sinov, yettita to'g'ri javob. Eng muhimi - oxirgisi: yo'ldan chiqishga urinish 403 bilan to'xtatildi.

Doimiy ulanish ishlayaptimi? #

13-bo'limda keep-alive ning foydasini ko'rgandik. Serverimiz uni qo'llab-quvvatlaydimi?

Python
PORT2 = 9981

m2 = Marshrutlovchi()
m2.qosh("GET", "/", lambda s: Javob(200, "javob"))

server2 = Server(PORT2, m2)
oqim2 = threading.Thread(target=server2.nechta_ishla, args=(1,), daemon=True)
oqim2.start()

s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(5)
s.connect(("127.0.0.1", PORT2))

# Bitta ulanishda uchta so'rov
for i in range(1, 4):
    oxirgi = (i == 3)
    ulanish = "close" if oxirgi else "keep-alive"
    s.sendall(f"GET / HTTP/1.1\r\nHost: x\r\n"
              f"Connection: {ulanish}\r\n\r\n".encode())
    javob = s.recv(4096).decode()
    birinchi = javob.split("\r\n")[0]
    ulanish_sarlavhasi = [q for q in javob.split("\r\n")
                          if q.startswith("Connection")][0]
    print(f"  {i}-so'rov: {birinchi}  |  {ulanish_sarlavhasi}")

s.close()
oqim2.join()
server2.yop()

print()
print("qabul qilingan ulanishlar soni: 1")
print("bajarilgan so'rovlar soni     :", len(server2.jurnal))
Natija
  1-so'rov: HTTP/1.1 200 OK  |  Connection: keep-alive
  2-so'rov: HTTP/1.1 200 OK  |  Connection: keep-alive
  3-so'rov: HTTP/1.1 200 OK  |  Connection: close

qabul qilingan ulanishlar soni: 1
bajarilgan so'rovlar soni     : 3

Bitta accept, uchta so'rov. Ikkita qo'l berish tejaldi.

Tana bo'laklab kelsa-chi? #

Yuqoridagi sinovda POST tanasi kichik edi va sarlavhalar bilan birga bitta recv da keldi. Bu eng xavfli holat: kod ishlayotgandek ko'rinadi, lekin haqiqiy sinovdan o'tmagan.

Tanani ataylab bo'laklab yuboramiz:

Python
PORT3 = 9982

m3 = Marshrutlovchi()
m3.qosh("POST", "/echo", lambda s: Javob(200, f"{len(s.tana)} bayt keldi"))

server3 = Server(PORT3, m3)
oqim3 = threading.Thread(target=server3.nechta_ishla, args=(1,), daemon=True)
oqim3.start()

TANA = b"x" * 5000

s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(5)
s.connect(("127.0.0.1", PORT3))

# Avval faqat sarlavhalarni yuboramiz - tana hali yo'q
s.sendall(f"POST /echo HTTP/1.1\r\nHost: x\r\n"
          f"Content-Length: {len(TANA)}\r\n"
          f"Connection: close\r\n\r\n".encode())

# Tanani KICHIK bo'laklarda, alohida yuboramiz
for i in range(0, len(TANA), 500):
    s.sendall(TANA[i:i + 500])

javob = b""
while True:
    b = s.recv(4096)
    if not b:
        break
    javob += b
s.close()
oqim3.join()
server3.yop()

bosh, tana = javob.split(b"\r\n\r\n", 1)
print("yuborilgan tana hajmi:", len(TANA))
print("javob                :", tana.decode())
print("to'liq o'qildimi     :", tana.decode().startswith(str(len(TANA))))
Natija
yuborilgan tana hajmi: 5000
javob                : 5000 bayt keldi
to'liq o'qildimi     : True
Bu sinov yozilmaganda kod buzuq edi

Ochig'ini aytamiz: bu darslikni tayyorlash paytida oqi() metodidagi halqada bitta qator tushib qolgan edi - qolgan = self.bufer.

Usiz halqa yangi baytlarni ko'rmaydi va cheksiz aylanadi.

Eng yomoni shundaki, yuqoridagi yettita sinovning hammasi o'tib ketgan: POST tanasi 11 bayt edi va sarlavhalar bilan birga bitta recv ga sig'ib ketgan. Xato faqat tana alohida kelganda ochiladi.

Aynan shu 8-bo'limdagi ogohlantirishning amaldagi ko'rinishi:

QachonNima ko'rinadi
Kichik so'rov, tez tarmoqHammasi ishlaydi
Katta tana yoki sekin tarmoqServer qotib qoladi

Xulosa: bo'laklab kelish holatini ataylab sinash kerak. Uni tasodifga qoldirsangiz, xato ishlab chiqarishda, eng noqulay paytda chiqadi.

Bu serverni internetga chiqarmang

Kod o'rganish uchun yozildi va unda ishlab chiqarish uchun zarur ko'p narsa yo'q:

YetishmayaptiNima uchun kerak
Bir vaqtda ko'p mijozHozir ketma-ket ishlaydi (18-bo'lim)
Soket kutish vaqtiSekin mijoz serverni ushlab turadi
TLSHamma narsa ochiq ketadi (15-bo'lim)
So'rov tezligini cheklashOddiy toshqin serverni yiqitadi
Transfer-Encoding: chunkedStandart talab qiladi
To'liq jurnal va monitoringNosozlikni topib bo'lmaydi
MIME turlarini aniqlashHamma fayl text/plain bo'lib ketyapti

To'g'ri yondashuv - ilovani sinalgan server ortiga qo'yish (19-bo'limdagi teskari proksi) va TLS ni o'shanga topshirish.

Bu kodning qiymati boshqa narsada: endi o'sha "sinalgan serverlar" ichida nima borligini bilasiz.

Amaliy topshiriq
  1. Serverni ishga tushiring va barcha sinovlarni bajaring.
  2. /vaqt marshrutini qo'shing va joriy vaqtni qaytaring.
  3. /xato marshrutiga so'rov yuboring - qanday kod qaytdi?
  4. Fayl kengaytmasiga qarab Content-Type ni aniqlaydigan funksiya yozing.
  5. SorovOqiuvchi ga so'rov qatori uzunligi chegarasini qo'shing.
  6. Ulanishga settimeout qo'shing va sekin mijozni sinang.
  7. Har ulanishni alohida oqimda ishlaydigan qiling.
  8. 18-bo'limdagi selectors yondashuviga o'tkazing.
  9. So'rovlar sonini sanaydigan /statistika marshrutini qo'shing.
  10. Yo'ldan chiqishga uchta turli urinish yozing va hammasi to'xtatilishini tekshiring.

Xulosa #

  • HTTP serveri - bu socket ustidagi matn tahlilchisi, boshqa hech narsa emas.
  • Eng muhim qism - bayt oqimidan to'liq so'rovni ajratib olish.
  • Buferda so'rovdan keyin qolgan baytlar saqlanadi - doimiy ulanish uchun zarur.
  • Cheksiz o'qishning oldini olish uchun hajm va vaqt chegarasi majburiy.
  • Content-Length har doim len(tana) dan hisoblanadi, qo'lda yozilmaydi.
  • Statik fayllarda yo'ldan chiqish eng ko'p uchraydigan zaiflik.
  • Himoya: realpath bilan normallashtirish va ildiz ichida qolganini tekshirish.
  • Ishlovchidagi xato try bilan ushlanadi va 500 ga aylantiriladi.
  • Doimiy ulanish Connection sarlavhasi va HTTP versiyasiga qarab hal qilinadi.
  • O'quv serverini internetga chiqarmang - uni teskari proksi ortiga qo'ying.

Darslik yakunida #

Yigirma bo'lim oldin brauzerga manzil yozgandan sahifa ochilgunicha nima sodir bo'lishini bilmas edingiz. Endi butun yo'lni ko'rasiz:

QadamBo'lim
Nom manzilga aylanadi11 - DNS
Manzil qaysi tarmoqda ekani aniqlanadi4 - IP
Qo'shni bo'lmasa, marshrutizatorga beriladi5 - marshrutlash
Kadr yig'iladi va yuboriladi3 - Ethernet
NAT tashqi manzilga o'giradi10 - NAT
TCP ulanish o'rnatiladi8 - TCP
TLS qo'l berish bo'ladi15 - TLS
HTTP so'rovi yuboriladi12 - HTTP
Javob keladi va sahifa chiziladi13 - versiyalar

Bundan keyin qayerga borish mumkin:

  • "Linux server administratsiyasi" - shu bilimlarni haqiqiy serverda qo'llash;
  • "Kiberxavfsizlik asoslari" - tarmoq hujumlari va himoya;
  • "Amaliy kriptografiya" - TLS ostidagi matematika;
  • "Node.js" yoki "PHP" - shu protokollar ustida ilova qurish.

Tarmoq - bu sehr emas. Bu kelishuvlar, baytlar va bir necha oddiy g'oya. Endi ularni bilasiz.

Xatolik topdingizmi?

Imlo xatosi, ishlamaydigan kod yoki noto‘g‘ri ma‘lumotni ko‘rsangiz - bizga xabar bering. Har bir xabar administrator tomonidan ko‘rib chiqiladi.