Skip to content

Tutorial: ssh.h and ssh.c

A hands-on walk through the client’s entry point. Unlike the other pages here, this one expects you to have a terminal open: there is a local server in the tree, so every change you make can be running against a real SSH connection about fifteen seconds later.

ssh.h is not the header for ssh.c. This catches everyone.

ssh.h is 93 lines of protocol-wide constants, included by 32 different .c files — among them sshd.c, ssh-agent.c and ssh-keygen.c, which have nothing to do with the client:

#define SSH_DEFAULT_PORT 22
#define SSH_MAX_IDENTITY_FILES 100
#define SSH_MAX_CERTIFICATE_FILES 100
#define PROTOCOL_MAJOR_2 2
#define SSH_AUTHSOCKET_ENV_NAME "SSH_AUTH_SOCK"
#define SSH_ESCAPECHAR_NONE -2
#define SSH_PRIVSEP_USER "sshd" /* ← a server constant! */
#define SSH_LISTEN_BACKLOG 128 /* ← also a server constant */
#define SSH_MAX_BANNER_LEN 8192

Read it as “the SSH constants header”, not “the ssh client header”. The presence of SSH_PRIVSEP_USER and SSH_LISTEN_BACKLOG — both purely server-side — is the giveaway. It is a historical grab-bag, and the file names lie about the relationship.

ssh.c has no header at all. Nothing outside it needs its symbols: it is a main() plus static helpers. That absence is a design statement — the ssh program is a thin shell around libssh.a, and all the reusable machinery lives in the files it calls into.

2,506 lines, of which main() is 1,226 — lines 620 to 1845. Everything else is a helper for it, in four groups:

GroupFunctionsJob
Name resolutionresolve_host, is_addr, resolve_addr, check_follow_cname, resolve_canonicalizeTurn what the user typed into a canonical name and an address list
Configprocess_config_files, tilde_expand_paths, default_client_percent_expandLoad ssh_config, expand ~ and %h-style tokens
Identitiescheck_load, load_public_identity_filesFind and load the keys that might be offered
Sessionssh_init_forwarding, ssh_session2_open, ssh_session2, fork_postauthSet up forwardings, open the session channel, run the loop
flowchart TD
    A["<b>Startup</b><br/>hygiene, options, getopt"] --> B["<b>Config</b><br/>parse, canonicalise host"]
    B -->|"host changed →<br/>throw away, re-parse"| B
    B --> C["<b>Defaults</b><br/>every unset -1 → real value"]
    C --> D{"<b>ControlPath?</b>"}
    D -->|"live master"| E(["mux runs the session,<br/>exit()s in mux.c"])
    D -->|"-O proxy"| F["borrow its socket"]
    D -->|"no master"| G["<b>Connect</b><br/>TCP or ProxyCommand"]
    G --> H["<b>Login</b><br/>banner, KEX, userauth"]
    H --> I["<b>Session</b><br/>forwardings, channel, loop"]
    F --> I
    I --> J(["exit_status"])

    style B fill:#7a5c1e,color:#fff
    style D fill:#7a5c1e,color:#fff
    style E fill:#5a3a7a,color:#fff
    style F fill:#5a3a7a,color:#fff
    style J fill:#2d6a4f,color:#fff

The amber boxes are the two places main() does something non-obvious — the config loop and the mux fork. Both are explained below. The full sequence, with line numbers:

#StepLineDoes
1Process hygiene640sanitise_stdfd, closefrom, save argv for setproctitle, seed_rng, getpwuid, umask
2initialize_options686Poison the struct, then every field to -1 / NULL
3getopt loop704~400 lines of mechanical option handling
4Host + command1119Require a hostname; assemble the remote command, or request a tty
5process_config_files1187Read ~/.ssh/config then /etc/ssh/ssh_config
6Canonicalisation1192, 1214Apply HostName, resolve, follow CNAMEs, build the address list
7Re-parse?1252If the host changed, throw the config away and do step 5 again
8fill_default_options1267Every remaining -1 becomes a real default
9muxclient1637Hand the whole request to an existing master, if there is one
10Identities1646, 2382load_public_identity_files, pkcs11_init
11ssh_connect1708TCP, or spawn ProxyCommand → sshconnect.c
12ssh_login1795Banner, key exchange, authentication → sshconnect2.c
13ssh_session21830Forwardings, session channel, client_loop

Three things in that picture are worth more than the rest.

This is the shape that surprises people, and the amber boxes are it.

Configuration can rename the host — a HostName directive replaces what you typed. Canonicalisation can change it again. But Host and Match blocks were already evaluated against the old name, so any stanza that would match the new one was missed.

So ssh parses the config, works out the real host, and if anything could now match differently, throws the result away and parses again (ssh.c:1252):

