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.
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
data:on viestin sisältö. Se voi olla mitä tahansa tekstiä, myös JSONia tai HTML:ää.event:on tapahtuman tyyppi, jonka mukaan selaimessa valitaan käsittelijä. Ilman sitä tapahtuma on tyyppiämessage.id:on tapahtuman tunniste, jonka selain lähettää takaisin uudelleenyhteydessä.retry:kertoo, kuinka monen millisekunnin kuluttua selain yrittää uudelleen.- Kaksoispisteellä alkava rivi on kommentti. Sillä pidetään yhteys auki, kun mitään ei tapahdu: monet välityspalvelimet katkaisevat hiljaisen yhteyden.
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
- Avoin yhteys varaa työprosessin tai säikeen koko yhteyden
ajaksi. Gunicornin neljä työprosessia riittää neljälle yhtäaikaiselle
kuuntelijalle. Tarvitaan siis säikeitä (
--threads) tai asynkroninen palvelin. - CGI ei sovi tähän lainkaan. Jokainen pyyntö käynnistää oman prosessin, ja palvelin puskuroi vastauksen, joten tapahtumavirta ei kulje. Tämän luennon SSE-esimerkki ajetaan siksi Cloud Runissa eikä users.jyu.fi-palvelimella.
- Aikarajat. Cloud Runin pyyntöaika on oletuksena 300 sekuntia
ja enimmillään 60 minuuttia, joten yhteys katkeaa ennen pitkää. Se ei ole
ongelma, koska selain yhdistää uudelleen, kunhan palvelin osaa käyttää
Last-Event-ID-otsaketta. - Useampi instanssi. Muistissa olevat kuuntelijat ovat instanssikohtaisia: toisen instanssin tapahtumat eivät näy toisen instanssin avaamassa tapahtumavirrassa. Yhteinen välittäjä, kuten Pub/Sub tai tietokannan kuuntelijat, ratkaisee tämän.
- Kustannukset. Avoin yhteys pitää instanssin hereillä, joten skaalautuminen nollaan ei toimi niin kauan kuin joku kuuntelee.
- EventSource ei osaa lähettää omia HTTP-otsakkeita. CSRF-tunnistetta
tai
Authorization-otsaketta ei voi liittää mukaan, joten tunnistautuminen hoituu evästeellä. Toisesta lähteestä yhteys vaatii samat CORS-otsakkeet kuin fetch, ja evästeitä vartennew EventSource(osoite, { withCredentials: true }). - HTTP/1.1 sallii selaimessa kuusi yhteyttä palvelinta kohden, ja avoin yhteys varaa niistä yhden jokaisessa välilehdessä. HTTP/2:lla rajoitus käytännössä poistuu.
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.
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.
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:
- Aina
wss://. Salaamattomassa yhteydessä kulkevat sekä viestit että kättelyn mukana lähtevä istuntoeväste. - Istunto voi vanhentua kesken yhteyden. Kirjautuminen tarkistetaan kättelyssä, mutta yhteys voi olla auki tuntikausia sen jälkeen, kun käyttäjä on kirjautunut ulos tai istunto on vanhentunut. Tarkista voimassaolo määrävälein ja sulje yhteys, kun istunto ei enää kelpaa. Uloskirjautumisen pitää katkaista myös avoimet yhteydet. HTTP-puolella tätä ongelmaa ei ole, koska jokainen pyyntö tarkistetaan erikseen.
- Evästeet eivät ole luotettava tunnistautumistapa sivustorajan yli. Selaimet estävät kolmannen osapuolen evästeitä, joten toisella sivustolla olevaan palvelimeen avattu yhteys voi jäädä kirjautumattomaksi, vaikka käyttäjä olisi kirjautunut. Lähetä tunniste ensimmäisessä viestissä — se on myös paras suoja CSWSH:ta vastaan, koska hyökkääjän sivusto ei saa tunnistetta käsiinsä.
- Tarkista oikeudet joka viestiltä. Avattu yhteys ei ole lupa kaikkeen. Jos yhteydessä otetaan vastaan komentoja, jokaisen kohdalla on tarkistettava, saako käyttäjä tehdä juuri sen — samalla tavalla kuin HTTP-pyynnössä tarkistetaan, onko varaus oma.
- Viestit ovat käyttäjän syötettä. Niitä koskee sama tarkistusvelvollisuus kuin lomakkeita ja JSON-pyyntöjä. Aseta viestille kokoraja (OWASP ehdottaa enintään 64 kt) ja tarkista rakenne ennen käyttöä.
- Rajoita yhteyksiä ja viestejä. Avoin yhteys on uusi hyökkäyspinta: rajoita yhteyksien määrää käyttäjää kohden, aseta viesteille määrärajoitus, katkaise joutokäynnissä olevat yhteydet ja tunnista kuolleet yhteydet ping- ja pong-kehyksillä.
- Lokit eivät näe viestejä. Palvelimen tavallinen lokirivi syntyy vain kättelystä, joten sen jälkeinen liikenne ei näy missään. Kirjaa itse yhteyden avaus ja sulkeminen, tunnistautumisvirheet ja rajojen ylitykset — mutta älä viestien sisältöjä äläkä tunnisteita.
- Välityspalvelimen on osattava protokollan vaihto.
Upgrade- jaConnection-otsakkeiden on välityttävä sellaisinaan, ja lukuaikakatkaisun on oltava riittävän pitkä. Tämä on sukua SSE:n puskurointiongelmalle.
Toistuva pyyntö, SSE vai WebSocket?
| Yksittäinen pyyntö | Toistuva pyyntö | SSE | WebSocket | |
|---|---|---|---|---|
| Suunta | selain kysyy | selain kysyy | palvelin lähettää | molemmat |
| Protokolla | HTTP | HTTP | HTTP | oma, HTTP:llä aloitettu |
| Viive | — | pyyntövälin verran | ei viivettä | ei viivettä |
| Uudelleenyhteys | ei tarvita | ei tarvita | automaattinen | tehtävä itse tai kirjastolla, kuten Socket.IO |
| Palvelimen resurssit | vapautuu heti | vapautuu heti | yhteys varattu | yhteys varattu |
| Sopii | kertaluonteiseen hakuun | harvoin muuttuvaan tietoon | pä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.
- Tapahtumapalvelun hallintasivu — kirjaudu ja lisää tai poista varauksia
- Infonäyttö — päivittyy heti. Yläreunan linkeistä saman sivun voi vaihtaa käyttämään WebSocket-yhteyttä SSE:n sijaan; palvelin tarjoaa molemmat.
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:
- varaus-sse.zip — tapahtumapalvelu, sekä Flask- että Quart-versiona; Quart-versiossa myös WebSocket-toteutus
- infonaytto-sse.zip — infonäyttö, joka kuuntelee tapahtumavirtaa SSE:llä tai WebSocketilla
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.
Käyttäjien kommentit