wiki:EsempioLilik

Commenti sull'esempio di web server per l'incontro del Lilik

Per l'incontro che il Lilik ha dedicato a GaPiL ho realizzato un piccolo server web come esempio di programma di rete. Il codice completo del server è su source:trunk/sources/wwwd.c, qui ci sono alcuni estratti commentati dello stesso.

Il programma può essere usato come esempio di un server più completo. Come per tutti i programmi che vengono invocati a riga di comando, esso prevede il passaggio degli argomenti tramite le due variabili predefinite argc e argv. In realtà queste contengono solo il risultato della scansione della riga di comando, che genera un vettore di stringhe argv, composto dalle argc parole che vengono trovate sulla stessa (dove una parola è definita come il testo presente fra degli spazi).

In realtà nei sistemi unix è invalsa la convenzione che non tutte le parole che si trovano sulla riga di comando siano trattate come argomenti per lo stesso, ma che alcune di esse, quelle che cominciano per il carattere '-', vengano utilizzate per indicare delle opzioni del comando. Per questo motivo le librerie generali del sistema prevedono una apposita funzione, getopt, che permette di gestire la scansione delle opzioni. La sezione di codice del programma che effettua questo compito è la seguente:

    while ( (i = getopt(argc, argv, "hdicr:")) != -1) {
        switch (i) {
        /*
         * Handling options
         */
        case 'h':
            printf("Wrong -h option use\n");
            usage();
            return(0);
            break;
        case 'i':
            demonize = 0;
            break;
        case 'c':
            compat = 1;
            break;
        case 'd':
            debugging = 1;
            break;
        case 'r':
            reroot = 1;
            rootdir = optarg;
            break;
        case '?':   /* unrecognized options */
            printf("Unrecognized options -%c\n",optopt);
            usage();
        default:    /* should not reached */
            usage();
        }
    }

Il funzionamento di getopt prevede che questa esegua la scansione del vettore di stringe argv ritornando un valore intero positivo tutte le volte che incontra una opzione (cioè una stringa che inizia con '-'), e -1 quando non trova più opzioni. Per questo la scansione avviene all'interno di un ciclo ripetuto fintanto che non si ottiene un -1 come valore di ritorno. Alla fine del ciclo la funzione avrà provveduto a riordinare opportunamente i valori di argv così che tutti gli argomenti rimasti si trovino in coda al vettore, a partire dall'elemento identificato dalla apposita variabile optind, definita dalla funzione stessa. Questo consente di non preoccuparsi dell'ordine in cui si scrivono argomenti e opzioni.

Tutte le volte viene chiamata la funzione getopt e se l'opzione corrisponde ad una delle lettere specificate nel terzo argomento della funzione stessa (tralasciamo per ora il significato dei ':'), questo stesso carattere viene restituito nella variabile i, se invece l'opzione è inesistente verrà restituito il carattere '?'. Per ciascuno di questi casi allora si potrà predisporre una opportuna operazione; come si può notare nella gran parte di essi si è semplicemente provveduto ad assegnare un valore ad una variabile che utilizzeremo nel seguito, in opportune istruzioni condizionali, per modificare il comportamento del programma.

Nel caso dell'esempio citato le varie opzioni permettono di eseguire la sola stampa di una pagina informativa (con -h), l'uso in modalità interattiva del server (con -i), la gestione delle system call interrotte in maniera compatibile con BSD (con -c), l'abilitazione della stampa di messaggi di debug (con -d) ed infine l'impostazione di una directory su cui effettuare un chroot (con -r).

Si noti però come ci sia un caso speciale, relativo all'opzione -r, che è quella che nella chiamata a getopt è seguita dal carattere ':'. Quest'ultimo infatti indica che detta opzione necessita di un parametro, deve cioè essere seguita, nella lista di parole di argv, da un valore che non sia un'altra opzione, ma che non sarà neanche trattato come argomento (nel caso la directory su cui eseguire il chroot). In questo caso nella variabile optarg viene riportato il puntatore alla stringa che identifica il parametro dell'opzione, che nell'esempio precedente viene salvato per un successivo riutilizzo.

