blob: b7cc54f075315b9bd28be5d49d0218e5c763cafb [file] [log] [blame]
// Copyright 2022 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package synckeys
import (
"context"
"fmt"
"io"
"io/ioutil"
"os"
"os/exec"
"os/user"
"path/filepath"
"strings"
"go.fuchsia.dev/fuchsia/tools/lib/logger"
)
const (
sshDirName = ".ssh"
fuchsiaAuthorizedKeyFilename = "fuchsia_authorized_keys"
fuchsiaPrivateSSHKeyFilename = "fuchsia_ed25519"
sshKeygenExecName = "ssh-keygen"
pathNotExistErrMsg = "No such file or directory"
differentKeysErrMsg = `keys on local and remote are not the same.
Please ensure the files %q and %q in %q on your local machine and in %q on %q are the same.
You can backup and delete the remote keys on %q and re-run fssh sync-keys which will copy the local keys over to remote.`
)
var fuchsiaKeyFilenames = []string{
fuchsiaAuthorizedKeyFilename,
fuchsiaPrivateSSHKeyFilename,
}
type Workstation struct {
KeysExist bool
KeyPaths []string
TempKeyDir string
HomeDir string
SSHDir string
Hostname string
}
// Fuchsia() syncs the Fuchsia SSH keys between the local and specified remote.
func Fuchsia(ctx context.Context, remoteHostname string) error {
logger.Debugf(ctx, "Running SyncKeys with remote %q...", remoteHostname)
logger.Debugf(ctx, "Checking for local Fuchsia keys...")
local, err := processLocalFuchsiaKeys(ctx, "")
if err != nil {
return fmt.Errorf("could not check for local Fuchsia keys: %w", err)
}
logger.Debugf(ctx, "Local keys exists: %t", local.KeysExist)
logger.Debugf(ctx, "Local keys paths: %q", strings.Trim(strings.Join(local.KeyPaths, ", "), ", "))
logger.Debugf(ctx, "Local home directory: %q", local.HomeDir)
logger.Debugf(ctx, "Local SSH directory: %q", local.SSHDir)
logger.Debugf(ctx, "Checking for remote Fuchsia keys...")
remote, err := processRemoteFuchsiaKeys(ctx, runCommand, remoteHostname, "")
if err != nil {
return fmt.Errorf("could not process remote Fuchsia keys: %w", err)
}
defer os.RemoveAll(remote.TempKeyDir)
logger.Debugf(ctx, "Remote keys exists: %t", remote.KeysExist)
logger.Debugf(ctx, "Remote keys paths: %q", strings.Trim(strings.Join(remote.KeyPaths, ", "), ", "))
logger.Debugf(ctx, "Remote keys local temporary directory: %q", remote.TempKeyDir)
logger.Debugf(ctx, "Remote home directory: %q", remote.HomeDir)
logger.Debugf(ctx, "Remote SSH directory: %q", remote.SSHDir)
if !local.KeysExist && !remote.KeysExist {
logger.Debugf(ctx, "Some/all Fuchsia keys missing from both remote and local.")
if err := generateFuchsiaKeys(ctx, runCommand, local, remote); err != nil {
return fmt.Errorf("could not generate Fuchsia keys: %w", err)
}
} else if local.KeysExist && remote.KeysExist {
logger.Debugf(ctx, "Both local and remote Fuchsia keys present.")
logger.Debugf(ctx, "Checking if local and remote Fuchsia keys are the same...")
sameKeys, err := sameFuchsiaKeys(ctx, local.SSHDir, remote.TempKeyDir)
if err != nil {
return fmt.Errorf("could not check if remote and local key files are the same: %w", err)
}
if !sameKeys {
return fmt.Errorf(differentKeysErrMsg,
fuchsiaAuthorizedKeyFilename,
fuchsiaPrivateSSHKeyFilename,
local.SSHDir,
remote.SSHDir,
remote.Hostname,
remote.Hostname,
)
}
logger.Debugf(ctx, "Remote and local keys are the same.")
} else if local.KeysExist && !remote.KeysExist {
logger.Debugf(ctx, "Fuchsia keys present on local but not remote.")
logger.Debugf(ctx, "Copying local Fuchsia keys to %q", remote.Hostname)
if err := copyLocalKeysToRemote(ctx, runCommand, local.KeyPaths, remote.Hostname, remote.SSHDir); err != nil {
return fmt.Errorf("could not copy local keys %q to directory %q on remote %q: %w",
local.KeyPaths,
remote.Hostname,
remote.SSHDir,
err,
)
}
logger.Debugf(ctx, "Copied local keys to remote %q successfully.", remote.Hostname)
} else if !local.KeysExist && remote.KeysExist {
logger.Debugf(ctx, "Fuchsia keys present on remote but not local.")
logger.Debugf(ctx, "Copying remote Fuchsia keys to local...")
var remotePaths []string
for _, filename := range fuchsiaKeyFilenames {
remotePaths = append(remotePaths, filepath.Join(remote.TempKeyDir, filename))
}
if err = copyPathsToDir(remotePaths, local.SSHDir); err != nil {
return fmt.Errorf("could not copy remote keys to local: %w", err)
}
logger.Debugf(ctx, "Copied remote Fuchsia keys from %q to local successfully.", remote.Hostname)
}
logger.Debugf(ctx, "Keys synced without error.")
return nil
}
func processLocalFuchsiaKeys(ctx context.Context, homeDir string) (*Workstation, error) {
// If home dir is not provided, get the user's home dir.
if homeDir == "" {
var err error
homeDir, err = getHomeDir()
if err != nil {
return nil, fmt.Errorf("could not get local home directory: %w", err)
}
}
sshDir := filepath.Join(homeDir, sshDirName)
paths := []string{}
for _, filename := range fuchsiaKeyFilenames {
paths = append(paths, filepath.Join(sshDir, filename))
}
exist := checkLocalFuchsiaKeys(ctx, paths)
return &Workstation{
KeysExist: exist,
HomeDir: homeDir,
SSHDir: sshDir,
KeyPaths: paths,
}, nil
}
func processRemoteFuchsiaKeys(ctx context.Context, runCommand runner, hostname string, homeDir string) (*Workstation, error) {
if homeDir == "" {
out, err := runCommand(ctx, "ssh", []string{
hostname,
"echo $HOME",
})
if err != nil {
return nil, fmt.Errorf("could not get remote home directory: %w", err)
}
homeDir = strings.TrimSpace(out)
}
sshDir := filepath.Join(homeDir, sshDirName)
paths := []string{}
for _, filename := range fuchsiaKeyFilenames {
paths = append(paths, filepath.Join(sshDir, filename))
}
tempKeyDir, err := ioutil.TempDir(os.TempDir(), "fssh-temp-remote-keys-")
if err != nil {
return nil, fmt.Errorf("could not create local temporary folder for remote SSH keys: %w", err)
}
var exists bool
if err = copyRemoteKeysToLocal(ctx, runCommand, tempKeyDir, hostname, paths); err != nil {
// copyRemoteKeysToLocal will throw an "path doesn't exist" error if
// any of the files are not present.
if strings.Contains(err.Error(), pathNotExistErrMsg) {
exists = false
} else {
err := fmt.Errorf("could not check for remote Fuchsia auth files: %w", err)
// Remote temp dir that will not be used.
if cleanupErr := os.RemoveAll(tempKeyDir); cleanupErr != nil {
err = fmt.Errorf("%v. Warning: could not cleanup dir %q: %w", err, tempKeyDir, cleanupErr)
}
return nil, err
}
} else {
exists = true
}
return &Workstation{
KeysExist: exists,
KeyPaths: paths,
TempKeyDir: tempKeyDir,
HomeDir: homeDir,
SSHDir: sshDir,
Hostname: hostname,
}, nil
}
func generateFuchsiaKeys(ctx context.Context, runCommand runner, local *Workstation, remote *Workstation) error {
// Check for existing private keys
localPrivateKeyPath := filepath.Join(local.SSHDir, fuchsiaPrivateSSHKeyFilename)
localPrivateKeyExists := localFileExists(localPrivateKeyPath)
remotePrivateKeyPath := filepath.Join(remote.TempKeyDir, fuchsiaPrivateSSHKeyFilename)
remotePrivateKeyExists := localFileExists(remotePrivateKeyPath)
// If both local and remote private keys exist, ensure contents is the same
if localPrivateKeyExists && remotePrivateKeyExists {
same, err := filesHaveSameContents(ctx, localPrivateKeyPath, remotePrivateKeyPath)
if err != nil {
return fmt.Errorf("could not check if remote private key (copied locally, %q) and local private key (%q) are the same: %w", remotePrivateKeyPath, localPrivateKeyPath, err)
}
if !same {
return fmt.Errorf(differentKeysErrMsg,
fuchsiaAuthorizedKeyFilename,
fuchsiaPrivateSSHKeyFilename,
local.SSHDir,
remote.SSHDir,
remote.Hostname,
remote.Hostname,
)
}
} else if remotePrivateKeyExists {
// If private key only exists on remote, copy it locally
if err := copyPathsToDir([]string{remotePrivateKeyPath}, local.SSHDir); err != nil {
return fmt.Errorf("could not copy remote private key (copied locally to %q) to local SSH dir %q: %w", remotePrivateKeyPath, local.SSHDir, err)
}
}
// At this point, if any Fuchsia private key exists:
// 1. the local private key is the same as the private key on the remote
// 2. the private key is in the local SSH dir
// This means we can generate any additionally needed keys locally and then
// copy them over to the remote to sync the keys.
if err := generateLocalKeys(ctx, runCommand, local.SSHDir); err != nil {
return fmt.Errorf("could not generate new SSH keys: %w", err)
}
if err := copyLocalKeysToRemote(ctx, runCommand, local.KeyPaths, remote.Hostname, remote.SSHDir); err != nil {
return fmt.Errorf("could note copy local keys to remote: %w", err)
}
return nil
}
func checkLocalFuchsiaKeys(ctx context.Context, paths []string) bool {
var missingFiles []string
for _, path := range paths {
if !localFileExists(path) {
missingFiles = append(missingFiles, path)
}
}
if len(missingFiles) > 0 {
missingFilesMsg := strings.Trim(strings.Join(missingFiles, ", "), ", ")
logger.Debugf(ctx, "Found some local Fuchsia key files but the following key files were missing: %q", missingFilesMsg)
}
return len(missingFiles) == 0
}
func generateLocalKeys(ctx context.Context, runCommand runner, sshDir string) error {
localPrivateKeyPath := filepath.Join(sshDir, fuchsiaPrivateSSHKeyFilename)
// If no existing private key exists, generate a new one.
if !localFileExists(localPrivateKeyPath) {
if err := generatePrivateKey(ctx, runCommand, localPrivateKeyPath); err != nil {
return fmt.Errorf("could not generate a new private SSH key: %w", err)
}
}
// Generate a authorized key file
localAuthKeyPath := filepath.Join(sshDir, fuchsiaAuthorizedKeyFilename)
if !localFileExists(localAuthKeyPath) {
if err := generatePublicKeyfile(ctx, runCommand, localPrivateKeyPath, localAuthKeyPath); err != nil {
return fmt.Errorf("could not generate local authorized key file %q: %w", localAuthKeyPath, err)
}
}
return nil
}
func generatePrivateKey(ctx context.Context, runCommand runner, dst string) error {
// Generating private/public key pair
if _, err := runCommand(ctx, sshKeygenExecName, []string{
"-N",
"",
"-t",
"ed25519",
"-f",
dst,
}); err != nil {
return fmt.Errorf("could not run %q : %w", sshKeygenExecName, err)
}
return nil
}
func generatePublicKeyfile(ctx context.Context, runCommand runner, privateKeyPath string, dst string) error {
// Generating authorized key file
authorizedKeyFileContents, err := runCommand(ctx, sshKeygenExecName, []string{
"-y",
"-f",
privateKeyPath,
})
if err != nil {
return fmt.Errorf("could not run %q : %w", sshKeygenExecName, err)
}
// Write authorized key file
if err = ioutil.WriteFile(dst, []byte(authorizedKeyFileContents), 0400); err != nil {
return fmt.Errorf("could not write authorized key file %q : %w", dst, err)
}
return nil
}
func sameFuchsiaKeys(ctx context.Context, dir1 string, dir2 string) (bool, error) {
for i := range fuchsiaKeyFilenames {
path1 := filepath.Join(dir1, fuchsiaKeyFilenames[i])
path2 := filepath.Join(dir2, fuchsiaKeyFilenames[i])
same, err := filesHaveSameContents(ctx, path1, path2)
if err != nil {
return false, fmt.Errorf("could not check %q and %q to see if they have the same contents: %w", path1, path2, err)
}
if !same {
logger.Warningf(ctx, "%s != %s", path1, path2)
return false, nil
}
}
return true, nil
}
// Checks that the 2 files have the same contents.
// The comparison is line based and empty lines are ignored.
func filesHaveSameContents(ctx context.Context, path1 string, path2 string) (bool, error) {
contents1, err := ioutil.ReadFile(path1)
if err != nil {
return false, fmt.Errorf("could not open file %q: %w'", path1, err)
}
contents2, err := ioutil.ReadFile(path2)
if err != nil {
return false, fmt.Errorf("could not open file %q: %w'", path2, err)
}
nonEmptyLineSplitter := func(c rune) bool {
return c == '\n'
}
lines1 := strings.FieldsFunc(string(contents1), nonEmptyLineSplitter)
lines2 := strings.FieldsFunc(string(contents2), nonEmptyLineSplitter)
same := len(lines1) == len(lines2)
if same {
for i, line := range lines1 {
if line != lines2[i] {
same = false
break
}
}
}
if !same {
logger.Warningf(ctx, "%s != %s", path1, path2)
logger.Warningf(ctx, "path1 contents: %q", string(contents1))
logger.Warningf(ctx, "path2 contents: %q", string(contents2))
}
return same, nil
}
func copyLocalKeysToRemote(ctx context.Context, runCommand runner, localPaths []string, remote string, remoteDir string) error {
var sshArgs []string
for _, path := range localPaths {
sshArgs = append(sshArgs, path)
}
if remote == "" {
sshArgs = append(sshArgs, remoteDir)
} else {
sshArgs = append(sshArgs, fmt.Sprintf("%s:%s", remote, remoteDir))
}
if _, err := runCommand(ctx, "scp", sshArgs); err != nil {
return fmt.Errorf("could not copy local SSH keys to remote %q at path %q: %w", remote, remoteDir, err)
}
return nil
}
func copyRemoteKeysToLocal(ctx context.Context, runCommand runner, localDir string, remote string, remotePaths []string) error {
var sshArgs []string
for _, path := range remotePaths {
arg := path
if remote != "" {
arg = fmt.Sprintf("%s:%s", remote, path)
}
sshArgs = append(sshArgs, arg)
}
sshArgs = append(sshArgs, localDir)
if _, err := runCommand(ctx, "scp", sshArgs); err != nil {
return fmt.Errorf("could not copy remote SSH keys on %q to local path %q: %w", remote, localDir, err)
}
return nil
}
type runner func(context.Context, string, []string) (string, error)
func runCommand(ctx context.Context, execName string, args []string) (string, error) {
path, err := findOnPath(execName)
if err != nil {
return "", err
}
logger.Debugf(ctx, "Running command %q", fmt.Sprintf("%s %s", path, strings.Join(args, " ")))
cmd := exec.Command(path, args...)
out, err := cmd.CombinedOutput()
if err != nil {
return "", fmt.Errorf(
"could not run %s: %q",
path,
string(out),
)
}
logger.Debugf(ctx, "command output: %q", string(out))
return string(out), nil
}
// findOnPath finds a executable with the name `execName` in the directories
/// specified by the PATH environment variable.
func findOnPath(execName string) (string, error) {
path, err := exec.LookPath(execName)
if err != nil {
return "", err
}
return path, nil
}
func getHomeDir() (string, error) {
usr, err := user.Current()
if err != nil {
return "", err
}
return usr.HomeDir, nil
}
func localFileExists(filename string) bool {
info, err := os.Stat(filename)
if os.IsNotExist(err) {
return false
}
return !info.IsDir()
}
// sameBoolValue checks to see if all boolean values are the same.
// returns true if the are all the same false if not.
func sameBoolValue(bools ...bool) bool {
if len(bools) == 0 {
return false
}
first := bools[0]
for _, b := range bools {
if b != first {
return false
}
}
return true
}
func copyPathsToDir(paths []string, dir string) error {
for _, path := range paths {
filename := filepath.Base(path)
dst, err := os.Create(filepath.Join(dir, filename))
if err != nil {
return err
}
defer dst.Close()
src, err := os.Open(path)
if err != nil {
return err
}
defer src.Close()
if _, err = io.Copy(dst, src); err != nil {
return err
}
}
return nil
}