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/main.go | |
| 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/main.go')
| -rw-r--r-- | rsh/main.go | 273 |
1 files changed, 273 insertions, 0 deletions
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 + } + } +} |