Il prosieguo del codice ci mostra allora come possono essere utilizzate queste opzioni; ad esempio nel caso della gestione del chroot, qualora l'opzione sia stata impostata, sarà stata definita la variabile reroot, per cui si potrà usare la condizione:

    if (reroot) {
        printf("chroot to %s\n", rootdir);
        if (chdir(rootdir)) {
            perror("Cannot find directory to chroot");
            exit(1);
        }
        if (chroot(rootdir)) {
            perror("Cannot chroot");
            exit(1);
        }
    }

questa ci dice che se si è selezionata l'opzione prima ci si porrà con chdir nella directory passata come parametro (uscendo in caso di errore, ad esempio se la directory non esiste) e poi si eseguirà il chroot stesso.

Prima di eseguire il chroot abbiamo però provveduto a creare il socket per il server con una opportuna chiamata ad una funzione di servizio:

    if ( (list_fd = sockbindopt(argv[optind], "www", 6,
                                SOCK_STREAM, reuse)) < 0) {
        return 1;
    }   

questo è necessario in quanto la funzione chiamata usa le routine del resolver per poter risolvere il nome del servizio e dell'indirizzo IP usato, e per questo deve poter accedere ai file di configurazione dello stesso che stanno in /etc.

Si è usata la funzione sockbindopt proprio per poter evitare di dovere eseguire manualmente le singole risoluzioni, le system call relative alla rete infatti richiedono esclusivamente valori numerici per gli indirizzi, la funzione esegue automaticamente le conversioni dai rispettivi identificativi letterali, e gestisce automaticamente anche l'uso di indirizzi IPv4 o IPv6; una volta ottenuti i valori numerici chiama poi le due system call necessarie, che sono socket e bind. La funzione prende cinque argomenti:

int sockbindopt(char *host, char *serv, int prot, int type, int reuse)

che indicano rispettivamente l'indirizzo (che può essere specificato sia un forma dotted decimal che per nome della macchina), il nome del servizio (che verrà convertito nel numero della porta), valore numerico del protocollo (che per un server web è il TCP, e vale quindi 6) ed il tipo di socket, che di nuovo è fissato dal protocollo scelto e può essere solo SOCK_STREAM, ed infine un valore logico che indica se deve essere impostata o meno l'opzione SO_REUSEADDR sul socket stesso.

Per vedere però quali sono le operazioni richieste, dobbiamo fare riferimento al codice della funzione (contenuto in source:trunk/sources/SockUtil.c), la prima parte, che si incarica di effettuare la risoluzione dell'indirizzo, è la seguente:

    memset(&hint, 0, sizeof(struct addrinfo)); 
    hint.ai_flags = AI_PASSIVE;            /* address for binding */
    hint.ai_family = PF_UNSPEC;            /* generic address (IPv4 or IPv6) */
    hint.ai_protocol = prot;               /* protocol */
    hint.ai_socktype = type;               /* socket type */
    res = getaddrinfo(host, serv, &hint, &addr);   /* calling getaddrinfo */
    if (res != 0) {                                /* on error exit */
        fprintf(stderr, "sockbind: resolution failed:");
        fprintf(stderr, " %s\n", gai_strerror(res));
        errno = 0;                         /* clear errno */
        return -1;
    }

