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
13 changes: 13 additions & 0 deletions .golangci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
151 changes: 151 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 <challenge>` with a fresh random challenge; the client
answers `<user> <response>` 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 <name>`, 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.
Expand All @@ -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).
Expand Down
5 changes: 4 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
45 changes: 45 additions & 0 deletions internal/cli/daemon.go
Original file line number Diff line number Diff line change
@@ -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())
}
17 changes: 17 additions & 0 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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/
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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
}
Expand Down
Loading