|
Xdev Host Manager
authored
2 days ago
|
1
|
#!/usr/bin/env bash
|
|
|
2
|
#
|
|
|
3
|
# ca_manager.sh - Local host certificate authority helper.
|
|
|
4
|
#
|
|
|
5
|
|
|
|
6
|
set -euo pipefail
|
|
|
7
|
|
|
|
8
|
CA_DIR="${HOST_MANAGER_CA_DIR:-/usr/local/xdev-host-manager/var/ca}"
|
|
|
9
|
OPENSSL="${OPENSSL:-openssl}"
|
|
|
10
|
DEFAULT_SUBJECT="/O=Xdev/OU=Madagascar/CN=Xdev Madagascar Local Host CA"
|
|
|
11
|
DEFAULT_DAYS=3650
|
|
|
12
|
HOST_CERT_DAYS=825
|
|
|
13
|
|
|
|
14
|
usage() {
|
|
|
15
|
cat <<EOF
|
|
|
16
|
Usage:
|
|
|
17
|
$0 init [subject]
|
|
|
18
|
$0 status-json
|
|
|
19
|
$0 list-json
|
|
|
20
|
$0 export-ca
|
|
|
21
|
$0 sign-csr name csr-file dns-name [dns-name...]
|
|
|
22
|
|
|
|
23
|
Notes:
|
|
|
24
|
- Run init/sign-csr as root.
|
|
|
25
|
- CA private key is stored outside git in \$HOST_MANAGER_CA_DIR/private.
|
|
|
26
|
- The web app only reads status, issued cert metadata, and the public CA cert.
|
|
|
27
|
EOF
|
|
|
28
|
}
|
|
|
29
|
|
|
|
30
|
die() {
|
|
|
31
|
printf '[ERROR] %s\n' "$*" >&2
|
|
|
32
|
exit 1
|
|
|
33
|
}
|
|
|
34
|
|
|
|
35
|
need_openssl() {
|
|
|
36
|
command -v "$OPENSSL" >/dev/null 2>&1 || die "openssl not found"
|
|
|
37
|
}
|
|
|
38
|
|
|
|
39
|
json_escape() {
|
|
|
40
|
perl -Mstrict -Mwarnings -e '
|
|
Xdev Host Manager
authored
2 days ago
|
41
|
my $s = $ARGV[0] // "";
|
|
Xdev Host Manager
authored
2 days ago
|
42
|
$s =~ s/\\/\\\\/g;
|
|
|
43
|
$s =~ s/"/\\"/g;
|
|
|
44
|
$s =~ s/\n/\\n/g;
|
|
|
45
|
$s =~ s/\r/\\r/g;
|
|
|
46
|
$s =~ s/\t/\\t/g;
|
|
|
47
|
print qq{"$s"};
|
|
Xdev Host Manager
authored
2 days ago
|
48
|
' "${1:-}"
|
|
Xdev Host Manager
authored
2 days ago
|
49
|
}
|
|
|
50
|
|
|
|
51
|
safe_name() {
|
|
|
52
|
local name="${1:-}"
|
|
|
53
|
[[ "$name" =~ ^[A-Za-z0-9_.-]+$ ]] || die "unsafe name: $name"
|
|
|
54
|
printf '%s' "$name"
|
|
|
55
|
}
|
|
|
56
|
|
|
|
57
|
ca_cert="$CA_DIR/certs/ca.cert.pem"
|
|
|
58
|
ca_key="$CA_DIR/private/ca.key.pem"
|
|
|
59
|
serial_file="$CA_DIR/serial.srl"
|
|
|
60
|
|
|
|
61
|
init_ca() {
|
|
|
62
|
need_openssl
|
|
|
63
|
local subject="${1:-$DEFAULT_SUBJECT}"
|
|
|
64
|
[[ ! -e "$ca_key" ]] || die "CA key already exists: $ca_key"
|
|
|
65
|
|
|
|
66
|
install -d -m 0755 "$CA_DIR" "$CA_DIR/certs" "$CA_DIR/csr" "$CA_DIR/issued" "$CA_DIR/requests"
|
|
|
67
|
install -d -m 0700 "$CA_DIR/private"
|
|
|
68
|
|
|
|
69
|
"$OPENSSL" genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out "$ca_key"
|
|
|
70
|
chmod 0600 "$ca_key"
|
|
|
71
|
|
|
|
72
|
"$OPENSSL" req -new -x509 -days "$DEFAULT_DAYS" -sha256 \
|
|
|
73
|
-key "$ca_key" \
|
|
|
74
|
-out "$ca_cert" \
|
|
|
75
|
-subj "$subject" \
|
|
|
76
|
-addext "basicConstraints=critical,CA:TRUE,pathlen:0" \
|
|
|
77
|
-addext "keyUsage=critical,keyCertSign,cRLSign" \
|
|
|
78
|
-addext "subjectKeyIdentifier=hash"
|
|
|
79
|
|
|
|
80
|
chmod 0644 "$ca_cert"
|
|
|
81
|
chown -R root:host-manager "$CA_DIR" 2>/dev/null || true
|
|
|
82
|
chown root:root "$ca_key" 2>/dev/null || true
|
|
|
83
|
chmod 0600 "$ca_key"
|
|
|
84
|
}
|
|
|
85
|
|
|
|
86
|
cert_field() {
|
|
|
87
|
local cert="$1"
|
|
|
88
|
local field="$2"
|
|
|
89
|
case "$field" in
|
|
|
90
|
subject) "$OPENSSL" x509 -in "$cert" -noout -subject | sed 's/^subject=//' ;;
|
|
|
91
|
issuer) "$OPENSSL" x509 -in "$cert" -noout -issuer | sed 's/^issuer=//' ;;
|
|
|
92
|
not_before) "$OPENSSL" x509 -in "$cert" -noout -startdate | sed 's/^notBefore=//' ;;
|
|
|
93
|
not_after) "$OPENSSL" x509 -in "$cert" -noout -enddate | sed 's/^notAfter=//' ;;
|
|
|
94
|
fingerprint) "$OPENSSL" x509 -in "$cert" -noout -fingerprint -sha256 | sed 's/^sha256 Fingerprint=//' ;;
|
|
|
95
|
serial) "$OPENSSL" x509 -in "$cert" -noout -serial | sed 's/^serial=//' ;;
|
|
|
96
|
*) return 1 ;;
|
|
|
97
|
esac
|
|
|
98
|
}
|
|
|
99
|
|
|
Bogdan Timofte
authored
18 hours ago
|
100
|
cert_dns_names_json() {
|
|
|
101
|
local cert="$1"
|
|
|
102
|
local names name first=1
|
|
|
103
|
names="$("$OPENSSL" x509 -in "$cert" -noout -ext subjectAltName 2>/dev/null \
|
|
|
104
|
| sed -n '/DNS:/,$p' \
|
|
|
105
|
| tr ',' '\n' \
|
|
|
106
|
| sed -n 's/^[[:space:]]*DNS://p')"
|
|
|
107
|
|
|
|
108
|
printf '['
|
|
|
109
|
while IFS= read -r name; do
|
|
|
110
|
[[ -n "$name" ]] || continue
|
|
|
111
|
if [[ "$first" -eq 0 ]]; then
|
|
|
112
|
printf ','
|
|
|
113
|
fi
|
|
|
114
|
first=0
|
|
|
115
|
json_escape "$name"
|
|
|
116
|
done <<< "$names"
|
|
|
117
|
printf ']'
|
|
|
118
|
}
|
|
|
119
|
|
|
Xdev Host Manager
authored
2 days ago
|
120
|
status_json() {
|
|
|
121
|
need_openssl
|
|
|
122
|
if [[ ! -f "$ca_cert" ]]; then
|
|
|
123
|
printf '{"initialized":false,"ca_dir":'
|
|
|
124
|
json_escape "$CA_DIR"
|
|
|
125
|
printf ',"certificates":0}\n'
|
|
|
126
|
return
|
|
|
127
|
fi
|
|
|
128
|
|
|
|
129
|
local count=0
|
|
|
130
|
shopt -s nullglob
|
|
|
131
|
local cert
|
|
|
132
|
for cert in "$CA_DIR"/issued/*.cert.pem; do
|
|
|
133
|
count=$((count + 1))
|
|
|
134
|
done
|
|
|
135
|
shopt -u nullglob
|
|
|
136
|
|
|
|
137
|
printf '{"initialized":true'
|
|
|
138
|
printf ',"ca_dir":'; json_escape "$CA_DIR"
|
|
|
139
|
printf ',"subject":'; json_escape "$(cert_field "$ca_cert" subject)"
|
|
|
140
|
printf ',"not_before":'; json_escape "$(cert_field "$ca_cert" not_before)"
|
|
|
141
|
printf ',"not_after":'; json_escape "$(cert_field "$ca_cert" not_after)"
|
|
|
142
|
printf ',"fingerprint_sha256":'; json_escape "$(cert_field "$ca_cert" fingerprint)"
|
|
|
143
|
printf ',"certificates":%s' "$count"
|
|
|
144
|
printf '}\n'
|
|
|
145
|
}
|
|
|
146
|
|
|
|
147
|
list_json() {
|
|
|
148
|
need_openssl
|
|
|
149
|
printf '['
|
|
|
150
|
local first=1 cert name
|
|
|
151
|
shopt -s nullglob
|
|
|
152
|
for cert in "$CA_DIR"/issued/*.cert.pem; do
|
|
|
153
|
name="$(basename "$cert" .cert.pem)"
|
|
|
154
|
if [[ "$first" -eq 0 ]]; then
|
|
|
155
|
printf ','
|
|
|
156
|
fi
|
|
|
157
|
first=0
|
|
|
158
|
printf '{"name":'; json_escape "$name"
|
|
|
159
|
printf ',"subject":'; json_escape "$(cert_field "$cert" subject)"
|
|
Bogdan Timofte
authored
18 hours ago
|
160
|
printf ',"dns_names":'; cert_dns_names_json "$cert"
|
|
Xdev Host Manager
authored
2 days ago
|
161
|
printf ',"issuer":'; json_escape "$(cert_field "$cert" issuer)"
|
|
|
162
|
printf ',"serial":'; json_escape "$(cert_field "$cert" serial)"
|
|
|
163
|
printf ',"not_before":'; json_escape "$(cert_field "$cert" not_before)"
|
|
|
164
|
printf ',"not_after":'; json_escape "$(cert_field "$cert" not_after)"
|
|
|
165
|
printf ',"fingerprint_sha256":'; json_escape "$(cert_field "$cert" fingerprint)"
|
|
|
166
|
printf '}'
|
|
|
167
|
done
|
|
|
168
|
shopt -u nullglob
|
|
|
169
|
printf ']\n'
|
|
|
170
|
}
|
|
|
171
|
|
|
|
172
|
export_ca() {
|
|
|
173
|
[[ -f "$ca_cert" ]] || die "CA is not initialized"
|
|
|
174
|
cat "$ca_cert"
|
|
|
175
|
}
|
|
|
176
|
|
|
|
177
|
sign_csr() {
|
|
|
178
|
need_openssl
|
|
|
179
|
[[ -f "$ca_key" && -f "$ca_cert" ]] || die "CA is not initialized"
|
|
|
180
|
local name csr days ext cert
|
|
|
181
|
name="$(safe_name "${1:-}")"
|
|
|
182
|
csr="${2:-}"
|
|
|
183
|
shift 2 || true
|
|
|
184
|
[[ -f "$csr" ]] || die "missing CSR file: $csr"
|
|
|
185
|
[[ "$#" -ge 1 ]] || die "at least one DNS SAN is required"
|
|
|
186
|
|
|
|
187
|
cert="$CA_DIR/issued/$name.cert.pem"
|
|
|
188
|
[[ ! -e "$cert" ]] || die "certificate already exists: $cert"
|
|
|
189
|
ext="$(mktemp)"
|
|
|
190
|
{
|
|
|
191
|
printf 'basicConstraints=critical,CA:FALSE\n'
|
|
|
192
|
printf 'keyUsage=critical,digitalSignature,keyEncipherment\n'
|
|
|
193
|
printf 'extendedKeyUsage=serverAuth,clientAuth\n'
|
|
|
194
|
printf 'subjectAltName='
|
|
|
195
|
local first=1 dns
|
|
|
196
|
for dns in "$@"; do
|
|
|
197
|
[[ "$dns" =~ ^[A-Za-z0-9_.-]+$ ]] || die "unsafe DNS SAN: $dns"
|
|
|
198
|
if [[ "$first" -eq 0 ]]; then
|
|
|
199
|
printf ','
|
|
|
200
|
fi
|
|
|
201
|
first=0
|
|
|
202
|
printf 'DNS:%s' "$dns"
|
|
|
203
|
done
|
|
|
204
|
printf '\n'
|
|
|
205
|
} > "$ext"
|
|
|
206
|
|
|
|
207
|
cp "$csr" "$CA_DIR/csr/$name.csr.pem"
|
|
|
208
|
"$OPENSSL" x509 -req -sha256 \
|
|
|
209
|
-in "$csr" \
|
|
|
210
|
-CA "$ca_cert" \
|
|
|
211
|
-CAkey "$ca_key" \
|
|
|
212
|
-CAserial "$serial_file" \
|
|
|
213
|
-CAcreateserial \
|
|
|
214
|
-days "$HOST_CERT_DAYS" \
|
|
|
215
|
-extfile "$ext" \
|
|
|
216
|
-out "$cert"
|
|
|
217
|
rm -f "$ext"
|
|
|
218
|
chmod 0644 "$cert" "$CA_DIR/csr/$name.csr.pem"
|
|
|
219
|
chown root:host-manager "$cert" "$CA_DIR/csr/$name.csr.pem" 2>/dev/null || true
|
|
|
220
|
printf '%s\n' "$cert"
|
|
|
221
|
}
|
|
|
222
|
|
|
|
223
|
cmd="${1:-}"
|
|
|
224
|
case "$cmd" in
|
|
|
225
|
init) shift; init_ca "$@" ;;
|
|
|
226
|
status-json) status_json ;;
|
|
|
227
|
list-json) list_json ;;
|
|
|
228
|
export-ca) export_ca ;;
|
|
|
229
|
sign-csr) shift; sign_csr "$@" ;;
|
|
|
230
|
-h|--help|help|'') usage ;;
|
|
|
231
|
*) die "unknown command: $cmd" ;;
|
|
|
232
|
esac
|