Repository navigation
Expand file tree
/
Copy pathssh-agent-sync
More file actions
executable file
·240 lines (225 loc) · 10.3 KB
/
Copy pathssh-agent-sync
File metadata and controls
executable file
·240 lines (225 loc) · 10.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
#!/usr/bin/env bash
# Points ~/.ssh/agent.sock at the right SSH agent socket: a live forwarded
# agent when one is given and reachable, otherwise the local Secretive
# agent. A forwarded agent that offers exactly the local Secretive's keys
# is a loop (an `ssh macbook` from the Mac to itself forwards the Mac's
# own agent) and is treated as local: adopting it would route signing
# approvals to a Touch ID prompt on a machine nobody is sitting at.
#
# Every adoption is recorded in ~/.ssh/agent.fwd, the receipt. Only an
# interactive SSH login ever passes a socket path in, so the receipt is
# proof a human once chose that socket — which is what lets no-arg callers
# (tmux panes, launchd, git's signing path; none of them ever see a
# forwarded socket in their environment) find their way back to it. When
# the symlink sits on Secretive and the receipt's target answers, a no-arg
# call re-adopts it after re-running the loop check. A target that refuses
# the connection is dead for good — sshd mints a fresh socket name per
# session and the login shell pushes it — so the receipt is deleted. A
# target that accepts but never answers is likely a sleeping client: the
# receipt is kept and retried at most once a minute (the last attempt is
# recorded as epoch seconds in ~/.ssh/agent.fwd.stalled) so prompts don't
# stall two seconds per draw while the client is asleep.
#
# `ssh-agent-sync --local` clears the receipt and pins the symlink to
# Secretive: the escape hatch when a forgotten-but-live remote session
# keeps winning re-adoption. It sticks while that session sits attached to
# tmux (its login shell is blocked inside `tmux attach` and can't
# re-push); a detached session's precmd would push again within 30s.
#
# Adoption also prunes conclusively dead leftovers under ~/.ssh/agent/,
# since sshd doesn't always get to clean up its own. Only sockets sshd
# minted (s.*.sshd.*) that are over an hour old and refuse the connection
# are removed: s.*.agent.* files belong to local ssh-agent instances that
# may be live, and a socket that accepts-but-stalls may be a sleeping
# client's, so both are left alone.
#
# Safe to call repeatedly; only touches the symlink when it isn't already
# pointing at the right target. No-arg calls re-verify a previously
# adopted forwarded socket is still reachable and heal back to Secretive
# when it isn't: a torn-down session can leave a socket file that still
# passes -S but no longer answers. Prints the resolved mode ("local",
# "forwarded", or "none") so callers don't have to re-derive it.
#
# Usage: ssh-agent-sync [forwarded-socket-path | --local]
set -euo pipefail
lib_dir="${BASH_SOURCE[0]%/*}/lib"
# shellcheck source=bin/lib/bounded.sh
source "$lib_dir/bounded.sh"
sock="$HOME/.ssh/agent.sock"
# This path is also hardcoded in git/gitconfig.symlink's core.sshCommand
# (IdentityAgent=...), for git's own SSH transport rather than signing.
# Keep both in sync if Secretive ever changes its container/bundle id.
secretive="$HOME/Library/Containers/com.maxgoedjen.Secretive.SecretAgent/Data/socket.ssh"
receipt="$HOME/.ssh/agent.fwd"
stamp="$HOME/.ssh/agent.fwd.stalled"
forwarded="${1:-}"
# Reject unknown flags rather than silently running maintenance: a typo'd
# --local would otherwise print a mode and leave the caller believing the
# pin took.
if [[ "$forwarded" == --* && "$forwarded" != "--local" ]]; then
echo "ssh-agent-sync: unknown option: $forwarded" >&2
exit 2
fi
# Lists the keys an agent socket offers and returns the probe's verdict (0
# keys listed, 1 reachable but empty, 2 unreachable, 124 never answered), so
# one bounded round trip yields both. Requiring a key-type prefix filters
# out ssh-add's "The agent has no identities." sentence, which goes to
# stdout. A socket whose SSH transport stalled accepts the connection but
# never replies, so this must not be left unbounded: it runs from zsh's
# precmd and from git's signing path, and would hang both.
agent_keys() {
local out rc=0
out=$(run_bounded 2 env SSH_AUTH_SOCK="$1" ssh-add -L) || rc=$?
printf '%s\n' "$out" | grep -E '^(ssh-|ecdsa-|sk-)' || true
return "$rc"
}
# Replaces a symlink via rename(2) so readers never observe the unlink
# window `ln -sf` leaves: git-ssh-sign's -S test racing a relink would
# otherwise silently skip pinning SSH_AUTH_SOCK for that signature.
relink() {
local tmp="$2.$$.tmp"
rm -f "$tmp"
ln -s "$1" "$tmp"
mv -f "$tmp" "$2"
}
# The receipt and its stall stamp move together — the stamp must never
# outlive the receipt, or a stale backoff clock would silently defer the
# next receipt's first probe.
record_receipt() {
relink "$1" "$receipt"
rm -f "$stamp"
}
drop_receipt() {
rm -f "$receipt" "$stamp"
}
# Records when a probe attempt happened; the backoff below reads it. Called
# both before a probe (stampede control) and on a timed-out one (backoff).
stamp_attempt() {
date +%s > "$stamp"
}
# Removes dead sshd-forwarding leftovers next to the socket just adopted.
# Runs at login before tmux attaches, so it must stay cheap: at most three
# probes, each bounded at one second, and only connection-refused sockets
# (rc 2) are conclusively dead enough to unlink.
prune_stale() {
local f rc probes=0
[[ -d "$HOME/.ssh/agent" ]] || return 0
while IFS= read -r f; do
[[ -S "$f" ]] || continue
# The just-adopted socket can itself be over an hour old (sessions
# outlive the age gate); it is the one candidate that must survive.
if [[ "$f" -ef "$sock" ]]; then continue; fi
if (( probes >= 3 )); then break; fi
probes=$((probes + 1))
rc=0
run_bounded 1 env SSH_AUTH_SOCK="$f" ssh-add -l >/dev/null || rc=$?
if (( rc == 2 )); then rm -f "$f"; fi
done < <(find "$HOME/.ssh/agent" -name 's.*.sshd.*' -mmin +60 2>/dev/null)
}
[[ -d "$HOME/.ssh" ]] || mkdir -p -m 700 "$HOME/.ssh"
if [[ "$forwarded" == "--local" ]]; then
drop_receipt
if [[ -S "$secretive" ]] && ! [[ "$sock" -ef "$secretive" ]]; then
relink "$secretive" "$sock"
fi
elif [[ -n "$forwarded" && -S "$forwarded" ]] && ! [[ "$sock" -ef "$forwarded" ]]; then
forwarded_keys=$(agent_keys "$forwarded") || true
if [[ -S "$secretive" && -n "$forwarded_keys" && "$forwarded_keys" == "$(agent_keys "$secretive")" ]]; then
# A loop leaves no receipt: nothing here is worth re-adopting later.
[[ "$sock" -ef "$secretive" ]] || relink "$secretive" "$sock"
else
relink "$forwarded" "$sock"
record_receipt "$forwarded"
prune_stale
fi
elif [[ -S "$secretive" ]]; then
# A push that finds its socket already adopted still refreshes the
# receipt, so receipt and sock can't drift apart (an adoption can predate
# the receipt file itself). The string comparison against $sock screens
# out tmux panes, whose precmd passes the agent.sock path itself back in;
# matching the sock's target as a string rather than with -ef matters for
# the same reason — a receipt pointed at the sock would dangle the moment
# the sock moved.
if [[ -n "$forwarded" && "$forwarded" != "$sock" && -S "$forwarded" ]]; then
cur=$(readlink "$sock" 2>/dev/null) || cur=""
if [[ "$cur" == "$forwarded" ]] && ! [[ "$receipt" -ef "$forwarded" ]]; then
record_receipt "$forwarded"
fi
fi
failed_target="" failed_rc=0
if ! [[ "$sock" -ef "$secretive" ]]; then
# An adopted forwarded socket can outlive its SSH session as a file that
# still passes -S. ssh-add exits 2 when it cannot reach the agent at all
# (a stale, missing, or dangling sock alike); 1 means reachable-but-empty,
# which stays adopted (matching the empty-forwarded adoption above). A
# socket that connects but never answers times out to 124, which lands in
# the same ">= 2" bucket: unusable, so heal back to Secretive.
rc=0
run_bounded 2 env SSH_AUTH_SOCK="$sock" ssh-add -l >/dev/null || rc=$?
if (( rc >= 2 )); then
# Remember the verdict: when the receipt names this same socket, the
# block below reuses it rather than probing the same blackhole twice
# in one run (two 2s stalls, paid by a prompt or a commit).
failed_target=$(readlink "$sock" 2>/dev/null) || failed_target=""
failed_rc=$rc
relink "$secretive" "$sock"
fi
fi
# Re-adoption: the way back to a forwarded agent after a heal, without
# scanning or guessing. Runs only while the sock sits on Secretive —
# while a forwarded sock is adopted and answering, the sock itself is
# the source of truth and the receipt is left alone.
if [[ -L "$receipt" ]] && [[ "$sock" -ef "$secretive" ]]; then
if ! [[ -S "$receipt" ]]; then
# A dangling receipt is dead for good — fresh sessions mint fresh
# socket names and push them. This also clears pre-reboot receipts.
drop_receipt
else
now=$(date +%s)
# The stderr redirect must precede the input redirect: they apply left
# to right, and the stamp legitimately doesn't exist until a probe has
# stalled, so a trailing 2>/dev/null would let the failed open print
# "No such file or directory" into git's output on every signed commit.
IFS= read -r last 2>/dev/null < "$stamp" || last=0
[[ -z "$last" || "$last" == *[!0-9]* ]] && last=0
if (( now - last >= 60 )); then
target=$(readlink "$receipt" 2>/dev/null) || target=""
rc=0
keys=""
if [[ -n "$failed_target" && "$target" == "$failed_target" ]]; then
rc=$failed_rc
else
# Stamp before probing: when a wake fans precmd out across every
# pane at once, only the first pays the probe.
stamp_attempt
keys=$(agent_keys "$target") || rc=$?
fi
if (( rc <= 1 )); then
if [[ -n "$keys" && "$keys" == "$(agent_keys "$secretive")" ]]; then
# Same loop check as the push path: this "forwarded" agent is
# the local one seen through an ssh-to-self hop.
drop_receipt
else
# Link the sock at the target, not at the receipt: a receipt
# deleted later must not dangle the sock.
relink "$target" "$sock"
rm -f "$stamp"
fi
elif (( rc == 124 )); then
# Accepts but never answers: likely a sleeping client. Keep the
# receipt; the stamp defers the next attempt.
stamp_attempt
else
drop_receipt
fi
fi
fi
fi
fi
if [[ -S "$sock" ]] && [[ "$sock" -ef "$secretive" ]]; then
echo "local"
elif [[ -S "$sock" ]]; then
echo "forwarded"
else
echo "none"
fi