This is the second post in a series where I read through man ssh_config and write about things I find interesting. First post was about ObscureKeystrokeTiming.

Today's find: ChannelTimeout.

Before this option existed, SSH timeout handling was blunt. You had ClientAliveInterval and ClientAliveCountMax on the server side, which together formed a dead-peer detection mechanism. If the client stops responding to keepalive probes, the server drops the connection. But that's about the whole connection, not individual channels. A single SSH connection can multiplex many channels: your shell session, a port forward, an agent socket, an X11 tunnel. They're all riding the same connection, and there was no way to say "close this idle port forward after 10 minutes but keep my shell alive."

And ChannelTimeout fixes this. It was added to sshd in OpenSSH 9.2 (February 2023) and to the ssh client in 9.6 (December 2023). The syntax is a list of type=interval pairs:

ChannelTimeout session=30m direct-tcpip=10m

This tells ssh to close interactive sessions after 30 minutes of inactivity, and local port forwards after 10 minutes. The channel types you can target are:

agent-connection          # ssh-agent connections
direct-tcpip              # local forwards (LocalForward, DynamicForward)
direct-streamlocal@openssh.com   # local Unix socket forwards
forwarded-tcpip           # remote forwards (RemoteForward)
forwarded-streamlocal@openssh.com # remote Unix socket forwards
session                   # shell, command execution, scp, sftp
tun                       # TunnelForward connections
x11                       # X11 forwarding

You can use wildcards too, so *=15m sets a 15-minute timeout on every channel type. But there's a subtlety here that's worth understanding.

OpenSSH 9.7 (March 2024) added a global timeout type, and it behaves differently from wildcards. The man page is precise about the distinction: the global timeout watches all active channels taken together. Traffic on any active channel resets the timer. When the timer expires, all channels close. And the global timeout is explicitly not matched by wildcards, you have to specify it by name.

This matters for a common setup. Say you have a shell session open and an X11 forward. With per-channel timeouts (session=30m x11=10m), your X11 channel could be killed after 10 minutes of no X11 traffic even though you're actively typing in the shell. With a global timeout (global=30m), any activity on any channel, typing in the shell, X11 events, port forward traffic, resets the single shared timer. Everything stays alive as long as something is happening somewhere.

# Per-channel: idle X11 gets killed even if shell is active
ChannelTimeout session=30m x11=10m

# Global: everything stays alive as long as anything is active
ChannelTimeout global=30m

# Combined: global baseline plus aggressive cleanup of port forwards
ChannelTimeout global=1h direct-tcpip=10m

On the server side (sshd_config), there's a companion directive UnusedConnectionTimeout that closes connections with zero open channels. This pairs with ChannelTimeout nicely: channels time out individually, and once the last one dies, the connection itself is cleaned up. The man page specifically notes that this timeout starts after authentication completes but before the client opens any channels, so don't set it too short or legitimate clients won't have time to establish their session.

This is one of those options that's useful if you manage machines where people leave SSH sessions open indefinitely, set up port forwards and forget about them, or where you want to reclaim resources from abandoned connections without disrupting active work. For personal use, the global timeout is probably the most practical, it's the closest thing to "close everything if I walk away and forget."