-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathssh_key_manage.py
More file actions
executable file
·290 lines (254 loc) · 8.92 KB
/
Copy pathssh_key_manage.py
File metadata and controls
executable file
·290 lines (254 loc) · 8.92 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
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
#!/usr/bin/env python3
# Copyright (C) 2026 Savoir-faire Linux Inc.
# SPDX-License-Identifier: Apache-2.0
"""Add, remove, or replace SSH public keys for SEAPATH users."""
import argparse
import base64
import shlex
import shutil
import subprocess
import sys
from pathlib import Path
USERS = ("admin", "ansible", "root")
SSH_OPTION = "-o"
CONNECTION_OPTIONS = (
"Hostname",
"Port",
"AddressFamily",
"BindAddress",
"ProxyCommand",
"ProxyJump",
"HostKeyAlias",
"StrictHostKeyChecking",
"UserKnownHostsFile",
"GlobalKnownHostsFile",
"IdentityFile",
)
PRIVATE_KEY_HEADER = "-----BEGIN"
REMOTE_SCRIPT = r"""
set -eu
action=$1
key_type=$2
key_data=$3
key_line=$4
for account in admin ansible root; do
entry=$(getent passwd "$account") || {
echo "Account not found: $account" >&2
exit 1
}
home=$(printf '%s\n' "$entry" | cut -d: -f6)
ssh_directory=$home/.ssh
authorized_keys=$ssh_directory/authorized_keys
umask 077
mkdir -p "$ssh_directory"
chown "$account" "$ssh_directory"
chmod 700 "$ssh_directory"
touch "$authorized_keys"
chown "$account" "$authorized_keys"
chmod 600 "$authorized_keys"
case "$action" in
add)
if awk -v type="$key_type" -v data="$key_data" \
'{ for (i = 1; i < NF; i++) if ($i == type && $(i + 1) == data) found = 1 }
END { exit !found }' \
"$authorized_keys"; then
echo "$account: key already present"
continue
fi
printf '%s\n' "$key_line" >> "$authorized_keys"
echo "$account: key added"
;;
remove)
temporary=$(mktemp "$ssh_directory/.authorized_keys.XXXXXX")
trap 'rm -f "$temporary"' EXIT HUP INT TERM
awk -v type="$key_type" -v data="$key_data" \
'{ matched = 0
for (i = 1; i < NF; i++) if ($i == type && $(i + 1) == data) matched = 1
if (!matched) print }' "$authorized_keys" > "$temporary"
chown "$account" "$temporary"
chmod 600 "$temporary"
mv "$temporary" "$authorized_keys"
trap - EXIT HUP INT TERM
echo "$account: matching key removed"
;;
replace)
temporary=$(mktemp "$ssh_directory/.authorized_keys.XXXXXX")
trap 'rm -f "$temporary"' EXIT HUP INT TERM
printf '%s\n' "$key_line" > "$temporary"
chown "$account" "$temporary"
chmod 600 "$temporary"
mv "$temporary" "$authorized_keys"
trap - EXIT HUP INT TERM
echo "$account: authorized_keys replaced"
;;
*) echo "Unsupported action: $action" >&2; exit 2 ;;
esac
done
"""
def ssh_config(target):
try:
result = subprocess.run(
["ssh", "-G", target],
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
check=False,
)
except OSError as error:
raise RuntimeError(str(error)) from error
if result.returncode:
messages = result.stderr.strip().splitlines()
raise RuntimeError(messages[-1] if messages else f"ssh -G exited with status {result.returncode}")
config = {}
for line in result.stdout.splitlines():
option, separator, value = line.partition(" ")
if separator:
config[option.lower()] = value
return config
def ssh_command(target, login_key, config, remote_command):
command = ["ssh", "-F", "/dev/null"]
for option in CONNECTION_OPTIONS:
if option == "IdentityFile" and login_key:
continue
value = config.get(option.lower())
if value and value.lower() != "none":
command.extend((SSH_OPTION, f"{option}={value}"))
command.extend(
[
SSH_OPTION,
"BatchMode=yes",
SSH_OPTION,
"IdentitiesOnly=yes",
SSH_OPTION,
"IdentityAgent=none",
SSH_OPTION,
"PasswordAuthentication=no",
SSH_OPTION,
"KbdInteractiveAuthentication=no",
SSH_OPTION,
"PreferredAuthentications=publickey",
SSH_OPTION,
"LogLevel=ERROR",
"-l",
"admin",
]
)
if login_key:
command.extend(("-i", str(login_key)))
return command + [target, remote_command]
def is_private_key_text(text):
return text.startswith(PRIVATE_KEY_HEADER) and "PRIVATE KEY-----" in text
def is_public_key_text(text):
fields = text.split()
if len(fields) < 2:
return False
try:
base64.b64decode(fields[1], validate=True)
except ValueError:
return False
return True
def read_key_text(path, option):
try:
return path.read_text()
except (OSError, UnicodeDecodeError) as error:
raise ValueError(f"cannot read {option} {path}: {error}") from error
def read_public_key(path):
text = read_key_text(path, "--public-key")
if is_private_key_text(text.lstrip()):
raise ValueError(f"{path} contains a private key; --public-key requires a public key")
lines = [line.strip() for line in text.splitlines() if line.strip()]
if len(lines) != 1:
raise ValueError(f"public key file {path} must contain exactly one non-empty line")
fields = lines[0].split()
if not is_public_key_text(lines[0]):
raise ValueError(f"invalid public key in {path}")
return lines[0], fields[0], fields[1]
def validate_private_key(identity):
key_text = read_key_text(identity, "--key")
key_lines = [line.strip() for line in key_text.splitlines() if line.strip()]
if len(key_lines) == 1 and is_public_key_text(key_lines[0]):
raise ValueError(f"{identity} contains a public key; --key requires a private key")
def derive_public_key(identity):
try:
result = subprocess.run(
["ssh-keygen", "-y", "-f", str(identity)],
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
check=False,
)
except OSError as error:
raise ValueError(str(error)) from error
if result.returncode:
message = result.stderr.strip().splitlines()
detail = message[-1] if message else f"ssh-keygen exited with status {result.returncode}"
raise ValueError(detail)
line = result.stdout.strip()
fields = line.split()
if not is_public_key_text(line):
raise ValueError("ssh-keygen returned invalid public key")
return line, fields[0], fields[1]
def remote_command(action, key_type, key_data, key_line):
arguments = (action, key_type, key_data, key_line)
return "sudo -n /bin/sh -s -- " + " ".join(shlex.quote(argument) for argument in arguments)
def main():
parser = argparse.ArgumentParser(
description="Manage SSH public keys for standard SEAPATH accounts through admin SSH access."
)
parser.add_argument("target", help="IP address, hostname, or Host alias from SSH config")
parser.add_argument("action", choices=("add", "remove", "replace"), help="key operation")
key_group = parser.add_mutually_exclusive_group(required=True)
key_group.add_argument(
"--key",
type=Path,
help="private key whose derived public key is managed",
)
key_group.add_argument(
"--public-key",
type=Path,
help="public key to manage",
)
parser.add_argument(
"--login-key",
type=Path,
help="private key used to connect as admin; default: IdentityFile from SSH config",
)
args = parser.parse_args()
if shutil.which("ssh") is None:
parser.error("ssh command not found in PATH")
try:
managed_key = args.key.expanduser() if args.key else None
if managed_key:
validate_private_key(managed_key)
if args.public_key:
key_line, key_type, key_data = read_public_key(args.public_key.expanduser())
else:
key_line, key_type, key_data = derive_public_key(managed_key)
config = ssh_config(args.target)
except (RuntimeError, ValueError) as error:
parser.error(str(error))
login_key = args.login_key.expanduser() if args.login_key else None
if login_key:
try:
validate_private_key(login_key)
except ValueError as error:
parser.error(str(error))
command = remote_command(args.action, key_type, key_data, key_line)
try:
result = subprocess.run(
ssh_command(args.target, login_key, config, command),
input=REMOTE_SCRIPT,
text=True,
check=False,
)
except OSError as error:
print(f"cannot start ssh: {error}", file=sys.stderr)
return 1
if result.returncode:
print(f"operation failed (ssh exit status {result.returncode})", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())