From 8e172db3242ea1537c4bebbbb0037f5c1d030731 Mon Sep 17 00:00:00 2001 From: Mark Mennell Date: Sun, 12 Apr 2026 18:38:28 +0800 Subject: [PATCH 1/5] implement compression --- src/deflate.go | 110 ++++++++++ src/deflate_test.go | 484 ++++++++++++++++++++++++++++++++++++++++++++ src/sender.go | 36 ++++ 3 files changed, 630 insertions(+) create mode 100644 src/deflate.go create mode 100644 src/deflate_test.go diff --git a/src/deflate.go b/src/deflate.go new file mode 100644 index 0000000..64c3c1d --- /dev/null +++ b/src/deflate.go @@ -0,0 +1,110 @@ +package main + +import ( + "compress/zlib" + "io" + "os" + "strings" +) + +// minDeflateSize is the minimum payload size in bytes before compression is +// attempted. Below this, zlib framing overhead likely outweighs savings. +const minDeflateSize uint32 = 512 + +// incompressibleTypes lists media types (lowercased, without parameters) that +// are already compressed or otherwise unlikely to benefit from zlib-deflate. +var incompressibleTypes = map[string]bool{ + // images + "image/jpeg": true, "image/png": true, "image/gif": true, + "image/webp": true, "image/heic": true, "image/avif": true, + "image/apng": true, + // audio + "audio/aac": true, "audio/mpeg": true, "audio/ogg": true, + "audio/opus": true, "audio/webm": true, + // video + "video/h264": true, "video/h265": true, "video/h266": true, + "video/ogg": true, "video/vp8": true, "video/vp9": true, + "video/webm": true, + // archives / compressed containers + "application/gzip": true, "application/zip": true, + "application/epub+zip": true, + "application/octet-stream": true, + // zip-based office formats + "application/vnd.oasis.opendocument.presentation": true, + "application/vnd.oasis.opendocument.spreadsheet": true, + "application/vnd.oasis.opendocument.text": true, + "application/vnd.openxmlformats-officedocument.presentationml.presentation": true, + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": true, + "application/vnd.openxmlformats-officedocument.wordprocessingml.document": true, + "application/vnd.amazon.ebook": true, + // fonts (compressed) + "font/woff": true, "font/woff2": true, + // pdf (internally compressed) + "application/pdf": true, + // 3d models (compressed containers) + "model/3mf": true, "model/gltf-binary": true, + "model/vnd.usdz+zip": true, +} + +// shouldDeflate reports whether compression should be attempted for a payload +// with the given media type and size. It returns false for payloads that are +// too small or use a media type known to be already compressed. +func shouldDeflate(mediaType string, dataSize uint32) bool { + if dataSize < minDeflateSize { + return false + } + t := strings.ToLower(mediaType) + if i := strings.IndexByte(t, ';'); i >= 0 { + t = strings.TrimRight(t[:i], " ") + } + return !incompressibleTypes[t] +} + +// tryDeflate compresses the file at srcPath using zlib-deflate and writes the +// result to a temporary file. It returns worthwhile=true only when the +// compressed output is less than 90% of the original size (at least a 10% +// reduction). When not worthwhile the temporary file is removed. +func tryDeflate(srcPath string, srcSize uint32) (dstPath string, compressedSize uint32, worthwhile bool, err error) { + src, err := os.Open(srcPath) + if err != nil { + return "", 0, false, err + } + defer src.Close() + + dst, err := os.CreateTemp("", "fmsg-deflate-*") + if err != nil { + return "", 0, false, err + } + dstName := dst.Name() + + zw := zlib.NewWriter(dst) + if _, err := io.Copy(zw, src); err != nil { + _ = zw.Close() + _ = dst.Close() + _ = os.Remove(dstName) + return "", 0, false, err + } + if err := zw.Close(); err != nil { + _ = dst.Close() + _ = os.Remove(dstName) + return "", 0, false, err + } + if err := dst.Close(); err != nil { + _ = os.Remove(dstName) + return "", 0, false, err + } + + fi, err := os.Stat(dstName) + if err != nil { + _ = os.Remove(dstName) + return "", 0, false, err + } + + cSize := uint32(fi.Size()) + if cSize >= srcSize*9/10 { + _ = os.Remove(dstName) + return "", 0, false, nil + } + + return dstName, cSize, true, nil +} diff --git a/src/deflate_test.go b/src/deflate_test.go new file mode 100644 index 0000000..f9e5d5f --- /dev/null +++ b/src/deflate_test.go @@ -0,0 +1,484 @@ +package main + +import ( + "bytes" + "compress/zlib" + "crypto/rand" + "crypto/sha256" + "io" + "os" + "strings" + "testing" +) + +// --- shouldDeflate tests --- + +func TestShouldDeflate_TextTypes(t *testing.T) { + compressible := []string{ + "text/plain;charset=UTF-8", + "text/html", + "text/markdown", + "text/csv", + "text/css", + "text/javascript", + "text/calendar", + "text/vcard", + "text/plain;charset=US-ASCII", + "text/plain;charset=UTF-16", + "application/json", + "application/xml", + "application/xhtml+xml", + "application/rtf", + "application/x-tar", + "application/msword", + "application/vnd.ms-excel", + "application/vnd.ms-powerpoint", + "image/svg+xml", + "audio/midi", + "model/obj", + "model/step", + "model/stl", + } + for _, mt := range compressible { + if !shouldDeflate(mt, 1024) { + t.Errorf("shouldDeflate(%q, 1024) = false, want true", mt) + } + } +} + +func TestShouldDeflate_IncompressibleTypes(t *testing.T) { + skip := []string{ + "image/jpeg", + "image/png", + "image/gif", + "image/webp", + "image/heic", + "image/avif", + "image/apng", + "audio/aac", + "audio/mpeg", + "audio/ogg", + "audio/opus", + "audio/webm", + "video/H264", + "video/H265", + "video/H266", + "video/ogg", + "video/VP8", + "video/VP9", + "video/webm", + "application/gzip", + "application/zip", + "application/epub+zip", + "application/octet-stream", + "application/pdf", + "application/vnd.oasis.opendocument.presentation", + "application/vnd.oasis.opendocument.spreadsheet", + "application/vnd.oasis.opendocument.text", + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.amazon.ebook", + "font/woff", + "font/woff2", + "model/3mf", + "model/gltf-binary", + "model/vnd.usdz+zip", + } + for _, mt := range skip { + if shouldDeflate(mt, 1024) { + t.Errorf("shouldDeflate(%q, 1024) = true, want false", mt) + } + } +} + +func TestShouldDeflate_SmallPayload(t *testing.T) { + sizes := []uint32{0, 1, 100, 511} + for _, sz := range sizes { + if shouldDeflate("text/plain;charset=UTF-8", sz) { + t.Errorf("shouldDeflate(text/plain, %d) = true, want false", sz) + } + } +} + +func TestShouldDeflate_EdgeCases(t *testing.T) { + // Exactly at threshold: should attempt + if !shouldDeflate("text/plain;charset=UTF-8", 512) { + t.Error("shouldDeflate at threshold 512 should return true") + } + // Unknown type: default to try compression + if !shouldDeflate("application/x-custom", 1024) { + t.Error("shouldDeflate for unknown type should return true") + } + // Type with parameters should match base type + if shouldDeflate("application/pdf; charset=utf-8", 1024) { + t.Error("shouldDeflate should strip parameters and match application/pdf") + } + // Case insensitive + if shouldDeflate("VIDEO/H264", 1024) { + t.Error("shouldDeflate should be case-insensitive") + } +} + +// --- tryDeflate tests --- + +func writeTempFile(t *testing.T, data []byte) string { + t.Helper() + f, err := os.CreateTemp("", "deflate-test-*") + if err != nil { + t.Fatal(err) + } + if _, err := f.Write(data); err != nil { + f.Close() + os.Remove(f.Name()) + t.Fatal(err) + } + f.Close() + return f.Name() +} + +func TestTryDeflate_CompressibleData(t *testing.T) { + original := []byte(strings.Repeat("hello world, this is compressible text data! ", 100)) + srcPath := writeTempFile(t, original) + defer os.Remove(srcPath) + + dstPath, cSize, worthwhile, err := tryDeflate(srcPath, uint32(len(original))) + if err != nil { + t.Fatal(err) + } + if !worthwhile { + t.Fatal("expected compression to be worthwhile for repetitive text") + } + defer os.Remove(dstPath) + + if cSize >= uint32(len(original))*9/10 { + t.Errorf("compressed size %d not < 90%% of original %d", cSize, len(original)) + } + + // Verify the compressed file decompresses to the original data + f, err := os.Open(dstPath) + if err != nil { + t.Fatal(err) + } + defer f.Close() + + zr, err := zlib.NewReader(f) + if err != nil { + t.Fatal(err) + } + decompressed, err := io.ReadAll(zr) + zr.Close() + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(decompressed, original) { + t.Error("decompressed data does not match original") + } +} + +func TestTryDeflate_IncompressibleData(t *testing.T) { + // Random bytes are effectively incompressible + data := make([]byte, 2048) + if _, err := rand.Read(data); err != nil { + t.Fatal(err) + } + srcPath := writeTempFile(t, data) + defer os.Remove(srcPath) + + _, _, worthwhile, err := tryDeflate(srcPath, uint32(len(data))) + if err != nil { + t.Fatal(err) + } + if worthwhile { + t.Error("expected compression of random data to not be worthwhile") + } +} + +func TestTryDeflate_RoundTrip(t *testing.T) { + original := []byte(strings.Repeat("Round-trip test data with enough repetition to compress well. ", 50)) + srcPath := writeTempFile(t, original) + defer os.Remove(srcPath) + + dstPath, cSize, worthwhile, err := tryDeflate(srcPath, uint32(len(original))) + if err != nil { + t.Fatal(err) + } + if !worthwhile { + t.Fatal("expected compression to be worthwhile") + } + defer os.Remove(dstPath) + + // Read compressed file + compressed, err := os.ReadFile(dstPath) + if err != nil { + t.Fatal(err) + } + if uint32(len(compressed)) != cSize { + t.Errorf("compressed file size %d != reported size %d", len(compressed), cSize) + } + + // Decompress and verify + zr, err := zlib.NewReader(bytes.NewReader(compressed)) + if err != nil { + t.Fatal(err) + } + decompressed, err := io.ReadAll(zr) + zr.Close() + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(decompressed, original) { + t.Errorf("round-trip mismatch: got %d bytes, want %d bytes", len(decompressed), len(original)) + } +} + +func TestTryDeflate_CleanupOnNotWorthwhile(t *testing.T) { + // Random data won't compress well — the temp file should be removed + data := make([]byte, 2048) + if _, err := rand.Read(data); err != nil { + t.Fatal(err) + } + srcPath := writeTempFile(t, data) + defer os.Remove(srcPath) + + dstPath, _, worthwhile, err := tryDeflate(srcPath, uint32(len(data))) + if err != nil { + t.Fatal(err) + } + if worthwhile { + defer os.Remove(dstPath) + t.Fatal("expected not worthwhile for random data") + } + // dstPath should be empty and no leaked temp file + if dstPath != "" { + t.Errorf("expected empty dstPath when not worthwhile, got %q", dstPath) + } +} + +// --- Hash determinism tests --- + +func TestGetMessageHash_WithDeflate(t *testing.T) { + // Create repetitive data that compresses well + original := []byte(strings.Repeat("deflate hash test data ", 100)) + srcPath := writeTempFile(t, original) + defer os.Remove(srcPath) + + // Compress it + dstPath, cSize, worthwhile, err := tryDeflate(srcPath, uint32(len(original))) + if err != nil { + t.Fatal(err) + } + if !worthwhile { + t.Fatal("expected compression to be worthwhile") + } + defer os.Remove(dstPath) + + // Build header with deflate flag pointing at compressed file + h := &FMsgHeader{ + Version: 1, + Flags: FlagDeflate, + From: FMsgAddress{User: "alice", Domain: "example.com"}, + To: []FMsgAddress{{User: "bob", Domain: "other.com"}}, + Topic: "test", + Type: "text/plain;charset=UTF-8", + Size: cSize, + Filepath: dstPath, + } + + msgHash, err := h.GetMessageHash() + if err != nil { + t.Fatal(err) + } + + // Manually compute expected: SHA-256(encoded header + decompressed data) + expected := sha256.New() + expected.Write(h.Encode()) + expected.Write(original) + expectedHash := expected.Sum(nil) + + if !bytes.Equal(msgHash, expectedHash) { + t.Errorf("hash mismatch:\n got %x\n want %x", msgHash, expectedHash) + } +} + +func TestGetMessageHash_WithoutDeflate(t *testing.T) { + original := []byte(strings.Repeat("no deflate hash test ", 100)) + srcPath := writeTempFile(t, original) + defer os.Remove(srcPath) + + h := &FMsgHeader{ + Version: 1, + Flags: 0, + From: FMsgAddress{User: "alice", Domain: "example.com"}, + To: []FMsgAddress{{User: "bob", Domain: "other.com"}}, + Topic: "test", + Type: "text/plain;charset=UTF-8", + Size: uint32(len(original)), + Filepath: srcPath, + } + + msgHash, err := h.GetMessageHash() + if err != nil { + t.Fatal(err) + } + + expected := sha256.New() + expected.Write(h.Encode()) + expected.Write(original) + expectedHash := expected.Sum(nil) + + if !bytes.Equal(msgHash, expectedHash) { + t.Errorf("hash mismatch:\n got %x\n want %x", msgHash, expectedHash) + } +} + +func TestGetMessageHash_DeflateChangesHash(t *testing.T) { + // The same data produces different message hashes depending on whether + // it is deflated, because the header bytes differ (flags and size fields). + original := []byte(strings.Repeat("deflate vs plain ", 100)) + srcPath := writeTempFile(t, original) + defer os.Remove(srcPath) + + dstPath, cSize, worthwhile, err := tryDeflate(srcPath, uint32(len(original))) + if err != nil { + t.Fatal(err) + } + if !worthwhile { + t.Fatal("expected compression to be worthwhile") + } + defer os.Remove(dstPath) + + base := FMsgHeader{ + Version: 1, + From: FMsgAddress{User: "alice", Domain: "example.com"}, + To: []FMsgAddress{{User: "bob", Domain: "other.com"}}, + Topic: "test", + Type: "text/plain;charset=UTF-8", + } + + // Hash without deflate + plain := base + plain.Flags = 0 + plain.Size = uint32(len(original)) + plain.Filepath = srcPath + hashPlain, err := plain.GetMessageHash() + if err != nil { + t.Fatal(err) + } + + // Hash with deflate + deflated := base + deflated.Flags = FlagDeflate + deflated.Size = cSize + deflated.Filepath = dstPath + hashDeflated, err := deflated.GetMessageHash() + if err != nil { + t.Fatal(err) + } + + if bytes.Equal(hashPlain, hashDeflated) { + t.Error("expected different hashes for deflated vs non-deflated wire representations") + } +} + +func TestGetMessageHash_AttachmentDeflate(t *testing.T) { + msgData := []byte("short message body that fits in a file") + msgPath := writeTempFile(t, msgData) + defer os.Remove(msgPath) + + attOriginal := []byte(strings.Repeat("attachment data for compression test ", 100)) + attSrcPath := writeTempFile(t, attOriginal) + defer os.Remove(attSrcPath) + + attDstPath, attCSize, worthwhile, err := tryDeflate(attSrcPath, uint32(len(attOriginal))) + if err != nil { + t.Fatal(err) + } + if !worthwhile { + t.Fatal("expected attachment compression to be worthwhile") + } + defer os.Remove(attDstPath) + + h := &FMsgHeader{ + Version: 1, + Flags: 0, + From: FMsgAddress{User: "alice", Domain: "example.com"}, + To: []FMsgAddress{{User: "bob", Domain: "other.com"}}, + Topic: "test", + Type: "text/plain;charset=UTF-8", + Size: uint32(len(msgData)), + Filepath: msgPath, + Attachments: []FMsgAttachmentHeader{ + { + Flags: 1 << 1, // attachment deflate bit + Type: "text/csv", + Filename: "data.csv", + Size: attCSize, + Filepath: attDstPath, + }, + }, + } + + msgHash, err := h.GetMessageHash() + if err != nil { + t.Fatal(err) + } + + // Manually compute: SHA-256(header + msg data + decompressed attachment) + expected := sha256.New() + expected.Write(h.Encode()) + expected.Write(msgData) + expected.Write(attOriginal) + expectedHash := expected.Sum(nil) + + if !bytes.Equal(msgHash, expectedHash) { + t.Errorf("attachment hash mismatch:\n got %x\n want %x", msgHash, expectedHash) + } +} + +// --- Encode flag tests --- + +func TestEncode_DeflateFlag(t *testing.T) { + h := &FMsgHeader{ + Version: 1, + Flags: FlagDeflate, + From: FMsgAddress{User: "alice", Domain: "example.com"}, + To: []FMsgAddress{{User: "bob", Domain: "other.com"}}, + Topic: "test", + Type: "text/plain;charset=UTF-8", + } + b := h.Encode() + if b[1]&FlagDeflate == 0 { + t.Error("deflate flag bit (5) not set in encoded header flags byte") + } +} + +func TestEncode_AttachmentDeflateFlag(t *testing.T) { + h := &FMsgHeader{ + Version: 1, + Flags: 0, + From: FMsgAddress{User: "alice", Domain: "example.com"}, + To: []FMsgAddress{{User: "bob", Domain: "other.com"}}, + Topic: "test", + Type: "text/plain;charset=UTF-8", + Attachments: []FMsgAttachmentHeader{ + {Flags: 1 << 1, Type: "text/plain", Filename: "test.txt", Size: 100}, + }, + } + b := h.Encode() + // The encoded header ends with attachment headers. Find the attachment + // flags byte: it's the first byte after the attachment count byte. + // The attachment count is at len(b) - (1 + 1 + len("text/plain") + 1 + len("test.txt") + 4) - 1 + // Simpler: just verify the flags byte value appears in the output. + // The attachment count byte (1) followed by attachment flags byte (0x02). + found := false + for i := 0; i < len(b)-1; i++ { + if b[i] == 1 && b[i+1] == (1<<1) { // count=1, flags=0x02 + found = true + break + } + } + if !found { + t.Error("attachment deflate flag bit (1) not found in encoded header") + } +} diff --git a/src/sender.go b/src/sender.go index 1db7148..3010624 100644 --- a/src/sender.go +++ b/src/sender.go @@ -304,6 +304,42 @@ func deliverMessage(target pendingTarget) { return } + // Try zlib-deflate compression for message data and attachment data. + // Compressed temp files are cleaned up after delivery completes. + var deflateCleanup []string + defer func() { + for _, p := range deflateCleanup { + _ = os.Remove(p) + } + }() + if shouldDeflate(h.Type, h.Size) { + dp, cs, ok, derr := tryDeflate(h.Filepath, h.Size) + if derr != nil { + log.Printf("WARN: sender: deflate msg data for msg %d: %s", target.MsgID, derr) + } else if ok { + log.Printf("INFO: sender: deflated msg %d data: %d -> %d bytes", target.MsgID, h.Size, cs) + deflateCleanup = append(deflateCleanup, dp) + h.Filepath = dp + h.Size = cs + h.Flags |= FlagDeflate + } + } + for i := range h.Attachments { + att := &h.Attachments[i] + if shouldDeflate(att.Type, att.Size) { + dp, cs, ok, derr := tryDeflate(att.Filepath, att.Size) + if derr != nil { + log.Printf("WARN: sender: deflate attachment %s for msg %d: %s", att.Filename, target.MsgID, derr) + } else if ok { + log.Printf("INFO: sender: deflated msg %d attachment %s: %d -> %d bytes", target.MsgID, att.Filename, att.Size, cs) + deflateCleanup = append(deflateCleanup, dp) + att.Filepath = dp + att.Size = cs + att.Flags |= 1 << 1 + } + } + } + // Ensure sha256 is populated for outgoing messages so future pid lookups // (e.g. add-to notifications referencing this message) can find it. msgHash, err := h.GetMessageHash() From 9952f71a9b58258d4786df442c271c5a7631e9ce Mon Sep 17 00:00:00 2001 From: Mark Mennell Date: Sun, 12 Apr 2026 18:40:48 +0800 Subject: [PATCH 2/5] rm hyperbole --- src/deflate.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/deflate.go b/src/deflate.go index 64c3c1d..20d6590 100644 --- a/src/deflate.go +++ b/src/deflate.go @@ -8,7 +8,7 @@ import ( ) // minDeflateSize is the minimum payload size in bytes before compression is -// attempted. Below this, zlib framing overhead likely outweighs savings. +// attempted. const minDeflateSize uint32 = 512 // incompressibleTypes lists media types (lowercased, without parameters) that From d6706ce3b8aacce29e1d8ec95011077ade6ec7d4 Mon Sep 17 00:00:00 2001 From: Mark Mennell Date: Mon, 13 Apr 2026 00:06:37 +0800 Subject: [PATCH 3/5] deflate vs. compress wording --- src/deflate.go | 63 ++++++++++++++++++++++++---- src/deflate_test.go | 100 +++++++++++++++++++++++++++++++++++--------- src/sender.go | 16 +++---- 3 files changed, 144 insertions(+), 35 deletions(-) diff --git a/src/deflate.go b/src/deflate.go index 20d6590..68b85a7 100644 --- a/src/deflate.go +++ b/src/deflate.go @@ -1,6 +1,7 @@ package main import ( + "bytes" "compress/zlib" "io" "os" @@ -46,10 +47,10 @@ var incompressibleTypes = map[string]bool{ "model/vnd.usdz+zip": true, } -// shouldDeflate reports whether compression should be attempted for a payload +// shouldCompress reports whether compression should be attempted for a payload // with the given media type and size. It returns false for payloads that are // too small or use a media type known to be already compressed. -func shouldDeflate(mediaType string, dataSize uint32) bool { +func shouldCompress(mediaType string, dataSize uint32) bool { if dataSize < minDeflateSize { return false } @@ -60,17 +61,63 @@ func shouldDeflate(mediaType string, dataSize uint32) bool { return !incompressibleTypes[t] } -// tryDeflate compresses the file at srcPath using zlib-deflate and writes the -// result to a temporary file. It returns worthwhile=true only when the -// compressed output is less than 90% of the original size (at least a 10% -// reduction). When not worthwhile the temporary file is removed. -func tryDeflate(srcPath string, srcSize uint32) (dstPath string, compressedSize uint32, worthwhile bool, err error) { +// deflateSampleSize is the number of bytes sampled from the start of a file +// to estimate compressibility before committing to a full-file compression +// pass. Chosen large enough for zlib to find patterns but small enough to be +// fast even on very large files. +const deflateSampleSize = 8192 + +// probeSample compresses up to deflateSampleSize bytes from the start of src +// and reports whether the ratio looks promising (compressed < 80% of input). +// src is seeked back to the start on return. +func probeSample(src *os.File, srcSize uint32) (bool, error) { + sampleLen := int64(deflateSampleSize) + if int64(srcSize) < sampleLen { + sampleLen = int64(srcSize) + } + + var buf bytes.Buffer + zw := zlib.NewWriter(&buf) + if _, err := io.CopyN(zw, src, sampleLen); err != nil { + _ = zw.Close() + return false, err + } + if err := zw.Close(); err != nil { + return false, err + } + + if _, err := src.Seek(0, io.SeekStart); err != nil { + return false, err + } + + return int64(buf.Len()) < sampleLen*8/10, nil +} + +// tryCompress compresses the file at srcPath using zlib-deflate and writes the +// result to a temporary file. For files larger than deflateSampleSize it first +// compresses a prefix sample to estimate compressibility, avoiding a full pass +// over files that won't compress well. It returns worthwhile=true only when +// the compressed output is less than 80% of the original size (at least a 20% +// reduction). When not worthwhile the temporary file is removed. When +// worthwhile the caller is responsible for removing the file at dstPath. +func tryCompress(srcPath string, srcSize uint32) (dstPath string, compressedSize uint32, worthwhile bool, err error) { src, err := os.Open(srcPath) if err != nil { return "", 0, false, err } defer src.Close() + // For files larger than the sample size, probe a prefix first. + if srcSize > deflateSampleSize { + promising, err := probeSample(src, srcSize) + if err != nil { + return "", 0, false, err + } + if !promising { + return "", 0, false, nil + } + } + dst, err := os.CreateTemp("", "fmsg-deflate-*") if err != nil { return "", 0, false, err @@ -101,7 +148,7 @@ func tryDeflate(srcPath string, srcSize uint32) (dstPath string, compressedSize } cSize := uint32(fi.Size()) - if cSize >= srcSize*9/10 { + if cSize >= srcSize*8/10 { _ = os.Remove(dstName) return "", 0, false, nil } diff --git a/src/deflate_test.go b/src/deflate_test.go index f9e5d5f..f3e7b56 100644 --- a/src/deflate_test.go +++ b/src/deflate_test.go @@ -40,8 +40,8 @@ func TestShouldDeflate_TextTypes(t *testing.T) { "model/stl", } for _, mt := range compressible { - if !shouldDeflate(mt, 1024) { - t.Errorf("shouldDeflate(%q, 1024) = false, want true", mt) + if !shouldCompress(mt, 1024) { + t.Errorf("shouldCompress(%q, 1024) = false, want true", mt) } } } @@ -86,8 +86,8 @@ func TestShouldDeflate_IncompressibleTypes(t *testing.T) { "model/vnd.usdz+zip", } for _, mt := range skip { - if shouldDeflate(mt, 1024) { - t.Errorf("shouldDeflate(%q, 1024) = true, want false", mt) + if shouldCompress(mt, 1024) { + t.Errorf("shouldCompress(%q, 1024) = true, want false", mt) } } } @@ -95,27 +95,27 @@ func TestShouldDeflate_IncompressibleTypes(t *testing.T) { func TestShouldDeflate_SmallPayload(t *testing.T) { sizes := []uint32{0, 1, 100, 511} for _, sz := range sizes { - if shouldDeflate("text/plain;charset=UTF-8", sz) { - t.Errorf("shouldDeflate(text/plain, %d) = true, want false", sz) + if shouldCompress("text/plain;charset=UTF-8", sz) { + t.Errorf("shouldCompress(text/plain, %d) = true, want false", sz) } } } func TestShouldDeflate_EdgeCases(t *testing.T) { // Exactly at threshold: should attempt - if !shouldDeflate("text/plain;charset=UTF-8", 512) { + if !shouldCompress("text/plain;charset=UTF-8", 512) { t.Error("shouldDeflate at threshold 512 should return true") } // Unknown type: default to try compression - if !shouldDeflate("application/x-custom", 1024) { + if !shouldCompress("application/x-custom", 1024) { t.Error("shouldDeflate for unknown type should return true") } // Type with parameters should match base type - if shouldDeflate("application/pdf; charset=utf-8", 1024) { + if shouldCompress("application/pdf; charset=utf-8", 1024) { t.Error("shouldDeflate should strip parameters and match application/pdf") } // Case insensitive - if shouldDeflate("VIDEO/H264", 1024) { + if shouldCompress("VIDEO/H264", 1024) { t.Error("shouldDeflate should be case-insensitive") } } @@ -142,7 +142,7 @@ func TestTryDeflate_CompressibleData(t *testing.T) { srcPath := writeTempFile(t, original) defer os.Remove(srcPath) - dstPath, cSize, worthwhile, err := tryDeflate(srcPath, uint32(len(original))) + dstPath, cSize, worthwhile, err := tryCompress(srcPath, uint32(len(original))) if err != nil { t.Fatal(err) } @@ -151,8 +151,8 @@ func TestTryDeflate_CompressibleData(t *testing.T) { } defer os.Remove(dstPath) - if cSize >= uint32(len(original))*9/10 { - t.Errorf("compressed size %d not < 90%% of original %d", cSize, len(original)) + if cSize >= uint32(len(original))*8/10 { + t.Errorf("compressed size %d not < 80%% of original %d", cSize, len(original)) } // Verify the compressed file decompresses to the original data @@ -185,7 +185,7 @@ func TestTryDeflate_IncompressibleData(t *testing.T) { srcPath := writeTempFile(t, data) defer os.Remove(srcPath) - _, _, worthwhile, err := tryDeflate(srcPath, uint32(len(data))) + _, _, worthwhile, err := tryCompress(srcPath, uint32(len(data))) if err != nil { t.Fatal(err) } @@ -199,7 +199,7 @@ func TestTryDeflate_RoundTrip(t *testing.T) { srcPath := writeTempFile(t, original) defer os.Remove(srcPath) - dstPath, cSize, worthwhile, err := tryDeflate(srcPath, uint32(len(original))) + dstPath, cSize, worthwhile, err := tryCompress(srcPath, uint32(len(original))) if err != nil { t.Fatal(err) } @@ -241,7 +241,7 @@ func TestTryDeflate_CleanupOnNotWorthwhile(t *testing.T) { srcPath := writeTempFile(t, data) defer os.Remove(srcPath) - dstPath, _, worthwhile, err := tryDeflate(srcPath, uint32(len(data))) + dstPath, _, worthwhile, err := tryCompress(srcPath, uint32(len(data))) if err != nil { t.Fatal(err) } @@ -255,6 +255,68 @@ func TestTryDeflate_CleanupOnNotWorthwhile(t *testing.T) { } } +func TestTryDeflate_ProbeRejectsLargeIncompressible(t *testing.T) { + // A file larger than deflateSampleSize filled with random bytes should be + // rejected by the sample probe without writing a full compressed file. + data := make([]byte, deflateSampleSize+4096) + if _, err := rand.Read(data); err != nil { + t.Fatal(err) + } + srcPath := writeTempFile(t, data) + defer os.Remove(srcPath) + + _, _, worthwhile, err := tryCompress(srcPath, uint32(len(data))) + if err != nil { + t.Fatal(err) + } + if worthwhile { + t.Error("expected probe to reject large random data") + } +} + +func TestTryDeflate_ProbeAcceptsLargeCompressible(t *testing.T) { + // A file larger than deflateSampleSize filled with repetitive text should + // pass the probe and compress the full file successfully. + data := []byte(strings.Repeat("probe compressible test data! ", 1000)) + if len(data) <= deflateSampleSize { + t.Fatalf("test data %d bytes not larger than sample size %d", len(data), deflateSampleSize) + } + srcPath := writeTempFile(t, data) + defer os.Remove(srcPath) + + dstPath, cSize, worthwhile, err := tryCompress(srcPath, uint32(len(data))) + if err != nil { + t.Fatal(err) + } + if !worthwhile { + t.Fatal("expected large compressible data to be worthwhile") + } + defer os.Remove(dstPath) + + if cSize >= uint32(len(data))*8/10 { + t.Errorf("compressed size %d not < 80%% of original %d", cSize, len(data)) + } + + // Verify round-trip + f, err := os.Open(dstPath) + if err != nil { + t.Fatal(err) + } + defer f.Close() + zr, err := zlib.NewReader(f) + if err != nil { + t.Fatal(err) + } + decompressed, err := io.ReadAll(zr) + zr.Close() + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(decompressed, data) { + t.Error("decompressed data does not match original") + } +} + // --- Hash determinism tests --- func TestGetMessageHash_WithDeflate(t *testing.T) { @@ -264,7 +326,7 @@ func TestGetMessageHash_WithDeflate(t *testing.T) { defer os.Remove(srcPath) // Compress it - dstPath, cSize, worthwhile, err := tryDeflate(srcPath, uint32(len(original))) + dstPath, cSize, worthwhile, err := tryCompress(srcPath, uint32(len(original))) if err != nil { t.Fatal(err) } @@ -339,7 +401,7 @@ func TestGetMessageHash_DeflateChangesHash(t *testing.T) { srcPath := writeTempFile(t, original) defer os.Remove(srcPath) - dstPath, cSize, worthwhile, err := tryDeflate(srcPath, uint32(len(original))) + dstPath, cSize, worthwhile, err := tryCompress(srcPath, uint32(len(original))) if err != nil { t.Fatal(err) } @@ -390,7 +452,7 @@ func TestGetMessageHash_AttachmentDeflate(t *testing.T) { attSrcPath := writeTempFile(t, attOriginal) defer os.Remove(attSrcPath) - attDstPath, attCSize, worthwhile, err := tryDeflate(attSrcPath, uint32(len(attOriginal))) + attDstPath, attCSize, worthwhile, err := tryCompress(attSrcPath, uint32(len(attOriginal))) if err != nil { t.Fatal(err) } diff --git a/src/sender.go b/src/sender.go index 3010624..687d9d4 100644 --- a/src/sender.go +++ b/src/sender.go @@ -312,12 +312,12 @@ func deliverMessage(target pendingTarget) { _ = os.Remove(p) } }() - if shouldDeflate(h.Type, h.Size) { - dp, cs, ok, derr := tryDeflate(h.Filepath, h.Size) + if shouldCompress(h.Type, h.Size) { + dp, cs, ok, derr := tryCompress(h.Filepath, h.Size) if derr != nil { - log.Printf("WARN: sender: deflate msg data for msg %d: %s", target.MsgID, derr) + log.Printf("WARN: sender: compress msg data for msg %d: %s", target.MsgID, derr) } else if ok { - log.Printf("INFO: sender: deflated msg %d data: %d -> %d bytes", target.MsgID, h.Size, cs) + log.Printf("INFO: sender: compressed msg %d data: %d -> %d bytes", target.MsgID, h.Size, cs) deflateCleanup = append(deflateCleanup, dp) h.Filepath = dp h.Size = cs @@ -326,12 +326,12 @@ func deliverMessage(target pendingTarget) { } for i := range h.Attachments { att := &h.Attachments[i] - if shouldDeflate(att.Type, att.Size) { - dp, cs, ok, derr := tryDeflate(att.Filepath, att.Size) + if shouldCompress(att.Type, att.Size) { + dp, cs, ok, derr := tryCompress(att.Filepath, att.Size) if derr != nil { - log.Printf("WARN: sender: deflate attachment %s for msg %d: %s", att.Filename, target.MsgID, derr) + log.Printf("WARN: sender: compress attachment %s for msg %d: %s", att.Filename, target.MsgID, derr) } else if ok { - log.Printf("INFO: sender: deflated msg %d attachment %s: %d -> %d bytes", target.MsgID, att.Filename, att.Size, cs) + log.Printf("INFO: sender: compressed msg %d attachment %s: %d -> %d bytes", target.MsgID, att.Filename, att.Size, cs) deflateCleanup = append(deflateCleanup, dp) att.Filepath = dp att.Size = cs From 97a6d4859b55042d5109aa7707b7011c884aa1c4 Mon Sep 17 00:00:00 2001 From: Mark Mennell Date: Tue, 14 Apr 2026 10:28:10 +0800 Subject: [PATCH 4/5] TLS per FMSG-001 --- AGENTS.md | 2 +- README.md | 9 ++++++--- SPEC.md | 6 +++--- src/dns.go | 12 ++++++------ src/host.go | 52 ++++++++++++++++++++++++++++++++++++++++++++------- src/sender.go | 9 ++++++--- 6 files changed, 67 insertions(+), 23 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 56b3030..300059f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,7 +11,7 @@ All code MUST conform to the specification. When in doubt, re-read SPEC.md and f - Serialize and parse wire fields in the exact order defined in SPEC.md. - Use the flag bit assignments from SPEC.md (bit 0 = has pid, bit 1 = has add to, bit 2 = common type, etc.).im - Reject/accept response codes must match SPEC.md — do not invent new codes. -- Resolve `_fmsg.` using A/AAAA records only (never TXT, MX, or SRV). +- Resolve `fmsg.` using A/AAAA records only (never TXT, MX, or SRV). - Validate sender IP before issuing CHALLENGE. - Connection 2 for challenge MUST target the same IP as Connection 1. - Topic field is only present on the wire when pid is absent. diff --git a/README.md b/README.md index fa905eb..8546a3d 100644 --- a/README.md +++ b/README.md @@ -16,13 +16,16 @@ Tested with Go 1.25 on Linux and Windows, AMD64 and ARM ## Environment -`FMSG_DATA_DIR`, `FMSG_DOMAIN` and `FMSG_ID_URL` are required to be set and valid; otherwise fmsgd will abort on startup. In addition to these `FMSG_` varibles, `PG` variables need to be set for the PostgreSQL database to use, refer to: https://www.postgresql.org/docs/current/libpq-envars.html +`FMSG_DATA_DIR`, `FMSG_DOMAIN`, `FMSG_ID_URL`, `FMSG_TLS_CERT` and `FMSG_TLS_KEY` are required to be set and valid; otherwise fmsgd will abort on startup. In addition to these `FMSG_` varibles, `PG` variables need to be set for the PostgreSQL database to use, refer to: https://www.postgresql.org/docs/current/libpq-envars.html | Variable | Default | Description | |----------------------------|---------|---------------------------------------------------------------------------------------------------------------------------------------------------------| | FMSG_DATA_DIR | | Path where messages will be stored. e.g. /opt/fmsg/data | | FMSG_DOMAIN | | Domain name this host is located. e.g. example.com | | FMSG_ID_URL | | Base HTTP URL for fmsg Id API, e.g. http://localhost:5000 | +| FMSG_TLS_CERT | | Path to TLS certificate file (PEM). Certificate must match `fmsg.`. | +| FMSG_TLS_KEY | | Path to TLS private key file (PEM). | +| FMSG_TLS_INSECURE_SKIP_VERIFY | false | Set to "true" to skip TLS certificate verification on outgoing connections. For development/testing only. | | FMSG_MAX_MSG_SIZE | 10240 | Bytes. Maximum size above which to reject messages greater than before downloading them. | | FMSG_PORT | 4930 | TCP port to listen on | | FMSG_MAX_PAST_TIME_DELTA | 604800 | Seconds. Duration since message timestamp to reject if greater than. Note sending host could have been holding messages waiting for us to be reachable. | @@ -34,8 +37,8 @@ Tested with Go 1.25 on Linux and Windows, AMD64 and ARM | FMSG_RETRY_MAX_AGE | 86400 | Seconds. Maximum age of a message since creation before giving up on delivery retries (default 1 day). | | FMSG_POLL_INTERVAL | 10 | Seconds. How often the sender polls the database for pending messages. | | FMSG_MAX_CONCURRENT_SEND | 1024 | Maximum number of concurrent outbound message deliveries. | -| FMSG_SKIP_DOMAIN_IP_CHECK | false | Set to "true" to skip verifying this host's external IP is in the _fmsg DNS authorised IP set on startup. | -| FMSG_SKIP_AUTHORISED_IPS | false | Set to "true" to skip verifying remote hosts IP is in the _fmsg DNS authorised IP set during message exchange. WARNING setting this true is effectivly disables sender verification. | +| FMSG_SKIP_DOMAIN_IP_CHECK | false | Set to "true" to skip verifying this host's external IP is in the fmsg DNS authorised IP set on startup. | +| FMSG_SKIP_AUTHORISED_IPS | false | Set to "true" to skip verifying remote hosts IP is in the fmsg DNS authorised IP set during message exchange. WARNING setting this true effectively disables sender verification. | diff --git a/SPEC.md b/SPEC.md index 44702ab..7042393 100644 --- a/SPEC.md +++ b/SPEC.md @@ -153,7 +153,7 @@ Per-recipient codes (one byte per recipient on this host, in message order): ## 9. Domain Resolution -Resolve `_fmsg.` for A/AAAA records. The sender's domain is: +Resolve `fmsg.` for A/AAAA records. The sender's domain is: - The domain of _add to from_ when _has add to_ is set. - The domain of _from_ otherwise. @@ -175,7 +175,7 @@ One message per connection. Two TCP connections used: Connection 1 (message tran Host A delivers iff _from_ or _add to from_ belongs to Host A's domain. For each unique recipient domain: -1. Resolve recipient domain IPs via `_fmsg.`. Connect to first responsive IP (Connection 1). Retry with backoff if unreachable. +1. Resolve recipient domain IPs via `fmsg.`. Connect to first responsive IP (Connection 1). Retry with backoff if unreachable. 2. Register the message header hash and Host B's IP in an outgoing record (for matching challenges). 3. Transmit the message header on Connection 1. 4. Wait for response. During this wait, be ready to handle a CHALLENGE on Connection 2 (see §10.5). @@ -200,7 +200,7 @@ Host A delivers iff _from_ or _add to from_ belongs to Host A's domain. For each - If _has add to_: _add to from_ exists and is in _from_ or _to_; _add to_ has ≥ 1 distinct address. - ≥ 1 recipient in _to_ or _add to_ belongs to Host B's domain. - Common type IDs (message and attachment) are mapped. -4. DNS-verify sender IP: resolve `_fmsg.`, check Connection 1 source IP is in result set. Fail → TERMINATE. +4. DNS-verify sender IP: resolve `fmsg.`, check Connection 1 source IP is in result set. Fail → TERMINATE. 5. If _size_ + attachment sizes > MAX_SIZE → respond code 4, close. 6. Compute DELTA = now − _time_: - DELTA > MAX_MESSAGE_AGE → respond code 7, close. diff --git a/src/dns.go b/src/dns.go index 8929b53..a5e6daa 100644 --- a/src/dns.go +++ b/src/dns.go @@ -53,9 +53,9 @@ func resolverAuthenticatedData(name string, qtype uint16) (bool, error) { return false, lastErr } -// lookupAuthorisedIPs resolves _fmsg. for A and AAAA records +// lookupAuthorisedIPs resolves fmsg. for A and AAAA records func lookupAuthorisedIPs(domain string) ([]net.IP, error) { - fmsgDomain := "_fmsg." + domain + fmsgDomain := "fmsg." + domain ips, err := net.LookupIP(fmsgDomain) if err != nil { return nil, fmt.Errorf("DNS lookup for %s failed: %w", fmsgDomain, err) @@ -114,7 +114,7 @@ func getExternalIP() (net.IP, error) { } // verifyDomainIP checks that this host's external IP is present in the -// _fmsg. authorised IP set. Panics if not found. +// fmsg. authorised IP set. Panics if not found. func verifyDomainIP(domain string) { externalIP, err := getExternalIP() if err != nil { @@ -124,17 +124,17 @@ func verifyDomainIP(domain string) { authorisedIPs, err := lookupAuthorisedIPs(domain) if err != nil { - log.Panicf("ERROR: failed to lookup _fmsg.%s: %s", domain, err) + log.Panicf("ERROR: failed to lookup fmsg.%s: %s", domain, err) } for _, ip := range authorisedIPs { if externalIP.Equal(ip) { - log.Printf("INFO: external IP %s found in _fmsg.%s authorised IPs", externalIP, domain) + log.Printf("INFO: external IP %s found in fmsg.%s authorised IPs", externalIP, domain) return } } - log.Panicf("ERROR: external IP %s not found in _fmsg.%s authorised IPs %v", externalIP, domain, authorisedIPs) + log.Panicf("ERROR: external IP %s not found in fmsg.%s authorised IPs %v", externalIP, domain, authorisedIPs) } // checkDomainIP verifies the external IP is authorised unless diff --git a/src/host.go b/src/host.go index 828ec5c..5e489e4 100644 --- a/src/host.go +++ b/src/host.go @@ -4,6 +4,7 @@ import ( "bufio" "bytes" "compress/zlib" + "crypto/tls" "encoding/binary" "encoding/hex" "errors" @@ -160,12 +161,45 @@ var MinUploadRate float64 = 5000 var ReadBufferSize = 1600 var MaxMessageSize = uint32(1024 * 10) var SkipAuthorisedIPs = false +var TLSInsecureSkipVerify = false var DataDir = "got on startup" var Domain = "got on startup" var IDURI = "got on startup" var AtRune, _ = utf8.DecodeRuneInString("@") var MinNetIODeadline = 6 * time.Second +var serverTLSConfig *tls.Config + +func buildServerTLSConfig() *tls.Config { + certFile := os.Getenv("FMSG_TLS_CERT") + keyFile := os.Getenv("FMSG_TLS_KEY") + if certFile == "" || keyFile == "" { + log.Fatalf("ERROR: FMSG_TLS_CERT and FMSG_TLS_KEY must be set") + } + cert, err := tls.LoadX509KeyPair(certFile, keyFile) + if err != nil { + log.Fatalf("ERROR: loading TLS certificate: %s", err) + } + return &tls.Config{ + Certificates: []tls.Certificate{cert}, + MinVersion: tls.VersionTLS12, + CipherSuites: []uint16{ + tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, + tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, + }, + NextProtos: []string{"fmsg/1"}, + } +} + +func buildClientTLSConfig(serverName string) *tls.Config { + return &tls.Config{ + ServerName: serverName, + MinVersion: tls.VersionTLS12, + InsecureSkipVerify: TLSInsecureSkipVerify, + NextProtos: []string{"fmsg/1"}, + } +} + // loadEnvConfig reads env vars (after godotenv.Load so .env is picked up). func loadEnvConfig() { Port = env.GetIntDefault("FMSG_PORT", 4930) @@ -177,6 +211,7 @@ func loadEnvConfig() { ReadBufferSize = env.GetIntDefault("FMSG_READ_BUFFER_SIZE", 1600) MaxMessageSize = uint32(env.GetIntDefault("FMSG_MAX_MSG_SIZE", 1024*10)) SkipAuthorisedIPs = os.Getenv("FMSG_SKIP_AUTHORISED_IPS") == "true" + TLSInsecureSkipVerify = os.Getenv("FMSG_TLS_INSECURE_SKIP_VERIFY") == "true" } // Updates DataDir from environment, panics if not a valid directory. @@ -204,7 +239,7 @@ func setDomain() { } Domain = domain - // verify our external IP is in the _fmsg authorised IP set + // verify our external IP is in the fmsg authorised IP set checkDomainIP(domain) } @@ -518,7 +553,7 @@ func verifySenderIP(c net.Conn, senderDomain string) error { authorisedIPs, err := lookupAuthorisedIPs(senderDomain) if err != nil { - log.Printf("WARN: DNS lookup failed for _fmsg.%s: %s", senderDomain, err) + log.Printf("WARN: DNS lookup failed for fmsg.%s: %s", senderDomain, err) return fmt.Errorf("DNS verification failed") } @@ -528,7 +563,7 @@ func verifySenderIP(c net.Conn, senderDomain string) error { } } - log.Printf("WARN: remote IP %s not in authorised IPs for _fmsg.%s", remoteIP.String(), senderDomain) + log.Printf("WARN: remote IP %s not in authorised IPs for fmsg.%s", remoteIP.String(), senderDomain) return fmt.Errorf("DNS verification failed") } @@ -1031,14 +1066,14 @@ func readHeader(c net.Conn) (*FMsgHeader, *bufio.Reader, error) { // TODO [Spec step 2]: The spec defines challenge modes (NEVER, ALWAYS, // HAS_NOT_PARTICIPATED, DIFFERENT_DOMAIN) as implementation choices. // Currently defaults to ALWAYS. Implement configurable challenge mode. -func challenge(conn net.Conn, h *FMsgHeader) error { +func challenge(conn net.Conn, h *FMsgHeader, senderDomain string) error { // Connection 2 MUST target the same IP as Connection 1 (spec 2.1). remoteHost, _, err := net.SplitHostPort(conn.RemoteAddr().String()) if err != nil { return fmt.Errorf("failed to parse remote address for challenge: %w", err) } - conn2, err := net.Dial("tcp", net.JoinHostPort(remoteHost, fmt.Sprintf("%d", RemotePort))) + conn2, err := tls.Dial("tcp", net.JoinHostPort(remoteHost, fmt.Sprintf("%d", RemotePort)), buildClientTLSConfig("fmsg."+senderDomain)) if err != nil { return err } @@ -1506,7 +1541,7 @@ func handleConn(c net.Conn) { return } - if err := challenge(c, header); err != nil { + if err := challenge(c, header, determineSenderDomain(header)); err != nil { log.Printf("ERROR: Challenge failed to, %s: %s", c.RemoteAddr().String(), err) abortConn(c) return @@ -1576,6 +1611,9 @@ func main() { setDomain() setIDURL() + // load TLS configuration (must be after loadEnvConfig for FMSG_TLS_INSECURE_SKIP_VERIFY) + serverTLSConfig = buildServerTLSConfig() + // start sender in background (small delay so listener is ready first) go func() { time.Sleep(1 * time.Second) @@ -1584,7 +1622,7 @@ func main() { // start listening addr := fmt.Sprintf("%s:%d", listenAddress, Port) - ln, err := net.Listen("tcp", addr) + ln, err := tls.Listen("tcp", addr, serverTLSConfig) if err != nil { log.Fatal(err) } diff --git a/src/sender.go b/src/sender.go index 687d9d4..3c54937 100644 --- a/src/sender.go +++ b/src/sender.go @@ -1,6 +1,7 @@ package main import ( + "crypto/tls" "database/sql" "encoding/hex" "fmt" @@ -398,21 +399,23 @@ func deliverMessage(target pendingTarget) { targetIPs, err := lookupAuthorisedIPs(target.Domain) if err != nil { - log.Printf("ERROR: sender: DNS lookup for _fmsg.%s failed: %s", target.Domain, err) + log.Printf("ERROR: sender: DNS lookup for fmsg.%s failed: %s", target.Domain, err) return } var conn net.Conn + dialer := &net.Dialer{Timeout: 10 * time.Second} + tlsConf := buildClientTLSConfig("fmsg." + target.Domain) for _, ip := range targetIPs { addr := net.JoinHostPort(ip.String(), fmt.Sprintf("%d", RemotePort)) - conn, err = net.DialTimeout("tcp", addr, 10*time.Second) + conn, err = tls.DialWithDialer(dialer, "tcp", addr, tlsConf) if err == nil { break } log.Printf("WARN: sender: connect to %s failed: %s", addr, err) } if conn == nil { - log.Printf("ERROR: sender: could not connect to any IP for _fmsg.%s", target.Domain) + log.Printf("ERROR: sender: could not connect to any IP for fmsg.%s", target.Domain) return } defer conn.Close() From 61abd5debce807e6e9c4d558e7443e826f077c0e Mon Sep 17 00:00:00 2001 From: Mark Mennell Date: Tue, 14 Apr 2026 16:37:24 +0800 Subject: [PATCH 5/5] rm redundant text --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 8546a3d..0fd9882 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![Go 1.25](https://github.com/markmnl/fmsgd/actions/workflows/go1.25.yml/badge.svg)](https://github.com/markmnl/fmsgd/actions/workflows/go1.25.yml) -Implementation of [fmsg](https://github.com/markmnl/fmsg) host written in Go! Uses local filesystem and PostgreSQL database to store messages per the [fmsg-store-postgres standard](https://github.com/markmnl/fmsg/blob/main/STANDARDS.md). +Implementation of [fmsg](https://github.com/markmnl/fmsg) host written in Go! Uses local filesystem and PostgreSQL database to store messages. ## Building from source