# GitHub Issue #1339: Duplicate entries in known_hosts

**URL:** https://github.com/pyinfra-dev/pyinfra/issues/1339
**Status:** Open
**Reporter:** @pikeas
**Created:** 2025-04-14

## Issue Summary

Pyinfra creates duplicate entries in `~/.ssh/known_hosts` when unable to connect to a host. The user reports that after multiple failed connection attempts, the same host key is appended to the known_hosts file multiple times.

### Reproduction Steps
1. Have a known_hosts file with a `@cert-authority` entry (which paramiko cannot parse)
2. Attempt to connect to a host that is not yet signed by the CA
3. Each connection attempt adds a duplicate entry to known_hosts

### Example Output
```
$ pyinfra $host exec -- echo "hello world"
    Failed to load host keys from /Users//.ssh/known_hosts: ('@cert-authority *.internal.example.com ssh-ed25519  ', Error('Incorrect padding'))
    No host key for  found in known_hosts, accepting & adding to host keys file
    Added host key for  to known_hosts
    [] Authentication error () (Authentication failed.)
```

After multiple attempts, known_hosts contains:
```
@cert-authority *.internal.example.com ssh-ed25519
 ssh-ed25519
 ssh-ed25519
 ssh-ed25519
```

## Root Cause Analysis

The issue stems from an interaction between two mechanisms:

### 1. Memoized Host Keys Loading

In `/Users/nick/Dev/Webroot/pyinfra/src/src/pyinfra/connectors/sshuserclient/client.py`, the `get_host_keys()` function is memoized:

```python
@memoize
def get_host_keys(filenames):
    """Load host keys from one or more files."""
    with HOST_KEYS_LOCK:
        host_keys = HostKeys()
        for filename in filenames:
            try:
                host_keys.load(filename)
            except Exception as e:
                logger.warning("Failed to load host keys from %s: %s", filename, e)
        return host_keys
```

The memoization caches the `HostKeys` object based on the filenames tuple. When paramiko encounters unparseable lines (like `@cert-authority`), it logs a warning and continues, but the host key entry is NOT added to the in-memory `HostKeys` object.

### 2. Missing Host Key Policy Appends to File

In the `AcceptNewPolicy` and `AskPolicy` classes, when a host key is not found:

```python
class AcceptNewPolicy(MissingHostKeyPolicy):
    @override
    def missing_host_key(self, client, hostname, key):
        logger.warning(
            (
                f"No host key for {hostname} found in known_hosts, "
                "accepting & adding to host keys file"
            ),
        )
        append_hostkey(client, hostname, key)
        logger.warning("Added host key for %s to known_hosts", hostname)
```

The `append_hostkey()` function appends the key to the file:

```python
def append_hostkey(client, hostname, key):
    """Append hostname to the clients host_keys_file"""
    # ...
    with HOST_KEYS_LOCK:
        host_key_entry = HostKeyEntry([hostname], key)
        # ...
        with open(client._host_keys_filename, "a") as host_keys_file:
            hk_entry = host_key_entry.to_line()
            # ...
            host_keys_file.write(hk_entry)
```

### 3. The Bug: Missing In-Memory Update

**The critical bug is that `append_hostkey()` only appends to the file but does NOT update the memoized `HostKeys` object in memory.**

