Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions pkg/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -542,8 +542,14 @@ func (s *Server) relay(ctx context.Context, index uint32, downConn net.Conn) err

targets, ok := s.getTargetsForModule(moduleName)
if !ok {
_, _ = writeWithTimeout(downConn, fmt.Appendf(nil, "unknown module: %s\n", moduleName), writeTimeout)
_, _ = writeWithTimeout(downConn, RsyncdExit, writeTimeout)
// Use the rsyncd "@ERROR:" wire format so that the rsync
// client treats this as a fatal protocol error and exits with
// a non-zero status (RERR_FERROR_XFER, exit 5), matching the
// behavior of a real rsyncd. Sending only "unknown module:
// ...\n" followed by "@RSYNCD: EXIT" caused the client to
// exit 0, which masked the failure for downstream tools such
// as tunasync (which then marked the job as success).
_, _ = writeWithTimeout(downConn, fmt.Appendf(nil, "@ERROR: Unknown module '%s'\n", moduleName), writeTimeout)
s.accessLog.F("client %s requests non-existing module %s", ip, moduleName)
return nil
}
Expand Down
32 changes: 32 additions & 0 deletions pkg/server/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,38 @@ func TestMotdFromServer(t *testing.T) {
r.Equal(proxyMotd+"\n"+serverMotd, string(allData))
}

// TestUnknownModuleSendsErrorPrefix verifies that requesting a module that
// the proxy is not configured to serve makes the proxy reply with an
// "@ERROR:" line, matching real rsyncd's wire format. The rsync client
// treats an "@ERROR:" reply as a fatal protocol error and exits 5,
// while a plain message followed by "@RSYNCD: EXIT" causes the client
// to exit 0, which historically masked configuration breakage in tools
// such as tunasync.
func TestUnknownModuleSendsErrorPrefix(t *testing.T) {
srv := startServer(t)
defer srv.Close()

srv.modules = map[string][]Target{}
srv.upstreamQueues = map[string]*queue.Queue{}

r := require.New(t)

rawConn, err := net.Dial("tcp", srv.TCPListener.Addr().String())
r.NoError(err)
conn := rsync.NewConn(rawConn)
defer conn.Close()

_, err = doClientHandshake(conn, RsyncdServerVersion, "does-not-exist")
r.NoError(err)

allData, err := io.ReadAll(conn)
r.NoError(err)

r.Contains(string(allData), "@ERROR:", "proxy should reply with @ERROR: prefix so client exits non-zero")
r.Contains(string(allData), "does-not-exist")
r.NotContains(string(allData), "@RSYNCD: EXIT", "should not send EXIT after @ERROR; rsync client must treat the response as fatal")
}

// See also: https://github.com/ustclug/rsync-proxy/commit/d581c18dab8008c5bc9c1a5d667b49d67a4edfed
func TestClientReadTimeout(t *testing.T) {
srv := startServer(t)
Expand Down
25 changes: 25 additions & 0 deletions test/e2e/e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"net"
"os"
"os/exec"
"path/filepath"
"testing"

Expand Down Expand Up @@ -247,3 +248,27 @@ func TestDiscoverModulesFromUpstream(t *testing.T) {

r.Equal("bar\nbaz\nfoo\n", string(outputBytes))
}

// TestUnknownModuleExitCode verifies that a real rsync client exits with
// a non-zero status when it requests a module that the proxy does not
// know about. This is a regression test for an earlier behavior where
// the proxy emitted "unknown module: <name>\n@RSYNCD: EXIT\n", which
// rsync interprets as a normal end-of-listing (exit 0). The fix makes
// the proxy emit "@ERROR:" instead, matching real rsyncd; rsync then
// treats it as a fatal protocol error and exits with code 5
// (RERR_FERROR_XFER).
func TestUnknownModuleExitCode(t *testing.T) {
proxy := startProxy(t)

r := require.New(t)

cmd := newRsyncCommand(getRsyncPath(proxy, "/does-not-exist/"))
out, err := cmd.CombinedOutput()
r.Error(err, "rsync should fail when module does not exist; got output: %s", out)

exitErr, ok := err.(*exec.ExitError)
r.True(ok, "expected *exec.ExitError, got %T: %v", err, err)
r.NotEqual(0, exitErr.ExitCode(), "rsync should exit non-zero on unknown module; output: %s", out)
r.Contains(string(out), "@ERROR", "rsync should report the @ERROR line; output: %s", out)
r.Contains(string(out), "does-not-exist", "rsync should report the module name; output: %s", out)
}
Loading