7 Commits
v1.1 ... v1.1-1

Author SHA1 Message Date
2cf1d1f8f7 Add libwatt.a for DOS builds, fix CI workflow
All checks were successful
Build and Release / build-and-release (push) Successful in 17s
- Include Watt-32 library so DOS cross-compilation works in CI
- Use gitea.com checkout action (matches Fastway workflows)
- Use RELEASE_TOKEN secret and form upload for assets
- Handle re-runs by deleting existing release before creating
2026-04-07 10:14:51 -07:00
ba30ad5a4e Fix CI runner label to match registered runner
Some checks failed
Build and Release / build-and-release (push) Failing after 12s
The runner is registered as 'fpc', not 'fpc-agent1.server.lan'.
2026-04-07 09:24:10 -07:00
46912a9798 Version 1.1-1, standardize naming and move tests
Some checks failed
Build and Release / build-and-release (push) Has been cancelled
- Adopt standard versioning: 1.0, 1.1, 1.1-1 (major.minor-revision)
- Archive naming: comet-1.1-1-linux-x64.tar.gz (professional format)
- Move test sources from src/ to test/ directory
- Update Makefile, release script, and CI workflow to match
2026-04-07 09:00:39 -07:00
945e7b17fa Fire session events for host application integration
Some checks failed
Build and Release / build-and-release (push) Has been cancelled
Add cetSessionStart, cetSessionAuth, and cetSessionEnd event firing
to both Comet and BinkP protocols so BBS software importing Comet as
a library receives the full session lifecycle via the event callback.
BBS hosts can wire this directly to websockets for real-time client
updates.

Also fix BinkP using FileExists() instead of CometFileExists() for
.FLO entry checks, ensuring case-insensitive path matching on Unix
consistent with the Comet protocol side.
2026-04-07 08:08:07 -07:00
cb430cfb50 Add Gitea Actions workflow for automated release builds
Builds all 5 platforms (Linux, FreeBSD, Windows, DOS, OS/2)
on fpc-agent1.server.lan, runs tests, packages archives, and
creates a Gitea release with assets on tag push.
2026-04-03 15:01:32 -07:00
1472bea823 Nodelist year-rollover fix, release script, version 1.01.01
CometNodelistLoadDir now parses the first-line date comment
(year + day number) instead of relying solely on the .NNN
extension, so nodelists spanning multiple years sort correctly.

Added release.sh for build-and-push workflow.
Updated .gitignore for build artifacts and test binaries.
2026-04-03 14:58:10 -07:00
9e8545c326 BinkP protocol fixes, auto-detection, and NR/1.1 compliance
- Fix auth bypass: reject passwordless login when password configured
- Protocol auto-detect via MSG_PEEK (BinkP vs Comet on same port)
- Zero-length file handling (empty .PKT files)
- M_SKIP on interrupted RX files and EOB cleanup
- CRAM-MD5 challenge sent first per FTS-1027
- Session-end message flush for pending M_GOT/M_SKIP
- DNS resolver fallback for systemd-resolved (netdb)
- CometTcpPeek (MSG_PEEK wrapper) for protocol sniffing
- Restore NR in OPT and binkp/1.1 version string
- Rebuild all-platform binaries and TIC files
2026-04-03 11:42:24 -07:00
29 changed files with 725 additions and 82 deletions

View File

@@ -0,0 +1,102 @@
name: Build and Release
on:
push:
tags:
- 'v*'
env:
GITEA_URL: https://kjgr.io
jobs:
build-and-release:
runs-on: fpc
steps:
- name: Checkout
uses: https://gitea.com/actions/checkout@v4
- name: Extract version
id: version
run: |
VERSION="${GITHUB_REF_NAME#v}"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- name: Build all platforms
run: make all-platforms
- name: Run tests
run: make test
- name: Package releases
run: |
VER="${{ steps.version.outputs.version }}"
mkdir -p dist
sed "s/Version [0-9][0-9]*\.[0-9][0-9]*/Version $VER/g; s/Comet [0-9][0-9]*\.[0-9][0-9]*/Comet $VER/g" COMET.DOC > /tmp/COMET.DOC
package() {
local plat="$1" binary="$2" fmt="$3" desc="$4"
local dir="comet-${VER}-${plat}"
mkdir -p "dist/$dir"
cp "$binary" "dist/$dir/"
cp COMET.SAM COMET.QA FSP-COMET.001 INSTALL.txt "dist/$dir/" 2>/dev/null || true
cp /tmp/COMET.DOC "dist/$dir/"
cat > "dist/$dir/FILE_ID.DIZ" <<FILEID
Comet $VER - FidoNet TCP Mailer
$desc
Direct TCP file transfer for FidoNet
SHA-256 verification, sliding window,
ED25519/CRAM-MD5 authentication,
per-block zlib compression, BinkP
compatible on port 24554.
Bidirectional transfers with adaptive
block sizing (512B-64KB). Supports
BSO, FrontDoor, D'Bridge outbound.
(C) 2026 Ken Johnson 1:218/720 GPL2
FILEID
if [ "$fmt" = "tar" ]; then
tar czf "dist/comet-${VER}-${plat}.tar.gz" -C dist "$dir"
else
(cd dist && zip -r "comet-${VER}-${plat}.zip" "$dir")
fi
rm -rf "dist/$dir"
}
package linux-x64 build/linux/comet tar "Linux x86-64 binary."
package freebsd-x64 build/freebsd/comet tar "FreeBSD x86-64 binary."
package win64 build/win/comet.exe zip "Windows x86-64 binary."
package dos build/go32v2/comet.exe zip "DOS (DJGPP/Watt-32) binary."
package os2 build/os2/comet.exe zip "OS/2 binary."
- name: Create release
run: |
VER="${{ steps.version.outputs.version }}"
TAG="${GITHUB_REF_NAME}"
API="${GITEA_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
AUTH="Authorization: token ${{ secrets.RELEASE_TOKEN }}"
# Delete existing release for this tag (handles re-runs)
OLD_ID=$(curl -s -H "$AUTH" "${API}/releases/tags/${TAG}" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null)
if [ -n "$OLD_ID" ] && [ "$OLD_ID" != "None" ]; then
echo "Deleting existing release ${OLD_ID} for tag ${TAG}"
curl -s -X DELETE -H "$AUTH" "${API}/releases/${OLD_ID}"
fi
# Create release
RELEASE_ID=$(curl -s -X POST \
-H "$AUTH" \
-H "Content-Type: application/json" \
-d "{\"tag_name\": \"${TAG}\", \"name\": \"Comet ${VER}\", \"body\": \"Comet ${VER}\"}" \
"${API}/releases" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))")
echo "Created release ID: ${RELEASE_ID}"
# Upload assets
for f in dist/comet-${VER}-*; do
[ -f "$f" ] || continue
echo "Uploading: $f"
curl -s -X POST \
-H "$AUTH" \
-F "attachment=@${f}" \
"${API}/releases/${RELEASE_ID}/assets"
done

