diff options
| author | Lena <lena@omega> | 2026-01-01 00:00:00 +0000 |
|---|---|---|
| committer | Lena <lena@omega> | 2026-01-01 00:00:00 +0000 |
| commit | 7e04941bccb2683f8a6e3ee38a99c50129234dd1 (patch) | |
| tree | 471227fa437291e7a6b499e3de6c106c54eaf311 /rsh | |
| download | rsend-7e04941bccb2683f8a6e3ee38a99c50129234dd1.tar.gz | |
rsend: push phone folders to a home SSH host over rsync
A small Android app for one-way folder backup, a KISS alternative to
Syncthing. It bundles rsync (built from pinned source via the NDK) and
a pure-Go SSH transport, both shipped in the APK as lib*.so and run from
the native library directory.
rsend pins the host key, stores the ed25519 identity Keystore-encrypted,
pushes each folder additively or as a mirror, and runs on demand or on a
WiFi-only schedule. The build is self-contained and reproducible: make
setup provisions the toolchain, make builds rsync, rsh, and the APK.
Diffstat (limited to 'rsh')
| -rwxr-xr-x | rsh/build.sh | 49 | ||||
| -rw-r--r-- | rsh/e2e_test.go | 84 | ||||
| -rw-r--r-- | rsh/go.mod | 7 | ||||
| -rw-r--r-- | rsh/go.sum | 6 | ||||
| -rw-r--r-- | rsh/main.go | 273 | ||||
| -rw-r--r-- | rsh/sshserver_test.go | 141 | ||||
| -rw-r--r-- | rsh/transport_test.go | 224 |
7 files changed, 784 insertions, 0 deletions
diff --git a/rsh/build.sh b/rsh/build.sh new file mode 100755 index 0000000..88cdf9c --- /dev/null +++ b/rsh/build.sh @@ -0,0 +1,49 @@ +#!/bin/sh +# Build the rsh SSH transport for each Android ABI as a Bionic PIE executable +# named lib*.so (packaged inside the APK and exec'd from nativeLibraryDir), +# using the Android NDK. Also build a plain host binary for tests and manual +# debugging. +# +# Android needs PIE and the Bionic dynamic linker (/system/bin/linker64), so +# the on-device build is GOOS=android with cgo and the NDK clang. A GOOS=linux +# build targets glibc's linker and will not exec on a phone. +set -eu + +root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +. "$root/versions" + +# Locate the NDK toolchain. +ndk=${ANDROID_NDK_HOME:-${ANDROID_NDK_ROOT:-}} +if [ -z "$ndk" ] && [ -n "${ANDROID_HOME:-}" ]; then + ndk="$ANDROID_HOME/ndk/$ANDROID_NDK" +fi +if [ -z "$ndk" ] || [ ! -d "$ndk" ]; then + echo "rsh: Android NDK not found; set ANDROID_NDK_HOME" >&2 + exit 1 +fi +tc="$ndk/toolchains/llvm/prebuilt/linux-x86_64/bin" + +ldflags="-s -w -buildid=" + +cd "$root/rsh" + +for abi in $ABIS; do + case "$abi" in + arm64-v8a) goarch=arm64 goarm= cc="aarch64-linux-android${ANDROID_MIN_SDK}-clang" ;; + armeabi-v7a) goarch=arm goarm=7 cc="armv7a-linux-androideabi${ANDROID_MIN_SDK}-clang" ;; + x86_64) goarch=amd64 goarm= cc="x86_64-linux-android${ANDROID_MIN_SDK}-clang" ;; + x86) goarch=386 goarm= cc="i686-linux-android${ANDROID_MIN_SDK}-clang" ;; + *) echo "rsh: unknown ABI $abi" >&2; exit 1 ;; + esac + dest="$root/app/src/main/jniLibs/$abi" + mkdir -p "$dest" + echo "rsh: building $abi (android/$goarch)" + CGO_ENABLED=1 GOOS=android GOARCH="$goarch" GOARM="$goarm" \ + CC="$tc/$cc" GOFLAGS=-trimpath CGO_CFLAGS="-ffile-prefix-map=$root=." \ + go build -buildmode=pie -ldflags "$ldflags" -o "$dest/libxrsh.so" . +done + +# Host binary (no NDK, cgo off) for tests and manual debugging. +mkdir -p "$root/out" +echo "rsh: building host binary -> out/rsh" +CGO_ENABLED=0 GOFLAGS=-trimpath go build -ldflags "$ldflags" -o "$root/out/rsh" . diff --git a/rsh/e2e_test.go b/rsh/e2e_test.go new file mode 100644 index 0000000..1fa80c7 --- /dev/null +++ b/rsh/e2e_test.go @@ -0,0 +1,84 @@ +package main + +import ( + "bytes" + "os" + "os/exec" + "path/filepath" + "strconv" + "testing" +) + +// TestEndToEndRealRsync drives the actual rsync binary through the actual rsh +// transport binary into the in-process SSH server, which runs the remote rsync +// via /bin/sh. It proves real rsync + the Go transport move files intact. +func TestEndToEndRealRsync(t *testing.T) { + if testing.Short() { + t.Skip("short mode") + } + rsyncBin, err := exec.LookPath("rsync") + if err != nil { + t.Skip("rsync not installed") + } + + // Build the rsh binary so rsync can exec it as its remote shell. + rshBin := filepath.Join(t.TempDir(), "rsh") + if out, err := exec.Command("go", "build", "-o", rshBin, ".").CombinedOutput(); err != nil { + t.Fatalf("build rsh: %v\n%s", err, out) + } + + keyPath, pub := genClientKey(t) + srv := newTestServer(t, pub, shellExec) + kh := writeKnownHosts(t, "127.0.0.1", srv.port(), srv.hostKey.PublicKey()) + + src := t.TempDir() + dst := t.TempDir() + mustWrite(t, filepath.Join(src, "a.txt"), "alpha") + mustWrite(t, filepath.Join(src, "sub", "b.bin"), "\x00\x01\x02\x03beta") + mustWrite(t, filepath.Join(src, "sub", "c.txt"), "gamma gamma gamma") + + cmd := exec.Command(rsyncBin, "-a", "-e", rshBin, src+"/", "u@127.0.0.1:"+dst+"/") + cmd.Env = append(os.Environ(), + "RSH_KEY="+keyPath, + "RSH_KNOWN_HOSTS="+kh, + "RSH_PORT="+strconv.Itoa(srv.port()), + ) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("rsync: %v\n%s", err, out) + } + + checkSame(t, src, dst) +} + +func mustWrite(t *testing.T, path, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +func checkSame(t *testing.T, src, dst string) { + t.Helper() + err := filepath.Walk(src, func(p string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() { + return err + } + rel, _ := filepath.Rel(src, p) + want, _ := os.ReadFile(p) + got, err := os.ReadFile(filepath.Join(dst, rel)) + if err != nil { + t.Errorf("missing on dest: %s (%v)", rel, err) + return nil + } + if !bytes.Equal(got, want) { + t.Errorf("content mismatch for %s", rel) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} diff --git a/rsh/go.mod b/rsh/go.mod new file mode 100644 index 0000000..661c774 --- /dev/null +++ b/rsh/go.mod @@ -0,0 +1,7 @@ +module rsend/rsh + +go 1.25.0 + +require golang.org/x/crypto v0.53.0 + +require golang.org/x/sys v0.46.0 // indirect diff --git a/rsh/go.sum b/rsh/go.sum new file mode 100644 index 0000000..68cadc1 --- /dev/null +++ b/rsh/go.sum @@ -0,0 +1,6 @@ +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= diff --git a/rsh/main.go b/rsh/main.go new file mode 100644 index 0000000..31029cb --- /dev/null +++ b/rsh/main.go @@ -0,0 +1,273 @@ +// rsh is rsend's SSH transport. It is invoked three ways: +// +// rsh [-l USER] [USER@]HOST CMD... rsync remote shell (rsync -e), strict +// rsh -keygen DIR generate an ed25519 key, print the pubkey +// rsh -pubkey print the pubkey for RSH_KEY_DATA/RSH_KEY +// rsh -scan USER@HOST connect, print host-key fingerprint + line +// +// Transport mode mirrors what ssh does for rsync: it dials the host, runs the +// remote command, and bridges stdin/stdout/stderr. Host keys are verified +// strictly against RSH_KNOWN_HOSTS; an unknown or changed key fails loud. +// Key, known_hosts path, and port come from the environment because rsync owns +// the argument vector: +// +// RSH_KEY path to the ed25519 private key +// RSH_KEY_DATA the private key itself (PEM), preferred over RSH_KEY so the +// app need never write the plaintext key to disk +// RSH_KNOWN_HOSTS path to the known_hosts file (required in transport mode) +// RSH_PORT TCP port (optional, default 22) +// +// Pure Go, no cgo. +package main + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/pem" + "errors" + "fmt" + "io" + "net" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "golang.org/x/crypto/ssh" + "golang.org/x/crypto/ssh/knownhosts" +) + +const dialTimeout = 30 * time.Second + +func main() { + if err := run(os.Args[1:], os.Stdin, os.Stdout, os.Stderr); err != nil { + var ee *ssh.ExitError + if errors.As(err, &ee) { + os.Exit(ee.ExitStatus()) + } + fmt.Fprintf(os.Stderr, "rsh: %v\n", err) + os.Exit(1) + } +} + +func run(args []string, in io.Reader, out, errw io.Writer) error { + if len(args) >= 1 { + switch args[0] { + case "-keygen": + if len(args) != 2 { + return errors.New("usage: rsh -keygen DIR") + } + return keygen(args[1], out) + case "-pubkey": + if len(args) != 1 { + return errors.New("usage: rsh -pubkey") + } + return pubkey(out) + case "-scan": + if len(args) != 2 { + return errors.New("usage: rsh -scan USER@HOST") + } + return scan(args[1], out) + } + } + return transport(args, in, out, errw) +} + +// transport runs the remote command and bridges I/O, verifying the host key +// strictly against RSH_KNOWN_HOSTS. +func transport(args []string, in io.Reader, out, errw io.Writer) error { + user, host, cmd, err := parseTransport(args) + if err != nil { + return err + } + if user == "" { + return errors.New("no remote user (expected -l USER or USER@HOST)") + } + if len(cmd) == 0 { + return errors.New("no remote command") + } + kh := os.Getenv("RSH_KNOWN_HOSTS") + if kh == "" { + return errors.New("RSH_KNOWN_HOSTS not set") + } + cb, err := knownhosts.New(kh) + if err != nil { + return fmt.Errorf("known_hosts: %w (run Test connection first)", err) + } + client, err := dial(user, host, cb) + if err != nil { + return err + } + defer client.Close() + go keepAlive(client) + + session, err := client.NewSession() + if err != nil { + return err + } + defer session.Close() + session.Stdin = in + session.Stdout = out + session.Stderr = errw + return session.Run(strings.Join(cmd, " ")) +} + +// scan connects (authenticating, so it also proves the key is installed), +// captures the presented host key, and prints its SHA256 fingerprint and a +// known_hosts line for the caller to pin. +func scan(target string, out io.Writer) error { + user, host := splitUserHost(target) + if user == "" { + return errors.New("scan target must be USER@HOST") + } + var hostKey ssh.PublicKey + capture := func(_ string, _ net.Addr, key ssh.PublicKey) error { + hostKey = key + return nil + } + client, err := dial(user, host, capture) + if err != nil { + return err + } + client.Close() + + addr := knownhosts.Normalize(net.JoinHostPort(host, strconv.Itoa(sshPort()))) + line := knownhosts.Line([]string{addr}, hostKey) + fmt.Fprintf(out, "%s\n%s\n", ssh.FingerprintSHA256(hostKey), line) + return nil +} + +// keygen writes an ed25519 key pair into dir and prints the public key in +// authorized_keys format to out. +func keygen(dir string, out io.Writer) error { + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return err + } + block, err := ssh.MarshalPrivateKey(priv, "rsend") + if err != nil { + return err + } + if err := os.WriteFile(filepath.Join(dir, "id_ed25519"), pem.EncodeToMemory(block), 0o600); err != nil { + return err + } + sshPub, err := ssh.NewPublicKey(pub) + if err != nil { + return err + } + authLine := ssh.MarshalAuthorizedKey(sshPub) + if err := os.WriteFile(filepath.Join(dir, "id_ed25519.pub"), authLine, 0o644); err != nil { + return err + } + _, err = out.Write(authLine) + return err +} + +// pubkey loads the private key (RSH_KEY_DATA or RSH_KEY) and prints its public +// key in authorized_keys format. The app uses it to validate and display a key +// the user imported: if loadSigner accepts it here, the transport will too. +func pubkey(out io.Writer) error { + signer, err := loadSigner() + if err != nil { + return err + } + _, err = out.Write(ssh.MarshalAuthorizedKey(signer.PublicKey())) + return err +} + +// loadSigner reads the private key from RSH_KEY_DATA (the key itself) or, if +// that is unset, the file named by RSH_KEY. +func loadSigner() (ssh.Signer, error) { + if data := os.Getenv("RSH_KEY_DATA"); data != "" { + return ssh.ParsePrivateKey([]byte(data)) + } + keyPath := os.Getenv("RSH_KEY") + if keyPath == "" { + return nil, errors.New("RSH_KEY or RSH_KEY_DATA not set") + } + pemBytes, err := os.ReadFile(keyPath) + if err != nil { + return nil, fmt.Errorf("read key: %w", err) + } + return ssh.ParsePrivateKey(pemBytes) +} + +// dial opens an SSH connection authenticated with the configured key, verifying +// the host key with hostKey. +func dial(user, host string, hostKey ssh.HostKeyCallback) (*ssh.Client, error) { + signer, err := loadSigner() + if err != nil { + return nil, err + } + cfg := &ssh.ClientConfig{ + User: user, + Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)}, + HostKeyCallback: hostKey, + Timeout: dialTimeout, + } + addr := net.JoinHostPort(host, strconv.Itoa(sshPort())) + return ssh.Dial("tcp", addr, cfg) +} + +// parseTransport extracts the user, host, and remote command from the argument +// vector rsync hands to its remote shell: an optional "-l USER", the host +// (possibly USER@HOST), then the remote command. +func parseTransport(args []string) (user, host string, cmd []string, err error) { + i := 0 + for i < len(args) { + a := args[i] + if a == "-l" { + if i+1 >= len(args) { + return "", "", nil, errors.New("-l requires an argument") + } + user = args[i+1] + i += 2 + continue + } + if strings.HasPrefix(a, "-") { + return "", "", nil, fmt.Errorf("unexpected option %q", a) + } + break + } + if i >= len(args) { + return "", "", nil, errors.New("missing host") + } + host = args[i] + cmd = args[i+1:] + if u, h := splitUserHost(host); u != "" { + host = h + if user == "" { + user = u + } + } + return user, host, cmd, nil +} + +func splitUserHost(s string) (user, host string) { + if i := strings.Index(s, "@"); i >= 0 { + return s[:i], s[i+1:] + } + return "", s +} + +func sshPort() int { + if s := os.Getenv("RSH_PORT"); s != "" { + if p, err := strconv.Atoi(s); err == nil && p > 0 { + return p + } + } + return 22 +} + +// keepAlive pings the server so a long transfer is not dropped by an idle NAT. +// It returns once the connection is closed. +func keepAlive(client *ssh.Client) { + t := time.NewTicker(60 * time.Second) + defer t.Stop() + for range t.C { + if _, _, err := client.SendRequest("keepalive@openssh.com", true, nil); err != nil { + return + } + } +} diff --git a/rsh/sshserver_test.go b/rsh/sshserver_test.go new file mode 100644 index 0000000..087bff4 --- /dev/null +++ b/rsh/sshserver_test.go @@ -0,0 +1,141 @@ +package main + +import ( + "crypto/ed25519" + "crypto/rand" + "errors" + "fmt" + "io" + "net" + "os/exec" + "testing" + + "golang.org/x/crypto/ssh" +) + +// execHandler runs the remote command for one session and returns its exit +// code. +type execHandler func(cmd string, stdin io.Reader, stdout, stderr io.Writer) int + +// testServer is a minimal in-process SSH server for tests: one host key, one +// accepted client public key, and a pluggable exec handler. No sshd needed. +type testServer struct { + ln net.Listener + hostKey ssh.Signer + authPub ssh.PublicKey + handle execHandler +} + +func newTestServer(t *testing.T, clientPub ssh.PublicKey, handle execHandler) *testServer { + t.Helper() + _, hpriv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + signer, err := ssh.NewSignerFromKey(hpriv) + if err != nil { + t.Fatal(err) + } + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + s := &testServer{ln: ln, hostKey: signer, authPub: clientPub, handle: handle} + go s.serve() + t.Cleanup(func() { ln.Close() }) + return s +} + +func (s *testServer) port() int { return s.ln.Addr().(*net.TCPAddr).Port } + +func (s *testServer) serve() { + cfg := &ssh.ServerConfig{ + PublicKeyCallback: func(_ ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) { + if s.authPub != nil && string(key.Marshal()) == string(s.authPub.Marshal()) { + return &ssh.Permissions{}, nil + } + return nil, fmt.Errorf("unknown public key") + }, + } + cfg.AddHostKey(s.hostKey) + for { + nConn, err := s.ln.Accept() + if err != nil { + return + } + go s.handleConn(nConn, cfg) + } +} + +func (s *testServer) handleConn(nConn net.Conn, cfg *ssh.ServerConfig) { + conn, chans, reqs, err := ssh.NewServerConn(nConn, cfg) + if err != nil { + return + } + defer conn.Close() + go ssh.DiscardRequests(reqs) + for newCh := range chans { + if newCh.ChannelType() != "session" { + newCh.Reject(ssh.UnknownChannelType, "only session") + continue + } + ch, requests, err := newCh.Accept() + if err != nil { + return + } + go s.handleSession(ch, requests) + } +} + +func (s *testServer) handleSession(ch ssh.Channel, requests <-chan *ssh.Request) { + for req := range requests { + if req.Type != "exec" { + if req.WantReply { + req.Reply(false, nil) + } + continue + } + var payload struct{ Command string } + ssh.Unmarshal(req.Payload, &payload) + if req.WantReply { + req.Reply(true, nil) + } + code := s.handle(payload.Command, ch, ch, ch.Stderr()) + ch.SendRequest("exit-status", false, ssh.Marshal(struct{ Status uint32 }{uint32(code)})) + ch.Close() + return + } +} + +// shellExec runs cmd via /bin/sh -c, exactly as sshd would, wiring the channel +// to the process. Used by the real-rsync end-to-end test. +// +// stdin is fed through a detached goroutine rather than c.Stdin so that Wait +// does not block on the stdin copy: rsync's remote receiver exits while the +// client still holds the channel open, and a c.Run with a non-file Stdin would +// deadlock waiting for that copy to finish. +func shellExec(cmd string, stdin io.Reader, stdout, stderr io.Writer) int { + c := exec.Command("/bin/sh", "-c", cmd) + c.Stdout = stdout + c.Stderr = stderr + inPipe, err := c.StdinPipe() + if err != nil { + return 1 + } + if err := c.Start(); err != nil { + return 1 + } + go func() { + io.Copy(inPipe, stdin) + inPipe.Close() + }() + err = c.Wait() + var ee *exec.ExitError + if errors.As(err, &ee) { + return ee.ExitCode() + } + if err != nil { + return 1 + } + return 0 +} diff --git a/rsh/transport_test.go b/rsh/transport_test.go new file mode 100644 index 0000000..562c4d2 --- /dev/null +++ b/rsh/transport_test.go @@ -0,0 +1,224 @@ +package main + +import ( + "bytes" + "errors" + "io" + "net" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + + "golang.org/x/crypto/ssh" + "golang.org/x/crypto/ssh/knownhosts" +) + +// genClientKey makes a client key via keygen and returns the private key path +// and the parsed public key. +func genClientKey(t *testing.T) (keyPath string, pub ssh.PublicKey) { + t.Helper() + dir := t.TempDir() + if err := keygen(dir, io.Discard); err != nil { + t.Fatal(err) + } + keyPath = filepath.Join(dir, "id_ed25519") + pb, err := os.ReadFile(filepath.Join(dir, "id_ed25519.pub")) + if err != nil { + t.Fatal(err) + } + pub, _, _, _, err = ssh.ParseAuthorizedKey(pb) + if err != nil { + t.Fatal(err) + } + return keyPath, pub +} + +func writeKnownHosts(t *testing.T, host string, port int, hostKey ssh.PublicKey) string { + t.Helper() + addr := knownhosts.Normalize(net.JoinHostPort(host, strconv.Itoa(port))) + line := knownhosts.Line([]string{addr}, hostKey) + p := filepath.Join(t.TempDir(), "known_hosts") + if err := os.WriteFile(p, []byte(line+"\n"), 0o600); err != nil { + t.Fatal(err) + } + return p +} + +func setEnv(t *testing.T, key, kh string, port int) { + t.Setenv("RSH_KEY", key) + t.Setenv("RSH_KNOWN_HOSTS", kh) + t.Setenv("RSH_PORT", strconv.Itoa(port)) +} + +func TestTransportBridgesStdio(t *testing.T) { + keyPath, pub := genClientKey(t) + echo := func(_ string, stdin io.Reader, stdout, _ io.Writer) int { + io.Copy(stdout, stdin) + return 0 + } + srv := newTestServer(t, pub, echo) + kh := writeKnownHosts(t, "127.0.0.1", srv.port(), srv.hostKey.PublicKey()) + setEnv(t, keyPath, kh, srv.port()) + + want := []byte("the quick brown fox\x00\x01\x02 binary tail") + var out bytes.Buffer + if err := transport([]string{"-l", "u", "127.0.0.1", "cat"}, bytes.NewReader(want), &out, io.Discard); err != nil { + t.Fatalf("transport: %v", err) + } + if !bytes.Equal(out.Bytes(), want) { + t.Fatalf("bridged data mismatch:\n got %q\nwant %q", out.Bytes(), want) + } +} + +func TestTransportKeyFromData(t *testing.T) { + keyPath, pub := genClientKey(t) + echo := func(_ string, stdin io.Reader, stdout, _ io.Writer) int { + io.Copy(stdout, stdin) + return 0 + } + srv := newTestServer(t, pub, echo) + kh := writeKnownHosts(t, "127.0.0.1", srv.port(), srv.hostKey.PublicKey()) + keyData, err := os.ReadFile(keyPath) + if err != nil { + t.Fatal(err) + } + // RSH_KEY points nowhere; RSH_KEY_DATA must take precedence and work. + t.Setenv("RSH_KEY", "/does/not/exist") + t.Setenv("RSH_KEY_DATA", string(keyData)) + t.Setenv("RSH_KNOWN_HOSTS", kh) + t.Setenv("RSH_PORT", strconv.Itoa(srv.port())) + + want := []byte("via key data") + var out bytes.Buffer + if err := transport([]string{"-l", "u", "127.0.0.1", "cat"}, bytes.NewReader(want), &out, io.Discard); err != nil { + t.Fatalf("transport: %v", err) + } + if !bytes.Equal(out.Bytes(), want) { + t.Fatalf("bridged data mismatch: %q", out.Bytes()) + } +} + +func TestTransportRejectsUnknownHostKey(t *testing.T) { + keyPath, pub := genClientKey(t) + srv := newTestServer(t, pub, func(_ string, _ io.Reader, _, _ io.Writer) int { return 0 }) + + // Pin a DIFFERENT key, so the server's real host key must be rejected. + _, wrong := genClientKey(t) + kh := writeKnownHosts(t, "127.0.0.1", srv.port(), wrong) + setEnv(t, keyPath, kh, srv.port()) + + if err := transport([]string{"-l", "u", "127.0.0.1", "true"}, bytes.NewReader(nil), io.Discard, io.Discard); err == nil { + t.Fatal("expected host-key mismatch error, got nil") + } +} + +func TestTransportPropagatesExitCode(t *testing.T) { + keyPath, pub := genClientKey(t) + srv := newTestServer(t, pub, func(_ string, _ io.Reader, _, _ io.Writer) int { return 7 }) + kh := writeKnownHosts(t, "127.0.0.1", srv.port(), srv.hostKey.PublicKey()) + setEnv(t, keyPath, kh, srv.port()) + + err := transport([]string{"-l", "u", "127.0.0.1", "false"}, bytes.NewReader(nil), io.Discard, io.Discard) + var ee *ssh.ExitError + if !errors.As(err, &ee) || ee.ExitStatus() != 7 { + t.Fatalf("expected exit status 7, got %v", err) + } +} + +func TestPubkeyMatchesKeygen(t *testing.T) { + keyPath, pub := genClientKey(t) + keyData, err := os.ReadFile(keyPath) + if err != nil { + t.Fatal(err) + } + t.Setenv("RSH_KEY", "") + t.Setenv("RSH_KEY_DATA", string(keyData)) + + var out bytes.Buffer + if err := pubkey(&out); err != nil { + t.Fatalf("pubkey: %v", err) + } + got, _, _, _, err := ssh.ParseAuthorizedKey(out.Bytes()) + if err != nil { + t.Fatalf("parse pubkey output: %v", err) + } + if !bytes.Equal(got.Marshal(), pub.Marshal()) { + t.Fatal("pubkey output does not match the keygen public key") + } +} + +func TestPubkeyRejectsMissingKey(t *testing.T) { + t.Setenv("RSH_KEY", "") + t.Setenv("RSH_KEY_DATA", "") + if err := pubkey(io.Discard); err == nil { + t.Fatal("expected error when no key is configured") + } +} + +func TestScanPrintsFingerprintAndLine(t *testing.T) { + keyPath, pub := genClientKey(t) + srv := newTestServer(t, pub, func(_ string, _ io.Reader, _, _ io.Writer) int { return 0 }) + t.Setenv("RSH_KEY", keyPath) + t.Setenv("RSH_PORT", strconv.Itoa(srv.port())) + + var out bytes.Buffer + if err := scan("u@127.0.0.1", &out); err != nil { + t.Fatalf("scan: %v", err) + } + lines := strings.SplitN(strings.TrimSpace(out.String()), "\n", 2) + if len(lines) != 2 { + t.Fatalf("want fingerprint + known_hosts line, got %q", out.String()) + } + if !strings.HasPrefix(lines[0], "SHA256:") { + t.Errorf("fingerprint: %q", lines[0]) + } + // The printed known_hosts line must validate the real host key. + khPath := filepath.Join(t.TempDir(), "kh") + if err := os.WriteFile(khPath, []byte(lines[1]+"\n"), 0o600); err != nil { + t.Fatal(err) + } + cb, err := knownhosts.New(khPath) + if err != nil { + t.Fatal(err) + } + addr := net.JoinHostPort("127.0.0.1", strconv.Itoa(srv.port())) + remote := &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: srv.port()} + if err := cb(addr, remote, srv.hostKey.PublicKey()); err != nil { + t.Errorf("pinned line did not validate the host key: %v", err) + } +} + +func TestParseTransport(t *testing.T) { + cases := []struct { + args []string + user, host string + cmd string + wantErr bool + }{ + {[]string{"-l", "alice", "host", "rsync", "--server"}, "alice", "host", "rsync --server", false}, + {[]string{"bob@host", "echo", "hi"}, "bob", "host", "echo hi", false}, + {[]string{"-l", "alice", "carol@host", "x"}, "alice", "host", "x", false}, + {[]string{"host"}, "", "host", "", false}, + {[]string{"-l"}, "", "", "", true}, + {[]string{"-p", "22", "host", "x"}, "", "", "", true}, + } + for _, c := range cases { + u, h, cmd, err := parseTransport(c.args) + if c.wantErr { + if err == nil { + t.Errorf("%v: expected error", c.args) + } + continue + } + if err != nil { + t.Errorf("%v: %v", c.args, err) + continue + } + if u != c.user || h != c.host || strings.Join(cmd, " ") != c.cmd { + t.Errorf("%v: got (%q,%q,%q) want (%q,%q,%q)", + c.args, u, h, strings.Join(cmd, " "), c.user, c.host, c.cmd) + } + } +} |