Tervezői döntések a Unixban

A Unix/Linux szerverek üzemeltetése wikiből
A lap korábbi változatát látod, amilyen KornAndras (vitalap | szerkesztései) 2014. január 4., 14:55-kor történt szerkesztése után volt.

A Unixban számos megoldás nagyon régi. Ezeknek egy része nagyszerű, más részük viszont mai szemmel nézve furcsa vagy egyenesen rossz.

Ebben a szócikkben ezek közül az ötletek közül járunk néhányat körül, leginkább Neil Brown Ghosts of Unix Past cikksorozata nyomán.

Tartalomjegyzék

1 A fájldeszkriptor, mint elv

Ritchie és Thomson "The Unix Time-Sharing System" című cikkükben azt vallják: a Unix annak köszönheti sikerét, hogy néhány jó ötletet maximálisan kihasznált. Ezek közül néhány:

  1. Lényegében azonos fájl-I/O, eszközkezelés és processzközi I/O.
  2. Hierarchikus fájlrendszer, egyenként felcsatolható és lecsatolható kötetekkel.

"Unix alatt minden file"? Nem igazán, mivel egy socket és egy fifo azért másképp viselkedik; de (szinte) mindennek lehet fájldeszkriptora.

Így:

  • ha egy programot nem érdekli, hogy sockettel, fifoval vagy közönséges fájllal van dolga, nem kell törődnie vele;
  • ha viszont fel akarja használni pl. egy örökölt socket jellegzetességeit, megteheti.

Mára a kernel és a processzek közötti kommunikációban nagyon sok helyen találkozunk fájldeszkriptorokkal ("fájlleírókkal"); pl. az inotify mechanizmus, amelynek a segítségével programok fájlok vagy könyvtárak megváltozásáról értesülhetnek aktív polling nélkül, szintén fájldeszkriptorokon át továbbítja az eseményeket.

Viszont pl. a hagyományos SysV IPC-mechanizmusok nem fájldeszkriptorokra épülnek; így pl. nem tudunk olyan (egyszálú) programot írni, ami egyszerre vár arra, hogy egy (SysV) szemafor átbillenjen VAGY hogy egy pipe-on adat érkezzen, mert a szemafor nem fájldeszkriptor, ezért nem működik rajta a select() rendszerhívás.

Az is hasznos lenne, ha userspace programok is megvalósíthatnának fájldeszkriptor-szemantikával működő objektumokat (pl. egy GUIban egy ablakhoz tartozhatna egy FD, amiből aztán read() hívással ki lehetne olvasni a felhasználó válaszát).

  • Érdekesség: unix domain socketen át két processz fájldeszkriptorokat is adhat át egymásnak.

2 Hierarchikus fájlrendszer

  • Ez akkoriban újdonság volt; a kortárs oprendszerek közül a CP/M pl. teljesen lapos fájlrendszert használt, más rendszereken pedig rögzítve volt a hierarchiaszintek száma.
  • Azáltal, hogy a kötetek önállóan le- és felcsatolhatók az egyetlen hierarchia tetszőleges pontjain, egységes namespace, egyetlen közös hierarchikus rendszer jött létre.
    • A DOS/Windows pl. nem használja(-ta) ki teljesen a hierarchikus fájlrendszer ideáját, hiszen nem egy fát, hanem egy erdőt (fizikai eszközönként egy fával) valósít meg.
  • Ebben a közös namespace-ben aztán virtuális fájlrendszerek is megjelenhettek, bennük olyan objektumokkal, amik nem is valódi fájlok (pl. procfs, sysfs).
    • Viszont ezeket is a korábbi, megszokott, egységes módon lehet elérni és a neveik is teljesen illeszkednek a korábbi rendszerbe.

Sajnos a device node-ok tekintetében ez a hierarchikus elrendezés Unix alatt sem jut teljesen érvényre. Gondoljuk végig, hogy valójában, implicit módon, Linux alatt is jelen van itt egy hierarchia:

  1. szint: blokk- vagy karaktereszközről van szó?
  2. szint: major number: nagyjából azt a drivert vagy kernel-alrendszert azonosítja, amely meghajtja az adott eszközt.
  3. szint: minor number: az azonos jellegű eszközök közül egy konkrét példányt azonosít;
    • ez valójában lehet több szint is, ha a driver almezőkre osztja a minor number-mezőt; pl. diszk sorszáma, azon belül partíció sorszáma.
    • Ezért nem tudunk Linux alatt 15-nél több partíciót kezelni egyetlen merevlemezen.

Csakhogy ez a hierarchia a fájlrendszerben valójában nem jelenik meg. Nem kérhetünk pl. "könyvtárlistát" az összes soros portról, vagy az összes merevlemezről (nem, a /dev/[hs]d? nem biztos, hogy az összes). A device node-ok lényegében "kilapítják" a hierarchiát azzal, hogy egyetlen könyvtárban, a /dev-ben, egymás mellett található meg több, a (sajnos virtuális) hierarchia különböző szintjeihez tartozó bejegyzés is. Az, hogy még valódi alkönyvtárak is vannak itt, inkább csak az összevisszaságot növeli.

Az egybájtos major és minor számok már évekkel ezelőtt szűknek bizonyultak, de a bővítés szerencsére viszonylag egyszerű volt.

