This is nntp.c in view mode; [Download] [Up]
/* * nntp module for nn. * * The original taken from the nntp 1.5 clientlib.c * Modified heavily for nn. * * Rene' Seindal (seindal@diku.dk) Thu Dec 1 18:41:23 1988 * * I have modified Rene's code quite a lot for 6.4 -- I hope he * can still recognize a bit here and a byte there; in any case, * any mistakes are mine :-) ++Kim */ #include "config.h" /* * nn maintains a cache of recently used articles to improve efficiency. * To change the size of the cache, define NNTPCACHE in config.h to be * the new size of this cache. */ #ifndef NNTPCACHE #define NNTPCACHE 10 #endif #ifdef NNTP #include <stdio.h> #include "nntp.h" #include <sys/socket.h> #ifndef EXCELAN #include <netdb.h> #endif #include <errno.h> /* This is necessary due to the definitions in m-XXX.h */ #if !defined(NETWORK_DATABASE) || defined(NETWORK_BYTE_ORDER) #include <netinet/in.h> #endif #ifdef EXCELAN #ifndef IPPORT_NNTP #define IPPORT_NNTP 119 #endif #endif import char *db_directory, *tmp_directory, *news_active; export char nntp_server[256]; /* name of nntp server */ export int nntp_failed = 0; /* bool: t iff connection is broken in nntp_get_article() or nntp_get_active() */ export int nntp_cache_size = NNTPCACHE; export char *nntp_cache_dir = NULL; export int nntp_local_server = 0; export int nntp_debug = 0; import int silent, no_update; import int sys_nerr; import char *sys_errlist[]; extern int user_error(); extern int sys_error(); extern int sys_warning(); #define syserr() (errno >= 0 && errno < sys_nerr ? \ sys_errlist[errno] : "Unknown error.") import char *mktemp(); static FILE *nntp_in = NULL; /* fp for reading from server */ static FILE *nntp_out = NULL; /* fp for writing to server */ static int is_connected = 0; /* bool: t iff we are connected */ static group_header *group_hd; /* ptr to servers current group */ static int group_is_set = 0; /* bool: t iff group_hd is set */ static int try_again = 0; /* bool: t if timeout forces retry */ static int can_post = 0; /* bool: t iff NNTP server accepts postings */ #define ERR_TIMEOUT 503 /* Response code for timeout */ #ifdef NO_BZERO static bzero(p, l) register char *p; register int l; { while (l-- > 0) *p++ = 0; } #endif #ifdef NO_RENAME static rename(old, new) char *old, *new; { if (unlink(new) < 0 && errno != ENOENT) return -1; if (link(old, new) < 0) return -1; return unlink(old); } #endif /* * debug_msg: print a debug message. * * The master appends prefix and str to a log file, and clients * prints it as a message. * * This is controlled via the nntp-debug variable in nn, and * the option -D2 (or -D3 if the normal -D option should also * be turned on). Debug output from the master is written in * $TMP/nnmaster.log. */ static debug_msg(prefix, str) char *prefix, *str; { static FILE *f = NULL; if (who_am_i == I_AM_MASTER) { if (f == NULL) { f = open_file(relative(tmp_directory, "nnmaster.log"), OPEN_CREATE); if (f == NULL) { nntp_debug = 0; return; } } fprintf(f, "%s %s\n", prefix, str); fflush(f); return; } msg("NNTP%s %s", prefix, str); user_delay(1); } /* * io_error: signal an I/O error in talking to the server. * * An nn client terminates a session with the user. The master * simply closes the connection. The flag nntp_failed is set, for * use by the master to terminate collection. * * BUG: if the nntp server is forcibly killed, errno can contain a * bogus value, resulting in strange error messages. It is * probably better just to write out the numerical value of errno. */ static io_error() { if (who_am_i != I_AM_MASTER) { user_error("Lost connection to NNTP server %s: %s", nntp_server, syserr()); /* NOTREACHED */ } nntp_failed = 1; if (is_connected) { log_entry('N', "Lost connection to server %s: %s", nntp_server, syserr()); nntp_close_server(); } } /* * find_server: Find out which host to use as NNTP server. * * This is done by consulting the file NNTP_SERVER (defined in * config.h). Set nntp_server[] to the host's name. */ static void find_server() { char *cp, *name, *getenv(); char buf[BUFSIZ]; FILE *fp; /* * This feature cannot normally be enabled, because the database and * the users rc file contains references to articles by number, and * these numbers are not unique across NNTP servers. */ #ifdef DEBUG if ((cp = getenv("NNTPSERVER")) != NULL) { strncpy(nntp_server, cp, sizeof nntp_server); return; } #endif /* DEBUG */ name = NNTP_SERVER; if (*name != '/') name = relative(lib_directory, name); if ((fp = open_file(name, OPEN_READ)) != NULL) { while (fgets(buf, sizeof buf, fp) != 0) { if (*buf == '#' || *buf == '\n') continue; if ((cp = strchr(buf, '\n')) != 0) *cp = '\0'; strncpy(nntp_server, buf, sizeof nntp_server); fclose(fp); return; } fclose(fp); } if (who_am_i != I_AM_MASTER) printf("\nCannot find name of NNTP server.\nCheck %s\n", name); sys_error("Failed to find name of NNTP server!"); } /* * get_server_line: get a line from the server. * * Expects to be connected to the server. * The line can be any kind of line, i.e., either response or text. */ static get_server_line(string, size) char *string; int size; { register char *cp, *nl; if (fgets(string, size, nntp_in) == NULL) { io_error(); return -1; } for (cp = string, nl = NULL; *cp != NUL; cp++) { if (*cp == CR) { nl = cp; break; } if (nl == NULL && *cp == NL) nl = cp; } if (nl != NULL) *nl = NUL; return 0; } /* * get_server: get a response line from the server. * * Expects to be connected to the server. * Returns the numerical value of the reponse, or -1 in case of errors. */ static get_server(string, size) char *string; int size; { if (get_server_line(string, size) < 0) return -1; if (nntp_debug) debug_msg("<<<", string); return isdigit(*string) ? atoi(string) : 0; } /* * get_socket: get a connection to the nntp server. * * Errors can happen when YP services or DNS are temporarily down or * hung, so we log errors and return failure rather than exitting if we * are the master. The effects of retrying every 15 minutes (or whatever * the -r interval is) are not that bad. Dave Olson, SGI */ static get_socket() { int s; struct sockaddr_in sin; #ifndef EXCELAN struct servent *getservbyname(), *sp; struct hostent *gethostbyname(), *hp; #ifdef h_addr int x = 0; register char **cp; #endif if ((sp = getservbyname("nntp", "tcp")) == NULL) return sys_warning("nntp/tcp: Unknown service.\n"); s = who_am_i == I_AM_MASTER ? 10 : 2; while ((hp = gethostbyname(nntp_server)) == NULL) { if (--s < 0) goto host_err; sleep(10); } bzero((char *) &sin, sizeof(sin)); sin.sin_family = hp->h_addrtype; sin.sin_port = sp->s_port; #else /* EXCELAN */ char *machine; bzero((char*) &sin, sizeof(sin)); sin.sin_family = AF_INET; sin.sin_port = htons(0); #endif /* EXCELAN */ #ifdef h_addr /* get a socket and initiate connection -- use multiple addresses */ s = x = -1; for (cp = hp->h_addr_list; cp && *cp; cp++) { s = socket(hp->h_addrtype, SOCK_STREAM, 0); if (s < 0) goto sock_err; bcopy(*cp, (char *)&sin.sin_addr, hp->h_length); x = connect(s, (struct sockaddr *)&sin, sizeof (sin)); if (x == 0) break; if (who_am_i != I_AM_MASTER) msg("Connecting to %s failed: %s", nntp_server, syserr()); (void) close(s); s = -1; } if (x < 0) sys_warning("Giving up on NNTP server %s!", nntp_server); #else /* no name server */ #ifdef EXCELAN if ((s = socket(SOCK_STREAM, NULL, &sin, SO_KEEPALIVE)) < 0) goto sock_err; sin.sin_port = htons(IPPORT_NNTP); machine = nntp_server; if ((sin.sin_addr.s_addr = rhost(&machine)) == -1) { (void) close(s); goto host_err; } /* And then connect */ if (connect(s, &sin) < 0) goto conn_err; #else /* not EXCELAN */ if ((s = socket(AF_INET, SOCK_STREAM, 0)) < 0) goto sock_err; /* And then connect */ bcopy(hp->h_addr, (char *) &sin.sin_addr, hp->h_length); if (connect(s, (struct sockaddr *) &sin, sizeof(sin)) < 0) goto conn_err; #endif /* EXCELAN */ #endif return s; host_err: sys_warning("NNTP server %s unknown.\n", nntp_server); return -1; sock_err: sys_warning("Can't get NNTP socket: %s", syserr()); return -1; conn_err: (void) close(s); if (who_am_i == I_AM_MASTER) sys_warning("Connecting to %s failed: %s", nntp_server, syserr()); return -1; } /* * connect_server: initialise a connection to the nntp server. * * It expects nntp_server[] to be set previously, by a call to * nntp_check. It is called from nntp_get_article() and * nntp_get_active() if there is no established connection. */ static connect_server() { int sockt_rd, sockt_wr; int response; char line[NNTP_STRLEN]; if (who_am_i != I_AM_MASTER && !silent) msg("Connecting to NNTP server %s ... ", nntp_server); nntp_failed = 1; is_connected = 0; sockt_rd = get_socket(); if (sockt_rd < 0) return -1; if ((nntp_in = fdopen(sockt_rd, "r")) == NULL) { close(sockt_rd); return -1; } sockt_wr = dup(sockt_rd); if ((nntp_out = fdopen(sockt_wr, "w")) == NULL) { close(sockt_wr); fclose(nntp_in); nntp_in = NULL; /* from above */ return -1; } /* Now get the server's signon message */ response = get_server(line, sizeof(line)); if (who_am_i == I_AM_MASTER) { if (response != OK_CANPOST && response != OK_NOPOST) { log_entry('N', "Failed to connect to NNTP server"); log_entry('N', "Response: %s", line); fclose(nntp_out); fclose(nntp_in); return -1; } } else { switch (response) { case OK_CANPOST: can_post = 1; break; case OK_NOPOST: can_post = 0; break; default: user_error(line); /* NOTREACHED */ } } if (who_am_i != I_AM_MASTER && !silent) msg("Connecting to NNTP server %s ... ok (%s)", nntp_server, can_post ? "posting is allowed" : "no posting"); is_connected = 1; group_is_set = 0; nntp_failed = 0; try_again = 0; return 0; } /* * put_server: send a line to the nntp server. * * Expects to be connected to the server. */ static put_server(string) char *string; { if (nntp_debug) debug_msg(">>>", string); fprintf(nntp_out, "%s\r\n", string); if (fflush(nntp_out) == EOF) { io_error(); return -1; } return 0; } /* * ask_server: ask the server a question and return the answer. * * Expects to be connected to the server. * Returns the numerical value of the reponse, or -1 in case of * errors. * Contains some code to handle server timeouts intelligently. */ /* LIST XXX return fatal ERR_FAULT code if requested list does not exist */ /* This is only fatal for LIST ACTIVE -- else change to ERR_NOGROUPS */ static int fix_list_response = 0; /*VARARGS*/ static ask_server(va_alist) va_dcl { char buf[NNTP_STRLEN]; char *fmt; int response; int fix_err; use_vararg; fix_err = fix_list_response; fix_list_response = 0; start_vararg; fmt = va_arg1(char *); vsprintf(buf, fmt, va_args2toN); end_vararg; if (put_server(buf) < 0) return -1; response = get_server(buf, sizeof(buf)); /* * Handle the response from the server. Responses are handled as * followes: * * 100-199 Informational. Passed back. (should they be ignored?). * 200-299 Ok messages. Passed back. * 300-399 Ok and proceed. Can not happen in nn. * 400-499 Errors (no article, etc). Passed up and handled there. * 500-599 Fatal NNTP errors. Handled below. */ if (response == ERR_GOODBYE || response > ERR_COMMAND) { if (fix_err && response == ERR_FAULT) return ERR_NOGROUP; nntp_failed = 1; nntp_close_server(); if (response != ERR_TIMEOUT) { /* if not timeout, complain */ sys_error("NNTP %s response: %d", buf, response); /* NOTREACHED */ } try_again = 1; group_is_set = 0; } return response; } /* * copy_text: copy text response into file. * * Copies a text response into an open file. * Return -1 on error, 0 otherwise. It is treated as an error, if * the returned response it not what was expected. */ static int last_copy_blank; static copy_text(fp) register FILE *fp; { char buf[NNTP_STRLEN]; register char *cp; register int nlines; nlines = 0; last_copy_blank = 0; while (get_server_line(buf, sizeof buf) >= 0) { cp = buf; if (*cp == '.') if (*++cp == '\0') { if (nlines <= 0) break; if (nntp_debug) { sprintf(buf, "%d lines", nlines); debug_msg("COPY", buf); } return 0; } fputs(cp, fp); last_copy_blank = (*cp == NUL); putc('\n', fp); nlines++; } fclose(fp); if (nntp_debug) debug_msg("COPY", "EMPTY"); return -1; } static do_set_group() { int n; switch (n = ask_server("GROUP %s", group_hd->group_name)) { case OK_GROUP: group_is_set = 1; return 1; case ERR_NOGROUP: log_entry('N', "NNTP: group %s not found", group_hd->group_name); return -1; default: if (try_again) return 0; /* Handle nntp server timeouts */ break; } if (!nntp_failed) { log_entry('N', "GROUP %s response: %d", group_hd->group_name, n); nntp_failed = 1; } return -1; } /* * The following functions implements a simple lru cache of recently * accessed articles. It is a simple way to improve effeciency. Files * must be kept by name, because the rest of the code expects to be able * to open an article multiple times, and get separate file pointers. */ struct cache { char *file_name; /* file name */ article_number art; /* article stored in file */ group_header *grp; /* from this group */ unsigned time; /* time last accessed */ } cache[NNTPCACHE]; static unsigned time_counter = 1; /* virtual time */ /* * search_cache: search the cache for an (article, group) pair. * * Returns a pointer to the slot where it is, null otherwise */ static struct cache *search_cache(art, gh) article_number art; group_header *gh; { struct cache *cptr = cache; int i; if (who_am_i == I_AM_MASTER) return NULL; if (nntp_cache_size > NNTPCACHE) nntp_cache_size = NNTPCACHE; for (i = 0; i < nntp_cache_size; i++, cptr++) if (cptr->art == art && cptr->grp == gh) { cptr->time = time_counter++; return cptr; } return NULL; } /* * new_cache_slot: get a free cache slot. * * Returns a pointer to the allocated slot. * Frees the old filename, and allocates a new, unused filename. * Cache files can also stored in a common directory defined in * ~/.nn or CACHE_DIRECTORY if defined in config.h. */ static struct cache *new_cache_slot() { register struct cache *cptr = cache; int i, lru; unsigned min_time = time_counter; char name[FILENAME]; if (nntp_cache_dir == NULL) { #ifdef CACHE_DIRECTORY nntp_cache_dir = CACHE_DIRECTORY; #else if (who_am_i == I_AM_MASTER) nntp_cache_dir = db_directory; else nntp_cache_dir = nn_directory; #endif } if (who_am_i == I_AM_MASTER) { cptr = &cache[0]; if (cptr->file_name == NULL) cptr->file_name = mk_file_name(nntp_cache_dir, "master_cache"); return cptr; } for (i = 0; i < nntp_cache_size; i++, cptr++) if (min_time > cptr->time) { min_time = cptr->time; lru = i; } cptr = &cache[lru]; if (cptr->file_name == NULL) { sprintf(name, "%s/nn-%d.%02d~", nntp_cache_dir, process_id, lru); cptr->file_name = copy_str(name); } else unlink(cptr->file_name); cptr->time = time_counter++; return cptr; } /* * clean_cache: clean up the cache. * * Removes all allocated files. */ static void clean_cache() { struct cache *cptr = cache; int i; for (i = 0; i < nntp_cache_size; i++, cptr++) if (cptr->file_name) unlink(cptr->file_name); } /* * nntp_check: Find out whether we need to use NNTP. * * This is done by comparing the NNTP servers name with whatever * gethostname() returns. * use_nntp and news_active are initialised as a side effect. */ nntp_check() { char host[128]; if (nntp_local_server) return; find_server(); gethostname(host, sizeof host); use_nntp = strcmp(host, nntp_server) != 0; /* too simplistic ??? */ if (use_nntp) { freeobj(news_active); news_active = mk_file_name(db_directory, "ACTIVE"); } } /* * nntp_no_post: Check to see whether posting is allowed. */ nntp_no_post() { if (!is_connected && connect_server() < 0) return 1; /* If we cannot connect, neither can inews */ if (can_post == 0) { msg("NNTP server does not allow postings from this host. Sorry!"); return 1; } return 0; } /* * nntp_set_group: set the server's current group. * * Actual communication is delayed until an article is accessed, to * avoid unnecessary traffic. */ nntp_set_group(gh) group_header *gh; { group_hd = gh; group_is_set = 0; return 0; } /* * nntp_get_active: get a copy of the active file. * * If we are the master get a copy of the file from the nntp server. * nnadmin just uses the one we already got. In this way the master * can maintain a remote copy of the servers active file. * We try to be a little smart, if not inefficient, about the * modification times on the local active file. * Even when the master is running on the nntp server, a separate * copy of the active file will be made for access via NFS. */ nntp_get_active() { FILE *old, *new; char bufo[NNTP_STRLEN], bufn[NNTP_STRLEN]; char *new_name; int same, n; if (who_am_i != I_AM_MASTER) return access(news_active, 4); again: if (!is_connected && connect_server() < 0) return -1; new_name = mktemp(relative(db_directory, ".actXXXXXX")); switch (n = ask_server("LIST")) { case OK_GROUPS: new = open_file(new_name, OPEN_CREATE_RW|MUST_EXIST); if (copy_text(new) == 0) { if (fflush(new) != EOF) break; fclose(new); } unlink(new_name); if (!nntp_failed) { log_entry('N', "LIST empty"); nntp_failed = 1; } return -1; default: if (try_again) goto again; /* Handle nntp server timeouts */ log_entry('N', "LIST response: %d", n); return -1; } rewind(new); same = 0; if ((old = open_file(news_active, OPEN_READ)) != NULL) { do { fgets(bufo, sizeof bufo, old); fgets(bufn, sizeof bufn, new); } while (!feof(old) && !feof(new) && strcmp(bufo, bufn) == 0); same = feof(old) && feof(new); fclose(old); } fclose(new); if (same) unlink(new_name); else if (rename(new_name, news_active) != 0) sys_error("Cannot rename %s to %s", new_name, news_active); return 0; } /* * nntp_get_newsgroups: get a copy of the newsgroups file. * * Use the "LIST NEWSGROUPS" command to get the newsgroup descriptions. * Based on code from: olson%anchor.esd@sgi.com (Dave Olson) */ FILE *nntp_get_newsgroups() { char *new_name; FILE *new = NULL; int n; new_name = mktemp(relative(tmp_directory, "nngrXXXXXX")); new = open_file(new_name, OPEN_CREATE_RW|OPEN_UNLINK); if (new == NULL) return NULL; again: if (!is_connected && connect_server() < 0) goto err; fix_list_response = 1; switch (n = ask_server("LIST NEWSGROUPS")) { case ERR_NOGROUP: /* really ERR_FAULT */ goto err; case OK_GROUPS: if (copy_text(new) == 0) { if (fflush(new) != EOF) break; fclose(new); } if (!nntp_failed) { log_entry('N', "LIST NEWSGROUPS empty"); nntp_failed = 1; } return NULL; default: if (try_again) goto again; /* Handle nntp server timeouts */ log_entry('N', "LIST NEWSGROUPS response: %d", n); goto err; } rewind(new); return new; err: fclose(new); return NULL; } /* * nntp_get_article_list: get list of all article numbers in group * * Sends XHDR command to the server, and parses the following * text response to get a list of article numbers which is saved * in a list and returned. * Return NULL on error. It is treated as an error, if * the returned response it not what was expected. */ static article_number *article_list = NULL; static long art_list_length = 0; static sort_art_list(f1, f2) register article_number *f1, *f2; { return (*f1 < *f2) ? -1 : (*f1 == *f2) ? 0 : 1; } article_number *nntp_get_article_list(gh) group_header *gh; { char buf[NNTP_STRLEN]; register article_number *art; register char *cp; register long count = 0; /* No. of completions plus one */ int n; static int try_listgroup = 1; again: if (!is_connected && connect_server() < 0) return NULL; /* it is really an extreme waste of time to use XHDR since all we */ /* are interested in is the article numbers (as we do locally). */ /* If somebody hacks up an nntp server that understands LISTGROUP */ /* they will get much less load on the nntp server */ /* It should simply return the existing article numbers is the group*/ /* -- they don't even have to be sorted (only XHDR needs that) */ if (try_listgroup) { switch (n = ask_server("LISTGROUP %s", group_hd->group_name)) { case OK_GROUP: break; default: if (try_again) goto again; /* Handle nntp server timeouts */ log_entry('N', "LISTGROUP response: %d", n); return NULL; case ERR_COMMAND: try_listgroup = 0; goto again; /* error may have closed down server connection */ } } if (!try_listgroup) { if (group_is_set == 0) switch (do_set_group()) { case -1: return NULL; case 0: goto again; case 1: break; } switch (n = ask_server("XHDR message-id %ld-%ld", (long)gh->first_db_article, (long)gh->last_db_article)) { case OK_HEAD: break; default: if (try_again) goto again; /* Handle nntp server timeouts */ log_entry('N', "XHDR response: %d", n); return NULL; case ERR_COMMAND: nntp_failed = 2; return NULL; } } count = 0; art = article_list; while (get_server_line(buf, sizeof buf) >= 0) { cp = buf; if (*cp == '.' && *++cp == '\0') break; if (count == art_list_length) { art_list_length += 250; article_list = resizeobj(article_list, article_number, art_list_length + 1); art = article_list + count; } *art++ = atol(cp); count++; } if (article_list != NULL) { *art = 0; if (try_listgroup && count > 1) quicksort(article_list, count, article_number, sort_art_list); } return article_list; } /* * nntp_get_article: get an article from the server. * * Returns a FILE pointer. * If necessary the server's current group is set. * The article (header and body) are copied into a file, so they * are seekable (nn likes that). */ static char *mode_cmd[] = { "ARTICLE", "HEAD", "BODY" }; FILE *nntp_get_article(article, mode) article_number article; int mode; /* 0 => whole article, 1 => head only, 2 => body only */ { FILE *tmp; static struct cache *cptr; int n; again: if (!is_connected && connect_server() < 0) { return NULL; } /* * Set the server group to the current group */ if (group_is_set == 0) switch (do_set_group()) { case -1: return NULL; case 0: goto again; case 1: break; } /* * Search the cache for the requested article, and allocate a new * slot if necessary (if appending body, we already got it). */ if (mode != 2) { cptr = search_cache(article, group_hd); if (cptr != NULL) goto out; cptr = new_cache_slot(); } /* * Copy the article. */ switch (n = ask_server("%s %ld", mode_cmd[mode], (long)article)) { case OK_ARTICLE: case OK_HEAD: tmp = open_file(cptr->file_name, OPEN_CREATE|MUST_EXIST); if (copy_text(tmp) < 0) return NULL; if (mode == 1 && !last_copy_blank) fputc(NL, tmp); /* add blank line after header */ if (fclose(tmp) == EOF) goto err; cptr->art = article; cptr->grp = group_hd; goto out; case OK_BODY: tmp = open_file(cptr->file_name, OPEN_APPEND|MUST_EXIST); fseek(tmp, (off_t)0, 2); if (copy_text(tmp) < 0) return NULL; if (fclose(tmp) == EOF) goto err; goto out; case ERR_NOARTIG: return NULL; default: if (try_again) goto again; /* Handle nntp server timeouts */ log_entry('N', "ARTICLE %ld response: %d", (long)article, n); nntp_failed = 1; return NULL; } out: return open_file(cptr->file_name, OPEN_READ|MUST_EXIST); err: sys_error('N', "Cannot write temporary file %s", cptr->file_name); } /* * Return local file name holding article */ char *nntp_get_filename(art, gh) article_number art; group_header *gh; { struct cache *cptr; cptr = search_cache(art, gh); return cptr == NULL ? NULL : cptr->file_name; } /* * nntp_close_server: close the connection to the server. */ nntp_close_server() { if (!is_connected) return; if (!nntp_failed) { /* avoid infinite recursion */ int n; n = ask_server("QUIT"); if (n != OK_GOODBYE) ; /* WHAT NOW ??? */ } (void) fclose(nntp_out); (void) fclose(nntp_in); is_connected = 0; } /* * nntp_cleanup: clean up after an nntp session. * * Called from nn_exit(). */ nntp_cleanup() { if (is_connected) nntp_close_server(); clean_cache(); } #endif /* NNTP */
These are the contents of the former NiCE NeXT User Group NeXTSTEP/OpenStep software archive, currently hosted by Netfuture.ch.