# Issue #1445: Fails when ssh_config for host contains `ProxyJump none`

## Issue Summary

**Reporter:** cdleonard
**Status:** Open
**Created:** 2025-09-03
**URL:** https://github.com/pyinfra-dev/pyinfra/issues/1445

### Problem Description

When a host's SSH configuration contains `ProxyJump none`, pyinfra attempts to proxy-jump through the literal hostname "none" and fails on the DNS name resolution step.

According to the [ssh_config manual](https://man.openbsd.org/ssh_config#ProxyJump):

> Setting the host to none disables this option entirely.

This is a common pattern used in SSH configurations to explicitly disable any inherited ProxyJump settings for specific hosts. Paramiko's SSHConfig library already has custom handling for `ProxyCommand none` but not for `ProxyJump none`.

### Related Upstream Issue

- Paramiko bug: https://github.com/paramiko/paramiko/issues/2285

### Impact

- Users who have `ProxyJump none` in their SSH config (often to disable inherited proxy settings) cannot use pyinfra to connect to those hosts
- pyinfra will attempt to resolve "none" as a hostname and fail with a DNS resolution error

## Root Cause Analysis

### Technical Details

The issue is located in `/Users/nick/Dev/Webroot/pyinfra/src/src/pyinfra/connectors/sshuserclient/client.py` in the `parse_config` method of the `SSHClient` class.

When parsing SSH configuration, the code handles `proxyjump` on lines 268-288:

```python
elif "proxyjump" in host_config:
    hops = host_config["proxyjump"].split(",")
    sock = None

    for i, hop in enumerate(hops):
        hop_hostname, hop_config = self.derive_shorthand(ssh_config, hop)
        logger.debug("SSH ProxyJump through %s:%s", hop_hostname, hop_config["port"])

        c = SSHClient()
        c.connect(
            hop_hostname, _pyinfra_ssh_config_file=ssh_config_file, sock=sock, **hop_config
        )
        # ... rest of proxy jump handling
```

The problem is that when `host_config["proxyjump"]` equals `"none"` (case-insensitive), the code proceeds to:
1. Split "none" by commas, resulting in `["none"]`
2. Attempt to parse "none" as a valid hostname via `derive_shorthand()`
3. Attempt to establish an SSH connection to the literal hostname "none"
4. Fail because "none" is not a resolvable hostname

### OpenSSH Behavior

OpenSSH treats `ProxyJump none` specially - it disables any proxy jumping for that host, effectively clearing any inherited ProxyJump settings. This is commonly used in configurations like:

```
Host *.internal.example.com
    ProxyJump bastion.example.com

Host bastion.example.com
    ProxyJump none
```

### Paramiko Handling of ProxyCommand none

Paramiko already handles `ProxyCommand none` specially. Looking at paramiko's source, when `proxycommand` is set to `none` (case-insensitive), it's treated as "no proxy command" rather than a command to execute. Pyinfra should apply similar logic to `ProxyJump none`.

## Proposed Fix

### Solution: Check for "none" value before processing ProxyJump

Add a check to detect when `proxyjump` is set to "none" (case-insensitive) and skip the proxy jump setup entirely.

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

**Current code (lines 268-288):**
```python
elif "proxyjump" in host_config:
    hops = host_config["proxyjump"].split(",")
    sock = None

    for i, hop in enumerate(hops):
        hop_hostname, hop_config = self.derive_shorthand(ssh_config, hop)
        logger.debug("SSH ProxyJump through %s:%s", hop_hostname, hop_config["port"])

        c = SSHClient()
        c.connect(
            hop_hostname, _pyinfra_ssh_config_file=ssh_config_file, sock=sock, **hop_config
        )

        if i == len(hops) - 1:
            target = hostname
            target_config = {"port": cfg["port"]}
        else:
            target, target_config = self.derive_shorthand(ssh_config, hops[i + 1])

        sock = c.gateway(hostname, cfg["port"], target, target_config["port"])
    cfg["sock"] = sock
```

**Proposed fix:**
```python
elif "proxyjump" in host_config:
    proxyjump_value = host_config["proxyjump"]
    # "ProxyJump none" disables proxy jumping (OpenSSH behavior)
    if proxyjump_value.lower() != "none":
        hops = proxyjump_value.split(",")
        sock = None

        for i, hop in enumerate(hops):
            hop_hostname, hop_config = self.derive_shorthand(ssh_config, hop)
            logger.debug("SSH ProxyJump through %s:%s", hop_hostname, hop_config["port"])

            c = SSHClient()
            c.connect(
                hop_hostname, _pyinfra_ssh_config_file=ssh_config_file, sock=sock, **hop_config
            )

            if i == len(hops) - 1:
                target = hostname
                target_config = {"port": cfg["port"]}
            else:
                target, target_config = self.derive_shorthand(ssh_config, hops[i + 1])

            sock = c.gateway(hostname, cfg["port"], target, target_config["port"])
        cfg["sock"] = sock
```

### Alternative Implementation (more explicit)

For clarity, a separate condition could be added:

```python
elif "proxyjump" in host_config and host_config["proxyjump"].lower() != "none":
    hops = host_config["proxyjump"].split(",")
    # ... rest of code unchanged
```

## Related Files That Need Modification

1. **`/Users/nick/Dev/Webroot/pyinfra/src/src/pyinfra/connectors/sshuserclient/client.py`**
   - Primary file requiring the fix (lines 268-288)
   - Add check for `proxyjump` value being "none" (case-insensitive)

2. **`/Users/nick/Dev/Webroot/pyinfra/src/tests/test_connectors/test_sshuserclient.py`**
   - Add test case for `ProxyJump none` configuration
   - Verify that no proxy jump is attempted when `ProxyJump none` is set

## Testing Recommendations

### Unit Test to Add

Add a new test case in `test_sshuserclient.py`:

```python
SSH_CONFIG_PROXYJUMP_NONE = """
Host 192.168.1.5
    User "directuser"
    ProxyJump none
    ForwardAgent yes
"""

# In TestSSHUserConfig class:
@patch(
    "pyinfra.connectors.sshuserclient.client.open",
    mock_open(read_data=SSH_CONFIG_DATA),
    create=True,
)
@patch(
    "pyinfra.connectors.sshuserclient.config.open",
    mock_open(read_data=SSH_CONFIG_PROXYJUMP_NONE),
    create=True,
)
def test_load_ssh_config_proxyjump_none(self):
    """Test that ProxyJump none disables proxy jumping (issue #1445)."""
    client = SSHClient()

    _, config, forward_agent, _, _, _ = client.parse_config(
        "192.168.1.5",
        ssh_config_file="other_file",
    )

    # Verify no sock is set (no proxy jump configured)
    assert "sock" not in config or config.get("sock") is None
    assert config.get("username") == "directuser"
    assert forward_agent is True
```

### Manual Testing

1. Create an SSH config with `ProxyJump none`:
   ```
   Host testhost
       HostName 192.168.1.100
       User testuser
       ProxyJump none
   ```

2. Run pyinfra against `testhost` and verify it connects directly without attempting to proxy through "none"

3. Test inheritance scenario:
   ```
   Host *.internal
       ProxyJump bastion

   Host bastion.internal
       ProxyJump none
   ```
   Verify that `bastion.internal` connects directly while other `*.internal` hosts use the proxy

## Additional Notes

- The fix is minimal and follows the pattern already established by paramiko for `ProxyCommand none`
- Case-insensitive comparison is important since SSH config values are typically case-insensitive
- The issue reporter suggested pyinfra handle this because it already has customization on top of paramiko's SSH config handling
- This is a common SSH configuration pattern, especially in environments with bastion hosts

## References

- [OpenSSH ssh_config manual - ProxyJump](https://man.openbsd.org/ssh_config#ProxyJump)
- [Paramiko Issue #2285 - ProxyJump none not handled](https://github.com/paramiko/paramiko/issues/2285)
- [GitHub Issue #1445](https://github.com/pyinfra-dev/pyinfra/issues/1445)
