v11.1.0: Automatic per-token SSH key support
This commit is contained in:
parent
6bc5791980
commit
6cad92952a
18
CHANGELOG.md
18
CHANGELOG.md
|
@ -13,6 +13,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||||
|
|
||||||
- Nothing yet.
|
- 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]
|
## [11.0.1 - 2024-02-29]
|
||||||
|
|
||||||
## Fixed
|
## Fixed
|
||||||
|
|
|
@ -2,4 +2,4 @@
|
||||||
|
|
||||||
__all__ = ["api", "api_client", "client", "exceptions"]
|
__all__ = ["api", "api_client", "client", "exceptions"]
|
||||||
|
|
||||||
__version__ = "11.0.1"
|
__version__ = "11.1.0"
|
||||||
|
|
|
@ -12,6 +12,7 @@ from .models import Currency, Invoice, ServerUpdateRequest, TokenInfo
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
LATEST_API_VERSION = 2
|
LATEST_API_VERSION = 2
|
||||||
|
"""This is probably not used anymore."""
|
||||||
|
|
||||||
CLEARNET_ENDPOINT = "https://api.sporestack.com"
|
CLEARNET_ENDPOINT = "https://api.sporestack.com"
|
||||||
TOR_ENDPOINT = (
|
TOR_ENDPOINT = (
|
||||||
|
|
|
@ -8,7 +8,7 @@ import os
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any, Dict, Optional
|
from typing import TYPE_CHECKING, Any, Dict, Optional, Union
|
||||||
|
|
||||||
import typer
|
import typer
|
||||||
|
|
||||||
|
@ -54,6 +54,7 @@ cli.add_typer(token_cli, name="token")
|
||||||
server_cli = typer.Typer(help="Commands to interact with SporeStack servers.")
|
server_cli = typer.Typer(help="Commands to interact with SporeStack servers.")
|
||||||
cli.add_typer(server_cli, name="server")
|
cli.add_typer(server_cli, name="server")
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
_log_level = os.getenv("LOG_LEVEL", "warning").upper()
|
_log_level = os.getenv("LOG_LEVEL", "warning").upper()
|
||||||
_numeric_log_level = getattr(logging, _log_level, None)
|
_numeric_log_level = getattr(logging, _log_level, None)
|
||||||
if _numeric_log_level is None:
|
if _numeric_log_level is None:
|
||||||
|
@ -65,6 +66,8 @@ DEFAULT_TOKEN = "primary"
|
||||||
DEFAULT_FLAVOR = "vps-1vcpu-1gb"
|
DEFAULT_FLAVOR = "vps-1vcpu-1gb"
|
||||||
# Users may have a different key file, but this is the most common.
|
# Users may have a different key file, but this is the most common.
|
||||||
DEFAULT_SSH_KEY_FILE = HOME / ".ssh" / "id_rsa.pub"
|
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
|
# On disk format
|
||||||
TOKEN_VERSION = 1
|
TOKEN_VERSION = 1
|
||||||
|
@ -94,6 +97,27 @@ def invoice_qr(invoice: "Invoice") -> None:
|
||||||
qr.terminal()
|
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()
|
@server_cli.command()
|
||||||
def launch(
|
def launch(
|
||||||
days: Annotated[
|
days: Annotated[
|
||||||
|
@ -120,9 +144,24 @@ def launch(
|
||||||
],
|
],
|
||||||
hostname: Annotated[
|
hostname: Annotated[
|
||||||
str,
|
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[
|
flavor: Annotated[
|
||||||
str, typer.Option(help="Run `sporestack server flavors` to see more options.")
|
str, typer.Option(help="Run `sporestack server flavors` to see more options.")
|
||||||
] = DEFAULT_FLAVOR,
|
] = DEFAULT_FLAVOR,
|
||||||
|
@ -151,21 +190,16 @@ def launch(
|
||||||
typer.echo(f"Launching server with token {token}...", err=True)
|
typer.echo(f"Launching server with token {token}...", err=True)
|
||||||
_token = load_token(token)
|
_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
|
from .client import Client
|
||||||
|
|
||||||
client = Client(api_client=get_api_client(), client_token=_token)
|
client = Client(
|
||||||
|
api_client=get_api_client(),
|
||||||
typer.echo(f"Loading SSH key from {ssh_key_file}...")
|
client_token=_token,
|
||||||
if not ssh_key_file.exists():
|
ssh_key=ssh_key_file.read_text(),
|
||||||
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()
|
|
||||||
|
|
||||||
if quote:
|
if quote:
|
||||||
quote_response = client.server_quote(days=days, flavor=flavor)
|
quote_response = client.server_quote(days=days, flavor=flavor)
|
||||||
|
@ -185,18 +219,16 @@ def launch(
|
||||||
)
|
)
|
||||||
|
|
||||||
server = client.token.launch_server(
|
server = client.token.launch_server(
|
||||||
machine_id=machine_id,
|
|
||||||
days=days,
|
days=days,
|
||||||
flavor=flavor,
|
flavor=flavor,
|
||||||
operating_system=operating_system,
|
operating_system=operating_system,
|
||||||
ssh_key=ssh_key,
|
|
||||||
region=region,
|
region=region,
|
||||||
hostname=hostname,
|
hostname=hostname,
|
||||||
autorenew=autorenew,
|
autorenew=autorenew,
|
||||||
)
|
)
|
||||||
|
|
||||||
if wait:
|
if wait:
|
||||||
tries = 360
|
tries = 60
|
||||||
while tries > 0:
|
while tries > 0:
|
||||||
response = server.info()
|
response = server.info()
|
||||||
if response.deleted_at > 0:
|
if response.deleted_at > 0:
|
||||||
|
@ -216,9 +248,44 @@ def launch(
|
||||||
raise typer.Exit(code=1)
|
raise typer.Exit(code=1)
|
||||||
else:
|
else:
|
||||||
print_machine_info(response)
|
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()
|
@server_cli.command()
|
||||||
|
@ -251,20 +318,6 @@ def server_info_path() -> Path:
|
||||||
# Put servers in a subdirectory
|
# Put servers in a subdirectory
|
||||||
servers_dir = SPORESTACK_DIR / "servers"
|
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.
|
# Make it, if it doesn't exist already.
|
||||||
SPORESTACK_DIR.mkdir(exist_ok=True)
|
SPORESTACK_DIR.mkdir(exist_ok=True)
|
||||||
servers_dir.mkdir(exist_ok=True)
|
servers_dir.mkdir(exist_ok=True)
|
||||||
|
@ -281,6 +334,15 @@ def token_path() -> Path:
|
||||||
return token_dir
|
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]:
|
def get_machine_info(hostname: str) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Get info from disk.
|
Get info from disk.
|
||||||
|
@ -848,6 +910,15 @@ def token_create(
|
||||||
)
|
)
|
||||||
typer.echo(f"{token}'s key is {_token}.")
|
typer.echo(f"{token}'s key is {_token}.")
|
||||||
typer.echo("Save it, don't share it, and don't lose it!")
|
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")
|
@token_cli.command(name="import")
|
||||||
|
|
|
@ -69,9 +69,11 @@ class Server:
|
||||||
class Token:
|
class Token:
|
||||||
token: str = field(default_factory=random_token)
|
token: str = field(default_factory=random_token)
|
||||||
api_client: APIClient = field(default_factory=APIClient)
|
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:
|
def add(self, dollars: int, currency: Currency) -> Invoice:
|
||||||
"""Add to token"""
|
"""Fund the token."""
|
||||||
response = self.api_client.token_add(
|
response = self.api_client.token_add(
|
||||||
token=self.token,
|
token=self.token,
|
||||||
dollars=dollars,
|
dollars=dollars,
|
||||||
|
@ -119,15 +121,20 @@ class Token:
|
||||||
|
|
||||||
def launch_server(
|
def launch_server(
|
||||||
self,
|
self,
|
||||||
ssh_key: str,
|
|
||||||
flavor: str,
|
flavor: str,
|
||||||
days: int,
|
days: int,
|
||||||
operating_system: str,
|
operating_system: str,
|
||||||
|
ssh_key: Union[str, None] = None,
|
||||||
region: Union[str, None] = None,
|
region: Union[str, None] = None,
|
||||||
hostname: str = "",
|
hostname: str = "",
|
||||||
autorenew: bool = False,
|
autorenew: bool = False,
|
||||||
machine_id: str = random_machine_id(),
|
machine_id: str = random_machine_id(),
|
||||||
) -> Server:
|
) -> 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(
|
self.api_client.server_launch(
|
||||||
machine_id=machine_id,
|
machine_id=machine_id,
|
||||||
days=days,
|
days=days,
|
||||||
|
@ -150,6 +157,8 @@ class Client:
|
||||||
"""Token to manage/pay for servers with."""
|
"""Token to manage/pay for servers with."""
|
||||||
api_client: APIClient = field(default_factory=APIClient)
|
api_client: APIClient = field(default_factory=APIClient)
|
||||||
"""Your own API Client, perhaps if you want to connect through Tor."""
|
"""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:
|
def flavors(self) -> api.Flavors.Response:
|
||||||
"""Returns available flavors (server sizes)."""
|
"""Returns available flavors (server sizes)."""
|
||||||
|
@ -174,4 +183,6 @@ class Client:
|
||||||
@property
|
@property
|
||||||
def token(self) -> Token:
|
def token(self) -> Token:
|
||||||
"""Returns a Token object with the api_client and token specified."""
|
"""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
|
||||||
|
)
|
||||||
|
|
Loading…
Reference in New Issue