Alma Mater Studiorum - Universita' di Bologna Sede di Cesena Reti di Calcolatori Esercitazione 1 Implementazione di un superserver Unix di rete Vedi: • W.R. Stevens, Unix Network Programming, Prentice Hall Copyright © 2006-2014 by D. Romagnoli & C. Salati 1 Servizi nel mondo di Unix • Tipicamente uno host internet supporta svariati servizi di rete (e.g. ftp, telnet, TFTP, ...). • Ogni servizio di rete, concorrente o sequenziale, dovrebbe • comportarsi secondo uno degli schemi visti a lezione. • essere attivato dal sistema alla partenza e rimanere in permanenza attivo in attesa di richieste di clienti; richieste che potrebbero pero’ anche non arrivare mai! (in gergo Unix un processo che si comporta in questo modo e’ chiamato daemon, tradotto impropriamente, come demone) • In realta’ la struttura dei servizi standard nel mondo Unix (e.g. cat, csh, …) non e’ quella degli schemi visti a lezione per i servizi di rete. • Un servizio standard Unix ha la struttura di un filtro Unix. • Quindi, quello che vorremmo davvero e’ che anche i servizi di rete fossero strutturati come dei normali filtri Unix. • Cosi’ potremmo anche esportare automaticamente in rete tutti i servizi locali implementati come filtri Unix (e.g. il servizio cat). 2 Struttura canonica di un server CO concorrente // trascuriamo la trattazione degli errori int sockfd, newSockfd; sockfd = socket(. . .); bind(sockfd, . . .); listen(sockfd, 5); for (;;) { newSockfd = accept(sockfd, . . .); if (fork() == 0) { // processo figlio/clone close(sockfd); doYourJob(newSockfd); close(newSockfd); exit(0); } else { // processo padre close (newSockfd); } 3 } Struttura canonica di un server CO sequenziale // trascuriamo la trattazione degli errori int sockfd, newSockfd; sockfd = socket(. . .); bind(sockfd, . . .); listen(sockfd, 5); for (;;) { newSockfd = accept(sockfd, . . .); doYourJob(newSockfd); close(newSockfd); } • Il server sa che opera su risorse reali di tipo socket • Il server sa su quali risorse opera (conosce il sockaddr del socket) • Il server e’ attivo in permanenza in attesa di nuovi clienti 4 Struttura canonica di un server CO: in realta’ … .1 • Il server e’ composto di 2 parti: 1. Una parte generica, indipendente dal particolare servizio che viene offerto, di attesa di nuovi clienti 2. Una parte specifica, dipendente dal particolare servizio che viene offerto, e che riguarda il servizio del (l’interazione con il) singolo cliente • Parte generica del server: • Vede e gestisce esplicitamente l’accesso al Servizio di Trasposto tramite system call specifiche dell’API socket • Conosce il sockaddr del server socket • E’ sempre viva, in attesa di nuovi clienti (nuove richieste di servizio) • E’ lei che determina se il servizio e’ sequenziale o concorrente 5 Struttura canonica di un server CO: in realta’ … .2 • Parte specifica del server (sostanzialmente, funzione doYourJob()): • Vede l’accesso al Servizio di Trasporto soltanto in modo opaco, tramite l’uso del file descriptor associato al socket di comunicazione con il cliente, e utilizzando solo le system call generiche read() e write() • Il file descriptor tramite cui interagire con il cliente e’ l’unico parametro di input della parte specifica • Non conosce il sockaddr del server socket, e in realta’ non sa nemmeno che la risorsa reale rappresentata dal file descriptor tramite cui interagire con il cliente e’ un socket • La durata della sua vita e’ collegata a quella del servizio offerto al cliente cui e’ associata La parte generica potrebbe essere messa a fattor comune tra tutti i server 6 Struttura di un filtro Unix .1 • E’ creato dinamicamente quando il servizio da lui implementato e’ invocato dal cliente. Non e’ attivo in permanenza in attesa di nuovi clienti. E’ chi attiva il servizio (e.g. una shell) che determina se il servizio stesso sara’ eseguito in modo concorrente o sequenziale. • Serve un singolo cliente, poi muore. • Interagisce con il mondo esterno tramite • standard input (in realta’ il file descriptor = 0) e • standard output (in realta’ il file descriptor = 1) , • che accede tramite operazioni di read() e write(). • Al momento della sua attivazione i file descriptor 0 e 1 sono gia’ aperti e collegati a risorse reali del sistema: e’ su queste risorse che il filtro opera, rispettivamente, in lettura (read()) e in scrittura (write()). • E’ chi attiva il servizio che nel momento in cui lo fa (e.g. tramite command line/shell) definisce il significato dei (le risorse reali associate ai) file descriptor 0 e 1. 7 Struttura di un filtro Unix .2 • Il processo figlio / filtro Unix eredita dal processo padre i file descriptor 0 e 1 gia’ aperti sulle risorse di sistema che esso dovra’ utilizzare. • Il filtro ignora quali siano le risorse reali di sistema che accede tramite i file descriptor 0 e 1. • Il filtro ignora anche quale sia il tipo delle risorse che accede tramite i file descriptor 0 e 1: • File vero e proprio • Device driver • Pipe • ... E quindi, anche: socket! • Il filtro sa che puo’ operare su queste risorse tramite operazioni di read() e write(). • Se la risorsa di sistema e’ un TSAP, questo deve essere: • Gia’ bind-ato • Gia’ connesso ad un pari remoto 8 Struttura di un filtro Unix: esempio d’uso #cat copia standard input in standard output #cat > dstFile copia standard input in dstFile #cat srcFile copia srcFile in standard output, visualizza cioe’ su console il contenuto di srcFile #cat srcFile > dstFile copia srcFile in dstFile #cat > dstFile Questo testo sara’ inserito in dstFile. ^D # # e’ il prompt della shell in esecuzione 9 Struttura canonica di un filtro Unix // // // // // l’interazione con il mondo esterno avviene tramite i due file descriptor 0 e 1 che il server-filtro eredita gia’ aperti e associati alle risorse di sistema opportune dal processo padre doYourJob(0, 1); exit(0); N.B.: • I file descriptor 0 e 1 sono di norma associati a risorse reali diverse ma niente impedisce che essi siano associati ad una stessa risorsa (che in questo caso deve essere capace di supportare contemporaneamente operazioni di read e di write!). • In realta’ poi, un socket rappresenta davvero 2 risorse separate: • Uno stream di byte in ingresso • Uno stream di byte in uscita 10 Struttura di un server CO e filtri Unix int sockfd, newSockfd; sockfd = socket(. . .); bind(sockfd, . . .); listen(sockfd, 5); for (;;) { newSockfd = accept(sockfd, . . .); if (fork() == 0) { // processo figlio/clone close(sockfd); // la parte specifica del server e’ come un // filtro Unix, con 2 fd come parametri di // ingresso, input fd e output fd doYourJob(newSockfd /*in*/, newSockfd /*out*/); close(newSockfd); exit(0); } else { // processo padre close (newSockfd); } 11 } Server di rete e filtri Unix .1 • La parte generica dei server di rete puo’ essere messa a fattor comune: • Gestisce l’attesa di nuovi clienti per tutti i servizi di rete. • Determina se l’attivazione di ciascuno di questi servizi deve avvenire in modo sequenziale o concorrente. • Attiva un server specifico quando vede arrivare dalla rete una richiesta di nuovo servizio indirizzata a lui. (quindi deve conoscere a priori il file name del programma eseguibile che implementa il servizio!) • Si comporta rispetto ai clienti sulla rete come fa una shell rispetto all’operatore seduto al terminale! • I servizi specifici di rete sono implementati come filtri Unix cosi’ come i servizi specifici locali. A questo punto, in realta’, non c’e’ nessuna differenza tra un servizio di rete e un servizio locale. cat puo’ ad esempio essere utilizzato per realizzare un servizio di eco TCP! Come? 12 Server di rete e filtri Unix .2 • Quando si interagisce con i servizi tramite shell, si identifica il servizio che si vuole attivare con il suo nome. Indicando il file name del programma eseguibile (filtro Unix) che implementa il servizio e che si vuole che sia messo in esecuzione. • Quando si interagisce con i servizi tramite il superserver (generico) di rete, si identifica il servizio che si vuole attivare attraverso la sua porta (il suo TSAP) well known. Ovviamente rimane comunque necessario, per il superserver di rete, conoscere quale e’ il file name del programma eseguibile (filtro Unix) che implementa il servizio che e’ stato richiesto, altrimenti come potrebbe attivarlo? • E’ ovvio che il superserver non puo’ implementare direttamente nessun servizio, nemmeno se questo e’ sequenziale. Come la shell il superserver non conosce la semantica e il protocollo dei diversi servizi che offre. Il superserver deve comunque rimanere in attesa di nuove richieste di servizio, relative ad altri servizi. 13 File e file descriptor in Unix • Nel mondo Unix tutte le risorse del sistema, indipendentemente da quale sia il loro tipo reale, sono accedute in modo uniforme: 1. Tramite l’uso di un file descriptor (sono viste come dei file). 2. Tramite uno stesso insieme di system call (circa). • Un file descriptor e’ una handle che consente ad un processo di accedere alla risorsa reale associata a quel file descriptor. 1. L’associazione di una risorsa reale ad un file descriptor avviene (di norma) tramite l’invocazione della system call open() (ma un filtro eredita i file descriptor su cui operare dal processo padre). 2. La disassociazione di una risorsa reale da un file descriptor avviene tramite l’invocazione della system call close(). • Implementativamente un file descriptor e’ costituito da un intero non negativo di piccole dimensioni (“a small, nonnegative integer”, Linux man page): attualmente con valore compreso tra 0 e 1023. • Dal punto di vista del sistema operativo il file descriptor e’ un indice nel vettore User File Descriptor Table contenuto nel descrittore di 14 ciascun processo. La User File Descriptor Table • Nella User File Descriptor Table sono contenuti i riferimenti a tutte le risorse reali utilizzate (accedibili) dal processo in un dato momento. • Quando un processo esegue una system call open() Unix cerca nella User File Descriptor Table la prima entry libera a partire da 0 e la associa alla risorsa reale riferita nella system call open(). • Quando un processo esegue una system call close() Unix dichiara libero il file descriptor (la entry corrispondente nella User File Descriptor Table) riferito nella chiamata della system call. • Eseguendo la system call fork() tutte le risorse del sistema che erano accedibili dal processo padre rimangono accedibili anche dal processo figlio, e cio’ utilizzando gli stessi valori di file descriptor. Al momento dell’esecuzione della system call fork() la User File Descriptor Table del processo padre viene copiata nella User File Descriptor Table del processo figlio. • Dal punto di vista dell’utente e’ possibile clonare un file descriptor su un altro file descriptor tramite una delle due system call dup() e 15 dup2(). File Descriptor Table 16 Unix system programming • int dup(int fd); • fd è il file descriptor da duplicare • L’effetto di una invocazione di dup() è di copiare l’elemento fd della tabella dei file aperti nella prima posizione libera (quella con l’indice minimo tra quelle disponibili). • Restituisce il nuovo file descriptor (quello destinazione dell’operazione di copiatura), oppure -1 (in caso di errore). • int dup2(int oldfd, int newfd); • oldfd è il file descriptor da duplicare. • newfd è il file descriptor in cui deve essere duplicato. • L’effetto di una invocazione di dup2() è di copiare l’elemento oldfd della tabella dei file aperti nell’elemento newfd della stessa tabella. • N.B. se newfd era gia’ in uso al momento dell’invocazione di dup2() esso viene implicitamente chiuso (tramite close()). • Restituisce newfd oppure -1 (in caso di errore). 17 Protocollo di attivazione di un filtro Unix • Per attivare un servizio standard (un filtro Unix), il processo padre (e.g. una shell) deve duplicare se’ stesso tramite l’invocazione della system call fork() per poi mettere in esecuzione nel suo clone figlio il codice eseguibile del programma filtro. • Per attivare correttamente un filtro Unix e’ quindi sufficiente che il processo padre: • Si cloni tramite chiamata alla system call fork(). • Nel processo figlio dup-lichi sui file descriptor 0 e 1 i file descriptor (le risorse reali) su cui vuole fare lavorare il server-figlio. (e chiuda tutti gli altri file descriptor che non interessano al serverfiglio) • Nel processo clone figlio metta in esecuzione il codice del filtro tramite invocazione della system call exec(). • N.B.: I file descriptor 0 e 1 sono come due parametri formali a cui il chiamante associa come argomenti attuali le risorse reali che il filtro 18 Unix deve utilizzare per input e output. La system call fork() int fork(void); • Crea un nuovo processo che e’ un clone esatto del processo chiamante (padre). • Il nuovo processo (figlio): • Esegue lo stesso codice del processo padre e, al termine della system call, ha il program counter posizionato all’istruzione successiva a quella contenente l’invocazione della system call fork(). • Come spazio dati ha una copia di quello del processo padre. • Condivide le risorse di sistema accedibili dal processo padre: la sua User File Descriptor Table e’ una copia di quella del processo padre. • La system call fork() ritorna: • In caso di errore, un numero negativo al processo padre (il processo figlio non e’ nemmeno stato creato). • In caso di terminazione corretta (esecuzione con successo), • Il PID del processo figlio al processo padre, • 0 al processo figlio. Cio’ permette di capire, al ritorno dalla fork(), se si e’ il processo padre oppure il processo figlio. 19 Il superserver inetd di Unix • Il superserver inetd e’ un demone (un processo attivato dal sistema alla partenza e che rimane sempre attivo). • inetd ha 3 scopi (il terzo conseguenza del secondo): 1. Evitare che tutti i diversi server di rete del sistema debbano essere attivi in permanenza. inetd rimane in attesa delle richieste dei clienti al posto dei singoli server supportati, e attiva un server solo al momento in cui c’e’ effettivamente una richiesta di servizio pendente per lui. 2. Consentire ai singoli server di ignorare l’interazione con la rete, comportandosi (essendo implementati) come normali filtri Unix. 3. Consentire quindi di esportare sulla rete qualunque servizio implementato come filtro Unix (in particolare, servizi gia’ esistenti). • Per fare questo inetd deve conoscere quali sono i servizi di rete definiti sul sistema: • Su quale servizio di trasporto (TCP o UDP) e su quale porta well known e’ offerto un servizio. • Quale e’ il file eseguibile (filtro) che implementa il servizio. • Il superserver acquisisce le informazioni sui servizi che deve supportare da un file di configurazione. 20 Il file di configurazione del superserver inetd Assumeremo che la sintassi del file di configurazione sia la seguente: • Ogni riga del file descrive un servizio. • Ogni servizio e’ descritto da una riga che ha la sintassi seguente: <servizio> ::= <filter pathName> <transport protocol> <port number> <wait flag> <argument list> • <filter pathName> e’ il pathname del codice eseguibile che implementa il servizio (come filtro). • <transport protocol> puo’ assumere i valori tcp o udp. • <port number> e’ il numero della porta well known che identifica il servizio sulla rete. • <wait flag> indica se il servizio deve essere offerto in forma sequenziale (wait) o concorrente (nowait). • <argument list> specifica una lista di parametri (N.B.: uguali per ogni invocazione) che il superserver deve passare al filtro che 21 implementa il servizio nel momento in cui lo attiva. Il file di configurazione del superserver: esercizio • Nota che ci potrebbero essere diversi parametri che caratterizzano un Servizio Applicativo dal punto di vista del suo utilizzo del Servizio di Trasporto. • Quali potrebbero essere ad esempio dei parametri significativi di Trasporto specifici di ciascun Servizio Applicativo nel caso di • servizi Applicativi basati sul Servizio di Trasporto TCP? • servizi Applicativi basati sul Servizio di Trasporto UDP? • Come dovrebbe essere modificata di conseguenza la struttura del file di configurazione del superserver? 22 Esempio di file di configurazione del superserver inetd /bin/cat tcp 10001 nowait /bin/csh tcp 10002 nowait –i (o bash se non funziona csh) /bin/java tcp 10003 wait prog1 arg1 • La prima linea descrive il servizio cat (copiatura da file a file) su TCP che utilizza la porta 10001 e funziona in modalita’ concorrente (nowait). N.B.: questo servizio, essendo i file di input e di output associati ad un unico socket, implementa sulla rete un servizio di eco. • La seconda linea descrive il servizio shell (csh) su TCP che utilizza la porta 10002, funziona in modalita’ concorrente (nowait) e ha come parametro di ingresso “-i”. • La terza linea descrive il servizio prog1 su TCP implementato in Java, che utilizza la porta 10003, funziona in modalita’ sequenziale (wait) e ha come parametro di ingresso “arg1”. Attenzione: per i servizi implementati in Java il nome dell’eseguibile che deve essere messo in esecuzione dal superserver e’ quello della Java VM, alla quale occorre passare il nome del servizio effettivo (prog1 nel nostro caso), piu’ gli eventuali parametri. 23 Servizi sequenziali e servizi concorrenti .1 • Ogni servizio, implementato come un filtro, ignora la nozione di servizio sequenziale/concorrente. • E’ il superserver che e’ responsabile, per ogni servizio, di implementare questa nozione, in base all’indicazione relativa presente nel file di configurazione. N.B.: e’ ovvio che il superserver non puo’ comunque entrare nei dialoghi applicativi relativi ai diversi servizi, visto che non ne conosce nemmeno le regole. • Se un servizio e’ definito come concorrente (nowait) il superserver, anche dopo avere attivato una istanza del servizio, continuera’ ad attendere sulla porta well known del servizio richieste di altri clienti. • Se un servizio e’ definito come sequenziale (wait) il superserver, una volta attivata una istanza del servizio, non attendera’ piu’ alcun evento (nuova richiesta di servizio) sulla porta well known del servizio fino a che l’istanza precedente non sia terminata. Nel caso di un servizio sequenziale il superserver deve avere la possibilita’ di capire quando il filtro che ha attivato per 24 implementare il servizio ha terminato la sua esecuzione. Servizi sequenziali e servizi concorrenti .2 • Il superserver, dopo avere attivato una istanza di un servizio S, deve comunque rimanere attivo, anche se S e’ un servizio sequenziale: Il superserver deve comunque gestire nuove richieste, provenienti dalla rete, destinate ad S o ad un altro servizio. • Se S e’ un servizio concorrente, l’istanza di S e il superserver saranno eseguiti in parallelo e in modo completamente indipendente. Nessuno dei due ha piu’ bisogno di avere a che fare con l’altro. • Se pero’ S e’ un servizio sequenziale il superserver deve essere informato della terminazione dell’istanza di S che ha attivato. • S e’ implementato da un normale filtro, sia che esso sia concorrente sia che esso sia sequenziale! • In Unix ci sono normali meccanismi di sistema operativo che consentono ad un processo figlio di informare il processo padre della propria terminazione e anche di ritornargli un valore (intero)! • L ’esecuzione della system call exit(n) o dell’istruzione return(n) nella funzione main() del processo figlio lo terminano e consentono di informare il padre di cio’ e di passargli il valore n. 25 Sincronizzazione del superserver con i processi figli .1 • In realta’ il superserver deve comunque gestire in qualche modo anche la morte dei processi figli che implementano servizi concorrenti, altrimenti questi processi rimarrebbero nel sistema come zombi. Basta pero’ che il processo padre prenda atto della morte di un processo figlio per evitare che questo diventi uno zombi. • Il superserver puo’ prendere atto della (aspettare la) morte di un processo figlio invocando la system call wait(). • La system call wait() permette di ricavare il PID (quindi l’identita’) di un figlio che muore e anche la ragione di questa morte. L’interfaccia della funzione e’: int wait(int* err); • • Il valore di ritorno e’ il PID del processo figlio che e’ morto. Il parametro di ritorno err fornisce lo stato di terminazione del figlio (in errore, e se del caso quale, o meno). err e’ il valore ritornato dal processo figlio come argomento della system call exit() o dell’istruzione return() che lo 26 ha terminato. Sincronizzazione del superserver con i processi figli .2 • Poiche’ la system call wait() e’ bloccante, il superserver deve chiamarla solo quando e’ sicuro della morte (gia’ avvenuta) di un processo figlio. • Altrimenti l’intero superserver rimarrebbe bloccato in attesa della morte di un figlio, e verrebbe ad assumere il comportamento di superserver super-sequenziale! • Il superserver e’ informato (in modo asincrono) della morte di un processo figlio perche’, quando questo evento accade, il sistema operativo gli invia il segnale (interrupt software) SIGCLD (detto anche SIGCHLD). • Per poter catturare effettivamente il segnale, il superserver deve registrare sul sistema operativo, tramite chiamata della system call signal(), il suo interesse per l’evento (se non lo fa, il segnale non gli e’ inviato). 27 Unix system programming • void (* signal(int sig, void (*func)()))(int); • sig è l’intero (o il nome simbolico) che individua il segnale da gestire • il parametro func è un puntatore a una funzione che indica l’azione da associare al segnale; in particolare func può: • puntare alla routine di gestione dell’interruzione (handler) • valere SIG_IGN (nel caso di segnale ignorato) • valere SIG_DFL (nel caso di azione di default) • ritorna un puntatore a funzione: • al precedente gestore del segnale • SIG_ERR (-1), nel caso di errore • Una funzione che e’ uno handler di segnali per un processo deve avere il seguente prototipo: void signalHandler(int sig); 28 Struttura di uno handler di segnali • L’argomento di ingresso di un signal handler e’ l’identificatore numerico del segnale che e’ scattato: cio’ permette di registrare la stessa funzione per piu’ segnali e di gestire poi uno switch in funzione del segnale che si e’ manifestato. • Nel nostro caso lo handler deve gestire solo il segnale SIGCLD. • Per fare questo nel proprio corpo invochera’ la system call wait() che, dato il contesto della chiamata, sara’ non bloccante. • Nel caso di morte di un figlio relativo ad un servizio concorrente la presa d’atto del fatto, tramite chiamata a wait(), e’ gia’ sufficiente ad evitare che il figlio morto diventi uno zombi. • Nel caso di morte di un figlio relativo ad un servizio sequenziale il superserver dovra’ anche riabilitare il servizio, dovra’ cioe’ tornare ad aspettare richieste di nuovi clienti sulla relativa porta well known. • Lo handler viene attivato in seguito alla ricezione di uno dei segnali a cui e’ stato associato, e viene visto dal sistema come una thread secondaria del processo, in aggiunta a quella principale (che potrebbe eventualmente essere stata interrotta!). • In uscita un signal handler non ritorna alcun valore. 29 Aspetti particolari dei segnali e di SIGCLD • Un aspetto particolare della chiamata alla funzione wait() e’ che essa permette di rimuovere i processi terminati dallo stato di zombi. Infatti tutti i processi, quando terminano, vengono posti dal sistema operativo nello stato di zombi, in cui rimangono o fino alla morte del processo che li ha creati o fino a che questo prende atto dell’evento tramite la chiamata della funzione wait(). • Bisogna anche osservare che la ricezione di un segnale da parte di un processo interrompe l’esecuzione di una system call eventualmente in corso (bloccata): in questo caso la system call termina con codice di errore EINTR. • Poiche’ il superserver deve gestire il segnale di SIGCLD deve prendere in considerazione e gestire questa possibilita’. La cosa e’ vera in particolare per la system call select(), che e’ bloccante e che costituisce il cuore del superserver. Infatti il superserver deve rimanere in attesa di richieste, contemporaneamente, sulle porte well known di tutti i servizi che esso supporta (e che in quel momento sono abilitati). 30 Gestione del ritorno da select() . . . rs = select(. . ., NULL); if (rs<0) { if (errno == EINTR) { // morte di un figlio, non e’ un // errore vero, ma non c’e’ neanche // nessun socket pronto . . . } else { // e’ un errore vero . . . } } // tutto OK e ci sono socket pronti . . . 31 Schema del superserver inetd Per gestire la morte di un figlio. Alla morte di un figlio se il servizio era di tipo wait occorre reinserire nella select il socket-descriptor associato al servizio Per ogni servizio listato nel file di configurazione socket() bind() listen() (solo per TCP) Ciclo infinito signal() select() Test per lettura nfd=accept() (solo per TCP) close nfd padre Se wait-flag==wait elimina dalla select il fd associato al servizio figlio fork() close di tutti i fd diversi da quello del socket; dup(nfd); dup(sfd); su stdin e stdout execle() del server-program 32 La system call exec() • Negli schemi standard dei server di rete concorrenti visti a lezione il server padre, quando deve servire un nuovo cliente, esegue il fork() di un processo figlio, che puo’ immediatamente procedere a fornire il servizio al cliente perche’ il suo codice e’ identico a quello del padre. • Nel caso del superserver, pero’, il padre non contiene il codice capace di implementare ciascuno dei singoli servizi che esso offre, ma solo quello per la loro attivazione. • Come puo’ il superserver arrivare a fornire il servizio richiesto dal cliente? • Ci riesce perche’ (come una shell) il clone figlio del superserver non continua ad eseguire lo stesso codice del superserver ma, dopo una fase iniziale di housekeeping, mette in esecuzione nel proprio contesto un nuovo programma, quello del filtro Unix che implementa effettivamente il servizio desiderato dal cliente. N.B.: e’ evidente che tutto questo meccanismo si basa sulla nozione 33 di porta well known (che indentifica il servizio desiderato)! La system call exec() • La funzione execle() permette di mettere in esecuzione, nel contesto del processo chiamante (cioe’ mantenendo il possesso delle stesse risorse reali possedute dal processo chiamante), un ben determinato programma. • Noi la useremo nel processo clone-figlio per specializzarlo a fornire il servizio specifico per il quale e’ stato creato (sia per servizi concorrenti che per servizi sequenziali). • La system call execle() e’ una funzione ad argomenti variabili di cui i primi due sono obbligatori e sono: • nome del file contenente il programma eseguibile che si vuole eseguire, comprensivo dell’intero path; • nome del programma. segue poi la lista degli argomenti terminata da un NULL, infine l’ultimo argomento e’ un puntatore all’enviroment. • Quindi la chiamata: execle(“/bin/csh”, “csh”, “-i”, NULL, env); 34 mette in esecuzione il programma csh passandogli il parametro “-i”. La system call execle() int execle(char *pathname, char *arg0, …, char *argn, (char *)NULL, char **envp); • La funzione, in effetti, ritorna al chiamante solo in caso di errore! • Se non c’e’ errore, l’effetto della system call e’ di passare il controllo alla prima istruzione del programma contenuto nel file pathname. • Dal punto di vista C la modalita’ di passaggio degli argomenti arg0 .. argn equivale a passare un array NULL-terminato di puntatori, come e’ in effetti il parametro di ingresso argv della funzione main(). • arg0 per convenzione deve essere il nome del programma, come indicato alla pagina precedente. • L’ultimo parametro e’ un puntatore ad una esplicita environment list, che e’ a sua volta implementata come un array NULL-terminato di puntatori a stringa. • Quello che faremo e’ fare ereditare al processo figlio lo stesso environment del superserver. 35 Struttura del superserver .1 1. Il parametro di ingresso envp della funzione main() int main (int argc, char **argv, char **envp); consente al superserver di procurarsi il riferimento envp all’environment da utilizzare in execle(). 2. La funzione main() del superserver apre in lettura il file di configurazione. 3. Per ogni linea di tale file ricava i parametri del servizio descritto in quella linea e li salva in una opportuna struttura dati: tutti i servizi sono inizialmente definiti come abilitati. 4. Per ogni servizio, crea un socket e lo bind()-a alla porta well known su cui quel servizio e’ offerto (quella che e’ indicata nel file di configurazione, e il cui numero deve essere congruente con le indicazioni IANA). 5. Se il servizio utilizza il trasporto TCP il superserver mette anche in stato listen()-ing il socket relativo. 36 Struttura del superserver .2 7. Anche il file descriptor del socket well known deve essere salvato nella struttura dati di cui sopra. 8. Il superserver chiama la system call signal() per registrare il proprio interesse a gestire il segnale SIGCLD. 9. Terminata l’inizializzazione il superserver entra in un ciclo infinito in cui, tramite chiamata della system call select(), attende l’arrivo di nuove richieste di servizio da nuovi clienti; quando queste arrivano, attiva per ciascuna di esse (per il tramite di un corrispondente processo figlio) l’opportuno server specifico (che e’/deve essere strutturato come un filtro Unix). 10. All’interno del ciclo (reinizializza e) riempie il parametro di ingresso readfds della select() con tutti e soli i file descriptor dei servizi correntemente abilitati. Se un servizio nowait e’ gia’ attivo, esso non puo’ essere nuovamente attivato in questo momento, e quindi non deve essere 37 in stato abilitato. Struttura del superserver .3 11. Al termine della select(), trascurando i casi di errore, scandisce il parametro di ritorno readfds (N.B. quindi readfds e’ un parametro value-result!) che, a questo punto, lista tutti i file descriptor sui quali e’ pendente una nuova richiesta di servizio da parte di un nuovo cliente. Per ciascuna di queste richieste mette in funzione il server specifico. 12. Se il servizio specifico utilizza il servizio di trasporto TCP, chiama la system call accept() per prendere in carico la nuova connessione e ricavare il corrispondente socket descriptor nfd: ad esso e’ associata la nuova richiesta di servizio. 38 Struttura del superserver .4 11. Se il servizio specifico utilizza il servizio di trasporto UDP, registra come file descriptor nfd sul quale interagire con il cliente il file descriptor della relativa porta well known? Cio’ e’ possibile? No! Nel caso di un servizio UDP concorrente lo schema visto a lezione prevede che il servizio sia effettivamente fornito su una porta diversa da quella well known, altrimenti sarebbe impossibile utilizzare la porta well known per aspettare nuovi clienti! Allora? Nel caso di un servizio UDP sequenziale si e’ visto che l’implementazione piu’ conveniente si basa sull’utilizzo da parte del server specifico di una porta (effimera) diversa da quella, well known, su cui il servizio e’ offerto. Allora? 39 Struttura del superserver .5 14. Chiama la system call fork() per generare il processo che fornira’ effettivamente il servizio alla richiesta corrente. 15. Nel ramo del padre, se il servizio considerato e’ di tipo TCP : • Chiude il socket nfd. • Se il servizio considerato e’ di tipo wait allora registra nella struttura dati di descrizione dei servizi il PID del processo figlio e marca il servizio come disabilitato (se il servizio considerato e’ di tipo nowait non c’e’ niente di specifico da fare). • Passa a considerare la prossima richiesta pendente o, se non ce ne sono piu’, ritorna all’inizio del ciclo principale. 16. Nel ramo del figlio: • Chiude i file descriptor 0 e 1. • Duplica sui file descriptor 0 e 1 il file descriptor nfd. • Chiude tutti i file descriptor del processo ad eccezione di 0, 1 e 2. • Chiama la system call execle() per attivare il servizio richiesto. 40 Struttura dati di descrizione dei servizi • Ci sono svariate modalita’ per salvare le informazioni relative ai servizi che devono essere supportati. • La migliore e’ quella di utilizzare una coda linkata (doppiamente) di strutture (ogni struttura descrive un singolo servizio). Questa soluzione ha il vantaggio di non porre limiti al numero massimo di servizi supportabili dal superserver • Un’altra possibilita’ e’ quella di utilizzare un vettore di strutture. Questa soluzione (quella consigliata) ha lo svantaggio di limitare ad un valore massimo il numero di servizi supportabili. Il problema puo’ in realta’ essere risolto facilmente allocando l’array dinamicamente in modo che abbia dimensioni sufficienti a contenere la descrizione di tutti i servizi supportati. Per sapere quanto deve essere grande l’array basta, per prima cosa, contare il numero di righe del file di configurazione. 41 Esempio di struttura dati di descrizione dei servizi .1 typedef struct SrvElmT { char tr[4]; // tcp se il servizio usa il trasporto TCP, // udp se usa il trasporto UDP char conc[7]; // wait se il servizio e' di tipo sequenziale, // nowait altrimenti char port[8]; char srvFullName[500]; // pathname completo del file di codice eseguibile // del programma char srvName[20]; // nome del programma // continua . . . 42 Esempio di struttura dati di descrizione dei servizi .2 // . . . Continua: int numArgs; // numero di argomenti da passare alla execle, // 0 in caso di assenza di essi char argv1[20]; char argv2[20]; char argv3[20]; char argv4[20]; char argv5[20]; int fd; // socket descriptor associato alla porta well // known del servizio int pid; // significativo solo per servizi di tipo wait (?) } SrvElmT, *SrvElmTP; 43 SrvElmT srvArray[20]; // massimo 20 servizi Servizi UDP • I servizi UDP non possono essere trattati in modo analogo a quelli TCP. • La ragione di fondo e’ che mentre in TCP un socket rappresenta una connessione (e quindi una relazione gia’ instaurata con un singolo cliente), in UDP esso rappresenta una porta (e quindi c’e’ l’eventualita’ di potere/dovere interagire su quella porta con piu’ clienti, vecchi e nuovi). • Diverse conseguenze sul superserver: • Un servizio concorrente UDP non puo’ essere trattato in modo analogo ad un servizio concorrente TCP. Se mantenesse il servizio abilitato, alla prossima select() il superserver potrebbe trovare gia’ pendente come nuova richiesta quella del servizio appena attivato, perche’ il datagram che aveva richiesto il servizio all’iterazione precedente potrebbe essere ancora da consumare. • Per realizzare un servizio UDP sequenziale come un filtro Unix (ma con interazioni a messaggio, non a stream!) bisogna gestire il fatto che il server filtro deve interagire solo con un singolo cliente e non puo’ farlo esplicitamente (cioe’ eliminando lui le interazioni non 44 volute sulla porta well known: i filtri Unix non lo fanno!). Servizio CL concorrente .1.1 SuperServerp socket server di associazione porta UDP server well-known porta UDP effimera socket client Client UDP 45 Servizio CL concorrente .1.2 SuperServerp socket server di associazione socket server di comunicazione porta UDP server well-known porta UDP server effimera porta UDP effimera socket client Client UDP 46 Servizio CL concorrente .1.3 fork() SuperServerp Server Figlio socket server di associazione socket server di comunicazione porta UDP server well-known porta UDP server effimera porta UDP effimera socket client Client UDP • Il server figlio, appena attivato, dopo la exec(), potrebbe andare subito a leggere il datagram pendente nel socket well known, ma chi dice che arriverebbe a farlo prima della nuova select() di SuperServerp • E comunque, questo non sarebbe il comportamento di 47 un filtro Unix! Servizi UDP implementati tramite filtri Unix .1 • Il servizio deve interagire con un singolo cliente, senza conoscerne necessariamente l’identita’. Non deve nemmeno sapere che sta interagendo con il cliente tramite un socket UDP! • Poiche’ deve poter ignorare l’identita’ del cliente remoto (infatti opera sui file descriptor 0 e 1 tramite operazioni di read() e write()) bisogna che il socket UDP che e’ associato ai file descriptor 0 e 1 sia gia’ connesso al cliente! • E’ il superserver che deve passare al server specifico una porta UDP gia’ connessa! • Ma per un server UDP concorrente la porta con cui interagire con il cliente non puo’ essere la porta well known, deve essere una porta effimera. • Quindi e’ questa porta che deve essere passata al server specifico come associata ai file descriptor 0 e 1, non la porta well known. • Nota che la disciplina di utilizzo delle porte che va bene per un server UDP concorrente si puo’ applicare altrettanto bene ad un 48 server sequenziale! Servizio CL concorrente .2.1 SuperServerp socket server di associazione porta UDP server well-known porta UDP effimera socket client Client UDP 49 Servizio CL concorrente .2.2 SuperServerp socket server di associazione socket server di comunicazione porta UDP server well-known porta UDP server effimera porta UDP effimera socket client Client UDP 50 Servizio CL concorrente .2.3 SuperServerp ? recvfrom(MSG_PEEK?) socket server di associazione socket server di comunicazione porta UDP server well-known ? porta UDP server effimera porta UDP effimera socket client Client UDP 51 Servizio CL concorrente SuperServerp ? .2.4 connect(socket client) socket server di associazione socket server di comunicazione porta UDP server well-known ? porta UDP server effimera porta UDP effimera socket client Client UDP 52 Servizio CL concorrente .2.5 fork() SuperServerp ? SuperServerf ? socket server di associazione socket server di comunicazione porta UDP server well-known ? porta UDP server effimera porta UDP effimera socket client Client UDP 53 Servizio CL concorrente SuperServerp ? .2.6 Server Figlio exec() socket server di associazione socket server di comunicazione porta UDP server well-known ? porta UDP server effimera porta UDP effimera socket client Client UDP • Il server figlio, dopo la exec(), non ha comunque piu’ il messaggio nella sua memoria dati centrale • E ovviamente, secondo il protocollo di attivazione di un server-filtro Unix, non ha nemmeno piu’ accesso alla porta well known • Come fa a vedere il messaggio? 54 Servizi UDP implementati tramite filtri Unix .2 • Pero’ il datagram del cliente che ha portato all’attivazione del servizio UDP e’ registrato nella porta well known, non nella porta effimera che vogliamo passare al server specifico. Come puo’ il server specifico andarlo ad accedere? Oltretutto la sua presenza nella porta well known puo’ costituire un problema per il superserver che, nel caso di un servizio UDP concorrente, deve utilizzare la porta well known per aspettare richieste di nuovi clienti e non deve considerare 2 volte una stessa richiesta. • Quindi bisogna che il superserver estragga il datagram UDP dalla porta well known e lo inserisca nella porta effimera prima di attivare il servizio. • N.B.: L’utilizzo di una seconda porta (effimera) per interagire con il client e’ strettamente necessario solo per realizzare un servizio UDP concorrente, ma torna comodo anche nel caso di un servizio UDP sequenziale. 55 Servizio CL concorrente .3.1 SuperServerp socket server di associazione porta UDP server well-known porta UDP effimera socket client Client UDP 56 Servizio CL concorrente .3.2 SuperServerp socket server di associazione socket server di comunicazione porta UDP server well-known porta UDP server effimera porta UDP effimera socket client Client UDP 57 Servizio CL concorrente .3.3 SuperServerp recvfrom() socket server di associazione socket server di comunicazione porta UDP server well-known porta UDP server effimera porta UDP effimera socket client Client UDP 58 Servizio CL concorrente .3.4 SuperServerp sendto(porta server effimera) socket server di associazione socket server di comunicazione porta UDP server well-known porta UDP server effimera porta UDP effimera socket client Client UDP 59 Servizio CL concorrente SuperServerp .3.5 connect(socket client) socket server di associazione socket server di comunicazione porta UDP server well-known porta UDP server effimera porta UDP effimera socket client Client UDP 60 Servizio CL concorrente .3.6 fork() SuperServerp SuperServerf socket server di associazione socket server di comunicazione porta UDP server well-known porta UDP server effimera porta UDP effimera socket client Client UDP 61 Servizio CL concorrente SuperServerp .3.7 Server Figlio exec() socket server di associazione socket server di comunicazione porta UDP server well-known porta UDP server effimera porta UDP effimera socket client Client UDP 62 Servizi UDP implementati tramite filtri Unix .3 1. Il superserver acquisisce l’indirizzo del cliente e il datagram UDP che ha attivato il servizio eseguendo una system call recvfrom() sul socket della porta well known. 2. Il superserver crea un nuovo socket e lo bind-a ad una porta effimera. 3. Il superserver invia il datagram UDP che ha portato all’attivazione del servizio alla porta effimera appena creata. N.B.: per inviare il datagram il superserver puo’ utilizzare sia la porta effimera appena creata (la stessa che e’ destinazione del datagram!) che la porta well known del servizio UDP! 4. Il superserver connette la porta effimera appena creata all’indirizzo del cliente. N.B.: l’ordine di queste due operazioni e’ fondamentale perche’ se il superserver connettesse la porta effimera prima di inviarle il datagram vedrebbe il suo datagram venire cestinato! 63 bind(), INADDR_ANY, e porte effimere • Quando il superserver utilizza la system call bind() per associare il socket server UDP di comunicazione ad una porta effimera, assegna al parametro *myaddr il valore 0.0.0.0:0 (cioe’ INADDR_ANY:0). • Notare che l’indirizzo 0.0.0.0:0 non e’ un indirizzo valido (vero), e che non e’ utilizzabile come indirizzo destinazione in una operazione di rete: INADDR_ANY non e’ un indirizzo di rete valido. La porta numero 0 non esiste. • Effettuare il bind all’indirizzo 0.0.0.0:0 non significa chiedere effettivamente l’associazione a questo indirizzo (che non e’ un indirizzo valido), ma chiedere l’associazione a una qualunque porta effimera libera e a tutti gli indirizzi IP della macchina. • Nella system call bind() il parametro *myaddr e’ di ingresso, non di ingresso uscita. • Ma allora come posso sapere a quale porta effimera il socket e’ stato effettivamente associato, cosi’ da potergli inviare il datagram UDP del cliente? Vedi dispense, parte teoria. • E quale indirizzo IP devo utilizzare come destinazione della sendto()? Ovviamente 127.0.0.1! (perche’?) 64 Servizi UDP implementati tramite filtri Unix .4 5. A questo punto il superserver puo’ comportarsi con un servizio UDP cosi’ come si comporta con un servizio TCP, utilizzando il socket descriptor della porta effimera come nfd. • Vedi punto 13 della struttura del superserver. 6. Nota che avendo gia’ estratto dalla porta well known del servizio UDP il datagram che ha portato a questa attivazione del servizio, il superserver puo’, nel caso di un servizio UDP concorrente, continuare a considerare abilitato il servizio anche nelle iterazioni successive, andando ad attendere immediatamente l’arrivo di nuovi datagram di richiesta. 65 Servizi UDP implementati tramite filtri Unix .5 • Dal punto di vista del server specifico l’unico vincolo che c’e’, nel caso di utilizzo del servizio di trasporto UDP, e’ che bisogna inviare o ricevere un intero PDU applicativo tramite ogni singola operazione di write() e read(). • Il servizio specifico non puo’ per esempio leggere da standard input singoli caratteri se il pari gliene manda molti in un singolo datagram. • Ma se un server specifico avesse bisogno di sapere chi c’e’ dall’altra parte della rete? • Ovvio che in questo caso dovrebbe anche sapere a priori che sta interagendo con un cliente remoto tramite il servizio di trasporto UDP. • Basterebbe che chiamasse la funzione getpeername(). • N.B.: lo stesso discorso si applica ovviamente anche ad un server TCP (che ovviamente interagisce con il pari tramite una connessione. La cosa e’ meno scontata per un server UDP!). 66 Servizi TCP e setsockopt() • Immaginiamo il seguente scenario: • Il superserver riceve una richiesta per il servizio concorrente TCP S definito sulla porta well known WK, e di conseguenza attiva il processo figlio PS per offrire il servizio. • Il servizio e’ ovviamente offerto tramite una connessione che ha come end-point locale la porta WK. • Mentre il processo PS e’ ancora attivo il superserver, per un motivo o per l’altro, termina (e.g. e’ abortito) e viene riattivato. • Alla partenza il superserver deve ovviamente appropriarsi di tutte le porte well known che gestisce. • Questo costituisce un problema per la rete? Ci sono ambiguita’? • Questo scenario vi ricorda qualcosa descritto parlando di setsockopt()? • Durante l’implementazione del superserver tenete conto di quanto detto qui. 67 Esercizio 1: superserver .1 • Realizzare un superserver di rete. • Il superserver deve essere configurato tramite file di configurazione. • Il superserver deve essere in grado di gestire sia servizi TCP che servizi UDP, sia servizi concorrenti (nowait) che servizi sequenziali (wait), indipendentemente dal tipo di servizio di Trasporto utilizzato. • Verificare il superserver interagendo con i servizi TCP cat e csh, e con un servizio UDP di echo (da realizzare a partire dagli esempi presentati nelle dispense). • Come modulo cliente per il test dei servizi TCP si puo’ utilizzare un normale Hyperterm di Windows (o un client TELNET). • Come modulo cliente per il test dei servizi UDP si puo’ utilizzare il client di echo visto a lezione con gli opportuni adattamenti. • Attenzione: esiste il pericolo di una corsa critica tra la morte di un server sequenziale figlio e la registrazione di questo server come gia’ attivo da parte del superserver! Opzionale: Cercate in qualche modo di gestire questa situazione! 68 Esercizio 1: superserver .2 • Per testare il buon funzionamento del superserver rispetto a servizi sequenziali e’ necessario avere server che terminano. • Tutti i servizi implementati come filtri Unix e basati su TCP terminano nel momento in cui vedono chiudersi la connessione con il client, e questo vale anche per cat e csh. • Nell’esempio di servizio di eco “sequenziale” basato sul trasporto UDP visto a lezione il server, pero’ non termina mai. N.B.: esso non e’ nemmeno implementato come un filtro Unix • Occorrera’ quindi definire un protocollo applicativo che possa portare alla terminazione del server UDP di eco. • A noi interessa solo poter testare il superserver, quindi quale sia questo “protocollo” non ci interessa. Esempi di possibilita’: • Il server UDP di eco termina dopo avere echeggiato N messaggi. • Il server UDP di eco termina quando riceve un messagio vuoto. • Il server UDP di eco viene terminato tramite kill da console. 69