diff --git a/CHANGELOG.md b/CHANGELOG.md index 4501df8..17f114b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Nothing yet. +## [11.1.0 - 2024-03-16] + +## Added + +### Library + +- `ssh_key` to `client.Client()` and to `client.Token()`. This acts as a default SSH key when launching servers this way. + +### CLI + +- Support for automatic per-token SSH keys (can be overridden with `--ssh-key-file` still.) To generate, run: `ssh-keygen -C "" -t ed25519 -f ~/.sporestack/sshkey/{token}/id_ed25519` +- This means that you don't have to pass `--ssh-key-file` if you are using a token that has a locally associated SSH key. +- When launching a server with `sporestack server launch`, it will suggest adding a readymade configuration to `~/.ssh/config` to utilize whatever key you selected. + +## Summary + +These changes should make it easier to stay private with SporeStack, conveniently, by utilizing a SSH key per token. In general, we recommend using one unique SSH key per token that you have. + ## [11.0.1 - 2024-02-29] ## Fixed diff --git a/src/sporestack/__init__.py b/src/sporestack/__init__.py index a02c6db..10e7e54 100644 --- a/src/sporestack/__init__.py +++ b/src/sporestack/__init__.py @@ -2,4 +2,4 @@ __all__ = ["api", "api_client", "client", "exceptions"] -__version__ = "11.0.1" +__version__ = "11.1.0" diff --git a/src/sporestack/api_client.py b/src/sporestack/api_client.py index 9122ad6..70ca8bf 100644 --- a/src/sporestack/api_client.py +++ b/src/sporestack/api_client.py @@ -12,6 +12,7 @@ from .models import Currency, Invoice, ServerUpdateRequest, TokenInfo log = logging.getLogger(__name__) LATEST_API_VERSION = 2 +"""This is probably not used anymore.""" CLEARNET_ENDPOINT = "https://api.sporestack.com" TOR_ENDPOINT = ( diff --git a/src/sporestack/cli.py b/src/sporestack/cli.py index da372f9..af5de75 100644 --- a/src/sporestack/cli.py +++ b/src/sporestack/cli.py @@ -8,7 +8,7 @@ import os import sys import time from pathlib import Path -from typing import TYPE_CHECKING, Any, Dict, Optional +from typing import TYPE_CHECKING, Any, Dict, Optional, Union import typer @@ -54,6 +54,7 @@ cli.add_typer(token_cli, name="token") server_cli = typer.Typer(help="Commands to interact with SporeStack servers.") cli.add_typer(server_cli, name="server") +log = logging.getLogger(__name__) _log_level = os.getenv("LOG_LEVEL", "warning").upper() _numeric_log_level = getattr(logging, _log_level, None) if _numeric_log_level is None: @@ -65,6 +66,8 @@ DEFAULT_TOKEN = "primary" DEFAULT_FLAVOR = "vps-1vcpu-1gb" # Users may have a different key file, but this is the most common. DEFAULT_SSH_KEY_FILE = HOME / ".ssh" / "id_rsa.pub" +DEFAULT_TOKEN_SSH_KEY_PRIVATE = Path("id_ed25519") +DEFAULT_TOKEN_SSH_KEY_PUBLIC = DEFAULT_TOKEN_SSH_KEY_PRIVATE.with_suffix(".pub") # On disk format TOKEN_VERSION = 1 @@ -94,6 +97,27 @@ def invoice_qr(invoice: "Invoice") -> None: qr.terminal() +def normalize_ssh_key_file(ssh_key_file: Union[Path, None], token: str) -> Path: + if ssh_key_file is None: + token_specific_path = ssh_key_path(token) + token_specific_key = token_specific_path / DEFAULT_TOKEN_SSH_KEY_PUBLIC + if token_specific_key.exists(): + ssh_key_file = token_specific_key + elif DEFAULT_SSH_KEY_FILE.exists(): + ssh_key_file = DEFAULT_SSH_KEY_FILE + + if ssh_key_file is None: + typer.echo( + "No SSH key specified with --ssh-key-file, nor was " + f"{token_specific_key} or {DEFAULT_SSH_KEY_FILE} found.", + err=True, + ) + typer.echo("You can generate a SSH key with `ssh-key-gen`", err=True) + raise typer.Exit(code=1) + + return ssh_key_file + + @server_cli.command() def launch( days: Annotated[ @@ -120,9 +144,24 @@ def launch( ], hostname: Annotated[ str, - typer.Option(help="Give the server a hostname to help remember what it's for."), + typer.Option( + help=( + "Give the server a hostname to help remember what it's for. " + "(Note: This is visible to us.)" + ) + ), ] = "", - ssh_key_file: Path = DEFAULT_SSH_KEY_FILE, + ssh_key_file: Annotated[ + Union[Path, None], + typer.Option( + help=( + "SSH key that the new server will allow to login as root. Defaults " + "to the token-specific SSH key, or ~/.ssh/id_rsa.pub if the former " + "was not found." + ), + show_default=False, + ), + ] = None, flavor: Annotated[ str, typer.Option(help="Run `sporestack server flavors` to see more options.") ] = DEFAULT_FLAVOR, @@ -151,21 +190,16 @@ def launch( typer.echo(f"Launching server with token {token}...", err=True) _token = load_token(token) - from . import utils + ssh_key_file = normalize_ssh_key_file(ssh_key_file=ssh_key_file, token=token) + typer.echo(f"Using SSH key: {ssh_key_file}") + from .client import Client - client = Client(api_client=get_api_client(), client_token=_token) - - typer.echo(f"Loading SSH key from {ssh_key_file}...") - if not ssh_key_file.exists(): - msg = f"{ssh_key_file} does not exist. " - msg += "You can try generating a key file with `ssh-keygen`" - typer.echo(msg, err=True) - raise typer.Exit(code=1) - - ssh_key = ssh_key_file.read_text() - - machine_id = utils.random_machine_id() + client = Client( + api_client=get_api_client(), + client_token=_token, + ssh_key=ssh_key_file.read_text(), + ) if quote: quote_response = client.server_quote(days=days, flavor=flavor) @@ -185,18 +219,16 @@ def launch( ) server = client.token.launch_server( - machine_id=machine_id, days=days, flavor=flavor, operating_system=operating_system, - ssh_key=ssh_key, region=region, hostname=hostname, autorenew=autorenew, ) if wait: - tries = 360 + tries = 60 while tries > 0: response = server.info() if response.deleted_at > 0: @@ -216,9 +248,44 @@ def launch( raise typer.Exit(code=1) else: print_machine_info(response) - return - print_machine_info(server.info()) + if not wait: + print_machine_info(server.info()) + return + + typer.echo("Consider adding the following to ~/.ssh/config...") + + config = ( + "\nHost {host}\n" + "\tHostname {hostname}\n" + f"\tIdentityFile {str(ssh_key_file).strip('.pub')}\n" + "\tUser root\n" + "\t # Remove this comment if you wish to connect via Tor. " + "ProxyCommand nc -x localhost:9050 %h %p\n" + ) + + typer.echo("If you wish to connect with IPv4:") + typer.echo( + config.format( + host=hostname if hostname != "" else response.ipv4, hostname=response.ipv4 + ) + ) + + typer.echo("Or if you wish to connect with IPv6:") + typer.echo( + config.format( + host=hostname if hostname != "" else response.ipv6, hostname=response.ipv6 + ) + ) + + msg = ( + "If you've done that, you should be able to run `ssh {host}` " + "to connect to the server." + ) + if hostname != "": + typer.echo(msg.format(host=hostname)) + else: + typer.echo(msg.format(host=f"({response.ipv4} or {response.ipv6})")) @server_cli.command() @@ -251,20 +318,6 @@ def server_info_path() -> Path: # Put servers in a subdirectory servers_dir = SPORESTACK_DIR / "servers" - # Migrate existing server.json files into servers subdirectory - if ( - SPORESTACK_DIR.exists() - and not servers_dir.exists() - and len(list(SPORESTACK_DIR.glob("*.json"))) > 0 - ): - typer.echo( - f"Migrating server profiles found in {SPORESTACK_DIR} to {servers_dir}.", - err=True, - ) - servers_dir.mkdir() - for json_file in SPORESTACK_DIR.glob("*.json"): - json_file.rename(servers_dir / json_file.name) - # Make it, if it doesn't exist already. SPORESTACK_DIR.mkdir(exist_ok=True) servers_dir.mkdir(exist_ok=True) @@ -281,6 +334,15 @@ def token_path() -> Path: return token_dir +def ssh_key_path(token: str) -> Path: + ssh_key_dir = SPORESTACK_DIR / "sshkey" / token + + # Make it, if it doesn't exist already. + ssh_key_dir.mkdir(exist_ok=True, parents=True) + + return ssh_key_dir + + def get_machine_info(hostname: str) -> Dict[str, Any]: """ Get info from disk. @@ -848,6 +910,15 @@ def token_create( ) typer.echo(f"{token}'s key is {_token}.") typer.echo("Save it, don't share it, and don't lose it!") + typer.echo() + typer.echo("Optional: Make a SSH key just for this token.") + token_ssh_key_path = ssh_key_path(token) / DEFAULT_TOKEN_SSH_KEY_PRIVATE + typer.echo(f'Run: ssh-keygen -C "" -t ed25519 -f "{token_ssh_key_path}"') + typer.echo( + "If you do this, servers launched from that token will default to use " + "that key and you won't have to pass --ssh-key-file every time you " + "launch a server!" + ) @token_cli.command(name="import") diff --git a/src/sporestack/client.py b/src/sporestack/client.py index eafb635..b5dc5b9 100644 --- a/src/sporestack/client.py +++ b/src/sporestack/client.py @@ -69,9 +69,11 @@ class Server: class Token: token: str = field(default_factory=random_token) api_client: APIClient = field(default_factory=APIClient) + ssh_key: Union[str, None] = None + """SSH public key for launching new servers with.""" def add(self, dollars: int, currency: Currency) -> Invoice: - """Add to token""" + """Fund the token.""" response = self.api_client.token_add( token=self.token, dollars=dollars, @@ -119,15 +121,20 @@ class Token: def launch_server( self, - ssh_key: str, flavor: str, days: int, operating_system: str, + ssh_key: Union[str, None] = None, region: Union[str, None] = None, hostname: str = "", autorenew: bool = False, machine_id: str = random_machine_id(), ) -> Server: + if ssh_key is None: + if self.ssh_key is not None: + ssh_key = self.ssh_key + else: + raise ValueError("ssh_key must be set in Client() or launch_server().") self.api_client.server_launch( machine_id=machine_id, days=days, @@ -150,6 +157,8 @@ class Client: """Token to manage/pay for servers with.""" api_client: APIClient = field(default_factory=APIClient) """Your own API Client, perhaps if you want to connect through Tor.""" + ssh_key: Union[str, None] = None + """SSH public key for launching new servers with.""" def flavors(self) -> api.Flavors.Response: """Returns available flavors (server sizes).""" @@ -174,4 +183,6 @@ class Client: @property def token(self) -> Token: """Returns a Token object with the api_client and token specified.""" - return Token(token=self.client_token, api_client=self.api_client) + return Token( + token=self.client_token, api_client=self.api_client, ssh_key=self.ssh_key + )