qui in sostanza, la funzione del resolver getaddrinfo si incarica di eseguire la risoluzione, restituendo una linked list di risultati in strutture apposite (di tipo addrinfo, che alloca autonomamente) sulla base di quanto richiesto con le indicazione passate nella struttura hint (anch'essa di tipo addrinfo).

Il passo successivo è quello di ripetere, all'interno di un ciclo sulla lista di indirizzi ritornati, la chiamata prima alla funzione socket che crea il socket su cui si riceveranno le connessioni, e poi a bind che gli associa l'indirizzo che abbiamo risolto con la precedente chiamata a getaddrinfo; il ciclo si ferma con il primo indirizzo per cui entrambe avranno successo. Il codice è il seguente:

    while (addr != NULL) {                 /* loop on possible addresses */
        /* get a socket */
        sock = socket(addr->ai_family, addr->ai_socktype, addr->ai_protocol);
        if (sock < 0) {                    /* on error */
            if (addr->ai_next != NULL) {   /* if other addresses */
                addr=addr->ai_next;        /* take next */
                continue;                  /* restart cycle */
            } else {                       /* else stop */
                perror("sockbind: cannot create socket");
                return sock;
            }
        }
        /* set the socket */
        if (setsockopt(sock, SOL_SOCKET, SO_REUSEADDR,
                       &reuse, sizeof(reuse))) {
            printf("error on socket options\n");
            return -1;
        }
        if ( (res = bind(sock, addr->ai_addr, addr->ai_addrlen)) < 0) {
            if (addr->ai_next != NULL) {   /* if other addresses */
                addr=addr->ai_next;        /* take next */
                close(sock);               /* close socket */
                continue;                  /* restart cycle */
            } else {                       /* else stop */
                perror("sockbind: cannot connect");
                close(sock);
                return res;
            }
        } else break;                      /* ok, we are binded! */
    }
    freeaddrinfo(save);                    /* done, release memory */
    return sock;

E' importante realizzare il fatto che per poter creare un server di rete prima occorre creare un socket, che è una speciale infrastruttura messa a disposizione dal kernel per mettere in comunicazione due programmi. La caratteristica di un socket è che questo viene visto da un processo in maniera analoga ad un file: viene cioè gestito attraverso l'uso di un file descriptor (che nell'esempio è il valore sock restituito dalla chiamata a socket che lo crea), sul quale poi si potranno eseguire le ordinarie operazioni di lettura e scrittura. La differenza è che nel caso di un socket invece che su disco quando si scrive si inviano dati all'altro processo, mentre quando si legge si ricevono dati da esso, si ha cioè una linea di comunicazione bidirezionale.

Questa interfaccia è però completamente generica, mentre le modalità con cui si effettuano le comunicazioni fra i due processi sono le più varie, e dipenderanno anche dal protocollo di rete che si usa per effettuarle. Per questo per la creazione del socket occorre anche specificare quale tipo di protocollo si andrà ad utilizzare e che tipo di socket si usa; nel caso specifico la comunicazione è di tipo stream, si ha cioè un flusso continuo di dati, per via dell'uso di TCP che fornisce tutta una serie di servizi che assicurano che la comunicazione sia attiva ed i pacchetti vengano ricevuti, Se si fosse usato UDP la comunicazione sarebbe stata di tipo datagram e non si avrebbe avuto nessuna garanzia sulla ricezione dei pacchetti.

Una volta creato il socket resta però da definire chi sta dall'altra parte. Nel caso di un client, che non è trattato qui, tutto quello che serve è chiamare la funzione connect a cui dovrà essere fornito l'indirizzo remoto (che nel caso di TCP/IP, come qui, è costituito dal numero IP e dal numero della porta) cui si trova il server con cui si vuole comunicare (l'indirizzo locale in genere viene determinato automaticamente dal kernel), ed al ritorno della funzione il socket potrà essere utilizzato per leggere e scrivere.

Nel caso di un server occorre invece dire a quale indirizzo si è reperibili; questo è fatto dalla chiamata alla funzione bind che associa il socket ad un indirizzo locale (di nuovo costituito da numero IP più porta); in genere il numero IP non si imposta e si usa l'indirizzo generico, che vuol dire che il server utilizzerà tutti gli indirizzi IP disponibili su una macchina, ma è sempre possibile sceglierne uno solo (ad esempio solo il localhost, se il server non deve essere contattato dall'esterno). Avendo usato un indirizzo ricevuto da una struttura addrinfo non dovremo impostare i valori a mano, né tener conto della eventuale presenza o meno di indirizzi IPv4 o IPv6.

Infine si noti come fra le due chiamate a socket e bind si sia invocata setsockopt con il valore 1 per la variabile reuse per impostare una caratteristica specifica del socket, SO_REUSEADDR, che consente di evitare il fallimento di bind quando la porta su cui ci si pone in ascolto risulti ancora occupata da una precedente esecuzione del server stesso.

Una volta completata l'esecuzione di sockbindopt il socket sarà completamente predisposto, il server però non accetterà connessioni fino alla chiamata della funzione listen, che dice al kernel che da quel momento in poi il server è in ascolto di nuove connessioni; con questo passo, riportato di seguito, viene completata l'inizializzazione di quello che usualmente viene chiamato il listening socket del server:

    if (listen(list_fd, BACKLOG) < 0 ) {
        PrintErr("listen error");
        exit(1);
    }

Una volta posto il server in ascolto il nostro programma entra nel suo ciclo principale, riportato di seguito. Qui viene usato uno schema classico in cui il processo originario (il padre) resta sempre in ascolto, e mette in esecuzione, per ciascuna connessione ricevuta, un processo secondario (il figlio) che si cura di servire la richiesta; il relativo codice, semplificato eliminando la parte relativa alle stampe di debug, è il seguente:

    while (1) {
        /* accept connection */
        len = sizeof(cli_add);
        while (((conn_fd = accept(list_fd, (struct sockaddr *)&cli_add, &len)) 
                < 0) && (errno == EINTR)); 
        if (conn_fd < 0) {
            PrintErr("accept error");
            exit(1);
        }
        /* fork to handle connection */
        if ( (pid = fork()) < 0 ){
            PrintErr("fork error");
            exit(1);
        }
        if (pid == 0) {      /* child */
            close(list_fd);          /* close listening socket */   
            ServEcho(conn_fd);       /* handle echo */
            exit(0);
        } else {             /* parent */
            close(conn_fd);          /* close connected socket */
        }
    }

Il ciclo viene effettuato sulle chiamate alla system call accept, il cui solo compito è quello di accettare una connessione remota. Quando questo avviene la funzione restituisce un secondo socket, detto socket connesso, che è quello che potrà essere utilizzato per comunicare con il client che si è connesso. Nel caso di un server infatti il primo socket che abbiamo creato (il listening socket) serve solo a ricevere le richieste di connessione, è il socket restituito da accept che deve essere usato per le successive comunicazioni. Se non ci sono connessioni la funzione si bloccherà, ed il server andrà in stato di sleep.

Per permettere la presenza di più connessioni contemporanee il nostro server deve utilizzare le capacità multitasking del sistema, per questo una volta verificato il successo di accept viene invocata la system call fork. Questa è una delle chiamate fondamentali in un sistema unix, il cui effetto è quello di duplicare il processo corrente, dando vita a quello che viene chiamato un processo figlio. Per questo motivo si dice che detta funzione ritorna due volte, nel padre e nel figlio.

Dopo la chiamata i due processi eseguiranno lo stesso codice, la differenza però sta nel valore di ritorno di fork che nel padre è un numero positivo corrispondente all'identificativo assegnato nel processo figlio, mentre nel figlio è 0. Questo permette di creare una condizione su questo valore (a parte la prima condizione, relativa ad un eventuale errore, che dà un risultato negativo) come mostrato nel codice precedente.

In questo modo si può far eseguire compiti diversi ai due processi, nel nostro caso tutto quello che facciamo fare al padre è chiudere il socket connesso per poi ripetere il ciclo da capo. In questo modo il padre rieseguirà la funzione accept e potrà, all'arrivo di una nuova connessione, ripetere la creazione di un altro processo figlio. Sarà compito di questi poi gestire le comunicazioni (in maniera indipendente, essendo a quel punto programmi diversi) con i vari client.

Per quanto riguarda l'esecuzione del processo figlio possiamo vedere che una volta chiuso il listening socket (che serve solo al padre per ricevere nuove connessioni) tutto il lavoro verrà svolto dalla funzione ServPage che prende come argomento il socket connesso sul quale eseguirà tutte le connessioni. Una volta completato detto lavoro, al ritorno della funzione, il figlio semplicemente termina con la chiamata ad exit.

Per vedere allora come viene svolto il lavoro dobbiamo esaminare il codice della funzione ServPage, ma prima di questo è necessario dire anche qualcosa sul protocollo HTTP (una descrizione di base si trova nel primo capitolo di I servizi Web), questo prevede che il client esegua una richiesta la cui prima riga prevede tre parole, che indicano rispettivamente il metodo, la URI e la versione del protocollo. In particolare i metodi si possono considerare il comandi del protocollo, che indicano l'operazione richiesta, mentre la URI è in sostanza un pathname che indica la risorsa che si richiede al server (di norma la pagina). Alla prima riga seguono in genere una serie di righe ulteriori conenenti le cosiddette intestazioni HTTP, la richiesta è chiusa da una riga vuota.

Nel nostro caso si è implementato soltanto il metodo GET che è quello che esegue la richiesta di una pagina web, indicata dal pathname fornito dalla URI. Inoltre si è implementato soltanto la risposta secondo la versione 1.0 del protocollo, che prevede che il server chiuda la connessione dopo aver fornito la risorsa richiesta per indicare al client che ha completato la trasmissione dei dati (la versione 1.1 prevede la possibilità di inviare più pagine su una unica connessione).

Dato che il protocollo prevede che le richieste siano organizzate per righe, diventa naturale usare l'interfaccia classica ANSI C per l'I/O su file bufferizzato che prevede nativamente le funzioni per leggere e scrivere righe di testo. Per questo le prime operazioni eseguite dalla nostra funzione di servizio sono le seguenti:

    sock = fdopen(sockfd, "w+");
    line = fgets(buffer, MAXLINE, sock);
    if (line == NULL) {
        PrintErr("Errore in lettura");
        return;
    }

in cui prima si associa al nostro socket un file in stile ANSI C con fdopen, e poi si legge la prima riga che contiene la richiesta del client con fgets.

Il passo successivo è eseguire la scansione di questa riga per identificare il metodo e la pagina richiesta, questo viene fatto dalla seguente sezione di codice che definisce le due variabili method e filename; se non si riesce ad ottenere detti valori si provvede ad inviare al client una opportuna pagina di errore (nel caso un codice 400), e poi si ritorna dalla funzione:

    copy = strndupa(line, MAXLINE);
    if ((method = strtok_r(copy, " ", &ptr)) == NULL) {
        print_headers(sock, codes[1]);
        print_error(sock, codes[1], line);
        return;
    }
    if ((filename = strtok_r(NULL, " ", &ptr)) == NULL) {
        print_headers(sock, codes[1]);
        print_error(sock, codes[1], line);
        return;
    }
    if ((version = strtok_r(NULL, " ", &ptr)) == NULL) {
        print_headers(sock, codes[1]);
        print_error(sock, codes[1], line);
        return;
    }

Per la scansione si usa la funzione strtok_r che a partire dalla copia della riga letta, creata con strndupa, individua le parole in essa presenti, usando lo spazio come separatore. Una volta ottenuta la sezione della riga che indica il metodo la si confronta con la lista dei metodi supportati con il seguente ciclo:

    i = 0;
    while ( (ptr = methods[i]) != NULL) {
        if ( (strncmp(ptr, method, strlen(ptr)) == 0)) {
            break;
        }
        i++;
    }
    if (i>=2) {
        print_headers(sock, codes[4]);
        print_error(sock, codes[4], method);
        return;
    }

dove si confronta la stringa con quelle presenti nel vettore dei metodi definiti, che è terminato da un vettore nullo. Completato il confronto se non si è trovato nessun metodo implementato si invia al client una opportuna pagina di errore con il corretto codice HTTP.

Se si è trovato un metodo valido (nel caso GET) viene poi letto, per completare la ricezione della richiesta dal client, tutto quanto esso ha scritto sul socket, fintanto che non si incontra una riga vuota; non si farà nessun uso di tutto questo per cui, come si vede dal seguente codice:

    while (strcmp(line,"\r\n")) {
        line = fgets(buffer, MAXLINE, sock);
    }

viene sovrascritto ripetutamente il contenuto di buffer, che è anche il motivo per cui la scansione della stringa è stata fatta su una copia della stessa (altrimenti il contenuto originale sarebbe stato modificato).

Una volta completata la lettura della richiesta (e la relativa scansione della prima riga) si può passare ad utilizzare le informazioni così ricavate, il primo passo è quello di aprire il file che è stato richiesto, questo viene fatto dal seguente codice:

    if ( (file = fopen(filename, "r")) == NULL) {
        if ( (errno == EACCES)||(errno == EPERM) ) {
            print_headers(sock, codes[5]);
            print_error(sock, codes[5], filename);
        } else {
            print_headers(sock, codes[2]);
            print_error(sock, codes[2], filename);
        }
        return;
    }

Di nuovo si usa l'I/O bufferizzato, ed in caso di errore di riporta lo stesso al client, inviandogli una risposta contenente l'intestazione con l'adeguato codice di errore, ed una pagina web con una spiegazione dello stesso ed un po' di informazioni (nel caso il nome del file che ha dato l'errore). Per questo si usano le due funzioni di servizio: print_headers che stampa le intestazioni con il codice indicato, e print_error che genera una pagina HTML con la descrizione dell'errore. In entrambi i casi si usa il vettore codes i cui elementi sono una apposita struttura che contiene le informazioni relative ai vari codici di risposta HTTP. In questo modo si può usare la stessa funzione per eseguire le stampe di tutti i dati necessari.

Se si è aperto con successo il file, a questo punto si inviano le intestazioni per una risposta corretta e poi si passa, all'interno di un ciclo ripetuto fintanto che non si incontra la fine del file, a leggere da esso un blocco di dati per poi scriverlo sul socket, dal quale arriverà al client. Nel caso specifico si leggono i dati a blocci di 1kb, e poi li si scrivono, il relativo codice è il seguente:

    while (!feof(file)) {
        if ( (nleft = full_fread(file, outbuf, 1024)) != 0) {
            if (ferror(file)) {
                strncpy(buffer, strerror(errno), MAXLINE);
                print_headers(sock, codes[3]);
                print_error(sock, codes[3], buffer);
                return;
            }
        }
        if (full_fwrite(sock, outbuf, 1024-nleft) != 0) {
            if (ferror(file)) {
                strncpy(buffer, strerror(errno), MAXLINE);
                print_headers(sock, codes[3]);
                print_error(sock, codes[3], buffer);
                return;
            }
        }
    }

Si noti come si siano usate due funzioni di servizio, full_fread e full_fwrite che servono ad eseguire una lettura ed una scrittura completa. In generale infatti non è detto che ad una lettura o scrittura siano eseguite per tutta l'estensione richiesta, è perfettamente legittimo che le operazioni siano eseguite solo parzialmente (perché il buffer di scrittura era pieno o perché il file è finito), per questo le due funzioni ciclano al loro interno ripetendo le operazioni di I/O fintanto che tutti i dati sono trasmessi o viene rilevato un errore o una condizione di EOF (end of file). Con la conclusione di questo ciclo la pagina è stata inviata, e la funzione ServPage termina con la chiusura del file e del socket, ritornando nel corpo principale del programma dove abbiamo visto esserci semplicemente l'istruzione di terminazione del programma.

Infine per capire meglio il funzionamento del programma vale la pena soffermarsi sulle due funzioni di servizio print_headers e print_error; entrambe usano come argomento una variabile di tipo:

struct code_page {
    char * code; 
    char * name;
    char * body;
};

La struttura è stata creata per poter gestire i vari codici di ritorno HTTP, ed i messaggi ad essi associati; il primo campo è il codice numerico dell'errore, il secondo il suo nome simbolico, ed il terzo una stringa di formato per printf. Quest'ultimo verrà usata nella pagina di errore, e dovrà contenere un '%s', che sarà sostituito dalla stringa passata come ulteriore argomento alla funzione che usa questo campo.

Per definire gli errori si è creato un vettore di variabili di questo tipo, per poi usarne i membri all'interno delle funzioni che necessitano di accedere alle relative informazioni; nel nostro caso il vettore è il seguente:

    struct code_page codes[] = {
        { "200", "OK", "%s"},
        { "400", "Bad Request", 
          "Your browser sent a request that this server could not understand."
          "<P>The request line<P>%s<P> is invalid following the protocol<P>"}, 
        { "404", "Not Found", 
          "The requested URL %s was not found on this server.<P>"},
        { "500", "Internal Server Error", 
          "We got an error processing your request.<P>Error is: %s<P>"},
        { "405", "Method Not Allowed", "Method %s not allowed.<P>"},
        { "403", "Forbidden", "You cannot access %s.<P>"}
    };

Dopo di che è stato sufficiente usare uno degli elementi di questo vettore per scrivere una funzione generica; in particolare se guardiamo il codice di print_headers vedremo che essa è tutto sommato banale e si limita ad una serie di scritture (che nel nostro caso andranno sul file associato al socket). Queste non sono altro che le intestazioni minimali che permettono ad un browser di ricevere correttamente la nostra pagina; in particolare sono necessarie la riga che dichiara che chiuderemo la connessione alla conclusione dell'invio (quella con Connection: close) e quella che dichiara il contenuto della pagina (quella con Content-Type: text/html ...):

void print_headers(FILE *file, struct code_page code)
{
    time_t tempo;

    fprintf(file, "HTTP/1.0 %s %s \n", code.code, code.name);
    time(&tempo);
    fprintf(file, "Date: %s", ctime(&tempo));
    fprintf(file, "Server: WWWd test server\n");
    fprintf(file, "Connection: close\n");
    fprintf(file, "Content-Type: text/html; charset=iso-8859-1\n");
    fprintf(file, "\n");
    return;
}
Last modified 14 years ago Last modified on Apr 27, 2005, 11:13:59 PM