18
.gitignore vendored
View File

@@ -6,12 +6,30 @@
link.res
ppas.sh
# Build output
build/
dist/
# Binaries
comet
test_crc
test_sha
test_frame
test_sock
test_md5
test_crypt
test_outbound
test_x25519_*
# Release archives and staging
releases/staging/
releases/*.zip
releases/*.tar.gz
releases/*.tic
# Misc scripts/test files
REGRESSION_*.txt
run_speed_tests.sh
# Backup files
*~

View File

@@ -22,6 +22,7 @@ CROSSBIN = $(FPCUP)/cross/bin
# Directories
SRCDIR = src
TESTDIR = test
BUILDDIR = build
# Source files
@@ -110,17 +111,17 @@ test: $(TESTS)
@./test_md5
@echo "=== All tests passed ==="
test_crc: $(SRCDIR)/test_crc.pas $(SRCDIR)/cometcrc.pas
$(FPC) $(FPCFLAGS) $(FPCOPT) -FE. $(SRCDIR)/test_crc.pas
test_crc: $(TESTDIR)/test_crc.pas $(SRCDIR)/cometcrc.pas
$(FPC) $(FPCFLAGS) $(FPCOPT) -Fu$(SRCDIR) -FE. $(TESTDIR)/test_crc.pas
test_sha: $(SRCDIR)/test_sha.pas $(SRCDIR)/cometsha.pas
$(FPC) $(FPCFLAGS) $(FPCOPT) -FE. $(SRCDIR)/test_sha.pas
test_sha: $(TESTDIR)/test_sha.pas $(SRCDIR)/cometsha.pas
$(FPC) $(FPCFLAGS) $(FPCOPT) -Fu$(SRCDIR) -FE. $(TESTDIR)/test_sha.pas
test_frame: $(SRCDIR)/test_frame.pas $(SRCDIR)/cometfrm.pas $(SRCDIR)/comettcp.pas $(SRCDIR)/cometcrc.pas $(SRCDIR)/cometdef.pas $(SRCDIR)/cometlog.pas
$(FPC) $(FPCFLAGS) $(FPCOPT) -FE. $(SRCDIR)/test_frame.pas
test_frame: $(TESTDIR)/test_frame.pas $(SRCDIR)/cometfrm.pas $(SRCDIR)/comettcp.pas $(SRCDIR)/cometcrc.pas $(SRCDIR)/cometdef.pas $(SRCDIR)/cometlog.pas
$(FPC) $(FPCFLAGS) $(FPCOPT) -Fu$(SRCDIR) -FE. $(TESTDIR)/test_frame.pas
test_md5: $(SRCDIR)/test_md5.pas $(SRCDIR)/cometmd5.pas $(SRCDIR)/cometcram.pas
$(FPC) $(FPCFLAGS) $(FPCOPT) -FE. $(SRCDIR)/test_md5.pas
test_md5: $(TESTDIR)/test_md5.pas $(SRCDIR)/cometmd5.pas $(SRCDIR)/cometcram.pas
$(FPC) $(FPCFLAGS) $(FPCOPT) -Fu$(SRCDIR) -FE. $(TESTDIR)/test_md5.pas
# Debug build (with debugging symbols)
debug: FPCOPT += -g -gl -dDEBUG
@@ -130,12 +131,13 @@ debug: clean linux
clean:
rm -rf $(BUILDDIR)
rm -f $(SRCDIR)/*.o $(SRCDIR)/*.ppu $(SRCDIR)/*.rsj
rm -f $(TESTDIR)/*.o $(TESTDIR)/*.ppu $(TESTDIR)/*.rsj
rm -f *.o *.ppu *.rsj link.res ppas.sh
rm -f $(TESTS)
# Full clean including backups
distclean: clean
rm -f $(SRCDIR)/*~ $(SRCDIR)/*.bak *~ *.bak
rm -f $(SRCDIR)/*~ $(SRCDIR)/*.bak $(TESTDIR)/*~ $(TESTDIR)/*.bak *~ *.bak
# Install (copy binary to /usr/local/bin)
install: linux

BIN
libwatt.a Normal file

Binary file not shown.

95
release.sh Executable file
View File

@@ -0,0 +1,95 @@
#!/bin/bash
# Build all platforms, package, and push a release to GitHub/Gitea
# Usage: ./release.sh v1.01.01 "Release notes here"
set -e
TAG="$1"
NOTES="$2"
if [ -z "$TAG" ]; then
echo "Usage: $0 <tag> [release notes]"
echo "Example: $0 v1.01.01 \"Nodelist year-rollover fix\""
exit 1
fi
VER="${TAG#v}"
echo "=== Building Comet $VER ==="
make clean
make all-platforms
echo "=== Running tests ==="
make test
echo "=== Packaging releases ==="
mkdir -p dist
rm -f dist/comet-${VER}-*
# Update COMET.DOC version
sed "s/Version [0-9][0-9]*\.[0-9][0-9]*/Version $VER/g; s/Comet [0-9][0-9]*\.[0-9][0-9]*/Comet $VER/g" COMET.DOC > /tmp/COMET.DOC
DOCS="COMET.SAM COMET.QA FSP-COMET.001 INSTALL.txt"
build_platform() {
local plat="$1" binary="$2" fmt="$3" desc="$4"
local dir="comet-${VER}-${plat}"
mkdir -p "dist/$dir"
cp "$binary" "dist/$dir/"
cp $DOCS "dist/$dir/" 2>/dev/null || true
cp /tmp/COMET.DOC "dist/$dir/"
# Include systemd service for Linux
if [ "$plat" = "linux-x64" ] && [ -f releases/staging/comet-*-linux-x64/comet.service ]; then
cp releases/staging/comet-*-linux-x64/comet.service "dist/$dir/" 2>/dev/null || true
fi
cat > "dist/$dir/FILE_ID.DIZ" <<EOF
Comet $VER - FidoNet TCP Mailer
$desc
Direct TCP file transfer for FidoNet
SHA-256 verification, sliding window,
ED25519/CRAM-MD5 authentication,
per-block zlib compression, BinkP
compatible on port 24554.
Bidirectional transfers with adaptive
block sizing (512B-64KB). Supports
BSO, FrontDoor, D'Bridge outbound.
(C) 2026 Ken Johnson 1:218/720 GPL2
EOF
if [ "$fmt" = "tar" ]; then
tar czf "dist/comet-${VER}-${plat}.tar.gz" -C dist "$dir"
else
(cd dist && zip -r "comet-${VER}-${plat}.zip" "$dir")
fi
rm -rf "dist/$dir"
}
build_platform linux-x64 build/linux/comet tar "Linux x86-64 binary."
build_platform freebsd-x64 build/freebsd/comet tar "FreeBSD x86-64 binary."
build_platform win64 build/win/comet.exe zip "Windows x86-64 binary."
build_platform dos build/go32v2/comet.exe zip "DOS (DJGPP/Watt-32) binary."
build_platform os2 build/os2/comet.exe zip "OS/2 binary."
echo ""
echo "=== Archives ==="
ls -lh dist/comet-${VER}-*
echo ""
echo "=== Pushing release $TAG ==="
git tag "$TAG" 2>/dev/null || echo "Tag $TAG already exists"
git push origin "$TAG" 2>/dev/null || echo "Tag already pushed (or no remote)"
gh release create "$TAG" \
dist/comet-${VER}-linux-x64.tar.gz \
dist/comet-${VER}-freebsd-x64.tar.gz \
dist/comet-${VER}-win64.zip \
dist/comet-${VER}-dos.zip \
dist/comet-${VER}-os2.zip \
--title "Comet $VER" \
--notes "${NOTES:-Release $VER}"
echo ""
echo "=== Done ==="

