設定應用程式以使用 SSH


本文件說明如何設定應用程式,以程式輔助方式透過 SSH 和 OS 登入,在兩個虛擬機器 (VM) 執行個體之間建立連線。啟用應用程式使用 SSH 功能,有助於自動化系統管理程序。

本指南中使用的所有程式碼範例都託管在 GoogleCloudPlatform/python-docs-samples GitHub 頁面中。

事前準備

  • 為服務帳戶設定 SSH
  • 在專案或以服務帳戶身分執行的 VM 上設定 OS Login
  • 如果尚未設定,請先設定驗證機制。驗證是指驗證身分,以便存取 Google Cloud 服務和 API 的程序。如要在本機開發環境中執行程式碼或範例,您可以選取下列任一選項,向 Compute Engine 進行驗證:

    Select the tab for how you plan to use the samples on this page:

    Console

    When you use the Google Cloud console to access Google Cloud services and APIs, you don't need to set up authentication.

    gcloud

    1. After installing the Google Cloud CLI, initialize it by running the following command:

      gcloud init

      If you're using an external identity provider (IdP), you must first sign in to the gcloud CLI with your federated identity.

    2. Set a default region and zone.

設定 SSH 應用程式

設定應用程式,以便管理安全殼層金鑰,並啟動與 Compute Engine VM 的安全殼層連線。整體來說,應用程式應執行下列操作:

  1. 匯入 Google OS 登入程式庫,以便建構用戶端程式庫,進而使用 OS 登入 API 進行驗證。
  2. 初始化 OS Login 用戶端物件,讓應用程式能夠使用 OS 登入功能。
  3. 實作 create_ssh_key() 方法,為 VM 的服務帳戶產生安全殼層金鑰,並將公開金鑰新增至服務帳戶。
  4. 從 OS Login 程式庫呼叫 get_login_profile() 方法,以取得服務帳戶使用的 POSIX 使用者名稱。
  5. 實作 run_ssh() 方法,以便執行遠端 SSH 指令。
  6. 移除臨時的安全殼層 (SSH) 金鑰組檔案。

安全殼層應用程式範例

oslogin_service_account_ssh.py 範例應用程式可說明 SSH 應用程式的可能實作方式。在本範例中,應用程式使用 run_ssh() 方法在遠端執行個體上執行指令並傳回指令輸出結果。

"""
Example of using the OS Login API to apply public SSH keys for a service
account, and use that service account to run commands on a remote
instance over SSH. This example uses zonal DNS names to address instances
on the same internal VPC network.
"""
from __future__ import annotations

import argparse
import subprocess
import time
from typing import Optional
import uuid

from google.cloud import oslogin_v1
import requests

SERVICE_ACCOUNT_METADATA_URL = (
    "http://metadata.google.internal/computeMetadata/v1/instance/"
    "service-accounts/default/email"
)
HEADERS = {"Metadata-Flavor": "Google"}


def execute(
    cmd: list[str],
    cwd: Optional[str] = None,
    capture_output: bool = False,
    env: Optional[dict] = None,
    raise_errors: bool = True,
) -> tuple[int, str]:
    """
    Run an external command (wrapper for Python subprocess).

    Args:
        cmd: The command to be run.
        cwd: Directory in which to run the command.
        capture_output: Should the command output be captured and returned or just ignored.
        env: Environmental variables passed to the child process.
        raise_errors: Should errors in run command raise exceptions.

    Returns:
        Return code and captured output.
    """
    print(f"Running command: {cmd}")
    process = subprocess.run(
        cmd,
        cwd=cwd,
        stdout=subprocess.PIPE if capture_output else subprocess.DEVNULL,
        stderr=subprocess.STDOUT,
        text=True,
        env=env,
        check=raise_errors,
    )
    output = process.stdout
    returncode = process.returncode

    if returncode:
        print(f"Command returned error status {returncode}")
        if capture_output:
            print(f"With output: {output}")

    return returncode, output


def create_ssh_key(
    oslogin_client: oslogin_v1.OsLoginServiceClient,
    account: str,
    expire_time: int = 300,
) -> str:
    """
    Generates a temporary SSH key pair and apply it to the specified account.

    Args:
        oslogin_client: OS Login client object.
        account: Name of the service account this key will be assigned to.
            This should be in form of `user/<service_account_username>`.
        expire_time: How many seconds from now should this key be valid.

    Returns:
        The path to private SSH key. Public key can be found by appending `.pub`
        to the file name.

    """
    private_key_file = f"/tmp/key-{uuid.uuid4()}"
    execute(["ssh-keygen", "-t", "rsa", "-N", "", "-f", private_key_file])

    with open(f"{private_key_file}.pub") as original:
        public_key = original.read().strip()

    # Expiration time is in microseconds.
    expiration = int((time.time() + expire_time) * 1000000)

    request = oslogin_v1.ImportSshPublicKeyRequest()
    request.parent = account
    request.ssh_public_key.key = public_key
    request.ssh_public_key.expiration_time_usec = expiration

    print(f"Setting key for {account}...")
    oslogin_client.import_ssh_public_key(request)

    # Let the key properly propagate
    time.sleep(5)

    return private_key_file


