Avoimet yhteydet: SSE ja WebSocket

Luennon ensimmäisessä osassa selain kysyi ja palvelin vastasi. Tässä osassa katsotaan tilanteita, joissa tieto syntyy palvelimella ja selaimen pitäisi saada se heti: Server-Sent Events ja WebSocket. Molemmat perustuvat pitkäkestoiseen yhteyteen, mikä muuttaa sekä palvelimen resurssien käyttöä että tietoturvan kysymyksiä.

Ensimmäisen osan asiat — tilakoodit, mediatyypit, tietojen tarkistaminen, istunnot, CSRF ja CORS — pätevät tässäkin, ja niihin viitataan tarvittaessa.

Server-Sent Events

Tähän asti selain on kysynyt ja palvelin vastannut. Joskus tieto syntyy palvelimella: toinen käyttäjä tekee varauksen, mittaus päivittyy tai työ valmistuu. Silloin selaimen pitäisi saada tieto heti. Yksinkertaisin tapa on toistaa sama pyyntö tasaisin väliajoin (engl. polling), mutta se tuottaa turhia pyyntöjä ja viivettä.

Server-Sent Events (SSE) kääntää asetelman: selain avaa yhden pitkäkestoisen HTTP-yhteyden, jonka palvelin jättää auki ja kirjoittaa siihen tapahtumia sitä mukaa kuin niitä syntyy.

Server-Sent Events: yksi pyyntö, monta vastausta Selain avaa EventSource-olion, joka tekee tavallisen GET-pyynnön HTTP-otsakkeella Accept: text/event-stream. Palvelin vastaa mediatyypillä text/event-stream ja jättää yhteyden auki. Se kirjoittaa avoimeen yhteyteen tapahtumia sitä mukaa kuin niitä syntyy, ja hiljaisina hetkinä kommenttirivin, joka pitää yhteyden auki. Kun yhteys katkeaa, selain yhdistää itse uudelleen ja kertoo Last-Event-ID-otsakkeessa viimeksi saamansa tapahtuman tunnisteen, jolloin palvelin voi lähettää väliin jääneet tapahtumat. Selainnew EventSource(…)Palvelinavoin yhteys GET /tapahtumat HTTP/1.1Accept: text/event-stream1HTTP/1.1 200 OKContent-Type: text/event-streamCache-Control: no-cacheretry: 3000id: 12event: tiladata: {"varaukset": [ … ]}2Yhteys jää auki. Palvelin kirjoittaa siihen lisää, kun jotain tapahtuu.id: 13event: lisattydata: {"vid": 7, "otsikko": "Tiimipalaveri", …}3: keepalive (kommentti pitää yhteyden auki)4id: 14event: poistettudata: {"vid": 7, …}5Yhteys katkeaa, esimerkiksi verkkokatkos tai palvelimen aikarajaSelain odottaa retry-ajan ja yhdistää itse uudelleenGET /tapahtumat HTTP/1.1Accept: text/event-streamLast-Event-ID: 14 (selain lisää itse)6HTTP/1.1 200 OKContent-Type: text/event-streamid: 15event: lisattydata: {"vid": 8, …} (väliin jäänyt tapahtuma)7
SSE-yhteys: yksi pyyntö ja monta vastausta samassa yhteydessä. Katkoksen jälkeen selain kertoo Last-Event-ID-otsakkeessa, mihin asti se ehti.

Selainpuoli

Selainpuoli on EventSource-olio. Se avaa yhteyden, jäsentää viestit ja yhdistää katkoksen jälkeen itse uudelleen:

const lahde = new EventSource("/tapahtumat");

lahde.addEventListener("lisatty", e => {
  const varaus = JSON.parse(e.data);
  lisaaRivi(varaus);
});

lahde.addEventListener("poistettu", e => poistaRivi(JSON.parse(e.data).vid));

lahde.addEventListener("error", () => {
  // Selain yrittää uudelleen itse; tässä vain kerrotaan tilanne käyttäjälle.
  tila.textContent = "Yhteys katkesi, yritetään uudelleen…";
});

Viestien muoto

Virta on tekstiä, jossa jokainen viesti päättyy tyhjään riviin:

retry: 3000

id: 13
event: lisatty
data: {"vid": 7, "otsikko": "Tiimipalaveri"}

: keepalive

Palvelinpuoli Flaskilla

Vastaus muodostetaan generaattorista, jonka Flask lähettää pala palalta. Mediatyyppi on text/event-stream:

