From 3a36a6c9b018dd2a7e4c97f4dee421940bc175f9 Mon Sep 17 00:00:00 2001 From: Malar Invention Date: Sun, 9 Feb 2025 10:35:48 +0530 Subject: [PATCH] init: initial bootstrap of pass sshkeys plugin --- .env | 1 + .envrc | 1 + Makefile | 6 ++ lastpass2pass.py | 135 +++++++++++++++++++++++++++ sshkey-alt.bash | 220 ++++++++++++++++++++++++++++++++++++++++++++ sshkey-orig.bash | 231 ++++++++++++++++++++++++++++++++++++++++++++++ sshkeys.bash | 235 +++++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 829 insertions(+) create mode 100644 .env create mode 100644 .envrc create mode 100644 Makefile create mode 100755 lastpass2pass.py create mode 100644 sshkey-alt.bash create mode 100644 sshkey-orig.bash create mode 100644 sshkeys.bash diff --git a/.env b/.env new file mode 100644 index 0000000..a2e6b67 --- /dev/null +++ b/.env @@ -0,0 +1 @@ +PASSWORD_STORE_ENABLE_EXTENSIONS=true \ No newline at end of file diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..40448e6 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +dotenv \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..a1787b6 --- /dev/null +++ b/Makefile @@ -0,0 +1,6 @@ +install: + @echo "Installing pass-cli plugin (sshkeys.bash)..." + @mkdir -p ~/.password-store/.extensions/ + @cp sshkeys.bash ~/.password-store/.extensions/ + @chmod +x ~/.password-store/.extensions/sshkeys.bash + @echo "Installation complete. You can now use 'pass-sshkeys'." \ No newline at end of file diff --git a/lastpass2pass.py b/lastpass2pass.py new file mode 100755 index 0000000..5ae0853 --- /dev/null +++ b/lastpass2pass.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 + +import argparse +import os +import re +import subprocess + + +class Record: + def __init__(self, name, url, username, password, extra, grouping, fav): + self.name_val = name + self.url = url + self.username = username + self.password = password + self.extra = extra + self.grouping = grouping + self.fav = fav + + @property + def name(self): + s = "lastpass/" + if self.grouping: + s += f"{self.grouping}/" + if self.name_val: + s += self.name_val + s = s.replace(" ", "_").replace("'", "") + return s + + def to_str(self): + s = f"{self.password}\n---\n" + if self.grouping: + s += f"{self.grouping} / " + if self.name_val: + s += f"{self.name_val}\n" + if self.username: + s += f"username: {self.username}\n" + if self.password: + s += f"password: {self.password}\n" + if self.url and self.url != "http://sn": + s += f"url: {self.url}\n" + if self.extra: + s += f"{self.extra}\n" + return s + + +def main(): + parser = argparse.ArgumentParser(description="Import LastPass CSV export into pass") + parser.add_argument( + "-f", "--force", action="store_true", help="Overwrite existing records" + ) + parser.add_argument( + "-d", + "--default", + metavar="GROUP", + default="", + help="Place uncategorised records into GROUP", + ) + parser.add_argument("filename", help="Path to LastPass CSV file") + args = parser.parse_args() + + print(f"Reading '{args.filename}'...") + + entries = [] + current_entry = [] + entry_pattern = re.compile(r"^(http|ftp|ssh)") + + try: + with open(args.filename, "r") as f: + for line in f: + line = line.strip() + if entry_pattern.match(line): + if current_entry: + entries.append("\n".join(current_entry)) + current_entry = [] + current_entry.append(line) + if current_entry: + entries.append("\n".join(current_entry)) + except FileNotFoundError: + print(f"Couldn't find {args.filename}!") + return 1 + + print(f"{len(entries)} records found!") + + records = [] + for entry in entries: + parts = entry.split(",") + url = parts[0] + username = parts[1] if len(parts) > 1 else "" + password = parts[2] if len(parts) > 2 else "" + fav = parts[-1] if len(parts) > 6 else "" + grouping = parts[-2] if len(parts) > 5 else args.default + name = parts[-3] if len(parts) > 4 else "" + extra = ",".join(parts[3:-4])[1:-1] if len(parts) > 7 else "" + + records.append(Record(name, url, username, password, extra, grouping, fav)) + + print(f"Records parsed: {len(records)}") + + successful = 0 + errors = [] + for record in records: + output_path = f"{record.name}.gpg" + if os.path.exists(output_path) and not args.force: + print(f"skipped {record.name}: already exists") + continue + + print(f"Creating record {record.name}...", end="") + try: + proc = subprocess.Popen( + ["pass", "insert", "-m", record.name], stdin=subprocess.PIPE, text=True + ) + proc.communicate(input=record.to_str()) + if proc.returncode == 0: + print(" done!") + successful += 1 + else: + print(" error!") + errors.append(record) + except Exception as e: + print(f" error! ({str(e)})") + errors.append(record) + + print(f"{successful} records successfully imported!") + + if errors: + print(f"There were {len(errors)} errors:") + error_names = [e.name for e in errors] + print(", ".join(error_names) + ".") + print( + "These probably occurred because an identically-named record already existed, or because there were multiple entries with the same name in the csv file." + ) + + +if __name__ == "__main__": + main() diff --git a/sshkey-alt.bash b/sshkey-alt.bash new file mode 100644 index 0000000..b8fd1a3 --- /dev/null +++ b/sshkey-alt.bash @@ -0,0 +1,220 @@ +#!/usr/bin/env bash + +# pass ssh extension for SSH key/config management + +VERSION="0.2.0" +SSH_DIR="$HOME/.ssh" +CONFIG_FILE="$SSH_DIR/config" +PASS_DIR="$PASSWORD_STORE_DIR" + +# Helper functions +die() { echo "Error: $*" >&2; exit 1; } +yesno() { + local answer + read -r -p "$1 [y/N] " answer + [[ "$answer" =~ [Yy] ]] +} + +# Process a Host block and store in pass +process_host_block() { + local hostname="$1" + local -a host_block=("${!2}") + + # Extract IdentityFiles + local identity_files=() + for line in "${host_block[@]}"; do + if [[ "$line" =~ ^[Ii][Dd][Ee][Nn][Tt][Ii][Tt][Yy][Ff][Ii][Ll][Ee[[:space:]]+([^[:space:]]+) ]]; then + identity_files+=("${BASH_REMATCH[1]}") + fi + done + + # Process each IdentityFile + for identity_file in "${identity_files[@]}"; do + local expanded_path="${identity_file/#\~/$HOME}" + expanded_path=$(realpath -m "$expanded_path" 2>/dev/null) + + # Resolve relative to SSH_DIR + [[ "$expanded_path" != "$SSH_DIR"/* ]] && expanded_path="$SSH_DIR/$identity_file" + + if [[ -f "$expanded_path" ]]; then + local rel_path="${expanded_path#$SSH_DIR/}" + rel_path="${rel_path//../_dotdot_}" + local store_path="ssh/$hostname/$rel_path" + echo "Importing $expanded_path to $store_path" + pass insert --multiline "$store_path" < "$expanded_path" || die "Failed to insert $store_path" + else + echo "Skipping non-existent IdentityFile: $identity_file" + fi + done + + # Store Host block + local config_store="ssh/$hostname/config" + echo "Storing Host block in $config_store" + printf "%s\n" "${host_block[@]}" | pass insert --multiline "$config_store" >/dev/null || die "Failed to save config" +} + +# Import single host +cmd_import() { + local hostname="$1" + [[ -z "$hostname" ]] && die "Usage: pass ssh import " + + # Find Host block + local in_block=0 + local host_block=() + while IFS= read -r line; do + [[ "$line" =~ ^[[:space:]]*# ]] && continue + if (( in_block )); then + if [[ "$line" =~ ^[Hh][Oo][Ss][Tt][[:space:]]+ ]]; then + break + fi + host_block+=("$line") + elif [[ "$line" =~ ^[Hh][Oo][Ss][Tt][[:space:]]+$hostname([[:space:]]+|$) ]]; then + in_block=1 + host_block+=("$line") + fi + done < "$CONFIG_FILE" + + (( ${#host_block[@]} )) || die "Host '$hostname' not found in $CONFIG_FILE" + process_host_block "$hostname" host_block[@] +} + +# Import all hosts from SSH config +cmd_import_all() { + echo "Parsing SSH config to find all Host blocks..." + + # Parse all Host blocks + local current_host="" in_block=0 + declare -a current_block all_hosts + while IFS= read -r line; do + # Skip comments + [[ "$line" =~ ^[[:space:]]*# ]] && continue + + if [[ "$line" =~ ^[Hh][Oo][Ss][Tt][[:space:]]+([^#]*) ]]; then + # New Host block + if (( in_block )); then + all_hosts+=("$current_host" "${current_block[@]}") + fi + current_host="${BASH_REMATCH[1]%% *}" # First hostname + current_block=("$line") + in_block=1 + elif [[ "$line" =~ ^[Mm][Aa][Tt][Cc][Hh][[:space:]] ]] && (( in_block )); then + # End of Host block + all_hosts+=("$current_host" "${current_block[@]}") + current_host="" + current_block=() + in_block=0 + elif (( in_block )); then + current_block+=("$line") + fi + done < "$CONFIG_FILE" + (( in_block )) && all_hosts+=("$current_host" "${current_block[@]}") + + # Process all found Host blocks + local i=0 + while (( i < ${#all_hosts[@]} )); do + local host="${all_hosts[i]}" + ((i++)) + local -a block=() + while (( i < ${#all_hosts[@]} )) && [[ ${all_hosts[i]} != "" ]]; do + block+=("${all_hosts[i]}") + ((i++)) + done + echo "Importing host: $host" + process_host_block "$host" block[@] + done +} + +# Export single host +cmd_export() { + local hostname="$1" + [[ -z "$hostname" ]] && die "Usage: pass ssh export " + + # Retrieve Host block + local config_store="ssh/$hostname/config" + local host_block + host_block=$(pass show "$config_store" 2>/dev/null) || die "No config found for $hostname" + + # Check conflicts + local exported_patterns="${host_block%%$'\n'*}" + exported_patterns="${exported_patterns#Host }" + local conflict=0 + while IFS= read -r line; do + if [[ "$line" =~ ^[Hh][Oo][Ss][Tt][[:space:]]+ ]]; then + local existing_patterns="${line#Host }" + for pattern in $exported_patterns; do + [[ " $existing_patterns " == *" $pattern "* ]] && conflict=1 + done + fi + done < "$CONFIG_FILE" + + if (( conflict )) && ! yesno "Overwrite conflicting Host entries?"; then + die "Export aborted" + fi + + # Backup and merge config + local backup="${CONFIG_FILE}.bak.$(date +%s)" + cp "$CONFIG_FILE" "$backup" || die "Failed to backup config" + awk -v patterns="$exported_patterns" ' + BEGIN { in_block=0; delete_lines=0 } + /^[Hh][Oo][Ss][Tt][[:space:]]+/ { + if (in_block) { in_block=0 } + split($0, parts, /[[:space:]]+/) + for (i=2; i<=NF; i++) { + for (p in patterns_arr) { + if (parts[i] == patterns_arr[p]) { + delete_lines=1 + in_block=1 + next + } + } + } + } + in_block { next } + delete_lines { delete_lines=0; next } + { print } + ' "$backup" > "$CONFIG_FILE" || die "Failed to remove conflicts" + echo "$host_block" >> "$CONFIG_FILE" + + # Export keys + while IFS= read -r line; do + [[ "$line" =~ ^[Ii][Dd][Ee][Nn][Tt][Ii][Tt][Yy][Ff][Ii][Ll][Ee[[:space:]]+([^[:space:]]+) ]] || continue + local identity_file="${BASH_REMATCH[1]}" + local expanded_path="${identity_file/#\~/$HOME}" + expanded_path=$(realpath -m "$expanded_path") + [[ "$expanded_path" != "$SSH_DIR"/* ]] && expanded_path="$SSH_DIR/$identity_file" + local rel_path="${expanded_path#$SSH_DIR/}" + rel_path="${rel_path//../_dotdot_}" + local store_path="ssh/$hostname/$rel_path" + + if [[ -f "$expanded_path" ]] && ! yesno "Overwrite $expanded_path?"; then + echo "Skipping $expanded_path" + continue + fi + + pass show "$store_path" > "$expanded_path" || echo "Warning: $store_path missing" + chmod 600 "$expanded_path" + done <<< "$host_block" + + echo "Exported $hostname. Backup: $backup" +} + +# Export all hosts +cmd_export_all() { + # Find all imported hosts + local hostname + while IFS= read -r -d '' hostname; do + hostname="${hostname#ssh/}" + hostname="${hostname%/}" + echo "Exporting host: $hostname" + cmd_export "$hostname" + done < <(find "$PASS_DIR/ssh" -mindepth 1 -maxdepth 1 -type d -printf '%P\0' 2>/dev/null) +} + +# Main handler +case "$1" in + import) shift; cmd_import "$@" ;; + import-all) shift; cmd_import_all ;; + export) shift; cmd_export "$@" ;; + export-all) shift; cmd_export_all ;; + *) die "Usage: pass ssh import|import-all|export|export-all [hostname]" ;; +esac \ No newline at end of file diff --git a/sshkey-orig.bash b/sshkey-orig.bash new file mode 100644 index 0000000..41836b1 --- /dev/null +++ b/sshkey-orig.bash @@ -0,0 +1,231 @@ +#!/usr/bin/env bash + +# pass ssh extension for importing/exporting SSH keys and configs + +VERSION="0.1.0" +SSH_DIR="$HOME/.ssh" +CONFIG_FILE="$SSH_DIR/config" +PASS_DIR="$PASSWORD_STORE_DIR" + +# Helper functions +die() { echo "Error: $*" >&2; exit 1; } +yesno() { + local answer + read -r -p "$1 [y/N] " answer + [[ "$answer" =~ [Yy] ]] +} + +# Import SSH keys and config into pass +cmd_import() { + local hostname="$1" + [[ -z "$hostname" ]] && die "Usage: pass ssh import " + + # Find Host block in SSH config + local in_block=0 + local host_block=() + while IFS= read -r line; do + if [[ "$line" =~ ^[[:space:]]*# ]]; then + continue # Skip comments + fi + if (( in_block )); then + if [[ "$line" =~ ^[Hh][Oo][Ss][Tt][[:space:]]+ ]]; then + break + fi + host_block+=("$line") + else + if [[ "$line" =~ ^[Hh][Oo][Ss][Tt][[:space:]]+$hostname([[:space:]]+|$) ]]; then + in_block=1 + host_block+=("$line") + fi + fi + done < "$CONFIG_FILE" + + (( ${#host_block[@]} )) || die "Host '$hostname' not found in $CONFIG_FILE" + + # Extract IdentityFiles + local identity_files=() + for line in "${host_block[@]}"; do + if [[ "$line" =~ ^[Ii][Dd][Ee][Nn][Tt][Ii][Tt][Yy][Ff][Ii][Ll][Ee][[:space:]]+([^[:space:]]+) ]]; then + identity_files+=("${BASH_REMATCH[1]}") + fi + done + + # Process each IdentityFile + for identity_file in "${identity_files[@]}"; do + # Expand path + local expanded_path="${identity_file/#\~/$HOME}" + expanded_path=$(realpath -m "$expanded_path" 2>/dev/null) + + # Resolve relative to SSH_DIR + if [[ "$expanded_path" != "$SSH_DIR"/* ]]; then + expanded_path="$SSH_DIR/$identity_file" + fi + + # Check if private key exists + if [[ -f "$expanded_path" ]]; then + # Determine store path + local rel_path="${expanded_path#$SSH_DIR/}" + rel_path="${rel_path//../_dotdot_}" # Sanitize .. + + local store_path="ssh/$hostname/$rel_path" + echo "Importing $expanded_path to $store_path" + + # Insert into pass + pass insert --multiline "$store_path" < "$expanded_path" || die "Failed to insert $store_path" + else + echo "Skipping non-existent IdentityFile: $identity_file" + fi + done + + # Save Host block + local config_store="ssh/$hostname/config" + echo "Storing Host block in $config_store" + printf "%s\n" "${host_block[@]}" | pass insert --multiline "$config_store" >/dev/null || die "Failed to save config" +} + +# Export SSH keys and config from pass +cmd_export() { + local hostname="$1" + [[ -z "$hostname" ]] && die "Usage: pass ssh export " + + # Retrieve Host block + local config_store="ssh/$hostname/config" + local host_block + host_block=$(pass show "$config_store" 2>/dev/null) || die "No config found for $hostname" + + # Check existing Host entries + local existing_patterns=() + while IFS= read -r line; do + if [[ "$line" =~ ^[Hh][Oo][Ss][Tt][[:space:]]+([^#]+) ]]; then + existing_patterns+=("${BASH_REMATCH[1]}") + fi + done < "$CONFIG_FILE" + + # Check if exported Host patterns exist + local exported_patterns + if [[ "$host_block" =~ ^[Hh][Oo][Ss][Tt][[:space:]]+([^[:space:]#]+) ]]; then + exported_patterns="${BASH_REMATCH[1]}" + else + die "Invalid Host block in $config_store" + fi + + # Check for conflicts + local conflict=0 + for pattern in $exported_patterns; do + for existing in "${existing_patterns[@]}"; do + if [[ " $existing " == *" $pattern "* ]]; then + echo "Conflict: Host pattern '$pattern' exists in $CONFIG_FILE" + conflict=1 + fi + done + done + + if (( conflict )) && ! yesno "Overwrite conflicting Host entries?"; then + die "Export aborted" + fi + + # Backup original config + local backup="${CONFIG_FILE}.bak.$(date +%s)" + cp "$CONFIG_FILE" "$backup" || die "Failed to backup config" + + # Remove conflicting Host blocks + awk -v patterns="$exported_patterns" ' + BEGIN { in_block=0; delete_lines=0 } + /^[Hh][Oo][Ss][Tt][[:space:]]+/ { + if (in_block) { in_block=0 } + split($0, parts, /[[:space:]]+/) + for (i=2; i<=NF; i++) { + for (p in patterns_arr) { + if (parts[i] == patterns_arr[p]) { + delete_lines=1 + in_block=1 + next + } + } + } + } + in_block { next } + delete_lines { delete_lines=0; next } + { print } + ' "$backup" > "$CONFIG_FILE" || die "Failed to remove conflicts" + + # Append new Host block + echo "Appending Host block for $hostname to $CONFIG_FILE" + echo "$host_block" >> "$CONFIG_FILE" + + # Export IdentityFiles + local identity_files=() + while IFS= read -r line; do + if [[ "$line" =~ ^[Ii][Dd][Ee][Nn][Tt][Ii][Tt][Yy][Ff][Ii][Ll][Ee][[:space:]]+([^[:space:]]+) ]]; then + identity_files+=("${BASH_REMATCH[1]}") + fi + done <<< "$host_block" + + for identity_file in "${identity_files[@]}"; do + local expanded_path="${identity_file/#\~/$HOME}" + expanded_path=$(realpath -m "$expanded_path") + + # Resolve relative to SSH_DIR + if [[ "$expanded_path" != "$SSH_DIR"/* ]]; then + expanded_path="$SSH_DIR/$identity_file" + fi + + local rel_path="${expanded_path#$SSH_DIR/}" + rel_path="${rel_path//../_dotdot_}" + local store_path="ssh/$hostname/$rel_path" + + if ! pass show "$store_path" >/dev/null 2>&1; then + echo "Warning: $store_path not found in pass" + continue + fi + + if [[ -f "$expanded_path" ]] && ! yesno "Overwrite $expanded_path?"; then + echo "Skipping $expanded_path" + continue + fi + + echo "Exporting $store_path to $expanded_path" + mkdir -p "$(dirname "$expanded_path")" + pass show "$store_path" > "$expanded_path" + chmod 600 "$expanded_path" + done + + echo "Export complete. Original config backed up to $backup" +} + +# Bulk operations +cmd_import_all() { + echo "Importing all hosts from $CONFIG_FILE" + local hostname="" + while IFS= read -r line; do + if [[ "$line" =~ ^[Hh][Oo][Ss][Tt][[:space:]]+([^#[:space:]]+) ]]; then + hostname="${BASH_REMATCH[1]}" + echo "Importing host: $hostname" + cmd_import "$hostname" || echo "Failed to import $hostname" + fi + done < "$CONFIG_FILE" +} + +cmd_export_all() { + echo "Exporting all hosts to $CONFIG_FILE" + local hosts=() + while IFS= read -r -d '' path; do + if [[ "$path" =~ ^ssh/([^/]+)/config ]]; then + hosts+=("${BASH_REMATCH[1]}") + fi + done < <(pass ls ssh | grep -F /config | tr '\n' '\0') + + for host in "${hosts[@]}"; do + echo "Exporting host: $host" + cmd_export "$host" || echo "Failed to export $host" + done +} + +# Main command handler +case "$1" in + import) shift; cmd_import "$@" ;; + export) shift; cmd_export "$@" ;; + import-all) shift; cmd_import_all "$@" ;; + export-all) shift; cmd_export_all "$@" ;; + *) die "Usage: pass ssh import|export|import-all|export-all " ;; +esac diff --git a/sshkeys.bash b/sshkeys.bash new file mode 100644 index 0000000..1c22fb1 --- /dev/null +++ b/sshkeys.bash @@ -0,0 +1,235 @@ +#!/usr/bin/env bash + +# pass ssh extension for importing/exporting SSH keys and configs + +VERSION="0.1.0" +SSH_DIR="$HOME/.ssh" +CONFIG_FILE="$SSH_DIR/config" +PASS_DIR="$PASSWORD_STORE_DIR" + +# Helper functions +die() { echo "Error: $*" >&2; exit 1; } +yesno() { + local answer + read -r -p "$1 [y/N] " answer + [[ "$answer" =~ [Yy] ]] +} + +# Import SSH keys and config into pass +cmd_import() { + local hostname="$1" + [[ -z "$hostname" ]] && die "Usage: pass ssh import " + + # Find Host block in SSH config + local in_block=0 + local host_block=() + while IFS= read -r line; do + if [[ "$line" =~ ^[[:space:]]*# ]]; then + continue # Skip comments + fi + if (( in_block )); then + if [[ "$line" =~ ^[Hh][Oo][Ss][Tt][[:space:]]+ ]]; then + break + fi + host_block+=("$line") + else + if [[ "$line" =~ ^[Hh][Oo][Ss][Tt][[:space:]]+$hostname([[:space:]]+|$) ]]; then + in_block=1 + host_block+=("$line") + fi + fi + done < "$CONFIG_FILE" + + (( ${#host_block[@]} )) || die "Host '$hostname' not found in $CONFIG_FILE" + + # Extract IdentityFiles and paths + local identity_files=() + local identity_paths=() + for line in "${host_block[@]}"; do + if [[ "$line" =~ ^[Ii][Dd][Ee][Nn][Tt][Ii][Tt][Yy][Ff][Ii][Ll][Ee][[:space:]]+([^[:space:]]+) ]]; then + identity_file="${BASH_REMATCH[1]}" + identity_files+=("$identity_file") + + # Store original path relative to SSH_DIR + if [[ "$identity_file" == "$SSH_DIR"/* ]]; then + rel_path="${identity_file#$SSH_DIR/}" + else + rel_path="$identity_file" + fi + identity_paths+=("$rel_path") + fi + done + + # Process each IdentityFile with path tracking + for i in "${!identity_files[@]}"; do + identity_file="${identity_files[$i]}" + rel_path="${identity_paths[$i]}" + + # Check if private key exists + if [[ -f "$expanded_path" ]]; then + # Determine store path + local rel_path="${expanded_path#$SSH_DIR/}" + rel_path="${rel_path//../_dotdot_}" # Sanitize .. + + local store_path="ssh/$hostname/$rel_path" + echo "Importing $expanded_path to $store_path" + + # Insert into pass + pass insert --multiline "$store_path" < "$expanded_path" || die "Failed to insert $store_path" + else + echo "Skipping non-existent IdentityFile: $identity_file" + fi + done + + # Save Host block + local config_store="ssh/$hostname/config" + echo "Storing Host block in $config_store" + printf "%s\n" "${host_block[@]}" | pass insert --multiline "$config_store" >/dev/null || die "Failed to save config" +} + +# Export SSH keys and config from pass +cmd_export() { + local hostname="$1" + [[ -z "$hostname" ]] && die "Usage: pass ssh export " + + # Retrieve Host block + local config_store="ssh/$hostname/config" + local host_block + host_block=$(pass show "$config_store" 2>/dev/null) || die "No config found for $hostname" + + # Check existing Host entries + local existing_patterns=() + while IFS= read -r line; do + if [[ "$line" =~ ^[Hh][Oo][Ss][Tt][[:space:]]+([^#]+) ]]; then + existing_patterns+=("${BASH_REMATCH[1]}") + fi + done < "$CONFIG_FILE" + + # Check if exported Host patterns exist + local exported_patterns + if [[ "$host_block" =~ ^[Hh][Oo][Ss][Tt][[:space:]]+([^[:space:]#]+) ]]; then + exported_patterns="${BASH_REMATCH[1]}" + else + die "Invalid Host block in $config_store" + fi + + # Check for conflicts + local conflict=0 + for pattern in $exported_patterns; do + for existing in "${existing_patterns[@]}"; do + if [[ " $existing " == *" $pattern "* ]]; then + echo "Conflict: Host pattern '$pattern' exists in $CONFIG_FILE" + conflict=1 + fi + done + done + + if (( conflict )) && ! yesno "Overwrite conflicting Host entries?"; then + die "Export aborted" + fi + + # Backup original config + local backup="${CONFIG_FILE}.bak.$(date +%s)" + cp "$CONFIG_FILE" "$backup" || die "Failed to backup config" + + # Remove conflicting Host blocks + awk -v patterns="$exported_patterns" ' + BEGIN { in_block=0; delete_lines=0 } + /^[Hh][Oo][Ss][Tt][[:space:]]+/ { + if (in_block) { in_block=0 } + split($0, parts, /[[:space:]]+/) + for (i=2; i<=NF; i++) { + for (p in patterns_arr) { + if (parts[i] == patterns_arr[p]) { + delete_lines=1 + in_block=1 + next + } + } + } + } + in_block { next } + delete_lines { delete_lines=0; next } + { print } + ' "$backup" > "$CONFIG_FILE" || die "Failed to remove conflicts" + + # Append new Host block + echo "Appending Host block for $hostname to $CONFIG_FILE" + echo "$host_block" >> "$CONFIG_FILE" + + # Export IdentityFiles + local identity_files=() + while IFS= read -r line; do + if [[ "$line" =~ ^[Ii][Dd][Ee][Nn][Tt][Ii][Tt][Yy][Ff][Ii][Ll][Ee][[:space:]]+([^[:space:]]+) ]]; then + identity_files+=("${BASH_REMATCH[1]}") + fi + done <<< "$host_block" + + for identity_file in "${identity_files[@]}"; do + local expanded_path="${identity_file/#\~/$HOME}" + expanded_path=$(realpath -m "$expanded_path") + + # Resolve relative to SSH_DIR + if [[ "$expanded_path" != "$SSH_DIR"/* ]]; then + expanded_path="$SSH_DIR/$identity_file" + fi + + local rel_path="${expanded_path#$SSH_DIR/}" + rel_path="${rel_path//../_dotdot_}" + local store_path="ssh/$hostname/$rel_path" + + if ! pass show "$store_path" >/dev/null 2>&1; then + echo "Warning: $store_path not found in pass" + continue + fi + + if [[ -f "$expanded_path" ]] && ! yesno "Overwrite $expanded_path?"; then + echo "Skipping $expanded_path" + continue + fi + + echo "Exporting $store_path to $expanded_path" + mkdir -p "$(dirname "$expanded_path")" + pass show "$store_path" > "$expanded_path" + chmod 600 "$expanded_path" + done + + echo "Export complete. Original config backed up to $backup" +} + +# Bulk operations +cmd_import_all() { + echo "Importing all hosts from $CONFIG_FILE" + local hostname="" + while IFS= read -r line; do + if [[ "$line" =~ ^[Hh][Oo][Ss][Tt][[:space:]]+([^#[:space:]]+) ]]; then + hostname="${BASH_REMATCH[1]}" + echo "Importing host: $hostname" + cmd_import "$hostname" || echo "Failed to import $hostname" + fi + done < "$CONFIG_FILE" +} + +cmd_export_all() { + echo "Exporting all hosts to $CONFIG_FILE" + local hosts=() + while IFS= read -r -d '' path; do + if [[ "$path" =~ ^ssh/([^/]+)/config ]]; then + hosts+=("${BASH_REMATCH[1]}") + fi + done < <(pass ls ssh | grep -F /config | tr '\n' '\0') + + for host in "${hosts[@]}"; do + echo "Exporting host: $host" + cmd_export "$host" || echo "Failed to export $host" + done +} + +# Main command handler +case "$1" in + import) shift; cmd_import "$@" ;; + export) shift; cmd_export "$@" ;; + import-all) shift; cmd_import_all "$@" ;; + export-all) shift; cmd_export_all "$@" ;; + *) die "Usage: pass ssh import|export|import-all|export-all " ;; +esac