From 7e04941bccb2683f8a6e3ee38a99c50129234dd1 Mon Sep 17 00:00:00 2001 From: Lena Date: Thu, 1 Jan 2026 00:00:00 +0000 Subject: 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. --- ci/testsshd/go.mod | 7 +++ ci/testsshd/go.sum | 6 +++ ci/testsshd/main.go | 149 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 162 insertions(+) create mode 100644 ci/testsshd/go.mod create mode 100644 ci/testsshd/go.sum create mode 100644 ci/testsshd/main.go (limited to 'ci/testsshd') diff --git a/ci/testsshd/go.mod b/ci/testsshd/go.mod new file mode 100644 index 0000000..88ccdc7 --- /dev/null +++ b/ci/testsshd/go.mod @@ -0,0 +1,7 @@ +module rsend/ci/testsshd + +go 1.25.0 + +require golang.org/x/crypto v0.53.0 + +require golang.org/x/sys v0.46.0 // indirect diff --git a/ci/testsshd/go.sum b/ci/testsshd/go.sum new file mode 100644 index 0000000..68cadc1 --- /dev/null +++ b/ci/testsshd/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/ci/testsshd/main.go b/ci/testsshd/main.go new file mode 100644 index 0000000..19a53ac --- /dev/null +++ b/ci/testsshd/main.go @@ -0,0 +1,149 @@ +// testsshd is a minimal SSH server for rsend integration tests: one host key, +// an authorized_keys allowlist, and command execution via /bin/sh (so it runs +// the remote rsync). It is not a product component; it stands in for a real +// sshd so the full push path can be exercised on a device or in CI. +package main + +import ( + "flag" + "io" + "log" + "net" + "os" + "os/exec" + + "golang.org/x/crypto/ssh" +) + +func main() { + addr := flag.String("addr", "127.0.0.1:2222", "listen address") + hostKeyPath := flag.String("hostkey", "", "OpenSSH private host key (required)") + authPath := flag.String("authorized", "", "authorized_keys file (required)") + flag.Parse() + + if *hostKeyPath == "" || *authPath == "" { + log.Fatal("testsshd: -hostkey and -authorized are required") + } + + hostKey := mustSigner(*hostKeyPath) + allowed := mustAuthorized(*authPath) + + cfg := &ssh.ServerConfig{ + PublicKeyCallback: func(_ ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) { + if allowed[string(key.Marshal())] { + return &ssh.Permissions{}, nil + } + return nil, io.EOF + }, + } + cfg.AddHostKey(hostKey) + + ln, err := net.Listen("tcp", *addr) + if err != nil { + log.Fatalf("testsshd: listen: %v", err) + } + log.Printf("testsshd: listening on %s", ln.Addr()) + for { + c, err := ln.Accept() + if err != nil { + log.Fatalf("testsshd: accept: %v", err) + } + go handle(c, cfg) + } +} + +func mustSigner(path string) ssh.Signer { + b, err := os.ReadFile(path) + if err != nil { + log.Fatalf("testsshd: read host key: %v", err) + } + s, err := ssh.ParsePrivateKey(b) + if err != nil { + log.Fatalf("testsshd: parse host key: %v", err) + } + return s +} + +func mustAuthorized(path string) map[string]bool { + b, err := os.ReadFile(path) + if err != nil { + log.Fatalf("testsshd: read authorized_keys: %v", err) + } + m := map[string]bool{} + for len(b) > 0 { + key, _, _, rest, err := ssh.ParseAuthorizedKey(b) + if err != nil { + break + } + m[string(key.Marshal())] = true + b = rest + } + if len(m) == 0 { + log.Fatal("testsshd: no authorized keys") + } + return m +} + +func handle(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 nc := range chans { + if nc.ChannelType() != "session" { + nc.Reject(ssh.UnknownChannelType, "only session") + continue + } + ch, requests, err := nc.Accept() + if err != nil { + return + } + go session(ch, requests) + } +} + +func session(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 := run(payload.Command, ch) + ch.SendRequest("exit-status", false, ssh.Marshal(struct{ Status uint32 }{uint32(code)})) + ch.Close() + return + } +} + +func run(cmd string, ch ssh.Channel) int { + c := exec.Command("/bin/sh", "-c", cmd) + c.Stdout = ch + c.Stderr = ch.Stderr() + in, err := c.StdinPipe() + if err != nil { + return 1 + } + if err := c.Start(); err != nil { + return 1 + } + go func() { + io.Copy(in, ch) + in.Close() + }() + if err := c.Wait(); err != nil { + if ee, ok := err.(*exec.ExitError); ok { + return ee.ExitCode() + } + return 1 + } + return 0 +} -- cgit v1.2.3