@app.get("/tapahtumat")
def tapahtumat():
    viimeisin = int(request.headers.get("Last-Event-ID", 0))
    jono = queue.Queue()
    kuuntelijat.append(jono)

    @stream_with_context
    def tapahtumavirta():
        try:
            yield "retry: 3000\n\n"
            for tapahtuma in jalkeen(viimeisin):      # katkoksen aikana syntyneet
                yield viesti(tapahtuma)
            while True:
                try:
                    yield viesti(jono.get(timeout=15))
                except queue.Empty:
                    yield ": keepalive\n\n"
        finally:
            kuuntelijat.remove(jono)                  # yhteys katkesi

    return Response(tapahtumavirta(), mimetype="text/event-stream", headers={
        "Cache-Control": "no-cache",
        "X-Accel-Buffering": "no",     # estää välityspalvelimen puskuroinnin
    })

Tapahtumat välitetään avoimille yhteyksille jonojen kautta. Kun varaus tallennetaan, palvelin kirjoittaa tapahtuman jokaisen kuuntelijan jonoon, ja tapahtumavirta lähettää sen eteenpäin.

Mitä palvelimella pitää huomioida

WSGI, ASGI ja Quart

Yllä olevista rajoituksista ensimmäinen on periaatteellinen. Flask on WSGI-sovellus: palvelin antaa jokaiselle pyynnölle oman työprosessin tai säikeen, joka on varattu niin kauan kuin vastaus kestää. SSE-yhteys kestää minuutteja tai tunteja, joten se pitää säiettä varattuna vaikkei mitään tapahtuisi.

Quart on Flaskin asynkroninen vastine, joka noudattaa ASGI-rajapintaa. Kaikki yhteydet ajetaan yhdessä tapahtumasilmukassa: odottava yhteys on pelkkä await, joka ei varaa säiettä. Yksi prosessi riittää siis sadoille tai tuhansille kuuntelijoille. Rajapinta on lähes sama kuin Flaskissa:

@app.get("/tapahtumat")
async def tapahtumat():
    jono = asyncio.Queue()
    kuuntelijat.add(jono)

    async def tapahtumavirta():
        try:
            yield "retry: 3000\n\n"
            while True:
                try:
                    # Odottaminen ei varaa säiettä, vain tehtävän silmukassa.
                    yield viesti(await asyncio.wait_for(jono.get(), timeout=15))
                except asyncio.TimeoutError:
                    yield ": keepalive\n\n"
        finally:
            kuuntelijat.discard(jono)

    vastaus = Response(tapahtumavirta(), mimetype="text/event-stream")
    vastaus.timeout = None          # Quart katkaisisi muuten vastauksen aikarajaan
    return vastaus

Hintana on asynkroninen ohjelmointimalli: silmukassa ei saa kutsua estäviä eli blocking-funktioita, jotka pysäyttävät suorituksen odottamaan vastausta. Tavallinen tietokantakirjasto tai requests pysäyttäisi kaikki yhteydet kerralla. Niiden tilalle tarvitaan asynkroniset vastineet tai erillinen säie.

Kurssin esimerkki on tehty Flaskilla, koska yhdelle infonäytölle säikeet riittävät ja loppu kurssista käyttää Flaskia. Paketissa on myös Quart-versio samasta palvelusta vertailun vuoksi. Sitä testattaessa yksi prosessi piti sata yhtäaikaista SSE-yhteyttä auki, ja jokainen kuuntelija sai tapahtumat perille.

Tapahtumavirrassa voi kulkea myös HTML:ää, jolloin sivu päivittyy ilman omaa javascriptiä. htmx:ssä siihen on hx-sse-lisäosa.

WebSocket