A blokkeszközök és karaktereszközök merev, kétpólusú szétválasztása viszont ahhoz vezetett, hogy a hálózati interfészek nem kaptak helyet a /dev alatt (hiszen egyik kategóriába sem illettek igazán); így ezek most "speciálisak", és csak külön függvényekkel tudjuk őket listázni és kezelni. Ha eleve lett volna egy /dev/block és egy /dev/char, simán lehetett volna csinálni /dev/net-et is.

Hasonlóképpen nem érvényesül maximálisan a hierarchikus fájlrendszer elve:

  • A hálózati socketeknél; pedig a listen() rendszerhívást akár helyettesíthetné egy olyasmi is, hogy megnyitjuk olvasásra a /dev/net/ip/tcp/0.0.0.0/80-at.
  • A fennálló hálózati kapcsolatoknál (amiket pl. a netstattal tudunk kilistázni).
  • A processzeknél (pl. nem tudunk "könyvtárlistát" kérni egy adott felhasználó összes processzéről).
  • A SysV IPC-nek természetesen saját namespace-e van (mindhárom mechanizmusnak külön!).

3 Fájlnevek

A Unix kernel számára a fájlnév egy bájtfolyam. Nem szerepelhet benne / karakter (pontosabban: az a byte, amely az ASCII szerint ennek a karakternek felel meg), mert az a könyvtárhierarchia szintjeit választja el egymástól, és nem szerepelhet a 0 byte sem, mert az jelzi a sztring végét.

Ez nagyszerű szabadságot ad a felhasználónak, mert szinte azt ír a fájlnevekbe, amit csak akar; a kernel nem korlátozza feleslegesen és önkényesen, és ez jó, igaz?

Éppen ellenkezőleg. Feleslegesen nagy a szabadság; messze a legtöbb esetben nincs szükség rá (hiszen nem akarunk pl. újsort tartalmazó fájlneveket használni), mégis az összes programot fel kell(ene) készíteni a váratlan karaktereket tartalmazó fájlnevek helyes kezelésére. A kézenfekvőnek látszó megoldások (pl. cat *) többsége hibás, mert nem kezeli helyesen a szokatlan fájlneveket. Márpedig ha egy feladatcsoport nyilvánvaló és egyszerű megoldásai mind hibásak és csak bonyolult módszerekkel lehet helyesen megoldani az egyszerű feladatokat is, akkor ott valami gond van.

David A. Wheeler szerint egy jól megtervezett rendszerben az egyszerű feladatok megoldása egyszerű, és a kézenfekvő egyszerű megoldások egyben helyesek is. Wheeler ezt az elvet úgy nevezi, hogy "no sharp edges", és a unixos fájlrendszerek ezt az elvet bizony nem követik. Az a feltételezés, hogy a fájlnevek "normálisak" vagy "ésszerűek" (holott ezt az operációs rendszer nem garantálja), sok esetben biztonsági hiányosságokat eredményez.

3.1 Fájlnevek reprezentációja, kódlapok

Azáltal, hogy a kernel számára a fájlnév csak egy bájtfolyam, nincs egy meghatározott, szabványos módja a nem-ASCII fájlnevek reprezentációjának. A felhasználói programok olyan kódolást használnak, amilyet csak akarnak.

Ez különös problémákhoz vezethet akkor, ha pl. a kódolás nem garantálja, hogy nem fog sem 0-ás, sem 47-es ("/") bájtot előállítani több bájton ábrázolt karakter belsejében sem: sok fájlnévvel nincs gond, de néhány esetleg teljesen mást csinál, mint amit várnánk.

További gond, hogy az egyik felhasználói program által az X kódrendszer alapján elnevezett fájl egy másik felhasználói programban, amely esetleg az Y kódrendszert használja, teljesen másképp nézhet ki. Ha mondjuk egy kollégánk latin1 kódolással hozza létre a WéîrdÑàmë nevű fájlt, mi pedig UTF8-as locale-t használunk, nálunk helytelenül fog megjelenni a fájlnév és fordítva. Ez a probléma több rétegben is megjelenhet: pl. ha ékezetes nevű képra hivatkozunk HTML-ben stb.

Ezek a gondok nagyjából elkerülhetők lettek volna, ha a kernel API egy meghatározott kódrendszerben kérné ill. adná vissza a fájlneveket; pl. UTF8-ban. (Persze tudjuk, hogy az UTF8 még sehol sem volt a Unix születésekor.)

3.2 Különös karakterek a fájlnevekben

Azáltal, hogy egy fájlnévben "bármi" lehet, nagyon nehézzé válik olyan programot írni, ami tetszőleges fájlnevekkel elboldogul és helyesen (vagy akár csak biztonságosan) működik.

3.2.1 Kötőjellel kezdődő fájlnév

Sok program kapcsolóként fogja értelmezni a kötőjellel kezdődő fájlnevet. Pl. ha abban a könyvtárban, ahol kiadjuk a cat * parancsot, van egy -n nevű fájl, ÉS GNU cat-et használunk, akkor a kimenetben meg lesznek számozva a sorok, de a -n nevű fájl tartalma nem jelenik meg.