View File

@@ -1,10 +1,10 @@
Area COMET
Origin 1:218/720
File comet101-dos.zip
Desc Comet 1.01 FidoNet TCP Mailer - DOS (DJGPP)
Size 619430
Date 02 Apr 2026
CRC 498A3997
Desc Comet 1.01 FidoNet TCP Mailer - DOS (DJGPP/Watt-32)
Size 618195
Date 03 Apr 2026
CRC 3194480E
Replaces comet100*
Created Comet 1.01
Pw

Binary file not shown.

Binary file not shown.

View File

@@ -2,9 +2,9 @@ Area COMET
Origin 1:218/720
File comet101-freebsd-x64.tar.gz
Desc Comet 1.01 FidoNet TCP Mailer - FreeBSD x86-64
Size 464270
Date 02 Apr 2026
CRC 0D09FE57
Size 468498
Date 03 Apr 2026
CRC A94DC666
Replaces comet100*
Created Comet 1.01
Pw

Binary file not shown.

View File

@@ -2,9 +2,9 @@ Area COMET
Origin 1:218/720
File comet101-linux-x64.tar.gz
Desc Comet 1.01 FidoNet TCP Mailer - Linux x86-64
Size 465218
Date 02 Apr 2026
CRC F22B5955
Size 474975
Date 03 Apr 2026
CRC BB35A4B7
Replaces comet100*
Created Comet 1.01
Pw

View File

@@ -2,9 +2,9 @@ Area COMET
Origin 1:218/720
File comet101-os2.zip
Desc Comet 1.01 FidoNet TCP Mailer - OS/2
Size 439321
Date 02 Apr 2026
CRC 17F5A720
Size 426154
Date 03 Apr 2026
CRC 9B3E3B7E
Replaces comet100*
Created Comet 1.01
Pw

Binary file not shown.

View File

@@ -2,9 +2,9 @@ Area COMET
Origin 1:218/720
File comet101-win64.zip
Desc Comet 1.01 FidoNet TCP Mailer - Windows x86-64
Size 267759
Date 02 Apr 2026
CRC 4B5A01C5
Size 266796
Date 03 Apr 2026
CRC 37F25BC3
Replaces comet100*
Created Comet 1.01
Pw

Binary file not shown.

View File

