// 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                        generate an ed25519 key, print the private key (PEM)
//	rsh -pubkey                        print the pubkey for RSH_KEY_DATA/RSH_KEY
//	rsh -scan USER@HOST                connect, print host-key type + 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, and
// only the already-pinned key type is offered, so a server that switches key
// type fails at negotiation rather than presenting an unverified key.
// 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)
//
// The only non-standard Go code linked here is golang.org/x/crypto. The
// Android binaries are built with cgo against Bionic (see rsh/build.sh); the
// host binary is not.
package main

import (
	"crypto/ed25519"
	"crypto/rand"
	"encoding/pem"
	"errors"
	"fmt"
	"io"
	"net"
	"os"
	"strconv"
	"strings"
	"time"

	"golang.org/x/crypto/ssh"
	"golang.org/x/crypto/ssh/knownhosts"
)

// defaultDialTimeout bounds the TCP connect and the SSH handshake. A firewall
// that drops packets instead of refusing them makes a connect hang for the
// whole budget, so keep it short: a reachable host completes the handshake
// well inside this even on poor mobile networks.
const defaultDialTimeout = 10 * time.Second

// unreachablePrefix marks a failure to reach the host at all, as opposed to a
// rejected key or a mismatched host key. The app greps rsync's merged output
// for it so it can skip the remaining folders on the same dead remote instead
// of paying the timeout once per folder.
const unreachablePrefix = "unreachable: "

// scanAlgos is the host-key preference used when first pinning a host. The
// x/crypto default puts ssh-ed25519 last, behind ecdsa, rsa and dss, so a stock
// server would get its ecdsa key pinned while the README tells the user to
// verify the ed25519 one. Prefer ed25519, accept the other modern types if that
// is all the server has, and never ssh-rsa (SHA-1) or ssh-dss.
var scanAlgos = []string{
	ssh.KeyAlgoED25519,
	ssh.KeyAlgoECDSA256,
	ssh.KeyAlgoECDSA384,
	ssh.KeyAlgoECDSA521,
	ssh.KeyAlgoRSASHA512,
	ssh.KeyAlgoRSASHA256,
}

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) != 1 {
				return errors.New("usage: rsh -keygen")
			}
			return keygen(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)
	}
	port, err := sshPort()
	if err != nil {
		return err
	}
	algos, err := pinnedAlgos(kh, knownhosts.Normalize(net.JoinHostPort(host, strconv.Itoa(port))))
	if err != nil {
		return err
	}
	if len(algos) == 0 {
		// No literal pin for this address. The file may still authorise the
		// host through a hashed or wildcard entry, which the literal match
		// deliberately does not understand, so fall back to the modern
		// preference and let the callback decide. Verification stays strict:
		// an unknown or changed key still fails loud, just after the dial.
		algos = scanAlgos
	}
	client, err := dial(user, host, cb, algos)
	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")
	}
	port, err := sshPort()
	if err != nil {
		return err
	}
	addr := knownhosts.Normalize(net.JoinHostPort(host, strconv.Itoa(port)))
	// Prefer the key type already pinned for this host, then fall back to the
	// general preference. A server usually offers several types; picking a
	// different one than last time would reproduce a different line and read as
	// a host-key change on a server nobody touched. Preferring rather than
	// requiring matters: negotiation walks the client list in order, so an
	// untouched server still reproduces its pinned line, while one whose key
	// type was genuinely rotated presents the new key and reaches the "host key
	// changed" prompt instead of failing with no common algorithm and no way to
	// accept the new key short of deleting the remote.
	algos := scanAlgos
	if kh := os.Getenv("RSH_KNOWN_HOSTS"); kh != "" {
		if pinned, perr := pinnedAlgos(kh, addr); perr == nil && len(pinned) > 0 {
			algos = append(append([]string{}, pinned...), scanAlgos...)
		}
	}
	var hostKey ssh.PublicKey
	capture := func(_ string, _ net.Addr, key ssh.PublicKey) error {
		hostKey = key
		return nil
	}
	client, err := dial(user, host, capture, algos)
	if err != nil {
		return err
	}
	client.Close()

	line := knownhosts.Line([]string{addr}, hostKey)
	// The type belongs next to the fingerprint: the user is told to compare it
	// against a specific key file on the server, and "SHA256:..." alone does not
	// say which one.
	fmt.Fprintf(out, "%s %s\n%s\n", hostKey.Type(), ssh.FingerprintSHA256(hostKey), line)
	return nil
}

// pinnedAlgos returns the host-key algorithms to offer for addr: exactly the
// key types already pinned for it. Offering only the pinned type turns a server
// that switches key type into a loud negotiation failure instead of a silent
// prompt to trust a key the user never verified.
//
// rsend writes this file itself from its own pins, one exact unhashed address
// per line (knownhosts.Line), so matching the address literally is enough.
func pinnedAlgos(khPath, addr string) ([]string, error) {
	data, err := os.ReadFile(khPath)
	if err != nil {
		return nil, fmt.Errorf("known_hosts: %w", err)
	}
	var algos []string
	seen := make(map[string]bool)
	for rest := data; len(rest) > 0; {
		marker, hosts, key, _, next, perr := ssh.ParseKnownHosts(rest)
		if perr == io.EOF {
			break
		}
		if perr != nil {
			return nil, fmt.Errorf("known_hosts: %w", perr)
		}
		rest = next
		// A @revoked line names a key that must never be accepted, and a
		// @cert-authority line names a signing key rather than a host key.
		// Neither says anything about what this host may present.
		if marker != "" {
			continue
		}
		for _, h := range hosts {
			if h != addr {
				continue
			}
			for _, a := range algosForKeyType(key.Type()) {
				if !seen[a] {
					seen[a] = true
					algos = append(algos, a)
				}
			}
		}
	}
	return algos, nil
}

