first commit
This commit is contained in:
commit
3fb78dcafb
6 changed files with 570 additions and 0 deletions
0
README.md
Normal file
0
README.md
Normal file
BIN
__pycache__/add_host.cpython-312.pyc
Normal file
BIN
__pycache__/add_host.cpython-312.pyc
Normal file
Binary file not shown.
108
add_host.py
Normal file
108
add_host.py
Normal file
|
@ -0,0 +1,108 @@
|
|||
import os
|
||||
import subprocess
|
||||
|
||||
class Colors:
|
||||
GREEN = "\033[0;32m"
|
||||
RED = "\033[0;31m"
|
||||
YELLOW = "\033[1;33m"
|
||||
CYAN = "\033[0;36m"
|
||||
BOLD = "\033[1m"
|
||||
RESET = "\033[0m"
|
||||
|
||||
def print_error(message):
|
||||
print(f"{Colors.RED}{Colors.BOLD}[✖] {Colors.RESET}{message}")
|
||||
|
||||
def print_warning(message):
|
||||
print(f"{Colors.YELLOW}{Colors.BOLD}[⚠] {Colors.RESET}{message}")
|
||||
|
||||
def print_info(message):
|
||||
print(f"{Colors.GREEN}{Colors.BOLD}[✔] {Colors.RESET}{message}")
|
||||
|
||||
def add_host(CONF_DIR):
|
||||
"""
|
||||
Interactive prompt to create a new SSH host in ~/.ssh/conf/<label>/config.
|
||||
Offers to generate a new SSH key pair (ed25519) quietly (-q),
|
||||
and then prompt to copy that key to the remote server via ssh-copy-id.
|
||||
"""
|
||||
print_info("Adding a new SSH host...")
|
||||
|
||||
host_label = input("Enter Host label (e.g. myserver): ").strip()
|
||||
if not host_label:
|
||||
print_error("Host label cannot be empty.")
|
||||
return
|
||||
|
||||
hostname = input("Enter HostName (IP or domain): ").strip()
|
||||
if not hostname:
|
||||
print_error("HostName cannot be empty.")
|
||||
return
|
||||
|
||||
user = input("Enter username (default: 'root'): ").strip() or "root"
|
||||
port = input("Enter SSH port (default: 22): ").strip() or "22"
|
||||
|
||||
# Create subdirectory: ~/.ssh/conf/<label>
|
||||
host_dir = os.path.join(CONF_DIR, host_label)
|
||||
if os.path.exists(host_dir):
|
||||
print_warning(f"Directory {host_dir} already exists; continuing anyway.")
|
||||
else:
|
||||
os.makedirs(host_dir, mode=0o700, exist_ok=True)
|
||||
print_info(f"Created directory: {host_dir}")
|
||||
|
||||
config_path = os.path.join(host_dir, "config")
|
||||
if os.path.exists(config_path):
|
||||
print_warning(f"Config file already exists: {config_path}; it will be overwritten/updated.")
|
||||
|
||||
# Ask about generating an SSH key
|
||||
gen_key_choice = input("Generate a new ed25519 SSH key for this host? (y/n): ").lower().strip()
|
||||
identity_file = ""
|
||||
|
||||
if gen_key_choice == 'y':
|
||||
key_path = os.path.join(host_dir, "id_ed25519")
|
||||
if os.path.exists(key_path):
|
||||
print_warning(f"{key_path} already exists. Skipping generation.")
|
||||
identity_file = key_path
|
||||
else:
|
||||
# Generate a new SSH key (quietly, suppressing randomart)
|
||||
cmd = ["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", key_path]
|
||||
try:
|
||||
subprocess.check_call(cmd)
|
||||
print_info(f"Generated new SSH key at {key_path}")
|
||||
identity_file = key_path
|
||||
|
||||
# Prompt to copy the key to the server
|
||||
copy_key = input("Would you like to copy this key to the server now? (y/n): ").lower().strip()
|
||||
if copy_key == 'y':
|
||||
ssh_copy_cmd = ["ssh-copy-id", "-i", key_path]
|
||||
if port != "22":
|
||||
ssh_copy_cmd += ["-p", port]
|
||||
ssh_copy_cmd.append(f"{user}@{hostname}")
|
||||
try:
|
||||
subprocess.check_call(ssh_copy_cmd)
|
||||
print_info("Key successfully copied to remote server.")
|
||||
except subprocess.CalledProcessError as e:
|
||||
print_error(f"Error copying key to server: {e}")
|
||||
except subprocess.CalledProcessError as e:
|
||||
print_error(f"Error generating SSH key: {e}")
|
||||
else:
|
||||
# If not generating a new key, optionally ask for an existing path
|
||||
existing_key = input("Enter existing IdentityFile path (or leave empty to skip): ").strip()
|
||||
if existing_key:
|
||||
identity_file = os.path.expanduser(existing_key)
|
||||
|
||||
# Build the config lines
|
||||
config_lines = [
|
||||
f"Host {host_label}",
|
||||
f" HostName {hostname}",
|
||||
f" User {user}",
|
||||
f" Port {port}"
|
||||
]
|
||||
if identity_file:
|
||||
config_lines.append(f" IdentityFile {identity_file}")
|
||||
|
||||
# Write (or overwrite) the config
|
||||
try:
|
||||
with open(config_path, "w") as f:
|
||||
for line in config_lines:
|
||||
f.write(line + "\n")
|
||||
print_info(f"Created/updated config at: {config_path}")
|
||||
except Exception as e:
|
||||
print_error(f"Failed to write config to {config_path}: {e}")
|
136
edit_host.py
Normal file
136
edit_host.py
Normal file
|
@ -0,0 +1,136 @@
|
|||
import os
|
||||
from collections import OrderedDict
|
||||
|
||||
class Colors:
|
||||
GREEN = "\033[0;32m"
|
||||
RED = "\033[0;31m"
|
||||
YELLOW = "\033[1;33m"
|
||||
CYAN = "\033[0;36m"
|
||||
BOLD = "\033[1m"
|
||||
RESET = "\033[0m"
|
||||
|
||||
def print_error(message):
|
||||
print(f"{Colors.RED}{Colors.BOLD}[✖] {Colors.RESET}{message}")
|
||||
|
||||
def print_warning(message):
|
||||
print(f"{Colors.YELLOW}{Colors.BOLD}[⚠] {Colors.RESET}{message}")
|
||||
|
||||
def print_info(message):
|
||||
print(f"{Colors.GREEN}{Colors.BOLD}[✔] {Colors.RESET}{message}")
|
||||
|
||||
def load_config_file(file_path):
|
||||
"""
|
||||
Parse a single SSH config file and return a list of host blocks.
|
||||
Each block is an OrderedDict with keys like 'Host', 'HostName', etc.
|
||||
"""
|
||||
blocks = []
|
||||
host_data = None
|
||||
|
||||
try:
|
||||
with open(file_path, 'r') as f:
|
||||
lines = f.readlines()
|
||||
except Exception as e:
|
||||
print_error(f"Error reading SSH config file {file_path}: {e}")
|
||||
return blocks
|
||||
|
||||
for line in lines:
|
||||
stripped_line = line.strip()
|
||||
# Skip empty lines and comments
|
||||
if not stripped_line or stripped_line.startswith('#'):
|
||||
continue
|
||||
|
||||
# Start of a new Host block
|
||||
if stripped_line.lower().startswith('host '):
|
||||
host_labels = stripped_line.split()[1:]
|
||||
# Pick the first label that isn't a wildcard
|
||||
for label in host_labels:
|
||||
if '*' not in label:
|
||||
# If we already have a host_data in progress, close it out
|
||||
if host_data:
|
||||
blocks.append(host_data)
|
||||
host_data = OrderedDict({'Host': label})
|
||||
break
|
||||
elif host_data:
|
||||
# Split on the first whitespace into key/value
|
||||
if ' ' in stripped_line:
|
||||
key, value = stripped_line.split(None, 1)
|
||||
host_data[key] = value.strip()
|
||||
|
||||
# Add the last block if it exists
|
||||
if host_data:
|
||||
blocks.append(host_data)
|
||||
|
||||
return blocks
|
||||
|
||||
def edit_host(CONF_DIR):
|
||||
"""
|
||||
Let the user update fields for an existing host.
|
||||
1) Ask which host label to edit
|
||||
2) Locate its subdirectory + config
|
||||
3) Update (HostName, User, Port, IdentityFile) as needed
|
||||
4) Rewrite the config
|
||||
"""
|
||||
host_label = input("Enter the Host label to edit: ").strip()
|
||||
if not host_label:
|
||||
print_error("Host label cannot be empty.")
|
||||
return
|
||||
|
||||
host_dir = os.path.join(CONF_DIR, host_label)
|
||||
config_path = os.path.join(host_dir, "config")
|
||||
if not os.path.isfile(config_path):
|
||||
print_warning(f"No config file found at {config_path}; cannot edit this host.")
|
||||
return
|
||||
|
||||
# Load the config file and look for the relevant host block
|
||||
blocks = load_config_file(config_path)
|
||||
if not blocks:
|
||||
print_warning(f"No valid Host blocks found in {config_path}")
|
||||
return
|
||||
|
||||
# We'll assume there's only one block in each config
|
||||
# (the "Host <label>"). If multiple blocks exist, adapt accordingly.
|
||||
target_block = None
|
||||
for b in blocks:
|
||||
if b.get("Host") == host_label:
|
||||
target_block = b
|
||||
break
|
||||
|
||||
if not target_block:
|
||||
print_warning(f"No matching Host '{host_label}' found in {config_path}")
|
||||
return
|
||||
|
||||
old_hostname = target_block.get("HostName", "")
|
||||
old_user = target_block.get("User", "")
|
||||
old_port = target_block.get("Port", "22")
|
||||
old_identity = target_block.get("IdentityFile", "")
|
||||
|
||||
print_info("Leave a field blank to keep its current value.")
|
||||
new_hostname = input(f"Enter new HostName [{old_hostname}]: ").strip()
|
||||
new_user = input(f"Enter new User [{old_user}]: ").strip()
|
||||
new_port = input(f"Enter new Port [{old_port}]: ").strip()
|
||||
new_ident = input(f"Enter new IdentityFile [{old_identity}]: ").strip()
|
||||
|
||||
# If user leaves it blank, keep the old value
|
||||
final_hostname = new_hostname if new_hostname else old_hostname
|
||||
final_user = new_user if new_user else old_user
|
||||
final_port = new_port if new_port else old_port
|
||||
final_ident = new_ident if new_ident else old_identity
|
||||
|
||||
# Rebuild the config lines
|
||||
new_config_lines = [
|
||||
f"Host {host_label}",
|
||||
f" HostName {final_hostname}",
|
||||
f" User {final_user}",
|
||||
f" Port {final_port}"
|
||||
]
|
||||
if final_ident:
|
||||
new_config_lines.append(f" IdentityFile {final_ident}")
|
||||
|
||||
# Overwrite the file
|
||||
try:
|
||||
with open(config_path, "w") as f:
|
||||
for line in new_config_lines:
|
||||
f.write(line + "\n")
|
||||
print_info(f"Updated config at: {config_path}")
|
||||
except Exception as e:
|
||||
print_error(f"Failed to update config: {e}")
|
238
main.py
Normal file
238
main.py
Normal file
|
@ -0,0 +1,238 @@
|
|||
#!/usr/bin/env python3
|
||||
import os
|
||||
import sys
|
||||
import glob
|
||||
import socket
|
||||
import asyncio
|
||||
from collections import OrderedDict
|
||||
from tabulate import tabulate
|
||||
|
||||
# Import from add_host.py
|
||||
from add_host import add_host, print_info, print_error, print_warning, Colors
|
||||
# Import the new edit_host function from edit_host.py
|
||||
from edit_host import edit_host as edit_existing_host
|
||||
|
||||
# ------------------ Configuration ------------------
|
||||
SSH_DIR = os.path.expanduser("~/.ssh")
|
||||
CONF_DIR = os.path.join(SSH_DIR, "conf")
|
||||
MAIN_CONFIG = os.path.join(SSH_DIR, "config")
|
||||
SOCKET_DIR = os.path.join(SSH_DIR, "s") # Additional directory for ControlPath
|
||||
|
||||
# Default content to use if ~/.ssh/config doesn't exist
|
||||
DEFAULT_CONFIG_CONTENT = """###
|
||||
#Local ssh
|
||||
###
|
||||
|
||||
Include conf/*/config
|
||||
|
||||
###
|
||||
#Catch all ssh config
|
||||
###
|
||||
|
||||
Host *
|
||||
UserKnownHostsFile /dev/null
|
||||
StrictHostKeyChecking no
|
||||
ServerAliveInterval 60
|
||||
ConnectTimeout 60
|
||||
AddKeysToAgent yes
|
||||
EscapeChar `
|
||||
ControlMaster auto
|
||||
ControlPersist 72000
|
||||
ControlPath ~/.ssh/s/%C
|
||||
"""
|
||||
|
||||
def ensure_ssh_setup():
|
||||
"""
|
||||
Ensure ~/.ssh, ~/.ssh/conf, and ~/.ssh/s exist, and if ~/.ssh/config does not exist,
|
||||
create it with DEFAULT_CONFIG_CONTENT.
|
||||
"""
|
||||
# 1) Create ~/.ssh if it doesn't exist
|
||||
if not os.path.isdir(SSH_DIR):
|
||||
os.makedirs(SSH_DIR, mode=0o700, exist_ok=True)
|
||||
print_info(f"Created directory: {SSH_DIR}")
|
||||
|
||||
# 2) Create ~/.ssh/conf if it doesn't exist
|
||||
if not os.path.isdir(CONF_DIR):
|
||||
os.makedirs(CONF_DIR, mode=0o700, exist_ok=True)
|
||||
print_info(f"Created directory: {CONF_DIR}")
|
||||
|
||||
# 3) Create ~/.ssh/s if it doesn't exist
|
||||
if not os.path.isdir(SOCKET_DIR):
|
||||
os.makedirs(SOCKET_DIR, mode=0o700, exist_ok=True)
|
||||
print_info(f"Created directory: {SOCKET_DIR}")
|
||||
|
||||
# 4) Create ~/.ssh/config if it doesn't exist
|
||||
if not os.path.isfile(MAIN_CONFIG):
|
||||
with open(MAIN_CONFIG, "w") as f:
|
||||
f.write(DEFAULT_CONFIG_CONTENT)
|
||||
print_info(f"Created default SSH config at: {MAIN_CONFIG}")
|
||||
|
||||
def load_config_file(file_path):
|
||||
"""
|
||||
Parse a single SSH config file and return a list of host blocks.
|
||||
Each block is an OrderedDict with keys like 'Host', 'HostName', etc.
|
||||
"""
|
||||
blocks = []
|
||||
host_data = None
|
||||
|
||||
try:
|
||||
with open(file_path, 'r') as f:
|
||||
lines = f.readlines()
|
||||
except Exception as e:
|
||||
print_error(f"Error reading SSH config file {file_path}: {e}")
|
||||
return blocks
|
||||
|
||||
for line in lines:
|
||||
stripped_line = line.strip()
|
||||
# Skip empty lines and comments
|
||||
if not stripped_line or stripped_line.startswith('#'):
|
||||
continue
|
||||
|
||||
# Start of a new Host block
|
||||
if stripped_line.lower().startswith('host '):
|
||||
host_labels = stripped_line.split()[1:]
|
||||
for label in host_labels:
|
||||
if '*' not in label:
|
||||
if host_data:
|
||||
blocks.append(host_data)
|
||||
host_data = OrderedDict({'Host': label})
|
||||
break
|
||||
elif host_data:
|
||||
# Split on the first whitespace into key/value
|
||||
if ' ' in stripped_line:
|
||||
key, value = stripped_line.split(None, 1)
|
||||
host_data[key] = value.strip()
|
||||
|
||||
if host_data:
|
||||
blocks.append(host_data)
|
||||
|
||||
return blocks
|
||||
|
||||
async def check_ssh_port(ip_address, port):
|
||||
"""
|
||||
Attempt to open an SSH connection to see if the port is open.
|
||||
Returns True if successful, False otherwise.
|
||||
"""
|
||||
try:
|
||||
reader, writer = await asyncio.wait_for(
|
||||
asyncio.open_connection(ip_address, port), timeout=1
|
||||
)
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
return True
|
||||
except:
|
||||
return False
|
||||
|
||||
async def check_host(host):
|
||||
"""
|
||||
Given a host block (Host, HostName, User, Port, IdentityFile, etc.),
|
||||
resolve the IP, check SSH port, etc. Return a row for tabulate.
|
||||
"""
|
||||
host_label = host.get('Host', 'N/A')
|
||||
hostname = host.get('HostName', 'N/A')
|
||||
user = host.get('User', 'N/A')
|
||||
port = int(host.get('Port', '22'))
|
||||
identity_file = host.get('IdentityFile', 'N/A')
|
||||
|
||||
# Check if identity file exists
|
||||
if identity_file != 'N/A':
|
||||
expanded_identity = os.path.expanduser(identity_file)
|
||||
identity_exists = os.path.isfile(expanded_identity)
|
||||
else:
|
||||
identity_exists = False
|
||||
|
||||
identity_display = (
|
||||
f"{Colors.GREEN}{identity_file}{Colors.RESET}"
|
||||
if identity_exists
|
||||
else f"{Colors.RED}{identity_file}{Colors.RESET}"
|
||||
)
|
||||
|
||||
# Resolve IP
|
||||
try:
|
||||
ip_address = socket.gethostbyname(hostname)
|
||||
colored_ip = f"{Colors.GREEN}{ip_address}{Colors.RESET}"
|
||||
except socket.error:
|
||||
ip_address = None
|
||||
colored_ip = f"{Colors.RED}N/A{Colors.RESET}"
|
||||
|
||||
# Check if port is open
|
||||
if ip_address:
|
||||
port_open = await check_ssh_port(ip_address, port)
|
||||
colored_port = (
|
||||
f"{Colors.GREEN}{port}{Colors.RESET}"
|
||||
if port_open
|
||||
else f"{Colors.RED}{port}{Colors.RESET}"
|
||||
)
|
||||
else:
|
||||
colored_port = f"{Colors.RED}{port}{Colors.RESET}"
|
||||
|
||||
return [
|
||||
host_label,
|
||||
user,
|
||||
colored_port,
|
||||
hostname,
|
||||
colored_ip,
|
||||
identity_display
|
||||
]
|
||||
|
||||
async def list_hosts():
|
||||
"""
|
||||
List out all hosts found in ~/.ssh/conf/*/config, showing connectivity details.
|
||||
If no hosts are found, print an empty table or a warning message.
|
||||
"""
|
||||
pattern = os.path.join(CONF_DIR, "*", "config")
|
||||
conf_files = sorted(glob.glob(pattern))
|
||||
|
||||
all_host_blocks = []
|
||||
for conf_file in conf_files:
|
||||
blocks = load_config_file(conf_file)
|
||||
all_host_blocks.extend(blocks)
|
||||
|
||||
headers = ["No.", "Host", "User", "Port", "HostName", "IP Address", "IdentityFile"]
|
||||
if not all_host_blocks:
|
||||
print_warning("No hosts found. The server list is empty.")
|
||||
print("\nSSH Conf Subdirectory Host List")
|
||||
print(tabulate([], headers=headers, tablefmt="grid"))
|
||||
return
|
||||
|
||||
tasks = [check_host(h) for h in all_host_blocks]
|
||||
results = await asyncio.gather(*tasks)
|
||||
|
||||
table = [[idx + 1] + row for idx, row in enumerate(results)]
|
||||
print("\nSSH Conf Subdirectory Host List")
|
||||
print(tabulate(table, headers=headers, tablefmt="grid"))
|
||||
|
||||
def interactive_menu():
|
||||
"""
|
||||
A simple interactive menu:
|
||||
1. List Hosts
|
||||
2. Add a Host
|
||||
3. Edit a Host
|
||||
4. Exit
|
||||
"""
|
||||
while True:
|
||||
print("\n" + f"{Colors.CYAN}{Colors.BOLD}SSH Config Manager Menu{Colors.RESET}")
|
||||
print("1. List Hosts")
|
||||
print("2. Add a Host")
|
||||
print("3. Edit a Host")
|
||||
print("4. Exit")
|
||||
|
||||
choice = input("Select an option (1-4): ").strip()
|
||||
if choice == '1':
|
||||
asyncio.run(list_hosts())
|
||||
elif choice == '2':
|
||||
add_host(CONF_DIR)
|
||||
elif choice == '3':
|
||||
edit_existing_host(CONF_DIR)
|
||||
elif choice == '4':
|
||||
print_info("Exiting...")
|
||||
break
|
||||
else:
|
||||
print_error("Invalid choice. Please select 1, 2, 3, or 4.")
|
||||
|
||||
def main():
|
||||
ensure_ssh_setup()
|
||||
interactive_menu()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
88
migration.py
Normal file
88
migration.py
Normal file
|
@ -0,0 +1,88 @@
|
|||
#!/usr/bin/env python3
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
|
||||
def main():
|
||||
"""
|
||||
For each config file in ~/.ssh/conf, find lines matching:
|
||||
IdentityFile ~/.ssh/conf/<folderA>/keys/<folderB>/id_ed25519[.pub]?
|
||||
and replace them with:
|
||||
IdentityFile ~/.ssh/conf/<folderB>/id_ed25519[.pub]?
|
||||
|
||||
Then physically move the key file from:
|
||||
~/.ssh/conf/<folderA>/keys/<folderB>/
|
||||
to:
|
||||
~/.ssh/conf/<folderB>/
|
||||
"""
|
||||
|
||||
conf_root = os.path.expanduser("~/.ssh/conf")
|
||||
# Regex capturing the path:
|
||||
# group(1) -> "IdentityFile ~/.ssh/conf/"
|
||||
# group(2) -> folderA
|
||||
# group(3) -> folderB
|
||||
# group(4) -> "id_ed25519" or "id_ed25519.pub"
|
||||
# group(5) -> "id_ed25519" (without .pub)
|
||||
# group(6) -> ".pub" or empty
|
||||
pattern = re.compile(r'(IdentityFile\s+~/.ssh/conf/)([^/]+)/keys/([^/]+)/((id_ed25519)(\.pub)?)')
|
||||
|
||||
# Walk the ~/.ssh/conf directory tree
|
||||
for root, dirs, files in os.walk(conf_root):
|
||||
for f in files:
|
||||
# Only process "config" or "*.conf" files
|
||||
if f == "config" or f.endswith(".conf"):
|
||||
file_path = os.path.join(root, f)
|
||||
|
||||
# Read the file
|
||||
with open(file_path, 'r') as fp:
|
||||
lines = fp.readlines()
|
||||
|
||||
new_lines = []
|
||||
changed = False
|
||||
|
||||
# Check each line for the pattern
|
||||
for line in lines:
|
||||
match = pattern.search(line)
|
||||
if match:
|
||||
# We found a line with the old IdentityFile path
|
||||
new_line = pattern.sub(r'\1\3/\4', line)
|
||||
changed = True
|
||||
|
||||
# Example of what we captured:
|
||||
folderA = match.group(2) # e.g. "foxdale"
|
||||
folderB = match.group(3) # e.g. "atlas"
|
||||
keyfile = match.group(4) # e.g. "id_ed25519" or "id_ed25519.pub"
|
||||
|
||||
# Build old/new file paths
|
||||
old_file_path = os.path.join(conf_root, folderA, "keys", folderB, keyfile)
|
||||
new_file_path = os.path.join(conf_root, folderB, keyfile)
|
||||
|
||||
print(f"Moving {old_file_path} => {new_file_path}")
|
||||
|
||||
# Ensure the destination folder exists
|
||||
os.makedirs(os.path.dirname(new_file_path), exist_ok=True)
|
||||
|
||||
# Move the file if it exists
|
||||
if os.path.isfile(old_file_path):
|
||||
try:
|
||||
shutil.move(old_file_path, new_file_path)
|
||||
except Exception as e:
|
||||
print(f"Error moving file: {e}")
|
||||
else:
|
||||
print(f"[WARNING] Key file not found: {old_file_path}")
|
||||
|
||||
line = new_line
|
||||
|
||||
new_lines.append(line)
|
||||
|
||||
# If we changed anything, rewrite the config file
|
||||
if changed:
|
||||
with open(file_path, 'w') as fp:
|
||||
fp.writelines(new_lines)
|
||||
print(f"Updated IdentityFile paths in {file_path}")
|
||||
|
||||
print("Done fixing IdentityFile paths and moving key files.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
Loading…
Add table
Add a link
Reference in a new issue