Skip to content

Fake Server

go-while edited this page Oct 23, 2026 · 3 revisions

Fake Server

Package fakeserver (doc.go). A server listens on 127.0.0.1:0 unless Options.Addr names another address.

s := fakeserver.New(t, fakeserver.Options{...}) // closed when the test ends
s, err := fakeserver.Start(fakeserver.Options{...}) // without testing; call s.Close()

Three modes

Script

Options.Script is a list of steps. Every connection runs them in order, after the greeting. Expect is an anchored regular expression; "" matches any line.

s := fakeserver.New(t, fakeserver.Options{
	Script: []fakeserver.Step{
		{Expect: `MODE READER`, Reply: []string{"200 reader"}},
		{Expect: `GROUP \S+`, Reply: []string{"211 3 1 3 alt.test"}},
		{Expect: `XOVER .*`, Hang: true}, // never answers
	},
})
Step field Effect
Expect anchored regexp for the client line
ReadDot after the match, read a dot-terminated block (a POST or TAKETHIS payload)
Delay wait before the reply
Reply the reply lines (CRLF is added)
Hang never reply again; the connection is held until the server closes
CloseAfter close the connection after the reply
Compress after the reply (e.g. 206 Compression active), compress the connection (COMPRESS DEFLATE)

A line that doesn't match the current step gets 500 fakeserver: unexpected <line> and goes into s.Mismatches(). After the last step the server keeps reading and recording lines until the client closes.

Handler

Options.Handler gets each connection and does what it wants. The connection is closed when the function returns.

s := fakeserver.New(t, fakeserver.Options{Handler: func(c *fakeserver.Conn) {
	for {
		line, err := c.ReadLine()
		if err != nil {
			return
		}
		if strings.EqualFold(line, "QUIT") {
			c.WriteLines("205 bye")
			return
		}
		c.WriteLines("500 what?")
	}
}})

Conn has ReadLine, WriteLines, ReadDotBlock, Raw (the net.Conn), Server and ID (the accept order, from 1), and StartCompress, StartCompressOutputOnly and Compressed (see COMPRESS DEFLATE). Faults don't apply to your own handler.

Store

Options.Store serves a Store of groups and articles with a built-in RFC 3977 reader. Options.StoreOpts changes how it behaves. See Store and Groups and NNTP Commands.

Which mode runs

  • RawStall wins over everything: the server accepts TCP and never writes (a TLS handshake or SOCKS stall).
  • Otherwise the greeting is written, unless NoGreeting is set. Then Handler runs when set, else Store, else Script.
  • With NoGreeting and no Handler, neither Store nor Script runs: lines are only recorded (a greeting stall).

Options

Field Effect
Addr listen address, default 127.0.0.1:0
Greeting default 200 fakeserver ready (201 ... for a ReaderOnly store)
NoGreeting, RawStall stalls, see above
TLS a *tls.Config: TLS on every connection
Script, Handler, Store, StoreOpts the mode
Faults see Faults
Deadline bounds waits and delays; 0 = the hang guard (see Getting Started)
MaxConns more than this many open connections: the next one gets MaxConnsGreeting and is closed
MaxConnsGreeting default 502 Too many connections

What the server records

Method Returns
Addr, Host, Port the listen address
Lines() every client line, in order, all connections
Articles() every dot block read (POST and TAKETHIS payloads, ReadDot steps), raw wire lines
Mismatches() Script mode: lines no step matched
Count(cmd), Counts() how often each command was seen (the upper-cased first word of each line)
Accepted(), Open() connections accepted so far, and still open
WaitOpen(t, n, d) wait until Open() == n
WaitLines(t, want) wait until Lines() equals want

Lines inside a dot block (an article) are not counted as commands.

TLS

TLSConfig makes a self-signed certificate for 127.0.0.1 and localhost, and a pool that trusts it:

srvConf, roots := fakeserver.TLSConfig(t)
s := fakeserver.New(t, fakeserver.Options{Store: st, TLS: srvConf})

conn, err := tls.Dial("tcp", s.Addr(), &tls.Config{RootCAs: roots})
if err != nil {
	t.Fatal(err)
}
c := client.New(t, conn)
if _, err := c.ReadGreeting(); err != nil {
	t.Fatal(err)
}

For a server under test that should reject the certificate, don't give it the pool. The kit has no STARTTLS.

COMPRESS DEFLATE

RFC 8054 compression, opt-in:

Where How
Store server StoreOpts.Compress: true
Streaming peer PeerOptions.Compress: true
Script a step with Reply: []string{"206 Compression active"}, Compress: true
Your Handler write the 206, then call c.StartCompress()

From the end of the 206 line on, both directions are compressed. Every write (each reply, every fault, c.Raw().Write) is deflated and sync-flushed. The client's stream is inflated as it arrives, whatever flush the client uses, so Lines(), Counts(), Articles() and Mismatches() record the commands as the client wrote them. Corrupt compressed input closes the connection, as the RFC says. All Faults work on a compressed connection.

The Store server and the peer:

Command Reply
CAPABILITIES lists COMPRESS DEFLATE while compression is off (after the login, when one is required), not once it is on
COMPRESS DEFLATE 206 Compression active
COMPRESS without or with a bad algorithm name (names are upper case, so deflate is bad) 501
COMPRESS SHRINK (another algorithm) 503 Compression algorithm not supported
COMPRESS with any valid name while compression is on 502 Compression already active
COMPRESS before a required login 480
AUTHINFO once compression is on 502