SSE:ssä tieto kulkee vain palvelimelta selaimeen. Kun molempien osapuolten pitää lähettää jatkuvasti — chat, yhteismuokkaus, moninpeli — tarvitaan WebSocket. Se on oma protokollansa (ws:// ja wss://), joka aloitetaan tavallisella HTTP-pyynnöllä ja vaihdetaan sitten toiseksi.

WebSocket: kättely ja kaksisuuntainen yhteys Selain avaa WebSocket-olion, joka lähettää tavallisen GET-pyynnön HTTP-otsakkeilla Upgrade: websocket ja Connection: Upgrade sekä satunnaisen Sec-WebSocket-Key-avaimen. Mukana on myös Origin-otsake, jonka palvelimen on tarkistettava itse. Palvelin vastaa tilakoodilla 101 Switching Protocols, minkä jälkeen yhteydessä ei kulje enää HTTP-pyyntöjä vaan kehyksiä, joita kumpikin osapuoli voi lähettää milloin tahansa. Kun yhteys katkeaa, selain ei yhdistä uudelleen itse, vaan sivun oman koodin on avattava uusi yhteys. Selainnew WebSocket(…)Palvelinavoin yhteys GET /ws HTTP/1.1Upgrade: websocketConnection: UpgradeSec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw==Origin: https://appro.mit.jyu.fi (palvelin tarkistaa)Cookie: session=…1HTTP/1.1 101 Switching ProtocolsUpgrade: websocketConnection: UpgradeSec-WebSocket-Accept: HSmrc0sMlYUkAGmm5OPpG2HaGWk=2Tästä eteenpäin yhteydessä kulkee kehyksiä, ei HTTP-pyyntöjä{"tapahtuma": "tila", "varaukset": [ … ]}3{"komento": "tilaa", "huone": "Neuvotteluhuone A"} (selain voi lähettää milloin tahansa)4{"tapahtuma": "lisatty", "data": { … }}5Yhteys katkeaaSelain ei yhdistä uudelleen itse: sivun koodi avaa uuden yhteydenGET /ws HTTP/1.1Upgrade: websocket (kaikki alusta uudelleen)6
Kättely tehdään HTTP:llä, ja tilakoodin 101 jälkeen yhteydessä kulkee kehyksiä kumpaankin suuntaan. Katkoksen jälkeen uuden yhteyden avaaminen jää sivun oman koodin tehtäväksi.

Selainpuoli

const pistoke = new WebSocket(`wss://${location.host}/ws`);

pistoke.addEventListener("open", () => pistoke.send(JSON.stringify({ komento: "tilaa" })));
pistoke.addEventListener("message", e => kasittele(JSON.parse(e.data)));
pistoke.addEventListener("close", () => yhdistaUudelleen());

Huomaa close-käsittelijä. EventSource yhdistää katkoksen jälkeen itse, mutta WebSocket ei: uudelleenyhteys, kasvava odotusaika ja väliin jääneiden tapahtumien hakeminen ovat sivun oman koodin vastuulla.

Palvelinpuoli

Flask ei osaa WebSocketia itse, koska WSGI ei tunne protokollan vaihtoa. Tuki saadaan lisäosalla, esimerkiksi flask-sock:

from flask_sock import Sock

sock = Sock(app)


@sock.route("/ws")
def ws(pistoke):
    while True:
        viesti = pistoke.receive()          # estävä kutsu omassa säikeessään
        pistoke.send(vastaus(viesti))

Quartissa tuki on sisäänrakennettu, koska ASGI määrittelee WebSocketin omana yhteystyyppinään:

@app.websocket("/ws")
async def ws():
    if not sallittu_lahde(websocket.headers.get("Origin"), websocket.headers.get("Host")):
        abort(403)

    jono = asyncio.Queue()
    kuuntelijat.add(jono)
    try:
        await websocket.send(json.dumps({"tapahtuma": "tila", "varaukset": varaukset()}))
        while True:
            tunniste, tyyppi, data = await jono.get()
            await websocket.send(json.dumps({"id": tunniste, "tapahtuma": tyyppi,
                                             "data": data}))
    finally:
        # Quart peruuttaa tämän tehtävän, kun selain sulkee yhteyden.
        kuuntelijat.discard(jono)

Rakenne on sama kuin SSE:ssä: kuuntelijoille on jonot, ja tapahtuma kirjoitetaan jokaiseen jonoon. Myös resurssit kuluvat samoin, joten WSGI:n ja ASGI:n ero pätee tähän sellaisenaan.

Socket.IO

Flask-sovelluksissa WebSocketien sijaan näkee usein Socket.IO-kirjaston ja sen Flask-version Flask-SocketIO. Socket.IO ei ole WebSocketin toteutus vaan oma protokollansa, joka käyttää WebSocketia kuljetuskerroksena. Siksi selaimen WebSocket-olio ei pysty puhumaan Socket.IO-palvelimelle eikä Socket.IO-asiakas tavalliselle WebSocket-palvelimelle: molemmissa päissä on käytettävä Socket.IO-kirjastoa.

Socket.IO ratkaisee ne asiat, jotka WebSocketissa jäävät ohjelmoijan huoleksi. Se yhdistää katkoksen jälkeen itse, välittää nimettyjä tapahtumia omien viestimuotojen sijaan, jakaa yhteydet huoneisiin ja käyttää tarvittaessa tavallisia HTTP-pyyntöjä (long polling), jos WebSocket-yhteys ei onnistu. Oletuksena yhteys aloitetaan HTTP-pyynnöillä ja vaihdetaan WebSocketiksi, kun se on mahdollista.

Hintana on monimutkaisempi julkaisu. Kun palvelininstansseja on useampi, tapahtumat on välitettävä instanssien välillä yhteisen viestinvälittäjän, esimerkiksi Redisin, kautta; sama ongelma tuli vastaan SSE:n kohdalla. HTTP-varayhteys edellyttää lisäksi, että saman asiakkaan pyynnöt ohjataan aina samalle instanssille (sticky sessions). Luvun tietoturvaohjeet pätevät Socket.IO:hon sellaisenaan.

Ohjauksessa 5 sama chat on toteutettu sekä selaimen omalla WebSocket-rajapinnalla että Socket.IO:lla, joten niitä voi verrata rinnakkain.

Same-origin policy ei koske WebSocket-yhteyttä

Selain ei tee WebSocket-kättelylle esitarkistusta eikä estä yhteyttä toiseen lähteeseen. Se kertoo lähteen Origin-otsakkeessa ja jättää päätöksen palvelimelle. Kokeilin tämän: toisesta lähteestä ladattu sivu sai yhteyden auki ja vastaanotti kaikki viestit, eikä konsoliin tullut virhettä.

Koska selain lähettää evästeet kättelyn mukana, tästä seuraa CSRF:n sukulainen Cross-Site WebSocket Hijacking: pahantahtoinen sivusto avaa yhteyden kirjautuneen käyttäjän evästeillä ja lukee kaiken, mitä yhteydessä liikkuu. Suojana on tarkistaa Origin-otsake palvelimella ja hylätä tuntemattomat lähteet, kuten yllä olevassa esimerkissä.

Selaimen WebSocket-rajapinta ei osaa lähettää omia HTTP-otsakkeita, joten Authorization-otsaketta ei voi käyttää — sama rajoitus kuin EventSource-oliolla. Tunniste lähetetään joko ensimmäisenä viestinä yhteyden avauduttua tai Sec-WebSocket-Protocol-alaprotokollana. Kyselyparametri on huono paikka, koska osoitteet päätyvät lokeihin.

Muu tietoturva

Origin-tarkistus ei yksin riitä. WebSocket poikkeaa HTTP:stä siinä, että yhteys on pitkäkestoinen, joten kertaalleen tehty tarkistus ei päde koko yhteyden ajan. OWASP:n WebSocket Security Cheat Sheet kokoaa asiat; tärkeimmät ovat:

Toistuva pyyntö, SSE vai WebSocket?

Neljä tapaa liikuttaa tietoa selaimen ja palvelimen välillä
Yksittäinen pyyntö Toistuva pyyntö SSE WebSocket
Suuntaselain kysyyselain kysyy palvelin lähettäämolemmat
ProtokollaHTTPHTTPHTTP oma, HTTP:llä aloitettu
Viive—pyyntövälin verran ei viivettäei viivettä
Uudelleenyhteysei tarvitaei tarvita automaattinentehtävä itse tai kirjastolla, kuten Socket.IO
Palvelimen resurssitvapautuu heti vapautuu hetiyhteys varattuyhteys varattu
Sopiikertaluonteiseen hakuun harvoin muuttuvaan tietoonpäivityksiin ja ilmoituksiin chattiin ja yhteismuokkaukseen

Valitse yksinkertaisin, joka riittää. Toistuva pyyntö ei vaadi mitään erityistä palvelimelta, SSE tuo päivitykset ilman viivettä ja hoitaa katkokset itse, ja WebSocket kannattaa vasta, kun selaimenkin pitää lähettää jatkuvasti.

Esimerkkisovellus

Esimerkkinä on sama varausaihe kuin luennon ensimmäisessä osassa, mutta omana palvelunaan: hallintasivulla tehty varaus ilmestyy infonäytölle heti, koska näyttö kuuntelee tapahtumavirtaa eikä toista pyyntöä minuutin välein.

Palvelu on Cloud Runissa, ja avoin yhteys pitää instanssin hereillä, mikä maksaa. Siksi se on käynnissä vain tarvittaessa, eikä linkkejä voi olettaa toimiviksi kaiken aikaa.

Lähdekoodit, testit ja julkaisuohjeet:

Kokeile avata kehittäjätyökalujen Network-välilehti infonäytöllä. SSE-yhteys näkyy yhtenä pyyntönä, joka ei pääty, ja sen sisältö karttuu tapahtuma kerrallaan. WebSocket-yhteydessä näkyy kättely tilakoodilla 101 ja erillinen näkymä kehyksille. Kokeile myös sulkea palvelu tai katkaista verkkoyhteys hetkeksi: SSE-versio toipuu itsestään, WebSocket-versio sivun oman koodin avulla.

Lisätietoa

Käyttäjien kommentit

Kommentoi Lisää kommentti