def run_ssh(cmd: str, private_key_file: str, username: str, hostname: str) -> str:
    """
    Runs a command on a remote system.

    Args:
        cmd: command to be run.
        private_key_file: private SSH key to be used for authentication.
        username: username to be used for authentication.
        hostname: hostname of the machine you want to run the command on.

    Returns:
        Output of the executed command.
    """
    ssh_command = [
        "ssh",
        "-i",
        private_key_file,
        "-o",
        "StrictHostKeyChecking=no",
        "-o",
        "UserKnownHostsFile=/dev/null",
        f"{username}@{hostname}",
        cmd,
    ]
    print(f"Running ssh command: {' '.join(ssh_command)}")
    tries = 0
    while tries < 3:
        try:
            ssh = subprocess.run(
                ssh_command,
                shell=False,
                stdout=subprocess.PIPE,
                stderr=subprocess.STDOUT,
                text=True,
                check=True,
                env={"SSH_AUTH_SOCK": ""},
                timeout=10,
            )
        except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as err:
            time.sleep(10)
            tries += 1
            if tries == 3:
                if isinstance(err, subprocess.CalledProcessError):
                    print(
                        f"Failed to run SSH command (return code: {err.returncode}. Output received: {err.output}"
                    )
                else:
                    print("Failed to run SSH - timed out.")
                raise err
        else:
            return ssh.stdout


def main(
    cmd: str,
    project: str,
    instance: Optional[str] = None,
    zone: Optional[str] = None,
    account: Optional[str] = None,
    hostname: Optional[str] = None,
    oslogin: Optional[oslogin_v1.OsLoginServiceClient] = None,
) -> str:
    """
    Runs a command on a remote system.

    Args:
        cmd: command to be executed on the remote host.
        project: name of the project in which te remote instance is hosted.
        instance: name of the remote system instance.
        zone: zone in which the remote system resides. I.e. us-west3-a
        account: account to be used for authentication.
        hostname: hostname of the remote system.
        oslogin: OSLogin service client object. If not provided, a new client will be created.

    Returns:
        The commands output.
    """
    # Create the OS Login API object.
    if oslogin is None:
        oslogin = oslogin_v1.OsLoginServiceClient()

    # Identify the service account ID if it is not already provided.
    account = (
        account or requests.get(SERVICE_ACCOUNT_METADATA_URL, headers=HEADERS).text
    )

    if not account.startswith("users/"):
        account = f"users/{account}"

    # Create a new SSH key pair and associate it with the service account.
    private_key_file = create_ssh_key(oslogin, account)
    try:
        # Using the OS Login API, get the POSIX username from the login profile
        # for the service account.
        profile = oslogin.get_login_profile(name=account)
        username = profile.posix_accounts[0].username

        # Create the hostname of the target instance using the instance name,
        # the zone where the instance is located, and the project that owns the
        # instance.
        hostname = hostname or f"{instance}.{zone}.c.{project}.internal"

        # Run a command on the remote instance over SSH.
        result = run_ssh(cmd, private_key_file, username, hostname)

        # Print the command line output from the remote instance.
        print(result)
        return result
    finally:
        # Shred the private key and delete the pair.
        execute(["shred", private_key_file])
        execute(["rm", private_key_file])
        execute(["rm", f"{private_key_file}.pub"])


if __name__ == "__main__":
    parser = argparse.ArgumentParser(
        description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
    )
    parser.add_argument(
        "--cmd", default="uname -a", help="The command to run on the remote instance."
    )
    parser.add_argument("--project", help="Your Google Cloud project ID.")
    parser.add_argument("--zone", help="The zone where the target instance is located.")
    parser.add_argument("--instance", help="The target instance for the ssh command.")
    parser.add_argument("--account", help="The service account email.")
    parser.add_argument(
        "--hostname",
        help="The external IP address or hostname for the target instance.",
    )
    args = parser.parse_args()

    main(
        args.cmd,
        args.project,
        instance=args.instance,
        zone=args.zone,
        account=args.account,
        hostname=args.hostname,
    )

執行安全殼層應用程式

建立使用 SSH 的應用程式後,您可以按照類似下列範例的程序執行應用程式,該範例會安裝並執行 oslogin_service_account_ssh.py 範例應用程式。您安裝的程式庫可能會有所不同,這取決於應用程式使用的程式設計語言。

或者您也可以編寫應用程式,直接匯入及執行 oslogin_service_account_ssh.py

  1. 連線至代管 SSH 應用程式的 VM。

  2. 在 VM 上安裝 pip 和 Python 3 用戶端程式庫:

    sudo apt update && sudo apt install python3-pip -y && pip install --upgrade google-cloud-os-login requests
    
  3. 選用步驟:如果您使用 oslogin_service_account_ssh.py 範例應用程式,請從 GoogleCloudPlatform/python-docs-samples 下載該應用程式:

    curl -O https://raw.githubusercontent.com/GoogleCloudPlatform/python-docs-samples/master/compute/oslogin/oslogin_service_account_ssh.py
    
  4. 執行 SSH 應用程式。範例應用程式會使用 argparse 接受指令列的變數。在本範例中,請指示應用程式在專案中的另一個 VM 上安裝並執行 cowsay

    python3 service_account_ssh.py \
       --cmd 'sudo apt install cowsay -y && cowsay "It works!"' \
       --project=PROJECT_ID --instance=VM_NAME --zone=ZONE
    

    更改下列內容:

    • PROJECT_ID:應用程式連線至的 VM 專案 ID。
    • VM_NAME:應用程式連線的 VM 名稱。
    • ZONE:應用程式連線的 VM 區域。

    輸出結果會與下列內容相似:

    ⋮
    ___________
     It works!
    -----------
          \   ^__^
           \  (oo)\_______
              (__)\       )\/\
                  ||----w |
                  ||     ||
    

後續步驟