if (want_final_pass) {
debug("re-parsing configuration");
free(options.hostname);
options.hostname = xstrdup(host);
process_config_files(options.host_arg, pw, 1, NULL);

want_final_pass is set either by canonicalisation being enabled, or by the config itself containing Match canonical or Match final (readconf.c:755). This is exactly what Match final means: “evaluate me on the second pass, when the host name is settled.”

You can watch both passes happen — see the exercises below.

Two lines explain a behaviour that trips up every new SSH user.

initialize_options starts by poisoning the whole struct and then setting every field to a sentinel:

memset(options, 'X', sizeof(*options));
options->forward_agent = -1;
options->forward_x11 = -1;
...

The 'X' fill is deliberate: anything the function forgets to initialise shows up as obvious garbage rather than a plausible zero.

Then every assignment in the config parser is guarded (readconf.c:1231):

if (*activep && *intptr == -1)
*intptr = value;

Only unset options get written. So the first value wins, and since the command line is parsed before any config file, -o and friends beat the config automatically — no precedence table required. ssh_config(5) documents the consequence rather than the mechanism:

Since the first obtained value for each directive is used, more host-specific declarations should be given near the beginning of the file.

Finally fill_default_options sweeps the struct and turns every remaining -1 into a real default.

The purple boxes are easy to miss, and they do not work the way the goto skip_connect in the source suggests. muxclient has three possible outcomes:

OutcomeWhenEffect on main()
Never returnsThe usual case: a live master and an ordinary session. mux_client_request_session proxies the whole session and ends with exit(exitval) (mux.c:2171)main() stops at line 1637. Steps 10–13 never run
Returns a socketOnly -O proxy (mux.c, SSHMUX_COMMAND_PROXY)goto skip_connect — ssh_session2 runs over the borrowed socket
Returns −1No master listening, or the request failedFalls through to a normal connection

So the reason ControlMaster makes later connections feel instant is stronger than “it skips the handshake”: for a plain session the second ssh process does almost nothing at all. It parses its config, hands the request to the master over a unix socket, shuttles bytes, and exits — without ever constructing a struct ssh connection of its own.

main() is a sequencer. The moment real protocol work starts, it hands off:

CallLands inCovered by
ssh_connectsshconnect.c—
ssh_loginsshconnect.c:1630 → sshconnect2.creading sshconnect2.c
ssh_session2channels.c, clientloop.cconnection workflows
muxclient / muxserver_listenmux.c—

Yes — there is one in the tree, and it needs no root and touches nothing system-wide. It runs unprivileged on 127.0.0.1:2222 out of devroot/, and is configured to use the just-built sshd-session and sshd-auth helpers, so you are always debugging your own binaries rather than the system ones.

In one terminal:

Terminal window
./tools/run-sshd.sh

In another:

Terminal window
./tools/run-ssh.sh -o LogLevel=ERROR 'echo "HELLO from $(hostname) as $(id -un)"'
HELLO from wa as haoyu

Both scripts are three lines; they just point the real binaries at devroot/etc/{sshd,ssh}_config. Drop -o LogLevel=ERROR and the dev config’s LogLevel DEBUG3 gives you the full protocol trace on both ends.

The edit-build-run loop is:

Terminal window
make -j"$(nproc)" ssh && ./tools/run-ssh.sh -o LogLevel=ERROR true

make relinks only what changed, so a one-line edit to ssh.c is a few seconds.

Find the end of the canonicalisation block — just after ssh.c:1267, where defaults have been filled — and add:

logit("TUTORIAL: host=%s port=%d user=%s", host, options.port,
options.user ? options.user : "(unset)");

Rebuild and run:

Terminal window
make -j"$(nproc)" ssh && ./tools/run-ssh.sh -o LogLevel=INFO true
TUTORIAL: host=127.0.0.1 port=2222 user=haoyu

Note that host is the resolved name from devroot/etc/ssh_config, not the dev you typed — that substitution is step 6 in the diagram.

The re-parse already logs itself at debug level. Force it on:

Terminal window
./tools/run-ssh.sh -v -o CanonicalizeHostname=yes true 2>&1 | grep -i "re-pars\|canonical"
debug1: hostname canonicalisation enabled, will re-parse configuration
debug1: re-parsing configuration

Now try it without CanonicalizeHostname and both lines vanish — one pass was enough. Add Match final to devroot/etc/ssh_config and they come back, with no canonicalisation involved at all.

Put a logit() immediately before each of ssh_connect (1708), ssh_login (1795) and ssh_session2 (1830). Run once normally, then run with a control master:

Terminal window
./tools/run-ssh.sh -M -o ControlPath=/tmp/ssh-tut-%C -o LogLevel=INFO -f -N
./tools/run-ssh.sh -o ControlPath=/tmp/ssh-tut-%C -o LogLevel=INFO true

The first invocation prints all three. The second prints none of them — not even ssh_session2:

TUTORIAL: host=127.0.0.1 port=2222 user=haoyu

That is the purple path in the diagram. main() never got past line 1637, because muxclient ran the session and exited inside mux.c. The goto skip_connect branch that does reach ssh_session2 is reserved for -O proxy.

Clean up with ./tools/run-ssh.sh -O exit -o ControlPath=/tmp/ssh-tut-%C.

Comment out the if (want_final_pass) block at ssh.c:1252, rebuild, and put a Match final stanza with a distinctive User in the dev client config. The stanza is now silently ignored — you have just reproduced the bug the two-pass design exists to prevent.

Revert with git checkout ssh.c.

  1. Skim, don’t read, the getopt block (704–1118). Four hundred lines of mechanical case arms. Search it when you need a specific flag; reading it front to back teaches nothing.
  2. Read 1119 → 1290: host name checks, command assembly, the two config passes, canonicalisation, defaults. This is where all the interesting decisions are.
  3. Read 1637 → 1842: mux, identities, connect, login, session. Fifteen lines of actual calls with a lot of error handling around them.
  4. Then follow one handoff down. Reading sshconnect2.c picks up exactly where ssh_login goes.

For the structs being passed around — struct ssh, Options, Sensitive — see core types.