The flow is:
1. First connection attempt: `get_host_keys()` loads and caches the HostKeys object (without entries paramiko can't parse)
2. `missing_host_key()` is called because the host isn't in the cached object
3. `append_hostkey()` writes to the file but doesn't update `client._host_keys`
4. Connection fails (e.g., authentication error)
5. Second connection attempt: `get_host_keys()` returns the CACHED object (still without the new host)
6. `missing_host_key()` is called again (because the in-memory object wasn't updated)
7. `append_hostkey()` appends AGAIN to the file
8. This repeats for each connection attempt

### Related Issue

This is a continuation of issue #1209 (now closed), which fixed the problem of known_hosts losing content by switching from `host_keys.save()` (which overwrites the file) to `append_hostkey()` (which appends). However, the fix didn't account for the memoization cache needing to be updated.

## Proposed Fix

The fix requires updating the in-memory `HostKeys` object after appending to the file. There are two approaches:

### Option A: Update the cached HostKeys object after appending (Recommended)

Modify `append_hostkey()` to also update the client's `_host_keys` object:

**File:** `/Users/nick/Dev/Webroot/pyinfra/src/src/pyinfra/connectors/sshuserclient/client.py`

```python
def append_hostkey(client, hostname, key):
    """Append hostname to the clients host_keys_file"""

    if client._host_keys_filename is None:
        logger.warning("No host keys filename, not saving key for: %s", hostname)
        return

    with HOST_KEYS_LOCK:
        # Check if the key already exists in the in-memory cache
        if client._host_keys.lookup(hostname) is not None:
            existing_key = client._host_keys.lookup(hostname).get(key.get_name())
            if existing_key is not None and existing_key == key:
                logger.debug("Host key for %s already exists in memory, skipping", hostname)
                return

        # The paramiko client saves host keys incorrectly whereas the host keys object does
        # this correctly, so use that with the client filename variable.
        # See: https://github.com/paramiko/paramiko/pull/1989
        host_key_entry = HostKeyEntry([hostname], key)
        if host_key_entry is None:
            raise SSHException(
                "Append Hostkey: Failed to parse host {0}, could not append to hostfile".format(
                    hostname
                ),
            )
        with open(client._host_keys_filename, "a") as host_keys_file:
            hk_entry = host_key_entry.to_line()
            if hk_entry is None:
                raise SSHException(f"Append Hostkey: Failed to append hostkey ({host_key_entry})")

            host_keys_file.write(hk_entry)

        # Update the in-memory HostKeys object to prevent duplicate additions
        client._host_keys.add(hostname, key.get_name(), key)
```

### Option B: Clear the memoize cache after appending

Modify `append_hostkey()` to clear the `get_host_keys` cache:

```python
def append_hostkey(client, hostname, key):
    # ... existing code ...

    # Clear the memoize cache so the next connection attempt re-reads the file
    get_host_keys.cache.clear()
```

However, Option B is less efficient as it forces a file re-read on every connection attempt.

### Recommended Implementation (Option A - Enhanced)

The cleanest solution is to check both the in-memory cache AND update it after writing:

```python
def append_hostkey(client, hostname, key):
    """Append hostname to the clients host_keys_file"""

    if client._host_keys_filename is None:
        logger.warning("No host keys filename, not saving key for: %s", hostname)
        return

    with HOST_KEYS_LOCK:
        # Check if this exact key already exists in memory to prevent duplicates
        existing_entry = client._host_keys.lookup(hostname)
        if existing_entry is not None:
            existing_key = existing_entry.get(key.get_name())
            if existing_key is not None:
                # Key already exists, don't append again
                logger.debug(
                    "Host key for %s already in memory cache, skipping file append",
                    hostname,
                )
                return

        # The paramiko client saves host keys incorrectly whereas the host keys object does
        # this correctly, so use that with the client filename variable.
        # See: https://github.com/paramiko/paramiko/pull/1989
        host_key_entry = HostKeyEntry([hostname], key)
        if host_key_entry is None:
            raise SSHException(
                "Append Hostkey: Failed to parse host {0}, could not append to hostfile".format(
                    hostname
                ),
            )
        with open(client._host_keys_filename, "a") as host_keys_file:
            hk_entry = host_key_entry.to_line()
            if hk_entry is None:
                raise SSHException(f"Append Hostkey: Failed to append hostkey ({host_key_entry})")

            host_keys_file.write(hk_entry)

        # Update the in-memory HostKeys object to prevent duplicate additions
        # on subsequent connection attempts
        client._host_keys.add(hostname, key.get_name(), key)
```

## Files Requiring Modification

1. **`/Users/nick/Dev/Webroot/pyinfra/src/src/pyinfra/connectors/sshuserclient/client.py`**
   - Modify `append_hostkey()` function to:
     a. Check if the host key already exists in the in-memory `client._host_keys` before appending
     b. Update `client._host_keys` after successfully appending to the file

## Testing Recommendations

1. Add a test case that simulates multiple connection attempts with the same host
2. Verify that only one entry is added to the known_hosts file
3. Test the scenario where paramiko fails to parse existing entries (like `@cert-authority`)
4. Ensure the fix works with both `AcceptNewPolicy` and `AskPolicy`

## Additional Notes

- The fix in issue #1209 was correct in switching from `host_keys.save()` to appending, as it preserves comments and unparseable lines
- This issue is a follow-up edge case that wasn't caught in the original fix
- The memoization of `get_host_keys()` is important for performance, so clearing the cache on every append (Option B) should be avoided