Emiatt elvileg mindig minden ismeretlen fájlnevet ./fájlnév formában kellene átadnunk minden programnak, tehát cat * helyett cat ./*-ot kellene írnunk. A valóságban létező scriptek döntő része erre nem figyel oda, és a "principle of least surprise"-nak is ellentmond, hogy a programok parancssorában átadott fájlnevek esetleg nem fájlnévként, hanem kapcsolóként értelmeződnek.

Nem gyakran van szükség kötőjellel kezdődő nevű fájlra, úgyhogy lehet, hogy értelmes lenne megtiltani, hogy a fájlnevek kötőjellel kezdődjenek.

3.2.2 Backslash-t tartalmazó, vagy arra végződő fájlnév

Ha shell-parancssorokat generálunk, vagy pl. arra számítunk, hogy egy while read ciklussal be tudunk olvasni soronként egy fájlnevet, csúf meglepetések érhetnek; l. a A find használatáról szóló fejezetet.

3.2.3 Shell metakaraktert tartalmazó fájlnév

Az alábbi parancs:

cp $(find . -group 42) /export

a legtöbb shellben nem azt csinálja, amit szeretnénk, ha van csillagot, kérdőjelet vagy pl. szögletes zárójelet tartalmazó fájlnevünk.

3.2.4 Újsort tartalmazó fájlnév

Az újsor karaktert tartalmazó fájlnevek helyes feldolgozása shellscriptben szintén nem egyszerű.

3.2.5 Vezérlőkaraktert tartalmazó fájlnév

A fájlnevek szerepe döntően az, hogy az emberek számára megjegyezhető, megjeleníthető címkéket társítson adatokhoz. Nehezen látható be, miért van szükség arra, hogy fájlnevek mondjuk a DEL vagy a backspace karaktert tartalmazhassák. Ezeknek az egyértelmű megjelenítése és a bevitele is nehézkes.

3.3 Fájlnevek megjelenítése

Egy ismeretlen fájlnevet nem írhatunk ki csak úgy a konzolra, mert tartalmazhat olyan escape-szekvenciát, amit a terminál értelmez. A rosszindulatú fájlnév egyike lehet a legrégibb cross-site scripting támadásoknak.

De általános esetben még ettől eltekintve sem tudunk kiírni egy fájlnevet, hiszen fogalmunk sincs, milyen kódlappal készült.

4 Az open() rendszerhívás

Nem egyértelmű sikertörténet.

4.1 O_TRUNC

Egyike a kevésbé jó kezdeti ötleteknek az volt, hogy a fájlok "végének levágását" (truncate művelet) az open() rendszerhívással oldották meg. Ha O_TRUNC kapcsolóval hívtuk meg az opent, megnyitotta a fájlt és rögtön az elejénél elvágta (tehát akármekkora is volt eredetileg, az open() után nulla méretű lett). Arra hosszú-hosszú évekig nem volt mód, hogy ne rögtön az elejénél vágjunk el egy fájlt, vagy hogy egy már nyitott fájlt vágjunk el anélkül, hogy újra megnyitnánk.

Csak később, a BSD Unixban jelent meg az ftruncate() rendszerhívás, ami az open()-től elválasztotta a levágást, mint funkciót.

Vegyük észre: az volt a gond, hogy az open() hívásra olyasmit bíztak, ami igazából nem függött össze szorosan az elsődleges funkciójával. Nem tartották be a Unix-filozófiának azt az elvét, hogy "egyvalami egy dolgot csináljon, de azt jól".

4.2 O_CLOEXEC

Másfelől viszont időközben újabb igény jelentkezett arra, hogy bővüljön az open() funkcióinak köre: bevezették az O_CLOEXEC flaget. exec() rendszerhívás végrehajtása során az ezzel a flaggel megnyitott fájldeszkriptort a kernel automatikusan lezárja az új bináris betöltése előtt. Erre korábban is volt lehetőség; az fcntl() hívás megfelelő paraméterezésével ezt a flaget utólag be lehetett állítani egy nyitott fájlleírón. Miért kellett ezt az open() hívásba beemelni?

Röviden: a threadek miatt. Ha egy többszálú processz egyik threadje megnyit egy fájlt, a fájldeszkriptor automatikusan megjelenik a többi szál állapotterében is. Ha a többi szál közül az egyik éppen egy exec() hívás előtt tart, versenyhelyzet alakul ki: előfordulhat, hogy az exec() előbb fut le, mint a fájlt megnyitó szál fcntl() hívása, így az exec()-kel létrehozott új processz örökölheti az imént létrejött fájlleírót, akkor is, ha ezt nem akartuk.

Hogy kellett volna ezt jól csinálni?

Például úgy, hogy alapértelmezés szerint nem öröklődnek exec() híváson át a fájldeszkriptorok, hanem explicit módon be kell ezt állítani rajtuk fcntl()-lel. Ha annak idején így csinálták volna, most nem kellett volna az amúgy sem kifejezetten letisztult szemantikájú open() hívást újabb funkcióval bővíteni. Persze akkor még senki nem tudta, hogy majd lesznek threadek, amiknek közös lesz a fájlleíró-táblája, most pedig már lehetetlen az open() alapértelmezését megváltoztatni, mert majdnem minden program elromlana.

4.3 O_NONBLOCK

További sokfunkciós open()-kapcsoló az O_NONBLOCK. Hatásai (Linuxon):

  • A fájllal kapcsolatos olvasási és írási műveletek nem blokkolódnak, ha nincs elég olvasható adat, vagy ha nem sikerül kiírni az összeset, amit szerettünk volna, hanem azonnal visszatérnek. Ez az elsődleges hatás.
  • Maga az open() sem blokkolódik. Ez helyzettől függően mást-mást jelent.
    • Named pipe írásra való megnyitása esetén, ha még nincs olvasó folyamat, általában blokkolódnánk; O_NONBLOCK esetén a hívás hibát ad.
    • Ha olvasásra nyitjuk meg a named pipe-ot, és még nincs író folyamat, általában blokkolódnánk; O_NONBLOCK esetén a hívás sikerül és azonnal visszatér, és a későbbi olvasások adnak hibát, ha nincs mit olvasni.
    • Ha cserélhető lemezes meghajtót (pl. CD-ROMot) nyitottunk meg, a hívás mindenképpen sikeres, de valójában nem nyúl a lemezhez. Így akkor is megnyithatjuk a CD-ROMot, mint eszközt, ha nincs is benne lemez, és a nyitott fájldeszkriptoron keresztül pl. utasíthatjuk, hogy tolja ki vagy húzza be a tálcát. Olvasni viszont nem fogunk tudni ebből a fájlleíróból, akkor sem, ha van lemez a meghajtóban.

Ebből talán látszik, hogy az open() két elkülöníthető funkciót is megvalósít:

  1. Fájlleírót társít valamihez, és
  2. ezt a fájlleírót "inicializálja", felkészíti arra, hogy I/O történjen rajta.

A CD-meghajtó megnyitása az első funkcióhoz tartozik; a CD jelenlétének ellenőrzése a másodikhoz. Látható, hogy lehet értelme csak az elsőt kérni a második nélkül (hiszen különben nem tudnánk kinyitni a CD-tálcát, ha nincs bent lemez, mert az open() hibát adna).

Persze a valóságban általában valóban mindkét funkciót igénybe akarjuk venni, amikor megnyitunk egy fájlt, tehát van létjogosultsága egy olyan rendszerhívásnak, amely mindkettőt megvalósítja; de a két funkció (szinte) szétválaszthatatlan összekapcsolása nevezhető tervezési hibának.

Más esetben is lenne értelme ennek a szétválasztásnak. Jelenleg, ha egy program meg akarja nézni, milyen típusú az a "fájl", amit meg fog nyitni (könyvtár, fájl, symlink, device, fifo stb.), akkor versenyhelyzetbe kerül: egy stat() (vagy lstat()) hívással megnézheti, milyen fájlról van szó, de csak a következő művelettel tudja megnyitni, és közben a fájl kicserélődhetett valami másra. Ha lehetséges lenne megnyitni anélkül, hogy bármilyen műveletet végzünk a fájlon (l. pl. O_TRUNC, ugye), és az így létrehozott fájlleíró jellegét ellenőrizhetnénk az inicializálása előtt, ez a versenyhelyzet elkerülhető lenne.

Ez a funkció-szétválasztás a socketeknél egyébként megvan: az újonnan létrehozott socketeket általában külön inicializálni kell (pl. a bind() hívással), mielőtt bármire használhatnánk őket.

Egyébként a non-blocking jelleg nem is a fájldeszkriptor tulajdonsága kellene, hogy legyen, hanem az olvasásé és írásé. Alighanem az lenne a tiszta megoldás, ha az open()-nek átadott O_NONBLOCK kapcsoló csak magára az openre vonatkozna (az nem blokkolódna), és minden írásnál és olvasásnál külön adhatnánk meg, szeretnénk-e, ha a hívás blokkolódna addig, amíg teljes egészében végre nem lehet hajtani. Erre jelenleg nincs mód, így például nem tudunk ugyanazon a fájlleírón pl. az egyik szálon blokkolva írni, a másikon pedig blokkolódás veszélye nélkül olvasni.

Azáltal, hogy a blokkolódással összefüggő kapcsoló nem a read() és a write() paramétere, a programkód olvasása is nehezebb, mert nem látszik ránézésre, hogy egy I/O művelet blokkolódhat-e (meg kell keresni azt a helyet, ahol a fájlt megnyitottuk; a fájlleírókkal dolgozó függvényeket esetleg mindkét esetre külön-külön fel kell készíteni stb.).

4.4 O_CREAT

Az O_CREAT flag hatására az open()-nek átadott fájlt megnyitása során létrehozzuk, ha még nem létezett. Ha létezett, akkor pedig megnyitjuk. Ez a viselkedés a /tmp-vel kapcsolatos symlink-támadások alapja.

Szükség van-e erre a viselkedésre?

Persze. Ha pl. egy program a saját beállításait menti ki, mindegy neki, létezik-e már az a fájl, amibe írni szeretne; ha nem létezik, jöjjön szépen létre, ha pedig létezik, íródjon felül. Ha megnyitás előtt letörölnénk, vagy megnéznénk, létezik-e, és aszerint nyitnánk meg O_CREAT-tal vagy anélkül, az versenyhelyzethez és időnként hibás működéshez vezetne.

HA azt szeretnénk, hogy tényleg mi hozzuk létre a fájlt (tehát már létezőt ne nyissunk meg), akkor az O_CREAT mellé meg kell adnunk az O_EXCL kapcsolót is. Ha a megadott néven már létezik könyvtárbejegyzés, az open() hibát ad.

Ha nemlétező fájlra mutató symlinket nyitunk meg, az O_CREAT O_EXCL nélkül létrehozza a célfájlt; az O_EXCL kapcsolóval kombinálva viszont nem, mert a megnyitandó nevű symlink létezése miatt a hívás hibával tér vissza.

Így viszont lehetetlen megoldani, hogy egy olyan program, amely létező fájlt nem akar felülírni, symlinket követve hozzon létre új fájlt. Az a baj, hogy az O_EXCL két funkciót kombinál: kikapcsolja a symlinkek követését ÉS előírja, hogy a megnyitandó fájl még nem létezhet. (A nem túl hordozható O_NOFOLLOW opcióval a symlinkek követése külön is kikapcsolható, de az O_EXCL funkcióját nem kérhetjük O_NOFOLLOW nélkül.)

5 Signalok

Az eredeti Unixban minden signal handlert csak egyszer lehetett meghívni; ha egyszer lefutott, újra kellett regisztrálni, hogy a következő ugyanilyen signalt is ugyanaz a függvény kezelje. Erre az volt a szokásos megoldás, hogy a signal handler újraregisztrálta saját magát. Ez viszont versenyhelyzetet eredményezett, mert befuthatott a második signal azután, hogy a signal handler elindult, de azelőtt, hogy újraregisztrálta volna magát a handler, így pedig elveszhetett a második signal.

Ezt a problémát a Unix továbbfejlesztői két mechanizmussal próbálták megoldani: egyrészt a signal handlerek most már lehetnek állandók, másrészt pedig lehetőség van a signalok blokkolására, amíg az előző signal feldolgozása folyik.

Szintén érdekes kérdés volt, mi történjen, ha olyankor fut be signal, amikor a processz éppen rendszerhívásban áll (és pl. vár valamire), nem pedig userspace kódot hajt végre. A következő lehetőségek jönnek szóba:

  • Várjuk meg a signal kézbesítésével, amíg visszatér a rendszerhívás.
  • Szakítsuk meg a rendszerhívást (térjen vissza hibával a signal feldolgozása előtt vagy közvetlenül utána).
  • Kényszerítsük ki a rendszerhívás azonnali visszatérését (úgy, hogy esetleg csak egy részét intézte el a kérésnek, pl. a kiírandó puffernek csak a felét írta ki).
  • Függesszük fel a rendszerhívás futását, majd indítsuk újra a signal feldolgozása után.

Melyik a helyes?

Mikor melyik. :)

A SysV Unixban tovább bővítették a signalrendszert: megjelent a sigaction() rendszerhívás, amivel elég finoman szabályozható, mi történjen a processzünkben egyes signalok beérkezésekor. A teljesség igénye nélkül, ilyeneket lehet beállítani:

  • SIGCHLD beállítása esetén: kapjunk-e értesítést, ha a gyermekfolyamatunk felfüggesztődik vagy újra elindul.
  • SIGCHLD beállítása esetén: a kilépő gyermekfolyamatok tűnjenek el, ne keletkezzenek zombik akkor sem, ha nem olvassuk ki a visszatérési értéküket.
  • Ha handlert állítunk be: ne tiltsa le a kernel az azonos típusú signalok fogadását a signal handler futása közben.
  • Ha handlert állítunk be: a handler csak az első signalt kezelje, utána álljon vissza a signal "dispositionje" (l. Unix-alapok) az alapértelmezésre.
  • Ha handlert állítunk be: kérhetjük, hogy az újraindítható, a signal által félbeszakított rendszerhívások induljanak újra a signal feldolgozása után.
  • Ha handlert állítunk be: kérhetjük, hogy a handler a signal sorszámán kívül kapjon adatokat arról is, ki küldte a signalt, ill. milyen körülmények között keletkezett a signal; pl.:
    • SIGCHLD esetén megtudhatjuk, milyen visszatérési értékkel lépett ki a gyermekfolyamat, valamint hogy mennyi processzoridőt használt el;
    • SIGILL esetén azt, hogy pontosan mi volt érvénytelen: opkód, operandus, koprocesszor-művelet stb.;
    • SIGFPE esetén azt, pontosan milyen lebegőpontos hiba lépett fel: nullával osztás, túlcsordulás, alulcsordulás stb.
    • Melyik processz küldte a signalt? Milyen UID-val futott ez a processz?
    • Stb.

További érdekesség: van olyan Unix, amin nem SIGCHLD, hanem SIGCLD van. Ezeknek nemcsak a neve más, hanem a viselkedése is: SIGCHLD eseményenként egyszer jön, a SIGCLD-k pedig folyamatosan érkeznek, amíg van olyan gyermekfolyamatunk, aminek kiolvashatnánk a visszatérési értékét, de még nem tettük. Ez egy újabb próbálkozás lehetett a signalkézbesítés "megbízhatóvá" tételére.

Tovább bonyolítják a helyzetet a "realtime" signalok; ezeket a Unix-alapoknál már megbeszéltük, úgyhogy csak ismétlésképpen:

  • a hagyományos signalokból típusonként csak egy állhat sorba egy processznél, mert egy bit jelzi, hogy érkezett ilyen;
  • a realtime signaloknak valódi várakozási sora van, tehát ugyanolyanból több is sorbaállhat.
  • A realtime signalnak lehet adatot tartalmazó "melléklete".

Ez persze új megbízhatósági problémákat vet fel, hiszen nyilván csak véges sok signalt tudunk sorbaállítani; mennyi legyen a limit, és mi történjen a túllépése esetén? (Emlékezzünk vissza, hogy itt még a processzek erőforráskorlátainak is szerepe van/lehet.)

Látható, hogy a signalok eredeti koncepciója elégtelennek bizonyult, és a sok bővítés és változtatás nem általánossá és letisztulttá, hanem inkább összetetté tette a rendszert.

Bizonyos signalok, pl. a SIGSEGV, kezelésére az eredeti elképzelés is alkalmas volt:

  • A program futását mindenképpen meg kell szakítani, hiszen "érvénytelen műveletet hajtott végre"; a folytatás kizárt.
  • Ilyen signal rendszerhívás végrehajtása közben nem keletkezhet (kivéve többszálú processznél, de ott meg nem kell miatta megszakítani a rendszerhívást).
  • A signal handler reaktiválása nem olyan fontos; ha a SIGSEGV feldolgozása közben újabb SIGSEGV-et kapunk, aligha van értelme újra meghívni ugyanazt a handlert.

A többi, nem kritikus eseményt jelző, azonnali választ igénylő signalt valószínűleg nem is kellett volna a jelenlegi formájában bevezetni. Hogy mit lehetett volna csinálni helyette? Pl. azt, amit 2007-ben a Linuxban: bevezetni a signalfd() rendszerhívást. Ez egy kísérlet arra, hogy egyrészt univerzálisabbá tegyék a fájlleírók használatát, másrészt szinkronná váljon a signalok feldolgozása. A signalfd() egy olyan fájlleíróval tér vissza, amelyből akkor tudunk olvasni, ha van sorbaálló signalunk. Így már tudunk egyszerre várni signalra és arra, hogy adat váljon elérhetővé egy fájldeszkriptoron (hiszen a signalokra is fájldeszkriptor segítségével várunk). Ez a megoldás ígéretesnek tűnik, de természetesen nem hordozható.

Ha ilyesmit hordozhatóan szeretnénk csinálni, kerülőutat kell választanunk: nyissunk egy pipe-ot, és a signal handlerrel írjunk bele, a főprogramban pedig arra várjunk, hogy a pipe-hoz tartozó fájlleíró olvashatóvá váljon.

6 A fájlok jogosultságrendszere

Az első Unixnak 32kb memóriával rendelkező gépen kellett futnia. Nem csoda, hogy vonzónak tűnt egy olyan jogosultságrendszer, ami fájlonként hat bájtban viszonylag finom hozzáférés-szabályozást tesz lehetővé.

Sajnos azonban ez az egyszerű rendszer egyszerre túl szűk és túl tág. Például igazából felesleges, hogy minden fájlnak szükségszerűen van saját tulajdonosa és főként csoport-tulajdonosa; az esetek legnagyobb részében egy könyvtárban található összes fájl tulajdonosa ugyanaz. A tulajdonos-mezők talán lehettek volna opcionálisak. (Az Andrew hálózati fájlrendszerben pl. csak a könyvtáraknak van tulajdonosa, a fájloknak külön nincs, mégis jól használható.)

Mondhatnánk, hogy fájlonként csak hat bájt, úgyhogy kinek fáj ez? A gond azonban az, hogy ha 65536-nál többféle tulajdonost, vagy négynél több jogosultságbitet szeretnénk, a fix struktúra miatt nehézkes a bővítés, és egy nagyobb fix struktúra tárolása érezhetően költségesebb, ráadásul az esetek nagy részében felesleges (hiszen általában megteszi a hagyományos hat bájt).

Emellett a hagyományos modell nem is túl intuitív, amit az is mutat, hogy sokan a mai napig sem értették meg. A Web tele van olyan HOWTO-kkal, sőt, "hivatalos" dokumentációkkal, amelyekben 0777-es vagy 0666-os jogokat állíttatnak be valamin, "hogy biztos jó legyen".

A túlzott egyszerűségből fakadó nehézségeket ismerjük: ezek miatt volt szükség előbb a Posix ACL-ekre, majd az NFSv4 ACL-ekre. Az is fontos Unix-elv, hogy "az egyszerű dolgok legyenek egyszerűek, a bonyolultak pedig lehetségesek" - ezt a hagyományos jogosultságrendszer nem teljesíti.

Az ACL-rendszerek mellett azonban egy másik bővítés is a jogosultságrendszer túlzott egyszerűségéből adódó problémát (nevezetesen hogy egy fájlhoz csak egyetlen csoportnak lehet az alapértelmezettől eltérő hozzáférése) próbálta orvosolni. Ez a bővítés pedig nem más, mint a kiegészítő csoporttagságok rendszere (supplementary group memberships). Sajnos a BSD-ben, amelyben először megjelent, egy processz eredetileg legfeljebb 16 csoporttagsággal rendelkezhetett. Ezt a korlátot az NFS is átvette és ez máig problémákat okoz.

Előbb a Posix ACL-ek, majd az NTFS ACL-eken alapuló NFSv4 ACL-ek rendszere a fájlrendszer oldalán is megpróbálta, megpróbálja megszüntetni azt a korlátot, hogy csak egyetlen csoportra vonatkozóan adhatunk speciális rendelkezéseket, de a Posix ACL-t sosem szabványosították, az NFSv4 ACL-ek pedig nagy kifejezőerejűek ugyan, de elég bonyolultak. Lehet, hogy fájlrendszerbeli jogosultságkezelés problémájának valóban nincs jó és elegáns megoldása? Vagy csak nem találtuk meg?

Talán meglepő, de kapcsolódik ehhez a témához a mount namespace fogalma is. Linux alatt ugyanis ugyanazt a fájlrendszert több mount namespace-be is bemountolhatjuk, különböző opciókkal, és akár minden processznek lehet saját mount namespace-e. Így pl. elvileg szintén megvalósítható hozzáférésvezérlés (legalábbis mountpointok szintjén). Van, aki szerint ez intuitívabb a unixos jogosultságbiteknél; szerintem nem. :) Filozófiai-ideológiai értelemben mellette szól, hogy a már amúgy is létező hierarchikus fájlrendszert, mint mechanizmust használja fel; viszont azáltal, hogy külön namespace-ek jönnek létre, megint erdőnk lesz fa helyett.

Egész hierarchiákra kiterjedő, tehát könyvtárról alkönyvátrra és fájlra öröklődő jogosultságrendszer a Unixban sajnos nincs, pedig lennének nyilvánvaló előnyei. Bezzeg a webszerverek megoldották: ha valahol elhelyezünk egy .htaccesst, a benne foglaltak az egész könyvtárstruktúrára vonatkoznak.

A jogosultságrendszer viszont nem olyan, mint a signalok kezelése: nem lehet a régi mellett (a signalfd() mintájára) újat bevezetni és a régit is megtartani. Ezért kellett "unixjogosultság-emuláció" a Posix ACL-be; az NFSv4 ACL-ek esetében pedig még bonyolultabb elérni, hogy a régi alkalmazások által látott jogosultságbiteknek bármi köze legyen az ACL által reprezentált jogosultságokhoz. A kompatibilitási követelmény miatt nem várható, hogy a közeljövőben sikerülne valami szépre és rugalmasra cserélni a jogosultságrendszert.

7 A setuid-mechanizmus

A setuid-/setgid-mechanizmus egyike azoknak a unixos megoldásoknak, amelyek önmagukban jól működnek, de a rendszer más elemeire nagyon nagy terheket rónak. (Érdekesség: a setuid-bitet Dennis Ritchie még szabadalmaztatta is, de a szabadalom időközben közkinccsé vált).

Maga ez a két bit, és a szemantikájuk, rendkívül egyszerű és kiválóan teljesítenek kb. 40 éve. Mégsincs minden rendben. Ugyanis a setuidos programoknak egyszerre kell felhasználói eszközként és emelt jogosultságokkal rendelkező szolgáltatásként viselkedniük (megakadályozva, hogy a felhasználók ezeket a jogosultságokat a program által nyújtott szolgáltatástól különböző célra használják). Ez annyira nehézzé teszi a setuidos programok fejlesztését, hogy nagyon gyakoriak a hibák.

Rengeteg mindent kell szokatlan nézőpontból végiggondolni setuidos program írása közben. Ott vannak kapásból a környezeti változók, amelyeket a program a szülőjétől örököl. Ezeket teljes egészükben az a felhasználó ellenőrzi, aki a programot futtatja. Sajnos számos változónak hatása lehet a setuidos program (vagy az általa használt függvénykönyvtárak) viselkedésére. Nehéz kizárni, hogy ezeknek a hatásoknak biztonsági vonatkozásai legyenek: pl. ha a PATH-t állítja el a felhasználó, és a setuidos program külső binárist hív meg abszolút elérési út nélkül, máris fennáll a veszélye annak, hogy a felhasználó tetszőleges kódot futtathat a setuidos userként. Más változók hatása ennél kevésbé nyilvánvaló, de ugyanilyen veszélyes lehet; például még 2010. októberében is találtak olyan hibát (a glibc-ben), ami alkalmasan választott környezeti változó és egy setuidos program segítségével tetszőleges kód setuidos userkénti futtatására adott lehetőséget. Valószínű, hogy még tucatjával vannak ilyen, eddig fel nem fedezett hibák.

7.1 Setuidos scriptek

Tovább bonyolódik a helyzet, ha scripteken is értelmezni szeretnénk a setuid bitet. A script futtatása ugyanis úgy történik, hogy a kernel a script első sorában megadott interpretert indítja el úgy, hogy az első argumentum a script neve. Ezek után, ha az interpreter pl. a /bin/sh, megtehetjük a következőt:

$ ln -s /usr/sbin/setuidscript ./-i
$ PATH=.
$ -i

A kernel tehát azt a parancssort rakja majd össze, hogy

/bin/sh -i

-- csakhogy az sh a -i kapcsoló hatására interaktív üzemmódba vált.

Ennél kevésbé triviális exploitok is lehetségesek, de persze mindegyiknek a kivédésére léteznek módszerek; a perlhez pl. jár(t) a speciálisan felokosított suidperl interpreter, amely setuidos perlscriptek biztonságos futtatását próbálja lehetővé tenni.

Számunkra itt most az a lényeg, hogy a setuid bit önmagában ugyan nagyon egyszerű és nagyszerű, vagy legalábbis annak tűnik, de más rendszereket nagyon elbonyolít, ezért mégsem nevezhető jó megoldásnak.

7.2 A setuid-bit és a signalok

A ping program setuid root (mert raw socketeket használ). Ha elindítjuk a terminálunkon, jogosan várjuk el, hogy ctrl-c-vel megszakíthassuk, ctrl-z-vel felfüggeszthessük, majd utána CONT signal segítségével folytathassuk a futását.

Igen ám, de a signalok kézbesítésénél a főszabály az, hogy csak a saját UID-nkkal futó processzeknek küldhetünk signalt (kivéve a rootot, aki bármelyik processznek). Itt most a setuid-bit miatt újabb kivételt kell tenni, legalábbis bizonyos signalok tekintetében.

7.3 Egyéb nehézségek

Számos Unix és a Linux is törli a setuid-bitet azokról a fájlokról, amiket írásra nyitunk meg, mert ez elejét veheti néhány exploitnak; viszont azt is jól mutatja, mennyire messzire ható következményei vannak/voltak a látszólag egyszerű setuid-bit bevezetésének.

Itt is azt látjuk, hogy a setuiddal kapcsolatos problémákat nem magában a setuid-mechanizmusban kell kijavítani; olyan gondokról van szó, amik "átgyűrűznek" más alrendszerekbe. Ez azt is jelenti, hogy minden újabb mechanizmus, alrendszer stb. bevezetésekor oda kell figyelni arra, hogy a setuid-bit ne okozzon benne biztonsági rést.

A setuid root helyett jobbnak tűnik az egyszer majd talán használhatóvá váló capability-rendszer, már csak azért is, mert finomabban lehet vele adagolni a jogosultságokat (a Fedora 15-ben állítólag jópár setuidos program setuid-bitjét kiváltják capabilitykkel). Arra, hogy gipszjakab gázgéza jogaival futtathasson programokat, alternatív megoldás lehet egy unix socketen figyelő szerver (amelyet gázgéza futtat, gipszjakab pedig kéréseket küld neki), de ez ránézésre sokkal bonyolultabb, mint a setuid-bit.

A fájlrendszerbeli capabilitykre való áttérés ugyanakkor azt a kérdést is felveti, hogy azok a függvénykönyvtárak, amelyek bizonyos konfigurációs környezeti változókat figyelmen kívül hagynak, ha setuidos program tölti be őket, vajon ugyanígy járnak-e el, ha olyan programba töltődnek be, amely az alapértelmezettnél bővebb capability-halmazzal rendelkezik. Lehet újraauditálni az összeset.

Emellett arról se feledkezzünk meg, hogy a fájlrendszerbeli capabilityk megjelenése újabb terheket ró a fájlrendszerek és a fájlokat manipuláló programok (pl. backup-rendszerek) fejlesztőire. Neil Brown szerint nem egyértelmű, hogy az a biztonság, amit nyerünk, megér ennyit.

8 Hardlinkek

Talán nem nyilvánvaló, de a hardlink is egyike azoknak a mechanizmusoknak, amelyek ránézésre rém egyszerűek, viszont sokminden mást elbonyolítanak, pedig ez a helyzet. Neil Brown szerint a hardlink-rendszer is olyan, mint a jogosultságrendszer: a legtöbb esetben nincs szükség az általa nyújtott kifinomultságra (a létező fájlok döntő részének 1-es a link countja), sok esetben viszont nem elég jó. Miért? Pl. mert:

  • nincs copy-on-write;
  • nem tudjuk fájloknak csak bizonyos részeit összehardlinkelni, tehát fájl- és nem blokk- vagy extent-szintű a hardlink;
  • mert nincs hatékony módszer arra, hogy felderítsük, hol vannak a fájlra mutató linkek.

A hardlinkek a következő nehézségeket okozzák:

  • Az archiválóprogramok meg kell, hogy keressék őket (erre pedig nincs hatékony módszer), hogy az összehardlinkelt fájlokat csak egyszer mentsék le.
  • A du is csak egyszer számolhatja őket.
  • Aki olvashat egy fájlt, tud rá mutató hardlinket létrehozni, olyan könyvtárban is, amelyet az eredeti fájl tulajdonosa nem ér el. Így az eredeti tulajdonos kvótáját terhelő, általa eltávolíthatatlan fájlok keletkezhetnek.
  • A szövegszerkesztők általában úgy mentenek, hogy előbb kimentik a fájlt egy új néven, majd rámozgatják az eredeti fájlra. Ha az eredeti fájlnak hardlinkjei voltak, azok eltörnek. Ez néha jó, néha nem (a szövegszerkesztő nem tudja magától kitalálni, mit szeretnénk).
  • A hardlinkek miatt nem igazán lehet tisztán hierarchikus, útvonal-alapú hozzáférésvezérlést csinálni: ha egy fájlra két különböző könyvtárból is van hardlink, melyiktől örökölje a jogosultság-beállításokat?

Hogy mi legyen hardlink helyett? Például blokkszintű deduplikáció, ha a célunk a helytakarékosság; vagy symlink, ha ugyanazt a tartalmat több helyen is látni akarjuk úgy, hogy az egyik helyen megváltoztatva a többi helyen is megváltozzon. Persze ez még kevés, mert a hardlinkeknél referenciaszámlálás is van...

9 Konklúzió

Rendszerek tervezésekor és bővítésekor az alábbi kérdéseket mindenképpen érdemes feltennünk magunknak:

  1. Hogyan használhatjuk fel az új cél érdekében az, ami már megvan, ahelyett, hogy valami teljesen újat csinálnánk?
  2. Hogyan biztosíthatjuk, hogy az új megoldásunk a jövőben még újabb megoldások alapja lehessen?
  3. Nem tartalmaz a megoldásunk olyan elemet, amely nem szükségszerűen együtt járó funkciók egymással való kombinálását kényszeríti ki?
  4. Az általunk bevezetett új elem nem növeli meg más elemek összetettségét indokolatlan mértékben?
Személyes eszközök