SSHDesk Your ssh config, finally legible Techlosoft - Connectivity Desk WHAT IT IS ========== SSHDesk reads a real OpenSSH ssh_config and tells you what ssh would actually do with it. Ask it for a host and it prints the effective value of every keyword, resolved by OpenSSH's own rules, with the file and line each value came from. Ask it for a keyword and it lists every declaration that could have applied, in order, marking the winner and saying why each loser lost. Ask it to check the file and it finds the things that are wrong but invisible: the wildcard block at the top that quietly steals settings from every specific block below it, the included file that is dead because an earlier one leaked a Host block, the ProxyJump chain that loops, the key that does not exist, the key that everyone can read. It is a static analyser. It never opens a connection, never runs a command, never touches an agent. Its sibling OpsTunnel forwards ports; SSHDesk only reads text. INSTALL ======= Pre-built binaries are in dist/. There is nothing to install - copy the one for your platform anywhere on your PATH and run it. dist/sshdesk-linux-amd64 Linux, x86-64 dist/sshdesk-darwin-arm64 macOS, Apple Silicon dist/sshdesk-darwin-amd64 macOS, Intel dist/sshdesk-windows-amd64.exe Windows, x86-64 On macOS and Linux you may need to mark it executable: chmod +x sshdesk-linux-amd64 To build from source you need Go 1.24 or newer. There are no dependencies of any kind, so no network access is required: go build -o sshdesk . COMMANDS ======== sshdesk resolve [--config ] [--user ] [--json] sshdesk explain [--config ] [--user ] [--json] sshdesk hosts [--config ] [--json] sshdesk check [--config ] [--json] sshdesk graph [--config ] [--out ssh.svg] [--json] sshdesk help | -h | --help resolve The effective setting for every keyword that applies to , with the file:line it came from, the blocks that matched in the order ssh reads them, and the user@hostname:port the whole thing adds up to. explain Every declaration of that could have applied to , in file order. Each one is marked WINNER, IGNORED (it matched but an earlier value had already been obtained), "block does not match", or "file never read". This is the command that settles arguments. hosts Every Host pattern that names exactly one host, with its resolved user@hostname:port, where it is declared, its jump host and its first key. check The analysis. See WHAT CHECK FINDS below. graph The ProxyJump topology. Without --out it prints the chains as text; with --out it writes a standalone SVG (no external fonts, scripts or images). FLAGS --config ssh_config to read. Default: ~/.ssh/config Short form: -f --system System config, read AFTER the user one so the user file still wins. Default: /etc/ssh/ssh_config, used only if it exists. --no-system Do not read the system config at all. Use this when you want to reason about one file and nothing else. --user Resolve as if "ssh -l " had been given. Affects "Match user" and the reported target. Short form: -u --out Where graph writes its SVG. This is the ONLY path SSHDesk ever writes to. Short form: -o --json Machine-readable output. Available on every reporting subcommand: resolve, explain, hosts, check, graph. Flags may appear before or after positional arguments; either order works. THE ONE RULE ============ OpenSSH is FIRST-OBTAINED-VALUE-WINS. For each keyword, the earliest matching declaration is the one that takes effect. Every later declaration of that keyword is read, matched, and then thrown away. Specificity does not enter into it. This is the opposite of what almost everyone assumes, and it is why this file: Host * User deploy Port 2222 Host web1 User www-data Port 22 connects to web1 as deploy on port 2222. The web1 block is not wrong; it is too late. `sshdesk explain web1 Port` says so in as many words, and `sshdesk check` reports it as a shadowed block. The correct shape is the reverse: specific blocks first, `Host *` at the very end of the file, where it acts as a set of defaults instead of a set of overrides. THE ONE EXCEPTION Some keywords accumulate instead of being overwritten. Every matching block contributes a value, in file order: CertificateFile DynamicForward IdentityFile LocalForward RemoteForward SendEnv SetEnv CanonicalizePermittedCNAMEs So two IdentityFile lines in two matching blocks mean ssh tries both keys, in the order they were obtained. QUICK START =========== # what will ssh actually do for this host? sshdesk resolve web1.example.com # why is it using THAT port? sshdesk explain web1.example.com Port # what is wrong with my config? sshdesk check # what hosts do I even have? sshdesk hosts # draw the bastion topology sshdesk graph --out ssh.svg # feed a pipeline sshdesk check --json | jq '.findings[] | select(.severity=="error")' EXAMPLE OUTPUT ============== $ sshdesk resolve web1 SSHDesk effective configuration config : /home/dominic/.ssh/config files : 4 read (3 via Include) host : web1 matching blocks, in the order ssh reads them: /home/dominic/.ssh/config:5 Host * /home/dominic/.ssh/config:27 Host web1 web2 web3 effective settings (first obtained value wins): HostName %h.prod.example.com /home/dominic/.ssh/config:28 IdentityFile ~/.ssh/id_ed25519 /home/dominic/.ssh/config:10 Port 2222 /home/dominic/.ssh/config:9 ProxyJump bastion /home/dominic/.ssh/config:31 User deploy /home/dominic/.ssh/config:8 connection target: deploy@web1.prod.example.com:2222 user : deploy /home/dominic/.ssh/config:8 hostname : web1.prod.example.com /home/dominic/.ssh/config:28 port : 2222 /home/dominic/.ssh/config:9 via : bastion /home/dominic/.ssh/config:31 2 matching declaration(s) were ignored because an earlier one already set the keyword. Run 'sshdesk explain web1 ' to see which. $ sshdesk explain web1 Port => #1 /home/dominic/.ssh/config:9 block : Host * (/home/dominic/.ssh/config:5) value : 2222 status : WINNER - this is the value ssh uses why : first matching declaration of Port; ssh keeps the first value it obtains #4 /home/dominic/.ssh/config:30 block : Host web1 web2 web3 (/home/dominic/.ssh/config:27) value : 22 status : IGNORED - matched, but too late why : first-obtained value already set at /home/dominic/.ssh/config:9 (Host *) EFFECTIVE: 2222 (from /home/dominic/.ssh/config:9) HOW THE PARSER WORKS ==================== The goal is to agree with OpenSSH, not to be convenient. TOKENISING Lines are split the way OpenSSH's argv_split() splits them: - whitespace separates arguments; - a '#' that STARTS an argument ends the line, so `Host a#b` is one pattern named "a#b" while `Host a #b` is the pattern "a" plus a comment; - single and double quotes group text including spaces; - a backslash escapes a quote, another backslash, or a space; any other backslash is kept literally; - the keyword may be separated from its value by whitespace or '=', so `Port 22`, `Port=22`, `Port = 22` and `Port =22` are the same line. Only the FIRST separator is special, so `SetEnv FOO=bar` keeps its '='. - keywords are case-insensitive; Host PATTERNS are not (see below). LINE CONTINUATION There isn't any. OpenSSH reads ssh_config one line at a time and never joins lines, so a trailing backslash is simply part of the value and the next line is a separate directive. SSHDesk implements exactly that, and `check` reports any line ending in a backslash, because a config that contains one is a config whose author believed otherwise. PATTERN MATCHING Host patterns use ssh's own glob, not the shell's and not Go's filepath.Match: * matches zero or more characters, including '.' and '/' ? matches exactly one character There are no character classes, no braces, and no escaping. `[abc]` is three literal characters. Matching is byte-by-byte and case sensitive. A Host line may carry several patterns and any of them may be negated with '!'. A negated pattern that matches disables the whole block IMMEDIATELY, even if an earlier pattern on the same line already matched: Host *.example.com !bastion.example.com matches every name in the domain except the bastion. CASE ssh lowercases the host you type on the command line but uses the pattern from the file verbatim. `Host CI-Runner` therefore matches nothing at all, ever. `check` reports uppercase patterns for this reason. INCLUDE Include takes one or more glob patterns. An absolute path is used as is, '~' expands to your home directory, and a relative path resolves against the directory of the configuration being read - ~/.ssh for the user config, /etc/ssh for the system one. SSHDesk uses the directory of the file you passed to --config, which is the same thing whenever you are analysing a real config in place. Nesting is capped at 16 levels, as in OpenSSH, so a file that includes itself is reported instead of hanging. Included files are spliced in at the point of the Include, so precedence is plain textual order across all of them. Two consequences that surprise people, both faithfully reproduced: - An Include inside a Host or Match block that does not match is not read at all. Nothing in that file can apply. - ssh carries ONE "is the current block active" flag through the include. An included file that ends while a Host block is still open therefore leaks that block back into the including file: every directive after the Include, and every file included after it, is only read for hosts matching that trailing block. This silently kills whole files. `check` reports it as include-leaks-block. MATCH Match criteria are ANDed, each may be negated with '!', and each pattern argument is a comma-separated list with its own negation rules. all always matches host matches the hostname AFTER any HostName substitution originalhost matches the name you typed on the command line user the user from --user, else an already-obtained User, else your local account name - which is what ssh does localuser your local account name exec NEVER EVALUATED - see SCOPE canonical NEVER EVALUATED final NEVER EVALUATED localnetwork NEVER EVALUATED tagged evaluated only against an already-obtained Tag A block whose criteria cannot be evaluated is treated as NOT matching, its keywords are reported with status "unevaluated" rather than silently dropped, and both `resolve` and `check` say out loud that it was skipped. WHAT CHECK FINDS ================ shadowed-block A broad wildcard block placed before a specific one, listing the exact keywords it steals and the lines they are stolen from. include-leaks-block An included file that ends inside an open block. duplicate-host-pattern The same pattern declared in two Host blocks. uppercase-host-pattern A pattern that can never match. wildcard-user User set on `Host *`. wildcard-port Port set on `Host *`. identity-missing An IdentityFile that does not exist. identity-permissions A key readable, writable or executable by group or other. ssh refuses such keys. identity-relative An IdentityFile resolved against the current working directory. identity-not-a-file An IdentityFile that is a directory. proxyjump-undefined A jump target with no Host block of its own. proxyjump-catchall-only A jump target matched only by `Host *`. proxyjump-cycle A jump chain that loops, printed as the loop. weak-setting StrictHostKeyChecking no, UserKnownHostsFile /dev/null, ForwardAgent yes, ForwardX11 yes, GSSAPIDelegateCredentials yes and friends, each with the reason it matters. weak-algorithm 3DES, RC4, MD5, SHA-1 and DSA still listed in Ciphers, MACs, KexAlgorithms and the rest. unknown-keyword A keyword ssh does not know, with the nearest known spelling by edit distance. unknown-keyword-ignored The same, but covered by IgnoreUnknown. deprecated-keyword Protocol, RSAAuthentication, Cipher and other corpses. match-exec A Match block that was deliberately not evaluated. match-unevaluated Same, for canonical / final / localnetwork. line-continuation A line ending in a backslash. bad-quoting An unterminated quote. include-empty An Include that matched no files. include-depth Include nesting past 16 levels. Findings are sorted by file, then line, then rule, so the output is stable and diffable. `check` exits 0 even when it finds problems - see EXIT CODES. RECOGNISED KEYWORDS =================== Anything outside this list is reported as unknown (unless IgnoreUnknown covers it). Unknown keywords are still parsed and still resolved; SSHDesk does not pretend a line does not exist just because it does not know it. AddKeysToAgent AddressFamily BatchMode BindAddress BindInterface CanonicalDomains CanonicalizeFallbackLocal CanonicalizeHostname CanonicalizeMaxDots CanonicalizePermittedCNAMEs CASignatureAlgorithms CertificateFile ChannelTimeout CheckHostIP Ciphers ClearAllForwardings Compression ConnectionAttempts ConnectTimeout ControlMaster ControlPath ControlPersist DynamicForward EnableEscapeCommandline EnableSSHKeysign EscapeChar ExitOnForwardFailure FingerprintHash ForkAfterAuthentication ForwardAgent ForwardX11 ForwardX11Timeout ForwardX11Trusted GatewayPorts GlobalKnownHostsFile GSSAPIAuthentication GSSAPIDelegateCredentials HashKnownHosts Host HostbasedAcceptedAlgorithms HostbasedAuthentication HostKeyAlgorithms HostKeyAlias HostName IdentitiesOnly IdentityAgent IdentityFile IgnoreUnknown Include IPQoS KbdInteractiveAuthentication KbdInteractiveDevices KexAlgorithms KnownHostsCommand LocalCommand LocalForward LogLevel LogVerbose MACs Match NoHostAuthenticationForLocalhost NumberOfPasswordPrompts ObscureKeystrokeTiming PasswordAuthentication PermitLocalCommand PermitRemoteOpen PKCS11Provider Port PreferredAuthentications ProxyCommand ProxyJump ProxyUseFdpass PubkeyAcceptedAlgorithms PubkeyAuthentication RekeyLimit RemoteCommand RemoteForward RequestTTY RequiredRSASize RevokedHostKeys SecurityKeyProvider SendEnv ServerAliveCountMax ServerAliveInterval SessionType SetEnv StdinNull StreamLocalBindMask StreamLocalBindUnlink StrictHostKeyChecking SyslogFacility TCPKeepAlive Tag Tunnel TunnelDevice UpdateHostKeys User UserKnownHostsFile VerifyHostKeyDNS VisualHostKey XAuthLocation Recognised but reported as obsolete: ChallengeResponseAuthentication, Cipher, CompressionLevel, GSSAPITrustDNS, Protocol, RhostsRSAAuthentication, RSAAuthentication, UsePrivilegedPort, UseRoaming. PERCENT TOKENS Expanded where a value is displayed or a file is checked: %% %h %n %r %u %d %l %L. NOT expanded: %C (a hash of connection parameters), %p, %i, %k, %j, and anything else - they are left in place and an IdentityFile that still contains a percent token after expansion is reported as unchecked rather than guessed at. SCOPE / WHAT THIS DOES NOT DO ============================= This section is the important one. SSHDesk is deliberately smaller than ssh. IT NEVER CONNECTS TO ANYTHING There is no network code in the binary. No TCP, no DNS, no ssh protocol, no port forwarding, no agent socket. It cannot tell you whether a host is up, whether a key is accepted, or whether the port is open. If you want a tunnel, that is OpsTunnel; SSHDesk and OpsTunnel share no code and no state. IT NEVER RUNS A COMMAND `Match exec` is parsed, reported, and then left alone. Its block is treated as NOT matching and every keyword inside it is reported with status "unevaluated". The same goes for ProxyCommand, LocalCommand, KnownHostsCommand and PKCS11Provider: they are values to be read, never programs to be run. A config analyser that executes the config is a remote code execution feature. IT NEVER WRITES TO YOUR CONFIG DIRECTORY There is no code in this program that creates, renames, chmods, truncates or deletes anything under the configuration directory. The only file it ever writes is the path you pass to `graph --out`. There is no --fix, no --sort, no rewrite mode, and no backup file. Reordering a six-year-old ssh_config is a decision, not a transformation. IT DOES NOT CANONICALISE HOSTNAMES CanonicalizeHostname, CanonicalDomains and CanonicalizePermittedCNAMEs are parsed and reported but not applied: applying them needs DNS. Consequently `Match canonical` and `Match final` are never evaluated, and ssh's second configuration pass is not simulated. If your config leans on canonicalisation, SSHDesk's answer is the FIRST-pass answer. IT DOES NOT READ KNOWN_HOSTS, THE AGENT, OR YOUR KEYS No known_hosts parsing, no host key comparison, no fingerprints. Identity files are stat()ed - existence, type and permission bits only. Their contents are never opened, never parsed and never printed. Anything that looks like a private-key header in a value is replaced with "[redacted: private key material]" before it reaches your terminal or a JSON report. IT DOES NOT EVALUATE EVERY MATCH CRITERION `exec`, `canonical`, `final` and `localnetwork` are never evaluated, as above. `tagged` is only evaluated against a Tag that an earlier matching block already set. `user` uses --user, then any already-obtained User, then your local account name. IT ONLY KNOWS THE KEYWORDS LISTED ABOVE That is a snapshot of OpenSSH 9.x. A keyword added after this build is reported as unknown, with a nearest-match suggestion that will be wrong. It is still parsed and resolved correctly; only the advice is stale. IgnoreUnknown is honoured. IT DOES NOT VALIDATE VALUES `Port banana` is a Port declaration whose value is "banana". SSHDesk reports it faithfully; only the `hosts` listing bothers to point out that a port is not a number between 1 and 65535. Enumerated values (yes/no/ask, cipher names, LogLevel names) are not checked against ssh's tables. IT IS NOT A LINTER YOU CAN GATE A BUILD ON `check` exits 0 whether it finds nothing or forty things. There is no --fail-on, no severity threshold and no exit-code contract for findings. Parse --json and decide for yourself. OTHER THINGS IT IS NOT No sshd_config support - the keyword table, the Match criteria and the precedence rule are all different on the server side. No ~user expansion, since that needs a passwd lookup for another account. No SSHFP, no certificates, no kerberos, no PKCS#11. Permission checks are POSIX mode bits, so on Windows the identity-permissions rule will not fire. EXIT CODES ========== 0 Success. This includes `check` runs that found problems: a list of findings is a legitimate answer, not a failure. Explicit help exits 0 and prints to stdout. 1 Bad invocation (unknown command, missing or extra positional argument, unparseable flag) or an I/O error (no config file, unreadable config, unwritable --out path). Usage is printed to stderr in that case. JSON OUTPUT =========== Every reporting subcommand accepts --json. The shapes are stable and sorted: resolve { host, config, files_read, target{user,hostname,port,...}, settings[{keyword,value,file,line,block}], matched_blocks[], unevaluated[], declarations[] } explain { host, keyword, known, accumulating, declarations[], effective[] } hosts { config, count, hosts[{host,user,hostname,port,target,...}] } check { config, files_read, counts{error,warning,info,total}, findings[{severity,rule,file,line,message,detail[]}] } graph { config, nodes[{name,declared,hostname,level,in_cycle}], edges[{from,via}], out } "declarations" in resolve and explain is the full trace: every declaration the parser walked past, with its status (applied, appended, ignored, not-matched, not-reached, unevaluated) and the reason. That is the machine-readable version of the argument-settling output.