@@ -435,18 +435,45 @@ begin
if UseComet then
begin
{ ---- Comet protocol session ---- }
CometSessionInit(State, Sock, True, Host, Port);
try
State.OurInit.Password := CometCfgGetPassword(Cfg, Addr);
HSResult := CometHandshake(State, Cfg);
if HSResult <> chrOK then
begin
LogError('Handshake failed: %d', [Ord(HSResult)]);
CometTcpClose(Sock);
Halt(1);
end;
{ ---- Sniff protocol before sending anything ---- }
{ Uses MSG_PEEK so the byte stays in the socket buffer.
BinkP answerers send M_NUL immediately (high bit set).
Comet answerers send banner immediately (low byte).
If no data, remote is Comet waiting for our banner. }
HSResult := CometSniffProtocol(Sock, 5);
if HSResult = chrBinkP then
begin
LogInfo('Remote speaks BinkP - proceeding on same connection');
UseComet := False;
end
else if HSResult = chrDisconnect then
begin
LogError('Connection lost during protocol detection');
CometTcpClose(Sock);
Halt(1);
end;
{ chrOK (Comet banner seen) or chrTimeout (no data) = proceed with Comet }
end;
if UseComet then
begin
{ ---- Comet handshake ---- }
CometSessionInit(State, Sock, True, Host, Port);
State.OurInit.Password := CometCfgGetPassword(Cfg, Addr);
HSResult := CometHandshake(State, Cfg);
if HSResult <> chrOK then
begin
LogError('Handshake failed: %d', [Ord(HSResult)]);
CometSessionDone(State);
CometTcpClose(Sock);
Halt(1);
end;
end;
if UseComet then
begin
{ ---- Comet protocol session ---- }
try
CometXferInit(XS, State, InDir, Cfg.TempDir,
CometAddSlash(Cfg.TempDir) + 'comet-abort.log');
XS.FreqDir := Cfg.FreqDir;

View File

