Skip to content
Open
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
88 changes: 84 additions & 4 deletions internal/maincmd/clientserver.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,13 @@ package maincmd
import (
"bufio"
"context"
"encoding/base64"
"fmt"
"io"
"net"
"net/url"
"os"
"os/user"
"strconv"
"strings"
"time"
Expand All @@ -15,10 +19,25 @@ import (
"github.com/gokrazy/rsync/internal/rsyncopts"
"github.com/gokrazy/rsync/internal/rsyncos"
"github.com/gokrazy/rsync/internal/rsyncstats"
"github.com/mmcloughlin/md4"
)

// rsync/clientserver.c:start_socket_client
func socketClient(ctx context.Context, osenv *rsyncos.Env, opts *rsyncopts.Options, host string, remotePath string, port int, paths []string, roDirs, rwDirs []string) (*rsyncstats.TransferStats, error) {
// Extract user[:password]@ from host (daemon protocol only).
// Password may be percent-encoded from net/url parsing.
var urlUser, urlPass string
if idx := strings.IndexByte(host, '@'); idx > -1 {
userinfo := host[:idx]
host = host[idx+1:]
if ci := strings.IndexByte(userinfo, ':'); ci > -1 {
urlUser = userinfo[:ci]
urlPass, _ = url.PathUnescape(userinfo[ci+1:])
} else {
urlUser = userinfo
}
}

if port < 0 {
if port := opts.RsyncPort(); port > 0 {
host += ":" + strconv.Itoa(port)
Expand Down Expand Up @@ -52,7 +71,7 @@ func socketClient(ctx context.Context, osenv *rsyncos.Env, opts *rsyncopts.Optio
return nil, err
}
}
done, err := StartInbandExchange(osenv, opts, conn, remotePath)
done, err := startInbandExchange(osenv, opts, conn, remotePath, urlUser, urlPass)
if err != nil {
return nil, err
}
Expand All @@ -66,8 +85,14 @@ func socketClient(ctx context.Context, osenv *rsyncos.Env, opts *rsyncopts.Optio
return stats, nil
}

// rsync/clientserver.c:start_inband_exchange
// StartInbandExchange is the public API for daemon-over-remote-shell
// and the rsyncclient package. Auth credentials come from env/file only.
func StartInbandExchange(osenv *rsyncos.Env, opts *rsyncopts.Options, conn io.ReadWriter, remotePath string) (done bool, _ error) {
return startInbandExchange(osenv, opts, conn, remotePath, "", "")
}

// rsync/clientserver.c:start_inband_exchange
func startInbandExchange(osenv *rsyncos.Env, opts *rsyncopts.Options, conn io.ReadWriter, remotePath string, urlUser, urlPass string) (done bool, _ error) {
module := remotePath
if idx := strings.IndexByte(module, '/'); idx > -1 {
module = module[:idx]
Expand Down Expand Up @@ -119,8 +144,15 @@ func StartInbandExchange(osenv *rsyncos.Env, opts *rsyncopts.Options, conn io.Re
}

if strings.HasPrefix(line, "@RSYNCD: AUTHREQD ") {
// TODO: implement support for authentication
return false, fmt.Errorf("authentication not yet implemented")
challenge := strings.TrimPrefix(line, "@RSYNCD: AUTHREQD ")
authUser := resolveUsername(urlUser)
pass, err := getPassword(opts, urlPass)
if err != nil {
return false, fmt.Errorf("authentication required: %v", err)
}
hash := generateAuthHash(pass, challenge)
fmt.Fprintf(conn, "%s %s\n", authUser, hash)
continue
}

if line == "@RSYNCD: OK" {
Expand Down Expand Up @@ -155,3 +187,51 @@ func StartInbandExchange(osenv *rsyncos.Env, opts *rsyncopts.Options, conn io.Re

return false, nil
}

// resolveUsername returns the auth username from (in priority order):
// URL user, RSYNC_USERNAME env, current OS user, or "nobody".
func resolveUsername(urlUser string) string {
if urlUser != "" {
return urlUser
}
if u := os.Getenv("RSYNC_USERNAME"); u != "" {
return u
}
if u, err := user.Current(); err == nil && u.Username != "" {
return u.Username
}
return "nobody"
}

// getPassword returns the auth password from (in priority order):
// URL password, --password-file, or RSYNC_PASSWORD env.
func getPassword(opts *rsyncopts.Options, urlPass string) (string, error) {
if urlPass != "" {
return urlPass, nil
}
if f := opts.PasswordFile(); f != "" {
data, err := os.ReadFile(f)
if err != nil {
return "", fmt.Errorf("reading password file: %v", err)
}
lines := strings.SplitN(string(data), "\n", 2)
return strings.TrimRight(lines[0], "\r"), nil
}
if p := os.Getenv("RSYNC_PASSWORD"); p != "" {
return p, nil
}
return "", fmt.Errorf("no password supplied (set RSYNC_PASSWORD or use --password-file)")
}

// generateAuthHash computes the rsync CSUM_MD4_OLD auth response:
// MD4(seed + password + challenge), base64-encoded without padding.
// CSUM_MD4_OLD prepends a 4-byte little-endian seed (0 for auth)
// before the password and challenge data.
func generateAuthHash(password, challenge string) string {
h := md4.New()
h.Write([]byte{0, 0, 0, 0})
h.Write([]byte(password))
h.Write([]byte(challenge))
digest := h.Sum(nil)
return base64.StdEncoding.WithPadding(base64.NoPadding).EncodeToString(digest)
}
17 changes: 13 additions & 4 deletions internal/maincmd/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package maincmd

import (
"fmt"
"net/url"
"os"
"strconv"
"strings"
Expand Down Expand Up @@ -104,10 +105,18 @@ func parseHostspec(src string, parsingURL bool) (host, path string, port int, _
// rsync/options.c:check_for_hostspec
func checkForHostspec(src string) (host, path string, port int, _ error) {
if strings.HasPrefix(src, "rsync://") {
var err error
if host, path, port, err = parseHostspec(strings.TrimPrefix(src, "rsync://"), true); err == nil {
if port == 0 {
port = -1
u, err := url.Parse(src)
if err == nil && u.Hostname() != "" {
host = u.Hostname()
if u.User != nil {
host = u.User.String() + "@" + host
}
path = strings.TrimPrefix(u.Path, "/")
port = -1
if u.Port() != "" {
if p, err := strconv.Atoi(u.Port()); err == nil {
port = p
}
}
return host, path, port, nil
}
Expand Down
3 changes: 2 additions & 1 deletion internal/rsyncopts/rsyncopts.go
Original file line number Diff line number Diff line change
Expand Up @@ -728,6 +728,7 @@ func (o *Options) AlwaysChecksum() bool { return o.always_checksum != 0 }
func (o *Options) IgnoreTimes() bool { return o.ignore_times != 0 }
func (o *Options) OutputMOTD() bool { return o.output_motd != 0 }
func (o *Options) RsyncPort() int { return o.rsync_port }
func (o *Options) PasswordFile() string { return o.password_file }
func (o *Options) XferDirs() int { return o.xfer_dirs }
func (o *Options) FilterRules() []string { return o.filterRules }
func (o *Options) Progress() bool {
Expand Down Expand Up @@ -1021,7 +1022,7 @@ func (o *Options) gokrazyTable() []poptOption {
//{"address", "", POPT_ARG_STRING, &o.bind_address, 0},
{"port", "", POPT_ARG_INT, &o.rsync_port, 0},
//{"sockopts", "", POPT_ARG_STRING, &o.sockopts, 0},
//{"password-file", "", POPT_ARG_STRING, &o.password_file, 0},
{"password-file", "", POPT_ARG_STRING, &o.password_file, 0},
//{"early-input", "", POPT_ARG_STRING, &o.early_input_file, 0},
//{"blocking-io", "", POPT_ARG_VAL, &o.blocking_io, 1},
//{"no-blocking-io", "", POPT_ARG_VAL, &o.blocking_io, 0},
Expand Down
107 changes: 102 additions & 5 deletions rsyncd/rsyncd.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ package rsyncd
import (
"bufio"
"context"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"io"
Expand All @@ -25,14 +27,17 @@ import (
"github.com/gokrazy/rsync/internal/rsyncos"
"github.com/gokrazy/rsync/internal/rsyncwire"
"github.com/gokrazy/rsync/internal/sender"
"github.com/mmcloughlin/md4"
)

type Module struct {
Name string `toml:"name"`
Path string `toml:"path"` // If empty, FS must be non-nil
FS fs.FS `toml:"-"` // If set, serve from this instead of Path
ACL []string `toml:"acl"`
Writable bool `toml:"writable"` // Must be false if FS is set
Name string `toml:"name"`
Path string `toml:"path"` // If empty, FS must be non-nil
FS fs.FS `toml:"-"` // If set, serve from this instead of Path
ACL []string `toml:"acl"`
Writable bool `toml:"writable"` // Must be false if FS is set
AuthUsers []string `toml:"auth_users"` // Usernames allowed to connect; empty means no auth
SecretsFile string `toml:"secrets_file"` // Path to file with user:password lines
}

// Option specifies the server options.
Expand Down Expand Up @@ -229,6 +234,13 @@ func (s *Server) HandleDaemonConn(ctx context.Context, conn *Conn) (err error) {
return err
}

if len(module.AuthUsers) > 0 {
if err := s.authServer(rd, cwr, &module, conn.name); err != nil {
fmt.Fprintf(cwr, "@ERROR: auth failed on module %s\n", module.Name)
return err
}
}

io.WriteString(cwr, terminationCommand)

// read requested flags
Expand Down Expand Up @@ -603,6 +615,91 @@ func (s *Server) Serve(ctx context.Context, ln net.Listener) error {
}
}

func (s *Server) authServer(rd *bufio.Reader, cwr io.Writer, module *Module, remoteAddr string) error {
challenge := genChallenge()
fmt.Fprintf(cwr, "@RSYNCD: AUTHREQD %s\n", challenge)

line, err := rd.ReadString('\n')
if err != nil {
return fmt.Errorf("reading auth response: %v", err)
}
line = strings.TrimSpace(line)
sp := strings.IndexByte(line, ' ')
if sp < 0 {
s.logger.Printf("auth failed on module %s from %s: invalid response", module.Name, remoteAddr)
return fmt.Errorf("invalid auth response")
}
user, response := line[:sp], line[sp+1:]

matched := false
for _, allowed := range module.AuthUsers {
if allowed == user {
matched = true
break
}
}
if !matched {
s.logger.Printf("auth failed on module %s from %s for %s: unknown user", module.Name, remoteAddr, user)
return fmt.Errorf("auth failed")
}

secret, err := lookupSecret(module.SecretsFile, user)
if err != nil {
s.logger.Printf("auth failed on module %s from %s for %s: %v", module.Name, remoteAddr, user, err)
return fmt.Errorf("auth failed")
}

expected := authHash(secret, challenge)
if response != expected {
s.logger.Printf("auth failed on module %s from %s for %s: password mismatch", module.Name, remoteAddr, user)
return fmt.Errorf("auth failed")
}

s.logger.Printf("auth ok on module %s from %s for %s", module.Name, remoteAddr, user)
return nil
}

func genChallenge() string {
var buf [16]byte
rand.Read(buf[:])
h := md4.New()
h.Write([]byte{0, 0, 0, 0})
h.Write(buf[:])
return base64.StdEncoding.WithPadding(base64.NoPadding).EncodeToString(h.Sum(nil))
}

func authHash(password, challenge string) string {
h := md4.New()
h.Write([]byte{0, 0, 0, 0})
h.Write([]byte(password))
h.Write([]byte(challenge))
return base64.StdEncoding.WithPadding(base64.NoPadding).EncodeToString(h.Sum(nil))
}

func lookupSecret(path, user string) (string, error) {
if path == "" {
return "", fmt.Errorf("no secrets file configured")
}
data, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("reading secrets file: %v", err)
}
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if line == "" || line[0] == '#' {
continue
}
parts := strings.SplitN(line, ":", 2)
if len(parts) != 2 {
continue
}
if parts[0] == user {
return parts[1], nil
}
}
return "", fmt.Errorf("user not found in secrets file")
}

func validateModule(mod Module) error {
if mod.Name == "" {
return errors.New("module has no name")
Expand Down