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.
First, a naming trap
Section titled “First, a naming trap”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 8192Read 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.
What ssh.c actually is
Section titled “What ssh.c actually is”2,506 lines, of which main() is 1,226 — lines 620 to 1845. Everything
else is a helper for it, in four groups:
| Group | Functions | Job |
|---|---|---|
| Name resolution | resolve_host, is_addr, resolve_addr, check_follow_cname, resolve_canonicalize | Turn what the user typed into a canonical name and an address list |
| Config | process_config_files, tilde_expand_paths, default_client_percent_expand | Load ssh_config, expand ~ and %h-style tokens |
| Identities | check_load, load_public_identity_files | Find and load the keys that might be offered |
| Session | ssh_init_forwarding, ssh_session2_open, ssh_session2, fork_postauth | Set up forwardings, open the session channel, run the loop |
The flow
Section titled “The flow”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:
| # | Step | Line | Does |
|---|---|---|---|
| 1 | Process hygiene | 640 | sanitise_stdfd, closefrom, save argv for setproctitle, seed_rng, getpwuid, umask |
| 2 | initialize_options | 686 | Poison the struct, then every field to -1 / NULL |
| 3 | getopt loop | 704 | ~400 lines of mechanical option handling |
| 4 | Host + command | 1119 | Require a hostname; assemble the remote command, or request a tty |
| 5 | process_config_files | 1187 | Read ~/.ssh/config then /etc/ssh/ssh_config |
| 6 | Canonicalisation | 1192, 1214 | Apply HostName, resolve, follow CNAMEs, build the address list |
| 7 | Re-parse? | 1252 | If the host changed, throw the config away and do step 5 again |
| 8 | fill_default_options | 1267 | Every remaining -1 becomes a real default |
| 9 | muxclient | 1637 | Hand the whole request to an existing master, if there is one |
| 10 | Identities | 1646, 2382 | load_public_identity_files, pkcs11_init |
| 11 | ssh_connect | 1708 | TCP, or spawn ProxyCommand → sshconnect.c |
| 12 | ssh_login | 1795 | Banner, key exchange, authentication → sshconnect2.c |
| 13 | ssh_session2 | 1830 | Forwardings, session channel, client_loop |
Three things in that picture are worth more than the rest.
The config file is parsed twice
Section titled “The config file is parsed twice”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.
Why ssh_config is first-match-wins
Section titled “Why ssh_config is first-match-wins”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 mux shortcut
Section titled “The mux shortcut”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:
| Outcome | When | Effect on main() |
|---|---|---|
| Never returns | The 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 socket | Only -O proxy (mux.c, SSHMUX_COMMAND_PROXY) | goto skip_connect — ssh_session2 runs over the borrowed socket |
| Returns −1 | No master listening, or the request failed | Falls 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.
Where ssh.c stops
Section titled “Where ssh.c stops”main() is a sequencer. The moment real protocol work starts, it hands off:
| Call | Lands in | Covered by |
|---|---|---|
ssh_connect | sshconnect.c | — |
ssh_login | sshconnect.c:1630 → sshconnect2.c | reading sshconnect2.c |
ssh_session2 | channels.c, clientloop.c | connection workflows |
muxclient / muxserver_listen | mux.c | — |
The local server
Section titled “The local server”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:
./tools/run-sshd.shIn another:
./tools/run-ssh.sh -o LogLevel=ERROR 'echo "HELLO from $(hostname) as $(id -un)"'HELLO from wa as haoyuBoth 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:
make -j"$(nproc)" ssh && ./tools/run-ssh.sh -o LogLevel=ERROR truemake relinks only what changed, so a one-line edit to ssh.c is a few
seconds.
Exercises
Section titled “Exercises”1. Put yourself in the flow
Section titled “1. Put yourself in the flow”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:
make -j"$(nproc)" ssh && ./tools/run-ssh.sh -o LogLevel=INFO trueTUTORIAL: host=127.0.0.1 port=2222 user=haoyuNote that host is the resolved name from devroot/etc/ssh_config, not the
dev you typed — that substitution is step 6 in the diagram.
2. Watch the config get parsed twice
Section titled “2. Watch the config get parsed twice”The re-parse already logs itself at debug level. Force it on:
./tools/run-ssh.sh -v -o CanonicalizeHostname=yes true 2>&1 | grep -i "re-pars\|canonical"debug1: hostname canonicalisation enabled, will re-parse configurationdebug1: re-parsing configurationNow 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.
3. Prove main() is just a sequencer
Section titled “3. Prove main() is just a sequencer”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:
./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 trueThe first invocation prints all three. The second prints none of them — not
even ssh_session2:
TUTORIAL: host=127.0.0.1 port=2222 user=haoyuThat 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.
4. Break it on purpose
Section titled “4. Break it on purpose”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.
Reading order, and what to skip
Section titled “Reading order, and what to skip”- Skim, don’t read, the
getoptblock (704–1118). Four hundred lines of mechanicalcasearms. Search it when you need a specific flag; reading it front to back teaches nothing. - Read 1119 → 1290: host name checks, command assembly, the two config passes, canonicalisation, defaults. This is where all the interesting decisions are.
- Read 1637 → 1842: mux, identities, connect, login, session. Fifteen lines of actual calls with a lot of error handling around them.
- Then follow one handoff down. Reading
sshconnect2.cpicks up exactly wheressh_logingoes.
For the structs being passed around — struct ssh, Options, Sensitive —
see core types.