feat: add support for recursively importing proxyjump nodes
parent
3a36a6c9b0
commit
f42e8b5901
|
|
@ -0,0 +1,124 @@
|
||||||
|
# pass-sshkeys
|
||||||
|
|
||||||
|
A [pass](https://www.passwordstore.org/) extension for managing SSH keys and configurations securely.
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
`pass-sshkeys` allows you to store and manage your SSH private keys and configurations within your password store. This enables you to:
|
||||||
|
|
||||||
|
- Securely store SSH keys encrypted with GPG
|
||||||
|
- Import/export SSH keys and configurations between machines
|
||||||
|
- Connect to hosts directly using stored keys without permanent import
|
||||||
|
- Keep your `.ssh` directory clean and manage keys on a per-host basis
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
- `pass` >= 1.7.0
|
||||||
|
- `bash` >= 4.0
|
||||||
|
- Standard Unix tools (`awk`, `sed`, etc.)
|
||||||
|
|
||||||
|
### From Git
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/malarinv/pass-sshkeys
|
||||||
|
cd pass-sshkeys
|
||||||
|
sudo make install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manual Installation
|
||||||
|
|
||||||
|
1. Copy `sshkeys.bash` to `/usr/lib/password-store/extensions/` or `~/.password-store/.extensions/`
|
||||||
|
2. Ensure it's executable: `chmod +x sshkeys.bash`
|
||||||
|
|
||||||
|
### User Extensions
|
||||||
|
|
||||||
|
If you don't want to install this as a system extension, you can enable user extensions with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export PASSWORD_STORE_ENABLE_EXTENSIONS=true
|
||||||
|
```
|
||||||
|
|
||||||
|
For convenience, add this alias to your `.bashrc`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
alias pass='PASSWORD_STORE_ENABLE_EXTENSIONS=true pass'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Import SSH Keys and Config
|
||||||
|
|
||||||
|
Import a single host:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pass ssh import hostname
|
||||||
|
```
|
||||||
|
|
||||||
|
When importing a host, the extension automatically detects and handles ProxyJump configurations:
|
||||||
|
|
||||||
|
- Recursively imports any ProxyJump hosts found in the config
|
||||||
|
- Maintains the complete chain of proxy hosts
|
||||||
|
- Stores all necessary keys and configurations for the entire connection chain
|
||||||
|
|
||||||
|
Import all hosts from SSH config:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pass ssh import-all
|
||||||
|
```
|
||||||
|
|
||||||
|
### Export SSH Keys and Config
|
||||||
|
|
||||||
|
Export a single host:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pass ssh export hostname
|
||||||
|
```
|
||||||
|
|
||||||
|
Export all stored hosts:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pass ssh export-all
|
||||||
|
```
|
||||||
|
|
||||||
|
### Direct Connection
|
||||||
|
|
||||||
|
Connect to a host using stored keys without importing:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pass ssh connect hostname
|
||||||
|
```
|
||||||
|
|
||||||
|
The connect command:
|
||||||
|
|
||||||
|
- Automatically sets up all ProxyJump hosts in the connection chain
|
||||||
|
- Creates temporary configurations and keys for both the target host and any proxy hosts
|
||||||
|
- Cleans up temporary files after the connection ends
|
||||||
|
|
||||||
|
## Storage Structure
|
||||||
|
|
||||||
|
Keys and configurations are stored in your password store under the `ssh/` prefix:
|
||||||
|
|
||||||
|
```fs
|
||||||
|
Password Store
|
||||||
|
└── ssh
|
||||||
|
└── hostname
|
||||||
|
├── config
|
||||||
|
├── id_rsa
|
||||||
|
└── id_ed25519
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
- All keys are encrypted using your GPG key(s)
|
||||||
|
- Temporary keys created during `connect` operations are stored in `/tmp` and cleaned up automatically
|
||||||
|
- Original SSH config files are backed up before modifications
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This extension is licensed under the GNU General Public License v3.0 or later.
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Contributions are welcome! Please feel free to submit a Pull Request.
|
||||||
186
sshkeys.bash
186
sshkeys.bash
|
|
@ -6,34 +6,54 @@ VERSION="0.1.0"
|
||||||
SSH_DIR="$HOME/.ssh"
|
SSH_DIR="$HOME/.ssh"
|
||||||
CONFIG_FILE="$SSH_DIR/config"
|
CONFIG_FILE="$SSH_DIR/config"
|
||||||
PASS_DIR="$PASSWORD_STORE_DIR"
|
PASS_DIR="$PASSWORD_STORE_DIR"
|
||||||
|
VERBOSE=0
|
||||||
|
|
||||||
# Helper functions
|
# Helper functions
|
||||||
die() { echo "Error: $*" >&2; exit 1; }
|
die() { echo "Error: $*" >&2; exit 1; }
|
||||||
|
debug() { [[ $VERBOSE -eq 1 ]] && echo "DEBUG: $*" >&2; }
|
||||||
yesno() {
|
yesno() {
|
||||||
local answer
|
local answer
|
||||||
read -r -p "$1 [y/N] " answer
|
read -r -p "$1 [y/N] " answer
|
||||||
[[ "$answer" =~ [Yy] ]]
|
[[ "$answer" =~ [Yy] ]]
|
||||||
}
|
}
|
||||||
|
|
||||||
# Import SSH keys and config into pass
|
# Import a host and its dependencies
|
||||||
cmd_import() {
|
cmd_import_with_deps() {
|
||||||
local hostname="$1"
|
local hostname="$1"
|
||||||
|
local is_dep="${2:-false}"
|
||||||
[[ -z "$hostname" ]] && die "Usage: pass ssh import <hostname>"
|
[[ -z "$hostname" ]] && die "Usage: pass ssh import <hostname>"
|
||||||
|
|
||||||
|
debug "Starting import for host: $hostname (dependency: $is_dep)"
|
||||||
|
|
||||||
|
# Skip if already imported in this session
|
||||||
|
local imported_key="imported_$hostname"
|
||||||
|
if [[ "${!imported_key}" == "1" ]]; then
|
||||||
|
debug "Host $hostname already imported in this session, skipping"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
declare -g "$imported_key=1"
|
||||||
|
debug "Marking $hostname as imported"
|
||||||
|
|
||||||
# Find Host block in SSH config
|
# Find Host block in SSH config
|
||||||
|
debug "Searching for Host block in $CONFIG_FILE"
|
||||||
local in_block=0
|
local in_block=0
|
||||||
local host_block=()
|
local host_block=()
|
||||||
while IFS= read -r line; do
|
while IFS= read -r line; do
|
||||||
|
debug "Processing line: $line"
|
||||||
if [[ "$line" =~ ^[[:space:]]*# ]]; then
|
if [[ "$line" =~ ^[[:space:]]*# ]]; then
|
||||||
continue # Skip comments
|
debug "Skipping comment line"
|
||||||
|
continue
|
||||||
fi
|
fi
|
||||||
if (( in_block )); then
|
if (( in_block )); then
|
||||||
if [[ "$line" =~ ^[Hh][Oo][Ss][Tt][[:space:]]+ ]]; then
|
if [[ "$line" =~ ^[Hh][Oo][Ss][Tt][[:space:]]+ ]]; then
|
||||||
|
debug "Found next Host block, stopping"
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
|
debug "Adding line to host block"
|
||||||
host_block+=("$line")
|
host_block+=("$line")
|
||||||
else
|
else
|
||||||
if [[ "$line" =~ ^[Hh][Oo][Ss][Tt][[:space:]]+$hostname([[:space:]]+|$) ]]; then
|
if [[ "$line" =~ ^[Hh][Oo][Ss][Tt][[:space:]]+$hostname([[:space:]]+|$) ]]; then
|
||||||
|
debug "Found matching Host block"
|
||||||
in_block=1
|
in_block=1
|
||||||
host_block+=("$line")
|
host_block+=("$line")
|
||||||
fi
|
fi
|
||||||
|
|
@ -41,13 +61,43 @@ cmd_import() {
|
||||||
done < "$CONFIG_FILE"
|
done < "$CONFIG_FILE"
|
||||||
|
|
||||||
(( ${#host_block[@]} )) || die "Host '$hostname' not found in $CONFIG_FILE"
|
(( ${#host_block[@]} )) || die "Host '$hostname' not found in $CONFIG_FILE"
|
||||||
|
debug "Found ${#host_block[@]} lines in host block"
|
||||||
|
|
||||||
|
# Check for ProxyJump directive and import dependencies
|
||||||
|
debug "Checking for ProxyJump directives"
|
||||||
|
local proxy_hosts=()
|
||||||
|
for line in "${host_block[@]}"; do
|
||||||
|
debug "Checking line for ProxyJump: $line"
|
||||||
|
if [[ "$line" =~ ^[[:space:]]*[Pp][Rr][Oo][Xx][Yy][Jj][Uu][Mm][Pp][[:space:]]+([^[:space:]]+) ]]; then
|
||||||
|
debug "Found ProxyJump directive: ${BASH_REMATCH[1]}"
|
||||||
|
IFS=',' read -ra proxy_hosts <<< "${BASH_REMATCH[1]}"
|
||||||
|
for proxy in "${proxy_hosts[@]}"; do
|
||||||
|
# Remove leading/trailing whitespace
|
||||||
|
proxy="${proxy#"${proxy%%[![:space:]]*}"}"
|
||||||
|
proxy="${proxy%"${proxy##*[![:space:]]}"}"
|
||||||
|
debug "Processing proxy host: $proxy"
|
||||||
|
if [[ "$proxy" != "none" && "$proxy" != "NONE" ]]; then
|
||||||
|
if $is_dep; then
|
||||||
|
echo " Importing nested ProxyJump dependency: $proxy"
|
||||||
|
else
|
||||||
|
echo "Importing ProxyJump dependency: $proxy"
|
||||||
|
fi
|
||||||
|
cmd_import_with_deps "$proxy" true
|
||||||
|
else
|
||||||
|
debug "Skipping 'none' ProxyJump value"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
# Extract IdentityFiles and paths
|
# Extract IdentityFiles and paths
|
||||||
|
debug "Extracting IdentityFiles"
|
||||||
local identity_files=()
|
local identity_files=()
|
||||||
local identity_paths=()
|
local identity_paths=()
|
||||||
for line in "${host_block[@]}"; do
|
for line in "${host_block[@]}"; do
|
||||||
if [[ "$line" =~ ^[Ii][Dd][Ee][Nn][Tt][Ii][Tt][Yy][Ff][Ii][Ll][Ee][[:space:]]+([^[:space:]]+) ]]; then
|
if [[ "$line" =~ ^[Ii][Dd][Ee][Nn][Tt][Ii][Tt][Yy][Ff][Ii][Ll][Ee][[:space:]]+([^[:space:]]+) ]]; then
|
||||||
identity_file="${BASH_REMATCH[1]}"
|
identity_file="${BASH_REMATCH[1]}"
|
||||||
|
debug "Found IdentityFile: $identity_file"
|
||||||
identity_files+=("$identity_file")
|
identity_files+=("$identity_file")
|
||||||
|
|
||||||
# Store original path relative to SSH_DIR
|
# Store original path relative to SSH_DIR
|
||||||
|
|
@ -56,35 +106,68 @@ cmd_import() {
|
||||||
else
|
else
|
||||||
rel_path="$identity_file"
|
rel_path="$identity_file"
|
||||||
fi
|
fi
|
||||||
|
debug "Relative path: $rel_path"
|
||||||
identity_paths+=("$rel_path")
|
identity_paths+=("$rel_path")
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
# Process each IdentityFile with path tracking
|
# Process each IdentityFile with path tracking
|
||||||
|
debug "Processing ${#identity_files[@]} IdentityFiles"
|
||||||
for i in "${!identity_files[@]}"; do
|
for i in "${!identity_files[@]}"; do
|
||||||
identity_file="${identity_files[$i]}"
|
identity_file="${identity_files[$i]}"
|
||||||
rel_path="${identity_paths[$i]}"
|
rel_path="${identity_paths[$i]}"
|
||||||
|
debug "Processing IdentityFile $((i+1))/${#identity_files[@]}: $identity_file"
|
||||||
|
|
||||||
|
# Expand path
|
||||||
|
local expanded_path="${identity_file/#\~/$HOME}"
|
||||||
|
expanded_path=$(realpath -m "$expanded_path")
|
||||||
|
debug "Expanded path: $expanded_path"
|
||||||
|
|
||||||
|
# Resolve relative to SSH_DIR if needed
|
||||||
|
if [[ "$expanded_path" != "$SSH_DIR"/* ]]; then
|
||||||
|
debug "Path not under SSH_DIR, adjusting"
|
||||||
|
expanded_path="$SSH_DIR/$identity_file"
|
||||||
|
fi
|
||||||
|
|
||||||
# Check if private key exists
|
# Check if private key exists
|
||||||
if [[ -f "$expanded_path" ]]; then
|
if [[ -f "$expanded_path" ]]; then
|
||||||
# Determine store path
|
# Determine store path
|
||||||
local rel_path="${expanded_path#$SSH_DIR/}"
|
local rel_path="${expanded_path#$SSH_DIR/}"
|
||||||
rel_path="${rel_path//../_dotdot_}" # Sanitize ..
|
rel_path="${rel_path//../_dotdot_}" # Sanitize ..
|
||||||
|
debug "Sanitized relative path: $rel_path"
|
||||||
|
|
||||||
local store_path="ssh/$hostname/$rel_path"
|
local store_path="ssh/$hostname/$rel_path"
|
||||||
|
debug "Store path: $store_path"
|
||||||
|
if $is_dep; then
|
||||||
echo " Importing $expanded_path to $store_path"
|
echo " Importing $expanded_path to $store_path"
|
||||||
|
else
|
||||||
|
echo "Importing $expanded_path to $store_path"
|
||||||
|
fi
|
||||||
|
|
||||||
# Insert into pass
|
# Insert into pass
|
||||||
|
debug "Inserting key into pass store"
|
||||||
pass insert --multiline "$store_path" < "$expanded_path" || die "Failed to insert $store_path"
|
pass insert --multiline "$store_path" < "$expanded_path" || die "Failed to insert $store_path"
|
||||||
else
|
else
|
||||||
|
debug "IdentityFile not found: $expanded_path"
|
||||||
echo "Skipping non-existent IdentityFile: $identity_file"
|
echo "Skipping non-existent IdentityFile: $identity_file"
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
# Save Host block
|
# Save Host block
|
||||||
local config_store="ssh/$hostname/config"
|
local config_store="ssh/$hostname/config"
|
||||||
|
debug "Saving Host block to $config_store"
|
||||||
|
if $is_dep; then
|
||||||
echo " Storing Host block in $config_store"
|
echo " Storing Host block in $config_store"
|
||||||
|
else
|
||||||
|
echo "Storing Host block in $config_store"
|
||||||
|
fi
|
||||||
printf "%s\n" "${host_block[@]}" | pass insert --multiline "$config_store" >/dev/null || die "Failed to save config"
|
printf "%s\n" "${host_block[@]}" | pass insert --multiline "$config_store" >/dev/null || die "Failed to save config"
|
||||||
|
debug "Host block saved successfully"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Import SSH keys and config into pass
|
||||||
|
cmd_import() {
|
||||||
|
cmd_import_with_deps "$1"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Export SSH keys and config from pass
|
# Export SSH keys and config from pass
|
||||||
|
|
@ -205,7 +288,7 @@ cmd_import_all() {
|
||||||
if [[ "$line" =~ ^[Hh][Oo][Ss][Tt][[:space:]]+([^#[:space:]]+) ]]; then
|
if [[ "$line" =~ ^[Hh][Oo][Ss][Tt][[:space:]]+([^#[:space:]]+) ]]; then
|
||||||
hostname="${BASH_REMATCH[1]}"
|
hostname="${BASH_REMATCH[1]}"
|
||||||
echo "Importing host: $hostname"
|
echo "Importing host: $hostname"
|
||||||
cmd_import "$hostname" || echo "Failed to import $hostname"
|
cmd_import_with_deps "$hostname" || echo "Failed to import $hostname"
|
||||||
fi
|
fi
|
||||||
done < "$CONFIG_FILE"
|
done < "$CONFIG_FILE"
|
||||||
}
|
}
|
||||||
|
|
@ -225,11 +308,96 @@ cmd_export_all() {
|
||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Connect directly using stored keys
|
||||||
|
cmd_connect() {
|
||||||
|
local hostname="$1"
|
||||||
|
[[ -z "$hostname" ]] && die "Usage: pass ssh connect <hostname>"
|
||||||
|
|
||||||
|
# Create temporary directory for keys
|
||||||
|
local tmp_dir=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$tmp_dir"' EXIT
|
||||||
|
|
||||||
|
# Function to process a host and its ProxyJump dependencies
|
||||||
|
process_host() {
|
||||||
|
local host="$1"
|
||||||
|
debug "Processing host: $host"
|
||||||
|
|
||||||
|
# Retrieve Host block
|
||||||
|
local config_store="ssh/$host/config"
|
||||||
|
local host_block
|
||||||
|
host_block=$(pass show "$config_store" 2>/dev/null) || die "No config found for $host"
|
||||||
|
|
||||||
|
# Append to temporary SSH config
|
||||||
|
echo "$host_block" >> "$tmp_config"
|
||||||
|
|
||||||
|
# Extract and restore keys
|
||||||
|
while IFS= read -r line; do
|
||||||
|
if [[ "$line" =~ ^[[:space:]]*[Pp][Rr][Oo][Xx][Yy][Jj][Uu][Mm][Pp][[:space:]]+([^[:space:]]+) ]]; then
|
||||||
|
debug "Found ProxyJump: ${BASH_REMATCH[1]}"
|
||||||
|
IFS=',' read -ra proxy_hosts <<< "${BASH_REMATCH[1]}"
|
||||||
|
for proxy in "${proxy_hosts[@]}"; do
|
||||||
|
# Remove leading/trailing whitespace
|
||||||
|
proxy="${proxy#"${proxy%%[![:space:]]*}"}"
|
||||||
|
proxy="${proxy%"${proxy##*[![:space:]]}"}"
|
||||||
|
if [[ "$proxy" != "none" && "$proxy" != "NONE" ]]; then
|
||||||
|
debug "Processing ProxyJump host: $proxy"
|
||||||
|
process_host "$proxy"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
elif [[ "$line" =~ ^[Ii][Dd][Ee][Nn][Tt][Ii][Tt][Yy][Ff][Ii][Ll][Ee][[:space:]]+([^[:space:]]+) ]]; then
|
||||||
|
local identity_file="${BASH_REMATCH[1]}"
|
||||||
|
local expanded_path="${identity_file/#\~/$HOME}"
|
||||||
|
expanded_path=$(realpath -m "$expanded_path")
|
||||||
|
|
||||||
|
# Resolve relative to SSH_DIR if needed
|
||||||
|
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/$host/$rel_path"
|
||||||
|
local tmp_key="$tmp_dir/$(basename "$identity_file")"
|
||||||
|
|
||||||
|
# Restore key to temporary location
|
||||||
|
if pass show "$store_path" > "$tmp_key" 2>/dev/null; then
|
||||||
|
chmod 600 "$tmp_key"
|
||||||
|
debug "Restored key $store_path to $tmp_key"
|
||||||
|
# Update config to use temporary key
|
||||||
|
sed -i "s|${identity_file}|${tmp_key}|g" "$tmp_config"
|
||||||
|
else
|
||||||
|
echo "Warning: Key $store_path not found in pass"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done <<< "$host_block"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Create empty temporary SSH config
|
||||||
|
local tmp_config="$tmp_dir/config"
|
||||||
|
touch "$tmp_config"
|
||||||
|
|
||||||
|
# Process the main host and its dependencies
|
||||||
|
process_host "$hostname"
|
||||||
|
|
||||||
|
# Execute SSH command with temporary config
|
||||||
|
echo "Connecting to $hostname..."
|
||||||
|
ssh -F "$tmp_config" "$hostname"
|
||||||
|
}
|
||||||
|
|
||||||
# Main command handler
|
# Main command handler
|
||||||
case "$1" in
|
case "$1" in
|
||||||
import) shift; cmd_import "$@" ;;
|
-v|--verbose)
|
||||||
export) shift; cmd_export "$@" ;;
|
VERBOSE=1
|
||||||
import-all) shift; cmd_import_all "$@" ;;
|
debug "Verbose mode enabled"
|
||||||
export-all) shift; cmd_export_all "$@" ;;
|
shift
|
||||||
*) die "Usage: pass ssh import|export|import-all|export-all <hostname>" ;;
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
case "$1" in
|
||||||
|
import) shift; cmd_import_with_deps "$@" ;;
|
||||||
|
import-all) shift; cmd_import_all ;;
|
||||||
|
export) shift; cmd_export "$@" ;;
|
||||||
|
export-all) shift; cmd_export_all ;;
|
||||||
|
connect) shift; cmd_connect "$@" ;;
|
||||||
|
*) die "Usage: pass ssh [-v|--verbose] import|import-all|export|export-all|connect [hostname]" ;;
|
||||||
esac
|
esac
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue