Files
comet/cometses.pas
Ken Johnson bade0eb593 Comet 1.00 - Initial commit: complete standalone FidoNet TCP mailer
Standalone FidoNet mailer daemon implementing the Comet protocol
(TCP variant of the Nova protocol family) with BinkP/1.1 fallback.
Written in Free Pascal for DOS/Win/OS2/Linux/FreeBSD.

15 source files, ~10K lines:
- Protocol: length-prefixed frames, SHA-256/384/512, CRC-32,
  bidirectional transfer with sliding window, adaptive block sizing
- Session: own TCP handshake with BinkP auto-detection on port 26638
- Outbound: BSO (Binkley), FrontDoor, D'Bridge format support
- Daemon: multi-session with thread pool, outbound scanner
- Paths: DOS<->Linux bridge with case-insensitive lookup, drive mapping
- Config: INI-style with heavily documented sample (COMET.SAM)

All 17 Nova interop bug fixes baked in from the start.
18/18 tests passing (CRC-32, SHA-256/384/512, frame encode/decode).
2026-03-29 20:02:37 -07:00

711 lines
19 KiB
ObjectPascal

{
Comet - Direct TCP File Transfer for FidoNet
cometses.pas - Session handshake and protocol negotiation
Comet session flow:
1. TCP connect on port 26638
2. Originator sends banner: "COMET/1.0\r\n"
3. Answerer reads banner:
- If starts with "COMET" -> respond "COMET/1.0\r\n", proceed to step 4
- If looks like BinkP frame -> switch to BinkP handler
- Otherwise -> reject, close
4. Both sides exchange INIT packets (extended with FidoNet addressing)
5. Both sides compute negotiated parameters (min of each)
6. Both sides send INITACK with agreed parameters
7. Session established -> proceed to file transfer (cometxfer)
Comet INIT payload (extends Nova INIT with addressing):
Offset Size Field
0 4 Protocol revision (currently 1)
4 4 Capability flags (COPT_*)
8 4 Maximum block size
12 4 Maximum window size
16 1 Number of addresses (1-255)
17 8*N Addresses: Zone(2)+Net(2)+Node(2)+Point(2) each, LE
17+8N var NUL-terminated password
... var NUL-terminated system name
... var NUL-terminated sysop name
... var NUL-terminated mailer string
Comet INITACK payload (same as Nova):
Offset Size Field
0 4 Shared capability flags
4 4 Agreed max block size
8 4 Agreed max window size
Copyright (C) 2026 Ken Johnson
License: GPL-2.0
}
unit cometses;
{$mode objfpc}{$H+}
interface
uses
SysUtils, cometdef, cometcfg, comettcp, cometfrm, cometlog;
const
COMET_BANNER = 'COMET/1.0' + #13#10;
COMET_BANNER_LEN = 11; { Length of banner string }
COMET_BANNER_PREFIX = 'COMET';
{ BinkP detection: first byte of a BinkP session is a frame type }
BINKP_FRAME_MARKER = $80; { BinkP command frame has high bit }
type
{ Session handshake result }
TCometHandshakeResult = (
chrOK, { Handshake succeeded }
chrBinkP, { Remote speaks BinkP, not Comet }
chrTimeout, { Handshake timed out }
chrBadPassword, { Password mismatch }
chrProtocolError, { Invalid data received }
chrDisconnect { Connection lost during handshake }
);
{ Complete session state }
TCometSessionState = record
{ Connection }
Sock: TCometSocket; { Socket handle }
IsOriginator: Boolean; { True = we initiated the connection }
RemoteIP: string; { Remote IP address }
RemotePort: Word; { Remote port number }
{ Negotiated parameters }
Session: TCometSession; { Negotiated session from cometdef }
{ Our info (from config) }
OurInit: TCometInitInfo; { What we sent in INIT }
{ Remote info (from their INIT) }
RemoteInit: TCometInitInfo; { What they sent in INIT }
{ Frame layer }
FrameRx: TCometFrameRx; { Frame receiver context }
{ Session state }
Active: Boolean; { Session is active }
end;
{ ---- Banner Exchange ---- }
{ Send the COMET banner. Returns True on success. }
function CometSendBanner(Sock: TCometSocket): Boolean;
{ Receive and check the remote banner. Non-blocking reads with timeout.
Returns chrOK if COMET banner received, chrBinkP if BinkP detected,
chrTimeout or chrDisconnect on failure. PeekBuf receives any bytes
read that weren't part of the banner (for BinkP handoff). }
function CometRecvBanner(Sock: TCometSocket; TimeoutSecs: Integer;
out PeekBuf: string): TCometHandshakeResult;
{ ---- INIT/INITACK Exchange ---- }
{ Build an INIT payload from config and init info. Returns payload bytes. }
function CometBuildInit(const Info: TCometInitInfo): TBytes;
{ Parse a received INIT payload into a TCometInitInfo record. }
function CometParseInit(Payload: PByte; PayLen: LongWord;
out Info: TCometInitInfo): Boolean;
{ Build an INITACK payload from negotiated session. }
function CometBuildInitAck(const Sess: TCometSession): TBytes;
{ Parse a received INITACK payload. }
function CometParseInitAck(Payload: PByte; PayLen: LongWord;
out SharedCaps: LongWord; out MaxBlock: LongWord;
out WindowSize: Word): Boolean;
{ Negotiate session parameters from both sides' INIT info. }
procedure CometNegotiate(const OurInit, RemoteInit: TCometInitInfo;
const Password: string; out Sess: TCometSession);
{ ---- Full Handshake ---- }
{ Perform complete Comet session handshake.
For originator: send banner, wait for banner, exchange INIT/INITACK.
For answerer: banner already validated by caller, exchange INIT/INITACK.
Fills in State with negotiated session parameters.
Returns handshake result code. }
function CometHandshake(var State: TCometSessionState;
const Cfg: TCometConfig): TCometHandshakeResult;
{ Initialize session state. Call before CometHandshake. }
procedure CometSessionInit(var State: TCometSessionState;
Sock: TCometSocket; IsOriginator: Boolean;
const RemoteIP: string; RemotePort: Word);
{ Clean up session state. }
procedure CometSessionDone(var State: TCometSessionState);
{ Fill OurInit from config. }
procedure CometBuildOurInit(var State: TCometSessionState;
const Cfg: TCometConfig);
implementation
{ ---- Little-endian helpers ---- }
procedure PutLE32(Buf: PByte; Val: LongWord);
begin
Buf[0] := Byte(Val and $FF);
Buf[1] := Byte((Val shr 8) and $FF);
Buf[2] := Byte((Val shr 16) and $FF);
Buf[3] := Byte((Val shr 24) and $FF);
end;
function GetLE32(Buf: PByte): LongWord;
begin
Result := LongWord(Buf[0]) or
(LongWord(Buf[1]) shl 8) or
(LongWord(Buf[2]) shl 16) or
(LongWord(Buf[3]) shl 24);
end;
procedure PutLE16(Buf: PByte; Val: Word);
begin
Buf[0] := Byte(Val and $FF);
Buf[1] := Byte((Val shr 8) and $FF);
end;
function GetLE16(Buf: PByte): Word;
begin
Result := Word(Buf[0]) or (Word(Buf[1]) shl 8);
end;
{ Write a NUL-terminated string to buffer. Returns bytes written. }
function PutNulStr(Buf: PByte; const S: string): LongWord;
var
L: LongWord;
begin
L := Length(S);
if L > 0 then
Move(S[1], Buf^, L);
Buf[L] := 0;
Result := L + 1;
end;
{ Read a NUL-terminated string from buffer. Advances P past the NUL.
Returns '' if NUL not found before Limit. }
function GetNulStr(var P: PByte; Limit: PByte): string;
var
Start: PByte;
begin
Start := P;
while (P < Limit) and (P^ <> 0) do
Inc(P);
SetLength(Result, P - Start);
if Length(Result) > 0 then
Move(Start^, Result[1], Length(Result));
if P < Limit then
Inc(P); { Skip NUL }
end;
{ ---- Banner Exchange ---- }
function CometSendBanner(Sock: TCometSocket): Boolean;
var
Banner: string;
begin
Banner := COMET_BANNER;
Result := CometTcpSendAll(Sock, @Banner[1], Length(Banner));
if Result then
LogTrace('TX banner: COMET/1.0');
end;
function CometRecvBanner(Sock: TCometSocket; TimeoutSecs: Integer;
out PeekBuf: string): TCometHandshakeResult;
var
Buf: array[0..63] of Byte;
BufPos: Integer;
Deadline: LongInt;
Got: LongInt;
begin
Result := chrTimeout;
PeekBuf := '';
BufPos := 0;
Deadline := DateTimeToTimeStamp(Now).Time div 1000 + (TimeoutSecs * 1000);
while BufPos < 64 do
begin
if not CometTcpWaitData(Sock, 500) then
begin
if DateTimeToTimeStamp(Now).Time div 1000 > Deadline then Exit;
if not CometTcpConnected(Sock) then
begin
Result := chrDisconnect;
Exit;
end;
Continue;
end;
Got := CometTcpRecv(Sock, @Buf[BufPos], 1);
if Got < 0 then
begin
Result := chrDisconnect;
Exit;
end;
if Got = 0 then Continue;
{ Check for BinkP on very first byte }
if (BufPos = 0) and ((Buf[0] and $80) <> 0) then
begin
{ BinkP command frame detected (high bit set) }
SetLength(PeekBuf, 1);
PeekBuf[1] := Char(Buf[0]);
Result := chrBinkP;
LogTrace('RX first byte $%02X - BinkP detected', [Buf[0]]);
Exit;
end;
Inc(BufPos);
{ Check for complete banner (ends with \n) }
if Buf[BufPos - 1] = $0A then
begin
{ Verify it starts with "COMET" }
if BufPos >= 5 then
begin
if (Buf[0] = Ord('C')) and (Buf[1] = Ord('O')) and
(Buf[2] = Ord('M')) and (Buf[3] = Ord('E')) and
(Buf[4] = Ord('T')) then
begin
LogTrace('RX banner: COMET (valid)');
Result := chrOK;
Exit;
end;
end;
{ Got a line but it's not COMET }
LogTrace('RX banner: not COMET');
Result := chrProtocolError;
Exit;
end;
end;
{ Buffer full without finding \n }
Result := chrProtocolError;
end;
{ ---- INIT/INITACK Building ---- }
function CometBuildInit(const Info: TCometInitInfo): TBytes;
var
Buf: array[0..4095] of Byte;
Pos: LongWord;
I: Integer;
begin
Result := nil;
Pos := 0;
{ Protocol revision (4 bytes LE) }
PutLE32(@Buf[Pos], Info.Version); Inc(Pos, 4);
{ Capability flags (4 bytes LE) }
PutLE32(@Buf[Pos], Info.Caps); Inc(Pos, 4);
{ Max block size (4 bytes LE) }
PutLE32(@Buf[Pos], Info.MaxBlock); Inc(Pos, 4);
{ Window size (4 bytes LE) }
PutLE32(@Buf[Pos], LongWord(Info.WindowSize)); Inc(Pos, 4);
{ Number of addresses (1 byte) }
Buf[Pos] := Byte(Length(Info.Addresses));
Inc(Pos);
{ Addresses: Zone(2) + Net(2) + Node(2) + Point(2) each }
for I := 0 to High(Info.Addresses) do
begin
PutLE16(@Buf[Pos], Info.Addresses[I].Zone); Inc(Pos, 2);
PutLE16(@Buf[Pos], Info.Addresses[I].Net); Inc(Pos, 2);
PutLE16(@Buf[Pos], Info.Addresses[I].Node); Inc(Pos, 2);
PutLE16(@Buf[Pos], Info.Addresses[I].Point); Inc(Pos, 2);
end;
{ NUL-terminated strings }
Inc(Pos, PutNulStr(@Buf[Pos], Info.Password));
Inc(Pos, PutNulStr(@Buf[Pos], Info.SysName));
Inc(Pos, PutNulStr(@Buf[Pos], Info.SysOp));
Inc(Pos, PutNulStr(@Buf[Pos], Info.Mailer));
SetLength(Result, Pos);
Move(Buf[0], Result[0], Pos);
end;
function CometParseInit(Payload: PByte; PayLen: LongWord;
out Info: TCometInitInfo): Boolean;
var
P, Limit: PByte;
NumAddrs, I: Integer;
begin
Result := False;
FillChar(Info, SizeOf(Info), 0);
SetLength(Info.Addresses, 0);
{ Minimum: 4+4+4+4+1 = 17 bytes (no addresses, empty strings) }
if PayLen < 17 then Exit;
P := Payload;
Limit := Payload + PayLen;
Info.Version := GetLE32(P); Inc(P, 4);
Info.Caps := GetLE32(P); Inc(P, 4);
Info.MaxBlock := GetLE32(P); Inc(P, 4);
Info.WindowSize := Word(GetLE32(P)); Inc(P, 4);
{ Addresses }
NumAddrs := P^; Inc(P);
if P + (NumAddrs * 8) > Limit then Exit;
SetLength(Info.Addresses, NumAddrs);
for I := 0 to NumAddrs - 1 do
begin
Info.Addresses[I].Zone := GetLE16(P); Inc(P, 2);
Info.Addresses[I].Net := GetLE16(P); Inc(P, 2);
Info.Addresses[I].Node := GetLE16(P); Inc(P, 2);
Info.Addresses[I].Point := GetLE16(P); Inc(P, 2);
Info.Addresses[I].Domain := '';
end;
{ NUL-terminated strings }
Info.Password := GetNulStr(P, Limit);
Info.SysName := GetNulStr(P, Limit);
Info.SysOp := GetNulStr(P, Limit);
Info.Mailer := GetNulStr(P, Limit);
Result := True;
end;
function CometBuildInitAck(const Sess: TCometSession): TBytes;
begin
Result := nil;
SetLength(Result, 12);
PutLE32(@Result[0], Sess.SharedCaps);
PutLE32(@Result[4], Sess.MaxBlock);
PutLE32(@Result[8], LongWord(Sess.WindowSize));
end;
function CometParseInitAck(Payload: PByte; PayLen: LongWord;
out SharedCaps: LongWord; out MaxBlock: LongWord;
out WindowSize: Word): Boolean;
begin
Result := False;
if PayLen < 12 then Exit;
SharedCaps := GetLE32(Payload);
MaxBlock := GetLE32(Payload + 4);
WindowSize := Word(GetLE32(Payload + 8));
Result := True;
end;
{ ---- Negotiation ---- }
procedure CometNegotiate(const OurInit, RemoteInit: TCometInitInfo;
const Password: string; out Sess: TCometSession);
var
I: Integer;
begin
FillChar(Sess, SizeOf(Sess), 0);
{ Capabilities: intersection of both sides }
Sess.SharedCaps := OurInit.Caps and RemoteInit.Caps;
{ Window: minimum of both }
if OurInit.WindowSize < RemoteInit.WindowSize then
Sess.WindowSize := OurInit.WindowSize
else
Sess.WindowSize := RemoteInit.WindowSize;
if Sess.WindowSize < 1 then Sess.WindowSize := 1;
if Sess.WindowSize > COMET_MAXWINDOW then Sess.WindowSize := COMET_MAXWINDOW;
{ Block size: minimum of both }
if OurInit.MaxBlock < RemoteInit.MaxBlock then
Sess.MaxBlock := OurInit.MaxBlock
else
Sess.MaxBlock := RemoteInit.MaxBlock;
if Sess.MaxBlock < COMET_MINBLKLEN then Sess.MaxBlock := COMET_MINBLKLEN;
if Sess.MaxBlock > COMET_MAXBLKLEN then Sess.MaxBlock := COMET_MAXBLKLEN;
{ Remote info }
SetLength(Sess.RemoteAddrs, Length(RemoteInit.Addresses));
for I := 0 to High(RemoteInit.Addresses) do
Sess.RemoteAddrs[I] := RemoteInit.Addresses[I];
Sess.RemoteSysName := RemoteInit.SysName;
Sess.RemoteSysOp := RemoteInit.SysOp;
Sess.RemoteMailer := RemoteInit.Mailer;
{ Password check (case-insensitive) }
if Password = '' then
Sess.PasswordOk := True { No password required }
else
Sess.PasswordOk := CompareText(Password, RemoteInit.Password) = 0;
end;
{ ---- Session Management ---- }
procedure CometSessionInit(var State: TCometSessionState;
Sock: TCometSocket; IsOriginator: Boolean;
const RemoteIP: string; RemotePort: Word);
begin
FillChar(State, SizeOf(State), 0);
State.Sock := Sock;
State.IsOriginator := IsOriginator;
State.RemoteIP := RemoteIP;
State.RemotePort := RemotePort;
State.Active := False;
CometFrameRxInit(State.FrameRx);
end;
procedure CometSessionDone(var State: TCometSessionState);
begin
CometFrameRxDone(State.FrameRx);
State.Active := False;
end;
procedure CometBuildOurInit(var State: TCometSessionState;
const Cfg: TCometConfig);
var
I: Integer;
begin
State.OurInit.Version := COMET_REVISION;
State.OurInit.Caps := Cfg.Capabilities;
State.OurInit.MaxBlock := Cfg.MaxBlockSize;
State.OurInit.WindowSize := Cfg.WindowSize;
State.OurInit.SysName := Cfg.SysName;
State.OurInit.SysOp := Cfg.SysOp;
State.OurInit.Mailer := COMET_NAME + '/' + COMET_VERSION;
SetLength(State.OurInit.Addresses, Length(Cfg.Addresses));
for I := 0 to High(Cfg.Addresses) do
State.OurInit.Addresses[I] := Cfg.Addresses[I];
{ Password: look up by remote address if we know it already }
State.OurInit.Password := '';
end;
{ ---- Full Handshake ---- }
function CometHandshake(var State: TCometSessionState;
const Cfg: TCometConfig): TCometHandshakeResult;
var
PeekBuf: string;
InitPayload: TBytes;
Frame: TCometFrame;
Ret: Integer;
Deadline: TDateTime;
Password: string;
AckPayload: TBytes;
SharedCaps: LongWord;
MaxBlock: LongWord;
WindowSize: Word;
begin
Result := chrProtocolError;
{ Build our INIT info }
CometBuildOurInit(State, Cfg);
if State.IsOriginator then
begin
{ --- Originator flow --- }
{ Step 1: Send banner }
if not CometSendBanner(State.Sock) then
begin
Result := chrDisconnect;
Exit;
end;
{ Step 2: Receive banner }
Result := CometRecvBanner(State.Sock, Cfg.TimeoutHandshake, PeekBuf);
if Result <> chrOK then Exit;
end;
{ Answerer: banner already validated by daemon accept loop }
{ Step 3: Send our INIT }
{ If remote addresses known, look up password }
if Length(State.Session.RemoteAddrs) > 0 then
State.OurInit.Password := CometCfgGetPassword(Cfg, State.Session.RemoteAddrs[0])
else
State.OurInit.Password := '';
InitPayload := CometBuildInit(State.OurInit);
if not CometFrameSend(State.Sock, NPKT_INIT, 0,
@InitPayload[0], Length(InitPayload)) then
begin
Result := chrDisconnect;
Exit;
end;
LogInfo('Sent INIT: %s (%s) [%s]',
[State.OurInit.SysName, State.OurInit.SysOp, State.OurInit.Mailer]);
{ Step 4: Wait for remote INIT }
Deadline := Now + (Cfg.TimeoutHandshake / 86400.0);
while Now < Deadline do
begin
if not CometTcpWaitData(State.Sock, 500) then
begin
if not CometTcpConnected(State.Sock) then
begin
Result := chrDisconnect;
Exit;
end;
Continue;
end;
Ret := CometFrameRecv(State.Sock, State.FrameRx, Frame);
case Ret of
N_NOPKT: Continue;
N_CARRIER:
begin
Result := chrDisconnect;
Exit;
end;
N_BADPKT:
begin
LogWarning('Bad frame during handshake');
Continue;
end;
end;
if Ret = NPKT_INIT then
begin
{ Parse remote INIT }
if not CometParseInit(Frame.Payload, Frame.PayLen, State.RemoteInit) then
begin
LogError('Cannot parse remote INIT');
Result := chrProtocolError;
Exit;
end;
LogInfo('Rcvd INIT: %s (%s) [%s]',
[State.RemoteInit.SysName, State.RemoteInit.SysOp,
State.RemoteInit.Mailer]);
if Length(State.RemoteInit.Addresses) > 0 then
begin
LogInfo('Remote address: %s',
[CometAddrToStr(State.RemoteInit.Addresses[0])]);
{ Now that we know who they are, set the password for negotiation }
Password := CometCfgGetPassword(Cfg, State.RemoteInit.Addresses[0]);
end
else
Password := '';
{ Negotiate parameters }
CometNegotiate(State.OurInit, State.RemoteInit, Password,
State.Session);
State.Session.WeAreOriginator := State.IsOriginator;
{ Check password }
if not State.Session.PasswordOk then
begin
LogError('Password mismatch for %s',
[CometAddrToStr(State.RemoteInit.Addresses[0])]);
{ Send HALT to inform remote }
CometFrameSend(State.Sock, NPKT_HALT, 0, nil, 0);
Result := chrBadPassword;
Exit;
end;
{ Step 5: Send INITACK }
AckPayload := CometBuildInitAck(State.Session);
if not CometFrameSend(State.Sock, NPKT_INITACK, 0,
@AckPayload[0], Length(AckPayload)) then
begin
Result := chrDisconnect;
Exit;
end;
LogInfo('Session negotiated: caps=$%08X blk=%d win=%d',
[State.Session.SharedCaps, State.Session.MaxBlock,
State.Session.WindowSize]);
{ Step 6: Wait for remote INITACK }
Break; { Fall through to INITACK wait loop }
end;
end;
{ Wait for INITACK }
while Now < Deadline do
begin
if not CometTcpWaitData(State.Sock, 500) then
begin
if not CometTcpConnected(State.Sock) then
begin
Result := chrDisconnect;
Exit;
end;
Continue;
end;
Ret := CometFrameRecv(State.Sock, State.FrameRx, Frame);
case Ret of
N_NOPKT: Continue;
N_CARRIER:
begin
Result := chrDisconnect;
Exit;
end;
N_BADPKT: Continue;
Ord(NPKT_HALT):
begin
LogError('Remote sent HALT during handshake');
Result := chrProtocolError;
Exit;
end;
end;
if Ret = NPKT_INITACK then
begin
if CometParseInitAck(Frame.Payload, Frame.PayLen,
SharedCaps, MaxBlock, WindowSize) then
begin
LogInfo('Rcvd INITACK: caps=$%08X blk=%d win=%d',
[SharedCaps, MaxBlock, WindowSize]);
{ Verify agreement (should match our negotiation) }
State.Active := True;
Result := chrOK;
Exit;
end
else
begin
LogError('Cannot parse INITACK');
Result := chrProtocolError;
Exit;
end;
end;
end;
{ If we get here, we timed out }
if Result <> chrOK then
Result := chrTimeout;
end;
end.