ergosteur

ergosteur / ssh-copy-id-win

Last active 1 month ago

Like 0

ssh-copy-id script for Linux that works with remote Windows hosts

Revision 42b9a58e8c234ca2fa1a63330223957466ef6937

ssh-copy-id-win.sh Raw
1#!/usr/bin/env bash
2#
3# ssh-copy-id-win — ssh-copy-id that also works against Windows OpenSSH
4# servers (regular users and Administrators), while keeping full *nix
5# compatibility.
6#
7# Windows design notes:
8#
9# * cmd.exe only. Every remote command is dispatched as
10# cmd.exe /c "<inner>"
11# so it does not matter what sshd has configured as DefaultShell.
12#
13# * ZERO quotes inside <inner>. This is deliberate and load-bearing:
14# if the remote DefaultShell is PowerShell, sshd runs
15# powershell -c "cmd.exe /c \"<inner>\""
16# and PowerShell re-escapes nested quotes as \" — which cmd.exe does
17# not understand. With exactly one quote pair (the outer one) the
18# string survives both shells identically. To achieve that:
19# - `cd /d %USERPROFILE%` first, then use short relative paths that
20# cannot contain spaces (.ssh\authorized_keys). `cd` consumes the
21# rest of its argument, so an unquoted path with spaces is fine.
22# - search on the base64 key blob only, which is whitespace-free.
23#
24# * Appends with `(echo LINE)>>file`, never `echo LINE>>file && ...`.
25# In the latter, cmd lifts the redirect out of the middle and the
26# space before `&&` ends up INSIDE the echoed text, producing a
27# trailing space. That breaks any later exact-match dedup and silently
28# appends a duplicate key on every run.
29#
30# * No hard-coded drive letters: %USERPROFILE% / %ProgramData% only.
31#
32# * Administrators are written to
33# %ProgramData%\ssh\administrators_authorized_keys, because the stock
34# Windows sshd_config has a `Match Group administrators` block that
35# makes that file the ONLY one consulted for admin accounts. The
36# required restrictive ACL (SYSTEM + Administrators) is applied after.
37
38set -u
39
40PROG=$(basename "$0")
41IDENTITY=""
42PORT=""
43SSH_OPTS=()
44FORCE_MODE="" # "" = autodetect, "admin", "user"
45DRY_RUN=0
46
47usage() {
48 cat <<EOF
49Usage: $PROG [-i identity_file] [-p port] [-o ssh_option] [-A|-U] [-n] [user@]hostname
50
51 -i identity_file Public key to install. Defaults to the most recently
52 modified ~/.ssh/id*.pub (same rule as ssh-copy-id).
53 -p port Remote SSH port.
54 -o ssh_option Extra option passed through to ssh (repeatable).
55 -A Force the Administrator path on Windows.
56 -U Force the regular-user path on Windows.
57 -n Dry run: print what would happen, change nothing.
58 -h This help.
59EOF
60}
61
62while getopts ":i:p:o:AUnh" opt; do
63 case "$opt" in
64 i) IDENTITY=$OPTARG ;;
65 p) PORT=$OPTARG ;;
66 o) SSH_OPTS+=(-o "$OPTARG") ;;
67 A) FORCE_MODE="admin" ;;
68 U) FORCE_MODE="user" ;;
69 n) DRY_RUN=1 ;;
70 h) usage; exit 0 ;;
71 \?) echo "$PROG: unknown option -$OPTARG" >&2; usage >&2; exit 1 ;;
72 :) echo "$PROG: option -$OPTARG requires an argument" >&2; exit 1 ;;
73 esac
74done
75shift $((OPTIND - 1))
76
77TARGET=${1:-}
78if [ -z "$TARGET" ]; then
79 echo "$PROG: missing [user@]hostname" >&2
80 usage >&2
81 exit 1
82fi
83
84PORT_OPTS=()
85[ -n "$PORT" ] && PORT_OPTS=(-p "$PORT")
86
87# This script makes several round trips (probe OS, detect admin, install,
88# set ACL). On a password-auth host that would mean re-entering the password
89# for each one, so all of them share a single multiplexed connection: you
90# authenticate once and the rest ride along.
91CTL_DIR=$(mktemp -d "${TMPDIR:-/tmp}/ssh-copy-id-win.XXXXXX")
92CTL_PATH="$CTL_DIR/s"
93
94cleanup() {
95 ssh -o ControlPath="$CTL_PATH" -O exit "$TARGET" >/dev/null 2>&1
96 rm -rf "$CTL_DIR"
97}
98trap cleanup EXIT INT TERM
99
100ssh_run() {
101 # LogLevel=ERROR keeps banners/warnings (e.g. the post-quantum notice) out
102 # of the output we parse for result markers.
103 ssh -o LogLevel=ERROR \
104 -o ControlMaster=auto -o ControlPath="$CTL_PATH" -o ControlPersist=60 \
105 -o PreferredAuthentications=publickey,keyboard-interactive,password \
106 "${PORT_OPTS[@]}" "${SSH_OPTS[@]}" "$TARGET" "$@"
107}
108
109# ---------------------------------------------------------------- identity
110if [ -z "$IDENTITY" ]; then
111 IDENTITY=$(ls -dt "$HOME"/.ssh/id*.pub 2>/dev/null | grep -v -- '-cert\.pub$' | head -n 1)
112 [ -n "$IDENTITY" ] || { echo "$PROG: no identity found and none given with -i" >&2; exit 1; }
113else
114 case "$IDENTITY" in
115 *.pub) : ;;
116 *) [ -f "${IDENTITY}.pub" ] && IDENTITY="${IDENTITY}.pub" ;;
117 esac
118fi
119[ -f "$IDENTITY" ] || { echo "$PROG: identity file not found: $IDENTITY" >&2; exit 1; }
120
121# First non-blank line, CR stripped, whitespace collapsed.
122PUBKEY_LINE=$(tr -d '\r' < "$IDENTITY" | grep -v '^[[:space:]]*$' | head -n 1 \
123 | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e 's/[[:space:]][[:space:]]*/ /g')
124[ -n "$PUBKEY_LINE" ] || { echo "$PROG: $IDENTITY contains no key" >&2; exit 1; }
125
126echo "$PROG: using identity: $IDENTITY" >&2
127
128# ------------------------------------------------------------ OS detection
129# Deliberately explicit: a failed connection must NOT be mistaken for
130# "not *nix, therefore Windows".
131PROBE_ERR="$CTL_DIR/probe.err"
132UNAME_OUT=$(ssh_run uname -s 2>"$PROBE_ERR")
133SSH_RC=$?
134
135if [ $SSH_RC -eq 255 ]; then
136 echo "$PROG: cannot connect to $TARGET" >&2
137 [ -s "$PROBE_ERR" ] && sed "s/^/$PROG: ssh: /" "$PROBE_ERR" >&2
138 echo "$PROG: (if this host uses password auth, run from an interactive" >&2
139 echo "$PROG: terminal so ssh can prompt for it)" >&2
140 exit 1
141fi
142
143REMOTE_OS=""
144case "$UNAME_OUT" in
145 *Linux*|*Darwin*|*BSD*|*SunOS*|*AIX*|*CYGWIN*|*MINGW*|*MSYS*) REMOTE_OS="nix" ;;
146esac
147
148if [ -z "$REMOTE_OS" ]; then
149 # Probe for Windows rather than assuming it.
150 VER_OUT=$(ssh_run 'cmd.exe /c ver' 2>/dev/null)
151 case "$VER_OUT" in
152 *Windows*|*windows*) REMOTE_OS="windows" ;;
153 esac
154fi
155
156if [ -z "$REMOTE_OS" ]; then
157 echo "$PROG: could not determine remote OS (uname gave '$UNAME_OUT')" >&2
158 exit 1
159fi
160
161# =========================================================== *nix remote ==
162if [ "$REMOTE_OS" = "nix" ]; then
163 echo "$PROG: remote is *nix ($UNAME_OUT), using standard behavior" >&2
164
165 if [ "$DRY_RUN" = 1 ]; then
166 echo "$PROG: [dry-run] would append $IDENTITY to ~/.ssh/authorized_keys on $TARGET" >&2
167 exit 0
168 fi
169
170 RESULT=$(ssh_run '
171 set -e
172 umask 077
173 mkdir -p ~/.ssh
174 tmpkey=$(mktemp ~/.ssh/.ssh-copy-id-win.XXXXXX)
175 cat > "$tmpkey"
176 touch ~/.ssh/authorized_keys
177 chmod 600 ~/.ssh/authorized_keys
178 if grep -qxF -f "$tmpkey" ~/.ssh/authorized_keys 2>/dev/null; then
179 echo SSHCPID_EXISTS
180 else
181 cat "$tmpkey" >> ~/.ssh/authorized_keys
182 echo SSHCPID_ADDED
183 fi
184 rm -f "$tmpkey"
185 ' < "$IDENTITY")
186
187 case "$RESULT" in
188 *SSHCPID_ADDED*) echo "$PROG: key added." ;;
189 *SSHCPID_EXISTS*) echo "$PROG: key already present, nothing to do." ;;
190 *) echo "$PROG: remote command failed:" >&2; echo "$RESULT" >&2; exit 1 ;;
191 esac
192 exit 0
193fi
194
195# ======================================================== Windows remote ==
196echo "$PROG: remote is Windows, using cmd.exe install path" >&2
197
198# The key blob is the longest whitespace-separated field: type blob [comment].
199KEYBLOB=$(printf '%s\n' $PUBKEY_LINE | awk 'length($0) > length(m) { m = $0 } END { print m }')
200
201# Everything below is injected unquoted into a cmd.exe command line, so it
202# must be free of characters cmd would treat as syntax.
203case "$KEYBLOB" in
204 ""|*[!A-Za-z0-9+/=]*)
205 echo "$PROG: could not extract a usable base64 key blob from $IDENTITY" >&2
206 exit 1 ;;
207esac
208case "$PUBKEY_LINE" in
209 *[\"%^\&\|\<\>\(\)!]*)
210 echo "$PROG: key line contains characters unsafe for an unquoted cmd.exe command line" >&2
211 echo "$PROG: (one of: \" % ^ & | < > ( ) !) — edit the key's comment and retry" >&2
212 exit 1 ;;
213esac
214
215# ---------------------------------------------------------- admin or user
216MODE="$FORCE_MODE"
217if [ -z "$MODE" ]; then
218 # S-1-5-32-544 = BUILTIN\Administrators, locale-independent.
219 if ssh_run 'cmd.exe /c "whoami /groups | findstr /L S-1-5-32-544 >nul"' >/dev/null 2>&1; then
220 MODE="admin"
221 else
222 MODE="user"
223 fi
224fi
225
226if [ "$MODE" = "admin" ]; then
227 BASE_DIR='%ProgramData%'
228 SUB_DIR='ssh'
229 REL_FILE='ssh\administrators_authorized_keys'
230 echo "$PROG: installing as Administrator key -> %ProgramData%\\$REL_FILE" >&2
231else
232 BASE_DIR='%USERPROFILE%'
233 SUB_DIR='.ssh'
234 REL_FILE='.ssh\authorized_keys'
235 echo "$PROG: installing as regular user key -> %USERPROFILE%\\$REL_FILE" >&2
236fi
237
238# Single quote pair, none inside. See header comment.
239# The `if` MUST be parenthesised. Without parens, cmd swallows everything
240# after it as the if-body, so when the directory already exists the entire
241# rest of the chain is skipped and the command silently does nothing.
242INNER="cd /d $BASE_DIR & (if not exist $SUB_DIR mkdir $SUB_DIR) & findstr /L /C:$KEYBLOB $REL_FILE >nul 2>&1 && echo SSHCPID_EXISTS || ( (echo $PUBKEY_LINE)>>$REL_FILE & echo SSHCPID_ADDED )"
243
244if [ "$DRY_RUN" = 1 ]; then
245 echo "$PROG: [dry-run] mode=$MODE, would run on $TARGET:" >&2
246 echo " cmd.exe /c \"$INNER\"" >&2
247 exit 0
248fi
249
250WIN_ERR=$(mktemp)
251RESULT=$(ssh_run "cmd.exe /c \"$INNER\"" 2>"$WIN_ERR")
252case "$RESULT" in
253 *SSHCPID_ADDED*) echo "$PROG: key added." ;;
254 *SSHCPID_EXISTS*) echo "$PROG: key already present, nothing to do." ;;
255 *)
256 echo "$PROG: remote command failed." >&2
257 echo "$PROG: command was:" >&2
258 echo " cmd.exe /c \"$INNER\"" >&2
259 [ -s "$WIN_ERR" ] && { echo "$PROG: stderr:" >&2; cat "$WIN_ERR" >&2; }
260 [ -n "$RESULT" ] && { echo "$PROG: stdout:" >&2; echo "$RESULT" >&2; }
261 rm -f "$WIN_ERR"
262 exit 1 ;;
263esac
264rm -f "$WIN_ERR"
265
266if [ "$MODE" = "admin" ]; then
267 # Windows sshd ignores administrators_authorized_keys unless only SYSTEM
268 # and Administrators have access. SIDs keep this locale-independent.
269 ACL_INNER="cd /d $BASE_DIR & icacls $REL_FILE /inheritance:r /grant *S-1-5-32-544:F /grant *S-1-5-18:F"
270 if ACL_OUT=$(ssh_run "cmd.exe /c \"$ACL_INNER\"" 2>&1); then
271 echo "$PROG: ACL set (SYSTEM + Administrators only)."
272 else
273 echo "$PROG: WARNING: could not set ACL on $REL_FILE — Windows will refuse the key until fixed:" >&2
274 echo "$ACL_OUT" >&2
275 exit 1
276 fi
277fi
278