The Store server starts compression when the status line it wrote for COMPRESS, after the faults, is a 206. So Faults.Override "COMPRESS": {"403 Unable to activate compression"} is a server that cannot compress, and {} one that never answers.

A half implementation

StoreOpts.CompressOutputOnly (or PeerOptions.CompressOutputOnly, or c.StartCompressOutputOnly() in a Handler) offers COMPRESS, answers 206 and compresses what it writes, but reads the client's bytes without inflating them, as a server that only ever compresses its responses. A client that compresses its commands, as RFC 8054 requires, gets no answer (its compressed bytes rarely hold a line end) or 500 for a binary line; the strict client shows that as a timeout or a 500 to its command.

Bytes a client pipelines behind COMPRESS DEFLATE (the RFC forbids that) are inflated too, so plain text there is corrupt input and closes the connection. INN's nnrpd reads such bytes as plain commands instead.

Authentication

  • Store mode: StoreOpts.User and Pass require AUTHINFO USER/PASS; every other command but CAPABILITIES, MODE, HELP, DATE and QUIT gets 480 before it. A 480 written later ends the login, like a login that timed out (see Faults).
  • Script mode: script the AUTHINFO lines like any others.
  • Streaming peer: PeerOptions.User and Pass. CHECK, TAKETHIS and IHAVE get 480 before the login (TAKETHIS after its article, IHAVE at once, without a 335).

Streaming peer

PeerHandler answers like a transit peer that is fed articles, as innd is: CAPABILITIES (VERSION 2, IHAVE, READER, STREAMING), MODE STREAM, MODE READER, AUTHINFO, CHECK and TAKETHIS (RFC 4644), IHAVE (RFC 3977 6.3.2) and QUIT. Anything else gets 500.

peer := &fakeserver.PeerOptions{
	Have:         map[string]bool{"<old@example.invalid>": true},
	Defer431Once: map[string]bool{"<busy@example.invalid>": true},
}
s := fakeserver.New(t, fakeserver.Options{Handler: fakeserver.PeerHandler(peer)})
c := client.Dial(t, s.Addr())
c.Cmd("MODE STREAM")                 // 203
c.Cmd("CHECK <old@example.invalid>")  // 438
c.Cmd("CHECK <busy@example.invalid>") // 431, then 238 the next time
PeerOptions field Effect
User, Pass require AUTHINFO; CHECK, TAKETHIS and IHAVE get 480 before it
Have message-IDs the peer has (CHECK 438, TAKETHIS 439, IHAVE 435); a TAKETHIS that gets 239 or an IHAVE that gets 235 adds its ID
Defer431Once CHECK of these IDs gets 431 the first time
Defer436Once IHAVE of these IDs gets 436 the first time, as the first reply: no article is sent
Reject these articles are refused once they are read, every time, as a content filter does: CHECK 238, TAKETHIS 439, IHAVE 335 then 437. They are not added to Have and OnArticle is not called
NoStream a peer without streaming: MODE STREAM gets 501, CHECK and TAKETHIS 500, CAPABILITIES has no STREAMING
StallModeStream wait this long before the first MODE STREAM reply
OnArticle called with each accepted article (raw, still dot-stuffed lines), before the 239 or 235
Compress, CompressOutputOnly COMPRESS DEFLATE as in the Store server (see COMPRESS DEFLATE); AUTHINFO gets 502 once it is on. innd has no COMPRESS, INN's nnrpd has

The handler keeps Have and the 431 and 436 deferrals across the connections of a server. Read Have from the test only after s.Close().

IHAVE

IHAVE works with and without MODE STREAM, the way innd answers it:

Situation Reply
not logged in 480, at once (the client sends no article)
the ID is not <...> without spaces 435 (innd's answer; RFC 3977 would allow 501)
the peer has the ID 435 Duplicate
the ID is in Defer436Once, first offer 436 Retry later
otherwise 335 Send it; after the article 235, or 437 when the ID is in Reject (or another connection delivered it meanwhile)

innd sends 436 before the transfer when it is paused or the article is arriving on another connection, and 437 only after it has read the article; the peer does the same. Every IHAVE article is recorded in s.Articles().

peer := &fakeserver.PeerOptions{
	Have:   map[string]bool{"<old@example.invalid>": true},
	Reject: map[string]bool{"<spam@example.invalid>": true},
}
s := fakeserver.New(t, fakeserver.Options{Handler: fakeserver.PeerHandler(peer)})
c := client.Dial(t, s.Addr())
c.Cmd("IHAVE <old@example.invalid>")  // 435
c.Cmd("IHAVE <new@example.invalid>")  // 335
c.Send("Message-ID: <new@example.invalid>\r\n\r\nbody\r\n.\r\n")
c.ReadStatus()                        // 235

A peer without streaming

innfeed and innxmit send MODE STREAM first and fall back to IHAVE when the peer refuses it. NoStream: true makes the peer refuse it as innd with streaming turned off does: MODE STREAM gets 501 Unknown MODE variant, CAPABILITIES lists no STREAMING, and CHECK and TAKETHIS are unknown commands (500). The peer doesn't read an article after such a TAKETHIS: its lines are read as commands and each gets 500, as on innd. IHAVE works as usual.

Clone this wiki locally