// algosForKeyType maps a pinned known_hosts key type to the signature
// algorithms a server may use with it. Only RSA differs: an "ssh-rsa" pin
// names the same key as an rsa-sha2-* signature. Plain ssh-rsa is SHA-1 and is
// left out, matching scanAlgos and OpenSSH's own default.
func algosForKeyType(t string) []string {
	if t == ssh.KeyAlgoRSA {
		return []string{ssh.KeyAlgoRSASHA512, ssh.KeyAlgoRSASHA256}
	}
	return []string{t}
}

// keygen generates an ed25519 key and prints the private key in PEM form to
// out. Nothing touches disk: the caller owns persistence (the app stores it
// encrypted) and derives the public key with -pubkey.
func keygen(out io.Writer) error {
	_, priv, err := ed25519.GenerateKey(rand.Reader)
	if err != nil {
		return err
	}
	block, err := ssh.MarshalPrivateKey(priv, "rsend")
	if err != nil {
		return err
	}
	return pem.Encode(out, block)
}

// 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) {
	var signer ssh.Signer
	var err error
	if data := os.Getenv("RSH_KEY_DATA"); data != "" {
		signer, err = ssh.ParsePrivateKey([]byte(data))
	} else {
		keyPath := os.Getenv("RSH_KEY")
		if keyPath == "" {
			return nil, errors.New("RSH_KEY or RSH_KEY_DATA not set")
		}
		pemBytes, readErr := os.ReadFile(keyPath)
		if readErr != nil {
			return nil, fmt.Errorf("read key: %w", readErr)
		}
		signer, err = ssh.ParsePrivateKey(pemBytes)
	}
	if err != nil {
		return nil, err
	}
	if signer.PublicKey().Type() != ssh.KeyAlgoED25519 {
		return nil, fmt.Errorf("client key must be %s", ssh.KeyAlgoED25519)
	}
	return signer, nil
}

// dial opens an SSH connection authenticated with the configured key, verifying
// the host key with hostKey and offering only the host-key algorithms in algos.
func dial(user, host string, hostKey ssh.HostKeyCallback, algos []string) (*ssh.Client, error) {
	signer, err := loadSigner()
	if err != nil {
		return nil, err
	}
	port, err := sshPort()
	if err != nil {
		return nil, err
	}
	timeout, err := dialTimeout()
	if err != nil {
		return nil, err
	}
	cfg := &ssh.ClientConfig{
		User:              user,
		Auth:              []ssh.AuthMethod{ssh.PublicKeys(signer)},
		HostKeyCallback:   hostKey,
		HostKeyAlgorithms: algos,
		Timeout:           timeout,
	}
	addr := net.JoinHostPort(host, strconv.Itoa(port))
	conn, err := net.DialTimeout("tcp", addr, timeout)
	if err != nil {
		return nil, fmt.Errorf("%s%w", unreachablePrefix, err)
	}
	if err := conn.SetDeadline(time.Now().Add(timeout)); err != nil {
		conn.Close()
		return nil, err
	}
	clientConn, chans, reqs, err := ssh.NewClientConn(conn, addr, cfg)
	if err != nil {
		conn.Close()
		return nil, err
	}
	if err := conn.SetDeadline(time.Time{}); err != nil {
		clientConn.Close()
		return nil, err
	}
	return ssh.NewClient(clientConn, chans, reqs), nil
}

// 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")
	}
	cmd = args[i+1:]
	u, h := splitUserHost(args[i])
	host = h
	if u != "" && 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], unbracket(s[i+1:])
	}
	return "", unbracket(s)
}

// unbracket strips the brackets people habitually put around an IPv6 literal.
// A bare literal already works, because net.JoinHostPort adds the brackets and
// knownhosts.Normalize takes them back off; a pre-bracketed one would be
// double-bracketed into an address that can be neither dialled nor matched
// against a pin.
func unbracket(h string) string {
	if len(h) > 1 && h[0] == '[' && h[len(h)-1] == ']' {
		return h[1 : len(h)-1]
	}
	return h
}

// dialTimeout returns the connect budget, overridable in whole seconds with
// RSH_CONNECT_TIMEOUT for running rsh by hand against a slow or filtered host.
func dialTimeout() (time.Duration, error) {
	s := os.Getenv("RSH_CONNECT_TIMEOUT")
	if s == "" {
		return defaultDialTimeout, nil
	}
	n, err := strconv.Atoi(s)
	if err != nil || n < 1 || n > 3600 {
		return 0, fmt.Errorf("invalid RSH_CONNECT_TIMEOUT %q", s)
	}
	return time.Duration(n) * time.Second, nil
}

func sshPort() (int, error) {
	s := os.Getenv("RSH_PORT")
	if s == "" {
		return 22, nil
	}
	p, err := strconv.Atoi(s)
	if err != nil || p < 1 || p > 65535 {
		return 0, fmt.Errorf("invalid RSH_PORT %q", s)
	}
	return p, nil
}

// 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
		}
	}
}
