-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathscript.sh
executable file
·3411 lines (3134 loc) · 143 KB
/
script.sh
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
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/bin/ksh93
typeset -r VERSION='2.0' LIC='[-?'"${VERSION}"' ]
[-copyright?Copyright (c) 2018 Jens Elkner. All rights reserved.]
[-license?CDDL 1.0]'
# The contents of this file are subject to the terms of the
# Common Development and Distribution License (the "License") version 1.1!
# You may not use this file except in compliance with the License.
#
# See LICENSE.txt included in this distribution for the specific
# language governing permissions and limitations under the License.
#
# Copyright 2018 Jens Elkner ([email protected])
# to get the annotation numbers right
ACME='https://tools.ietf.org/html/draft-ietf-acme-acme-15'
#https://github.com/letsencrypt/boulder/commits/master/docs/acme-divergences.md
DIVERGENCE_INFO='2019-05-06'
# start of boiler plate
SDIR=${.sh.file%/*}
typeset -r FPROG=${.sh.file}
typeset -r PROG=${FPROG##*/}
#include "includes/log.kshlib"
#include "includes/man.kshlib"
function showUsage {
typeset WHAT="$1" X='--man'
[[ -z ${WHAT} ]] && WHAT='MAIN' && X='-?'
getopts -a "${PROG}" "${ print ${Man.FUNC[${WHAT}]}; }" OPT $X
}
alias json='typeset -uli'
alias json_t='typeset -usi'
#include "includes/json.sh"
unset DEFAULT ; typeset -Ar DEFAULT=(
[CFG-DIR]="${HOME}/.acme2"
[PREFIX]='.well-known/acme-challenge'
[RESPONSE_DIR]='/data/http/sites/my_site/htdocs/.well-known/acme-challenge'
[ACCOUNT]='default'
[KEY_TYP]='P-256'
[KEY_TYP_DOM]='RSA256-2048'
[CA_NAMES]='le:https://acme-v02.api.letsencrypt.org/directory test:https://acme-staging-v02.api.letsencrypt.org/directory'
[CA_NAMES1]='le:https://acme-v01.api.letsencrypt.org/directory test:https://acme-staging.api.letsencrypt.org/directory'
[CA]='test'
[PORT]=0
[TIMEOUT]=60
[MY_RESPONSE]=0
[RSA_MIN_KEYSZ]=2048
[DAYS]=30
[FORCE_ORDER]=0
[REASON]=0
)
# filter, what can go into a config file
unset CFG_FILTER
typeset CFG_FILTER="${!DEFAULT[@]}" ; CFG_FILTER="${CFG_FILTER//+(CFG-DIR|RSA_MIN_KEYSZ) }"
CFG_FILTER+=' PFEXEC UTIL UTIL_CFG SLANG NOT_BEFORE NOT_AFTER CERT_DIR'
typeset -r CFG_FILTER
Man.addFunc LE_ENV '' '[+NAME?\ble.conf\b configuration variables]
[+DESCRIPTION?If in the configuration directory used by this script a file named \ble.conf\b is found, is gets read to augment or overwrite the default configuration hardocded into this script. After this, and if the directory contains an \ba-\b\aaccount\a\b.conf\b config file, this one gets read and may augment or overwrite the configuration obtained so far. \aaccount\a is the alias of the account to use when talking to the CA servers. Finally, if the directory contains a \bc-\b\aca_name\a\b.conf\b, this file gets read and may augment or overwrite the configuration obtained so far (\aca_name\a is the alias of the CA to use).]
[+?The config file is a normal text file, which should have a \akey\a\b=\b\avalue\a pair on each line. Actually it is used as a ksh93 snippet and thus it can even be used as a startup hook, however, when doing so you risk that it will lead to unexpected results. At the end the global variables set in this config file count - they get exposed via \bset\b(1) and read in by this script. So make sure, you use proper quoting and avoid unsupported multi-line or not simple string values. NOTE that most of these parameters can be overwritten for a single run using appropriate CLI options.]
[+?For convinience you may run this script with appropriate options and \b-c config\b to dump the current config and use this as the start for your customizations.]
[+ENVIRONMENT VARIABLES?The environment variables honored in an \ble.conf\b file are:]{
[PREFIX?The URL path prefix to use for HTTP based challenge responses without any leading slash. Default: \b'"${DEFAULT[PREFIX]}"'\b]
[RESPONSE_DIR?The directory, where the files should be stored, which contains the answer for a previously received ACME challenge. It should be the path the http server uses to satisfy \b/${PREFIX}/*\b requests from ACME servers (or redirects from related domain http servers). Default: \b'"${DEFAULT[RESPONSE_DIR]}"'\b]
[ACCOUNT?The alias of the account to use. It is just a local identifier, which allows less clutter/makes it easier to refer to an account on an ACME server. CLI option \b-a ...\b overwrites this setting. Default: \b'"${DEFAULT[ACCOUNT]}"'\b]
[KEY_TYP?The type of key and SHA hash to use. For RSA keys it should have the prefix "RSA" followed by the number of bits of hash sum to use, followed by a dash (-) and the keysize in bits, e.g "RSA512-4096". LE requirement for RSA keys is a length of 2048..4096 bit. For elliptic curve (EC) based keys it should set the name of the curve to use. This script supports "RSA256-\aKSZ\a", "RSA384-\aKSZ\a", "RSA512-\aKSZ\a" with a \aKSZ\a >= 2048 bits, "P-256", "P-521" and "P-384", whereby the last one is not supported by LE. Default: \b'"${DEFAULT[KEY_TYP]}"'\b]
[KEY_TYP_ACC?The type of key to use for account related operations. Default: \bKEY_TYP\b]
[KEY_TYP_DOM?The type of key to use for domain related operations. Default: \bKEY_TYP\b]
[CA_NAMES?A space separated list of known Certificate Authorities (CAs) supported by this script. Each entry has the format \aname\a\b:\b\aurl\a, whereby \aname\a denotes the short name or alias of the CA, and \aurl\a the coresponding URL to use to get an ACME directory response. Make sure, that no name collisions occure and \bCA\b - if set - uses an alias from this list. Note that \aname\a is just a local identifier, which allows less clutter/makes it easier to refer to a directory \aurl\a.]
[CA?The name aka alias for the Certificate Authority (CA) to use. It has to be part of \bCA_NAMES\b. For production use \ble\b is recommended. CLI option \b-A ...\b overwrites this setting. Default: \b'"${DEFAULT[CA]}"'\b]
[UTIL?The name of the external \atool\a to use to GET and POST contents via https. Per default it is automatically determined via \bPATH\b with preferring \bcurl\b(1) over \bwget\b(1). If \atool\a contains no slashes, \bPATH\b needs to be set correctly to find it. Otherwise if it is not absolute, it gets resolved via the current working directory as usual. In any way it must end with one of the three names mentioned before to be able to use correct options. CLI option \b-u ...\b overwrites this setting.]
[UTIL_CFG?This script uses a http-util to exchange messages with ACME servers - usually curl or wget. If this option is used, \apath\a will be used as explicit configuration/rc file and thus one is able to adjust the behavior of \bUTIL\b as needed, e.g. wrt. proxy usage etc.. CLI option \b-U ...\b overwrites this setting.]
[SLANG?Ask the ACME server to generate text messages using the given language. The value is a 2-lettercode for the language followed by a "-" with the 2 letter code for a country variant, e.g. "de-DE" (see "locale -a" w/o the trailing .encoding - encoding stays UTF-8). Invalid values are silently ignored.]
[EMAIL?The e-mail address to use, when a new account gets registered. If no e-mail address should be submitted (which is allowed at least by LE), use a dash (-) instead of an \aaddress\a. CLI option \b-e ...\b overwrites this setting.]
[NEWKEY?The value should be a file, which contains the new private key, which should be used for all further ACME operations for the related account and server. Obviously this option takes only effect, when the command \bchkey\b gets executed, otherwise it gets silently ignored. CLI option \b-k ...\b overwrites this setting. If this option is not set when the \bchkey\b command gets executed, a new private key gets generated on-the-fly.]
[DOMAINS?The value should contain a comma separated list of domain identifiers, to which the specified command gets applied. It gets ignored for all account related operations, since they are completely decoupled from authorization and certification. Some commands support the special domain \bALL\b (case insensitive). It behaves like the common names of all known certificates were specified. CLI option \b-d ...\b overwrites this setting.]
[PFEXEC?The utility to execute, if a command gets executed, which requires higher privileges (e.g. when listening on a privileged port). On Linux one may use for example \bsudo(8)\b or on Solaris \bpfexec\b(8). There is no default set, and thus all commands executed by default with the privileges of the user or role running this script. NOTE that e.g. \bsudo\b may require an interactive session to ask for a password and thus may fail, when it gets run e.g. as a cron job.]
[PORT?The number of the port, which should be used to listen for HTTP based authorization requests from ACME servers. Right now this requires python 2.x or 3.x with the six compatibility and the standard library installed. The default value \b0\b indicates, that the challenge response gets just copied to the \bRESPONSE_DIR\b and in turn served by a webserver like Apache httpd. ACME servers use always port 80 (i.e. http://\adomain\a:80/'"${DEFAULT[PREFIX]}"'), so unless one has setup redirects on \aDOMAIN\a to a non-privileged port [and machine]] (best practice) PORT=80 is required. If the PORT is set to a value < 1024 one needs to use PFEXEC option as well, which runs the server with net-private privileges, so that it is able to bind to the specified PORT.]
[TIMEOUT?Max. number of seconds the script should wait for ACME servers to verify a challenge. Default: \b'"${DEFAULT[TIMEOUT]}"'\b]
[MY_RESPONSE?Use 1 to let you start your own client/script/etc. in order to answer challenge response requests from ACME servers (PORT gets ignored in this case). Default: \b0\b]
[RSA_KEYSZ?The number of bits a generated RSA key should have. Keys with a length < '"${DEFAULT[RSA_MIN_KEYSZ]}"' gets rejected. Default: '"${DEFAULT[RSA_KEYSZ]}"'.]
[DAYS?If a certificate expires in less than these number of days, it qualifies for renewal. LE certificates are valid for a max. period of 90 days. So if a value > 90 is set, one forces the renewal of the related certificate. To avoid any trouble, LE recommends to renew certificates 30 days before its validity period ends. Default: '"${DEFAULT[DAYS]}"']
[NOT_BEFORE?When getting/renewing a certificate, ask to set the start of its validity period to the given date value. This is just a hint, i.e. the ACME server can ignore it or even reject the request, if it cannot or is not willingly to set it. The format of the date is similar to what netnews date or GNU date accepts. "YYYY-mm-dd HH:MM:SS" is its simplest/safe input format, but things like "next week", or "in 10 days", etc. are ok as well (do not forget the quotes). You should always check, whether it follows your intention by using the "\b'"${PROG} -c check-date '\b\adate\a\b'\b"'" command!]
[NOT_AFTER?Same as NOT_BEFORE, but applies to the end of the validity period of a certificate.]
[FORCE_ORDER?If set to a number != 0, a new order gets created for the given domains, even if there is already one.]
[CERT_DIR?The directory, where all obtained certificate files should be stored as as well in PEM format as \adomain\a\b.crt\b. Default is empty, i.e. do not copy.]
[CERT_EXT?Use the given extension for certificates stored in \aCERT_DIR\a instead of \b.crt\b]
[REASON?When revoking a certificate, one may indicate via the given \anum\aber, why the certificate revocation is requested. Allowed values are from 0..10, except 7 (for more details see https://tools.ietf.org/html/rfc5280#section-5.3.1). Default: \b0\b (i.e. unspecified).]
}
]
\n\n-H LE_ENV'
Man.addFunc mergeB2A '' '[+NAME?mergeB2A - merge two associative arrays]
[+DESCRIPTION?Iterates over the associative array \avnameB\a and applies its non-empty values to the corresponding fields of the associative \avnameA\a.]
\n\n\avnameA\a \avnameB\a
'
function mergeB2A {
typeset -n A=$1 B=$2
typeset X
for X in ${!B[@]} ; do
[[ -n ${B[$X]} ]] && A[$X]="${B[$X]}"
done
return 0
}
Man.addFunc yorn '' '[+NAME?yorn - ask a yes or no question.]
[+DESCRIPTION?Ask the question by using \aqword1\a...\aqwordN\a as the prompt and \adefault\a as the default value for the answer (e.g. if a user just presses <ENTER>). If \adefault\a is neither "y" nor "n", no default will be used.]
[+RETURN VALUES?]{
[+0?The user entered a value equivalent to "yes".]
[+1?The user entered a value equivalent to "no".]
}
\n\n\adefault\a \aqword\a ...
'
function yorn {
typeset D="$1"
typeset -l A=
shift
typeset PROMPT="$@"' ('
if [[ D == [yY] ]]; then
D=y
PROMPT+='Y/n): '
elif [[ $D == [nN] ]]; then
D=n
PROMPT+='y/N): '
else
D=
PROMPT+='y/n): '
fi
if [[ -t 1 ]]; then
while : ; do
read A?"${PROMPT}"
[[ -z $A ]] && A=$D
[[ $A == 'y' || $A == 'n' ]] && break
done
else
A=$D
fi
[[ $A == 'y' ]]
}
Man.addFunc checkEnv '' '[+NAME?checkEnv - manage LC_*, NLSPATH env vars]
[+DESCRIPTION?If \bLC_ALL\b is set, all \bLC_*\b gets set to its value. Finally \bLC_NUMERIC\b and \bLC_TIME\b are set to \bC\b, \bTZ\b to \bUTC\b and \bLC_ALL\b as well as \bNLSPATH\b get unset.]
'
function checkEnv {
if [[ -n ${LC_ALL} ]] ; then
export LC_MONETARY="${LC_ALL}" LC_MESSAGES="${LC_ALL}" \
LC_COLLATE="${LC_ALL}" LC_CTYPE="${LC_ALL}"
fi
if [[ -n ${LC_MESSAGES} ]]; then
X=${LC_MESSAGES%.*}
[[ $X == [a-z][a-z][-_][A-Z][A-Z] ]] && OPTS[SLANG]=${X//_/-}
fi
export LC_NUMERIC=C LC_TIME=C TZ=UTC
unset NLSPATH LC_ALL
return 0
}
Man.addFunc readCfg '' '[+NAME?readCfg - read in a name=value config file]
[+DESCRIPTION?Executes the given \aconfig\a file as a ksh93 script in its own context and puts all variables produced this way into the given associative array \avnameA\a using the variable name as key and ${key} as its value. To restrict the keys accepted, one may provide a comma or space separated \akey_list\a of allowed var names as a 3rd argument. Unaccepted keys are silently ignored. Default variables set by the shell are always ignored. Ideally the config file is only a \akey\a=\avalue\a line-by-line list. A value which spans multiple lines or uses single or double quotes not as its first and last character may lead to unexpected results/values.]
[+ENVIRONMENT VARIABLES?\bHOME\b, \bLOGNAME\b, \bLC_CTYPE\b, \bLC_NUMERIC\b, \bLC_TIME\b, \bTZ\b]
[+SEE ALSO?\benv\b(1)]
\n\n\avnameA\a \aconfig\a [\akey_list\a]
'
function readCfg {
[[ -z $1 || -z $2 ]] && Log.fatal "fn $0: arg0,1 missing - SW bug" && exit 1
typeset -n V=$1 || exit 1
[[ -f $2 ]] || return 0
(( VERB )) && Log.info "Reading '$2' ..."
typeset IN="$2" F=${LE_TMP}/cfg.sh MARKER='@#@@#@@#@@#@@#@@#@@#@@#@@#@@#@='
typeset X
typeset -Ai ALLOW
integer SEEN=0 FILTER=0
if [[ -n $3 ]]; then
for X in ${3//,/ } ; do
[[ -n $X ]] && ALLOW[$X]=1
done
FILTER=${#ALLOW[@]}
fi
# make it a little bit more robust and usewr friendly
print "typeset HOME='${HOME}' LOGNAME='${LOGNAME}' LC_CTYPE='${LC_CTYPE}'" \
"LC_NUMERIC='${LC_NUMERIC}' LC_TIME='${LC_TIME}' TZ='${TZ}'" \
>$F || exit 2
cat "${IN}" >>$F
print 'print "'"${MARKER}"'"\nset' >>$F
env -i /bin/ksh93 $F 2>/dev/null | while read LINE ; do
if (( ! SEEN )); then
[[ ${LINE} == ${MARKER} ]] && (( SEEN++ ))
continue
fi
[[ ${LINE} == +([A-Z_])=* ]] || continue
VAL="${LINE#*=}"
KEY="${.sh.match%=}"
[[ ${KEY} =~ ^(COLUMNS|ENV|FCEDIT|HISTCMD|IFS|JOBMAX|KSH_VERSION|LINENO|LINES|MAILCHECK|OPTIND|PPID|PS[1-4]|PWD|RANDOM|SECONDS|SHELL|SHLVL|TMOUT|LC_.*|HOME|LOGNAME|TZ|LE_TMP)$ ]] \
&& continue
[[ ${VAL:0:2} == "\$'" ]] && VAL=${ printf "${VAL:2:${#VAL}-3}" ; } #"
[[ ${VAL:0:1} == "'" || ${VAL:0:1} == '"' ]] && VAL="${VAL:1:${#VAL}-2}"
(( FILTER && ! ALLOW[${KEY}] )) && continue
[[ ${VAL:0:2} == '( ' && ${VAL:-2:2} == ' )' ]] && \
V[${KEY}]=( ${VAL:2:${#VAL}-4} ) || V[${KEY}]="${VAL}"
done
X="${CFG[SLANG]}"
if [[ -n $X ]]; then
[[ $X == {2}[a-z][-_]{2}[A-Z] ]] && CFG[SLANG]=${X//_/-} || CFG[SLANG]=
fi
return 0
}
Man.addFunc checkDir '' '[+NAME?checkDir - check dir availability]
[+DESCRIPTION?Checks, whether the given \bdir\b exists. If not, it tries to create it. As a side effect, on success the \bOLDPWD\b env var gets set to this directory (because of cd usage).]
\n\n\adir\a
'
function checkDir {
[[ -z $1 ]] && Log.fatal "fn $0: arg0 missing - SW bug" && exit 1
if [[ ! -e $1 ]]; then
mkdir -p "$1" || return 1
fi
cd "$1" || return 2
cd ~-
return 0
}
Man.addFunc cleanup '' '[+NAME?cleanup - cleanup the workspace of the script.]
[+DESCRIPTION?Removes the temporary directory \bLE_TMP\b and all its contents unless instructed via keep option not to do so.]
'
function cleanup {
[[ -n ${LE_TMP} && -d ${LE_TMP} ]] || return 0
(( OPTS[KEEP] )) && \
Log.warn "Remove ${LE_TMP} when not needed anymore!" && return 0
rm -rf "${LE_TMP}"
}
Man.addFunc getConfig '' '[+NAME?getConfig - prepare the configuation to use]
[+DESCRIPTION?Applies the builtin config (see \bDEFAULT[]]\b) to the given associative array \avnameA\a, determines the config dir to use, and sets \avnameA\a\b[CFG-DIR]]\b to it. After this the following config files get read and merged to \avnameA\a, one after another in the given order: \ble.conf\b, \ba-\b\avnameA\a\b[ACCOUNT]].conf\b, \bc-\b\avnameA\a\b[CA]].conf\b. Options given on the CLI have higest priority and thus gets always merged into \avnameA\a after a file has been read.]
[+?As a side effect this function also creates the temp directory to use for further work and sets the global var \bLE_TMP\b accordingly.]
\n\n\avnameA\a
'
function getConfig {
typeset -n CFG=$1
typeset X T
mergeB2A CFG DEFAULT || return 1
if (( OPTS[API] == 1 )); then
CFG[CFG-DIR]="${DEFAULT[CFG-DIR]%2}"
CFG[CA_NAMES]="${DEFAULT[CA_NAMES1]}"
CFG[API]=1
elif [[ -n ${OPTS[API]} ]] && (( OPTS[API] != 2 )); then
Log.fatal "Unsupported API version '${OPTS[API]}'."
return 1
else
CFG[API]=2
fi
integer ERR=0 N
if [[ -n ${OPTS[LANG]} ]]; then
CFG[SLANG]=${OPTS[LANG]}
OPTS[SLANG]= # allow to override
fi
# handle default, config, and options in this order
typeset CFG_DIR="${OPTS[CFG-DIR]}" T X URL CA
[[ -z ${CFG_DIR} ]] && CFG_DIR="${CFG[CFG-DIR]}"
checkDir "${CFG_DIR}" || return 2
OPTS[CFG-DIR]="${CFG_DIR}" # makes it easier to merge back
# required by readCfg()
LE_TMP=${ mktemp -dt acme.XXXXXX ; } # global var
if [[ -z ${LE_TMP} ]]; then
Log.fatal 'Unable to create a temporary directory.'
return 3
fi
readCfg CFG "${CFG_DIR}/le.conf" "${CFG_FILTER}" || return 4
mergeB2A CFG OPTS || return 5 # CLI has higher priority
readCfg CFG "${CFG_DIR}/${CFG[ACCOUNT]}.conf" "${CFG_FILTER}" || return 6
mergeB2A CFG OPTS || return 7 # CLI has higher priority
CA=${CFG[CA]}
[[ -z ${CA} ]] && Log.fatal 'No CA configured.' && return 8
readCfg CFG "${CFG_DIR}/${CFG[CA]}.conf" "${CFG_FILTER}" || return 9
mergeB2A CFG OPTS || return 10 # CLI has higher priority
[[ -z ${CFG[KEY_TYP_ACC]} ]] && CFG[KEY_TYP_ACC]=${CFG[KEY_TYP]}
[[ -z ${CFG[KEY_TYP_DOM]} ]] && CFG[KEY_TYP_DOM]=${CFG[KEY_TYP]}
CFG[ACCOUNT-URL-FILE]="${CFG_DIR}/a-${CFG[ACCOUNT]}.url"
# fail early
if (( CFG[PORT] < 0 || CFG[PORT] > 65534 )); then
Log.fatal "Invalid port '${CFG[PORT]}'."
(( ERR++ ))
fi
N=1
for X in ${CFG[CA_NAMES]} ; do
T=${X%%:*}
URL=${.sh.match:1}
[[ $T == ${CA} ]] && CFG[CA-URL]="${URL}" && N=0 && break
done
(( N )) && (( ERR++ )) && Log.fatal "CA named '${CA}' not found in CA_NAMES"
if [[ -n ${CFG[PFEXEC]} ]]; then
if ! whence -q ${CFG[PFEXEC]} ; then
Log.fatal "PFEXEC utility '${CFG[PFEXEC]}' not executable."
(( ERR++ ))
fi
fi
# connect timeout
(( CFG[TIMEOUT] < 0 )) || CFG[TIMEOUT]=60
if [[ -n ${OPTS[DEBUG-FN]} ]]; then
X= T=
set -s ${OPTS[DEBUG-FN]}
while [[ -n $1 ]]; do
[[ $T == $1 ]] || X+=",$1"
T="$1"
shift
done
CFG[DEBUG-FN]="$X,"
fi
(( CFG[DAYS] < 0 )) && Log.warn "'DAYS' has a negative value. So only" \
'already expired certificates qualify for renewal - probably not' \
'what you want!'
if [[ -n ${CFG[NOT_BEFORE]} ]]; then
N=${ printf '%(%s)T' "${CFG[NOT_BEFORE]}" ; }
if (( $? )); then
Log.fatal "Invalid 'NOT_BEFORE' date (${CFG[NOT_BEFORE]})."
(( ERR++ ))
fi
fi
if [[ -n ${CFG[NOT_AFTER]} ]]; then
N=${ printf '%(%s)T' "${CFG[NOT_AFTER]}" ; }
if (( $? )); then
Log.fatal "Invalid 'NOT_AFTER' date (${CFG[NOT_AFTER]})."
(( ERR++ ))
fi
fi
CFG[CERT_EXT]="${CFG[CERT_EXT]##.}"
if [[ -n ${CFG[REASON]} ]]; then
if [[ ! ${CFG[REASON]} =~ ^[0-9]+$ ]] ; then
Log.fatal "The certificate revocation REASON '${CFG[REASON]}' is" \
'not allowed. Use a number in the range of 0..10, except 7.'
(( ERR++ ))
elif (( CFG[REASON] < 0 || CFG[REASON] > 10 || CFG[REASON] == 7 )); then
Log.fatal 'The certificate revocation REASON code is out of range.'\
'Use a number in the range of 0..10, except 7.'
(( ERR++ ))
fi
fi
checkDir ${CFG[CFG-DIR]}/${CFG[CA]} || (( ERR++ ))
return ${ERR}
}
Man.addFunc dumpArray '' '[+NAME?dumpArray - dump the content of an associative array]
[+DESCRIPTION?Dumps the \akey\a\b='"'\avalue\a'"' entries of the given associative array \avnameA\a line-by-line. If one or more \aakey\a are given, an explicit \aakey\a\b=\b gets emitted if \avnameA\a does not contain it.]
\n\n\avnameA\a [\aakey\a ...]
'
function dumpArray {
typeset -n C=$1
typeset -A MISC
shift
[[ -n $1 ]] && for X ; do MISC["$X"]=1 ; done
for X in ${!C[@]} ; do
print -- "${X}='${C[$X]}'"
MISC["$X"]=
done
for X in ${!MISC[@]} ; do
[[ -n ${MISC["$X"]} ]] && print -- "${X}="
done
}
Man.addFunc checkBinaries '' '[+NAME?checkBinaries - check, whether required external tools are available.]
[+DESCRIPTION?Checks, whether the required external tools like openssl, curl, etc. are available and stores their path into the given associative array \avnameA\a with the keys \bOPENSSL\b and \bUTIL\b.]
\n\n\avnameA\a
'
function checkBinaries {
typeset -n CFG=$1
typeset TOOL="${CFG[UTIL]}"
integer ERR=0
(( VERB )) && Log.info 'Looking for external tools ...'
# openssl
if [[ -n ${OPENSSL} ]]; then
if [[ ${OPENSSL:0:1} != '/' ]]; then
[[ ${OPENSSL} =~ / ]] && OPENSSL="${PWD}/${OPENSSL}" || \
OPENSSL=${ whence ${OPENSSL} ; }
fi
if [[ ! -x ${OPENSSL} ]]; then
Log.fatal "openssl binary ${OPENSSL} is not executable. Unsetting" \
'OPENSSL env var may resolve this problem.'
(( ERR++ ))
fi
else
OPENSSL=${ whence openssl ; }
[[ -z ${OPENSSL} ]] && Log.fatal 'openssl is required but was not' \
'found. You may install it or adjust your \bPATH\b env var to' \
'solve this problem.' && (( ERR++ ))
fi
(( ERR == 0 )) && CFG[OPENSSL]="${OPENSSL}"
if [[ ${ uname -s ; } == 'SunOS' ]]; then
X=${ whence -p gsed ; }
[[ -z $X ]] && Log.fatal 'gsed (GNU sed) is required but was not' \
'found. You may install it or adjust your \bPATH\b env var to' \
'solve this problem.' && (( ERR++ ))
CFG[SED]="$X"
else
CFG[SED]='sed'
fi
# curl or wget
if [[ -n ${TOOL} ]]; then
if [[ ${TOOL:0:1} != '/' ]]; then
[[ ${TOOL} =~ / ]] && TOOL="${PWD}/${TOOL}" || \
TOOL=${ whence ${TOOL} ; }
fi
if [[ ! -x ${TOOL} ]]; then
Log.fatal "The http utility '${TOOL}' is not executable."
(( ERR++ ))
fi
if [[ ! ${TOOL} =~ /(curl|wget)$ ]]; then
Log.fatal "The http utility '${TOOL}' does neither end with" \
'curl nor wget.'
(( ERR++ ))
fi
else
for X in curl wget ; do
TOOL=${ whence $X ; }
[[ -n ${TOOL} ]] && break
done
fi
if [[ -n ${TOOL} ]]; then
CFG[UTIL]="${TOOL}"
CFG[UTIL-SHORT]="${TOOL##*/}"
CFG[AGENT]="acme-ksh/${VERSION} (ksh93/${.sh.version##* }" # 6.1 §3
X=${ ${TOOL} --version ; }
X=${X##+([^0-9])}
CFG[AGENT]+="; ${TOOL##*/}/${X%% *}"
CFG[AGENT]+="; ${ uname -s; } ${ uname -r; } ${ uname -p; }"
X=${ whence lsb_release ; }
[[ -n $X ]] && CFG[AGENT]+="; ${ lsb_release -ds; }"
CFG[AGENT]+=')'
else
Log.fatal 'No of the http-utils curl or wget found. You may' \
'install one of them or adjust your PATH env var to solve' \
'this problem.'
(( ERR++ ))
fi
return ${ERR}
}
Man.addFunc fetch '' '[+NAME?fetch - fetch a certain URL]
[+DESCRIPTION?Fetches a HTTP[S]] ressource using the configured \bUTIL\b and \bUTIL-SHORT\b values from the given associative array \avnameCFG\a. However, if a dump directory is set (e.g. via option \b-D \b\a...\a) and saving is \bnot enabled\b via option \b-s\b, the previously dumped response header and body for the request are used, i.e. no fetching via HTTP of the given resource happens.]
[+?\avnamePARAM\a is an associative array containing the parameters for the request:]{
[+URL?The URL to fetch. Mandatory!]
[+METHOD?If empty, a normal GET will be used, otherwise the given method.]
[+FOLLOW?If not empty, Location: headers are honored, i.e. follow redirects.]
[+DATA?If \bMETHOD\b == \bPOST\b, the data to post.]
[+DUMP?The basename of the dump file to use, if result dumping has been enabled. Default: Basename of the \bURL\b]
}
[+?On success (i.e. response from server obtained) the \bSTATUS_CODE\b and \bSTATUS_TXT\b for the response gets stored in the associative array \anameRES\a and the \bFILE\b entry will contain the name of the temporary file with the body of the server response, which gets overwritten on the next request. Furthermore the headers of the response get stored in \avnameRES\a as well (header names == keys, header values == values). Finally, if the server response contains a \bReplay-Nonce\b header, its value gets stored into \avnameCFG\a[NONCE]]\b.]
\n\n\avnameCFG\a \avnameRES\a \avnamePARAM\a
'
function fetch {
typeset -n CFG=$1 RESULT=$2 PARAM=$3
typeset URL="${PARAM[URL]}" X=${CFG[UTIL-SHORT]} T=${CFG[UTIL]} \
CT KEY VAL DUMP= SLANG=
typeset -a ARG
RESULT[FILE]=${LE_TMP}/wget.out # wget mixes up stdout and stderr ...
integer RES=0
KEY="${CFG[UTIL_CFG]}"
(( VERB )) && Log.info "Fetching '${URL}' ..."
rm -f "${RESULT[FILE]}"
if [[ -n ${CFG[TEST-DIR]} ]]; then
DUMP="${CFG[TEST-DIR]}/"
[[ -n ${PARAM[DUMP]} ]] && DUMP+="${PARAM[DUMP]}" || DUMP+="${URL##*/}"
(( VERB )) && Log.info "Dump basename: ${DUMP}"
fi
if [[ -z ${CFG[ACCEPT-LANG]} ]]; then
if [[ ${CFG[SLANG]} == {2}[a-z]-{2}[A-Z] ]]; then
CFG[ACCEPT-LANG]="${CFG[SLANG]}, ${CFG[SLANG]:0:2};q=0.8, en;q=0.7"
else
CFG[ACCEPT-LANG]='en;q=0.7'
fi
fi
if (( ! CFG[HTTP-DUMP] )) && \
[[ -n ${DUMP} && -f ${DUMP}.body && ${DUMP}.header ]]
then
CT=$(<${DUMP}.header)
cp ${DUMP}.body ${RESULT[FILE]}
elif [[ $X == 'curl' ]]; then
ARG=( '-s' '-A' "${CFG[AGENT]}" '--raw' '-D' '-' '-H' 'Expect:'
'-H' "Accept-Language: ${CFG[ACCEPT-LANG]}" '-o' "${RESULT[FILE]}"
)
[[ -n ${KEY} ]] && ARG+=( '-K' "${KEY}" )
if [[ ${PARAM[METHOD]} == 'HEAD' ]]; then
ARG+=( '-I' )
elif [[ ${PARAM[METHOD]} == 'POST' ]]; then
ARG+=( -X 'POST' )
X=${LE_TMP}/post.data
print -rn -- "${PARAM[DATA]}" >$X
[[ -s $X ]] && ARG+=( '--data-binary' "@$X"
'-H' 'Content-Type: application/jose+json' )
fi
[[ -n ${PARAM[FOLLOW]} ]] && \
ARG+=( '-L' ) || ARG+=( '--max-redirs' '0' )
CT=${ $T "${ARG[@]}" "${URL}" ; }
RES=$?
elif [[ $X == 'wget' ]]; then
ARG=( '-S' '-q' '-U' "${CFG[AGENT]}" '--content-on-error' '--no-hsts'
"--header=Accept-Language: ${CFG[ACCEPT-LANG]}"
'-O' "${RESULT[FILE]}"
)
[[ -n ${KEY} ]] && ARG+=( "--config=${KEY}" )
if [[ ${PARAM[METHOD]} == 'HEAD' ]]; then
ARG+=( '--method=HEAD' )
elif [[ ${PARAM[METHOD]} == 'POST' ]]; then
ARG+=( '--method=POST' )
X=${LE_TMP}/post.data
print -rn -- "${PARAM[DATA]}" >$X
[[ -s $X ]] && ARG+=( "--body-file=$X"
'--header=Content-Type: application/jose+json' )
fi
[[ -n ${PARAM[FOLLOW]} ]] || ARG+=( '--max-redirect=0' )
CT=${ $T "${ARG[@]}" "${URL}" 2>&1; } # wget emits headers to stderr
RES=$?
(( RES == 8 )) && RES=0
else
Log.fatal "Unknown http-util '$X'" && return 1
fi
if (( CFG[HTTP-DUMP] )) && [[ -n ${DUMP} ]] ; then
print "${URL}" >${DUMP}.url || { print $PWD; ls -l ; }
print -n -- "${CT}" >${DUMP}.header || { print $PWD; ls -l ; }
cp ${RESULT[FILE]} ${DUMP}.body || { print $PWD; ls -l ; }
X="${LE_TMP}/post.data"
if [[ -e $X ]] ; then
cp $X ${DUMP}.post && rm -f $X || { print $PWD; ls -l ; }
fi
fi
if (( RES )); then
Log.warn "Failed to get '${URL}' - ${CFG[UTIL-SHORT]} exit code was" \
"${RES}."
return 1
fi
CT="${CT#*$'\n'}"
set -- ${.sh.match}
RESULT[STATUS_CODE]="$2"
shift 2
RESULT[STATUS_TXT]="$@"
print -- "${CT}" | while read KEY VAL ; do
[[ -z ${KEY} ]] && continue
RESULT["${KEY%:}"]="${VAL%$'\r'}"
done
RESULT[BODY]=$(<${RESULT[FILE]})
if [[ -n ${RESULT[Replay-Nonce]} ]]; then
CFG[NONCE]="${RESULT[Replay-Nonce]}"
(( VERB )) && Log.info "New nonce '${CFG[NONCE]}'"
fi
return 0
}
# 7.3.4
# 7.4.1
Man.addFunc check403 '' '[+NAME?check403 - check for 403 server response (7.3.4, 7.4.1)]
[+DESCRIPTION?Check the response (\avnameRES\a\b[BODY]]\b) of the server for status code \b403\b and deserialize the \bproblem+json\b content if available. The entries of each deserialized key: value pair gets stored in \avnameRES\a whereby the key alias JSON property name gets prefixed a \b_RES_\b (i.e. \b_RES_type\b, \b_RES_detail\b, \b_RES_instance\b, etc.). Finally, if the \avnameRES\a\b[STATUS_CODE]]\b is \b403\b, an appropriate error message gets emitted to stderr and the function returns with \b0\b. Otherwise the function silently returns with the \avnameRES\a\b[STATUS_CODE]]\b.]
\n\n\avnameRES\a
'
function check403 {
typeset -n R=$1 # the fetch result
L=0 V= X=
# first unserialize problem report
if [[ ${R[Content-Type]} == 'application/problem+json' ]]; then
print -rn -- "${R[BODY]}" | JSONP.readValue L V
if (( L )); then
typeset -A PROPS
JSON.getVal $L PROPS
for X in ${!PROPS[@]} ; do
if JSON.isObject ${PROPS["$X"]} ; then
R[_RES_"$X"]=${PROPS["$X"]}
else
JSON.getVal ${PROPS["$X"]} V
R[_RES_"$X"]="$V"
fi
done
fi
fi
if (( R[STATUS_CODE] == 403 )); then
X='Server response: The requested operation is currently forbidden.'
# see also Boulder Section 6.5 .. Section 6.6
[[ -n ${R[_RES_detail]} ]] && X+=" ${R[_RES_detail]}."
[[ -n ${R[_RES_instance]} ]] && X+=" See also '${R[_RES_instance]}'."
if [[ -n ${R[_RES_subproblems]} ]] ; then
V=
JSON.toStringPretty ${R[_RES_subproblems]} V
[[ -n $V ]] && X+="\n\tDetails:\n$V"
fi
Log.fatal "$X"
return 0
fi
(( R[STATUS_CODE] == 0 )) && return 1
return ${R[STATUS_CODE]}
}
# Boulder Section Section 7.1, 7.1.3, 7.2, 7.3.6, 7.4
typeset -A DIR07KEYS=( [nonce]='new-nonce' [account]='new-account'
[impl]=7
[order]='new-order' [authz]='new-authz' [revoke]='revoke-cert'
[chkey]='key-change' [tos]='terms-of-service' [website]='website'
[caids]='caa-identities'
[existing]='only-return-existing' [extbind]='external-account-binding'
)
typeset -A DIR09KEYS=( [nonce]='newNonce' [account]='newAccount'
[impl]=9
[order]='newOrder' [authz]='newAuthz' [revoke]='revokeCert'
[chkey]='keyChange' [tos]='termsOfService' [website]='website'
[caids]='caaIdentities' [extacc]='externalAccountRequired'
[existing]='onlyReturnExisting' [extbind]='externalAccountBinding'
)
# 7.1.1
Man.addFunc getDirectory '' '[+NAME?getDirectory - fetch the ACME Directory object (ACME 7.1.1)]
[+DESCRIPTION?Pulls the \bDirectory object\b given via \avnameCFG\a\b[CA-URL]]\b if not already done and augments \avnameCFG\a with the obtained URLs using \bURL-\b{nonce|account|order|authz|revoke|chkey|tos} as related keys. If the response contains a "terms of service" URL it is checked, whether the user already agreed with it. If not, the user gets asked for it. If the user agrees, the file \bc-\aCA\a\b-TOS-\b\aURL_basename\a gets created containing the full URL and \avnameCFG\a\b[TOS]]\b gets set to it as well. If this file already exists, it is assumed, that it got created by this function and thus deduced, that the user already agreed with the CA'"'"'s TOS.]
[+?Another side effect is, that \avnameCFG\a\b[DIR-KEY]]\b gets set to the name of the associative array containing the mapping of certain implementation specific keys to more generic terms used in this script.]
[+?Note that if \avnameCFG\a\b[DIR-KEY]]\b is already set and \aforce\a is not given/empty, this function is a no-op.]
\n\n\avnameCFG\a [\aforce\a]
'
function getDirectory {
typeset -n CFG=$1
[[ -n ${CFG[DIR-KEY]} && -z $2 ]] && return 0
typeset -A RES PROPS PARAMS=( [DUMP]='directory' [FOLLOW]=1 )
typeset T= X= L N URL="${CFG[CA-URL]}"
integer ID
[[ -z ${URL} ]] && Log.fatal 'CA URL to use is not set.' && return 1
(( VERB )) && Log.info 'Getting remote directory ...'
PARAMS[URL]="${URL}"
fetch CFG RES PARAMS || return 2
if (( RES[STATUS_CODE] != 200 )); then
T="Unexpected server response for '${URL}':\n"
T+=$(<${RES[FILE]})
Log.warn "$T"
return 3
fi
if ! JSONP.readValue ID T <${RES[FILE]} ; then
T=$(<${RES[FILE]})
Log.warn "Invalid response from server for '${URL}':\n$T"
return 4
fi
JSON.getVal ${ID} PROPS
if [[ -n ${PROPS[newAccount]} ]]; then
CFG[DIR-KEY]=DIR09KEYS
else
CFG[DIR-KEY]=DIR07KEYS
fi
typeset -n DIRKEY=${CFG[DIR-KEY]}
if [[ -z ${PROPS[${DIRKEY[account]}]} ]]; then
# Boulder Section 7.1
CFG[IS_LE]=1
CFG[API]=1
DIRKEY['account']='new-reg'
DIRKEY['order']='new-cert'
else
CFG[IS_LE]=0
CFG[API]=2
fi
typeset -A REV
for T in ${!DIRKEY[@]} ; do
REV[${DIRKEY[$T]}]="$T"
done
for T in ${!PROPS[@]} ; do
N=${REV[$T]}
X=
if [[ -n $N ]] ; then
JSON.getVal ${PROPS[$T]} X
CFG["URL-$N"]="$X"
elif [[ $T == 'meta' ]]; then
typeset -A M
JSON.getVal ${PROPS[$T]} M
JSON.getVal ${M[${DIRKEY[tos]}]} X
[[ -n $X ]] && CFG[URL-tos]="$X"
fi
done
if [[ -n ${CFG[URL-tos]} ]]; then
T="${CFG[URL-tos]}"
X="${CFG[CFG-DIR]}/c-${CFG[CA]}-TOS_${T##*/}"
if [[ ! -e $X ]]; then
Log.warn '\n\tHave you read the "Terms of Service" document' \
"provided\n\tvia '$T' ?\n"
yorn ny 'I have read the "Terms of Service" document and agree'
if (( $? )); then
Log.fatal 'Without an agreement usage is not allowed.'
return 4
fi
print -- "$T" >"$X"
fi
CFG[TOS]="$X"
fi
return 0
}
# 7.2
Man.addFunc getNonce '' '[+NAME?getNonce - get an ACME nonce (7.2).]
[+DESCRIPTION?Get an ACME server nonce to be able to send new post requests. Automatically calls \bgetDirectory\b(). On success \avnameCFG\a\b[NONCE]]\b gets set to the new nonce obtained. If this field is already set, this functions is a no-op unless \aforce\a is given and contains a non-empty value.]
\n\n\avnameCFG\a [\aforce\a]
'
function getNonce {
typeset -n CFG=$1
typeset -A RES PARAMS=( [METHOD]='HEAD' [DUMP]='nonce' )
typeset X="${CFG[NONCE]}"
[[ -n $X && -z $2 ]] && return 0
getDirectory CFG || return 1
[[ -n ${CFG[NONCE]} && ${CFG[NONCE]} != $X ]] && return 0
(( VERB )) && Log.info 'Getting new nonce ...'
if [[ ${CFG[URL-new-nonce]} ]]; then
PARAMS[URL]="${CFG[URL-nonce]}"
else
# Boulder Section 7.1 - does not support new-nonce
PARAMS[URL]="${CFG[CA-URL]}"
fi
fetch CFG RES PARAMS || return 2
(( RES[STATUS_CODE] != 204 && RES[STATUS_CODE] != 200 )) && \
Log.warn 'Unexpected Nonce response:' \
"${RES[STATUS_CODE]} (${RES[STATUS_TXT]})"
if [[ -z ${RES[Replay-Nonce]} ]]; then
Log.warn 'No new nonce.'
return 3
fi
# fetch puts it already into CFG[NONCE]
return 0
}
# RFC 7515 Appendix C.
Man.addFunc str2base64url '' '[+NAME?str2base64url - base64url encode a string]
[+DESCRIPTION?Encodes the value of \avname\a as described in RFC 4648 "Base 64 Encoding with URL ..." + RFC 7515 "Terminology" and stores on success the encoded string back to \avname\a. If \aunescape\a is given and not empty, escape sequences contained in the string get converted to the corresponding character (printf ...) before its gets converted to base64url (e.g. \n to a byte with a value of 10, \xNN to a byte with a value of NN, etc.). Otherwise the string is taken as is (print -rn ...).]
\n\n\avnameCFG\a \avname\a [\aunescape\a]
'
function str2base64url {
typeset -n CFG=$1 S=$2
[[ -z $S ]] && return 0
typeset X
[[ -n $3 ]] && X=${ printf -- "$S" | ${CFG[OPENSSL]} base64 ; } || \
X=${ print -rn -- "$S" | ${CFG[OPENSSL]} base64 ; }
# the 62nd and 63rd char in the alphabet are '-','_' instead of '+','/'
X=${X//+/-}
X=${X//\//_}
# and trailing pad chars must be stripped
X=${X%%*(=)}
# and without any line breaks (openssl does not create WS or other chars)
X=${X//$'\n'}
S=$X
}
Man.addFunc file2base64url '' '[+NAME?file2base64url - base64url encode a file]
[+DESCRIPTION?Encodes the content of \afile\a as described in RFC 4648 "Base 64 Encoding with URL ..." + RFC 7515 "Terminology" and stores on success the encoded string back to \avname\a.]
\n\n\avnameCFG\a \avname\a \afile\a
'
function file2base64url {
typeset -n CFG=$1 S=$2
typeset F="$3"
[[ -z $F || ! -r $F ]] && Log.fatal "file '$F' is not readable." && \
return 1
S=
if [[ -s $F ]]; then
X=${ ${CFG[OPENSSL]} base64 -in "$F"; }
[[ -z $X ]] && return 2
# code dup from above
X=${X//+/-}
X=${X//\//_}
X=${X%%*(=)}
X=${X//$'\n'}
S=$X
fi
return 0
}
Man.addFunc base64url2str '' '[+NAME?base64url2str - base64url decode a string.]
[+DESCRIPTION?Decodes the base64url encoded value of \avname\a as described in RFC 7515 and stores on success the encoded string back to \avname\a.]
\n\n\avnameCFG\a \avname\a
'
function base64url2str {
typeset -n CFG=$1 B=$2
[[ -z $B ]] && return 0
typeset X=${B//-/+}
X=${X//_/\/}
integer M=${#X}
(( M %= 4 ))
if (( M == 0 )); then
: # no pad chars
elif (( M == 2 )); then
X+='==' # two pad chars
elif (( M == 3 )); then
X+='=' # one pad char
else
return 1 # Illegal base64url string
fi
if [[ -n $3 ]]; then
print -n -- "$X" | "${CFG[OPENSSL]}" base64 -d -A -out "$3"
return $?
fi
T=${ print -n -- "$X" | ${CFG[OPENSSL]} base64 -d -A ; }
[[ $T == $X ]] && return 1 # same as before means error
B="$T"
}
KEY_WARNING='# WARNING:
# Make sure this file can only be read by you and your ACME operators.
# Everyone who has access to this key may manage your account and certificates
# on the related ACME servers!
'
Man.addFunc createPrivateKey '' '[+NAME?createPrivateKey - create a new private key.]
[+DESCRIPTION?Create a new key pair using the given \akey_type\a and store the generated private key (which also contains the public one) in the given \afile\a. The \akey_type\a is expected to have the same format as described in LE_ENV:\bKEY_TYP\b. On success \afile\a gets overwritten w/o notice and chmoded to 0600.]
\n\n\avnameCFG\a \afile\a \akey_type\a
'
function createPrivateKey {
typeset -n CFG=$1
typeset FILE="$2" X="$3" ARGS= TYP X
integer LEN
[[ -z $X ]] && X='P-256'
if [[ ${X:0:2} == 'P-' ]]; then
TYP='EC'
LEN="${X:2}"
if (( LEN != 256 && LEN != 384 && LEN != 521 )); then
Log.fatal "Unsupported EC curve '$X' -" \
"use 'P-256', 'P-512' or 'P-384'."
return 2
fi
ARGS="-algorithm EC -pkeyopt ec_paramgen_curve:$X" \
ARGS+=' -pkeyopt ec_param_enc:named_curve'
elif [[ $X == RSA@(256|384|512)-+([0-9]) ]]; then
TYP='RSA'
LEN="${X:7}"
if (( LEN < 2048 )); then
Log.fatal 'RSA key size < 2048 is unsupported.'
return 3
elif (( LEN > 4096 )); then
Log.warn 'RSA key size > 4096 is not supported by LE servers.'
fi
(( LEN % 8 == 0 )) || Log.warn "RSA key size '$X' is not a" \
'multiple of 8 - might cause problems.'
ARGS+="-algorithm RSA -pkeyopt rsa_keygen_bits:${LEN}" \
ARGS+=' -pkeyopt rsa_keygen_pubexp:65537'
else
Log.fatal "Unsupported private key type '$X'."
return 4
fi
Log.info "Generating private key ($X) ..."
(( VERB )) && Log.info "${CFG[OPENSSL]} genpkey ${ARGS}"
${CFG[OPENSSL]} genpkey ${ARGS} -out ${LE_TMP}/akey || return 5
print -n "${KEY_WARNING}" >"${FILE}" || return 6
cat ${LE_TMP}/akey >>"${FILE}" || return 7
print > ${LE_TMP}/akey
chmod 0600 "${FILE}"
(( VERB )) && Log.info "done (${FILE})." || Log.info 'done.'
}
Man.addFunc getKeyFilename '' '[+NAME?getKeyFilename - get the path of a private key.]
[+DESCRIPTION?Get the path of the file containing the private key for \avnameCFG\a\b[ACCOUNT]]\b or, if \ause_domain\a is given and not empty, for domain \avnameCFG\a\b[DOMAIN_ASCII]]\b. The result gets stored into \avnameRES\a.]
\n\n\avnameCFG\a \avnameRES\a [\ause_domain\a]
'
function getKeyFilename {
typeset -n CFG=$1 NAME=$2
if [[ -n $3 ]]; then
if [[ -z ${CFG[DOMAIN_ASCII]} ]]; then
Log.fatal 'Internal error: CFG[DOMAIN_ASCII] is not set.'
return 1
fi
NAME="${CFG[CFG-DIR]}/${CFG[CA]}/r-${CFG[DOMAIN_ASCII]}.key"
else
if [[ -z ${CFG[ACCOUNT]} ]]; then
Log.fatal 'Internal error: CFG[ACCOUNT] is not set.'
return 1
fi
NAME="${CFG[CFG-DIR]}/a-${CFG[ACCOUNT]}.key"
fi
return 0
}
Man.addFunc getPrivateKey '' '[+NAME?getPrivateKey - get the private key for an account or domain.]
[+DESCRIPTION?Read in the private key for the account currently in use (\avnameCFG\a[ACCOUNT]]) or domain \avnameCFG\a[DOMAIN_ASCII]] if \aSUFFIX\a is given and is not empty. If no such key exists, a key gets created according to the type set via \avnameCFG\a fields \bKEY_TYP_ACC\b if \aSUFFIX\a is not given or empty, \bKEY_TYP_DOM\b otherwise (both fallback to \bKEY_TYP\b if unset). It gets stored in the config directory in use as well as into \avnameCFG\a[KEY\aSUFFIX\a]] (the PEM representation), \avnameCFG\a[KEY-DESC\aSUFFIX\a]] (the human readable textual representation). On success \avnameCFG\a[KEY-FILE\aSUFFIX\a]] gets also set to the path of the PEM file containing the key.]
[+?If \avnameCFG\a[KEY\aSUFFIX\a]] is already set, this function is a no-op.]
[+SEE ALSO?\bLE_ENV\b, \binvalidatePrivKeys()\b.]
\n\n\avnameCFG\a [\aSUFFIX\a]
'
function getPrivateKey {
typeset -n CFG=$1
typeset KEY SFX="$2" FILE
[[ -n ${CFG[KEY${SFX}]} ]] && return 0
getKeyFilename CFG FILE ${SFX} || return 1
if [[ -e ${FILE} ]]; then
KEY=${ ${CFG[OPENSSL]} pkey -noout -text -in ${FILE} ; }
if [[ -z ${KEY} || ${KEY:0:12} != 'Private-Key:' ]]; then
Log.fatal "Unable to read the private key from '${FILE}'."
return 2
fi
(( VERB )) && Log.info "Using private key '${FILE}'."
else
[[ -z ${SFX} ]] && KEY='ACC' || KEY='DOM'
createPrivateKey CFG "${FILE}" "${CFG[KEY_TYP_${KEY}]}" || return 3
KEY=${ ${CFG[OPENSSL]} pkey -noout -text -in ${FILE} ; }
fi
CFG[KEY-DESC${SFX}]="${KEY}"
CFG[KEY${SFX}]=${ ${CFG[OPENSSL]} pkey -in "${FILE}" ; } # avoid bloat
CFG[KEY-FILE${SFX}]="${FILE}"
}
Man.addFunc invalidatePrivKeys '' '[+NAME?invalidatePrivKeys - drop the private key for an account or domain.]
[+DESCRIPTION?Just drop all CFG[KEY*\aSUFFIX\a]] keys previously set via \bgetPrivateKey()\b.]
[+SEE ALSO?\bLE_ENV\b, \bgetPrivateKey()\b.]
\n\n\avnameCFG\a [\aSUFFIX\a]
'
function invalidatePrivKeys {
typeset -n CFG=$1
typeset SFX="$2"
CFG[KEY${SFX}]=
CFG[KEY-DESC${SFX}]=
CFG[KEY-FILE${SFX}]=
}
Man.addFunc getPublicKey '' '[+NAME?getPublicKey - get the public key for an account or domain.]
[+DESCRIPTION?Uses \bgetPrivateKey()\b to get the private key for the account currently in use (\avnameCFG\a[ACCOUNT]]) if \aSUFFIX\a was not specified or is empty, or for domain \avnameCFG\a\b[DOMAIN_ASCII]]\b. From this key the public portion gets extracted and stored into \avnameCFG\a[KEY-PUB\aSUFFIX\a]].]
[+?If \avnameCFG\a[KEY-PUB\aSUFFIX\a]] is already set, this function is a no-op.]
\n\n\avnameCFG\a [\aSUFFIX\a]
'
function getPublicKey {
typeset -n CFG=$1
typeset SFX="$2"
[[ -n ${CFG[KEY-PUB${SFX}]} ]] && return 0
getPrivateKey CFG "${SFX}" || return $?
(( VERB )) && Log.info 'Extracting public key ...'
KEY=${ print "${CFG[KEY${SFX}]}" | ${CFG[OPENSSL]} pkey -pubout ; }
[[ -z ${KEY} ]] && return 11
CFG[KEY-PUB${SFX}]="${KEY}"
(( VERB )) && Log.info 'done.'
return 0
}
Man.addFunc hexdump2str '' '[+NAME?hexdump2str - convert a hexdump to an escape sequence.]
[+DESCRIPTION?Converts the content given by \avname\a to an appropriate escaped string, which can be finally converted to its binary representation using the internal \bprint\b or \bprintf\b without option \b-r\b. The content of \avname\a is expected to contain hex bytes (0..2 [0-9a-fA-F]]) separated by a colon (\b:\b), only - e.g. ab:09:cd. Otherwise \avname\a stays as is.]
\n\n\avname\a
'
function hexdump2str {
typeset -n S=$1
typeset B= X
for X in ${S//:/ } ; do
[[ $X == {1,2}[0-9a-fA-F] ]] && return 1
B+="\x$X"
done
S="$B"
}
Man.addFunc checkAccountUrl '' '[+NAME?checkAccountUrl - test and read in an account url file.]
[+DESCRIPTION?Read in the \avnameCFG\a\b[ACCOUNT-URL-FILE]]\b file and store its content to \avnameCFG\a\b[ACCOUNT-URL]]\b. If the file does not exist or is empty, an error message gets emitted. If it contains invalid contents, i.e. not a valid or unrelated URL, the consumer of this value needs to deal with it.]
\n\n\avnameCFG\a
'
function checkAccountUrl {
typeset -n CFG=$1
typeset FILE="${CFG[ACCOUNT-URL-FILE]}"
[[ -z ${CFG[ACCOUNT-URL]} && -e ${FILE} ]] && CFG[ACCOUNT-URL]=$(<"${FILE}")
if [[ -z ${CFG[ACCOUNT-URL]} ]]; then
Log.fatal \
"\n\tFile '${ACC_FILE}' cannot be read or is empty!" \
"\n\tThis file is required for further ACME operations. You may re-create it" \
"\n\tby running the 'register' command (again)."
return 1
fi
return 0
}
Man.addFunc prepareJWS_PH '' '[+NAME?prepareJWS_PH - prepare JWS protected headers.]
[+DESCRIPTION?Prepares the two protected headers for JWS objects: one containing the \bjwk\b property and the other, which contains the \bkid\b property instead. They can be accessed via \bJSON\b and their JSON component IDs: \avnameCFG\a\b[JWS-PH-JWK]]\b and \avnameCFG\a\b[JWS-PH-KID]]\b. For convenience/direct access the JSON component IDs of the related fields gets stored into \avnameCFG\a as \bPH-ALG\b, \bPH-JWK\b, \bPH-NONCE\b, \bPH-URL\b as well. The 2 last ones need to be updated for each request and signature calculation should correspond to the 1st one.]
[+?The \bPH-JWK\b related object will contain only the required properties and thus can be used as is for JWK thumbprint generation (see RFC 7638).]
[+?It is required, that \avnameCFG\a\b[KEY-DESC]]\b contains the text form (openssl output) at least of the public key associated with the private key in use.]
[+?If \avnameCFG\a\b[JWS-PH-JWK]]\b and \avnameCFG\a\b[JWS-PH-KID]]\b are already set and not empty, this function is a no-op.]