Kyle is a programmer for TGV and can be contacted at [email protected].
MS-DOS 6's Interlnk program enables simple resource sharing by allowing you to connect two PCs--a client and a server--via their serial or parallel ports. In this way, you can use one PC to run programs and access data that resides on another. Unfortunately, the server PC must be dedicated, cannot run Windows, and does not allow remote access via the modem. Consequently, I ended up building my own client/server MS-DOS resource-sharing package that supports remote logins, security, and other features. Only three pieces of software are needed to build this utility--a serial port driver, basic networking, and an MS-DOS communication layer--all of which are available in various forms. Once I brought these pieces together, my job was relatively easy.
Still, the package (available electronically in both source and executable form; see "Availability," page 3) is approximately 6000 lines of C and 3000 lines of assembly code. I wrote most of this package in C for readability. However, there are certain functions that are simply easier to write in assembly stubs. Chaining to another interrupt is easily accomplished in assembly, for instance, and is much faster than any C function. Also, the CRC32 routine was written in assembly for speed: A 4.77-MHz 8088 can process 25K/sec in the assembly-language CRC routine. This was a five-fold increase over my best effort in straight C. The code/algorithms were adapted from information gleaned from the comp.compression FAQ. The original algorithm is attributed to Rob Warnock (rpw3@ sgi.com).
MS-DOS Internals
For all of its shortcomings, MS-DOS is not horribly laid out. Specifically, all important data and structures are contained in an area of contiguous data, roughly 2KB long, known as the "swappable data area" (SDA). This area contains information such as the process ID of the currently running process, any errors encountered, the current disk-transfer address, and so on. This layout is good because all you need to do to change the context of MS-DOS is swap the contents of this area for each active task, making MS-DOS reentrant and multitasking. Luckily, only a handful of data is needed to successfully multitask and run the installable file system. These are all noted in the dosdata.c module of my package.
This SDA has remained basically unchanged since MS-DOS 3.2, with the exception of MS-DOS 4.x, where Microsoft attempted to build multitasking into the MS-DOS kernel. Because of bugs and incompatibilities, this kernel was abandoned with the release of MS-DOS 5.0. This is only worth mentioning because my DOSRIFS CLIENT module (and the multitasking version of the SERVER module) is not compatible with MS-DOS 4.x.
Since MS-DOS was not designed to be reentrant or multitasking, there are a few precautions you must take before swapping the SDA. Certain functions of the BIOS must be protected; for instance, you don't want to interrupt a pending disk operation. The file _server.asm provides all of the necessary checks to keep MS-DOS and BIOS functioning correctly during a task switch.
MS-DOS Redirector Interface
The most difficult part of the project involved communicating with MS-DOS. MS-DOS 3.1 included the network-redirector interface attached to Int 0x2f, function 0x11. Although this is often referred to as the MSCDEX API and associated with CD-ROMs, it is actually an installable file-system API that can be used as a translation layer to allow access to any foreign file system. Using the network redirector requires knowledge of many of the MS-DOS internals: the list of lists (LOL), SDA (also known as the "MS-DOS data segment"), current directory structure (CDS), and system file table (SFT). All of the necessary information can be found in Ralf Brown's interrupt list (available from all major MS-DOS FTP sites) and in the book, Undocumented DOS, by Andrew Schulman et al. (Addison-Wesley, 1993). The 22 installable file system (IFS) functions are laid out in a seemingly random order. To simplify things, I grouped them (see Table 1).
Also note that there is no single way to determine whether a particular call should be intercepted. All loaded drivers in the chain receive all requests. If a driver doesn't process a request, it is supposed to chain to the next driver. The logical drives (A:, B:, C:, and so on) are processed by the last driver in the chain. To determine that a call should be intercepted, you need to follow two rules:
- For maintenance commands, the MS-DOS CDS pointer will point to the drive for which the maintenance is to be performed, so the driver need only check the first three characters in the path name for x:\ where x is the drive you are intercepting.
- For file I/O commands, ES:DI points to the file information in the system file table. The device on which the file was opened is in the low 6 bits of the dev_info field, numbered 1...26.
Client Module
All IFS dispatching occurs in clientDispatch. The functions in _dispatchTable are laid out based on the subfunction number. The table contains the address of the function that handles this request, the type of check necessary to determine if the request is for a RIFS device, and the RIFS function number (for the server).
If a function does not exist in the table (Null entry), or the call is not directed to a RIFS device, the driver restores the state of all the registers and chains to the next driver.
To conserve memory, each packet is retrieved from a common packet queue. If a packet cannot be found within 30 seconds, a General Failure error is returned. If the packet is found, the necessary fields are initialized, and the packet is sent to the correct function for further processing.
Each function packages the required parameters (Table 2), transmits the request, and waits for a reply. To enable retrying a command, the client module maintains two active packets: one send and one receive.
The most complex functions are file read and file write, because the length of the requested operation can exceed the packet size. To circumvent this, I simply break all requests into 1024-byte (or smaller) pieces and process them one at a time.
Finally, any active packets are released, and the correct status information is returned to MS-DOS.
Packeting
Packet processing is carried out in dispatch.c (see Listing One). This makes it easy to change the algorithms and protocols without looking at any other code. The two packeting functions are Transmit and Receive. A packet contains the following packet-specific fields:
- ID, two bytes identifying the destination. I used AY to identify packets headed for the server, and LY to identify packets headed toward the client.
- length, the total number of bytes in the packet, including the header.
- notLength, the 1's complement of the length, for error checking
- CRC32, the CRC 32 value calculated across all data, plus the header (for the calculation, the CRC32 field is set to 0).
Receive is somewhat more interesting as it is done byte-by-byte, using a simple state machine; see Table 3. If any state fails the validity check, the process begins again. The packets are assumed to come in blocks. If any byte is not received within about half a second after the last byte, the state times out and returns to 0.
After a full packet is received, it is submitted to either the client or the server for further processing.
Server Module
The server module is easier to implement than the client, and it can also be made portable to any environment (OS/2, Window NT, Windows 95, UNIX, and so on) with a little effort.
All server processing occurs in _serverDispatch, which is called by the assembly routine _serverTest in _SERVER.ASM. The _serverTest checks for the following conditions: DOS can be interrupted; the program is not trying to interrupt itself; BIOS can be interrupted; and at least one server request is pending.
Once at _serverDispatch, the MS-DOS data area is swapped with the saved version, and the various interrupts are set to prevent accidental interruption: Ctrl-Break handling is simply ignored, and the Abort, Retry, Fail? command is set to always return Fail.
Next, a check is made that a valid function number was used and a valid connection exists. Again, the connection can fail without warning or a correct connection might never have been made (invalid password given during login).
Finally, the packet is sent off for processing, and transmitted back to the client. The server never resends a packet, so the same one is reused when packaging the result.
When running under MS-DOS, the server runs in the background, interrupting only when necessary to process a packet. Unfortunately, this makes the server very system (and version) dependent.
Security
I've implemented two levels of security, login and share. When a client first establishes a connection with the server, it sends a password. If the password matches with the server, a user validation code is returned to the client with a result code of 0; otherwise, the result code ACCESS_ DENIED is returned and the port is reset. This user-validation code must be present in all packets sent to the server.
In addition, there is share-level security. When a drive is shared by the server, the following security flags are allowed in addition to a password: directory search, directory create, directory remove, file create, file remove, file read, and file write. To connect to a shared drive, the client sends an IFS_SHAREREQUEST message with the share name and password. If the server validates the password, it returns a verification number along with a result code of 0. This verification number must be present in the shareValidation field for any request to the shared drive, or the result code will be set to ACCESS_DENIED.
Parallel-Port Sharing
Port sharing is accomplished by intercepting interrupt 0x17 (the parallel port I/O) and routing the requests to the server. Due to packet overhead, sending one byte at a time is inefficient, so a packet is sent only when it becomes full (1024 bytes), a time-out is reached, or function 0x01 ("initialize parallel port") is called. All of these functions return a status of printer on line and printer not busy.
When the server receives a packet to be routed to a port, it sends the data to the printer using a simple loop. For this reason, you should run a print spooler on any server that shares its parallel ports.
Serial ports are currently not shareable. The interface exists, but since the BIOS routines do single-character I/O, the overhead would quickly eat up any savings. The data could be buffered in both directions, but I have not implemented it.
Communication
The communications interface is handled through the IO_BASE structure. This allows a very generic interface from which the specialized device drivers can be derived. Currently, only three such drivers are provided:
- IO_LOOP, the debugging driver that simply echoes all characters received at the Receive(...) function in dispatch.c. Since this guarantees no communication errors, it is useful for debugging the packeting and packet-processing functions. It also sets a baseline from which the speed of connections can be measured.
- IO_SERIAL, derived from a simple asynchronous library, provides serial port I/O with a throughput of up to 115,200 bps when using a null modem and a 16550a UART. Since interrupt-driven serial processing has been discussed at great length, I will not go into detail here. The code is fairly well documented and complete. I had no luck getting the transmitting code to work reliably in an interrupt-driven state, so sending packets is done in a loop, pumping out data as fast as the UART will accept it. Since the packet size is around 1060 bytes, this loop will require a maximum of less than 1 second at 14.4 Kbits/sec. (or about 8 seconds at 1200 bps). Most modems have built-in buffers for compression and error correction, so I would not expect any noticeable delay to occur in the server, even on slow machines.
- IO_PARALLEL, which provides a parallel-port interface with a maximum throughput of around 512 Kbits/sec. The parallel driver is interrupt driven, but because of the high overhead of interrupting for each character, the characters are sent in packet buffers of less than 2KB, preceded by a length and check code.
Configuration
The configuration files are simple ASCII text files that list various variables and values to associate with them.
Stubs
The stub files clstub and svrstub eliminate the client and server code, respectively. This is useful for machines that do not want or need to share resources and for machines running non-MS-DOS operating systems (where the server does not run in the background, but rather in a loop).
Running the Driver
Table 4 lists the functions available via the user interrupt. A sample application (pmap, available electronically in both source and executable format) exercises all of the user functions via a simple command-line interface.
Endless Possibilities
The exploration into basic client/server packaging will come in handy in future products. While the following tips are common knowledge to most professional programmers, it is good to occasionally be reminded of them:
- Develop the client and server simultaneously. This is immensely important in testing because the communication layer is eliminated as a source of errors.
- Begin with a well-defined, robust, and expandable messaging system. Attention to the details of each function is vitally important here, because any change must be propagated through both the client and server.
- Keep the transmit/receive mechanism completely independent of all other code.
Table 1: IFS functions.
Directory Maintenance
0x01 Remove Directory 0x03 Make Directory 0x05 Change Directory
Directory Searching
0x1b Find first 0x1c Find next
File Maintenance
0x0e set file attributes 0x0f get file attributes 0x11 rename 0x13 delete 0x16 open existing 0x17 create/truncate 0x2e extended open
File I/O
0x06 close 0x07 commit 0x08 read 0x09 write 0x0a lock 0x0b unlock 0x21 seek from end
Miscellaneous
0x0c get disk space 0x20 flush all disk buffers 0x23 process termination
Table 2: IFS messages.
IFS Messages Request Response IFS_RMDIR ASCIIz fully qualified directory Error returned by DOS to remove IFS_MKDIR ASCIIz fully qualified directory Error returned by DOS to create IFS_CHDIR: ASCIIz fully qualified directory Error returned by DOS to change to IFS_FINDFIRST WORD -- attribute Error returned by DOS ASCIIz -- file mask struct ffblk IFS_FINDNEXT struct ffblk (from findfirst) Error returned by DOS struct ffblk IFS_SETATTR WORD -- new file attribute Error returned by DOS ASCIIz-- fully qualified file name IFS_GETATTR ASCIIz-- fully qualified file name Error returned by DOS WORD-- file attributes DWORD-- file length IFS_RENAMEFILE ASCIIz-- old filename Error returned by DOS ASCIIz-- new filename IFS_DELETEFILE ASCIIz-- filename to delete Error returned by DOS IFS_OPENFILE WORD-- open mode Error returned by DOS ASCIIz-- file name WORD-- handle WORD-- attribute WORD-- file time WORD-- file date DWORD-- file length IFS_CREATEFILE WORD-- create mode Error returned by DOS ASCIIz-- file name WORD-- handle WORD-- attribute WORD-- file time WORD-- file date DWORD-- file length IFS_EXTOPEN WORD-- action Error returned by DOS WORD-- open mode WORD-- handle WORD-- create attributes WORD-- status (0=opened, 1=created, 2=replaced) ASCIIz-- filename WORD-- file time WORD-- file date DWORD-- file length IFS_CLOSEFILE: WORD-- handle of file to close Error returned by DOS struct ftime-- time from client SFT IFS_COMMITFILE WORD-- handle of file to commit 0 IFS_READFILE WORD-- handle of file from which to read Error returned by DOS DWORD-- offset into file from WORD-- number of which to begin bytes read WORD-- number of bytes to read BYTE[]-- bytes read IFS_WRITEFILE WORD-- handle of file to which to write Error returned by DOS DWORD-- offset into file from WORD-- number of bytes which to begin written WORD-- number of bytes to write BYTES[]-- data IFS_LOCKFILE WORD-- handle of file to lock or unlock Error returned by DOS WORD-- function (0=lock, 1=unlock) DWORD-- offset into file from which to begin DWORD-- number of bytes to lock IFS_GETSPACE BYTE-- logical drive number Error returned by DOS (0 = A:, 1 = B:, ...) struct dfree IFS_CLOSEALL none None IFS_PORTOUT WORD-- port # 0...2 0x9000 WORD-- number of bytes BYTE[]-- data IFS_LOGIN ASCIIz passWORD 0x00 validated 0x05 invalid password IFS_SHAREREQUEST ASCIIz-- share name 0x0000 (OK); 0x0005 (access denied) ASCIIz-- passWORD WORD-- access bits IFS_LIST WORD-- first share name to 0x0000 (OK); 0x0012 retrieve (no more shares) WORD-- number of share names returned WORD-- number of bytes in buffer BYTES[]-- share data WORD (access) ASCIIz name ASCIIz path ASCIIz comments
Table 3: Receiving a packet.
State Description 0 Nothing found yet 1,2 Looking for ID (AY or LY) 3,4 Looking for length 5,6 Looking for notLength > 6 Waiting for remainder of the packet
Table 4: User functions.
USER_LOAD_CHECK
entry: none exit: URESULT_OK; URESULT_NOT_LOADED
USER_GET_STATS
entry: p1 = port # p4 = ^status buffer exit: URESULT_OK; URESULT_PORT_NOT_OPEN; URESULT_INVALID_PORT
USER_RESET
entry: p1 = port # exit: URESULT_OK; URESULT_PORT_NOT_OPEN; URESULT_INVALID_PORT
USER_UNLOAD
entry: p4 = ^segment buffer (2 bytes) exit: URESULT_OK; URESULT_NOT_LOADED
USER_CONNECT
entry: p1 = port # p2 = 0 (disconnect), 1 (connect), 2 (connect/force) p4 = ASCIIz password + ASCIIz timeout + ASCIIz retry count exit: URESULT_OK; URESULT_PORT_NOT_OPEN; URESULT_INVALID_PORT; URESULT_BAD_PASSWORD; URESULT_IN_USE
USER_DRIVE_MAP
entry: p1 = local drive ('a'..'z') p2 = 0 (unmap), 1 (map), 2 (map/force) p4 = ASCIIz share name + ASCIIz password exit: URESULT_OK; URESULT_DRIVE_INVALID; URESULT_SHARE_NOT_FOUND; URESULT_DRIVE_IN_USE; URESULT_NOT_CONNECTED; URESULT_BAD_PASSWORD; URESULT_MISMATCH
USER_DRIVE_MAP_GET
entry: p1 = local drive ('a'..'z') p4 = ^128 byte buffer exit: URESULT_OK; URESULT_DRIVE_NOT_MAPPED; URESULT_DRIVE_INVALID
USER_PORT_MAP
entry: p1 = port (0..2 (lpt1..lpt3)) p2 = 0 (unmap), 1 (map), 2 (map/force) p4 = ASCIIz share name + ASCIIz password exit: URESULT_OK; URESULT_SHARE_NOT_FOUND; URESULT_PORT_IN_USE; URESULT_NOT_CONNECTED; URESULT_BAD_PASSWORD; URESULT_MISMATCH; URESULT_INVALID
USER_PORT_MAP_GET
entry: p1 = port (0..2) p4 = ^128 byte buffer exit: URESULT_OK; URESULT_PORT_NOT_MAPPED; URESULT_PORT_INVALID;
USER_LIST
entry: p1 = first share name to get p2 = buffer size p4 = ^buffer (buffer holds server name) exit: URESULT_OK; URESULT_SHARE_INVALID buffer format: WORD number of shares returned ...WORD access, ASCIIz name, ASCIIz path, ASCIIz comments,...
USER_SHARE
entry: p1 = access flags (high bit set if port + port # lower 15 bits) p2 = 0 (delete), 1 (add) p4 = ASCIIz name + ASCIIz path + ASCIIz password + ASCIIz remarks exit: URESULT_OK; URESULT_NAME_IN_USE; URESULT_PATH_NOT_FOUND; URESULT_SHARE_BUFFER_FULL
USER_SHARE_GET
entry: p1 = share # to get p2 = # of bytes in buffer p4 = ^ buffer exit: URESULT_OK; URESULT_INVALID_SHARE; URESULT_BUFFER_OVERFLOW buffer holds: WORD (access) + ASCIIz name + ASCIIz path + ASCIIz remarks
USER_SET_DIRECT
entry: p1 = port # p2 = 1 (lock port) 0 (unlock port) exit: URESULT_OK; URESULT_PORT_NOT_OPEN; URESULT_PORT_INVALID
USER_READ_DIRECT
entry: p1 = port # p2 = buffer length p4 = ^buffer exit: URSULT_OK; URESULT_PORT_NOT_OPEN; URESULT_PORT_INVALID buffer = WORD (# of bytes returned) + data
USER_WRITE_DIRECT
entry: p1 = port # p2 = buffer length p4 = ^buffer exit: URSULT_OK; URESULT_PORT_NOT_OPEN; URESULT_PORT_INVALID
Listing One
#include <stdlib.h> #include <dos.h> #include "dispatch.h" #include "asm.h" #include "server.h" #include "client.h" #include "misc.h" /* states: get byte <-------------+---+ byte == 'L' or 'K'? <---+-+ | | | | | +---> no ---------->--+ | | | | | packet ID[0] = bytes | | get byte | | byte == 'Y' | | | | | +---> no ---------->----+ | | | packet ID[1] = byte | get word | word <= PACKETSIZE | | | +---> no ---------->------+ | | length = word | get word | word = ~length | | | +---> no ---------->------+ | get remainder of packet dispatch packet | if more than 1/2 sec passes since the last byte, the packet pointer is reset */ void Receive(PORT *port) { register int key; IO_BASE *io = port->io; RCVINFO *rcv = port->rcvInfo; if (!port) return; key = io->readByte(io, 0); while (key >= 0) { rcv->stats.bytes.rcvd++; if (!rcv->packet) { rcv->packet = getPacket(); if (!rcv->packet) return; rcv->ptr = (void *) rcv->packet; rcv->ct = 0; } else { if (Elapsed(rcv->lastTime) > 180) { rcv->stats.errors.timeout++; rcv->ct = 0; } } rcv->lastTime = CLOCK; rcv->ptr[rcv->ct++] = key; switch (rcv->ct) { case 2: if (key == 'Y') break; else { rcv->stats.errors.packetID++; rcv->ct = 1; } case 1: if ((key != 'K') && (key != 'L')) { rcv->stats.errors.packetID++; rcv->ct = 0; } else break; case 4: /* length has been found */ if (rcv->packet->length > PACKETSIZE) { rcv->stats.errors.length++; rcv->ct = 0; } break; case 6: /* ~length has been found */ if (rcv->packet->length != ~rcv->packet->notLength) { rcv->stats.errors.length++; rcv->ct = 0; } break; default: if (rcv->ct == rcv->packet->length) { DWORD oldCRC = rcv->packet->crc32; rcv->packet->crc32 = 0; if (crc32(0, rcv->packet, rcv->packet->length) != oldCRC) { rcv->stats.errors.crc++; rcv->ct = 0; } else { rcv->fwdPacket = rcv->packet; rcv->packet = 0; if (rcv->fwdPacket->ID[0] == 'K') { rcv->stats.server.packetsRcvd++; serverSubmit(io->port->server, rcv->fwdPacket); } else { rcv->stats.client.packetsRcvd++; clientSubmit(io->port->client, rcv->fwdPacket); } } } } key = io->readByte(io, 0); } } /* send (packet) to (port) */ void Transmit(PORT *port, PACKET *packet) { IO_BASE *io = port->io; packet->length += sizeof(*packet); packet->notLength = ~packet->length; packet->crc32 = 0; packet->crc32 = crc32(0, packet, packet->length); io->writeString(io, packet, packet->length); } RCVINFO *createRcvInfo(void) { return calloc(1, sizeof(RCVINFO)); } void freeRcvInfo(RCVINFO *rcvInfo) { free(rcvInfo); }