diff --git a/.golangci.yaml b/.golangci.yaml index 9dc5736..bc72799 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -15,6 +15,19 @@ linters: - misspell - unconvert - gocritic + exclusions: + rules: + # golang.org/x/crypto/md4 is deprecated for general security use, + # but internal/daemon/auth.go uses it deliberately: real rsync's own + # daemon authentication protocol is defined in terms of classic + # MD4, and grsync's client and server can't interoperate with real + # rsync (or with each other, if this were "fixed") without it. This + # is the one place in the codebase that import is expected to + # appear. + - path: internal/daemon/auth\.go + linters: + - staticcheck + text: "SA1019.*md4" formatters: enable: diff --git a/README.md b/README.md index 6f36114..6d673c6 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,14 @@ out of scope (compression, progress reporting, `--dry-run`, partial/ append transfers, batch mode, full `--delete`, hard links, and device/ special files) - this is real, working sync, not yet full feature parity. +`grsync --daemon` also now speaks a real subset of the rsync daemon +protocol - `rsyncd.conf` parsing, the `@RSYNCD` greeting/handshake, module +listing, and real MD4 challenge-response authentication all match +upstream rsync, verified against its actual source rather than assumed. +See [rsync Daemon Mode](#rsync-daemon-mode) below for exactly what that +covers and where it hands off to grsync's own (non-rsync-wire-format) +transfer protocol. + ## Build ```sh @@ -57,6 +65,9 @@ argument is always the destination. | `--owner` | `-o` | preserve owner (implied by `--archive`; requires appropriate privileges) | | `--group` | `-g` | preserve group (implied by `--archive`; requires appropriate privileges) | | `--links` | `-l` | recreate symlinks as symlinks (implied by `--archive`) | +| `--daemon` | | run as an rsync-protocol daemon, serving modules from `--config` (see [rsync Daemon Mode](#rsync-daemon-mode)) | +| `--config PATH` | | path to the `rsyncd.conf` to serve (required with `--daemon`) | +| `--port PORT` | | TCP port to listen on in `--daemon` mode (default `873`, matching rsync) | All five filter-related flags share one ordered rule list - their relative order on the command line is preserved, matching rsync's first-match-wins @@ -293,6 +304,141 @@ particularly, since unlike device files it needs no elevated privilege) but was left out of this already-large integration ticket rather than expanding its scope further. +## rsync Daemon Mode + +`internal/daemon` implements grsync's `--daemon` server mode: a second way +to *reach* a grsync instance, over a plain TCP port instead of SSH, +speaking a real subset of upstream rsync's own daemon protocol rather +than an invented one. Every wire-format detail below (the `rsyncd.conf` +syntax, the `@RSYNCD` greeting, the MD4 authentication algorithm) was +checked against upstream rsync's actual source (`authenticate.c`, +`clientserver.c`) while building this, not reconstructed from memory or +the man page alone. + +```sh +grsync --daemon --config rsyncd.conf --port 8730 +``` + +### `rsyncd.conf` + +`daemon.ParseConfig` reads real `rsyncd.conf` syntax: `[module]` sections, +`name = value` parameters, `#` comments, blank lines, and trailing-`\` +line continuation. Parameters that appear before any `[module]` header +become that module's starting defaults, matching real rsync's own global +section. + +Supported parameters: + +| Parameter | Default | Meaning | +|---|---|---| +| `path` | *(required)* | directory the module exposes | +| `comment` | *(empty)* | shown next to the module name in a listing | +| `read only` | `true` | rejects uploads (`put`) to this module | +| `list` | `true` | hides the module from a `#list` request - it is *not* made unreachable; a client who already knows its name can still select it directly, matching real rsync's own documented behavior | +| `exclude` | *(none)* | space-separated patterns hidden from downloads, compiled via the same `sync.CompileRules`/`sync.Included` machinery `--exclude` uses | +| `auth users` | *(none)* | comma/space-separated usernames; a non-empty list means the module requires authentication | +| `secrets file` | *(none)* | path to a `name:password` per-line file | +| `max connections` | `0` (unlimited) | **parsed but not yet enforced** - see Scope boundaries below | + +An unrecognized parameter (real rsyncd.conf has dozens grsync doesn't +implement - `uid`, `hosts allow`, `log file`, `timeout`, and more) is +silently accepted, not an error: rejecting an otherwise-valid config file +over one unimplemented option would be worse than ignoring that line. A +line that isn't valid `name = value` or `[section]` syntax at all, or a +recognized parameter with a malformed value, is a hard parse error. + +### `rsync://` URLs + +`daemon.ParseURL` parses `rsync://[user@]host[:port]/module[/path]`, +including the bare `rsync://host` and `rsync://host/` forms real rsync +uses to mean "list this daemon's modules" rather than selecting one, and +IPv6 literals. This parser is implemented and tested, but **not yet wired +into the main `grsync SRC... DEST` sync command** - today it's only used +internally (and by `internal/daemon`'s own tests) to build a connection by +hand. Making `rsync://...` a valid source/destination argument the same +way an SSH `user@host:path` already is would be a natural, low-risk +follow-up. + +### Handshake and authentication + +The connection sequence matches real rsync: the daemon speaks first +(`@RSYNCD: 31.0`), the client replies with its own greeting, then sends +either `#list` (module listing) or a module name. An unknown module gets +an `@ERROR` line; a `list = false` module is skipped by `#list` but still +selectable by name. + +If the selected module has `auth users` configured, the daemon sends +`@RSYNCD: AUTHREQD ` with a fresh random challenge; the client +answers ` ` where +`response = base64_no_padding(MD4(secret + challenge))` - secret hashed +first, then challenge, no seed byte, exactly matching real rsync's +`generate_hash()`. **The password itself never crosses the wire, only +this one-way hash of it** - `TestAuth_NoPlaintextPasswordOnWire` in +`internal/daemon/auth_test.go` proves this by capturing and inspecting +the actual bytes each side sends, not just checking the outcome. +Comparison on the server side uses `crypto/subtle` for constant-time +comparison, and every authentication failure (unknown user, wrong +password, unreadable secrets file) returns the same generic +`@ERROR: auth failed on module `, matching real rsync's refusal to +let a client distinguish those cases. + +**Scoped down from real rsync, deliberately:** +- **Classic MD4 only** - real rsync protocol 30+ negotiates MD4 vs MD5 + via a digest list in the greeting line; grsync always uses MD4 and + ignores any digest list it receives. Digest negotiation is real, + unimplemented scope, not a bug. +- **Exact-match `auth users` only** - real rsync supports wildcards and + `@group` entries in this list; grsync matches usernames literally. +- **The secrets file's own permissions are never checked** - real rsync's + default "strict modes" refuses a world- or group-readable secrets file. + grsync reads whatever `secrets file` points to regardless of its + permissions. Given this project's cross-platform (including Windows) + scope, where POSIX permission bits don't map cleanly, this was left + out rather than half-implemented; worth a follow-up on POSIX platforms. + +### Access control and transfer + +Once authenticated, the client sends `get` (download) or `put` (upload). +`put` against a `read only` module is refused - with an `@ERROR` line and +without either side ever committing to the transfer protocol - before any +file ever moves. `get` walks and filters the module's `path` through the +module's `exclude` patterns before sending, exactly like `--exclude` +elsewhere in grsync. + +**`exclude` is enforced on downloads only.** That's where the daemon +itself walks and filters the directory it's about to send from, the same +mechanism `--exclude` already uses; `pipeline.Receiver` has no per-entry +filtering hook, so an upload to a non-read-only module is not currently +filtered against the module's `exclude` list. A deliberate, documented +boundary, not an oversight. + +After a module is selected (and authenticated, if required), this package +hands the connection straight to `pipeline.Sender`/`pipeline.Receiver` - +the same functions the SSH transport uses. **The handshake and +authentication above are real-rsync-protocol-shaped; the transfer that +follows is not.** Like the SSH transport (see +[End-to-End Sync Pipeline](#end-to-end-sync-pipeline)), it's +`encoding/gob`, not upstream rsync's actual binary wire format - so a +grsync daemon interoperates with another grsync client, not with a real +`rsync` binary, exactly the same boundary that already exists for SSH. + +**Other scope boundaries:** +- **`max connections` is parsed but not enforced** - nothing currently + caps concurrent connections to a module at that number. +- One connection's panic can't take the daemon process down: `Serve` + recovers per-connection, so a bug anywhere in the handshake/auth/ + transfer chain stays scoped to that one client instead of killing every + other in-flight connection. +- Every line read before a transfer begins (greeting, module selection, + auth response) is capped at 8 KiB, so an unauthenticated client can't + force unbounded memory growth by sending data with no newline. + +Tested with `TestDaemon_RealTCP_*` in `internal/daemon/server_test.go` +over an actual loopback TCP connection - listen, dial, full handshake, +auth, and transfer - not just in-memory pipes standing in for a +connection, since (unlike the SSH tests) nothing external is needed to +exercise this end to end. + ## Architecture - `cmd/grsync` - CLI entrypoint. @@ -308,6 +454,11 @@ expanding its scope further. - `internal/transport` - remote endpoint parsing, RSH command construction, frame protocol, subprocess session management, and the `--server` handshake. +- `internal/daemon` - the rsync daemon protocol: `rsyncd.conf` and + `rsync://` URL parsing, the `@RSYNCD` greeting/handshake and module + listing, MD4 challenge-response authentication, and per-module access + control, handing off to `internal/pipeline` for the actual transfer. + See [rsync Daemon Mode](#rsync-daemon-mode) above. Goal: full feature parity with upstream rsync, including protocol/format interoperability where specified (e.g. batch mode's file format). diff --git a/go.mod b/go.mod index 5791822..b52175b 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,10 @@ module github.com/syntaxroot-cc/grsync go 1.26.5 -require github.com/spf13/cobra v1.10.2 +require ( + github.com/spf13/cobra v1.10.2 + golang.org/x/crypto v0.54.0 +) require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect diff --git a/go.sum b/go.sum index a6ee3e0..96ae128 100644 --- a/go.sum +++ b/go.sum @@ -7,4 +7,6 @@ github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiT github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/cli/daemon.go b/internal/cli/daemon.go new file mode 100644 index 0000000..49c57c0 --- /dev/null +++ b/internal/cli/daemon.go @@ -0,0 +1,45 @@ +package cli + +import ( + "fmt" + "net" + "os" + + "github.com/spf13/cobra" + + "github.com/syntaxroot-cc/grsync/internal/daemon" +) + +// runDaemon implements --daemon mode: parse the rsyncd.conf at configPath, +// listen on port, and serve connections until the listener fails (e.g. the +// process is killed) or Accept itself errors. +func runDaemon(cmd *cobra.Command, configPath string, port int) error { + if configPath == "" { + return fmt.Errorf("--daemon requires --config PATH") + } + + f, err := os.Open(configPath) + if err != nil { + return fmt.Errorf("opening config %q: %w", configPath, err) + } + cfg, parseErr := daemon.ParseConfig(f) + closeErr := f.Close() + if parseErr != nil { + return fmt.Errorf("parsing config %q: %w", configPath, parseErr) + } + if closeErr != nil { + return closeErr + } + + addr := fmt.Sprintf(":%d", port) + ln, err := net.Listen("tcp", addr) + if err != nil { + return fmt.Errorf("listening on %s: %w", addr, err) + } + defer func() { _ = ln.Close() }() + + if _, err := fmt.Fprintf(cmd.OutOrStdout(), "grsync daemon listening on %s (%d module(s) configured)\n", ln.Addr(), len(cfg.Modules)); err != nil { + return err + } + return daemon.Serve(ln, cfg, cmd.ErrOrStderr()) +} diff --git a/internal/cli/root.go b/internal/cli/root.go index 1bb3e1f..e9e9062 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -10,6 +10,8 @@ import ( "strings" "github.com/spf13/cobra" + + "github.com/syntaxroot-cc/grsync/internal/daemon" ) // FilterRuleType identifies which kind of rule a FilterRule represents. @@ -58,6 +60,9 @@ type options struct { filterRules []FilterRule rsh string server bool + daemon bool + config string + port int } // filterRuleFlag implements pflag.Value. Each of --exclude/--include/ @@ -106,13 +111,22 @@ func NewRootCmd() *cobra.Command { // a remote-invoked grsync (e.g. `ssh host grsync --server /dest`) // switches into speaking internal/pipeline's protocol over its own // stdin/stdout against that destination, instead of a normal sync. + // --daemon takes none at all: everything it needs (which modules + // exist, where they live) comes from --config's rsyncd.conf, not + // from positional args. Args: func(cmd *cobra.Command, args []string) error { + if opts.daemon { + return cobra.NoArgs(cmd, args) + } if opts.server { return cobra.ExactArgs(1)(cmd, args) } return cobra.MinimumNArgs(2)(cmd, args) }, RunE: func(cmd *cobra.Command, args []string) error { + if opts.daemon { + return runDaemon(cmd, opts.config, opts.port) + } if opts.server { return runServer(cmd, args[0], opts) } @@ -166,6 +180,9 @@ func NewRootCmd() *cobra.Command { if err := flags.MarkHidden("server"); err != nil { panic(err) // only fails if "server" isn't a registered flag name, which would be a programming error caught immediately by any test run } + flags.BoolVar(&opts.daemon, "daemon", false, "run as an rsync-protocol daemon, serving modules defined in --config") + flags.StringVar(&opts.config, "config", "", "path to the rsyncd.conf file to serve (required with --daemon)") + flags.IntVar(&opts.port, "port", daemon.DefaultPort, "TCP port to listen on in --daemon mode") return cmd } diff --git a/internal/daemon/auth.go b/internal/daemon/auth.go new file mode 100644 index 0000000..7c99754 --- /dev/null +++ b/internal/daemon/auth.go @@ -0,0 +1,201 @@ +package daemon + +import ( + "bufio" + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "errors" + "fmt" + "io" + "os" + "strings" + + "golang.org/x/crypto/md4" +) + +// ErrAuthFailed is returned by ServeAuth and DialAuth when authentication +// is attempted and fails - a wrong password, an unauthorized user, or a +// secrets file that can't be read. The wire-level detail behind it is +// deliberately generic (see ServeAuth), matching real rsync's own refusal +// to distinguish "no such user" from "wrong password" in its response. +var ErrAuthFailed = errors.New("authentication failed") + +// AuthRequired reports whether a client must authenticate to use m: real +// rsyncd.conf's own rule is that a module requires auth exactly when it +// has a non-empty "auth users" list. +func (m Module) AuthRequired() bool { + return len(m.AuthUsers) > 0 +} + +// md4Hash returns the base64-encoded (standard alphabet, no padding - the +// same encoding real rsync's own base64_encode(..., pad=0) produces) MD4 +// digest of secret followed by challenge. This matches real rsync's +// generate_hash() in authenticate.c exactly: the secret is hashed first, +// then the challenge, with no seed byte - verified against the actual +// rsync source rather than assumed, since getting the byte order wrong +// here would silently produce a client and server that only interoperate +// with each other, never with real rsync or real docs describing the +// algorithm. +func md4Hash(secret, challenge string) string { + h := md4.New() + // hash.Hash.Write (which io.WriteString goes through) never returns + // an error - its doc comment guarantees this - so there is nothing + // meaningful to check here. + _, _ = io.WriteString(h, secret) + _, _ = io.WriteString(h, challenge) + return base64.RawStdEncoding.EncodeToString(h.Sum(nil)) +} + +// generateChallenge returns a fresh, random, base64-encoded challenge. +// Real rsync derives its challenge from the client address, current time, +// and pid; grsync uses a CSPRNG instead, which is at least as +// unpredictable and far simpler - nothing in the protocol requires the +// challenge to be derived any particular way, only that it not repeat. +func generateChallenge() (string, error) { + buf := make([]byte, 16) + if _, err := rand.Read(buf); err != nil { + return "", fmt.Errorf("generating challenge: %w", err) + } + return base64.RawStdEncoding.EncodeToString(buf), nil +} + +// readSecretsFile parses a "name:secret" per-line secrets file, matching +// the real "secrets file" format. Blank lines and "#"-prefixed lines are +// skipped; a line with no ":" is skipped rather than treated as an error, +// since it can never match a submitted username anyway. +func readSecretsFile(path string) (map[string]string, error) { + f, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("opening secrets file: %w", err) + } + defer func() { _ = f.Close() }() + + secrets := make(map[string]string) + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + if line == "" || strings.HasPrefix(line, "#") { + continue + } + name, secret, ok := strings.Cut(line, ":") + if !ok { + continue + } + secrets[name] = secret + } + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("reading secrets file: %w", err) + } + return secrets, nil +} + +func userAllowed(user string, allowed []string) bool { + for _, u := range allowed { + if u == user { + return true + } + } + return false +} + +// ServeAuth runs the server side of module authentication, following +// ServeGreeting having already selected m. If m doesn't require auth, it +// writes "@RSYNCD: OK" and returns immediately with an empty user. Password +// comparison happens in constant time (crypto/subtle) so a wrong-length or +// wrong-content response can't be distinguished by timing; the refusal +// reason is likewise never revealed on the wire, matching real rsync's own +// single generic "@ERROR: auth failed on module " message regardless +// of whether the username was unknown, unauthorized, or the password was +// wrong - only the server's own logs (not implemented here) would ever see +// that detail in real rsync. +func ServeAuth(c *conn, m Module) (user string, err error) { + if !m.AuthRequired() { + if err := writeLine(c.w, "@RSYNCD: OK"); err != nil { + return "", fmt.Errorf("writing OK: %w", err) + } + return "", nil + } + + challenge, err := generateChallenge() + if err != nil { + return "", err + } + if err := writeLine(c.w, "@RSYNCD: AUTHREQD "+challenge); err != nil { + return "", fmt.Errorf("writing AUTHREQD: %w", err) + } + + line, err := readLine(c.r) + if err != nil { + return "", fmt.Errorf("reading auth response: %w", err) + } + submittedUser, response, ok := strings.Cut(line, " ") + if !ok { + _ = writeLine(c.w, fmt.Sprintf("@ERROR: auth failed on module %s", m.Name)) + return "", fmt.Errorf("%w: malformed auth response", ErrAuthFailed) + } + + fail := func() (string, error) { + _ = writeLine(c.w, fmt.Sprintf("@ERROR: auth failed on module %s", m.Name)) + return "", ErrAuthFailed + } + + if !userAllowed(submittedUser, m.AuthUsers) { + return fail() + } + secrets, err := readSecretsFile(m.SecretsFile) + if err != nil { + return fail() + } + secret, ok := secrets[submittedUser] + if !ok { + return fail() + } + expected := md4Hash(secret, challenge) + if subtle.ConstantTimeCompare([]byte(response), []byte(expected)) != 1 { + return fail() + } + + if err := writeLine(c.w, "@RSYNCD: OK"); err != nil { + return "", fmt.Errorf("writing OK: %w", err) + } + return submittedUser, nil +} + +// DialAuth runs the client side of authentication: reads lines until +// "@RSYNCD: OK", answering an "@RSYNCD: AUTHREQD " line (if one +// arrives) with " " - the password +// itself is never sent, only this one-way hash of it, so ErrAuthFailed +// and any wire capture of this exchange should never contain it. An empty +// user is sent as "nobody", matching real rsync's own client behavior for +// anonymous-looking auth attempts. +func DialAuth(c *conn, user, password string) error { + for { + line, err := readLine(c.r) + if err != nil { + return fmt.Errorf("reading server response: %w", err) + } + + const authReqdPrefix = "@RSYNCD: AUTHREQD " + if strings.HasPrefix(line, authReqdPrefix) { + challenge := strings.TrimPrefix(line, authReqdPrefix) + sendUser := user + if sendUser == "" { + sendUser = "nobody" + } + response := md4Hash(password, challenge) + if err := writeLine(c.w, sendUser+" "+response); err != nil { + return fmt.Errorf("writing auth response: %w", err) + } + continue + } + + if line == "@RSYNCD: OK" { + return nil + } + if strings.HasPrefix(line, "@ERROR") { + return fmt.Errorf("%w: %s", ErrAuthFailed, line) + } + return fmt.Errorf("unexpected line during authentication: %q", line) + } +} diff --git a/internal/daemon/auth_test.go b/internal/daemon/auth_test.go new file mode 100644 index 0000000..8cc57f0 --- /dev/null +++ b/internal/daemon/auth_test.go @@ -0,0 +1,168 @@ +package daemon + +import ( + "bytes" + "errors" + "io" + "os" + "path/filepath" + "strings" + "testing" +) + +func writeSecretsFile(t *testing.T, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "rsyncd.secrets") + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("writing secrets file: %v", err) + } + return path +} + +// authPipe wires up a client/server conn pair over io.Pipe, each side's +// writes also captured into its own log buffer - so a test can inspect +// every byte either side put on the wire, not just the final result. +func authPipe() (client, server *conn, clientWireLog, serverWireLog *bytes.Buffer) { + clientR, serverW := io.Pipe() + serverR, clientW := io.Pipe() + clientWireLog = &bytes.Buffer{} + serverWireLog = &bytes.Buffer{} + client = newConn(pipeReadWriter{Reader: clientR, Writer: io.MultiWriter(clientW, clientWireLog)}) + server = newConn(pipeReadWriter{Reader: serverR, Writer: io.MultiWriter(serverW, serverWireLog)}) + return client, server, clientWireLog, serverWireLog +} + +func TestAuth_NoAuthRequiredSendsOK(t *testing.T) { + client, server, _, _ := authPipe() + m := Module{Name: "public"} // no AuthUsers + + errCh := make(chan error, 1) + go func() { + _, err := ServeAuth(server, m) + errCh <- err + }() + + if err := DialAuth(client, "", ""); err != nil { + t.Fatalf("DialAuth returned error: %v", err) + } + if err := <-errCh; err != nil { + t.Fatalf("ServeAuth returned error: %v", err) + } +} + +func TestAuth_CorrectPasswordSucceeds(t *testing.T) { + secretsPath := writeSecretsFile(t, "alice:hunter2\nbob:swordfish\n") + m := Module{Name: "private", AuthUsers: []string{"alice", "bob"}, SecretsFile: secretsPath} + + client, server, _, _ := authPipe() + errCh := make(chan error, 1) + var gotUser string + go func() { + var err error + gotUser, err = ServeAuth(server, m) + errCh <- err + }() + + if err := DialAuth(client, "alice", "hunter2"); err != nil { + t.Fatalf("DialAuth returned error: %v", err) + } + if err := <-errCh; err != nil { + t.Fatalf("ServeAuth returned error: %v", err) + } + if gotUser != "alice" { + t.Errorf("ServeAuth returned user %q, want %q", gotUser, "alice") + } +} + +func TestAuth_WrongPasswordFails(t *testing.T) { + secretsPath := writeSecretsFile(t, "alice:hunter2\n") + m := Module{Name: "private", AuthUsers: []string{"alice"}, SecretsFile: secretsPath} + + client, server, _, _ := authPipe() + errCh := make(chan error, 1) + go func() { + _, err := ServeAuth(server, m) + errCh <- err + }() + + dialErr := DialAuth(client, "alice", "wrong-password") + if dialErr == nil { + t.Fatalf("DialAuth with a wrong password returned nil error, want an error") + } + if !errors.Is(dialErr, ErrAuthFailed) { + t.Errorf("DialAuth error = %v, want it to wrap ErrAuthFailed", dialErr) + } + if err := <-errCh; !errors.Is(err, ErrAuthFailed) { + t.Errorf("ServeAuth error = %v, want ErrAuthFailed", err) + } +} + +func TestAuth_UnauthorizedUserFails(t *testing.T) { + secretsPath := writeSecretsFile(t, "alice:hunter2\n") + m := Module{Name: "private", AuthUsers: []string{"alice"}, SecretsFile: secretsPath} + + client, server, _, _ := authPipe() + errCh := make(chan error, 1) + go func() { + _, err := ServeAuth(server, m) + errCh <- err + }() + + dialErr := DialAuth(client, "eve", "hunter2") + if dialErr == nil { + t.Fatalf("DialAuth for an unauthorized user returned nil error, want an error") + } + if err := <-errCh; !errors.Is(err, ErrAuthFailed) { + t.Errorf("ServeAuth error = %v, want ErrAuthFailed", err) + } +} + +// TestAuth_NoPlaintextPasswordOnWire is the self-review requirement made +// concrete: it inspects the actual bytes each side wrote, not just the +// outcome, and fails if the raw password ever appears in either +// direction. +func TestAuth_NoPlaintextPasswordOnWire(t *testing.T) { + const password = "correct-horse-battery-staple" + secretsPath := writeSecretsFile(t, "alice:"+password+"\n") + m := Module{Name: "private", AuthUsers: []string{"alice"}, SecretsFile: secretsPath} + + client, server, clientLog, serverLog := authPipe() + errCh := make(chan error, 1) + go func() { + _, err := ServeAuth(server, m) + errCh <- err + }() + + if err := DialAuth(client, "alice", password); err != nil { + t.Fatalf("DialAuth returned error: %v", err) + } + if err := <-errCh; err != nil { + t.Fatalf("ServeAuth returned error: %v", err) + } + + if strings.Contains(clientLog.String(), password) { + t.Errorf("client wire log contains the plaintext password: %q", clientLog.String()) + } + if strings.Contains(serverLog.String(), password) { + t.Errorf("server wire log contains the plaintext password: %q", serverLog.String()) + } +} + +func TestAuth_MissingSecretsFileFails(t *testing.T) { + m := Module{Name: "private", AuthUsers: []string{"alice"}, SecretsFile: filepath.Join(t.TempDir(), "does-not-exist")} + + client, server, _, _ := authPipe() + errCh := make(chan error, 1) + go func() { + _, err := ServeAuth(server, m) + errCh <- err + }() + + dialErr := DialAuth(client, "alice", "hunter2") + if dialErr == nil { + t.Fatalf("DialAuth with a missing secrets file returned nil error, want an error") + } + if err := <-errCh; !errors.Is(err, ErrAuthFailed) { + t.Errorf("ServeAuth error = %v, want ErrAuthFailed", err) + } +} diff --git a/internal/daemon/config.go b/internal/daemon/config.go new file mode 100644 index 0000000..ffbfcc1 --- /dev/null +++ b/internal/daemon/config.go @@ -0,0 +1,266 @@ +// Package daemon implements grsync's rsync-daemon-protocol server: parsing +// rsyncd.conf, the rsync:// URL scheme, the @RSYNCD greeting/handshake, +// MD4 challenge-response authentication, and per-module access control. +// Once a client has authenticated and selected a module, this package +// hands the connection straight to internal/pipeline's existing +// Sender/Receiver - the daemon protocol is a second way to *establish* a +// connection, not a second way to *transfer files*. +package daemon + +import ( + "bufio" + "fmt" + "io" + "strconv" + "strings" +) + +// Module is one [module_name] section of an rsyncd.conf file, plus +// whatever global defaults it didn't override. +type Module struct { + Name string + // Path is the directory on the daemon's filesystem this module + // exposes. Required for every module - rsyncd.conf itself requires it. + Path string + // ReadOnly defaults to true, matching real rsync: "The default is for + // all modules to be read only." + ReadOnly bool + // List defaults to true; "list = false" hides the module from a + // #list request without preventing a client who already knows its + // name from connecting to it - a real, documented rsyncd.conf option, + // not an invented one. + List bool + // Comment is shown alongside the module name in a #list response + // ("\t", real rsync's own listing format). + Comment string + // Exclude is the raw, space-separated pattern list from the "exclude" + // parameter, not yet compiled into sync.Rule - see access.go. + Exclude []string + // AuthUsers is the raw, comma/space-separated list from "auth users". + // A non-empty list means this module requires authentication. + AuthUsers []string + // SecretsFile is the path to a "name:password" per-line file, per + // "secrets file". + SecretsFile string + // MaxConnections is the simultaneous-connection cap for this module; + // 0 (the default) means unlimited, matching real rsync. + MaxConnections int +} + +// Config is a parsed rsyncd.conf: every module it defines, keyed by name. +type Config struct { + Modules map[string]Module +} + +// moduleDefaults returns the built-in defaults every module starts from +// before its own [section] parameters (or the file's global parameters, +// set before any module header) are applied on top. +func moduleDefaults() Module { + return Module{ReadOnly: true, List: true} +} + +// ParseConfig parses rsyncd.conf content from r. +// +// Syntax, matching the real format (verified against the actual +// rsyncd.conf(5) man page, not assumed): global parameters may appear +// before any module header and become that module's starting defaults; +// a module begins with "[name]" and continues until the next module or +// EOF; "#"-prefixed lines are comments; blank lines are ignored; a line +// ending in "\" continues on the next line; only the first "=" in a +// "name = value" line is significant, and whitespace around it is +// trimmed. +// +// A parameter name this package doesn't implement (real rsyncd.conf has +// dozens - "uid", "hosts allow", "log file", "timeout", and more) is +// accepted and silently ignored, not an error: rejecting a real, +// syntactically valid config file just because it uses an option this +// package hasn't implemented yet would be worse than ignoring that one +// line. A line that isn't valid "name = value" or "[section]" syntax at +// all, or a recognized parameter with a malformed value (e.g. +// "max connections = abc"), is a hard parse error - that distinction is +// deliberate, not an oversight. +func ParseConfig(r io.Reader) (*Config, error) { + lines, err := readLogicalLines(r) + if err != nil { + return nil, fmt.Errorf("reading config: %w", err) + } + + cfg := &Config{Modules: make(map[string]Module)} + globalDefaults := moduleDefaults() + var current *Module // nil while still in the global (pre-module) section + + // flush commits the module currently being accumulated into cfg.Modules. + // Called when a new [section] header starts (to commit the previous + // one) and once more after the loop ends (to commit the last one) - + // current accumulates directly rather than round-tripping through the + // map on every parameter line. + flush := func() { + if current != nil { + cfg.Modules[current.Name] = *current + } + } + + for i, raw := range lines { + lineNo := i + 1 + line := strings.TrimSpace(raw) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + if strings.HasPrefix(line, "[") { + name, err := parseSectionHeader(line) + if err != nil { + return nil, fmt.Errorf("line %d: %w", lineNo, err) + } + // flush() must run before the duplicate check below: the + // module currently being accumulated in `current` hasn't + // been written into cfg.Modules yet (that's the whole point + // of deferring it), so checking for a duplicate against the + // map first would miss "[mod] ... [mod]" - the very case + // this check exists for - since the first "mod" wouldn't be + // in the map yet when the second one is seen. + flush() + if _, exists := cfg.Modules[name]; exists { + return nil, fmt.Errorf("line %d: module %q defined more than once", lineNo, name) + } + m := globalDefaults + m.Name = name + current = &m + continue + } + + key, value, err := parseParamLine(line) + if err != nil { + return nil, fmt.Errorf("line %d: %w", lineNo, err) + } + + if current == nil { + if err := applyParam(&globalDefaults, key, value); err != nil { + return nil, fmt.Errorf("line %d: %w", lineNo, err) + } + continue + } + if err := applyParam(current, key, value); err != nil { + return nil, fmt.Errorf("line %d: %w", lineNo, err) + } + } + flush() + + for name, m := range cfg.Modules { + if m.Path == "" { + return nil, fmt.Errorf("module %q has no \"path\" set", name) + } + } + + return cfg, nil +} + +// readLogicalLines reads r and joins any line ending in "\" with the line +// that follows it, so the rest of the parser never has to think about +// continuations. +func readLogicalLines(r io.Reader) ([]string, error) { + var lines []string + var current strings.Builder + + scanner := bufio.NewScanner(r) + for scanner.Scan() { + text := scanner.Text() + if strings.HasSuffix(text, "\\") { + current.WriteString(strings.TrimSuffix(text, "\\")) + continue + } + current.WriteString(text) + lines = append(lines, current.String()) + current.Reset() + } + if current.Len() > 0 { + lines = append(lines, current.String()) + } + if err := scanner.Err(); err != nil { + return nil, err + } + return lines, nil +} + +func parseSectionHeader(line string) (string, error) { + if !strings.HasSuffix(line, "]") { + return "", fmt.Errorf("malformed section header %q: missing closing \"]\"", line) + } + name := strings.TrimSpace(line[1 : len(line)-1]) + if name == "" { + return "", fmt.Errorf("malformed section header %q: empty module name", line) + } + return name, nil +} + +func parseParamLine(line string) (key, value string, err error) { + i := strings.IndexByte(line, '=') + if i == -1 { + return "", "", fmt.Errorf("malformed line %q: expected \"name = value\"", line) + } + key = strings.ToLower(strings.TrimSpace(line[:i])) + value = strings.TrimSpace(line[i+1:]) + if key == "" { + return "", "", fmt.Errorf("malformed line %q: empty parameter name", line) + } + return key, value, nil +} + +// applyParam interprets one recognized parameter into m. An unrecognized +// key is silently accepted (see ParseConfig's doc comment for why); a +// recognized key with a malformed value is a hard error. +func applyParam(m *Module, key, value string) error { + switch key { + case "path": + m.Path = value + case "comment": + m.Comment = value + case "read only": + b, err := parseBool(value) + if err != nil { + return fmt.Errorf("\"read only\": %w", err) + } + m.ReadOnly = b + case "list": + b, err := parseBool(value) + if err != nil { + return fmt.Errorf("\"list\": %w", err) + } + m.List = b + case "exclude": + m.Exclude = strings.Fields(value) + case "auth users": + m.AuthUsers = splitCommaOrSpace(value) + case "secrets file": + m.SecretsFile = value + case "max connections": + n, err := strconv.Atoi(value) + if err != nil { + return fmt.Errorf("\"max connections\": %q is not a valid integer", value) + } + m.MaxConnections = n + } + return nil +} + +// parseBool matches real rsyncd.conf's own accepted boolean spellings: +// "true"/"false", "yes"/"no", and "1"/"0", case-insensitively. +func parseBool(value string) (bool, error) { + switch strings.ToLower(value) { + case "true", "yes", "1": + return true, nil + case "false", "no", "0": + return false, nil + default: + return false, fmt.Errorf("%q is not a valid boolean (want true/false, yes/no, or 1/0)", value) + } +} + +// splitCommaOrSpace splits on commas and/or whitespace, matching +// "auth users"'s documented "comma and/or space-separated list" syntax. +func splitCommaOrSpace(value string) []string { + fields := strings.FieldsFunc(value, func(r rune) bool { + return r == ',' || r == ' ' || r == '\t' + }) + return fields +} diff --git a/internal/daemon/config_test.go b/internal/daemon/config_test.go new file mode 100644 index 0000000..a1bb932 --- /dev/null +++ b/internal/daemon/config_test.go @@ -0,0 +1,159 @@ +package daemon + +import ( + "strings" + "testing" +) + +func TestParseConfig_RealisticSample(t *testing.T) { + const sample = ` +# Global defaults - every module below inherits these unless it overrides them. +read only = yes +list = yes + +[public] + path = /srv/rsync/public + comment = Public files, read-only, no auth + exclude = *.tmp *.bak .git/ + +[private] + path = /srv/rsync/private + read only = false + list = false + auth users = alice, bob + secrets file = /etc/rsyncd.secrets + max connections = 4 + +[backup] + path = /srv/rsync/backup \ + continued path is not realistic but exercises line continuation +` + + cfg, err := ParseConfig(strings.NewReader(sample)) + if err != nil { + t.Fatalf("ParseConfig returned error: %v", err) + } + + if len(cfg.Modules) != 3 { + t.Fatalf("got %d modules, want 3: %+v", len(cfg.Modules), cfg.Modules) + } + + public, ok := cfg.Modules["public"] + if !ok { + t.Fatalf("missing module %q", "public") + } + if public.Path != "/srv/rsync/public" { + t.Errorf("public.Path = %q, want %q", public.Path, "/srv/rsync/public") + } + if public.Comment != "Public files, read-only, no auth" { + t.Errorf("public.Comment = %q, want %q", public.Comment, "Public files, read-only, no auth") + } + if !public.ReadOnly { + t.Errorf("public.ReadOnly = false, want true (inherited global default)") + } + if !public.List { + t.Errorf("public.List = false, want true (inherited global default)") + } + wantExclude := []string{"*.tmp", "*.bak", ".git/"} + if len(public.Exclude) != len(wantExclude) { + t.Fatalf("public.Exclude = %v, want %v", public.Exclude, wantExclude) + } + for i, p := range wantExclude { + if public.Exclude[i] != p { + t.Errorf("public.Exclude[%d] = %q, want %q", i, public.Exclude[i], p) + } + } + + private, ok := cfg.Modules["private"] + if !ok { + t.Fatalf("missing module %q", "private") + } + if private.ReadOnly { + t.Errorf("private.ReadOnly = true, want false (explicit override)") + } + if private.List { + t.Errorf("private.List = true, want false (explicit override)") + } + wantUsers := []string{"alice", "bob"} + if len(private.AuthUsers) != len(wantUsers) { + t.Fatalf("private.AuthUsers = %v, want %v", private.AuthUsers, wantUsers) + } + for i, u := range wantUsers { + if private.AuthUsers[i] != u { + t.Errorf("private.AuthUsers[%d] = %q, want %q", i, private.AuthUsers[i], u) + } + } + if private.SecretsFile != "/etc/rsyncd.secrets" { + t.Errorf("private.SecretsFile = %q, want %q", private.SecretsFile, "/etc/rsyncd.secrets") + } + if private.MaxConnections != 4 { + t.Errorf("private.MaxConnections = %d, want 4", private.MaxConnections) + } + + backup, ok := cfg.Modules["backup"] + if !ok { + t.Fatalf("missing module %q", "backup") + } + if !strings.Contains(backup.Path, "continued path is not realistic") { + t.Errorf("backup.Path = %q, want it to include the continuation line's content", backup.Path) + } +} + +func TestParseConfig_UnrecognizedParameterIsIgnoredNotAnError(t *testing.T) { + const sample = ` +[mod] +path = /srv/data +uid = nobody +gid = nogroup +timeout = 300 +` + cfg, err := ParseConfig(strings.NewReader(sample)) + if err != nil { + t.Fatalf("ParseConfig with unrecognized-but-valid parameters returned error: %v", err) + } + if cfg.Modules["mod"].Path != "/srv/data" { + t.Errorf("Path = %q, want %q", cfg.Modules["mod"].Path, "/srv/data") + } +} + +func TestParseConfig_MalformedSectionHeaderErrors(t *testing.T) { + _, err := ParseConfig(strings.NewReader("[unterminated\npath = /srv\n")) + if err == nil { + t.Fatalf("ParseConfig with a malformed section header returned nil error, want an error") + } +} + +func TestParseConfig_MalformedMaxConnectionsErrors(t *testing.T) { + _, err := ParseConfig(strings.NewReader("[mod]\npath = /srv\nmax connections = not-a-number\n")) + if err == nil { + t.Fatalf("ParseConfig with a non-numeric max connections returned nil error, want an error") + } +} + +func TestParseConfig_DuplicateModuleErrors(t *testing.T) { + _, err := ParseConfig(strings.NewReader("[mod]\npath = /a\n[mod]\npath = /b\n")) + if err == nil { + t.Fatalf("ParseConfig with a duplicate module name returned nil error, want an error") + } +} + +func TestParseConfig_MissingPathErrors(t *testing.T) { + _, err := ParseConfig(strings.NewReader("[mod]\nread only = yes\n")) + if err == nil { + t.Fatalf("ParseConfig with a module missing \"path\" returned nil error, want an error") + } +} + +func TestParseConfig_MalformedParamLineErrors(t *testing.T) { + _, err := ParseConfig(strings.NewReader("[mod]\npath = /srv\nthis line has no equals sign\n")) + if err == nil { + t.Fatalf("ParseConfig with a malformed parameter line returned nil error, want an error") + } +} + +func TestParseConfig_InvalidBooleanErrors(t *testing.T) { + _, err := ParseConfig(strings.NewReader("[mod]\npath = /srv\nread only = maybe\n")) + if err == nil { + t.Fatalf("ParseConfig with an invalid boolean value returned nil error, want an error") + } +} diff --git a/internal/daemon/protocol.go b/internal/daemon/protocol.go new file mode 100644 index 0000000..6b9d275 --- /dev/null +++ b/internal/daemon/protocol.go @@ -0,0 +1,231 @@ +package daemon + +import ( + "bufio" + "errors" + "fmt" + "io" + "sort" + "strconv" + "strings" +) + +// ProtocolVersion/SubProtocolVersion are what grsync's daemon claims in +// its @RSYNCD greeting line. This only governs the greeting/handshake +// text exchange this package implements - it is not a claim that +// everything a real rsync client would expect at this protocol version +// (digest negotiation, the real binary wire format for the transfer +// itself) is implemented. See the package doc comment and README for the +// exact boundary: handshake/auth are real-protocol-shaped, the transfer +// that follows is internal/pipeline's own gob protocol. +const ( + ProtocolVersion = 31 + SubProtocolVersion = 0 +) + +// conn bundles a connection's read and write sides into one io.ReadWriter +// that every protocol step - greeting, module selection, auth, and +// finally the handoff to pipeline.Sender/Receiver - shares. This matters +// for correctness, not just convenience: line-based reads (bufio.Reader) +// can buffer bytes past the line they were asked for, and if later code +// switched to reading directly from the underlying net.Conn instead of +// continuing through this same *bufio.Reader, any bytes already +// buffered-but-unread would be silently lost. +type conn struct { + r *bufio.Reader + w io.Writer +} + +func newConn(rw io.ReadWriter) *conn { + return &conn{r: bufio.NewReader(rw), w: rw} +} + +// Read/Write let *conn itself satisfy io.ReadWriter, so it can be handed +// directly to pipeline.Sender/Receiver once the handshake is done. +func (c *conn) Read(p []byte) (int, error) { return c.r.Read(p) } +func (c *conn) Write(p []byte) (int, error) { return c.w.Write(p) } + +func writeLine(w io.Writer, s string) error { + _, err := io.WriteString(w, s+"\n") + return err +} + +// maxLineLength bounds every line read during the connection's text-based +// phases (greeting, module selection, auth). Without a cap, an +// unauthenticated client could force unbounded memory growth just by +// sending bytes with no "\n" - bufio.Reader.ReadString itself keeps +// growing its buffer until the delimiter appears. Real rsync's own line +// reader (read_line_old) is bounded the same way, not just this package's +// own invention. +const maxLineLength = 8192 + +// readLine reads one line, stripping the trailing "\n" and any "\r" +// immediately before it (tolerating a CRLF-sending peer without requiring +// one, since real rsync's own daemon protocol is LF-only). Reads +// byte-by-byte through r rather than via ReadString so an over-length +// line can be rejected before consuming unbounded memory, while still +// only ever going through the one shared *bufio.Reader every phase of +// this package uses. +func readLine(r *bufio.Reader) (string, error) { + var buf []byte + for { + b, err := r.ReadByte() + if err != nil { + return "", err + } + if b == '\n' { + break + } + if len(buf) >= maxLineLength { + return "", fmt.Errorf("line exceeds maximum length of %d bytes", maxLineLength) + } + buf = append(buf, b) + } + return strings.TrimRight(string(buf), "\r"), nil +} + +func writeGreeting(w io.Writer) error { + return writeLine(w, fmt.Sprintf("@RSYNCD: %d.%d", ProtocolVersion, SubProtocolVersion)) +} + +// readGreeting parses a peer's "@RSYNCD: . ..." +// line. Any digest-list tokens after the version (real rsync protocol +// 30+ uses these to negotiate MD4 vs MD5) are accepted but ignored - this +// package always uses classic MD4 (see auth.go), not digest negotiation. +func readGreeting(r *bufio.Reader) (version, subVersion int, err error) { + line, err := readLine(r) + if err != nil { + return 0, 0, fmt.Errorf("reading greeting: %w", err) + } + const prefix = "@RSYNCD: " + if !strings.HasPrefix(line, prefix) { + return 0, 0, fmt.Errorf("invalid greeting %q: expected %q prefix", line, prefix) + } + fields := strings.Fields(strings.TrimPrefix(line, prefix)) + if len(fields) == 0 { + return 0, 0, fmt.Errorf("invalid greeting %q: missing version", line) + } + + verParts := strings.SplitN(fields[0], ".", 2) + version, err = strconv.Atoi(verParts[0]) + if err != nil { + return 0, 0, fmt.Errorf("invalid greeting %q: bad version %q", line, verParts[0]) + } + if len(verParts) == 2 { + subVersion, err = strconv.Atoi(verParts[1]) + if err != nil { + return 0, 0, fmt.Errorf("invalid greeting %q: bad subprotocol %q", line, verParts[1]) + } + } + return version, subVersion, nil +} + +// ErrModuleListRequested is returned by ServeGreeting when the client +// asked to list modules (and the listing has already been written) +// rather than selecting one - the caller should close the connection at +// that point, not proceed to authentication or transfer. +var ErrModuleListRequested = errors.New("client requested module listing, connection should now close") + +// ServeGreeting runs the server side of the initial handshake: writes our +// @RSYNCD greeting, reads the client's, then reads either "#list" +// (writes the listing and returns ErrModuleListRequested) or a module +// name (validated against cfg, returned as selected). +func ServeGreeting(c *conn, cfg *Config) (selected Module, err error) { + if err := writeGreeting(c.w); err != nil { + return Module{}, fmt.Errorf("writing greeting: %w", err) + } + if _, _, err := readGreeting(c.r); err != nil { + return Module{}, err + } + + request, err := readLine(c.r) + if err != nil { + return Module{}, fmt.Errorf("reading module request: %w", err) + } + + if request == "#list" { + if err := writeModuleList(c.w, cfg); err != nil { + return Module{}, err + } + return Module{}, ErrModuleListRequested + } + + m, ok := cfg.Modules[request] + if !ok { + _ = writeLine(c.w, fmt.Sprintf("@ERROR: Unknown module %q", request)) + return Module{}, fmt.Errorf("client requested unknown module %q", request) + } + return m, nil +} + +// writeModuleList writes every listable module (List == true - "list = +// false" modules are deliberately excluded here, not just documented as +// excluded) as "\t", then a terminating "@RSYNCD: EXIT" +// line, matching real rsync's own listing terminator. +func writeModuleList(w io.Writer, cfg *Config) error { + names := make([]string, 0, len(cfg.Modules)) + for name, m := range cfg.Modules { + if m.List { + names = append(names, name) + } + } + sort.Strings(names) // deterministic order; map iteration alone isn't + + for _, name := range names { + if err := writeLine(w, fmt.Sprintf("%s\t%s", name, cfg.Modules[name].Comment)); err != nil { + return fmt.Errorf("writing module list: %w", err) + } + } + return writeLine(w, "@RSYNCD: EXIT") +} + +// DialGreeting runs the client side of the initial handshake against an +// already-connected transport: reads the daemon's greeting first, then +// sends ours in reply, matching real rsync's actual ordering (the daemon +// speaks first on accept; the client's greeting is a response to it, not +// sent independently). Getting this backwards would deadlock a real +// synchronous transport where both ends' first move is a write with +// nobody yet reading - which is exactly how this ordering bug was caught. +// Then sends module (or "#list" if module is empty, matching a bare +// rsync://host URL). Returns the raw module-list lines when listing was +// requested (module == ""); returns nil lines otherwise, ready for the +// caller to proceed to authentication. +func DialGreeting(c *conn, module string) (listing []string, err error) { + if _, _, err := readGreeting(c.r); err != nil { + return nil, err + } + if err := writeGreeting(c.w); err != nil { + return nil, fmt.Errorf("writing greeting: %w", err) + } + + if module == "" { + if err := writeLine(c.w, "#list"); err != nil { + return nil, fmt.Errorf("requesting module list: %w", err) + } + return readModuleList(c.r) + } + + if err := writeLine(c.w, module); err != nil { + return nil, fmt.Errorf("selecting module %q: %w", module, err) + } + return nil, nil +} + +// readModuleList reads lines until the "@RSYNCD: EXIT" terminator +// writeModuleList sends, returning every line before it. +func readModuleList(r *bufio.Reader) ([]string, error) { + var lines []string + for { + line, err := readLine(r) + if err != nil { + return nil, fmt.Errorf("reading module list: %w", err) + } + if line == "@RSYNCD: EXIT" { + return lines, nil + } + if strings.HasPrefix(line, "@ERROR") { + return nil, fmt.Errorf("server error: %s", line) + } + lines = append(lines, line) + } +} diff --git a/internal/daemon/protocol_test.go b/internal/daemon/protocol_test.go new file mode 100644 index 0000000..9f44ddd --- /dev/null +++ b/internal/daemon/protocol_test.go @@ -0,0 +1,156 @@ +package daemon + +import ( + "bufio" + "errors" + "io" + "strings" + "testing" +) + +func TestReadLine_RejectsOverLongLineInsteadOfUnboundedGrowth(t *testing.T) { + huge := strings.Repeat("a", maxLineLength+1) + "\n" + r := bufio.NewReader(strings.NewReader(huge)) + + _, err := readLine(r) + if err == nil { + t.Fatalf("readLine with a %d-byte line returned nil error, want an error", len(huge)-1) + } +} + +// pipeReadWriter joins two io.Pipe halves into a single io.ReadWriter - +// the same small helper internal/transport and internal/pipeline each +// define for their own tests. +type pipeReadWriter struct { + io.Reader + io.Writer +} + +func testConfig() *Config { + return &Config{Modules: map[string]Module{ + "public": {Name: "public", Path: "/srv/public", ReadOnly: true, List: true, Comment: "public files"}, + "hidden": {Name: "hidden", Path: "/srv/hidden", ReadOnly: true, List: false, Comment: "not listed, but reachable by name"}, + "private": {Name: "private", Path: "/srv/private", ReadOnly: false, List: true}, + }} +} + +func TestGreeting_ModuleSelection(t *testing.T) { + clientR, serverW := io.Pipe() + serverR, clientW := io.Pipe() + client := newConn(pipeReadWriter{Reader: clientR, Writer: clientW}) + server := newConn(pipeReadWriter{Reader: serverR, Writer: serverW}) + + serverErrCh := make(chan error, 1) + var selected Module + go func() { + var err error + selected, err = ServeGreeting(server, testConfig()) + serverErrCh <- err + }() + + if _, err := DialGreeting(client, "public"); err != nil { + t.Fatalf("DialGreeting returned error: %v", err) + } + if err := <-serverErrCh; err != nil { + t.Fatalf("ServeGreeting returned error: %v", err) + } + if selected.Name != "public" { + t.Errorf("selected module = %q, want %q", selected.Name, "public") + } +} + +func TestGreeting_UnknownModuleErrors(t *testing.T) { + clientR, serverW := io.Pipe() + serverR, clientW := io.Pipe() + client := newConn(pipeReadWriter{Reader: clientR, Writer: clientW}) + server := newConn(pipeReadWriter{Reader: serverR, Writer: serverW}) + + serverErrCh := make(chan error, 1) + go func() { + _, err := ServeGreeting(server, testConfig()) + serverErrCh <- err + }() + + if _, err := DialGreeting(client, "does-not-exist"); err != nil { + t.Fatalf("DialGreeting returned error: %v", err) + } + // DialGreeting itself doesn't interpret the server's post-selection + // response (that's the auth phase's job, in a later step) - so the + // test reads it directly here, both to confirm the server actually + // sent an @ERROR line and to drain the pipe so ServeGreeting's write + // doesn't block forever waiting for a reader that will never come. + response, err := readLine(client.r) + if err != nil { + t.Fatalf("reading server response: %v", err) + } + if !strings.HasPrefix(response, "@ERROR") { + t.Errorf("server response = %q, want an @ERROR line", response) + } + if err := <-serverErrCh; err == nil { + t.Fatalf("ServeGreeting with an unknown module returned nil error, want an error") + } +} + +func TestGreeting_ModuleListingHidesListFalseModules(t *testing.T) { + clientR, serverW := io.Pipe() + serverR, clientW := io.Pipe() + client := newConn(pipeReadWriter{Reader: clientR, Writer: clientW}) + server := newConn(pipeReadWriter{Reader: serverR, Writer: serverW}) + + serverErrCh := make(chan error, 1) + go func() { + _, err := ServeGreeting(server, testConfig()) + serverErrCh <- err + }() + + listing, err := DialGreeting(client, "") // "" requests #list, matching a bare rsync://host URL + if err != nil { + t.Fatalf("DialGreeting returned error: %v", err) + } + if err := <-serverErrCh; !errors.Is(err, ErrModuleListRequested) { + t.Fatalf("ServeGreeting error = %v, want ErrModuleListRequested", err) + } + + joined := "" + for _, l := range listing { + joined += l + "\n" + } + if !strings.Contains(joined, "public\tpublic files") { + t.Errorf("listing = %q, want it to contain the public module", joined) + } + if !strings.Contains(joined, "private") { + t.Errorf("listing = %q, want it to contain the private module", joined) + } + if strings.Contains(joined, "hidden") { + t.Errorf("listing = %q, want it to NOT contain the list=false hidden module", joined) + } +} + +func TestGreeting_HiddenModuleStillReachableByName(t *testing.T) { + // "list = false" hides a module from listing, but real rsyncd.conf + // does not make it unreachable to a client who already knows its + // name - confirming that distinction is implemented correctly, not + // just "list = false" accidentally behaving like a full block. + clientR, serverW := io.Pipe() + serverR, clientW := io.Pipe() + client := newConn(pipeReadWriter{Reader: clientR, Writer: clientW}) + server := newConn(pipeReadWriter{Reader: serverR, Writer: serverW}) + + serverErrCh := make(chan error, 1) + var selected Module + go func() { + var err error + selected, err = ServeGreeting(server, testConfig()) + serverErrCh <- err + }() + + if _, err := DialGreeting(client, "hidden"); err != nil { + t.Fatalf("DialGreeting returned error: %v", err) + } + if err := <-serverErrCh; err != nil { + t.Fatalf("ServeGreeting returned error: %v", err) + } + if selected.Name != "hidden" { + t.Errorf("selected module = %q, want %q", selected.Name, "hidden") + } +} diff --git a/internal/daemon/server.go b/internal/daemon/server.go new file mode 100644 index 0000000..1932c4a --- /dev/null +++ b/internal/daemon/server.go @@ -0,0 +1,65 @@ +package daemon + +import ( + "errors" + "fmt" + "io" + "net" +) + +// ServeConn runs one full connection's daemon protocol end to end: the +// greeting/module-selection handshake, authentication (only performed at +// all if the selected module requires it), and the resulting transfer - +// using only this package's own per-phase functions plus the connection +// itself. A bare module-listing request (ErrModuleListRequested) is a +// normal outcome, not a failure; the connection is simply done at that +// point. +func ServeConn(nc net.Conn, cfg *Config) error { + c := newConn(nc) + + m, err := ServeGreeting(c, cfg) + if err != nil { + return err + } + + if _, err := ServeAuth(c, m); err != nil { + return err + } + + return ServeModule(c, m) +} + +// Serve accepts connections on ln until Accept itself fails (typically +// because ln was closed), running ServeConn for each one in its own +// goroutine so one slow or misbehaving client can't block any other. +// Per-connection errors are written to errLog rather than returned - +// Serve's own return only ever reflects the listener itself failing, not +// any individual client's session. +func Serve(ln net.Listener, cfg *Config, errLog io.Writer) error { + for { + nc, err := ln.Accept() + if err != nil { + return fmt.Errorf("accepting connection: %w", err) + } + + go func() { + defer func() { _ = nc.Close() }() + // A network-facing daemon must not let one connection's bad + // input (malformed data anywhere downstream in the + // handshake/auth/transfer chain, not just malformed + // rsyncd.conf) take the whole process - and therefore every + // other in-flight connection - down with it. Go doesn't + // isolate goroutine panics on its own, so this recover is + // what makes "one client, one goroutine" actually mean one + // client's failure stays scoped to that client. + defer func() { + if r := recover(); r != nil { + _, _ = fmt.Fprintf(errLog, "%s: panic: %v\n", nc.RemoteAddr(), r) + } + }() + if err := ServeConn(nc, cfg); err != nil && !errors.Is(err, ErrModuleListRequested) { + _, _ = fmt.Fprintf(errLog, "%s: %v\n", nc.RemoteAddr(), err) + } + }() + } +} diff --git a/internal/daemon/server_test.go b/internal/daemon/server_test.go new file mode 100644 index 0000000..87d12f4 --- /dev/null +++ b/internal/daemon/server_test.go @@ -0,0 +1,165 @@ +package daemon + +import ( + "bytes" + "net" + "os" + "path/filepath" + "testing" + "time" + + "github.com/syntaxroot-cc/grsync/internal/sync" +) + +// startTestDaemon listens on 127.0.0.1:0 (an OS-assigned free port) and +// serves cfg in the background until the test ends. Using a real TCP +// listener rather than io.Pipe is deliberate here: unlike the SSH +// transport tests, this needs no external binary or environment +// dependency (no sshd), so there's no reason to settle for anything less +// than a real end-to-end loopback round trip. +func startTestDaemon(t *testing.T, cfg *Config) (addr string, errLog *bytes.Buffer) { + t.Helper() + + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listening on loopback: %v", err) + } + t.Cleanup(func() { _ = ln.Close() }) + + errLog = &bytes.Buffer{} + go func() { _ = Serve(ln, cfg, errLog) }() + + return ln.Addr().String(), errLog +} + +func dialTestDaemon(t *testing.T, addr string) *conn { + t.Helper() + nc, err := net.DialTimeout("tcp", addr, 5*time.Second) + if err != nil { + t.Fatalf("dialing %s: %v", addr, err) + } + t.Cleanup(func() { _ = nc.Close() }) + return newConn(nc) +} + +func TestDaemon_RealTCP_AnonymousDownload(t *testing.T) { + modRoot := t.TempDir() + mustWriteFile(t, filepath.Join(modRoot, "readme.txt"), "served over a real TCP loopback connection") + + cfg := &Config{Modules: map[string]Module{ + "public": {Name: "public", Path: modRoot, ReadOnly: true, List: true}, + }} + addr, errLog := startTestDaemon(t, cfg) + + client := dialTestDaemon(t, addr) + if _, err := DialGreeting(client, "public"); err != nil { + t.Fatalf("DialGreeting: %v", err) + } + if err := DialAuth(client, "", ""); err != nil { + t.Fatalf("DialAuth: %v", err) + } + dest := t.TempDir() + if err := DialModule(client, DirectionGet, dest, nil, sync.WalkOptions{}, sync.AttrOptions{}); err != nil { + t.Fatalf("DialModule: %v", err) + } + + got, err := os.ReadFile(filepath.Join(dest, "readme.txt")) + if err != nil { + t.Fatalf("reading downloaded file: %v", err) + } + if string(got) != "served over a real TCP loopback connection" { + t.Errorf("downloaded content = %q, want the module's file content", got) + } + if errLog.Len() != 0 { + t.Errorf("daemon error log = %q, want empty", errLog.String()) + } +} + +func TestDaemon_RealTCP_AuthenticatedUpload(t *testing.T) { + modRoot := t.TempDir() + secretsPath := writeSecretsFile(t, "alice:hunter2\n") + + cfg := &Config{Modules: map[string]Module{ + "incoming": { + Name: "incoming", Path: modRoot, ReadOnly: false, + AuthUsers: []string{"alice"}, SecretsFile: secretsPath, + }, + }} + addr, errLog := startTestDaemon(t, cfg) + + client := dialTestDaemon(t, addr) + if _, err := DialGreeting(client, "incoming"); err != nil { + t.Fatalf("DialGreeting: %v", err) + } + if err := DialAuth(client, "alice", "hunter2"); err != nil { + t.Fatalf("DialAuth: %v", err) + } + + src := t.TempDir() + mustWriteFile(t, filepath.Join(src, "upload.txt"), "pushed over real TCP") + rules, err := sync.CompileRules(nil) + if err != nil { + t.Fatalf("compiling empty rule set: %v", err) + } + if err := DialModule(client, DirectionPut, src, rules, sync.WalkOptions{}, sync.AttrOptions{}); err != nil { + t.Fatalf("DialModule: %v", err) + } + + t.Logf("daemon error log: %q", errLog.String()) + got, err := os.ReadFile(filepath.Join(modRoot, "upload.txt")) + if err != nil { + t.Fatalf("reading uploaded file from module: %v", err) + } + if string(got) != "pushed over real TCP" { + t.Errorf("uploaded content = %q, want %q", got, "pushed over real TCP") + } + if errLog.Len() != 0 { + t.Errorf("daemon error log = %q, want empty", errLog.String()) + } +} + +func TestDaemon_RealTCP_WrongPasswordRejected(t *testing.T) { + modRoot := t.TempDir() + secretsPath := writeSecretsFile(t, "alice:hunter2\n") + + cfg := &Config{Modules: map[string]Module{ + "private": { + Name: "private", Path: modRoot, ReadOnly: true, + AuthUsers: []string{"alice"}, SecretsFile: secretsPath, + }, + }} + addr, _ := startTestDaemon(t, cfg) + + client := dialTestDaemon(t, addr) + if _, err := DialGreeting(client, "private"); err != nil { + t.Fatalf("DialGreeting: %v", err) + } + if err := DialAuth(client, "alice", "wrong"); err == nil { + t.Fatalf("DialAuth with a wrong password returned nil error, want an error") + } +} + +func TestDaemon_RealTCP_ModuleListing(t *testing.T) { + cfg := &Config{Modules: map[string]Module{ + "visible": {Name: "visible", Path: t.TempDir(), ReadOnly: true, List: true, Comment: "shown"}, + "hidden": {Name: "hidden", Path: t.TempDir(), ReadOnly: true, List: false}, + }} + addr, _ := startTestDaemon(t, cfg) + + client := dialTestDaemon(t, addr) + listing, err := DialGreeting(client, "") + if err != nil { + t.Fatalf("DialGreeting (listing): %v", err) + } + + joined := "" + for _, l := range listing { + joined += l + "\n" + } + if !bytes.Contains([]byte(joined), []byte("visible")) { + t.Errorf("listing = %q, want it to contain the visible module", joined) + } + if bytes.Contains([]byte(joined), []byte("hidden")) { + t.Errorf("listing = %q, want it to NOT contain the hidden module", joined) + } +} diff --git a/internal/daemon/session.go b/internal/daemon/session.go new file mode 100644 index 0000000..752353a --- /dev/null +++ b/internal/daemon/session.go @@ -0,0 +1,166 @@ +package daemon + +import ( + "errors" + "fmt" + + "github.com/syntaxroot-cc/grsync/internal/pipeline" + "github.com/syntaxroot-cc/grsync/internal/sync" +) + +// Direction is which way file data moves in a module session, sent by the +// client as a single line immediately after authentication succeeds (or +// immediately after ServeGreeting, for a module that needs none). +type Direction string + +const ( + // DirectionGet means the client downloads from the module - the + // daemon runs pipeline.Sender against the module's Path, the client + // runs pipeline.Receiver against its local destination. + DirectionGet Direction = "get" + // DirectionPut means the client uploads to the module - the daemon + // runs pipeline.Receiver against the module's Path, the client runs + // pipeline.Sender against its local source. Refused outright for a + // read-only module, before any transfer code runs. + DirectionPut Direction = "put" +) + +// ErrReadOnly is returned by ServeModule when a client requests +// DirectionPut against a module with "read only = true" (the default). +var ErrReadOnly = errors.New("module is read only") + +// transferDone is sent by whichever side ran pipeline.Receiver once it +// returns, and waited for by whichever side ran pipeline.Sender before +// that side's own call returns. This matters for real callers, not just +// tests: pipeline.Sender returning only means the last delta has been +// written to the connection, not that the receiver has finished applying +// it and closing its destination files - closing the TCP connection right +// after Sender returns (as --daemon's per-connection goroutine does) can +// otherwise race the receiver's still-in-flight disk writes. +const transferDone = "@GRSYNC: DONE" + +func waitForTransferDone(c *conn) error { + line, err := readLine(c.r) + if err != nil { + return fmt.Errorf("waiting for transfer completion: %w", err) + } + if line != transferDone { + return fmt.Errorf("unexpected line waiting for transfer completion: %q", line) + } + return nil +} + +// moduleRules compiles m.Exclude into sync.Rule via the same +// CompileRules/Included machinery internal/cli uses for --exclude, +// rather than a second, independently-trusted pattern matcher for module +// access control. +// +// This is applied on the DirectionGet (download) path only: that's where +// the daemon itself walks m.Path and can filter what it sends, matching +// exclude's most common real-world use (restricting what a mirror +// serves). pipeline.Receiver has no per-entry filtering hook, so a +// DirectionPut upload to a non-read-only module is not filtered against +// this list - a deliberate, documented scope boundary, not an oversight; +// see the README's daemon section. +func moduleRules(m Module) ([]sync.Rule, error) { + raw := make([]sync.RawRule, len(m.Exclude)) + for i, pattern := range m.Exclude { + raw[i] = sync.RawRule{Kind: sync.RuleExclude, Pattern: pattern} + } + return sync.CompileRules(raw) +} + +// moduleAttrOptions is what a daemon module preserves on an upload. +// Fixed, rather than client-controlled, since the module (not the +// client) owns what its own rsyncd.conf-configured directory considers +// worth preserving - a client can't currently ask a module to preserve +// more or less than this. +func moduleAttrOptions() sync.AttrOptions { + return sync.AttrOptions{Perms: true, Times: true, Owner: true, Group: true, Links: true} +} + +// ServeModule runs one authenticated client's session against the +// already-selected module m: reads the client's requested Direction, +// enforces read-only, acknowledges with "@RSYNCD: OK" or refuses with an +// "@ERROR" line, and only then hands the connection to pipeline.Sender or +// pipeline.Receiver. That ack is required, not cosmetic: without it, a +// refused DirectionPut would leave the client's pipeline.Sender blocked +// writing a file list nobody is left to read - the same deadlock shape +// the greeting phase's unknown-module case has, avoided here the same +// way, by never letting either side commit to the transfer until the +// other has confirmed it's ready. +func ServeModule(c *conn, m Module) error { + line, err := readLine(c.r) + if err != nil { + return fmt.Errorf("reading direction: %w", err) + } + direction := Direction(line) + + if direction == DirectionPut && m.ReadOnly { + _ = writeLine(c.w, "@ERROR: module is read only") + return fmt.Errorf("%w: module %q", ErrReadOnly, m.Name) + } + if direction != DirectionGet && direction != DirectionPut { + _ = writeLine(c.w, fmt.Sprintf("@ERROR: invalid direction %q", line)) + return fmt.Errorf("invalid direction %q", line) + } + + if err := writeLine(c.w, "@RSYNCD: OK"); err != nil { + return fmt.Errorf("writing OK: %w", err) + } + + switch direction { + case DirectionGet: + rules, err := moduleRules(m) + if err != nil { + return fmt.Errorf("compiling module %q exclude rules: %w", m.Name, err) + } + if err := pipeline.Sender(c, m.Path, sync.WalkOptions{Recursive: true}, rules); err != nil { + return err + } + return waitForTransferDone(c) + case DirectionPut: + if err := pipeline.Receiver(c, m.Path, moduleAttrOptions()); err != nil { + return err + } + return writeLine(c.w, transferDone) + default: + panic("unreachable: direction validated above") + } +} + +// DialModule runs the client side of a module session: sends the +// requested direction, waits for the server's ack (see ServeModule) and +// fails without touching the pipeline at all if it's an @ERROR instead, +// then runs the matching pipeline side against localPath. rules and +// walkOpts only matter for DirectionPut (they govern what the client's +// own Sender walk includes); attrOpts only matters for DirectionGet (what +// the client's own Receiver preserves). +func DialModule(c *conn, direction Direction, localPath string, rules []sync.Rule, walkOpts sync.WalkOptions, attrOpts sync.AttrOptions) error { + if err := writeLine(c.w, string(direction)); err != nil { + return fmt.Errorf("sending direction: %w", err) + } + + ack, err := readLine(c.r) + if err != nil { + return fmt.Errorf("reading server ack: %w", err) + } + if ack != "@RSYNCD: OK" { + return fmt.Errorf("server refused module session: %s", ack) + } + + switch direction { + case DirectionGet: + if err := pipeline.Receiver(c, localPath, attrOpts); err != nil { + return err + } + return writeLine(c.w, transferDone) + case DirectionPut: + if err := pipeline.Sender(c, localPath, walkOpts, rules); err != nil { + return err + } + return waitForTransferDone(c) + default: + return fmt.Errorf("invalid direction %q", direction) + } +} diff --git a/internal/daemon/session_test.go b/internal/daemon/session_test.go new file mode 100644 index 0000000..71ad613 --- /dev/null +++ b/internal/daemon/session_test.go @@ -0,0 +1,145 @@ +package daemon + +import ( + "errors" + "io" + "os" + "path/filepath" + "testing" + + "github.com/syntaxroot-cc/grsync/internal/sync" +) + +func modulePipe() (client, server *conn) { + clientR, serverW := io.Pipe() + serverR, clientW := io.Pipe() + client = newConn(pipeReadWriter{Reader: clientR, Writer: clientW}) + server = newConn(pipeReadWriter{Reader: serverR, Writer: serverW}) + return client, server +} + +func mustWriteFile(t *testing.T, path, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("creating parent dirs: %v", err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("writing %q: %v", path, err) + } +} + +func TestServeModule_GetDownloadsFiles(t *testing.T) { + modRoot := t.TempDir() + mustWriteFile(t, filepath.Join(modRoot, "hello.txt"), "hello from the module") + mustWriteFile(t, filepath.Join(modRoot, "sub", "nested.txt"), "nested content") + + m := Module{Name: "public", Path: modRoot, ReadOnly: true} + + client, server := modulePipe() + serverErrCh := make(chan error, 1) + go func() { serverErrCh <- ServeModule(server, m) }() + + dest := t.TempDir() + if err := DialModule(client, DirectionGet, dest, nil, sync.WalkOptions{}, sync.AttrOptions{Perms: true}); err != nil { + t.Fatalf("DialModule returned error: %v", err) + } + if err := <-serverErrCh; err != nil { + t.Fatalf("ServeModule returned error: %v", err) + } + + got, err := os.ReadFile(filepath.Join(dest, "hello.txt")) + if err != nil { + t.Fatalf("reading downloaded hello.txt: %v", err) + } + if string(got) != "hello from the module" { + t.Errorf("hello.txt content = %q, want %q", got, "hello from the module") + } + got, err = os.ReadFile(filepath.Join(dest, "sub", "nested.txt")) + if err != nil { + t.Fatalf("reading downloaded sub/nested.txt: %v", err) + } + if string(got) != "nested content" { + t.Errorf("sub/nested.txt content = %q, want %q", got, "nested content") + } +} + +func TestServeModule_GetHonorsModuleExclude(t *testing.T) { + modRoot := t.TempDir() + mustWriteFile(t, filepath.Join(modRoot, "keep.txt"), "keep me") + mustWriteFile(t, filepath.Join(modRoot, "secret.key"), "should never leave the module") + + m := Module{Name: "public", Path: modRoot, ReadOnly: true, Exclude: []string{"*.key"}} + + client, server := modulePipe() + serverErrCh := make(chan error, 1) + go func() { serverErrCh <- ServeModule(server, m) }() + + dest := t.TempDir() + if err := DialModule(client, DirectionGet, dest, nil, sync.WalkOptions{}, sync.AttrOptions{}); err != nil { + t.Fatalf("DialModule returned error: %v", err) + } + if err := <-serverErrCh; err != nil { + t.Fatalf("ServeModule returned error: %v", err) + } + + if _, err := os.Stat(filepath.Join(dest, "keep.txt")); err != nil { + t.Errorf("keep.txt should have been downloaded: %v", err) + } + if _, err := os.Stat(filepath.Join(dest, "secret.key")); !os.IsNotExist(err) { + t.Errorf("secret.key should have been excluded, stat error = %v", err) + } +} + +func TestServeModule_PutToReadOnlyModuleFails(t *testing.T) { + modRoot := t.TempDir() + m := Module{Name: "public", Path: modRoot, ReadOnly: true} + + client, server := modulePipe() + serverErrCh := make(chan error, 1) + go func() { serverErrCh <- ServeModule(server, m) }() + + src := t.TempDir() + mustWriteFile(t, filepath.Join(src, "upload.txt"), "trying to push this up") + + dialErr := DialModule(client, DirectionPut, src, nil, sync.WalkOptions{}, sync.AttrOptions{}) + if dialErr == nil { + t.Fatalf("DialModule against a read-only module returned nil error, want an error") + } + if err := <-serverErrCh; !errors.Is(err, ErrReadOnly) { + t.Errorf("ServeModule error = %v, want ErrReadOnly", err) + } + if _, err := os.Stat(filepath.Join(modRoot, "upload.txt")); !os.IsNotExist(err) { + t.Errorf("upload.txt should not have been written to the read-only module, stat error = %v", err) + } +} + +func TestServeModule_PutToWritableModuleSucceeds(t *testing.T) { + modRoot := t.TempDir() + m := Module{Name: "incoming", Path: modRoot, ReadOnly: false} + + client, server := modulePipe() + serverErrCh := make(chan error, 1) + go func() { serverErrCh <- ServeModule(server, m) }() + + src := t.TempDir() + mustWriteFile(t, filepath.Join(src, "upload.txt"), "pushed content") + + rules, err := sync.CompileRules(nil) + if err != nil { + t.Fatalf("compiling empty rule set: %v", err) + } + if err := DialModule(client, DirectionPut, src, rules, sync.WalkOptions{}, sync.AttrOptions{}); err != nil { + t.Fatalf("DialModule returned error: %v", err) + } + if err := <-serverErrCh; err != nil { + t.Fatalf("ServeModule returned error: %v", err) + } + + got, err := os.ReadFile(filepath.Join(modRoot, "upload.txt")) + if err != nil { + t.Fatalf("reading uploaded file from module: %v", err) + } + if string(got) != "pushed content" { + t.Errorf("uploaded content = %q, want %q", got, "pushed content") + } +} diff --git a/internal/daemon/url.go b/internal/daemon/url.go new file mode 100644 index 0000000..dc152a6 --- /dev/null +++ b/internal/daemon/url.go @@ -0,0 +1,72 @@ +package daemon + +import ( + "fmt" + "net/url" + "strconv" + "strings" +) + +// DefaultPort is the standard rsync daemon TCP port. +const DefaultPort = 873 + +// URL is a parsed rsync://[user@]host[:port]/module[/path] endpoint. +// This is a distinct addressing scheme from internal/transport's +// [user@]host:path SSH syntax - the two coexist in the same CLI, and +// grsync's own scheme prefix ("rsync://") is what tells them apart, the +// same way it tells real rsync's two connection methods apart. +type URL struct { + User string // empty if no "user@" was present + Host string + // Port is 0 if the URL didn't specify one; callers should treat that + // as DefaultPort, not as "port zero" (an invalid TCP port anyway). + Port int + // Module is empty for a bare "rsync://host" or "rsync://host/" URL, + // which is not an error: that's real rsync's own syntax for "list + // this daemon's available modules" rather than selecting one. + Module string + // Path is whatever followed the module name, empty if nothing did. + Path string +} + +// ParseURL parses s as an rsync:// URL. +func ParseURL(s string) (URL, error) { + u, err := url.Parse(s) + if err != nil { + return URL{}, fmt.Errorf("parsing %q: %w", s, err) + } + if u.Scheme != "rsync" { + return URL{}, fmt.Errorf("%q is not an rsync:// URL (scheme %q)", s, u.Scheme) + } + if u.Hostname() == "" { + return URL{}, fmt.Errorf("%q has no host", s) + } + + result := URL{Host: u.Hostname()} + if u.User != nil { + result.User = u.User.Username() + } + if portStr := u.Port(); portStr != "" { + port, err := strconv.Atoi(portStr) + if err != nil { + // u.Port() already validates this is numeric per net/url's + // own rules, so this should be unreachable - checked anyway + // rather than trusting that invariant silently. + return URL{}, fmt.Errorf("%q has an invalid port %q", s, portStr) + } + result.Port = port + } + + path := strings.TrimPrefix(u.Path, "/") + if path == "" { + return result, nil // no module: valid "list modules" form + } + if slash := strings.IndexByte(path, '/'); slash != -1 { + result.Module = path[:slash] + result.Path = path[slash+1:] + } else { + result.Module = path + } + + return result, nil +} diff --git a/internal/daemon/url_test.go b/internal/daemon/url_test.go new file mode 100644 index 0000000..0643cff --- /dev/null +++ b/internal/daemon/url_test.go @@ -0,0 +1,53 @@ +package daemon + +import "testing" + +func TestParseURL(t *testing.T) { + tests := []struct { + name string + input string + want URL + }{ + {"host and module", "rsync://example.com/mymodule", URL{Host: "example.com", Module: "mymodule"}}, + {"host, module, and path", "rsync://example.com/mymodule/some/dir", URL{Host: "example.com", Module: "mymodule", Path: "some/dir"}}, + {"user and host", "rsync://alice@example.com/mymodule", URL{User: "alice", Host: "example.com", Module: "mymodule"}}, + {"explicit port", "rsync://example.com:8730/mymodule", URL{Host: "example.com", Port: 8730, Module: "mymodule"}}, + {"user, port, module, and path", "rsync://bob@example.com:8730/mod/a/b", URL{User: "bob", Host: "example.com", Port: 8730, Module: "mod", Path: "a/b"}}, + {"no module (list-modules form)", "rsync://example.com", URL{Host: "example.com"}}, + {"no module, trailing slash", "rsync://example.com/", URL{Host: "example.com"}}, + {"ipv6 host", "rsync://[::1]/mymodule", URL{Host: "::1", Module: "mymodule"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseURL(tt.input) + if err != nil { + t.Fatalf("ParseURL(%q) returned error: %v", tt.input, err) + } + if got != tt.want { + t.Errorf("ParseURL(%q) = %+v, want %+v", tt.input, got, tt.want) + } + }) + } +} + +func TestParseURL_InvalidVariants(t *testing.T) { + tests := []struct { + name string + input string + }{ + {"wrong scheme", "http://example.com/mymodule"}, + {"no scheme at all", "example.com/mymodule"}, + {"ssh-style syntax, not a URL", "user@example.com:mymodule"}, + {"empty host", "rsync:///mymodule"}, + {"empty string", ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if _, err := ParseURL(tt.input); err == nil { + t.Errorf("ParseURL(%q) returned nil error, want an error", tt.input) + } + }) + } +}