diff --git a/README.md b/README.md index 88f4a2a..f53a73c 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,11 @@ ![Python](https://img.shields.io/badge/python-3.8%2B-brightgreen) ![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20Linux-blue) -Set up passwordless SSH on Raspberry Pi, NVIDIA Jetson or any Linux device in one command. +**Set up passwordless SSH on Raspberry Pi, NVIDIA Jetson or any Linux device in one command.** -Tired of juggling `ssh-keygen`, `ssh-copy-id` (missing on Windows) and `~/.ssh/config` edits every time you set up a new device? `ssh-keyup` handles all three in a single interactive session. +Tired of juggling `ssh-keygen`, `ssh-copy-id` (missing on Windows) and `~/.ssh/config` edits every time you set up a new device? + +**`ssh-keyup`** handles all three in a single interactive session. ![ssh-keyup demo](https://raw.githubusercontent.com/Kurokesu/ssh-keyup/main/demo.gif) @@ -101,5 +103,5 @@ ssh-keyup - Detects and recovers from **host key mismatches** (common after reflashing) - Handles re-runs gracefully: reuses existing keys or offers regeneration, detects duplicate config entries - Works with any device reachable over SSH: Raspberry Pi, NVIDIA Jetson, Orange Pi, VMs, servers -- **Zero dependencies**, Python 3.8+ standard library only +- **Zero Python dependencies**, standard library only. Uses system OpenSSH (`ssh`, `ssh-keygen`) - Installs via `pip` or runs as a single script diff --git a/pyproject.toml b/pyproject.toml index 8458e0b..3ccb06d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,3 +5,5 @@ build-backend = "setuptools.build_meta" # tool tables are ignored by setuptools, safe next to setup.cfg metadata [tool.ruff] line-length = 79 +# match python_requires, keeps fixes runtime safe on old Pythons +target-version = "py38" diff --git a/setup.cfg b/setup.cfg index f1a5233..3e45839 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,7 +1,7 @@ [metadata] name = ssh-keyup version = attr: ssh_keyup.__version__ -description = Passwordless SSH setup for Raspberry Pi, NVIDIA Jetson, or any Linux device +description = Passwordless SSH setup and ssh-copy-id alternative for Raspberry Pi, NVIDIA Jetson or any Linux device long_description = file: README.md long_description_content_type = text/markdown author = UAB Kurokesu @@ -10,14 +10,19 @@ license_files = LICENSE url = https://github.com/Kurokesu/ssh-keyup project_urls = Repository = https://github.com/Kurokesu/ssh-keyup -keywords = ssh, ssh-keys, ssh-config, ssh-copy-id, passwordless-ssh, raspberry-pi, rpi, jetson, automation, vscode, remote-ssh + Issues = https://github.com/Kurokesu/ssh-keyup/issues + Changelog = https://github.com/Kurokesu/ssh-keyup/releases +keywords = ssh, ssh-keys, ssh-config, ssh-copy-id, passwordless-ssh, openssh, ed25519, raspberry-pi, rpi, jetson, iot, automation, vscode, remote-ssh, windows, linux classifiers = - Development Status :: 4 - Beta + Development Status :: 5 - Production/Stable Environment :: Console + Intended Audience :: Developers + Intended Audience :: System Administrators Operating System :: Microsoft :: Windows Operating System :: POSIX :: Linux Programming Language :: Python :: 3 Topic :: System :: Systems Administration + Topic :: Utilities [options] py_modules = ssh_keyup diff --git a/ssh_keyup.py b/ssh_keyup.py old mode 100644 new mode 100755 index cd754ff..23267be --- a/ssh_keyup.py +++ b/ssh_keyup.py @@ -6,9 +6,13 @@ # # Copyright (c) 2026, UAB Kurokesu. All rights reserved. -__version__ = "1.1.0" +# Makes modern annotation syntax runtime safe on Python 3.8 +from __future__ import annotations + +__version__ = "1.1.1" import argparse +import contextlib import ipaddress import os import re @@ -16,10 +20,9 @@ import subprocess import sys import tempfile -from datetime import date +from datetime import datetime, timezone from pathlib import Path from shutil import which -from typing import Dict, List, Optional, Set, Tuple, Union if sys.platform == "win32": import ctypes @@ -75,14 +78,13 @@ def __init__(self) -> None: def enable_ansi() -> None: """Enable ANSI escape sequences on Windows 10+.""" if sys.platform == "win32": - try: + # Best effort, colors just stay off if this fails + with contextlib.suppress(Exception): k = ctypes.windll.kernel32 h = k.GetStdHandle(-11) m = ctypes.c_ulong() k.GetConsoleMode(h, ctypes.byref(m)) k.SetConsoleMode(h, m.value | 0x0004) - except Exception: - pass @staticmethod def banner() -> None: @@ -149,7 +151,7 @@ def ssh_info(msg: str) -> None: @staticmethod def prompt( - label: str, value: Optional[str] = None, *, + label: str, value: str | None = None, *, hint: str = "", default: str = "", ) -> str: """Prompt for input, or display and return a pre-supplied value.""" @@ -251,7 +253,7 @@ class Runner: """Run SSH commands natively or via Git Bash as fallback.""" @staticmethod - def _find_git_bash() -> Optional[str]: + def _find_git_bash() -> str | None: """Locate Git Bash on Windows for use as an SSH fallback.""" git = which("git") if not git: @@ -266,7 +268,7 @@ def _find_git_bash() -> Optional[str]: def __init__(self) -> None: self.git_bash = Runner._find_git_bash() openssh = all(which(c) for c in ("ssh", "ssh-keygen")) - self.mode: Optional[str] = ( + self.mode: str | None = ( "native" if openssh else ("gitbash" if self.git_bash else None) ) @@ -285,8 +287,8 @@ def check(self) -> None: "sudo apt install openssh-client") def _subprocess_args( - self, cmd: Union[List[str], str], - ) -> Tuple[Union[List[str], str], bool]: + self, cmd: list[str] | str, + ) -> tuple[list[str] | str, bool]: """Prepare the command and shell flag for subprocess.run.""" if self.mode == "native": return cmd, isinstance(cmd, str) @@ -296,17 +298,19 @@ def _subprocess_args( else " ".join(shlex.quote(a) for a in cmd)) return [self.git_bash, "-c", sh], False - def run(self, cmd: Union[List[str], str], **kwargs) -> int: + def run(self, cmd: list[str] | str, **kwargs) -> int: """Run a command and return the exit code.""" args, shell = self._subprocess_args(cmd) - return subprocess.run(args, shell=shell, **kwargs).returncode + r = subprocess.run(args, shell=shell, check=False, **kwargs) + return r.returncode def run_capture( - self, cmd: Union[List[str], str], **kwargs, - ) -> Tuple[int, str]: + self, cmd: list[str] | str, **kwargs, + ) -> tuple[int, str]: """Run a command, capture stderr, return (rc, text).""" args, shell = self._subprocess_args(cmd) - r = subprocess.run(args, shell=shell, stderr=subprocess.PIPE, **kwargs) + r = subprocess.run(args, shell=shell, check=False, + stderr=subprocess.PIPE, **kwargs) return r.returncode, (r.stderr or b"").decode(errors="replace") @@ -314,9 +318,9 @@ class SSHConfig: """Manage ssh-keyup entries in ~/.ssh/config.""" @staticmethod - def _find_managed_blocks(text: str) -> Dict[str, Tuple[int, int]]: + def _find_managed_blocks(text: str) -> dict[str, tuple[int, int]]: """Find ssh-keyup managed blocks in SSH config text.""" - blocks: Dict[str, Tuple[int, int]] = {} + blocks: dict[str, tuple[int, int]] = {} for m in re.finditer( r"^#ssh-keyup:begin (\S+)[^\n]*\n.*?^#ssh-keyup:end \1[^\n]*\n?", text, re.MULTILINE | re.DOTALL, @@ -326,7 +330,7 @@ def _find_managed_blocks(text: str) -> Dict[str, Tuple[int, int]]: @staticmethod def _has_unmanaged_host( - text: str, alias: str, managed_blocks: Dict[str, Tuple[int, int]], + text: str, alias: str, managed_blocks: dict[str, tuple[int, int]], ) -> bool: """Check for a Host entry outside managed markers.""" for m in re.finditer(r"^Host\s+(\S+)", text, re.MULTILINE): @@ -342,8 +346,9 @@ def _build_block( alias: str, host: str, user: str, file_alias: str, ) -> str: """Build the SSH config block text for a managed host entry.""" + stamp = datetime.now(timezone.utc).astimezone().date().isoformat() return ( - f"#ssh-keyup:begin {alias} {date.today().isoformat()}\n" + f"#ssh-keyup:begin {alias} {stamp}\n" f"Host {alias}\n" f" HostName {host}\n" f" User {user}\n" @@ -352,7 +357,7 @@ def _build_block( ) @staticmethod - def _splice_out(text: str, span: Tuple[int, int]) -> str: + def _splice_out(text: str, span: tuple[int, int]) -> str: """Remove a text span, collapsing surrounding blank lines.""" start, end = span before = text[:start].rstrip("\n") @@ -362,7 +367,7 @@ def _splice_out(text: str, span: Tuple[int, int]) -> str: return before or after @staticmethod - def check_existing(ssh_config: Path, alias: str) -> Tuple[str, bool]: + def check_existing(ssh_config: Path, alias: str) -> tuple[str, bool]: """Check for an existing alias, prompt to overwrite.""" if not ssh_config.exists(): return "", False @@ -391,7 +396,12 @@ def check_existing(ssh_config: Path, alias: str) -> Tuple[str, bool]: return SSHConfig._splice_out(text, blocks[alias]), True @staticmethod - def collect_entries(text: str) -> List[Dict[str, str]]: + def remove_stale(ssh_config: Path, base_text: str) -> None: + """Write config with the overwritten entry spliced out.""" + SSHConfig._atomic_write(ssh_config, base_text) + + @staticmethod + def collect_entries(text: str) -> list[dict[str, str]]: """Parse managed entries from SSH config text.""" entries = [] for m in re.finditer( @@ -401,7 +411,7 @@ def collect_entries(text: str) -> List[Dict[str, str]]: ): body = m.group(3) - def field(name: str) -> str: + def field(name: str, body: str = body) -> str: fm = re.search(rf"^\s*{name}\s+(\S+)", body, re.MULTILINE) return fm.group(1) if fm else "?" @@ -486,11 +496,6 @@ def update( text = block + "\n" SSHConfig._atomic_write(ssh_config, text) - @staticmethod - def revert(ssh_config: Path, base_text: str) -> None: - """Restore SSH config to its pre-update state.""" - SSHConfig._atomic_write(ssh_config, base_text) - class Deployer: """Deploy an SSH public key to a remote host.""" @@ -507,7 +512,7 @@ def _is_unknown_host(stderr: str) -> bool: and "REMOTE HOST IDENTIFICATION HAS CHANGED" not in stderr) @staticmethod - def _format_host_key_info(host: str, stderr: str) -> Optional[str]: + def _format_host_key_info(host: str, stderr: str) -> str | None: """Parse verbose SSH stderr into native-looking host key info.""" key_m = re.search(r"Server host key: (\S+) (\S+)", stderr) if not key_m: @@ -537,7 +542,7 @@ def _handle_unknown_host(host: str, stderr: str) -> bool: @staticmethod def _ssh_cmd(runner: Runner, remote: str, install_cmd: str, - pub_key: str, accept_new: bool = False) -> Tuple[int, str]: + pub_key: str, accept_new: bool = False) -> tuple[int, str]: """Run the SSH deploy command.""" policy = "accept-new" if accept_new else "yes" cmd = ["ssh"] @@ -597,7 +602,7 @@ def deploy(runner: Runner, user: str, host: str, pub_path: Path) -> bool: "\nSSH connection failed. Check host and credentials." ) if stderr.strip(): - seen: Set[str] = set() + seen: set[str] = set() for line in stderr.strip().splitlines(): if line not in seen and not line.startswith("debug1:"): seen.add(line) @@ -627,7 +632,7 @@ def is_ip(value: str) -> bool: return False -def split_target(target: str) -> Tuple[Optional[str], str]: +def split_target(target: str) -> tuple[str | None, str]: """Split a [user@]host target into user and host.""" if "@" in target: user, host = target.rsplit("@", 1) @@ -638,24 +643,23 @@ def split_target(target: str) -> Tuple[Optional[str], str]: _DESCRIPTION = ( "Set up SSH key auth in one command.\n" "Generates a per-host Ed25519 key pair, deploys it\n" - "to the remote host, and adds an entry to ~/.ssh/config." + "to the remote host and adds an entry to ~/.ssh/config." ) -_EPILOG = ( - "examples:\n" - " ssh-keyup" - " interactive mode\n" - " ssh-keyup pi@192.168.1.23 mypi" - " user, host and alias\n" - " ssh-keyup trinity@rpi-5.local" - " alias defaults to rpi-5\n" - " ssh-keyup 192.168.1.23" - " prompts for username and alias\n" - " ssh-keyup --host rpi-5 --user pi --alias mypi\n" - " ssh-keyup --list" - " show managed entries\n" - " ssh-keyup --remove mypi" - " delete a managed entry" +_EXAMPLES = [ + ("ssh-keyup", "interactive mode"), + ("ssh-keyup pi@192.168.1.23 mypi", "user, host and alias"), + ("ssh-keyup trinity@rpi-5.local", "alias defaults to rpi-5"), + ("ssh-keyup 192.168.1.23", "prompts for username and alias"), + ("ssh-keyup --host rpi-5 --user pi --alias mypi", "flags work too"), + ("ssh-keyup --list", "show managed entries"), + ("ssh-keyup --remove mypi", "delete a managed entry"), +] + +_CMD_WIDTH = max(len(cmd) for cmd, _ in _EXAMPLES) + 2 + +_EPILOG = "examples:\n" + "\n".join( + f" {cmd.ljust(_CMD_WIDTH)}{desc}" for cmd, desc in _EXAMPLES ) @@ -709,8 +713,8 @@ def parse_args() -> argparse.Namespace: return args -def gather_input(args: argparse.Namespace) -> Tuple[str, str, str]: - """Collect host, username, and alias from args or prompts.""" +def gather_input(args: argparse.Namespace) -> tuple[str, str, str]: + """Collect host, username and alias from args or prompts.""" host = cli.prompt("Remote host", args.host, hint="IP or name") if not host: cli.fatal("No host provided.") @@ -749,6 +753,13 @@ def generate_key(runner: Runner, key_path: Path) -> None: cli.fatal("ssh-keygen failed.") +def discard_keys(key_path: Path, pub_path: Path) -> None: + """Delete a key pair generated during a failed run.""" + cli.status("Cleaning up generated key pair...") + key_path.unlink(missing_ok=True) + pub_path.unlink(missing_ok=True) + + def main() -> None: """Entry point: gather input, generate keys, deploy, update config.""" try: @@ -795,24 +806,25 @@ def main() -> None: key_generated = True cli.separator() + # Remove stale entry now, otherwise deploy's ssh would resolve + # the typed host through its old HostName. Stays removed on + # deploy failure, user chose to overwrite. + if overwriting: + try: + SSHConfig.remove_stale(ssh_config, config_base) + except OSError as ex: + if key_generated: + discard_keys(key_path, pub_path) + cli.fatal(f"SSH config update failed: {ex}") if not Deployer.deploy(runner, user, host, pub_path): if key_generated: - cli.status("Cleaning up generated key pair...") - key_path.unlink(missing_ok=True) - pub_path.unlink(missing_ok=True) - if overwriting: - try: - SSHConfig.revert(ssh_config, config_base) - except Exception as ex: - cli.fail( - f"SSH config cleanup failed: {ex}" - ) + discard_keys(key_path, pub_path) sys.exit(1) try: SSHConfig.update(ssh_config, alias, host, user, file_alias, config_base) - except Exception as ex: + except OSError as ex: cli.fatal(f"Key deployed, but SSH config update failed: {ex}") cli.msg(f"Config updated {ssh_config}") diff --git a/test_ssh_keyup.py b/test_ssh_keyup.py index cb8ec96..099dbe2 100644 --- a/test_ssh_keyup.py +++ b/test_ssh_keyup.py @@ -116,9 +116,49 @@ def test_empty_text(self): assert ssh_keyup.SSHConfig.collect_entries("") == [] +class TestCheckExisting: + def test_overwrite_splices_block_keeps_disk(self, tmp_path, monkeypatch): + cfg = tmp_path / "config" + cfg.write_text(SAMPLE_CONFIG) + monkeypatch.setattr(ssh_keyup.cli, "ask_yn", lambda msg: True) + base, overwriting = ssh_keyup.SSHConfig.check_existing(cfg, "mypi") + assert overwriting is True + assert "#ssh-keyup:begin mypi" not in base + assert "#ssh-keyup:begin jet" in base + assert cfg.read_text() == SAMPLE_CONFIG + + def test_no_managed_entry(self, tmp_path): + cfg = tmp_path / "config" + cfg.write_text("Host manual\n HostName 10.0.0.1\n") + base, overwriting = ssh_keyup.SSHConfig.check_existing(cfg, "mypi") + assert overwriting is False + assert base == cfg.read_text() + + def test_decline_overwrite_keeps_config(self, tmp_path, monkeypatch): + cfg = tmp_path / "config" + cfg.write_text(SAMPLE_CONFIG) + monkeypatch.setattr(ssh_keyup.cli, "ask_yn", lambda msg: False) + with pytest.raises(SystemExit) as exc: + ssh_keyup.SSHConfig.check_existing(cfg, "mypi") + assert exc.value.code == 0 + assert cfg.read_text() == SAMPLE_CONFIG + + def test_remove_stale_writes_base(self, tmp_path, monkeypatch): + cfg = tmp_path / "config" + cfg.write_text(SAMPLE_CONFIG) + monkeypatch.setattr(ssh_keyup.cli, "ask_yn", lambda msg: True) + base, _ = ssh_keyup.SSHConfig.check_existing(cfg, "mypi") + ssh_keyup.SSHConfig.remove_stale(cfg, base) + text = cfg.read_text() + assert "#ssh-keyup:begin mypi" not in text + assert "#ssh-keyup:begin jet" in text + assert text == base + + class TestRemoveEntry: def test_removes_only_target_block(self, tmp_path, monkeypatch): monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) cfg = tmp_path / "config" cfg.write_text(SAMPLE_CONFIG) ssh_keyup.SSHConfig.remove_entry(cfg, "mypi") @@ -129,6 +169,7 @@ def test_removes_only_target_block(self, tmp_path, monkeypatch): def test_deletes_key_pair(self, tmp_path, monkeypatch): monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) ssh_dir = tmp_path / ".ssh" ssh_dir.mkdir() cfg = ssh_dir / "config"