@@ -150,6 +150,7 @@ type
LocalEOB: Boolean; { We sent M_EOB }
RemoteEOB: Boolean; { Received M_EOB }
Authenticated: Boolean; { Handshake complete }
AuthMethod: Byte; { AUTH_* - how session was authenticated }
Phase: Integer; { 0=setup, 1=transfer }
{ Remote info }
@@ -238,6 +239,50 @@ begin
end;
{ ---- Session event helpers ---- }
procedure BinkpFireSessionStart(var S: TBinkpState);
var
Ev: TCometEventData;
begin
FillChar(Ev, SizeOf(Ev), 0);
Ev.EventType := cetSessionStart;
Ev.Protocol := 'BinkP';
Ev.RemoteName := S.RemoteSysName;
if Length(S.RemoteAddrs) > 0 then
Ev.RemoteAddr := CometAddrToStr(S.RemoteAddrs[0]);
Ev.RemoteMailer := S.RemoteMailer;
CometFireEvent(Ev);
end;
procedure BinkpFireSessionAuth(var S: TBinkpState);
var
Ev: TCometEventData;
begin
FillChar(Ev, SizeOf(Ev), 0);
Ev.EventType := cetSessionAuth;
Ev.Protocol := 'BinkP';
Ev.AuthMethod := S.AuthMethod;
Ev.Encrypted := False; { BinkP encryption not yet implemented }
CometFireEvent(Ev);
end;
procedure BinkpFireSessionEnd(var S: TBinkpState);
var
Ev: TCometEventData;
begin
FillChar(Ev, SizeOf(Ev), 0);
Ev.EventType := cetSessionEnd;
Ev.Protocol := 'BinkP';
Ev.FilesSent := S.Result.FilesSent;
Ev.FilesRecvd := S.Result.FilesRecvd;
Ev.BytesSent := S.Result.BytesSent;
Ev.BytesRecvd := S.Result.BytesRecvd;
Ev.Success := S.Result.Success;
CometFireEvent(Ev);
end;
{ ---- Command name for logging ---- }
function BinkpCmdName(ID: Byte): string;
@@ -283,6 +328,7 @@ begin
SetLength(S.Msgs, S.MsgCount);
S.Msgs[Idx].Data := Frame;
S.Msgs[Idx].Sent := False;
LogDebug('BinkP TX queue %s: %s', [BinkpCmdName(CmdID), Arg]);
end;
@@ -390,6 +436,8 @@ begin
S.IMsg := (S.IBuf[0] and $80) <> 0;
S.ISize := ((S.IBuf[0] and $7F) shl 8) or S.IBuf[1];
S.IRead := 0;
LogDebug('BinkP RX hdr: %s len=%d (raw=$%02X%02X)',
[BoolToStr(S.IMsg, 'cmd', 'data'), S.ISize, S.IBuf[0], S.IBuf[1]]);
if S.ISize = 0 then
{ Fall through to body processing with zero-length frame }
@@ -478,9 +526,14 @@ begin
end;
if Length(S.RemoteAddrs) > 0 then
begin
LogInfo('BinkP remote: %s (%s)',
[CometAddrToStr(S.RemoteAddrs[0]), S.RemoteSysName]);
{ Fire session start event for host application }
BinkpFireSessionStart(S);
end;
{ Answerer: scan BSO outbound for files to send to the caller.
Same pattern as Comet inbound — scan all remote AKAs.
BSY locking is handled by the daemon, not the protocol. }
@@ -515,7 +568,7 @@ begin
begin
if FloEntries[J].Sent then Continue;
if (FloEntries[J].FilePath = '') or
not FileExists(FloEntries[J].FilePath) then Continue;
not CometFileExists(FloEntries[J].FilePath) then Continue;
if S.SendQueueLen >= Length(S.SendQueue) then
SetLength(S.SendQueue, S.SendQueueLen + 16);
FillChar(S.SendQueue[S.SendQueueLen], SizeOf(TBinkpSendEntry), 0);
@@ -552,16 +605,23 @@ begin
Pwd := 'ED25519-' + ED25519ToHex(SigBytes, 64);
FillChar(ED25519SK, 64, 0);
FillChar(ChalBytes, 32, 0);
S.AuthMethod := AUTH_ED25519;
LogInfo('BinkP: using ED25519 authentication');
end
else if (Pwd <> '') and (S.CRAMOpt <> '') then
begin
{ CRAM-MD5: send hashed response }
Pwd := CRAMBuildPassword(S.CRAMOpt, Pwd);
S.AuthMethod := AUTH_CRAM;
LogInfo('BinkP: using CRAM-MD5 authentication');
end
else if Pwd = '' then
begin
Pwd := '-';
S.AuthMethod := AUTH_NOPWD;
end
else
S.AuthMethod := AUTH_PLAIN;
QueueMsg(S, M_PWD, Pwd);
end;
@@ -590,9 +650,11 @@ begin
if ED25519Verify(@ChalBytes[0], 32, SigBytes, ED25519PK) then
begin
S.Authenticated := True;
S.AuthMethod := AUTH_ED25519;
S.Phase := 1;
QueueMsg(S, M_OK, 'secure');
LogInfo('BinkP ED25519 signature verified');
BinkpFireSessionAuth(S);
end
else
begin
@@ -617,9 +679,11 @@ begin
if (Challenge <> nil) and CRAMVerify(Challenge, CRAMResponse, Pwd) then
begin
S.Authenticated := True;
S.AuthMethod := AUTH_CRAM;
S.Phase := 1;
QueueMsg(S, M_OK, '');
LogInfo('BinkP CRAM-MD5 password verified');
BinkpFireSessionAuth(S);
end
else
begin
@@ -629,16 +693,25 @@ begin
S.IOError := True;
end;
end
else if (Pwd = '') or (Arg = '-') or (Arg = Pwd) then
else if (Pwd = '') and ((Arg = '-') or (Arg = '')) then
begin
{ No password required, or plain password match }
{ No password required on either side }
S.Authenticated := True;
S.AuthMethod := AUTH_NOPWD;
S.Phase := 1;
QueueMsg(S, M_OK, 'non-secure');
if Pwd = '' then
LogInfo('BinkP: no password - insecure session')
else
LogInfo('BinkP: plain password accepted');
LogInfo('BinkP: no password - insecure session');
BinkpFireSessionAuth(S);
end
else if (Pwd <> '') and (Arg = Pwd) then
begin
{ Plain password match }
S.Authenticated := True;
S.AuthMethod := AUTH_PLAIN;
S.Phase := 1;
QueueMsg(S, M_OK, '');
LogInfo('BinkP: plain password accepted');
BinkpFireSessionAuth(S);
end
else
begin
@@ -662,6 +735,7 @@ begin
S.Authenticated := True;
S.Phase := 1;
LogInfo('BinkP session established with %s', [S.RemoteSysName]);
BinkpFireSessionAuth(S);
end;
M_FILE:
@@ -675,9 +749,11 @@ begin
FParts.DelimitedText := Arg;
if FParts.Count >= 3 then
begin
{ Close any previous receive file }
{ Close any previous receive file and notify remote }
if S.RxFile.Active then
begin
QueueMsg(S, M_SKIP, Format('%s %d %d',
[S.RxFile.Name, S.RxFile.Size, S.RxFile.Time]));
CloseFile(S.RxFile.F);
CometDeleteTemp(S.RxFile.TempPath);
S.RxFile.Active := False;
@@ -725,6 +801,26 @@ begin
S.RxStartTime := Now;
BinkpFireFileEvent(cetFileStart, S.RxFile.Name,
S.RxFile.Size, S.RxFile.Offset, False, S.RxStartTime);
{ Zero-length file: complete immediately since no data
frames will arrive. Common for empty .PKT files. }
if S.RxFile.Size = 0 then
begin
CloseFile(S.RxFile.F);
S.RxFile.Active := False;
FinalPath := CometFinalizeFile(S.RxFile.TempPath,
S.InboundDir, S.RxFile.Name, LongInt(S.RxFile.Time));
if FinalPath <> '' then
begin
Inc(S.Result.FilesRecvd);
LogInfo('BinkP received: %s (0 bytes)',
[S.RxFile.Name]);
BinkpFireFileEvent(cetFileEnd, S.RxFile.Name, 0,
0, False, S.RxStartTime);
end;
QueueMsg(S, M_GOT, Format('%s 0 %d',
[S.RxFile.Name, S.RxFile.Time]));
end;
end
else
begin
@@ -813,9 +909,11 @@ begin
begin
S.RemoteEOB := True;
LogInfo('BinkP: remote sent EOB');
{ Close any incomplete receive file }
{ Close any incomplete receive file and notify remote }
if S.RxFile.Active then
begin
QueueMsg(S, M_SKIP, Format('%s %d %d',
[S.RxFile.Name, S.RxFile.Size, S.RxFile.Time]));
CloseFile(S.RxFile.F);
CometDeleteTemp(S.RxFile.TempPath);
S.RxFile.Active := False;
@@ -1142,9 +1240,10 @@ begin
SetLength(S.Msgs, 0);
{ Originator: consume any Comet banner before switching to non-blocking.
A Comet daemon sends "COMET/1.0\n" before detecting protocol.
A real binkd sends a BinkP command frame (first byte >= $80).
Read first byte: if < $80, consume the banner line and discard. }
Protocol sniff uses MSG_PEEK so the first byte is still in the socket.
If the first byte is a BinkP frame (>= $80), save as peek data for
the frame reader. If it's a low byte (e.g. Comet banner from a
daemon that sends banner first), consume the whole banner line. }
if S.IsOriginator then
begin
if CometTcpWaitData(S.Sock, 5000) then
@@ -1177,14 +1276,7 @@ begin
{ Set socket non-blocking }
CometTcpSetNonBlock(S.Sock, True);
{ Queue initial NUL info }
QueueMsg(S, M_NUL, 'SYS ' + S.Cfg.SysName);
QueueMsg(S, M_NUL, 'ZYZ ' + S.Cfg.SysOp);
QueueMsg(S, M_NUL, 'LOC ' + S.Cfg.Location);
QueueMsg(S, M_NUL, 'VER Comet/' + COMET_VERSION + ' binkp/1.1');
QueueMsg(S, M_NUL, 'TIME ' + FormatDateTime('ddd, dd mmm yyyy hh:nn:ss', Now));
{ Send OPT capabilities.
{ Build OPT capabilities string.
Only advertise features that are actually enabled. }
OptStr := 'NR EXTCMD';
@@ -1200,7 +1292,8 @@ begin
if not S.IsOriginator then
begin
{ Answering: always send CRAM-MD5 challenge }
{ Answering: CRAM-MD5 challenge MUST be the first message sent
(FTS-1027 requirement). binkd and other mailers expect this. }
S.CRAMOpt := CRAMGenerateChallenge;
{ Only send ED25519 challenge if we have a private key }
@@ -1212,8 +1305,16 @@ begin
end;
QueueMsg(S, M_NUL, 'OPT ' + S.CRAMOpt + ' ' + OptStr);
end
else
end;
{ Queue NUL info (after OPT on answering side per FTS-1027) }
QueueMsg(S, M_NUL, 'SYS ' + S.Cfg.SysName);
QueueMsg(S, M_NUL, 'ZYZ ' + S.Cfg.SysOp);
QueueMsg(S, M_NUL, 'LOC ' + S.Cfg.Location);
QueueMsg(S, M_NUL, 'VER Comet/' + COMET_VERSION + ' binkp/1.1');
QueueMsg(S, M_NUL, 'TIME ' + FormatDateTime('ddd, dd mmm yyyy hh:nn:ss', Now));
if S.IsOriginator then
QueueMsg(S, M_NUL, 'OPT ' + OptStr);
{ Queue our addresses }
@@ -1291,7 +1392,16 @@ begin
{$ENDIF}
end;
{ ---- Session end ---- }
{ ---- Session end: flush any remaining queued messages ---- }
if (S.MsgCount > 0) and not S.IOError then
begin
LogDebug('BinkP: flushing %d queued messages after session loop', [S.MsgCount]);
while (S.MsgCount > 0) and not S.IOError do
begin
if not CometTcpWaitSend(S.Sock, 5000) then Break;
if SendBlock(S) = 0 then Break;
end;
end;
{ Determine success }
if SessionDone(S) and not S.IOError then
@@ -1312,6 +1422,10 @@ begin
S.Result.RemoteSysName := S.RemoteSysName;
S.Result.RemoteSysOp := S.RemoteSysOp;
S.Result.RemoteMailer := S.RemoteMailer;
{ Fire session end event for host application }
BinkpFireSessionEnd(S);
{ Cleanup }
if S.TxFile.Active then
begin

View File

@@ -192,6 +192,52 @@ begin
FSlotIndex := ASlotIndex;
end;
{ ---- Session event helpers ---- }
procedure FireSessionStart(const Protocol, RemoteName, RemoteAddr,
RemoteMailer: string);
var
Ev: TCometEventData;
begin
FillChar(Ev, SizeOf(Ev), 0);
Ev.EventType := cetSessionStart;
Ev.Protocol := Protocol;
Ev.RemoteName := RemoteName;
Ev.RemoteAddr := RemoteAddr;
Ev.RemoteMailer := RemoteMailer;
CometFireEvent(Ev);
end;
procedure FireSessionAuth(const Protocol: string; AuthMethod: Byte;
Encrypted: Boolean);
var
Ev: TCometEventData;
begin
FillChar(Ev, SizeOf(Ev), 0);
Ev.EventType := cetSessionAuth;
Ev.Protocol := Protocol;
Ev.AuthMethod := AuthMethod;
Ev.Encrypted := Encrypted;
CometFireEvent(Ev);
end;
procedure FireSessionEnd(const Protocol: string; FilesSent, FilesRecvd: Integer;
BytesSent, BytesRecvd: Int64; Success: Boolean);
var
Ev: TCometEventData;
begin
FillChar(Ev, SizeOf(Ev), 0);
Ev.EventType := cetSessionEnd;
Ev.Protocol := Protocol;
Ev.FilesSent := FilesSent;
Ev.FilesRecvd := FilesRecvd;
Ev.BytesSent := BytesSent;
Ev.BytesRecvd := BytesRecvd;
Ev.Success := Success;
CometFireEvent(Ev);
end;
procedure TCometSessionThread.Execute;
begin
try
@@ -489,9 +535,21 @@ begin
FDaemon.UpdateSession(FSlotIndex, Info);
end;
{ Fire session events for host application }
FireSessionStart('Comet', State.Session.RemoteSysName,
CometAddrToStr(State.RemoteInit.Addresses[0]),
State.Session.RemoteMailer);
FireSessionAuth('Comet', State.Session.AuthMethod,
(State.Session.SharedCaps and COPT_CRYPT) <> 0);
{ Step 3: Run file transfer }
RunSession(State);
{ Fire session end event }
Info := FDaemon.GetSessionInfo(FSlotIndex);
FireSessionEnd('Comet', Info.FilesSent, Info.FilesRecvd,
Info.BytesSent, Info.BytesRecvd, True);
finally
CometSessionDone(State);
end;
@@ -519,19 +577,45 @@ begin
if FUseComet then
begin
{ ---- Comet protocol path ---- }
{ ---- Sniff protocol before sending anything ---- }
{ Uses MSG_PEEK so the byte stays in the socket buffer.
BinkP answerers send M_NUL immediately (high bit set).
Comet answerers send banner immediately (low byte).
If no data, remote is Comet waiting for our banner. }
HSResult := CometSniffProtocol(FSock, 5);
if HSResult = chrBinkP then
begin
LogInfo('Remote speaks BinkP - proceeding on same connection');
FUseComet := False;
end
else if HSResult = chrDisconnect then
begin
LogError('Connection lost during protocol detection with %s',
[CometAddrToStr(FTargetAddr)]);
Exit;
end;
{ chrOK (Comet banner seen) or chrTimeout (no data) = proceed with Comet }
end;
if FUseComet then
begin
{ ---- Comet handshake ---- }
CometSessionInit(State, FSock, True, FRemoteIP, FRemotePort);
State.OurInit.Password := CometCfgGetPassword(FDaemon.FCfg, FTargetAddr);
HSResult := CometHandshake(State, FDaemon.FCfg);
if HSResult <> chrOK then
begin
LogWarning('Outbound handshake failed with %s: %s',
[CometAddrToStr(FTargetAddr), IntToStr(Ord(HSResult))]);
CometSessionDone(State);
Exit;
end;
end;
if FUseComet then
begin
{ ---- Comet protocol session ---- }
try
State.OurInit.Password := CometCfgGetPassword(FDaemon.FCfg, FTargetAddr);
HSResult := CometHandshake(State, FDaemon.FCfg);
if HSResult <> chrOK then
begin
LogWarning('Outbound handshake failed with %s: %s',
[CometAddrToStr(FTargetAddr), IntToStr(Ord(HSResult))]);
Exit;
end;
{ Lock all remote AKAs }
if Length(State.RemoteInit.Addresses) > 0 then
begin
@@ -543,7 +627,19 @@ begin
Info.Status := cssActive;
FDaemon.UpdateSession(FSlotIndex, Info);
{ Fire session events for host application }
FireSessionStart('Comet', State.Session.RemoteSysName,
CometAddrToStr(State.RemoteInit.Addresses[0]),
State.Session.RemoteMailer);
FireSessionAuth('Comet', State.Session.AuthMethod,
(State.Session.SharedCaps and COPT_CRYPT) <> 0);
RunSession(State);
{ Fire session end event }
Info := FDaemon.GetSessionInfo(FSlotIndex);
FireSessionEnd('Comet', Info.FilesSent, Info.FilesRecvd,
Info.BytesSent, Info.BytesRecvd, True);
finally
CometSessionDone(State);
end;

View File

@@ -29,7 +29,7 @@ interface
const
{ Program identification }
COMET_NAME = 'Comet';
COMET_VERSION = '1.01';
COMET_VERSION = '1.1-1';
COMET_PRODCODE = $010B; { FTSC product code (Xenia family) }
{ Network }

View File

@@ -23,6 +23,10 @@
Multiple nodelist files are supported. The most recent entry for
a given address wins (last-loaded takes priority).
Directory loading parses the first-line date comment to determine
the true date (year + day number), so year rollovers are handled
correctly even with multiple years of nodelists present.
Copyright (C) 2026 Ken Johnson
License: GPL-2.0
}
@@ -109,6 +113,71 @@ const
NODELIST_GROW = 4096; { Grow entries array by this many at a time }
{ Read the first line of a nodelist file and extract a sortable date value.
First line format: ;A Friday, January 3, 2026 -- Day number 003 : ...
Returns Year * 1000 + DayNumber, or -1 if unparseable. }
function GetNodelistDateValue(const FilePath: string): LongInt;
var
F: TextFile;
Line, Upper: string;
P, YearVal, DayVal: Integer;
begin
Result := -1;
if not FileExists(FilePath) then Exit;
AssignFile(F, FilePath);
{$I-} Reset(F); {$I+}
if IOResult <> 0 then Exit;
try
if EOF(F) then Exit;
ReadLn(F, Line);
finally
CloseFile(F);
end;
if (Length(Line) = 0) or (Line[1] <> ';') then Exit;
Upper := UpperCase(Line);
{ Extract day number from "Day number NNN" }
P := Pos('DAY NUMBER', Upper);
if P = 0 then Exit;
P := P + 10; { skip past 'DAY NUMBER' }
while (P <= Length(Line)) and (Line[P] = ' ') do Inc(P);
DayVal := 0;
while (P <= Length(Line)) and (Line[P] >= '0') and (Line[P] <= '9') do
begin
DayVal := DayVal * 10 + Ord(Line[P]) - Ord('0');
Inc(P);
end;
if (DayVal < 1) or (DayVal > 366) then Exit;
{ Extract 4-digit year - scan for first 4-digit number >= 1990 }
YearVal := 0;
P := 2; { skip leading semicolon }
while P <= Length(Line) - 3 do
begin
if (Line[P] >= '0') and (Line[P] <= '9') and
(Line[P+1] >= '0') and (Line[P+1] <= '9') and
(Line[P+2] >= '0') and (Line[P+2] <= '9') and
(Line[P+3] >= '0') and (Line[P+3] <= '9') then
begin
YearVal := (Ord(Line[P]) - Ord('0')) * 1000 +
(Ord(Line[P+1]) - Ord('0')) * 100 +
(Ord(Line[P+2]) - Ord('0')) * 10 +
(Ord(Line[P+3]) - Ord('0'));
if YearVal >= 1990 then Break;
YearVal := 0;
end;
Inc(P);
end;
if YearVal = 0 then Exit;
Result := LongInt(YearVal) * 1000 + DayVal;
end;
procedure CometNodelistInit(var NL: TCometNodelist);
begin
SetLength(NL.Entries, 0);
@@ -331,29 +400,35 @@ function CometNodelistLoadDir(var NL: TCometNodelist;
var
SR: TSearchRec;
BestFile: string;
BestDay: Integer;
DayNum: Integer;
BestDate: LongInt;
DateVal: LongInt;
FilePath: string;
NormDir: string;
begin
Result := -1;
NormDir := CometStripSlash(DirPath);
if not DirectoryExists(NormDir) then Exit;
{ Find the most recent nodelist.NNN file }
{ Find the most recent nodelist.NNN file by parsing the
first-line date comment (year + day number) so that
nodelists spanning multiple years sort correctly. }
BestFile := '';
BestDay := -1;
BestDate := -1;
if FindFirst(NormDir + DirectorySeparator + 'nodelist.*', faAnyFile, SR) = 0 then
begin
try
repeat
if (SR.Attr and faDirectory) <> 0 then Continue;
{ Extract day number from extension }
DayNum := StrToIntDef(Copy(ExtractFileExt(SR.Name), 2, 10), -1);
if DayNum > BestDay then
{ Skip files without a numeric extension }
if StrToIntDef(Copy(ExtractFileExt(SR.Name), 2, 10), -1) < 0 then
Continue;
FilePath := NormDir + DirectorySeparator + SR.Name;
DateVal := GetNodelistDateValue(FilePath);
if DateVal > BestDate then
begin
BestDay := DayNum;
BestFile := NormDir + DirectorySeparator + SR.Name;
BestDate := DateVal;
BestFile := FilePath;
end;
until FindNext(SR) <> 0;
finally

View File

@@ -111,6 +111,19 @@ function CometSendBanner(Sock: TCometSocket): Boolean;
function CometRecvBanner(Sock: TCometSocket; TimeoutSecs: Integer;
out PeekBuf: string): TCometHandshakeResult;
{ Sniff protocol on an outbound connection WITHOUT sending anything.
Uses MSG_PEEK so the byte stays in the socket buffer.
Waits up to TimeoutSecs for the remote to send data.
BinkP answerers send M_NUL immediately - first byte has high bit set.
Comet answerers send banner immediately - first byte is 'C' ($43).
If no data arrives, remote is Comet waiting for our banner.
Returns chrBinkP if BinkP detected.
Returns chrOK if Comet detected (remote sent banner first).
Returns chrTimeout if no data (assume Comet - send banner next).
Returns chrDisconnect if connection lost. }
function CometSniffProtocol(Sock: TCometSocket;
TimeoutSecs: Integer): TCometHandshakeResult;
{ ---- INIT/INITACK Exchange ---- }
@@ -321,6 +334,64 @@ begin
end;
function CometSniffProtocol(Sock: TCometSocket;
TimeoutSecs: Integer): TCometHandshakeResult;
var
Buf: Byte;
Deadline: TDateTime;
Got: LongInt;
begin
Result := chrTimeout;
Deadline := Now + (TimeoutSecs / 86400.0);
while Now < Deadline do
begin
if not CometTcpWaitData(Sock, 500) then
begin
if not CometTcpConnected(Sock) then
begin
Result := chrDisconnect;
Exit;
end;
Continue;
end;
{ MSG_PEEK: read first byte WITHOUT consuming it from socket buffer.
Both BinkP and Comet can then read it normally. }
Got := CometTcpPeek(Sock, @Buf, 1);
if Got < 0 then
begin
{$IFDEF UNIX}
if fpGetErrno = ESysEAGAIN then Continue;
{$ENDIF}
Result := chrDisconnect;
Exit;
end;
if Got = 0 then
begin
Result := chrDisconnect;
Exit;
end;
{ First byte peeked - check for BinkP (high bit set) }
if (Buf and $80) <> 0 then
begin
Result := chrBinkP;
LogInfo('Protocol sniff: first byte $%02X - BinkP detected', [Buf]);
end
else
begin
{ Low byte = Comet banner from remote (answerer sends banner first). }
Result := chrOK;
LogInfo('Protocol sniff: first byte $%02X - Comet detected', [Buf]);
end;
Exit;
end;
LogInfo('Protocol sniff: no data in %d sec - assuming Comet', [TimeoutSecs]);
end;
{ ---- INIT/INITACK Building ---- }
function CometBuildInit(const Info: TCometInitInfo): TBytes;

View File

@@ -31,7 +31,7 @@ uses
{$ELSE}
Sockets,
{$IFDEF UNIX}
BaseUnix, Unix,
BaseUnix, Unix, netdb,
{$ENDIF}
{$IFDEF MSWINDOWS}
WinSock2,
@@ -117,6 +117,11 @@ function CometTcpSend(Sock: TCometSocket; Buf: Pointer;
function CometTcpRecv(Sock: TCometSocket; Buf: Pointer;
MaxLen: LongInt): LongInt;
{ Peek at data without consuming it (MSG_PEEK). Returns bytes peeked,
0 if nothing available, or -1 on error. Data stays in socket buffer. }
function CometTcpPeek(Sock: TCometSocket; Buf: Pointer;
MaxLen: LongInt): LongInt;
{ Send all data, looping until complete. Returns True on success. }
function CometTcpSendAll(Sock: TCometSocket; Buf: Pointer;
Len: LongInt): Boolean;
@@ -444,6 +449,9 @@ var
IA: TInAddr;
{$ELSE}
IA: in_addr;
{$IFDEF UNIX}
HEntry: THostEntry;
{$ENDIF}
{$ENDIF}
begin
{$IFDEF MSWINDOWS}
@@ -470,6 +478,18 @@ begin
end;
{$ELSE}
IA.s_addr := StrToHostAddr(Host).s_addr;
{$IFDEF UNIX}
{ StrToHostAddr uses FPC's internal resolver which may fail on systems
using systemd-resolved or other non-standard DNS setups.
Fall back to netdb.ResolveHostByName which uses the C library. }
if IA.s_addr = 0 then
begin
{ ResolveHostByName returns network byte order;
HostAddrToStr expects host byte order — swap. }
if ResolveHostByName(Host, HEntry) then
IA.s_addr := htonl(HEntry.Addr.s_addr);
end;
{$ENDIF}
if IA.s_addr <> 0 then
Result := HostAddrToStr(
{$IFDEF MSWINDOWS}
@@ -688,6 +708,29 @@ begin
end;
function CometTcpPeek(Sock: TCometSocket; Buf: Pointer;
MaxLen: LongInt): LongInt;
begin
if (Sock < 0) or (Buf = nil) or (MaxLen <= 0) then
begin
Result := -1;
Exit;
end;
{$IFDEF GO32V2}
Result := watt_recv(Sock, Buf, MaxLen, 2); { MSG_PEEK = 2 }
{$ELSE}
{$IFDEF UNIX}
repeat
Result := fpRecv(Sock, Buf, MaxLen, MSG_PEEK);
until (Result >= 0) or (fpGetErrno <> ESysEINTR);
{$ELSE}
Result := fpRecv(Sock, Buf, MaxLen, MSG_PEEK);
{$ENDIF}
{$ENDIF}
end;
function CometTcpSendAll(Sock: TCometSocket; Buf: Pointer;
Len: LongInt): Boolean;
begin