Skip to content

Commit fa321ed

Browse files
authored
FMSG-001 TCP+TLS Standard (#17)
* implement compression * rm hyperbole * deflate vs. compress wording * TLS per FMSG-001 * rm redundant text
1 parent c06648c commit fa321ed

6 files changed

Lines changed: 68 additions & 24 deletions

File tree

‎AGENTS.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ All code MUST conform to the specification. When in doubt, re-read SPEC.md and f
1111
- Serialize and parse wire fields in the exact order defined in SPEC.md.
1212
- Use the flag bit assignments from SPEC.md (bit 0 = has pid, bit 1 = has add to, bit 2 = common type, etc.).im
1313
- Reject/accept response codes must match SPEC.md — do not invent new codes.
14-
- Resolve `_fmsg.<domain>` using A/AAAA records only (never TXT, MX, or SRV).
14+
- Resolve `fmsg.<domain>` using A/AAAA records only (never TXT, MX, or SRV).
1515
- Validate sender IP before issuing CHALLENGE.
1616
- Connection 2 for challenge MUST target the same IP as Connection 1.
1717
- Topic field is only present on the wire when pid is absent.

‎README.md‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
[![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)
44

55

6-
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).
6+
Implementation of [fmsg](https://github.com/markmnl/fmsg) host written in Go! Uses local filesystem and PostgreSQL database to store messages.
77

88
## Building from source
99

@@ -16,13 +16,16 @@ Tested with Go 1.25 on Linux and Windows, AMD64 and ARM
1616

1717
## Environment
1818

19-
`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
19+
`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
2020

2121
| Variable | Default | Description |
2222
|----------------------------|---------|---------------------------------------------------------------------------------------------------------------------------------------------------------|
2323
| FMSG_DATA_DIR | | Path where messages will be stored. e.g. /opt/fmsg/data |
2424
| FMSG_DOMAIN | | Domain name this host is located. e.g. example.com |
2525
| FMSG_ID_URL | | Base HTTP URL for fmsg Id API, e.g. http://localhost:5000 |
26+
| FMSG_TLS_CERT | | Path to TLS certificate file (PEM). Certificate must match `fmsg.<FMSG_DOMAIN>`. |
27+
| FMSG_TLS_KEY | | Path to TLS private key file (PEM). |
28+
| FMSG_TLS_INSECURE_SKIP_VERIFY | false | Set to "true" to skip TLS certificate verification on outgoing connections. For development/testing only. |
2629
| FMSG_MAX_MSG_SIZE | 10240 | Bytes. Maximum size above which to reject messages greater than before downloading them. |
2730
| FMSG_PORT | 4930 | TCP port to listen on |
2831
| 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
3437
| FMSG_RETRY_MAX_AGE | 86400 | Seconds. Maximum age of a message since creation before giving up on delivery retries (default 1 day). |
3538
| FMSG_POLL_INTERVAL | 10 | Seconds. How often the sender polls the database for pending messages. |
3639
| FMSG_MAX_CONCURRENT_SEND | 1024 | Maximum number of concurrent outbound message deliveries. |
37-
| 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. |
38-
| 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. |
40+
| 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. |
41+
| 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. |
3942

4043

4144

‎SPEC.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ Per-recipient codes (one byte per recipient on this host, in message order):
153153

154154
## 9. Domain Resolution
155155

156-
Resolve `_fmsg.<domain>` for A/AAAA records. The sender's domain is:
156+
Resolve `fmsg.<domain>` for A/AAAA records. The sender's domain is:
157157
- The domain of _add to from_ when _has add to_ is set.
158158
- The domain of _from_ otherwise.
159159

@@ -175,7 +175,7 @@ One message per connection. Two TCP connections used: Connection 1 (message tran
175175

176176
Host A delivers iff _from_ or _add to from_ belongs to Host A's domain. For each unique recipient domain:
177177

178-
1. Resolve recipient domain IPs via `_fmsg.<domain>`. Connect to first responsive IP (Connection 1). Retry with backoff if unreachable.
178+
1. Resolve recipient domain IPs via `fmsg.<domain>`. Connect to first responsive IP (Connection 1). Retry with backoff if unreachable.
179179
2. Register the message header hash and Host B's IP in an outgoing record (for matching challenges).
180180
3. Transmit the message header on Connection 1.
181181
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
200200
- If _has add to_: _add to from_ exists and is in _from_ or _to_; _add to_ has ≥ 1 distinct address.
201201
- ≥ 1 recipient in _to_ or _add to_ belongs to Host B's domain.
202202
- Common type IDs (message and attachment) are mapped.
203-
4. DNS-verify sender IP: resolve `_fmsg.<sender domain>`, check Connection 1 source IP is in result set. Fail → TERMINATE.
203+
4. DNS-verify sender IP: resolve `fmsg.<sender domain>`, check Connection 1 source IP is in result set. Fail → TERMINATE.
204204
5. If _size_ + attachment sizes > MAX_SIZE → respond code 4, close.
205205
6. Compute DELTA = now − _time_:
206206
- DELTA > MAX_MESSAGE_AGE → respond code 7, close.

‎src/dns.go‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -53,9 +53,9 @@ func resolverAuthenticatedData(name string, qtype uint16) (bool, error) {
5353
return false, lastErr
5454
}
5555

56-
// lookupAuthorisedIPs resolves _fmsg.<domain> for A and AAAA records
56+
// lookupAuthorisedIPs resolves fmsg.<domain> for A and AAAA records
5757
func lookupAuthorisedIPs(domain string) ([]net.IP, error) {
58-
fmsgDomain := "_fmsg." + domain
58+
fmsgDomain := "fmsg." + domain
5959
ips, err := net.LookupIP(fmsgDomain)
6060
if err != nil {
6161
return nil, fmt.Errorf("DNS lookup for %s failed: %w", fmsgDomain, err)
@@ -114,7 +114,7 @@ func getExternalIP() (net.IP, error) {
114114
}
115115

116116
// verifyDomainIP checks that this host's external IP is present in the
117-
// _fmsg.<domain> authorised IP set. Panics if not found.
117+
// fmsg.<domain> authorised IP set. Panics if not found.
118118
func verifyDomainIP(domain string) {
119119
externalIP, err := getExternalIP()
120120
if err != nil {
@@ -124,17 +124,17 @@ func verifyDomainIP(domain string) {
124124

125125
authorisedIPs, err := lookupAuthorisedIPs(domain)
126126
if err != nil {
127-
log.Panicf("ERROR: failed to lookup _fmsg.%s: %s", domain, err)
127+
log.Panicf("ERROR: failed to lookup fmsg.%s: %s", domain, err)
128128
}
129129

130130
for _, ip := range authorisedIPs {
131131
if externalIP.Equal(ip) {
132-
log.Printf("INFO: external IP %s found in _fmsg.%s authorised IPs", externalIP, domain)
132+
log.Printf("INFO: external IP %s found in fmsg.%s authorised IPs", externalIP, domain)
133133
return
134134
}
135135
}
136136

137-
log.Panicf("ERROR: external IP %s not found in _fmsg.%s authorised IPs %v", externalIP, domain, authorisedIPs)
137+
log.Panicf("ERROR: external IP %s not found in fmsg.%s authorised IPs %v", externalIP, domain, authorisedIPs)
138138
}
139139

140140
// checkDomainIP verifies the external IP is authorised unless

‎src/host.go‎

Lines changed: 45 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"bufio"
55
"bytes"
66
"compress/zlib"
7+
"crypto/tls"
78
"encoding/binary"
89
"encoding/hex"
910
"errors"
@@ -160,12 +161,45 @@ var MinUploadRate float64 = 5000
160161
var ReadBufferSize = 1600
161162
var MaxMessageSize = uint32(1024 * 10)
162163
var SkipAuthorisedIPs = false
164+
var TLSInsecureSkipVerify = false
163165
var DataDir = "got on startup"
164166
var Domain = "got on startup"
165167
var IDURI = "got on startup"
166168
var AtRune, _ = utf8.DecodeRuneInString("@")
167169
var MinNetIODeadline = 6 * time.Second
168170

171+
var serverTLSConfig *tls.Config
172+
173+
func buildServerTLSConfig() *tls.Config {
174+
certFile := os.Getenv("FMSG_TLS_CERT")
175+
keyFile := os.Getenv("FMSG_TLS_KEY")
176+
if certFile == "" || keyFile == "" {
177+
log.Fatalf("ERROR: FMSG_TLS_CERT and FMSG_TLS_KEY must be set")
178+
}
179+
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
180+
if err != nil {
181+
log.Fatalf("ERROR: loading TLS certificate: %s", err)
182+
}
183+
return &tls.Config{
184+
Certificates: []tls.Certificate{cert},
185+
MinVersion: tls.VersionTLS12,
186+
CipherSuites: []uint16{
187+
tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
188+
tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
189+
},
190+
NextProtos: []string{"fmsg/1"},
191+
}
192+
}
193+
194+
func buildClientTLSConfig(serverName string) *tls.Config {
195+
return &tls.Config{
196+
ServerName: serverName,
197+
MinVersion: tls.VersionTLS12,
198+
InsecureSkipVerify: TLSInsecureSkipVerify,
199+
NextProtos: []string{"fmsg/1"},
200+
}
201+
}
202+
169203
// loadEnvConfig reads env vars (after godotenv.Load so .env is picked up).
170204
func loadEnvConfig() {
171205
Port = env.GetIntDefault("FMSG_PORT", 4930)
@@ -177,6 +211,7 @@ func loadEnvConfig() {
177211
ReadBufferSize = env.GetIntDefault("FMSG_READ_BUFFER_SIZE", 1600)
178212
MaxMessageSize = uint32(env.GetIntDefault("FMSG_MAX_MSG_SIZE", 1024*10))
179213
SkipAuthorisedIPs = os.Getenv("FMSG_SKIP_AUTHORISED_IPS") == "true"
214+
TLSInsecureSkipVerify = os.Getenv("FMSG_TLS_INSECURE_SKIP_VERIFY") == "true"
180215
}
181216

182217
// Updates DataDir from environment, panics if not a valid directory.
@@ -204,7 +239,7 @@ func setDomain() {
204239
}
205240
Domain = domain
206241

207-
// verify our external IP is in the _fmsg authorised IP set
242+
// verify our external IP is in the fmsg authorised IP set
208243
checkDomainIP(domain)
209244
}
210245

@@ -518,7 +553,7 @@ func verifySenderIP(c net.Conn, senderDomain string) error {
518553

519554
authorisedIPs, err := lookupAuthorisedIPs(senderDomain)
520555
if err != nil {
521-
log.Printf("WARN: DNS lookup failed for _fmsg.%s: %s", senderDomain, err)
556+
log.Printf("WARN: DNS lookup failed for fmsg.%s: %s", senderDomain, err)
522557
return fmt.Errorf("DNS verification failed")
523558
}
524559

@@ -528,7 +563,7 @@ func verifySenderIP(c net.Conn, senderDomain string) error {
528563
}
529564
}
530565

531-
log.Printf("WARN: remote IP %s not in authorised IPs for _fmsg.%s", remoteIP.String(), senderDomain)
566+
log.Printf("WARN: remote IP %s not in authorised IPs for fmsg.%s", remoteIP.String(), senderDomain)
532567
return fmt.Errorf("DNS verification failed")
533568
}
534569

@@ -1031,14 +1066,14 @@ func readHeader(c net.Conn) (*FMsgHeader, *bufio.Reader, error) {
10311066
// TODO [Spec step 2]: The spec defines challenge modes (NEVER, ALWAYS,
10321067
// HAS_NOT_PARTICIPATED, DIFFERENT_DOMAIN) as implementation choices.
10331068
// Currently defaults to ALWAYS. Implement configurable challenge mode.
1034-
func challenge(conn net.Conn, h *FMsgHeader) error {
1069+
func challenge(conn net.Conn, h *FMsgHeader, senderDomain string) error {
10351070

10361071
// Connection 2 MUST target the same IP as Connection 1 (spec 2.1).
10371072
remoteHost, _, err := net.SplitHostPort(conn.RemoteAddr().String())
10381073
if err != nil {
10391074
return fmt.Errorf("failed to parse remote address for challenge: %w", err)
10401075
}
1041-
conn2, err := net.Dial("tcp", net.JoinHostPort(remoteHost, fmt.Sprintf("%d", RemotePort)))
1076+
conn2, err := tls.Dial("tcp", net.JoinHostPort(remoteHost, fmt.Sprintf("%d", RemotePort)), buildClientTLSConfig("fmsg."+senderDomain))
10421077
if err != nil {
10431078
return err
10441079
}
@@ -1506,7 +1541,7 @@ func handleConn(c net.Conn) {
15061541
return
15071542
}
15081543

1509-
if err := challenge(c, header); err != nil {
1544+
if err := challenge(c, header, determineSenderDomain(header)); err != nil {
15101545
log.Printf("ERROR: Challenge failed to, %s: %s", c.RemoteAddr().String(), err)
15111546
abortConn(c)
15121547
return
@@ -1576,6 +1611,9 @@ func main() {
15761611
setDomain()
15771612
setIDURL()
15781613

1614+
// load TLS configuration (must be after loadEnvConfig for FMSG_TLS_INSECURE_SKIP_VERIFY)
1615+
serverTLSConfig = buildServerTLSConfig()
1616+
15791617
// start sender in background (small delay so listener is ready first)
15801618
go func() {
15811619
time.Sleep(1 * time.Second)
@@ -1584,7 +1622,7 @@ func main() {
15841622

15851623
// start listening
15861624
addr := fmt.Sprintf("%s:%d", listenAddress, Port)
1587-
ln, err := net.Listen("tcp", addr)
1625+
ln, err := tls.Listen("tcp", addr, serverTLSConfig)
15881626
if err != nil {
15891627
log.Fatal(err)
15901628
}

‎src/sender.go‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package main
22

33
import (
4+
"crypto/tls"
45
"database/sql"
56
"encoding/hex"
67
"fmt"
@@ -398,21 +399,23 @@ func deliverMessage(target pendingTarget) {
398399

399400
targetIPs, err := lookupAuthorisedIPs(target.Domain)
400401
if err != nil {
401-
log.Printf("ERROR: sender: DNS lookup for _fmsg.%s failed: %s", target.Domain, err)
402+
log.Printf("ERROR: sender: DNS lookup for fmsg.%s failed: %s", target.Domain, err)
402403
return
403404
}
404405

405406
var conn net.Conn
407+
dialer := &net.Dialer{Timeout: 10 * time.Second}
408+
tlsConf := buildClientTLSConfig("fmsg." + target.Domain)
406409
for _, ip := range targetIPs {
407410
addr := net.JoinHostPort(ip.String(), fmt.Sprintf("%d", RemotePort))
408-
conn, err = net.DialTimeout("tcp", addr, 10*time.Second)
411+
conn, err = tls.DialWithDialer(dialer, "tcp", addr, tlsConf)
409412
if err == nil {
410413
break
411414
}
412415
log.Printf("WARN: sender: connect to %s failed: %s", addr, err)
413416
}
414417
if conn == nil {
415-
log.Printf("ERROR: sender: could not connect to any IP for _fmsg.%s", target.Domain)
418+
log.Printf("ERROR: sender: could not connect to any IP for fmsg.%s", target.Domain)
416419
return
417420
}
418421
defer conn.Close()

0 commit comments

Comments
 (0)