Appunti_di_Sistemi_di_Elaborazione_e_Tra.pdf
Document Details
Uploaded by UnparalleledChrysoberyl
University of Genoa
Tags
Full Transcript
Dario Olianas A.A. 2013/2014 Appunti di Sistemi di Elaborazione e Trasmissione (SET) Sistemi operativi (prof. Chiola): 1. Virtualizzazione, standardizzazione, sicurezza ed efficienza 1.1 Virtualizzazione 1.2 Standardizzazione 1....
Dario Olianas A.A. 2013/2014 Appunti di Sistemi di Elaborazione e Trasmissione (SET) Sistemi operativi (prof. Chiola): 1. Virtualizzazione, standardizzazione, sicurezza ed efficienza 1.1 Virtualizzazione 1.2 Standardizzazione 1.3 Sicurezza 1.4 Efficienza e prestazioni 2. Componenti di un sistema operativo 2.1 Kernel 2.1.2 Bootstrap 2.1.3 Kernel monolitici e kernel modulari 2.2 Librerie 2.3 Altre componenti 3. Scambio di dati via rete 3.1 Trasmissione 3.2 Ricezione 3.3 Sincronizzazione 3.3.1 DMA ring based 3.4 Virtualizzazione dell’attività di rete 3.5 Socket 4. File descriptor 5. Processi 6. Controllo degli accessi 6.1 Approccio mandatorio e approccio discrezionale 6.2 Principi di Denning 6.3 Permessi in UNIX 6.4 Controllo degli accessi role-based: utenti e gruppi 6.5 sudo 7. File system 7.1 Dispositivi a blocchi 7.2 Link fisici 7.3 Link simbolici 7.4 Consistenza delle informazioni 7.4.1 Journaling 8. Virtual machine 8.1 Hypervisor 8.2 Rootkit 9. Standard ASN.1 9.1 Rapresentazione binaria 9.1.1 BER (Basic Encoding Rules) 9.1.2 PER (Packet Encoding Rules) 10. Thread 10.1 Primitive per l’uso dei thread 10.2 Thread-safety: prevenzione delle race conditions 10.3 Deadlock 11. Scheduling 12. Sandboxing e sicurezza Reti (prof.ssa Ribaudo): 1. Introduzione alle reti e ai protocolli di rete 2. Livello di trasporto 2.1 Protocollo UDP 2.1.1 Segmento UDP 2.2 Protocollo TCP 2.2.1 Connessione TCP 2.2.2 Timeout 2.2.3 Controllo di flusso e controllo di congestione 3. Livello applicativo 3.1 Protocollo DNS 3.1.1 Nomi di dominio 3.1.2 Caching DNS 3.2 Protocollo HTTP 3.2.1 Prestazioni di HTTP 3.2.3 Accesso con autenticazione 3.2.4 Cookies 3.3 Posta elettronica 3.3.1 Protocollo SMTP 3.3.2 Accesso alla posta elettronica 3.3.2.1 POP3 3.3.2.2 IMAP4 3.3.2.3 Webmail 3.4 Protocollo NTP 4. Livello di rete 4.1 Classi di indirizzamento IPv4 4.2 Indirizzamento IPv6 4.3 Struttura degli header IP 4.3.1 Frammentazione e negoziazione MTU 4.4 Protocollo DHCP 4.4.1 DORA (Discover, Offer, Request, Ack) 4.5 NAT (Network Address Translation) 4.6 Protocollo ICMP 4.7 Algoritmi di routing 4.7.1 RIP 4.7.2 OSPF 4.7.3 BGP 4.8 Protocollo IPv6 5. Livello datalink 5.1 Protocollo ARP 5.2 Ethernet 5.2.1 Modern Ethernet 5.3 Protocolli wireless 1. Virtualizzazione, standardizzazione, sicurezza ed efficienza 1.1 Virtualizzazione Abbiamo già visto alla fine del corso di SEI la virtualizzazione della memoria, utilizzata per motivi di protezione e di efficienza. Si può infatti definire un segmento in cui è contenuto il codice da eseguire e un altro in cui sono contenuti i dati. La MMU (Memory Management Unit) è il dispositivo hardware che si occupa di gestire la memoria virtuale e impedisce accessi non consentiti alla memoria: ad esempio in caso di tentativo di esecuzione di un segmento dati. La MMU si occupa inoltre della traduzione degli indirizzi di memoria virtuale in indirizzi fisici, attraverso la segmentation table, e quando viene fornito per la traduzione un indirizzo virtuale non valido lo segnala con una trap. Il codice di sistema e il codice delle applicazioni devono risiedere in segmenti diversi, poiché per ipotesi il codice di sistema è esente da errori, e quindi può (e deve) essere eseguito con pieni permessi di accesso ad ogni risorsa. In realtà non è per niente vero che il codice di sistema è privo di bug, ma senza questa assunzione non potremmo avere un sistema operativo (il SO deve avere pieno accesso alle risorse per poterle gestire). Il codice delle applicazioni invece è considerato inaffidabile, e viene eseguito sotto il controllo del SO. Quando un'applicazione tenta di eseguire un accesso non consentito alla memoria, la MMU se ne accorge e genera una trap, che interrompe l'esecuzione dell'istruzione corrente e invoca il trap handler, contenuto nel codice di sistema (quindi fidato, con pieni permessi di esecuzione). Uno degli scopi principali del sistema operativo è proprio quello di virtualizzare le risorse, rendendole facilmente disponibili ai vari software applicativi senza che debbano gestirle direttamente e senza conflitti in caso di più applicazioni eseguite contemporaneamente. Tramite la virtualizzazione, posso fare in modo che ogni processo creda di avere a disposizione un'intera macchina, con il suo processore, la sua memoria, il suo disco e le sue periferiche. La virtualizzazione è fondamentale non solo per i sistemi operativi: anche il cloud è basato sulla virtualizzazione, poiché consente di vedere dispositivi remoti come se fossero parte della mia macchina. 1.2 Standardizzazione La standardizzazione è un altro concetto chiave dei sistemi operativi, fondamentale per l'interoperabilità: un sistema operativo dovrà far funzionare assieme svariati dispositivi e software di diversi produttori: ciò è possibile solo operando con standard comuni. In questo corso faremo riferimento principalmente a sistemi operativi UNIX based. Ci sono moltissimi sistemi derivati da UNIX, ognuno con le sue peculiarità ma l'interoperabilità tra di loro è garantita dallo standard POSIX, che fornisce una serie di primitive implementate allo stesso modo su tutti i sistemi che lo rispettano. Persino Windows, entro certi limiti, è POSIX compliant. 1.3 Sicurezza Alcuni punti fondamentali per la sicurezza di un sistema operativo: controllo degli accessi integrità disponibilità affidabilità segretezza Il controllo degli accessi consente di sapere sempre chi sta utilizzando la macchina, tramite richiesta di username e password per il riconoscimento dei diversi utenti e assegnazione di diversi privilegi a ciascun utente. Il controllo degli accessi non serve solo a difendersi da attacchi esterni, ma anche da errori dell'utente: assegnando ad un utente minori privilegi si riducono anche le sue possibilità di danneggiare il sistema. L'integrità consiste nell'impedire che un software installato perda le sue caratteristiche di affidabilità per interventi più o meno volontari dell'utente o di altre applicazioni. La disponibilità consiste nella risposta immediata ai comandi, senza inutili tempi di attesa. Anche se non sembra una caratteristica di sicurezza, basta pensare ad un computer incaricato di gestire un aereo o una centrale nucleare per capire quanto la reattività ai comandi sia fondamentale per la sicurezza del sistema: se il pilota corregge la rotta, la rotta deve essere corretta immediatamente. La disponibilità si misura in termini di tempo. L'affidabilità è un concetto simile alla disponibilità, ma meno stringente: un sistema affidabile potrà subire dei guasti per cui diventa non disponibile, ma mi da garanzia di recupero per cui tornerà ad esserlo. Un esempio è un comune sistema operativo: può bloccarsi, e quindi non essere più disponibile, ma se riavvio la macchina il sistema riprenderà a funzionare come prima. È una caratteristica fondamentale perché un calcolatore, in quanto sistema fisico, può rompersi, ma se sostituisco la parte danneggiata devo avere la garanzia che la macchina ritorni a funzionare come prima. Tipicamente l'affidabilità viene raggiunta attraverso la ridondanza: utilizzare più risorse di quelle necessarie in modo che in caso di guasto di un componente nulla vada perduto. Ad esempio: un computer con un singolo hard disk non è affidabile perché in caso di rottura del disco avrò perso tutti i miei dati. Se ho un secondo hard disk di backup con una copia identica del primo avrò un sistema affidabile. La ridondanza può essere usata anche per aumentare la disponibilità: un sistema real time che deve darmi la risposta in un secondo, in condizioni normali dovrà darmela in meno di un secondo, in modo che in caso di guasti ci sia tempo di utilizzare i sistemi di emergenza per fornire la risposta. 1.4 Efficienza e prestazioni Come abbiamo detto, durante il suo funzionamento un sistema operativo moltiplica, attraverso la virtualizzazione, le risorse disponibili in modo da fornirne una copia ad ogni processo. Ma anche il sistema operativo è un processo in esecuzione sulla macchina, e dovrà quindi usare il minimo delle risorse possibili in modo da lasciarne di più per le applicazioni che dovrà eseguire. Qualche decina di anni fa il problema dell'efficienza era molto sentito, a causa del maggior costo delle risorse hardware. Ma l'efficienza di un sistema operativo è tutt'ora importante, perché è aumentata la quantità di risorse disponibili ma sono aumentate anche le aspettative, e anche perché virtualizzare una grande quantità di risorse è più complicato di virtualizzarne poche. 2. Componenti di un sistema operativo Prenderemo come riferimento la struttura di un sistema operativo UNIX. 2.1 Kernel Il kernel è lo strato di base che implementa la virtualizzazione e solitamente anche il controllo degli accessi. Appena la macchina viene accesa, la RAM contiene una serie di bit casuali e non predicibili. Ma nello stesso spazio di indirizzamento della RAM c'è anche una memoria EPROM, non volatile. Il program counter della CPU al momento dell'accensione contiene l'indirizzo della EPROM, il cui contenuto sarà il primo programma ad essere eseguito dalla CPU. Il BIOS (Basic Input/Output System) tra le altre cose identifica il dispositivo (generalmente l'hard disk) dal quale effettuare il bootstrap. È utile che il BIOS consenta di modificare questo dispositivo, per consentire ad esempio l'installazione di un nuovo sistema. 2.1.1 Bootstrap L'operazione di bootstrap è quella che mi consente di caricare in memoria nuovo codice eseguibile. Il primo programma ad essere caricato in memoria dal BIOS è il boot loader: un piccolo programma che consente più opzioni di avvio di quelle fornite dal BIOS: ad esempio può consentirmi di scegliere tra diversi sistemi operativi. Il boot loader carica una versione preliminare del sistema operativo, incaricata di predisporre la virtualizzazione della memoria. Per fare questo, il processore dovrà essere in modalità privilegiata, quella in cui si accede direttamente alla memoria con gli indirizzi fisici. Tutte le caratteristiche di sicurezza descritte prima sono disponibili solo dopo l'attivazione della memoria virtuale, quindi è opportuno che la predisposizione della memoria virtuale sia il più veloce possibile. Il kernel viene lanciato solo dopo l'attivazione della memoria virtuale. Un processo è un'applicazione mandata in esecuzione sotto il controllo del sistema operativo, con il suo segmento di codice diviso in codice di sistema e codice applicazione. Quando viene eseguita un'applicazione, il sistema fa una chiamata alla funzione main() della mia applicazione, e passa il processore in modalità non privilegiata: qualunque istruzione privilegiata all'interno dell'applicazione genererà una trap per la cui gestione sarà invocato il trap handler del sistema operativo, che è parte del kernel e viene dunque eseguito in modalità privilegiata. In caso di più processi in esecuzione, ognuno avrà vritualizzato il suo segmento di codice di sistema, uguale per tutti. Questa caratteristica mi consente il process switch: quando un processo è in esecuzione, il sistema può assegnargli un tempo di esecuzione dopo il quale viene inviato un interrupt: questo provocherà l'interruzione dell'applicazione e l'invocazione dell'interrupt handler, che riconosciuta la natura dell'interrupt salverà lo stato attuale dell'applicazione e ne manderà in esecuzione un'altra. Cosa succede quando un'applicazione chiede più risorse di quelle che le sono state assegnate? Supponiamo che il sistema abbia assegnato ad un'applicazione 1 MB di stack, e che l'applicazione chiami una funzione ricorsiva che esaurisce rapidamente lo spazio. Una volta esaurito, l'applicazione chiederà di accedere ad un indirizzo che non fa parte del suo segmento stack. La MMU se ne accorge e genera una trap, il trap handler del sistema operativo la riconosce e aumenta la dimensione del segmento stack dell'applicaizone. 2.1.2 Kernel monolitici e modulari Il kernel è la parte di software di sistema che virtualizza le risorse hardware, per effettuare questa virtualizzazione c'è la necessità di avere un alto grado di integrazione tra kernel e hardware: i componenti del kernel saranno quindi molto legati ai dispositivi fisici che costutuiscono la macchina. Ma uno stesso sistema operativo deve poter essere installato su macchine diverse. Nei sistemi UNIX solitamente si usa un kernel monolitico: un kernel che viene configurato prima della compilazione in modo da avere tutte le componenti necessarie per l'hardware sul quale dovrà essere installato. Ma questo è uno svantaggio in caso di aggiornamenti hardware alla macchina: dovrò ricompilare il kernel per avere i componenti che mi servono. La soluzione è un kernel modulare: un kernel al quale possono essere aggiunti e tolti moduli che danno il supporto a particolari dispositivi, anche durante l'esecuzione. I kernel moderni sono di tipo modulare. Esistono anche altri tipi di kernel, che vedremo successivamente: i microkernel sono kernel estremamente semplificati che fanno solo il minimo indispensabile (virtualizzazione) e demandano tutte le altre necessità a processi di tipo applicativo. 2.2 Librerie Le librerie vanno ad aggiungere codice nel segmento applicativo dei processi. Attraverso queste vengono implementate le system call: chiamate che permettono ad un'applicazione di chiedere funzionalità al sistema. Abbiamo già visto che per passare dalla modalità utente alla modalità sistema c'è bisogno delle trap. Ma l'utilizzo delle trap non è semplicissimo per il programmatore, perché richiede la conoscenza del funzionamento del trap handler del sistema operativo, e sarebbe inoltre ridotta la portabilità, poiché ogni sistema operativo ha un suo trap handler. Lo scopo delle system call è proprio quello di evitare al programmatore l'uso diretto delle trap: dovrà solo invocare una funzione che passa il controllo al sistema, inoltrandogli le richieste dell'applicazione. Lo standard POSIX stabilisce nomi e parametri delle system call. In un certo senso potremmo dire che le librerie virtualizzano per le applicazioni il vero funzionamento del sistema operativo. 2.3 Altre componenti Log Nei file di log vengono registrati tutti gli eventi che accadono sulla macchina: avvio, login, login errati, errori ecc. Utili in caso di malfunzionamenti per capire quale sequenza di eventi ha portato all'errore. Poiché registrando tutto raggiungono molto presto grandi dimensioni, sarà utile avere programmi per gestirli. Applicazioni di gestione Per il funzionamento di un sistema operativo avremo bisogno di applicazioni che permettano all'utente di gestirlo: applicazioni per accedere ai file, aggiungere utenti, installare applicazioni, gestione dei log. Applicazioni per lo sviluppo Compilatori, debugger, IDE, editor..... 3. Scambio di dati via rete Immaginiamo di voler far comunicare due programmi in esecuzione su macchine diverse. Per farlo ho innanzitutto bisogno che entrambe le macchine abbiano un'interfaccia di rete. Serve poi che nella macchina ricevente ci sia un'area di memoria libera abbastanza grande da contenere i dati da ricevere (buffer di ricezione). Anche il mittente avrà un buffer, detto buffer di trasmissione. Ci sarà un programma che legge dal buffer di trasmissione e lo copia, parola per parola, in un registro dell'interfaccia di rete, che vedendo dati in ingresso si attiverà e invierà i dati attraverso un canale di comunicazione verso l'interfaccia del ricevente. Nel ricevente ci sarà un programma che legge dal registro dell'interfaccia di rete e copia nel buffer di ricezione. Che problemi può dare un'implementazione simile? Beh, intanto il bus che trasferisce i dati dalla RAM all'interfaccia di rete è molto più veloce della connessione di rete: dovrà quindi essere opportunamente sincronizzato. Bisognerà anche coordinare l'attività del programma che trasmette e del programma che riceve, per evitare perdite di dati: i due programmi sono eseguiti su due macchine diverse, quindi con diversa frequenza di clock, e se uno va più veloce dell'altro ci saranno perdite di dati. 3.1 Trasmissione Questo è un modello molto semplificato, che implementato nella realtà sarebbe molto inefficiente. Il bus infatti è progettato per avere la più alta velocità possibile, in modo da accelerare i trasferimenti di dati e quindi la velocità generale della macchina. La rete a confronto è molto più lenta, quindi per essere mantenuto sincrono il programma che trasmette sulla rete dovrebbe essere estremamente rallentato, poiché dovrebe scrivere il dato da trasmettere nel registro della NIC, aspettare che venga trasmesso (se ne accorge perché il buffer di trasmissione si svuota) e scrivere il dato successivo. Il programma passerebbe quindi la maggior parte del tempo in attesa. Per ovviare a questo problema è stato introdotto il DMA channel: un coprocessore in grado di eseguire un piccolo sottoinsieme dell'instruction set del processore, tipicamente istruzioni relative alla copia di dati. Essendo un processore, il canale DMA conterrà dei registri in cui la CPU può scrivere. Il nostro programma di trasmissione potrà scrivere una serie di valori nei registri del canale DMA, che si occuperà della trasmissione. I valori richiesti dal DMA channel sono gli indirizzi di inizio e fine del buffer di trasmissione: una volta ricevuti si occuperà lui di trasmetterlo senza ricorrere al processore principale, il quale dovrebbe occuparsi solo della programmazione del canale DMA. L'uso del DMA introduce un altro problema: se prima il bus era gestito solo dal processore, ora avrò due dispositivi che possono usarlo e devo assicurarmi che non vadano in conflitto. Servirà quindi un arbitro che "diriga il traffico", decidendo ogni volta chi può utilizzare il bus. Quando l'arbitro nega l'utilizzo del bus al processore, questo potrà andare avanti nel suo funzionamento con i dati di cui dispone nella cache. È infatti raro che il processore richieda l'accesso alla RAM: mediamente una volta su 20, le altre volte i dati necessari sono già in cache. Per segnalare al processore che è finita la trasmissione, il DMA invia al processore un interrupt. 3.2 Ricezione In ricezione, alla NIC arriveranno dati dalla rete, che verranno scritti nei suoi registri. Quando i registri sono pieni, il processore deve copiare i dati nel buffer di ricezione in modo da liberare i registri per i nuovi dati in arrivo. I registri pieni vengono segnalati alla CPU attraverso un interrupt, e questo rallenta notevolmente il processore che sarà continuamente interrotto nel suo funzionamento. Inoltre, se al momento in cui viene inviato l'interrupt il processore sta funzionando in modalità sistema, non sentirà gli interrupt (perché potrebbe essere in corso la gestione di un altro interrupt o di una trap). Se il processore non risponde all'interrupt e continuano ad arrivare dati dalla rete, la NIC non sa più dove metterli e rischierò di perderli. Anche in questo caso risolvo il problema con il DMA: il DMA channel della maccina ricevente sarà dedicato ad aspettare i dati dalla NIC, prenderli quando sono pronti e copiarli nel buffer di ricezione. Anche in questa implementazione avrò bisogno di un interrupt, la prima volta, per attivare il DMA. Ma se la NIC invia l'interrupt appena arrivano dati e non quando i registri sono pieni, avrò più tempo utile per segnalare l'arrivo di dati e la necessità di attivazione del DMA alla CPU. Una soluzione più efficiente è integrare il DMA nell'interfaccia di rete, in modo da utilizzare un solo ciclo di lettura sul bus invece di due (si elimina la fase di trasmissione dal DMA alla NIC). È questa la soluzione usata nei computer moderni: il DMA channel unico per tutto il sistema era usato una trenitna di anni fa, adesso si usa il DMA bus mastering, in cui ogni dispositivo ha il suo DMA. 3.3 Sincronizzazione Abbiamo già detto che uno dei problemi della comunicazione tra macchine remote è che ogni parte del percorso dalla macchina trasmittente a quella ricevente ha velocità diversa: il clock del processore del trasmittente ha velocità diversa dal bus, che a sua volta ha velocità diversa dal canale di comunicazione. Anche la macchina ricevente avrà una frequenza di clock della CPU diversa, e quando il mittente è molto più veloce del ricevente il rischio è la perdita di dati. Bisogna quindi trovare una soluzione per sincronizzare tutti. Questo viene effettuato sul canale di comunicazione attraverso algoritmi di flow control (controllo di flusso), che cercano di regolare la velocità di mittente e destinatario rispetto al canale di comunicazione. Supponiamo di avere un mittente più veloce del destinatario: con un controllo di flusso i dati vengono rallentati in fase di trasmissione in modo da dare tempo al ricevente. Avremo quindi un primo trasferimento dalla RAM al DMA dell'interfaccia di rete con la prima parola da trasmettere. Segue un lungo intervallo in attesa che il trasferimento verso la rete sia terminato. A questo punto ci sarà un altro trasferimento DMA dalla RAM per la seconda parola, e così via. Vediamo che ci sono tempi morti anche in trasmissione, quando la NIC sta aspettando dal DMA la prossima parola da trasmettere. Come li posso evitare? Posso aggiungere un secondo registro alla NIC, che conterrà un'altra parola. Una volta finito di trasmettere il contenuto del primo registro, inizierò subito a trasmettere il secondo mentre il DMA carica un nuovo valore da sovrascrivere a quello del registro già trasmesso. Con due o più registri nella NIC, vi abbiamo implementato un buffer. Lo stesso discorso vale per la ricezione: con un solo registro avrei tempi morti nell'attesa che un valore appena ricevuto venga scritto in RAM dal DMA: con un buffer di ricezione posso continuare a ricevere mentre il DMA trasferisce i dati ricevuti dal buffer della NIC alla RAM. In genere le interfacce di rete hanno un firmware, sono piccole macchine programmabili. Finora abbiamo definito i buffer di trasmissione e ricezione in RAM come porizioni di memoria identificate da un indirizzo di inizio e uno di fine, quindi contigue. Ma cosa succede se voglio trasmettere dati non contigui? Dovrò riprogrammare i registri del DMA più volte, una per ogni frammento contiguo di dati, e la cosa può diventare poco efficiente per dati molto frammentati. Si risolve complicando ulteriormente il DMA della NIC, rendendolo in grado di gestire strutture dati dette ring. 3.3.1 Ring based DMA Supponiamo di voler ricevere su due buffer non contigui: come lo comunico al DMA della scheda di rete? La CPU crea in memoria una struttura dati, il ring, che conterrà le coordinate di uno dei due buffer, e passa l'indirizzo del mio ring al DMA, che leggerà gli indirizzi contenuti nel ring e inizierà a ricevere nel buffer corrispondente. Il DMA è sensibile ai cambiamenti del ring: se il processore modifica in corsa un indirizzo del ring, il DMA se ne accorge e inizia a utilizzare il nuovo buffer. I ring possono essere usati per programmare più trasferimenti consecutivi su buffer diversi, scrivendone consecutivamente gli indirizzi nel ring. L'utilizzo dei ring è quindi una bufferizzazione della programmazione del DMA. Queste porzioni di memoria contenenti gli indirizzi dei buffer sono chiamate ring perché vengono lette in modo circolare. Se il canale di comunicazione è full duplex si può inviare e ricevere contemporaneamente, se è half duplex no. 3.4 Virtualizzazione dell'attività di rete Abbiamo visto come funziona la trasmissione e la ricezione dei dati da una macchina all'altra: il passo successivo è virtualizzare tutto per le applicazioni. Se ho tre processi che vogliono trasmettere sullo stesso canale, posso farli trasmettere uno per volta, in coda (multiplexing). In ricezione (demultiplexing) è più complicato perché non so con che criteri sia stato fatto il multiplexing sul trasmittente, i dati trasmessi dovranno includere informazioni che mi dicono quali dati corridpondono a quale processo. Per fare questo posso usare dei buffer generici, gestiti dal sistema operativo, in cui mettere i dati in attesa di smistamento. Una volta ricevuti i dati li andrò a leggere e saprò a quale processo appartengono. La dimensione di questi buffer generici dovrà essere la massima possibile, in modo da poter ricevere anche i messaggi lunghi. Ma essendo decisa dal sistema operativo, le applicazioni dovranno adattarsi a non trasmettere o ricevere messaggi più lunghi della dimensione massima impostata dal sistema: per trasferimenti più lunghi i dati da trasmettere andranno scomposti in pacchetti della dimensione del buffer da trasmettere uno per volta. La dimensione dei buffer di ricezione deve essere convenzionale: altrimenti il mittente potrebbe inviare pacchetti più grossi. Il demultiplexing dei dati ricevuti è effettuato dal sistema operativo. In pratica, come avviene il demultiplexing? Bisogna riassegnare il segmento desiderato, che per ora appartiene al sistema operativo, al processo cui i dati erano destinati. Per fare questo bisogna agire sulla memoria virtuale. Oppure, si possono copiare i dati dal segmento del sistema operativo a quello dell'applicazione. Quale delle due soluzioni sia la più efficiente è un dettaglio implementativo: su alcuni sistemi può essere più veloce riassegnare un segmento, su altri copiare i dati. Memoria condivisa e scambio di messaggi All'interno di un sistema, la memoria è condivisa (shared memory) tra il processore e tutti i dispositivi dotati di DMA, che possono usarla sia per conservare i loro dati sia per comunicare tra di loro (ring). Tra due computer connessi in rete si usa un approccio diverso, quello del message passing (scambio di messaggi): nessuno ha accesso alla memoria dell'altro, ma le due macchine possono inviarsi messaggi che saranno gestiti esclusivamente dalla macchina ricevente. Questa differenza di approccio è motivata da problemi fisici: con una maggiore distanza, un sistema a RAM condivisa sarebbe inefficiente. API per la comunicazione di rete Abbiamo visto che la comunicazione di rete è un problema piuttosto complesso: sarà utile allora virtualizzarne il funzionamento per le applicazioni. Questo avviene attraverso delle API (Application Program Interface) standard, in modo da garantire la portabilità. Pensiamo al problema della computazione distribuita: devo usare le risorse di vari computer sparsi per il mondo, anche a grande distanza, ma connessi in rete. La soluzione è stata trovata nel protocollo MPI (Message Passing Interface). Sviluppando software che dovrà essere eseguito in un sistema distribuito, devo fare in modo che il numero di processori da usare non sia definito ma possa cambiare ad ogni esecuzione. Questo è possibile definendo un numero, il rank, che identifica i vari processi della mia applicazione. Se ne lancio solo uno il suo rank sarà 0, se ne lancio due avrò due processi 0 e 1 e così via. Questi numeri potrò poi utilizzarli come identificativo del mittente e identificativo del destinatario per lo scambio di messaggi tra i vari processi in esecuzione su macchine diverse. È compito dell'interfaccia MPI mandare in esecuzione tante copie del programma su tante macchine diverse e metterle in comunicazione tra loro. Può essere gestita una comunicazione uno a uno, uno a molti, molti a uno. 3.5 Socket L'API di comunicazione per eccellenza è il socket. L'idea del socket è quella di "fare un buco" nel perimetro che delimita il nostro processo in modo da far entrare e uscire flussi di informazione. Due processi in esecuzione su macchine diverse dovranno avere entrambe un socket aperto per comunicare. Non è indispensabile che i due processi siano su macchine diverse: possono anche essere sulla stessa. Come si fa a fare in modo che un processo sappia che c'è un altro processo che vuole comunicare con lui e quindi fargli aprire un socket? Per poter riconoscere univocamente ogni computer sulla rete è necessario che pgni computer abbia un suo indirizzo di rete che lo identifichi univocamente. Il protocollo IP esiste in due versioni, IPv4 e IPv6, differenti per il numero di bit da cui è composto l'indirizzo. Tramite l'indirizzo possiamo far arrivare il messaggio alla macchina di destinazione: manca il demultiplexing per capire a quale processo della macchina ricevente è destinato il messaggio. A livello di sistema, il demultiplexing può essere gestito tramite porte, identificate da un numero di 16 bit. Ad ogni porta va assegnato un socket, che riceverà i dati da essa. Questa associazione è chiamata bind. Come fa il mittente a sapere su quale porta troverà il ricevente? Deve essere deciso in precedenza. Ma essendo le porte gestite dal sistema, un processo potrebbe chiadere il bind su una porta e il sistema potrebbe negarglielo, magari perché la porta è già occupata. Il processo dovrebbe allora comunicare al mittente il cambiamento di porta. Ma anche il mittente può avere di questi problemi, quindi l'unico modo per garantire la comunicazione con questo sistema è necessario che almeno uno dei due abbia un'associazione fissa con una porta. È un esempio di architettura client/server: chi ha un bind fisso con la porta viene chiamato server, l'altro client. L'assegnazione delle porte è uno standard, che le suddivide in well known ed ephimeral: le well known sono assegnate ai server, le ephimeral ai client. In questo modo i server sono sempre contattabili, avendo porte assegnate dallo standard, e per prima cosa il client dovrà comunicare su quale porta ephimeral ha fatto il bind in modo che il server possa rispondergli. Dopo questa fase non ha più senso parlare di client e server: una volta che entrambi i processi sono reciprocamente raggiungibili la comunicazione diventa perfettamente simmetrica. I socket possono essere utilizzati in due modi: datagram e stream. La datagram è la modalità più semplice, ma con meno funzionalità: dopo che il server ha fatto il bind il client può subito iniziare la comunicazione tramite la funzione send. Funzioni POSIX utilizzate per la gestione dei socket: socket: crea un socket bind: chiamata dal server per farsi assegnare una porta send: invia dati. In modalità datagram si chiama send_to recv: riceve dati. Prende come parametro un puntatore al buffer di ricezione. In modalità datagram si chiama recv_to Gli indirizzi di rete sono suddivisi in network e host: una parte dell'indirizzo identifica la rete a cui il computer è connesso, l'host identifica il computer. 4. File descriptor Un file descriptor è un numero intero che rappresenta una risorsa messa a disposizione dal sistema per un processo che ne fa richiesta. Il sistema mantiene un array, in cui ogni posizione contiene le informazioni per raggiungere uno specifico file e il file descriptor è un indice di questo array. Da notare che per "file" non si intende per forza un file nel classico senso del termine, ma ouò essere anche, ad esempio, un socket, un dispositivo o un altro tipo di risorsa. Le prime tre posizioni di questo array, quindi i file descriptor 0, 1 e 2 sono associate ai file sdtin, stdout e stderr, che sono a loro volta collegati a tasitera e terminale. Stdout e stderr fanno entrambi rifetimento al terminale, ma con una differenza logica: stdout è gestito in modalità bufferizzata, stderr no. I dati stampati su stdout vanno prima a finire in un buffer, e potrebbero quindi non venire stampati in caso di errori, mentre i dati stampati su stderr vengono immediatamente mostrati a terminale. I file descriptor agiscono quindi da porta tra un processo e il mondo esterno: se non esistessero un processo potrebbe lavorare solo sulle sue variabili interne. Ogni volta che un processo vuole aprire un nuovo canale di comunicazione con l’esterno, deve farne richiesta al sistema operativo, attraverso system call come read(), write(), open(), socket(). Il sistema andrà a prendere la prima posizione libera nell’array dei file descriptor, vi metterà le informazioni necessarie ad accedere alla risorsa richiesta e ne restituirà l’indice al processo. L’idea che sta dietro a questa soluzione è che il processo possa accedere all’array dei file descriptor, che sarà quindi allocato nella memoria utente, ma non al contenuto delle sue celle, che dovrà essere gestito esclusivamente dal sistema operativo poiché sono risorse esterne al processo, la cui modifica potrebbe influenzare l’intero sistema. Quando un processo ha bisogno di accedere alla risorsa, ne passerà il file descriptor come parametro ad una system call, che chiederà al sistema operativo di svolgere l’operazione richiesta. Queste system call dovranno attivare una trap al loro interno per passare il controllo al sistema operativo. Ci sono primitive generiche, come read() e write(), che permettono di agire su diversi tipi di file descriptor, e primitive specifiche come quelle per i socket (send(), recv(), connect()). I socket, quando vengono utilizzati dal programmatore, possono essere visti come buffer indefinitamente grandi in cui posso andare a scrivere quanti dati voglio. In realtà, i socket virtualizzano un sistema di buffer più complesso, in cui ci sono buffer di trasmissione e buffer di ricezione. Se io continuo ad inviare dati ma il ricevente non svuota i buffer, arriverò ad un punto in cui i buffer di ricezione e dei nodi intermedi saranno saturi. Occorre quindi un meccanismo di controllo di flusso che notifichi al trasmittente quando i buffer di ricezione sono saturi. Le primitive read() e write() sono bloccanti, ovvero bloccano il processo che le ha lanciate se non sono in condizione di portare a termine il compito per mancanza di risorse. Per evitare blocchi e sblocchi troppo frequenti, che peggiorerebbero le prestazioni, il blocco viene effettuato quando il 90% dei buffer è saturo e lo sblocco non viene effettuato finché non si liberano almeno al 50%. 5. Processi In UNIX i processi si possono duplicare. Quando noi abbiamo un processo in esecuzione, possiamo usare la system call fork() che ha l'effetto di fare una copia identica del processo che l'ha chiamata. Ma chi crea il primo processo? In fase di boostrap, dopo che la memoria virtuale è stata inizializzata correttamente, il kernel crea il primo processo, init, che potrà avviarne altri tramite la fork. Il primo processo viene autogenerato passando dalla modalità reale (indirizzamento diretto della memoria) a quella protetta (memoria virtuale): il programma suddivide la sua memoria in memoria utente e memoria di sistema, diventando un processo. Questo processo, chiamando la fork nel suo codice utente, potrà crearne altri. Cosa vuol dire che un processo duplica se stesso? le strutture dati che rappresentano il processo nel kernel vengono replicate. i segmenti dati e codice del processo vengono replicati Avrò così un secondo processo uguale identico al primo. Identico anche nello stato: il processo figlio non partirà dall'inizio del main, ma dal momento in cui è stata chiamata la fork, perché anche il program counter è stato copiato dal padre. Come faccio a differenziarne il funzionamento? Bisogna guardare il valore ritornato da fork(), un intero che sarà maggiore di zero nel padre e zero nel figlio: è l'unico modo che hanno per differenziarsi, essendo due copie identiche dello stesso processo. La fork può anche ritornare -1 in caso di errore, se ad esempio non c'è abbastanza memoria per avviare un nuovo processo: questo sarà ovviamente ritornato nel padre (il figlio non è stato creato) che potrà agire di conseguenza. Il processo pong dell'esercitazione, ad esempio, fa una fork ogni volta che accetta una connessione poiché deve creare un altro processo che interagisca col ping, mentre quello in ascolto sulla pota 1491 deve restare in ascolto nel caso arrivino altre richieste di connessione. I vari processi sono identificati dal sistema da un PID (process identifier), un numero positivo diverso per ogni processo, corrispondente al valore ritornato dalla fork al padre del processo. Un altra funzione fondamentale per la creazione di nuovi processi è int execve(filename, argv, envp), che serve a mandare in esecuzione il file eseguibile filename. Quando viene invocata, il loader del sistema operativo va a cercare il file specificato, e se è un eseguibile valido modifica i segmenti del processo che l'ha chiamata per prepararla all'esecuzione del nuovo processo. Questo include il ridimensionamento dei segmenti dati e codice adattandoli a contenere dati e codice del nuovo processo, e la loro corretta inizializzazione. In questo caso, l'esecuzione non dovrà cominciare da un punto precedente, ma dovrà ricominciare da zero. Il parametro argv corrisponde all'argv del main. Il parametro envp contiene delle informazioni di ambiente della shell, che possono essere informazioni di geolocalizzazione, lingua, ecc. I file descriptor possono essere settati tramite flag (O_CLOEXEC) in modo da essere chiusi non appena viene invocata la execve. Fork ed execve sono usate principalmente dalla shell: si duplica con una fork e trasforma il processo figlio nel processo che l'utente ha richiesto tramite execve. Quando viene invocata la exit, il processo che l'ha invocata viene terminato, eliminando tutte le sue strutture dati, a meno che non siano ancora in uso (ad esempio, se l'applicazione stava usando un socket e l'invio dei dati non è ancora terminato). Un processo che ha invocato la exit e sta aspettando di poter essere terminato viene chiamato zombie. In questo stato il PID del processo è ancora bloccato, non può essere riassegnato. La exit ha un parametro di tipo intero: un codice di terminazione che viene inviato al processo padre, che leggendo questo codice avrà informazioni sull'esito dell'esecuzione del processo figlio. Questo valore non viene inviato automaticamente al processo padre, come potrebbe essere nel caso di un'eccezione sollevata, ma è il processo padre che deve andarlo a leggere. Finché il processo padre non legge il codice di terminazione, il figlio rimane in stato di zombie. Per evitare sprechi di risorse è necessario che il padre legga il codice di terminazione il prima possibile. 5.1 Stato di un processo e segnalazioni Un nuovo processo appena generato dalla fork è in stato init, e passerà in stato ready non appena i suoi segmenti di memoria saranno inizializzati. Lo scheduler del sistema operativo decide, tra tutti i processi in stato ready, quale mandare in esecuzione. La fase in cui vengono cambiati i valori dei registri interni del processore per passare da un processo all'altro è detta commutazione di processo (switch). L'operazione inversa, cioè il passaggio dallo stato running allo stato ready, può avvenire sia per volontà del processo stesso, sia per ordine del sistema operativo, che assegna un tempo di esecuzione ad ogni processo, terminato il quale lo interrompe per eseguirne un altro. Il processo passerà dallo stato ready allo stato running per tutta la sua esistenza, finché non invoca la exit per terminare: passerà quindi in stato zombie finché il sistema operativo non decide che può terminare e lo elimina. Segnalazioni Un processo, per invocare la exit e terminare volontariamente, deve per forza di cose essere in stato running. Se voglio forzare un processo a terminare quando è in stato ready, posso farlo attraverso le segnalazioni (signal), una virtualizzazione del meccanismo di interrupt a livello di sistema operativo. Tramite la system call signal (int signum, sighandler_t handler) (deprecata, adesso si usa la più complicata sigaction) posso stabilire corrispondenze tra la segnalazione signum,un intero che indica al processo il motivo della segnalazione, e la funzione handler, da eseguire nel caso in cui venga inviata la segnalazione signum. È una funzione che non fa parte del normale flusso di esecuzione del programma. Per inviare una segnalazione si usa la system call kill(). Con un handler programmato per eseguire la exit, la kill può essere facilmente usata per terminare un processo contro la sua volontà. Gli handler di default del sistema sono: term: invoca la exit terminando il processo ign: handler vuoto, ignora la segnalazione core: fa il core dump dello stato dell'applicazione stop: mette il processo in attesa (passa in stato wait) cont: riprende il processo che è attualmente in attesa (esce dallo stato wait) Schema dei passaggi di stato di un processo. Alcune segnalazioni non possono essere ridefinite dal programmatore: ad esempio SIGKILL sarà sempre associata all'handler term. La kill prende come parametro il PID del processo a cui inviare la segnalazione. Occorrono alcuni controlli di sicurezza però: ad esempio in un sistema multiutente non voglio che un utente possa terminare i processi di un altro (a meno che non sia root). Un processo può essere lanciato in foreground o in background: un processo invocato in background (nella shell bash si fa col carattere & alla fine del comando) non può interagire col terminale che lo ha lanciato, un processo inviato in foreground invece ne prende il controllo. Quando sto eseguendo un processo in foreground, la combinazione CTRL+C ha l'effetto di inviare una SIGKILL al processo. Se invece voglio mettere in attesa un processo posso usare la combinazione CTRL+Z, che invia una SIGSTOP. L'utente tramite shell può inviare una SIGKILL ad un processo, tramite il comando kill PID del processo. per conoscere il PID dei processi in esecuzione c'è il comando ps. Abbiamo detto che un processo rimane in stato zombie finché ha risorse ancora utilizzate o finché il padre non legge il suo codice di terminazione (il valore ritornato dalla exit). Esistono delle system call per leggere il codice di terminazione di un processo: wait() e waitpid(). Waitpid consente di specificare il PID di cui vogliamo sapere il codice di terminazione, la wait() di uno qualunque dei processi che il processo che la invoca ha mandato in esecuzione. Sono entrambe system call bloccanti. Per evitare che un processo rimanga zombie per tanto tempo, esiste la segnalazione SIGCHLD, inviata da un processo che invoca la exit a suo padre, che alla SIGCHLD avrà associato un handler che esegue la wait, leggendo il codice di terminazione del figlio che quindi potrà terminare. In pong server, possiamo provare a implementare questo sistema in modo che i processi generati dalla fork terminino. 6. Controllo degli accessi Vediamo un modello molto semplificato di sistema operativo: un insieme di utenti vogliono accedere ad un insieme di risorse, e il nucleo di sicurezza del sistema deve intermediare queste richieste, filtrarle, perché gli utenti non devono potervi accedere direttamente. Schema di interazione utente-kernel-risorsa / risorsa-kernel-utente Possiamo distinguere due casi: sistema aperto e sistema chiuso. In un sistema aperto c'è una quantità arbitraria di utenti che non possiamo conoscere a priori, che potrebbero arrivare in qualunque momento con richieste che non ci aspettiamo. In un sistema chiuso sappiamo da prima chi sono gli utenti del sistema e le loro possibili richieste. In un sistema chiuso posso mantenere una matrice utenti-risorse. In questa matrice posso definire quali utenti possono accedere a quali risorse, e anche le modalità di accesso consentite (lettura, scrittura, esecuzione). In questo schema manca una fase cruciale: l'autenticazione. Al kernel infatti serve un modo per riconoscere gli utenti con assoluta sicurezza, non semplicemente fidandoci di quello che ci dicono. Servirà allora una matrice degli accessi (access matrix). Un sistema aperto può esserlo solo per quanto riguarda gli utenti (non conosco a priori l'insieme degli utenti ma conosco quello delle risorse) o le risorse (non conosco a priori l'insieme delle risorse ma conosco quello degli utenti). Nel caso di sistema aperto dal punto di vista delle risorse userò una lista degli accessi, in cui ad ogni nuova risorsa aggiunta vado a definire i permessi di tutti gli utenti. Nel caso di sistema aperto dal punto di vista degli utenti, userò una capability list: ad ogni utente è associata una lista di permessi di accesso alle varie risorse. Adesso sembrano due modi equivalenti di gestire le cose: in realtà le differenze ci sono. 6.1 Assegnazione dei permessi: approccio mandatorio e approccio discrezionale Abbiamo visto il modo in cui vengono gestiti i permessi, ma non come vengono stabiliti (cioè abbiamo visto la struttura dati che implementa il controllo degli accessi, ma non il suo contenuto). Esistono due approcci per l'assegnazione dei permessi: l'approccio mandatorio e l'approccio discrezionale. L'approccio mandatorio è quello più adatto ad un sistema chiuso: il progettista del sistema, in base alle esigenze di funzionamento, decide manualmente i permessi. L'approccio discrezionale invece delega all'amministratore di sistema la gestione dei permessi. A parità di spesa, potrebbe sembrare più vantaggioso il sistema mandatorio perché, essendo la sicurezza affidata al progettista, in caso di malfunzionamenti potrò rivalermi su di lui, cosa che non posso fare usando un sistema discrezionale in cui la responsabilità della sicurezza è dell'amministratore. I sistemi mandatori sono scelti solitamente da chi ha fortissime esigenze di sicurezza, uno dei maggiori utilizzatori di sistemi mandatori è il Department of Defense statunitense. Pensiamo ad esempio ad un sistema che deve gesitre gli accessi a documenti classificati con diversi livelli di segretezza, consentendo agli utenti di accedere solo ai documenti per cui hanno l'abilitazione. Le abilitazioni sono gerarchiche: supponendo di avere una classifica di segretezza di segreto, riservato e pubblico, chi ha accesso ai documenti segreti avrà accesso a tutto, chi ha accesso ai documenti riservati avrà accesso solo a documenti riservati e pubblici e così via. Ma potrei voler dare ad un utente con abilitazioni inferiori il permesso in scrittura per documenti che richiedono abilitazioni superiori: questo ad esempio potrebbe servire se ho necessità che un utente con qualifiche per riservato comunichi con utenti di livello segreto senza che gli altri utenti di livello riservato lo sappiano. Le qualifiche per la scrittura sono quindi inverse rispetto a quelle per la lettura: gli utenti di livello superiore possno leggere a tutti i livelli inferiori, ma non scrivere, Gli utenti di livello inferiore possono scrivere su tutti i livelli superiori, ma non leggerli. Questo per evitare fughe di informazioni da un livello più alto ad un livello più basso, in caso di utenti malintenzionati con qualifiche alte. Buona configurazione del controllo degli accessi per un sistema di gestione delle informazioni classificate. Questo sistema è (teoricamente) sicuro, ma non è efficiente, perché non c'è modo di passare informazioni, anche legittime, dai livelli superiori ai livelli inferiori. Immaginandolo in un sistema informatico militare, sarò protetto nel caso di generali traditori che vorrebbero passare al nemico le informazioni di livello più alto a cui hanno accesso, ma tutti i generali non potranno dare ordini perché non possono comunicare con i livelli inferiori. Non è inoltre sicuro in caso di attacchi dal basso in cui l'attaccante non è interessato a rubare le informazioni ma solo a corromperle: può farlo su tutti i livelli, avendone i permessi di scrittura. E inoltre non è neanche completamente sicuro per quanto riguarda la segretezza, in quanto è violabile col sistema dei canali coperti (covert channel), che sfrutta le risorse condivise da tutti gli utenti, come l'unità disco, per passare informazioni ai livelli inferiori. Questo perché, misurando il tempo di risposta, posso sapere se qualcun altro lo sta usando. Il generale traditore può stabilire con l'altra spia che accederà al disco per trasmettere il valore 1, non vi accederà per trasmettere il valore 0. Il ricevente in questo modo saprà che deve codificare 1 quando ha un tempo di accesso alla risorsa condivisa più alto, 0 quando è più basso. Non è un sistema veloce, il bitrate è molto basso. Questo attacco può essere scongiurato evitando la condivisione di risorse tra gli utenti. Abbiamo visto che l'approccio mandatorio non è per nulla pratico, e infatti nella realtà è molto poco utilizzato. 6.2 Principi di Denning I principi di Denning sono una serie di regole per una buona gestione della sicurezza. Innanzitutto, bisogna partire dal presupposto che chi attacca è più furbo di noi. 1. Anello debole: se ci sono più modi in cui la sicurezza può essere compromessa, un attaccante furbo sceglierà il più semplice. La mia priorità sarà quindi sostituire i componenti peggiori, e non migliorare i componenti che già funzionano. E nella maggior parte dei casi, l'anello debole è l'uomo: sarà quindi più conveniente investire in corsi di formazione per amministratori di sistema piuttosto che in software aggiuntivi per la sicurezza. 2. Minimo privilegio: ad ogni utente vanno assegnati i minimi privilegi possibili per consentirgli di accedere alle risorse di cui ha bisogno nelle modalità di cui ha bisogno, e solo a quelle: devo essere il più restrittivo possibile nell'assegnazione dei privilegi, nessuno deve avere accesso a risorse non indispensabili in modalità non indispensabili. Questo mi protegge non solo da attacchi intenzionali ma anche da errori involontari. 3. Cambio di contesto: gli utenti possono dover svolgere compiti diversi in momenti diversi. Quindi in momenti diversi assegnerò all'utente permessi diversi, a seconda del contesto attuale, in modo da avere sempre assegnato il minimo priviliegio possibile in relazione non a tutto quello che devo fare in generale ma da quello che sto facendo al momento. È il principio del minimo privilegio applicato sul tempo. 4. Economia/ridondanza di controllo: la ridondanza dei sistemi di controllo mi garantisce una maggiore sicurezza: se uno fallisce, c'è sempre l'altro, e le possibilità di violazioni si riducono. 6.3 Permessi in UNIX Non tutti i filesystem supportano la gestione dei permessi. Nei sistemi UNIX esiste un utente root, con UID 0, che è proprietario di tutti i file. L'utente proprietario del file, a meno di modifiche manuali, è quello che lo ha creato. Quando si crea un file gli devono anche venire attribuiti dei permessi, stabiliti per default per ogni nuovo file creato (impostazioni di default modificabili dall'utente). Una volta creato, l'utente potrà modificarne i permessi col comando chmod. Chmod prende come parametro una lettera u (user), g (group) od o (other), che indica l'utente o gruppo a cui vogliamo attribuire i permessi, + o - per dire se stiamo dando o togliendo un permesso e le lettere r,w,x per specificare su quale permesso stiamo agendo. Il proprietario del file ha un permesso in più: quello di modificare i permessi sul file, ed è un privilegio che non può essere tolto. Quindi anche in caso un utente proprietario di un file si tolga il permesso di scrittura, potrà sempre ridarselo. Perché un utente dovrebbe volersi togliere dei permessi di accesso al file? Per il secondo e terzo principio di Denning: minimo privilegio e cambio di contesto, per una sicurezza ottimale i privilegi vanno assegnati a seconda delle necessità del momento e devono essere sempre i minimi possibile. Un approccio simile mi protegge non solo dagli attacchi, ma anche dagli errori di programmazione delle applicazioni: minori sono i permessi con cui l'applicazione buggata è eseguita, minori sono i danni che potrà fare. I permessi di accesso ai file sono discrezionali, il permesso di modificare i permessi è mandatorio: lo può fare il proprietario e basta, questa scelta non può essere modificata. Posso però cambiare il proprietario del file con chown: in questo modo il proprietario originale perde il privilegio di decidere i permessi sul file in favore del nuovo proprietario. Questo può essere fatto solo da root. La presenza dell'utente root è la più grossa vulnerabilità dei sistemi UNIX: è infatti una palese violazione del principio di Denning del minimo privilegio (è un utente con tutti i privilegi, e non gli possono essere tolti senza modifiche steutturali al sistema operativo), e rischia di trasformarsi anche in anello debole: un attaccante infatti tenterà ovviamente di ottenere l'accesso come root. È infatti buona norma non usare mai l'utente root per la normale attività, ma solo quando i suoi privilegi sono necessari. 6.4 Controllo degli accessi role-based: utenti e gruppi In UNIX ad ogni utente è assegnato un gruppo di appartenenza. Che non è necessariamente solo uno: un utente può essere membro di più gruppi. Nel file /etc/passwd sono contenute tutte le informazioni degli utenti (ma non più le password, ora memorizzate in /etc/shadow). Sono organizzate in linee, suddivise in campi: name:x:UID:GID:home directory:shell. La x mi indica se l'utente è protetto o no da password: una volta conteneva anche la password in chiaro, ma ovviamente la cosa non era sicura. Il file /etc/group ha struttura simile, ma riferita ai gruppi invece che agli utenti. Contiene tutti i gruppi, completi di GID, e tutti gli utenti che ne fanno parte. Di default ad ogni creazione di un nuovo utente viene creato anche un nuovo gruppo. Il nuovo utente apparterrà a questo gruppo e basta. Posso in seguito aggiungerlo ad altri gruppi e crearne di nuovi, come ad esempio un gruppo backup i cui utenti hanno permesso in lettura a tutti i file, poiché devono fare il backup del sistema. Posso creare gruppi per dare l'accesso solo a certi utenti a determinate risorse (come un gruppo autorizzato a stampare). Questi file di configurazione possono di norma essere modificati solo da root: posso però creare un gruppo di utenti delegati alla creazione di nuovi utenti. Questo per il principio del minimo privilegio che mi impone di usare l'utente root il meno possibile. Abbiamo detto prima che chmod può agire anche sui gruppi, col parametro g: i comandi dati con questo parametro si riferiranno al gruppo proprietario del file in questione. Per modificare il gruppo proprietario di un file, c'è il comando chgrp. Quando io eseguo un programma o uno script, questo avrà gli stessi permessi di accesso alle risorse dell'utente che lo ha lanciato. Se assegno il privilegio s ad un file eseguibile, questo verrà eseguito coi permessi del suo proprietario anche se a lanciarlo è un altro utente (al processo viene associato l'UID del proprietario e non dell'utente che lo ha lanciato). Questo può eseere fatto anche dall'utente root. Il bit s può essere anche associato ad un gruppo, con la sintassi chmod g+s: serve ad utilizzare l'utente root il meno possibile. 6.5 sudo Un altro modo per amministrare un sistema senza dovervi accedere come root è sudo: esegue un comando con permessi di root anche se lanciato da un altro utente. Mantiene le sue configurazioni in /etc/sudoers, che mi indica quali utenti sono autorizzati chiamare il comando sudo e i comandi che possono essere invocati tramite sudo. Può essere configurato per richiedere una password: non quella di root, ma dell'utente che lo chiama. Chiedere nuovamente la password ad un utente già autenticato è una doppia sicurezza per accertarmi che sia veramente lui e non qualcuno che si è impossessato del terminale dopo il login (l'utente legittimo ha lasciato la postazione incustodita). È anche un modo per rendere l'utente consapevole che è stato chiamato sudo, se ad esempio il comando è invocato dentro uno script. 7. File system Vedremo le caratteristiche di ext2, primo filesystem sviluppato appositamente per Linux. Compito di un filesystem è di memorizzare file di diversi tipi. Ci sono 5 principali tipi di file: regolari: possono contenere dati di qualunque tipo directory: un file che contiene altri file. La differenza con i file regolari è che, mentre i dati di un file regolare sono arbitrari e il sistema operativo non se ne interessa, il contenuto delle directory è gestito dal sistema operativo. link (simbolici) speciali: si dividono in file a blocchi (block) e file a caratteri (char), usati per accedere a dispositivi pipe, permettono la comunicazione tra processi I file regolari sono indirizzati da una struttura dati detta inode, che contiene i metadati del file: informazioni come dimensione, maschera degli accessi, date di creazione, modifica, ultimo accesso e soprattutto i puntatori ai blocchi di dati.I dati del file infatti sono suddivisi in blocchi, la cui dimensione varia da un filesystem all'altro. L'inode memorizza gli indirizzi di questi blocchi. L'inode viene identificato con un numero: esiste una inode table, un array i cui indici sono i numeri che identificano gli inode. Indirizzamento diretto inode->blocchi dati Per avere una memorizzazione efficiente dei file si usano più livelli di indirizzamento. All'interno degli inode abbiamo una quantità ridotta di puntatori ai blocchi di dati. Se il file di corto è finita qui: tutti i suoi blocchi di dati sono indirizzati dai puntatori dell'inode. Se il file è più grande entra in gioco l'indirizzamento indiretto: il blocco dati puntato dall'inode contiene a sua volta puntatori ad altri blocchi dati. È un modo furbo di moltiplicare i dati indirizzati da un singolo inode, ma il suo svantaggio è di aumentare i tempi di accesso, poiché dovrò attraversare più livelli di indirizzamento prima di arrivare ai dati. La dimensione degli indirizzi può essere 32 o 64 bit, a seconda dell'implementazione. Indirizzamento indiretto inode->blocchi di indirizzamento->blocchi dati Come si decide la dimensione dei blocchi di dati? Usando blocchi troppo piccoli avrò bisogno di più puntatori, e saranno peggiorati i tempi di accesso. Usando blocchi troppo grandi sprecherò spazio. La dimensione giusta è quindi un compromesso tra le esigenze di spazio e di efficienza. Non posso usare una soluzione dinamica, decidendo volta per volta la dimensione dei blocchi, perché peggiorerebbe di molto l'efficienza. Directory Le directory contengono le associazioni tra i nomi dei file regolari al loro interno e i numeri degli inode che lo compongono. Il sistema deve sapere qual'è la directory radice, dalla quale si possono raggiungere tutte le altre. VFS Non è infrequente che un sistema debba gestire più dispositivi di memoria di massa con filesystem diversi. Il VFS (Virtual File System) è un livello di astrazione interno al kernel che permette di gestire più filesystem, nonostante ognuno di questi memorizzi i file in modo diverso. Questo è indispensabile per avere un uso semplificato delle system call: è grazie al VFS che possiamo chiamare la open su qualunque file senza doverci preoccupare di come questo file è stato memorizzato dal filesystem. 7.1 Dispositivi a blocchi Un file speciale a blocchi è la virtualizzazione di un device driver. È detto a blocchi perché vi posso leggere e scrivere blocchi di dati da e verso il dispositivo. In un device a blocchi abbiamo una serie di blocchi numerati di dati, di dimensioni uguali e predefinite. Tramite un device driver io posso chiedere di leggere e scrivere su un blocco. Un file system memorizza in questi blocchi gli inode e i blocchi di dati dei file, e ogni file syetem ha un modo diverso di farlo. A livello fisico, su un disco dopo il boot sector sono memorizzati una serie di gruppi, ognuno dei quali è diviso in sei parti: superblock: una serie di dati che dicono qual'è lo stato del file system e ne permettono il mount file system descriptor: dice al sistema operativo quale file system stiamo usando Questi due blocchi sono fondamentali, se si corrompono non posso più accedere all'unità. Proprio per questa loro criticità vengono replicati in ogni gruppo: è un meccanismo di ridondanza per cui se uno di questi blocchi diventa inutilizzabile non perdo tutta l'unità disco. block bitmap: associa un bit ad ogni blocco per sapere se è usato o no inode bitmap: associa un bit ad ogni inode per sapere se è usato o no inode table: sequenza degli inode data blocks: blocchi di dati dei file Queste strutture non sono replicate: ogni gruppo ha i suoi blocchi e inode. Anni fa si tentava di mantenere i blocchi dati il più vicino possibile agli inode, per ottimizzare i tempi di accesso, ma oggi gli hard disk sono talmente sofisticati che il loro comportamento è scarsamente predicibile dal software. Questo livello di organizzazione di gruppi e blocchi è tipico di ext2, mentre la struttura in inode e blocchi è comune a molti file system, poiché è necessaria perché possano essere virtualizzati dal VFS. 7.2 Link fisici Abbiamo visto che il file system ha una struttura gerarchica delle directory, un albero le cui foglie sono file. Per accedere ad un file, dobbiamo prima vedere nella directory che lo contiene l'associazione tra il suo nome e i suoi inode. Un link non è altro che un'associazione tra una stringa di caratteri (il nome del file) e gli indici dei suoi inode. Se vogliamo cambiare il nome di un file, o metterlo in una directory diversa, non sarà necessario agire sugli inode o sui blocchi dati ma solo sull'associazione nome-inode. Se voglio rinominare, basta cambiare la stringa associata agli inode. Senza bisogno di cancellare il nome precedente: posso avere lo stesso inode associato a più nomi diversi, e non cambierà nulla accedervi con un nome o con l'altro: tutti gli attributi (permessi, proprietario ecc.) saranno gli stessi, poiché sono informazioni contenute nell'inode. Posso anche avere due link in due directory diverse che puntano allo stesso file. Uno stesso file ha due link in due directory diverse Perché un file (inode) sia accessibile, è necessario che nel file system ci sia almeno un link che punta il suo inode. Quando cancelliamo un file, in realtà eliminiamo solo il link specifico, l'associazione nome-inode in una specifica directory. Non si sta agendo su inode e dati, ma solo sul link. Nell'inode è presente un contatore del numero di link che lo puntano in tutto il file system, e i dati verranno eliminati solo quando non ci saranno più link a rendere il file accessibile. La creazione di un file comporta la creazione di un inode e di un link. Durante l'esistenza del file posso creare altri link per accedervi, con il comando ln, che prende in input il nome di un link già esistente e il nome del nuovo link. Quando lo voglio eliminare, con il comando rm elimino uno specifico link. L'applicazione rm poi controlla il contatore dei link all'inode, e se è a 1 ne elimina anche i dati. 7.3 Link simbolici Questo schema di accesso semplifica la condivisione di file tra utenti e applicazioni diverse. Ma cosa succede in un sistema in cui ho più file system montati in un'unica struttura? Avrò diverse numerazioni degli inode per ogni file system, quindi non potrò avere link da uno all'altro, a meno che io non usi i link simbolici. I link simbolici sono un livello di astrazione più alto che mi permette riferimenti incrociati da un filesystem all'altro: per la creazione dei link infatti non viene usato l'inode, ma un nome (pathname). Questo pathname farà riferimento ad un link in un altro file system, e andando a vedere quello avrò il numero di inode che mi permette di accedere al file. Un link simbolico è un tipo speciale di file: il suo inode fa riferimento a blocchi di dati che contengono una stringa di caratteri: il pathname assoluto del file puntato dal link. Vengono creati aggiungendo l'opzione -s al comando ln. Parecchie situazioni possono causare errori: ad esempio se il pathname puntato dal link simbolico non esiste, o se il file system che contiene il file non è ancora montato (facile se il file system in cui si trova il file è un dispositivo rimovibile). Con il comando ls -l i link simbolici ci verranno visualizzati nella forma nome -> pathname. 7.4 Consistenza delle informazioni Abbiamo visto che, in ext2, ogni blocco contiene alcune informazioni ridondanti, le bitmap di inode e blocchi dati, l'insieme degli inode e l'insieme dei blocchi di dati. Per creare un file avrò bisogno di: l'inode della directory che lo contiene l'inode del nuovo file almeno un blocco dati Dovrò marcare come utilizzati nelle bitmap l'inode e il blocco desiderati: nell'inode del file vi assocerò il suo blocco dati, e nella directory che lo contiene creerò un link inode-nome file. Cosa succede se la macchina si spegne durante la creazione di un file? Dipende dall'ordine con cui faccio le operazioni: se per prima cosa segno le bitmap avrò dei blocchi e degli inode marcati come utilizzati, ma che di fatto non lo sono; se invece prima memorizzo i dati, li indirizzo sull'inode e creo il link, avrò un file salvato correttamente i cui blocchi e inode sono marcati come spazio libero dalle bitmap. In fase di mount di un file system posso specificare se montarlo in sola lettura (read only) o in lettura e scrittura (read write). Questa informazione è memorizzata tra le informazioni ridondanti. Se un file system è montato con permessi di scrittura, tra le informazioni ridondanti viene aggiunto il flag dirty, che indica che il file system è a rischio di inconsistenza. Questo flag viene rimosso al momento del corretto spegnimento della macchina. In caso di spegnimento improvviso, all'avvio troverò ancora il flag dirty impostato, per cui saprò che il file system non è stato chiuso correttamente e potrebbe avere inconsistenze, e avvierò quindi un'applicazione di controllo e ripristino, che in UNIX è fsck. Questa applicazione tenta di ripristinare la consistenza dei dati, operazione che può essere fatta in tanti modi diversi: si può innanzitutto controllare se la struttura del file system dal punto di vista applicativo (albero delle directory) è coerente con le informazioni contenute nelle varie bitmap. Questa operazione richiederebbe tempi spropositati su file system di grandi dimensioni se non fosse implementata in modo estremamente ottimizzato. Richiede comunque alcuni minuti, per cui viene fatta il meno possibile: ogni volta che trovo il flag dirty all'avvio e ogni tot montaggi del file system, per controllo. Il contatore dei mount senza controlli viene memorizzato tra le informazioni ridondanti. Fino a una decina di anni fa, se fsck trovava file di cui si era perso il link li metteva, con un nome predefinito, nella directory /lost+found. 7.4.1 Journaling È più efficiente, piuttosto che eseguire periodicamente una lunga procedura di controllo di consistenza, avere un meccanismo che mi garantisca la consistenza dei dati sempre. È questo lo scopo del journaling, che consiste in: scrivere un elenco completo delle modifiche che voglio fare, prima di farle, e ci aggiungo un checksum per controllarne l'integrità. È importante che il checksum venga scritto per ultimo. effettuare le modifiche Se le operazioni si interrompono a metà, fsck può prendere l'elenco delle modifiche scritto all'inizio, controllare quali sono state effettivamente compiute e fare quelle che ancora rimangono in sospeso. Che succede se l'interruzione è avvenuta durante la scrittura del journal? Me ne accorgerò subito perché mancherà il checksum, quindi mi basterà eliminare il journal, perché sono sicuro che non è stata apportata nessuna modifica dato che la procedura impone che si inizi ad operare sul file system solo dopo aver scritto journal e checksum. C'è un problema di efficienza però: se per ogni operazione devo scrivere il journal rischio di raddoppiare i tempi di accesso all’unità. Quando si sceglie il file system da usare, bisogna valutare se è più importante l'efficienza o l'affidabilità per decidere se usare un file system con o senza journaling. C'è anche un fattore di usura dei dispositivi da considerare: ad esempio le memorie flash hanno un numero limitato di scritture che posso farci prima che si rompano, e sarà quindi preferibile usarvi un file system senza journaling. Ext2 non implementa il journaling, che è invece usato da ext3 ed ext4. 8. Virtual machine L'idea di virtual machine nasce insieme all'esigenza di eseguire più applicazioni contemporaneamente, ai tempi in cui le macchine erano ancora monoprogrammate. Si tratta di avere un hypervisor al di sotto del sistema operativo che virtualizza le risorse per permettere di avere più sistemi operativi in esecuzione contemporaneamente. Ogni sistema operativo avrà la sua porzione di tempo CPU, RAM e memoria di massa, per cui crederà di essere eseguito su una machina tutta sua, e le vaire macchine vituali eseguite su una stessa macchina fisica non possono interagire tra loro. Questa caratteristica aumenta la sicurezza del sistema perché, essendo ogni virtual machine isolata dalle altre, se un'applicazione danneggia il sistema operativo il danno sarà limitato alla VM nel quale è eseguito e non all'intero sistema. È stata una soluzione ideata da IBM per permettere ai suoi sistemi operativi di restare al passo coi tempi, offrendo la possibilità di eseguire più applicazioni sullo stesso sistema senza bisogno di riprogrammarlo da capo per implementare il multitasking. L'implementazione del multitasking nei sistemi operativi moderni ha soppiantato questa soluzione, che però rimane valida in caso di macchine grandi e costose. In molti casi infatti, per ridurre i costi di gestione, è conveniente avere una sola macchina molto potente su cui far girare molte VM, piuttosto che tante macchine a costo ridotto. Su una macchina di questo tipo avrò in esecuzione tante macchine virtuali più piccole, ma con l'affidabilità e la sicurezza della macchina fisica che le esegue. I datacenter affittano queste VM ai clienti, che dovranno occuparsi solo di usarle, senza preoccuparsi della manutenzione. Ad oggi, la maggior parte dei servizi Web è erogata da macchine virtuali 8.1 Hypervisor Ci sono due tipi di ipervisori: il tipo 1 è quello descritto in precedenza che sta sotto al sistema operativo, il tipo 2 viene invece eseguito da un altro sistema operativo. Ipervisori di tipo 2 sono usati tipicamente su personal computer per avere più sistemi operativi a disposizione, quelli di tipo 1 invece vengono usati sulle grandi macchine in uso dai datacenter. Emulatore L'ipervisore può anche emulare un'architettura hardware diversa per le macchine virtuali che esegue. Questo può servire ad esempio nello sviluppo di software, se voglio provare il codice su un'architettura diversa da quella della mia macchina fisica. Anche questa soluzione è stata ideata da IBM, per il passaggio dal System/360 al System/370. La nuova architettura hardware prevedeva delle istruzioni in più, e per questo il sistema operativo andava riscritto. Ma a quei tempi i sistemi operativi si scrivevano in assembly, e per iniziare lo sviluppo (che avrebbe richiesto alcuni anni) avrebbero dovuto aspettare di avere a disposizione le nuove macchine. Decisero quindi di emulare l'architettura del System/370 sui System/360 che già avevano e iniziare lì la programmazione del nuovo sistema. Realizzazione di un hypervisor Il sistema operativo eseguito da una macchina virtuale non si rende conto di essere su una VM, perché l'hypervisor implementa a livello software tutte le funzioni offerte dall'hardware: memoria virtuale, trap e interrupt, modalità privilegiata. Se la mia macchina fisica non implementa queste funzioni, non posso virtualizzare. Quando io avvio una macchina su cui è installato un ipervisore di tipo 1, questo fa le stesse operazioni che farebbe un normale sistema operativo: predispone la memoria virtuale, il trap handler ecc. Una volta terminato l'avvio e suddivise le risorse per le varie macchine virtuali, il processore ritorna in modalità utente. Ma i sistemi operativi delle macchine virtuali vorranno poter eseguire istruzioni privilegiate, e quando cercheranno di farlo scatterà una trap gestita dal trap handler dell'ipervisore, programmato per eseguire lui stesso le operazioni in modalità privilegiata e non lasciarle eseguire ai sistemi guest. Quando l'esecuzione del trap handler terminerà, il sistema operativo della VM sarà all'istruzione successiva, e troverà l'operazione privilegiata che ha richiesto già eseguita dal trap handler. Questo peggiora notevolmente l'efficienza dell'esecuzione di istruzioni privilegiate, poiché invece di essere eseguite direttamente vengono gestite da un trap handler. POSIX virtualizza le trap col meccanismo delle signal, definite a livello applicativo. Le signal però non sono state pensate per la gestione di ipervisori, quindi alcuni hypervisor di tipo 2 richiedono modifiche al sistema operativo che li esegue. 8.2 Rootkit Un rootkit è un software che altera il funzionamento del sistema per nascondere il suo comportamento reale alle applicazioni. Può essere utilizzato sia per difendersi dagli attacchi sia per farli. Un modo per implementare un rootkit è proprio con una macchina virutale. Un rootkit difensivo può essere implementato come un hypervisor, che controlla se il sistema operativo si sta comportando come dovrebbe. Un rootkit ostile invece può essere un malware che, una volta preso il controllo del sistema operativo installa un hypervisor che, nascondendo il suo funzionamento al sistema operativo, è al riparo da eventuali patch che non avrebbero più consentito il suo funzionamento se fosse stata eseguita come normale applicazione. 9. Standard ASN.1 ASN.1 è uno standard (ITU X.680) per definire regole sintattiche usate per descrivere dati che devono essere condivisi con più entità presenti in rete, senza preoccuparci di come verranno elaborati. Esempio: vogliamo descrivere i dati personali dei dipendenti di un'azienda. PersonnelRecord::=[APPLICATION 0] SET { name Name; title VisibleString; number EmployeeNumber; dateOfHire Date; nameOfSpouse Name; children SEQUENCE OF ChildInformation DEFAULT{} } È molto significativa la differenza tra maiuscole e minuscole: le parole che iniziano con la maiuscola sono tipi, quelle con la minuscola sono valori, mentre quelle tutte in maiuscolo sono parole chiave. SET: indica qualcosa di simile alla struct del C, come possiamo vedere nell'esempio: un'insieme di campi racchiusi tra parentesi graffe. Questi campi sono rappresentati con la sintassi nome Tipo. SEQUENCE OF: simile ad un vector del C++: un array, di dimensioni variabili. DEFAULT {} indica che il campo non deve essere necessariamente definito: nel caso in cui non venga specificato verrà usato come valore di default la lista vuota. I tipi all'interno del SET dovranno essere a loro volta definiti: Name ::= [APPLICATION 1] SEQUENCE { givenName VisibleString; initial VisibleString; familyName VisibleString; } Tra le parentesi quadre sono racchiuse opzioni che non verranno esaminate dall'analizzatore sintattico. È qualcosa di simile ad un commento, ma con più rigore: non posso metterci qualunque cosa, devono essere richiami a funzionalità aggiunte dall'utente (in questo caso una APPLICATION che richiede un parametro numerico). EmployeeNumber::=[APPLICATION 2] INTEGER; In questo caso EmployeeNumber è un numero intero, che è un tipo base: richiamo comunque APPLICATION con parametro 2, magari per controllare se il valore rientra in un range di valori consentiti. Date::=[APPLICATION 3] VisibleString -- YYYY MM DD Dopo il -- c'è un commento, che indica il formato con cui rappresentiamo la data. ChildInformation::=SET { name Name; dateOfBirth date; } Possiamo anche definire esplicitamente i dati, con la sintassi { name { givenName "John", initial "P", familyName "Smith" }, title "Director" ASN.1 prevede un buon numero di tipi base: non solo interi e stringhe, ma anche numeri reali, booleani e date (in formato UTC). Supporta anche qualcosa di simile agli enum del C, gli ENUMERATED, con cui posso definire ad esempio i giorni della settimana: ENUMERATED { domenica(0), lunedi(1),... } Meritano un discorso a parte gli spazi: a volte sono significativi e a volte no, e la cosa non è così immediata (non se la ricorda manco Chiola o.o), bisogna consultare la documentazione per sapere quando gli spazi contano e quando no. Si possono inoltre definire set di caratteri non standard e sistemi di compressione (con PACKED). 9.1 Rappresentazione binaria Finora ci siamo preoccupati di descrivere i dati in un modo che sia indipendente da tutto: linguaggi, macchine, implementazioni. Ma servirebbe a ben poco senza un'implementazione pratica: le informazioni scritte attraverso queste regole vanno codificate in forma binaria, e va fatto in maniera efficiente, con il minor utilizzo di spazio possibile. Per fare questo sono stati definiti altri standard che specificano il formato di rappresentazione binaria, descritti nel documento ITU X.690. L'ultima revisione di questi standard è del 2002. CER e DER hanno una rappresentazione canonica: ogni valore può essere rappresentato in un modo solo e quindi è possibile fare un confronto di uguaglianza direttamente sulla stringa binaria. E’ molto importante dal punto di vista pratico, e questa fa si che nella maggioranza dei casi venga utilizzato il formato DER. 9.1.1 BER (Basic Encoding Rules) Lo standard BER (Basic Encoding Rules) è uno di questi standard, e permette di codificare i dati in diversi formati. I campi ID, LENGTH e CONTENT sono comuni a tutti, ma se voglio inviare dati di lunghezza variabile posso usare un valore speciale per il campo LENGTH e terminare con un byte di End of content (EOC): serve a capire quando è finito il file visto che la lunghezza non è specificata nel campo LENGTH. Questa soluzione però mi impedisce di avere il byte di EOC all’interno del contenuto del file, perché verrebbe interpretato come terminatore invece che come contenuto. Il campo ID invece è un byte suddiviso in tre parti: due bit di CLASS più significativi che descrivono una classe di identificatori. Abbiamo quattro classi possibili: UNIVERSAL 00 APPLICATION 01 CONTENT-SPECIFIC 1 0 PRIVATE 11 I bit application, content specific e private offrono la possibilità di estendere l’uso di questo formato di rappresentazione per applicazioni particolari. Il bit P/C mi indica se il tipo di dato è primitivo o definito. I 5 bit rimanenti sono chiamati TAG. Come si può fare a codificare un valore di tipo booleano? Il primo byte assume valore 1: sette zeri seguiti da un uno. La lunghezza vale sempre 1, il contenuto, di un solo byte, conterrà o 0 (falso) o FF (vero). FF è la rappresentazione esadecimale di 8 uni, si usa questa soluzione perché l’unità minima allocabile è il byte, non posso usare un solo bit per rappresentare vero e falso. Per codificare un valore di tipo intero invece possiamo usare una quantità di byte proporzionale al valore che vogliamo rappresentare: può bastarci un solo byte per numeri piccoli, per numeri più grandi ce ne serviranno di più. Usare la lunghezza fissa o variabile è una scelta d’implementazione: la lunghezza fissa è più efficiente perché usa un byte in meno, la lunghezza variabile invece usa un byte in più ma è più semplice da calcolare, perché non richiede all’applicazione che la usa di sapere a priori qual’è la lunghezza del contenuto. Tornando alla rappresentazione degli interi, se scegliamo di usare la lunghezza variabile possiamo mettere nel campo LENGTH il valore di lunghezza variabile, scrivere gli ottetti che compongono il numero nel campo CONTENT e quando abbiamo finito scrivere il byte di EOC. Ci evita di dover calcolare la lunghezza del numero all’inizio, per scriverla nel campo LENGTH, ma ci impone di avere tutti i byte che compongono il numero diversi dal byte di terminazione. Supponiamo di dover rappresentare un numero >255, che quindi richiede 2 byte. Consideriamo i primi 9 bit (primo byte + primo bit del secondo): non posso averli tutti uguali a 0 o tutti uguali a 1. 9.1.2 PER (Packet Encoding Rules) L’idea di questo formato è di andare a ridurre il numero di bit utilizzati al minimo possibile. La compressione delle informazioni è fatta in due modi, e uno è eliminare il vincolo di organizzazione in ottetti per ottimizzare l’uso dello spazio: BER, CER e DER hanno 8 bit come unità minima allocabile, anche se i dati che voglio rappresentare potrebbero occuparne meno (abbiamo visto prima l’esempio dei booleani, con PER possiamo rappresentarli in un bit). L’eliminazione della suddivisione in byte porta alcune complicazioni: supponiamo di voler far stare all’interno di un byte più di un valore, come ad esempio un booleano da 1 bit e un esadecimale da 4 bit: ci rimarrebbero ancora 3 bit in cui potremmo mettere qualcos’altro. Però sorgono dei problemi di allineamento, dovuti al modo con cui sono implementati i processori: se considero ad esempio gruppi da 4 bit potrei pensare di indirizzare col valore 0 i 4 bit più a sinistra di un byte e col valore 1 i 4 bit più a destra, per scegliere quale gruppo da 4 bit devo considerare all’interno di un byte. Se metto il booleano nel primo bit, l’esadecimale va messo negli ultimi 4 e perderò 3 bit. Il formato PER ammette sia la versione allineata che non allineata dei dati, che però richiede programmi più complessi per accedere ai dati visto che non potrò accedervi con le istruzioni predefinite del processore (che richiedono allineamento). Quindi la scelta tra formato allineato e non allineato è una scelta tra spazio ed efficienza dei programmi che accedono ai dati. La seconda tecnica di compressione consiste nell’inserire solo le informazioni strettamente indispensabili per rappresentare i dati. Nella rappresentazione BER abbiamo visto che per rappresentare un valore ci sono molte codifiche aggiuntive oltre al valore vero e proprio: campo TAG, lunghezza ecc, informazioni che vengono ripetute in ogni dato di quel tipo. Si può pensare di mettere da una parte le informazioni che ci dicono come il dato è strutturato, e dall’altra i dati veri e propri: in questo avremo le informazioni sulla struttura del tipo di dato memorizzate una volta per tutte e non ci sarà bisogno di ripeterle in ogni istanza. Questa soluzione impone alcune limitazioni: dato che nei singoli dati non ci sono informazioni sulla loro struttura, abbiamo bisogno che queste informazioni ci vengano date in qualche modo per poter decodificare un dato. La codifica PER è definita nello standard ITU X.691. Esiste anche una codifica basata su XML, XER (XML Encoding Rules). 10. Thread I thread sono flussi di esecuzione differenti eseguiti all’interno di uno stesso processo. Il concetto di processo è pensato per avere programmi in esecuzione indipendenti uno dall'altro: ogni processo ha la sua porzione di risorse virtualizzate che usa indipendentemente da quello che fanno gli altri processi in esecuzione. Per i thread l'idea è esattamente opposta: vogliamo avere più programmi in esecuzione che possano vedersi e condividere il più possibile le risorse. Se i processi sono in competizione tra loro per ottenere le risorse, con il sistema operativo a fare da arbitro per fare in modo che tutti possano funzionare, i thread cooperano tra loro vivendo in un'ambiente comune messo a disposizione dal sistema operaitvo, ma non gestito da esso: la suddivisione delle risorse all'interno dell'ambiente è autogestita dai thread stessi. L'ambiente comune è un processo, e i file descriptor al suo interno sono visibili a tutti i thread. C'è il rischio di conflitti nell'utilizzo delle risorse, ma la loro gestione e prevenzione è affidata al programmatore e non al sistema operativo. In un sistema POSIX quando creo un processo al suo interno viene creato un solo thread, eseguito sequenzialmente. L'utilizzo di thread fa risparmiare nella gestione dei processi, poiché molte strutture dati non verranno replicate. Si risparmia inoltre nel costo della comunicazione tra processi: questa infatti richiede l’intervento del sistema operativo, mentre la comunicazione tra thread avviene attraverso le aree di memoria comuni. Dal punto di vista dell'allocazione della memoria i thread devono risiedere in uno stesso segmento. Come abbiamo già visto, la parte utente di memoria di un processo è suddivisa in codice, heap, dati statici e stack. Nell'utilizzo dei thread il codice, lo heap e i dati statici sono condivisi, mentre lo stack no: questo perché voglio che i thread vedano gli stessi dati e possano chiamare le stesse funzioni, mentre lo stack dipende dalle funzioni chiamate dai singoli thread e quindi non può essere condiviso, ne viene creato uno per ogni thread. Per comunicare informazioni contenute sullo stack con altri thread, andranno copiate in una delle aree di memoria condivisa, ad esempio tramite variabili globali. I thread, come i processi, vengono eseguiti singolarmente, e il loro ordine di esecuzione è deciso da uno scheduler del sistema operativo, a meno che il processore non supporti l'esecuzione di più thread contemporaneamente. In questo caso l'utilizzo di thread incrementerà la velocità del sistema. Ma se l'ordine di esecuzione non è predicibile, come fanno i thread a cooperare tra loro? Esistono delle primitive di sincronizzazione come pthread_join, che blocca il thread che la chiama finché l’esecuzione di un altro thread (specificato nei parametri) non termina. Senza questa funzione non potremmo sapere quando il risultato dell'esecuzione di un thread, generalmente memorizzato in una variabile globale, può essere letto. Un thread può essere joinable o non joinable: di default tutti i thread sono joinable, se voglio crearne uno non joinable devo specificarlo. Non è l'unico tipo di sincronizzazione di cui ho bisogno: devo anche sincronizzare l'accesso alle variabili comuni, in modo che vengano modificate nella giusta sequenza per produrre risultati corretti. Per fare questo esistono delle primitive chiamate mutex (mutual exclusion), che fanno in modo che due thread non possano essere eseguiti contemporameamente. Quando creo un thread, la funzione pthread_create ha anche il compito di passare alla funzione chiamata i parametri necessari. E dovranno naturalmente essere visibili al nuovo thread: non potranno quindi essere memorizzati nello stack del chiamante, solitamente si utilizza un array memorizzato tra i dati statici. Un array per consentire che a diverse istanze della stessa funzione possano essere passati parametri diversi. La programmazione con i thread è particolarmente complicata, soprattutto in fase di debugging, perché non avrò sempre la stessa sequenza di esecuzione dei thread. 12.1 Primitive per l'uso dei thread pthread_create(....,funzione,...): crea un nuovo thread che esegue la funzione specificata. La funzione deve avere un singolo parametro di tipo void* e tipo di ritorno void*. Quando creiamo un nuovo processo, di default viene creato un solo thread che chiama la funzione main. pthread join(thread_id,): blocca il thread che l'ha chiamata in attesa che termini il thread specificato da thread_id. È una system call bloccante. pthread_mutex_lock/unlock: la lock, se chiamata da tutti i thread, consente solo ad uno l'esecuzione e blocca tutti gli altri (system call bloccante), in modo che le variabili possano essere modificate dal thread in esecuzione con la certezza che gli altri thread non gliele stanno cambiando a loro volta. Una volta finito, il thread chiama la unlock, e un altro dei processi che aveva chiamato la lock si sblocca e procede (da solo) nell'esecuzione. 12.2 Thread-safety: prevenzione delle race conditions Analizziamo il risultato dell'esecuzione di questo codice C: static int i=1; void* t1 (void* p) { int v=*(int*)p; v++; *(int*)p=v; } void* t2 (void* p) { int v=*(*int)p; v--; *(int*)p=v; } da qualche altra parte avverranno le chiamate pthread_create(myt, NULL, t1, (void*)&i); pthread_create(myt+1, NULL, t2, (void*)&i); for(j=0; jarpa=>in-addr=>tutti i numeri che possono comporre un IP (nelle slide si capisce meglio). 3.2 Protocollo HTTP (RFC 1945 del 1992) Si sta lavorando alla versione 2.0, ancora in fase sperimentale (non standardizzata). È un protocollo applicativo basato su richiesta e risposta, sfrutta il TCP come protocollo di trasporto e trasmette sulla porta 80. Usa il TCP perché non vogliamo perdite di dati e perché la dimensione del messaggio non è stabilita a priori. Il browser richiede la pagina partendo da un URL (Universal Resource Locator) che ci indica il server su cui si trova la pagina e la sua posizione nel filesystem del server. A seguito della richiesta, il protocollo DNS ottiene l'indirizzo IP del server. Quando installiamo un web server, i file pubblicati sul web saranno nella directory /var/www Quando una richiesta HTTP va a buon fine viene restituita una risorsa, che può esssere una pagina HTML, codice JavaScript, immagini o video ecc. Quando digitiamo un URL nel browser viene formulata una richiesta HTTP, composta da header e body. Nell'header c'è una prima riga che specifica il tipo di richiesta che stiamo facendo: comprende metodo, URL e versione del protocollo, separati da uno spazio. Se richiediamo una versione del protocollo non supportata dal server, ci risponderà con quella precedente. I metodi di richiesta più usati sono GET, POST e PUT. Nelle righe dell'header vengono inclusi: host (poiché la request line include solo il percorso interno al server, senza specificarne il nome di rete), user agent, lingua in uso e altre informazioni. Le header lines sono separate dal body da due caratteri carriage return e line feed. Il body contiene le informazioni inviate nelle richieste POST e PUT, usate quando carichiamo qualcosa. Non sempre i dati vengono inviati nel body però: per piccoli trasferimenti i dati da inviare possono essere inclusi nella request line (ad esempio quando facciamo una ricerca le keyword appaiono nella barra degli indirizzi: sono quindi state inviate nella request line). Alla nostra richiesta il server ci invierà una risposta, strutturata in modo simile. La prima riga è la status line, contenente la versione del protocollo con cui ci è stata inviata la risposta, un codice di status (come il 404) e una stringa che descrive meglio l'esito della richiesta (OK, Not Found ecc.). Nelle header lines ci sono informazioni sulla risorsa e sul server, come data e ora di invio, nome del web server (Apache, Google Web Server...), ultima modifica, dimensione della risorsa, tipo di risorsa. Il body contiene la risorsa richiesta (pagina HTML, immagine ecc.) Le informazioni su data e ora di modifica vengono date solo se la risorsa richiesta è una pagina statica. In caso di pagine dinamiche no perché non esistono sul filesystem, sono state create appositamente per noi in seguito alla nostra richiesta. Le date sono in formato standard GMT. Appena finita la lettura dell'header, il browser inizia il rendering della pagina. Un server web può anche essere interrogato da terminale con il comando telnet: dopo aver stabilito la connessione con telnet nome.server.com 80 possiamo inviare a mano le richieste HTTP (bisogna essere veloci però, altrimenti scatta il timeout e il server ci chiude la connessione) 3.2.1 Prestazioni di HTTP La principale misura delle prestazioni di HTTP è il PLT (Page Load Time) che dipende sia dalle caratteristiche della rete (TCP, round trip time, banda disponibile) sia della risorsa da trasferire. La versione 1.0 dell'HTTP apriva una nuova connessione TCP per ogni risorsa trasferita. Poco performante per le pagine ricche di risorse, poiché si perde tempo a stabilire un sacco di connessioni. Le prestazioni sono state migliorate con tecniche di riduzione del contenuto (minifing): programmi che rendono il codice ultra compatto eliminando spazi, a capo, ed ogni carattere non indispensabile Browser che supportano fino a 6 connessioni parallele per dominio (dal 2008) Connessioni persistenti e pipelining: modifiche al protocollo che permetrono di sfruttare la banda al meglio. In particolare le connessioni persistenti sono una soluzione al problema detto prima degli sprechi di tempo per stabilire la connessione: una connessione persistente viene usata per più risorse e non viene terminata in caso di errori. Il pipelining invece serve ad inviare sequenzialmente le richieste, si può inviare una nuova richiesta senza dover prima aspettare la risposta a quella precedente. Caching e proxy: tecniche per evitare di trasmettere più volte lo stesso contenuto Content delivery network (CDN): l'idea che sta alla base delle CDN è di portare i contenuti il più vicino possibile agli utenti. Consistono in mirror (repliche) del sito in questione, messi a disposizione tramite leggere modifiche al DNS, che restituiranno una diversa risoulzione dell'indirizzo a seconda di dove si trova il client che ne ha fatto richiesta, in modo da dargli l'indirizzo del mirror più vicino. Un esempio di CDN sono le risorse di Facebook che ci appaiono con indirizzo fbstatic-*.akamaihd.net Il sito www.webpagetest.org ci fa vedere cosa succede quando apriamo una pagina. Caching e proxy Un ottimo modo per risparmiare tempo in trasferimenti inutili è il caching: ogni volta che visitiamo una pagina statica ne salviamo una copia locale, e per le richieste successive useremo la copia salvata invece di scaricarla nuovamente. Questo ovviamente se la pagina sul server non è cambiata, e questo posso verificarlo senza fare richieste al server: l'header Expired, infatti, include la data di scadenza della pagina, e se non è ancora passata posso usare la mia copia locale. Se quando ho scaricato la pagina il server non mi ha fornito la riga Expired nell'header, posso comunque servirmi della cache senza riscaricare la pagina: invio al server una richiesta di GET condizionale in cui dico di inviarmi la pagina solo se è stata modificata dall'ultima volta che l'ho scaricata. Se la pagina non è stata modificata, il server mi invierà una risposta 304 Not Modified, se è stata modificata mi invierà un 200 OK con la pagina richiesta. Se la copia posseduta in cache dal client è valida, la risorsa è disponibile in un RTT: infatti viene inviata la richiesta If-Modified-Since al server, che invierà la risorsa solo se è più recente della nostra copia, e se non lo è risponderà con un header 304 senza risorsa allegata. Web proxy Il proxy è un intermediario tra il client e il server. È utile a gestire il caching in una rete locale: se tutti i client di una determinata rete passano attraverso un proxy, questo terrà una copia cache delle pagine visitate da tutti gli utenti della rete, aumentando quindi le possibilità di hit. Ci sono alcuni header di Cache-Control che possono definire se e come la pagina va memorizzata in cache. 3.2.2 Accesso con autenticazione Un server web può decidere di non restituire alcune pagine quando vengono richieste, poiché riservate, ma di richiedere invece le credenziali per assicurarsi che il client sia autorizzato a visualizzare la pagina. Questo può essere fatto tramite speciali header HTTP (codice 401 Authorization Required), che faranno visualizzare al client una finestra di dialogo in cui inserire le credenziali. Non ha a che fare con i siti che richiedono login in una pagina (Facebook ecc.), questo è un diverso tipo di autenticazione fatta direttamente a livello di protocollo. L'amministratore di sistema del server può creare un file.htaccess, che ha l'effetto di proteggere con questo sistema la directory in cui si trova. Se l'header Authorization ha richiesto una connessione Basic username e password vengono inviati in chiaro (codificati in base 64), altrimenti vengono criptate. Le informazioni di autenticazione vanno inviate ad ogni richiesta, anche dopo che l'utente ha fatto il login. 3.2.3 Cookies Introdotti da Netscape a metà anni '90, i cookies servono a tenere traccia dell'utente in modo che si possano avere informazioni aggiuntive non gestite dal protocollo (stato del login, carrello degli acquisti). Standardizzati dall'RFC 2965, i cookies sono file di testo contenenti nome, data di scadenza, dominio, contenuto e un attributo secure che specifica se il cookie può essere trasferito solo su connessioni sicure. Vengono rilasciati dai siti quando vengono visitati, attraverso l'header Set-Cookie incluso nella risposta HTTP. Sono molto usati per riconoscere gli utenti a fini pubblicitari. Per motivi di privacy, il browser invia un determinato cookie solo al dominio che li ha rilasciati. Non sono sicuri per l'autenticazione. Ne vengono memorizzati al massimo 300 per browser, 20 per dominio e hanno dimensione massimo di 4 KB. Un problema per la privacy sono i third-party cookies: cookies di inserzionisti che riceviamo anche se non abbiamo visitato la pagina in questione, ma basta aver visitato una pagina che aveva un'inserzione pubblicitaria di quell'azienda. Questo permette alle grandi aziende pubblicitarie di avere un'enorme collezione di informazioni, e quindi di poter offrire pubblicità personalizzate agli utenti. 3.3 Posta elettronica Esistono diversi standard proprietari per la posta elettronica (i.e. MS Exchange), ma quello usati da tutti è la posta Internet. L'affidabilità è "best effort": il messaggio potrebbe anche non arrivare (ad esempio se la casella del destinatario è piena) ma il sistema sa gestire l'errore e avvisa il mittente del mancato recapito. Il formato dei mesaggi è testo ASCII standard a 7 bit (ASCII senza caratteri speciali): questo perché è nata negli anni '80 (niente multimedialità) negli USA (niente caratteri speciali). L'architettura del protocollo è client/server, e come HTTP può essere usato via Telnet (se scrivi veloce, perché scattano i timeout). Nelle slide un'immagine dell'ecosistema e-mail. La posta elettronica è un sistema asincrono: l'invio e la ricezione di un messaggio sono scollegate tra loro. Il client invia la mail al suo server di posta, che lo inoltra al server di posta del destinatario, che lo inoltrerà a sua volta al destinatario. Le informazioni sul destinatario vengono trovate a partire dall'indirizzo e-mail: da quello conoscerò il server ricevente, che ha le informazioni del destinatario. 3.3.1 Protocollo SMTP (RFC 821/822, aggiornati a 2821/2822) Usa TCP come protocollo di trasporto. Usa la porta 25 per conessioni non protette, la porta 465 per connessioni protette (SSL). L'interazione client/server è basata su doma