Skip to content
Merged
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
11 changes: 8 additions & 3 deletions setup.cfg
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down
136 changes: 74 additions & 62 deletions ssh_keyup.py
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,23 @@
#
# 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
import shlex
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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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:
Expand All @@ -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)
)
Expand All @@ -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)
Expand All @@ -296,27 +298,29 @@ 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")


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,
Expand All @@ -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):
Expand All @@ -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"
Expand All @@ -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")
Expand All @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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 "?"

Expand Down Expand Up @@ -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."""
Expand All @@ -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:
Expand Down Expand Up @@ -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"]
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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
)


Expand Down Expand Up @@ -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.")
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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}")

Expand Down
Loading