Repository navigation
Expand file tree
/
Copy pathinstall.sh
More file actions
2934 lines (2565 loc) · 122 KB
/
Copy pathinstall.sh
File metadata and controls
2934 lines (2565 loc) · 122 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
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
#!/usr/bin/env bash
# shellcheck shell=bash
# - - - - - - - - - - - - - - - - - - - - - - - - -
##@Version : 202610111835-git
# @@Author : Jason Hempstead
# @@Contact : git-admin@casjaysdev.pro
# @@License : MIT or LICENSE.md
# @@ReadME : install.sh --help
# @@Copyright : Copyright: (c) 2026 Jason Hempstead, Casjays Developments
# @@Created : Thursday, May 22, 2026 14:29 UTC
# @@File : install.sh
# @@Description : Full FreeIPA + Keycloak SSO bootstrap script, distro-agnostic
# @@Changelog : Reverse-proxy Keycloak under /kc via Apache mod_rewrite
# @@TODO : None
# @@Other :
# @@Resource : https://www.freeipa.org/page/Documentation
# @@Terminal App : yes
# @@sudo/root : yes
# @@Template : shell/bash
# - - - - - - - - - - - - - - - - - - - - - - - - -
# shellcheck disable=SC1001,SC1003,SC2001,SC2003,SC2016,SC2031,SC2034,SC2090,SC2115,SC2120,SC2155,SC2199,SC2229,SC2317,SC2329
# - - - - - - - - - - - - - - - - - - - - - - - - -
VERSION="202610111835-git"
# - - - - - - - - - - - - - - - - - - - - - - - - -
APPNAME="${0##*/}"
RUN_USER="${SUDO_USER:-$USER}"
SET_UID="${UID}"
SCRIPT_SRC_DIR="${BASH_SOURCE%/*}"
# - - - - - - - - - - - - - - - - - - - - - - - - -
set -euo pipefail
# - - - - - - - - - - - - - - - - - - - - - - - - -
# Color output — suppressed when NO_COLOR is set
INSTALL_COLOR_RED='\e[0;31m'
INSTALL_COLOR_GREEN='\e[0;32m'
INSTALL_COLOR_YELLOW='\e[1;33m'
INSTALL_COLOR_BLUE='\e[0;34m'
INSTALL_COLOR_RESET='\e[0m'
if [[ -n "${NO_COLOR:-}" ]]; then
INSTALL_COLOR_RED=""
INSTALL_COLOR_GREEN=""
INSTALL_COLOR_YELLOW=""
INSTALL_COLOR_BLUE=""
INSTALL_COLOR_RESET=""
fi
# - - - - - - - - - - - - - - - - - - - - - - - - -
# Global state — populated by sub-functions, consumed by later stages
INSTALL_DISTRO=""
INSTALL_DISTRO_FAMILY=""
INSTALL_DISTRO_VERSION=""
FREEIPA_FQDN="${FREEIPA_FQDN:-}"
FREEIPA_DOMAIN="${FREEIPA_DOMAIN:-}"
FREEIPA_REALM="${FREEIPA_REALM:-}"
FREEIPA_CRED_FILE="${FREEIPA_CRED_FILE:-/etc/ipa/creds.conf}"
INSTALL_LEGACY_CRED_FILE="/root/.freeipa-install.conf"
INSTALL_DNS="false"
INSTALL_NO_NTP="false"
INSTALL_USE_AUTO_FORWARDERS="false"
INSTALL_DNS_FORWARDERS=""
INSTALL_CONFIGURE_REVERSE_ZONE="false"
INSTALL_USE_LETSENCRYPT="false"
INSTALL_USE_FREEIPA_CA="false"
INSTALL_USE_SELFSIGNED="false"
INSTALL_CERT_PATH=""
INSTALL_KEY_PATH=""
INSTALL_CHAIN_PATH=""
INSTALL_FULLCHAIN_PATH=""
INSTALL_ADMIN_PASSWORD=""
INSTALL_DM_PASSWORD=""
FREEIPA_DEBUG="${FREEIPA_DEBUG:-0}"
# - - - - - - - - - - - - - - - - - - - - - - - - -
# Keycloak / LDAP globals
INSTALL_LDAP_BASE_DN=""
FREEIPA_KEYCLOAK_PORT="${FREEIPA_KEYCLOAK_PORT:-}"
FREEIPA_KEYCLOAK_REALM="${FREEIPA_KEYCLOAK_REALM:-}"
INSTALL_KEYCLOAK_ADMIN_PASSWORD=""
INSTALL_KEYCLOAK_LDAP_PASSWORD=""
INSTALL_KEYCLOAK_DB_PASSWORD=""
FREEIPA_COMPOSE_DIR="${FREEIPA_COMPOSE_DIR:-/opt/keycloak}"
FREEIPA_KEYCLOAK_CONFIG_DIR="${FREEIPA_KEYCLOAK_CONFIG_DIR:-/etc/keycloak}"
INSTALL_LDIF_TMP=""
INSTALL_CCACHE_DIR=""
# - - - - - - - - - - - - - - - - - - - - - - - - -
# Mail (Postfix/Dovecot) globals
FREEIPA_MAIL_DOMAIN="${FREEIPA_MAIL_DOMAIN:-}"
FREEIPA_MAIL_BASE_DIR="${FREEIPA_MAIL_BASE_DIR:-/var/mail/vhosts}"
FREEIPA_MAIL_VUSER="${FREEIPA_MAIL_VUSER:-vmail}"
FREEIPA_MAIL_VID="${FREEIPA_MAIL_VID:-5000}"
INSTALL_MAIL_LDAP_PASSWORD=""
INSTALL_MAIL_CERT_PATH=""
INSTALL_MAIL_KEY_PATH=""
FREEIPA_MAIL_LOCAL_FALLBACK="${FREEIPA_MAIL_LOCAL_FALLBACK:-true}"
FREEIPA_MAIL_KEYCLOAK_AUTH="${FREEIPA_MAIL_KEYCLOAK_AUTH:-true}"
FREEIPA_MAIL_KEYCLOAK_CLIENT_ID="${FREEIPA_MAIL_KEYCLOAK_CLIENT_ID:-dovecot-mail}"
INSTALL_MAIL_KEYCLOAK_SECRET=""
# - - - - - - - - - - - - - - - - - - - - - - - - -
# ─── Standard Utility Functions ──────────────────────────────────────────────
__primary_ip() {
# `hostname -I`'s first token is unreliable once Docker is installed (this
# script installs it as a prerequisite): docker0/br-* bridge addresses can
# sort before the real outbound interface. Ask the routing table which
# source address it would actually use to reach the internet instead.
local ip
ip="$(\ip -4 route get 1.1.1.1 2>/dev/null | \awk '{for (i = 1; i <= NF; i++) if ($i == "src") print $(i + 1)}')"
if [[ -z "${ip}" ]]; then
ip="$(\hostname -I 2>/dev/null | \awk '{print $1}' || true)"
fi
printf '%s' "${ip}"
}
__random_password() {
local length="${1:-32}"
# tr reads /dev/urandom infinitely; head closes the pipe after N bytes, sending
# SIGPIPE to tr. Run tr in a subshell and absorb the SIGPIPE with || true so
# set -o pipefail does not propagate tr's exit 141 to the caller.
# Excludes '#': Dovecot's config parser treats an unescaped '#' as a
# comment start even mid-line (confirmed breaking dovecot-ldap.conf.ext's
# dnpass), silently truncating any password that contains one.
( \tr -dc 'A-Za-z0-9!@$%^&*_+-' </dev/urandom 2>/dev/null || true ) | \head -c "${length}"
printf '\n'
}
__random_port() {
local port
while :; do
port=$(( 62000 + RANDOM % 3000 ))
if ! \ss -tlnp 2>/dev/null | \grep -q -- ":${port} "; then
printf '%s\n' "${port}"
return 0
fi
done
}
__save_credential() {
local file="${1:?Usage: __save_credential <file> <key> <value>}"
local key="${2:?}"
local value="${3:?}"
local _dir="${file%/*}"
[[ "${_dir}" == "${file}" ]] && _dir="."
\mkdir -p "${_dir}"
if [[ -f "${file}" ]] && \grep -q -- "^${key}=" "${file}"; then
local tmp
tmp="$(\mktemp)"
\grep -v -- "^${key}=" "${file}" > "${tmp}"
printf '%s=%s\n' "${key}" "${value}" >> "${tmp}"
\mv "${tmp}" "${file}"
else
printf '%s=%s\n' "${key}" "${value}" >> "${file}"
printf 'Generated %s (saved to %s)\n' "${key}" "${file}"
fi
\chmod 600 "${file}"
if [[ "${EUID:-$(id -u)}" -eq 0 ]]; then
\chown root:root "${file}"
else
\chown "${RUN_USER}:${RUN_USER}" "${file}"
fi
}
__load_credential() {
local file="${1:?Usage: __load_credential <file> <key>}"
local key="${2:?}"
[[ -f "${file}" ]] || return 1
local val
val="$(\grep -- "^${key}=" "${file}" | \tail -n1 | \cut -d= -f2- || true)"
[[ -n "${val}" ]] || return 1
printf '%s\n' "${val}"
}
# Older install.sh releases stored credentials at INSTALL_LEGACY_CRED_FILE
# (a dotfile in /root). On upgrade, move it to the new FREEIPA_CRED_FILE
# location once, so an upgraded script keeps reading the same generated
# passwords/ports a previous run already committed to, instead of treating
# this as a first-ever install and regenerating everything.
__migrate_legacy_credential_file() {
local new_file="${1:?Usage: __migrate_legacy_credential_file <new_file>}"
[[ -f "${INSTALL_LEGACY_CRED_FILE}" ]] || return 0
[[ -f "${new_file}" ]] && return 0
local dir="${new_file%/*}"
[[ "${dir}" == "${new_file}" ]] && dir="."
\mkdir -p "${dir}"
\mv "${INSTALL_LEGACY_CRED_FILE}" "${new_file}"
\chmod 600 "${new_file}"
\chown root:root "${new_file}"
__log "Migrated credentials from ${INSTALL_LEGACY_CRED_FILE} to ${new_file}"
}
# Older install.sh releases persisted the Keycloak port under its old key.
# Rename it in place so an upgraded script keeps the port the container uses.
__migrate_legacy_credential_keys() {
local file="${1:?Usage: __migrate_legacy_credential_keys <file>}"
[[ -f "${file}" ]] || return 0
if \grep -q -- '^INSTALL_KEYCLOAK_PORT=' "${file}" && ! \grep -q -- '^FREEIPA_KEYCLOAK_PORT=' "${file}"; then
\sed -i 's/^INSTALL_KEYCLOAK_PORT=/FREEIPA_KEYCLOAK_PORT=/' "${file}"
fi
\sed -i '/^\(INSTALL_\)\?FREEIPA_PORT=/d' "${file}"
}
__determine_domain_name() {
local domain
domain="$(\hostname -d 2>/dev/null)"
if [[ -n "${domain}" ]]; then
printf '%s\n' "${domain}"
return 0
fi
local fqdn
fqdn="$(\hostname -f 2>/dev/null)"
if [[ -n "${fqdn}" && "${fqdn}" == *.* ]]; then
printf '%s\n' "${fqdn#*.}"
return 0
fi
return 1
}
__determine_hostname_name() {
local fqdn
fqdn="$(\hostname -f 2>/dev/null)"
if [[ -n "${fqdn}" ]]; then
printf '%s\n' "${fqdn}"
return 0
fi
return 1
}
# - - - - - - - - - - - - - - - - - - - - - - - - -
# ─── Logging ─────────────────────────────────────────────────────────────────
__log() {
printf "${INSTALL_COLOR_GREEN}[%s] %s${INSTALL_COLOR_RESET}\n" "$(\date +'%Y-%m-%d %H:%M:%S')" "$1"
}
__warn() {
printf "${INSTALL_COLOR_YELLOW}[WARN] %s${INSTALL_COLOR_RESET}\n" "$1" >&2
}
__error() {
printf "${INSTALL_COLOR_RED}[ERROR] %s${INSTALL_COLOR_RESET}\n" "$1" >&2
# Messages that end in "(exit NN)" promise that status; honor it
if [[ "$1" =~ \(exit\ ([0-9]+)\)$ ]]; then
exit "${BASH_REMATCH[1]}"
fi
exit 1
}
__debug() {
if [[ "${FREEIPA_DEBUG}" -eq 1 ]]; then
printf "${INSTALL_COLOR_BLUE}[DEBUG] %s${INSTALL_COLOR_RESET}\n" "$1" >&2
fi
}
# - - - - - - - - - - - - - - - - - - - - - - - - -
# ─── Help / Version ──────────────────────────────────────────────────────────
__help() {
printf 'Usage: %s [OPTIONS]\n\n' "${APPNAME}"
printf 'Full FreeIPA + Keycloak SSO bootstrap script — distro-agnostic.\n'
printf 'Installs FreeIPA with DNS, NTP, optional Let'"'"'s Encrypt,\n'
printf 'and deploys Keycloak SSO federated against FreeIPA LDAP.\n\n'
printf 'Options:\n'
printf ' -h, --help Show this help and exit\n'
printf ' -v, --version Show version and exit\n'
printf ' --debug Enable debug output\n'
printf ' --no-ntp Skip time sync (needed in containers without CAP_SYS_TIME)\n'
printf ' --color Force color output\n'
printf ' --no-color Disable color output\n\n'
printf 'Environment:\n'
printf ' FREEIPA_FQDN Override auto-detected hostname\n'
printf ' FREEIPA_DOMAIN Override auto-detected domain\n'
printf ' FREEIPA_REALM Override auto-detected Kerberos realm\n'
printf ' FREEIPA_CRED_FILE Credentials file path (default: /etc/ipa/creds.conf)\n'
printf ' FREEIPA_DEBUG Enable debug output when set to 1 (same as --debug)\n'
printf ' FREEIPA_KEYCLOAK_PORT Override Keycloak port (default: random in 62000-64999)\n'
printf ' FREEIPA_KEYCLOAK_REALM Override Keycloak realm (default: domain name)\n'
printf ' FREEIPA_COMPOSE_DIR Docker Compose directory (default: /opt/keycloak)\n'
printf ' FREEIPA_KEYCLOAK_CONFIG_DIR Keycloak config directory (default: /etc/keycloak)\n'
printf ' FREEIPA_MAIL_DOMAIN Mail domain for virtual mailboxes (default: FREEIPA_DOMAIN)\n'
printf ' FREEIPA_MAIL_BASE_DIR Maildir storage root (default: /var/mail/vhosts)\n'
printf ' FREEIPA_MAIL_VUSER System user/group owning mailbox storage (default: vmail)\n'
printf ' FREEIPA_MAIL_VID UID/GID for FREEIPA_MAIL_VUSER (default: 5000)\n'
printf ' FREEIPA_MAIL_LOCAL_FALLBACK Add a Unix/PAM passdb fallback (default: true)\n'
printf ' FREEIPA_MAIL_KEYCLOAK_AUTH Add a Keycloak OAUTHBEARER passdb (default: true)\n'
printf ' FREEIPA_MAIL_KEYCLOAK_CLIENT_ID Keycloak client ID for token introspection (default: dovecot-mail)\n'
printf ' NO_COLOR Disable color output when set\n\n'
printf 'Notes:\n'
printf ' FreeIPA uses Apache on ports 80 and 443; HTTP requests redirect to HTTPS.\n'
printf ' An installed nginx service is stopped, disabled, and masked.\n'
}
__version() {
printf '%s version %s\n' "${APPNAME}" "${VERSION}"
}
# - - - - - - - - - - - - - - - - - - - - - - - - -
# ─── Cleanup ─────────────────────────────────────────────────────────────────
__cleanup() {
[[ -n "${INSTALL_LDIF_TMP}" && -f "${INSTALL_LDIF_TMP}" ]] && \rm -f "${INSTALL_LDIF_TMP}" 2>/dev/null || true
[[ -n "${INSTALL_CCACHE_DIR:-}" && -d "${INSTALL_CCACHE_DIR}" ]] && \rm -rf "${INSTALL_CCACHE_DIR}" 2>/dev/null || true
}
# Diagnostic only — set -e already aborts on the failing command; this just
# names the line/command so a failure deep in a 10-20 minute run is legible.
__on_err() {
local ec=$?
__warn "Command failed (exit ${ec}) at line ${BASH_LINENO[0]:-?}: ${BASH_COMMAND}"
}
# Re-raise after cleanup so callers still see 128+N, not 0.
__on_signal() {
local sig="${1:?Usage: __on_signal <INT|TERM>}"
case "${sig}" in
INT) __warn "Interrupted (SIGINT)."; exit 130 ;;
TERM) __warn "Terminated (SIGTERM)."; exit 143 ;;
esac
}
trap '__cleanup' EXIT
trap '__on_err' ERR
trap '__on_signal INT' INT
trap '__on_signal TERM' TERM
# Service restarts under Incus/systemd can emit a spurious SIGHUP during
# finalization. Ignore it so idempotent reruns complete instead of aborting
# late in the install.
trap ':' HUP
# - - - - - - - - - - - - - - - - - - - - - - - - -
# ─── Pre-flight checks ───────────────────────────────────────────────────────
__check_root() {
if [[ "$(\id -u)" -ne 0 ]]; then
__error "This script must be run as root (exit 77)"
exit 77
fi
}
# FreeIPA's own definition of "a server is configured here"
# (ipalib.facts.is_ipa_configured). /etc/ipa/default.conf alone proves nothing:
# an enrolled client, or the leftovers of an aborted install, have it too.
__freeipa_server_configured() {
if \command -v python3 >/dev/null 2>&1 && python3 -c 'import ipalib.facts' >/dev/null 2>&1; then
python3 -c 'import sys; from ipalib import facts; sys.exit(0 if facts.is_ipa_configured() else 1)' >/dev/null 2>&1
return
fi
[[ -s /var/lib/ipa/sysrestore/sysrestore.state ]]
}
# Refuse, before anything on the host is changed, to run on a machine that has
# IPA client/stale configuration but no configured FreeIPA server.
__preflight_freeipa_state() {
case "${INSTALL_DISTRO_FAMILY}" in
*debian*|*ubuntu*|*suse*)
__error "FreeIPA server install is not supported on ${INSTALL_DISTRO}: no freeipa-server package exists upstream for Debian/Ubuntu/openSUSE. Nothing was changed. Use RHEL-family or Fedora for the server role; client.sh supports this distro (exit 79)"
;;
esac
__freeipa_server_configured && return 0
local foreign
if foreign="$(__httpd_foreign_ssl_vhost)"; then
__error "An HTTPS virtual host on port 443 is already defined in ${foreign}; FreeIPA needs port 443 for its web UI. Nothing was changed. Move that virtual host to another port (for example 8443) or disable it, then re-run (exit 81)"
fi
[[ -f /etc/ipa/default.conf ]] || return 0
# An aborted ipa-server-install leaves server directories behind (even after its client part was configured);
# a plain enrolled client has only the client sysrestore state
local has_server_remnants=0 d
for d in /etc/dirsrv/slapd-* /var/lib/pki/pki-tomcat /etc/pki/pki-tomcat /var/lib/ipa/sysrestore/sysrestore.state; do
[[ -e "${d}" ]] && has_server_remnants=1
done
if ! \command -v ipa-server-install >/dev/null 2>&1 \
|| { [[ -f /var/lib/ipa-client/sysrestore/sysrestore.state ]] && [[ "${has_server_remnants}" -eq 0 ]]; }; then
__error "/etc/ipa/default.conf exists but no FreeIPA server is configured here (this host is an IPA client or has foreign IPA state). Nothing was changed. Run 'ipa-client-install --uninstall' or remove the stale configuration, then re-run (exit 78)"
fi
# What remains is an aborted ipa-server-install; clear it so the install can be re-run
__warn "Found remnants of an aborted FreeIPA server install; removing them with ipa-server-install --uninstall"
\ipa-server-install --uninstall --unattended \
|| __error "Could not clean up the aborted FreeIPA install; see /var/log/ipaserver-uninstall.log (exit 78)"
# A Dogtag Tomcat instance can be started by the failed installation after an
# earlier uninstall disabled its unit. Stop that exact FreeIPA service before
# the CA port preflight; unrelated services on 8443 remain untouched.
local pki_service="pki-tomcatd@pki-tomcat.service"
if \command -v systemctl >/dev/null 2>&1 && \systemctl is-active --quiet "${pki_service}"; then
__warn "Stopping leftover FreeIPA Dogtag service ${pki_service}"
\systemctl stop "${pki_service}" \
|| __error "Could not stop leftover FreeIPA Dogtag service ${pki_service} (exit 78)"
fi
\rm -f /etc/ipa/default.conf
__log "Aborted FreeIPA install cleaned up"
}
__check_requirements() {
__log "Checking system requirements..."
local mem_kb mem_gb disk_avail disk_gb
mem_kb="$(\awk '/MemTotal/{print $2}' /proc/meminfo || true)"
mem_gb=$(( mem_kb / 1024 / 1024 ))
if [[ "${mem_gb}" -lt 2 ]]; then
__error "System has less than 2 GB RAM (${mem_gb} GB). FreeIPA requires at least 2 GB."
elif [[ "${mem_gb}" -lt 4 ]]; then
__warn "System has less than 4 GB RAM (${mem_gb} GB). 4 GB+ recommended for full features."
fi
disk_avail="$(\df / | \tail -1 | \awk '{print $4}' || true)"
disk_gb=$(( disk_avail / 1024 / 1024 ))
if [[ "${disk_gb}" -lt 10 ]]; then
__warn "Less than 10 GB disk space available (${disk_gb} GB). Consider freeing up space."
fi
if [[ "${FREEIPA_FQDN}" == "localhost" || "${FREEIPA_FQDN}" == "localhost.localdomain" ]]; then
__error "Hostname is set to localhost. Configure a proper FQDN first."
fi
__log "System requirements check passed"
}
# - - - - - - - - - - - - - - - - - - - - - - - - -
# ─── Distro detection ────────────────────────────────────────────────────────
__detect_distro() {
if [[ -f /etc/os-release ]]; then
# shellcheck source=/dev/null
. /etc/os-release
INSTALL_DISTRO="${ID:-unknown}"
INSTALL_DISTRO_FAMILY="${ID_LIKE:-}"
INSTALL_DISTRO_VERSION="${VERSION_ID:-unknown}"
elif [[ -f /etc/redhat-release ]]; then
INSTALL_DISTRO="rhel"
INSTALL_DISTRO_FAMILY="rhel fedora"
INSTALL_DISTRO_VERSION="unknown"
elif [[ -f /etc/debian_version ]]; then
INSTALL_DISTRO="debian"
INSTALL_DISTRO_FAMILY="debian"
INSTALL_DISTRO_VERSION="unknown"
else
__error "Cannot detect distribution"
fi
# Base distros (Fedora, Debian) set no ID_LIKE at all — only derivatives
# (Alma/Rocky/CentOS, Ubuntu, openSUSE Leap/Tumbleweed) do. Fall back to
# mapping ID itself so package-manager case statements downstream still
# match instead of hitting "Unsupported distribution family: unknown".
if [[ -z "${INSTALL_DISTRO_FAMILY}" ]]; then
case "${INSTALL_DISTRO}" in
fedora) INSTALL_DISTRO_FAMILY="fedora" ;;
rhel|centos|almalinux|rocky) INSTALL_DISTRO_FAMILY="rhel fedora" ;;
debian) INSTALL_DISTRO_FAMILY="debian" ;;
opensuse*|sles) INSTALL_DISTRO_FAMILY="suse" ;;
*) INSTALL_DISTRO_FAMILY="unknown" ;;
esac
fi
__log "Detected distribution: ${INSTALL_DISTRO} ${INSTALL_DISTRO_VERSION} (family: ${INSTALL_DISTRO_FAMILY})"
}
# - - - - - - - - - - - - - - - - - - - - - - - - -
# ─── Domain / hostname detection ─────────────────────────────────────────────
__detect_domain() {
FREEIPA_FQDN="${FREEIPA_FQDN:-$(__determine_hostname_name 2>/dev/null || \hostname)}"
if [[ "${FREEIPA_FQDN}" == *.* ]]; then
# Extract domain from FQDN (everything after first label)
FREEIPA_DOMAIN="${FREEIPA_DOMAIN:-${FREEIPA_FQDN#*.}}"
FREEIPA_REALM="${FREEIPA_REALM:-${FREEIPA_DOMAIN^^}}"
# For complex domains (>2 labels), note the primary domain
local domain_parts
domain_parts="${FREEIPA_DOMAIN//[^.]}"
if [[ ${#domain_parts} -gt 1 ]]; then
local primary_domain
primary_domain="${FREEIPA_DOMAIN##*.}"
primary_domain="${FREEIPA_DOMAIN%.*}.${primary_domain}"
__log "Complex domain detected; primary domain: ${primary_domain}"
fi
elif [[ -n "${FREEIPA_DOMAIN}" ]]; then
# Hostname has no domain but the caller already provided one
FREEIPA_REALM="${FREEIPA_REALM:-${FREEIPA_DOMAIN^^}}"
FREEIPA_FQDN="${FREEIPA_FQDN}.${FREEIPA_DOMAIN}"
else
if [[ -t 0 ]]; then
__warn "No domain found in hostname. Enter domain manually:"
printf 'Enter domain (e.g., example.com): '
read -r FREEIPA_DOMAIN
else
__error "Non-interactive mode and no domain in hostname. Set FREEIPA_DOMAIN env var."
fi
FREEIPA_REALM="${FREEIPA_DOMAIN^^}"
FREEIPA_FQDN="${FREEIPA_FQDN}.${FREEIPA_DOMAIN}"
fi
# DNS/LDAP hostnames and domains are lowercase by convention (and Kerberos
# realms uppercase) regardless of how the caller cased an env override
FREEIPA_FQDN="${FREEIPA_FQDN,,}"
FREEIPA_DOMAIN="${FREEIPA_DOMAIN,,}"
FREEIPA_REALM="${FREEIPA_REALM^^}"
__log "Using hostname: ${FREEIPA_FQDN}"
__log "Using domain: ${FREEIPA_DOMAIN}"
__log "Using realm: ${FREEIPA_REALM}"
}
# - - - - - - - - - - - - - - - - - - - - - - - - -
# ─── Package installation ────────────────────────────────────────────────────
__install_packages() {
local pkg_mgr
case "${INSTALL_DISTRO_FAMILY}" in
*rhel*|*centos*)
if \command -v dnf >/dev/null 2>&1; then
pkg_mgr="dnf"
else
pkg_mgr="yum"
fi
__log "Installing FreeIPA packages via ${pkg_mgr}..."
"${pkg_mgr}" update -y
__install_ipa_packages "${pkg_mgr}" ipa-server ipa-server-dns ipa-server-trust-ad bind-utils chrony openldap-clients
;;
# Fedora renamed the ipa-* packages to freeipa-* (RHEL/CentOS/Alma/Rocky
# kept the ipa-* names) — matched after *rhel*/*centos* since a real
# RHEL-like's family string ("rhel centos fedora") also contains "fedora".
*fedora*)
if \command -v dnf >/dev/null 2>&1; then
pkg_mgr="dnf"
else
pkg_mgr="yum"
fi
__log "Installing FreeIPA packages via ${pkg_mgr}..."
"${pkg_mgr}" update -y
__install_ipa_packages "${pkg_mgr}" freeipa-server freeipa-server-dns freeipa-server-trust-ad bind-utils chrony openldap-clients
;;
# No freeipa-server package exists for Debian/Ubuntu/openSUSE: verified
# empirically (Docker, 2026-08) — Debian's own experimental repo has no
# installable candidate, Ubuntu never shipped one (blocked upstream by a
# bind-dyndb-ldap/bind9 packaging conflict) and its only PPA has been dead
# since 2014, and openSUSE's security:idm OBS project ships freeipa-client
# only. The FreeIPA server role is RHEL-family and Fedora only; client.sh
# remains cross-distro and is unaffected.
*debian*|*ubuntu*|*suse*)
local unsupported_msg
unsupported_msg="FreeIPA server install is not supported on ${INSTALL_DISTRO}"
unsupported_msg+=" — no working freeipa-server package exists upstream for"
unsupported_msg+=" Debian/Ubuntu/openSUSE. Use RHEL-family"
unsupported_msg+=" (RHEL/CentOS/AlmaLinux/Rocky) or Fedora for the server"
unsupported_msg+=" role; client.sh supports this distro."
__error "${unsupported_msg}"
;;
*)
__error "Unsupported distribution family: ${INSTALL_DISTRO_FAMILY}"
;;
esac
}
# - - - - - - - - - - - - - - - - - - - - - - - - -
# ─── /etc/hosts ──────────────────────────────────────────────────────────────
__configure_hosts() {
__log "Configuring /etc/hosts..."
local primary_ip short_hostname
primary_ip="$(__primary_ip)"
if [[ -z "${primary_ip}" ]]; then
__warn "Could not detect primary IP address; falling back to 127.0.0.1"
primary_ip="127.0.0.1"
fi
__log "Using IP address: ${primary_ip}"
# Remove stale aliases for the primary IP so reverse lookups return the IPA hostname.
local escaped_ip="${primary_ip//./\\.}"
\sed -i -E "/^[[:space:]]*${escaped_ip}[[:space:]]/d" /etc/hosts
# Remove any existing entries for this FQDN then re-add with correct IP
\sed -i "/${FREEIPA_FQDN}/d" /etc/hosts
short_hostname="${FREEIPA_FQDN%%.*}"
printf '%s %s %s\n' "${primary_ip}" "${FREEIPA_FQDN}" "${short_hostname}" >> /etc/hosts
__log "Updated /etc/hosts"
}
# - - - - - - - - - - - - - - - - - - - - - - - - -
# ─── Let's Encrypt certificate detection ─────────────────────────────────────
__check_letsencrypt_certs() {
local le_live_dir="/etc/letsencrypt/live"
[[ -d "${le_live_dir}" ]] || return 1
__log "Checking for Let's Encrypt certificates..."
local cert_dirs="" cert_count=0
local cert_dir cert_name
for cert_dir in "${le_live_dir}"/*; do
[[ -d "${cert_dir}" ]] || continue
[[ -f "${cert_dir}/cert.pem" ]] || continue
[[ -f "${cert_dir}/privkey.pem" ]] || continue
[[ -f "${cert_dir}/fullchain.pem" ]] || continue
if ! \openssl x509 -in "${cert_dir}/cert.pem" -noout -checkhost "${FREEIPA_FQDN}" >/dev/null 2>&1; then
__debug "Let's Encrypt candidate ${cert_dir##*/}: does not cover ${FREEIPA_FQDN}"
continue
fi
cert_name="${cert_dir##*/}"
if ! \openssl x509 -in "${cert_dir}/cert.pem" -noout -checkend 0 >/dev/null 2>&1; then
__warn "Skipping Let's Encrypt certificate ${cert_name}: expired"
continue
fi
if [[ "$(\openssl x509 -in "${cert_dir}/cert.pem" -noout -pubkey 2>/dev/null || true)" != "$(\openssl pkey -in "${cert_dir}/privkey.pem" -pubout 2>/dev/null || true)" ]]; then
__warn "Skipping Let's Encrypt certificate ${cert_name}: private key does not match"
continue
fi
# Self-signed and private-CA certificates (e.g. a bootstrap's placeholder under live/) are not for FreeIPA's public endpoints
if ! \openssl verify -untrusted "${cert_dir}/fullchain.pem" "${cert_dir}/cert.pem" >/dev/null 2>&1; then
__warn "Skipping certificate ${cert_name}: not issued by a publicly trusted CA"
continue
fi
# Skip the README file that certbot places in the directory
[[ "${cert_name}" == "README" ]] && continue
cert_dirs="${cert_dirs} ${cert_dir}"
cert_count=$(( cert_count + 1 ))
done
if [[ "${cert_count}" -eq 0 ]]; then
__log "No Let's Encrypt certificates found in ${le_live_dir}"
return 1
fi
local selected_dir=""
if [[ "${cert_count}" -eq 1 ]]; then
selected_dir="${cert_dirs# }"
cert_name="${selected_dir##*/}"
__log "Found 1 Let's Encrypt certificate: ${cert_name}"
else
__log "Found ${cert_count} Let's Encrypt certificates:"
local i=1
for cert_dir in ${cert_dirs}; do
cert_name="${cert_dir##*/}"
printf ' %d) %s\n' "${i}" "${cert_name}"
i=$(( i + 1 ))
done
local cert_choice
if [[ -t 0 ]]; then
printf 'Select certificate to use (1-%d, 0 to skip): ' "${cert_count}"
read -r cert_choice
else
# Non-interactive: auto-select the first certificate
cert_choice=1
__log "Non-interactive mode: auto-selecting first certificate"
fi
if [[ "${cert_choice}" -eq 0 ]] 2>/dev/null; then
__log "Skipping Let's Encrypt certificates"
return 1
elif [[ "${cert_choice}" -ge 1 && "${cert_choice}" -le "${cert_count}" ]] 2>/dev/null; then
i=1
for cert_dir in ${cert_dirs}; do
if [[ "${i}" -eq "${cert_choice}" ]]; then
selected_dir="${cert_dir}"
break
fi
i=$(( i + 1 ))
done
else
__warn "Invalid selection; skipping Let's Encrypt certificates"
return 1
fi
fi
if [[ -f "${selected_dir}/cert.pem" && -f "${selected_dir}/privkey.pem" ]]; then
INSTALL_CERT_PATH="${selected_dir}/cert.pem"
INSTALL_KEY_PATH="${selected_dir}/privkey.pem"
INSTALL_CHAIN_PATH="${selected_dir}/chain.pem"
INSTALL_FULLCHAIN_PATH="${selected_dir}/fullchain.pem"
__log "Using certificate: ${selected_dir##*/}"
__log " Certificate: ${INSTALL_CERT_PATH}"
__log " Private key: ${INSTALL_KEY_PATH}"
__log " Chain: ${INSTALL_CHAIN_PATH}"
__log " Full chain: ${INSTALL_FULLCHAIN_PATH}"
return 0
fi
__warn "Certificate directory found but required files are missing"
return 1
}
# - - - - - - - - - - - - - - - - - - - - - - - - -
# ─── SSL certificate selection ───────────────────────────────────────────────
__configure_ssl_certs() {
__log "Setting up SSL certificate options..."
if __check_letsencrypt_certs; then
__log "Using existing Let's Encrypt certificate"
INSTALL_USE_LETSENCRYPT="true"
INSTALL_USE_SELFSIGNED="false"
INSTALL_USE_FREEIPA_CA="false"
return 0
fi
# Fall back to FreeIPA's built-in CA
__log "No Let's Encrypt certificates found; using FreeIPA built-in CA"
INSTALL_USE_LETSENCRYPT="false"
INSTALL_USE_FREEIPA_CA="true"
INSTALL_USE_SELFSIGNED="false"
}
# - - - - - - - - - - - - - - - - - - - - - - - - -
# ─── DNS settings ────────────────────────────────────────────────────────────
__configure_dns_settings() {
__log "Auto-detecting DNS configuration..."
# On an idempotent rerun, the installed IPA DNS service already resolves the
# hostname. Preserve the existing integrated-DNS state instead of treating
# that successful lookup as evidence that IPA DNS is not in use.
if __freeipa_server_configured && [[ -f "/etc/named/ipa-ext.conf" ]]; then
INSTALL_DNS="true"
INSTALL_CONFIGURE_REVERSE_ZONE="false"
INSTALL_USE_AUTO_FORWARDERS="false"
INSTALL_DNS_FORWARDERS=""
__log "Integrated DNS is already installed; preserving existing configuration"
__log "DNS configuration: integrated DNS=Yes"
return 0
fi
if ! \nslookup "${FREEIPA_FQDN}" >/dev/null 2>&1; then
__log "Hostname not resolvable via DNS; will install integrated DNS server"
INSTALL_DNS="true"
INSTALL_CONFIGURE_REVERSE_ZONE="true"
# Extract only IPv4 nameservers from resolv.conf — skip IPv6 link-local
# (fe80::) and pure IPv6 addresses; ipa-server-install check_forwarders
# times out trying to validate link-local addresses from inside the container
local _ns _ipv4_fwds=""
while IFS= read -r _ns; do
_ipv4_fwds="${_ipv4_fwds:+${_ipv4_fwds} }${_ns}"
done < <(\awk '/^nameserver[[:space:]]+[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/{print $2}' /etc/resolv.conf)
if [[ -n "${_ipv4_fwds}" ]]; then
__log "Using IPv4 DNS forwarders: ${_ipv4_fwds}"
INSTALL_DNS_FORWARDERS="${_ipv4_fwds}"
else
__log "No IPv4 DNS forwarders found; integrated DNS will run without forwarders"
fi
INSTALL_USE_AUTO_FORWARDERS="false"
else
__log "Hostname is resolvable; integrated DNS not required"
INSTALL_DNS="false"
INSTALL_USE_AUTO_FORWARDERS="false"
INSTALL_CONFIGURE_REVERSE_ZONE="false"
fi
if [[ "${INSTALL_DNS}" == "true" ]]; then
__log "DNS configuration: integrated DNS=Yes"
else
__log "DNS configuration: integrated DNS=No"
fi
}
# - - - - - - - - - - - - - - - - - - - - - - - - -
# ─── NTP / Chrony ────────────────────────────────────────────────────────────
__configure_ntp_settings() {
if [[ "${INSTALL_NO_NTP}" == "true" ]]; then
__log "Skipping NTP/Chrony configuration (--no-ntp)"
return 0
fi
__log "Configuring NTP/Chrony..."
# Find the exact unit name (chronyd.service on RHEL, chrony.service on Debian)
# Match ^chrony[^-] to exclude chrony-wait.service and chronyd-restricted.service
local chrony_unit
chrony_unit="$(\systemctl list-unit-files 2>/dev/null | \awk '$1 ~ /^chronyd?\.service$/{print $1; exit}' || true)"
if [[ -n "${chrony_unit}" ]]; then
# enable is best-effort — may be masked in containers
\systemctl enable "${chrony_unit}" 2>/dev/null || true
# start requires CAP_SYS_TIME — non-fatal in containers
if ! \systemctl start "${chrony_unit}" 2>/dev/null; then
__warn "${chrony_unit} could not start (no CAP_SYS_TIME? running in container); continuing"
else
__log "${chrony_unit} enabled and started"
fi
else
__warn "Chrony service not found; skipping NTP configuration"
fi
}
# - - - - - - - - - - - - - - - - - - - - - - - - -
# ─── Let's Encrypt renewal hook ──────────────────────────────────────────────
__configure_letsencrypt_renewal() {
__log "Installing Let's Encrypt certificate for FreeIPA HTTPS and LDAPS (mail reads it directly) and creating renewal hook..."
local renewal_hook_dir="/etc/letsencrypt/renewal-hooks/deploy"
local hook="${renewal_hook_dir}/freeipa-renew.sh"
\mkdir -p "${renewal_hook_dir}"
# The Let's Encrypt certificate serves every public-facing service: Apache HTTPS and LDAPS here,
# Postfix and Dovecot straight from the same files. Kerberos, PKINIT and the CA keep IPA-issued certificates.
# The issuer chain is added to IPA trust so IPA clients and services can validate it.
{
printf '#!/usr/bin/env sh\n'
printf 'CRED_FILE=%q\n' "${FREEIPA_CRED_FILE}"
printf 'FQDN=%q\n' "${FREEIPA_FQDN}"
\cat << 'HOOKEOF'
# FreeIPA public certificate hook for Let's Encrypt (managed by install.sh)
# Run by certbot after a successful renewal, and once by install.sh.
LINEAGE="${RENEWED_LINEAGE:?RENEWED_LINEAGE not set}"
CERT_PATH="${LINEAGE}/fullchain.pem"
LEAF_PATH="${LINEAGE}/cert.pem"
KEY_PATH="${LINEAGE}/privkey.pem"
CHAIN_PATH="${LINEAGE}/chain.pem"
[ -f "${CERT_PATH}" ] && [ -f "${LEAF_PATH}" ] && [ -f "${KEY_PATH}" ] || exit 0
# certbot runs every deploy hook for every lineage it renews; only act for this host's certificate
openssl x509 -in "${LEAF_PATH}" -noout -checkhost "${FQDN}" >/dev/null 2>&1 || exit 0
openssl x509 -in "${LEAF_PATH}" -noout -checkend 0 >/dev/null 2>&1 || exit 0
[ "$(openssl x509 -in "${LEAF_PATH}" -noout -pubkey 2>/dev/null)" = "$(openssl pkey -in "${KEY_PATH}" -pubout 2>/dev/null)" ] || exit 0
openssl verify -untrusted "${CERT_PATH}" "${LEAF_PATH}" >/dev/null 2>&1 || exit 0
cred() { sed -n "s/^$1=//p" "${CRED_FILE}" | tail -n1; }
DM_PASS="$(cred INSTALL_DM_PASSWORD)"
ADMIN_PASS="$(cred INSTALL_ADMIN_PASSWORD)"
rc=0
# Make IPA trust the issuing chain so ipa-server-certinstall can validate it and IPA clients, sssd,
# Keycloak, Postfix and Dovecot verify LDAPS against /etc/ipa/ca.crt. The served chain normally stops at
# the intermediate; OpenSSL clients need the root as well, which comes from the system trust store.
if [ -f "${CHAIN_PATH}" ]; then
TRUST_DIR="$(mktemp -d)"
cp "${CHAIN_PATH}" "${TRUST_DIR}/chain.pem"
python3 - "${CHAIN_PATH}" "${TRUST_DIR}/root.pem" <<'PYEOF' || true
import re, sys
from cryptography import x509
from cryptography.hazmat.primitives import serialization
def load_all(path):
data = open(path, 'rb').read().decode('ascii', 'ignore')
pems = re.findall(r'-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----', data, re.S)
return [x509.load_pem_x509_certificate(p.encode()) for p in pems]
chain = load_all(sys.argv[1])
if not chain:
sys.exit(0)
top = chain[-1]
if top.subject == top.issuer:
sys.exit(0)
for bundle in ('/etc/pki/tls/certs/ca-bundle.crt', '/etc/ssl/certs/ca-certificates.crt'):
try:
candidates = load_all(bundle)
except OSError:
continue
for cand in candidates:
if cand.subject == top.issuer:
open(sys.argv[2], 'wb').write(cand.public_bytes(serialization.Encoding.PEM))
sys.exit(0)
PYEOF
# The default credential cache can be unusable (KCM without sssd-kcm); use a private one
CCACHE="$(mktemp)"
KRB5CCNAME="FILE:${CCACHE}"
export KRB5CCNAME
if printf '%s\n' "${ADMIN_PASS}" | kinit admin >/dev/null 2>&1; then
for f in "${TRUST_DIR}/root.pem" "${TRUST_DIR}/chain.pem"; do
[ -s "${f}" ] || continue
if ! out="$(ipa-cacert-manage install -t C,, "${f}" 2>&1)"; then
printf '%s\n' "${out}" | grep -qi 'already' || { printf '%s\n' "${out}" >&2; rc=1; }
fi
done
ipa-certupdate >/dev/null 2>&1 || rc=1
kdestroy >/dev/null 2>&1 || true
else
echo "kinit admin failed" >&2
rc=1
fi
rm -rf "${TRUST_DIR}"
rm -f "${CCACHE}"
fi
# One certificate for every public-facing service that FreeIPA itself runs: Apache HTTPS and LDAPS.
# Postfix and Dovecot read the same files directly. Kerberos/PKINIT and the CA keep IPA-issued certificates.
if ipa-server-certinstall -w -d --pin= -p "${DM_PASS}" "${KEY_PATH}" "${CERT_PATH}"; then
ipactl restart >/dev/null 2>&1 || rc=1
else
rc=1
fi
for svc in postfix dovecot; do
if systemctl is-active --quiet "${svc}" 2>/dev/null; then
systemctl reload "${svc}" || rc=1
fi
done
logger -t letsencrypt "FreeIPA HTTPS/LDAPS/mail certificate installed from ${LINEAGE} (rc=${rc})"
exit "${rc}"
HOOKEOF
} > "${hook}"
\chmod +x "${hook}"
__log "Renewal hook created: ${hook}"
if ! RENEWED_LINEAGE="${INSTALL_FULLCHAIN_PATH%/*}" "${hook}"; then
__error "Could not install the Let's Encrypt certificate on FreeIPA HTTPS/LDAPS (exit 71)"
fi
}
# - - - - - - - - - - - - - - - - - - - - - - - - -
# ─── FreeIPA installation ────────────────────────────────────────────────────
# FreeIPA needs python3-mod_wsgi (the system Python). A Python-version-specific mod_wsgi from another repo
# (python3.11-mod_wsgi ...) ships the same files and cannot coexist with it in one Apache process, so
# the transaction conflicts. Remove only that package, say so, and retry once.
__install_ipa_packages() {
local pkg_mgr="$1"
shift
if "${pkg_mgr}" install -y "$@"; then
return 0
fi
local alt
alt="$(\rpm -qa 2>/dev/null | \grep -E '^python3\.[0-9]+-mod_wsgi-' || true)"
if [[ -z "${alt}" ]]; then
return 1
fi
local p
while IFS= read -r p; do
__warn "Removing ${p}: it conflicts with the python3-mod_wsgi that FreeIPA requires"
"${pkg_mgr}" remove -y "${p}" || return 1
done <<< "${alt}"
"${pkg_mgr}" install -y "$@"
}
__install_freeipa() {
# Idempotency guard — skip if FreeIPA is already installed
if __freeipa_server_configured; then
__log "FreeIPA server already configured on this host; skipping ipa-server-install"
# Still load credentials so downstream functions have them
INSTALL_ADMIN_PASSWORD="$(__load_credential "${FREEIPA_CRED_FILE}" INSTALL_ADMIN_PASSWORD)" || {
__warn "FreeIPA installed but admin password not found in ${FREEIPA_CRED_FILE}"
}
INSTALL_DM_PASSWORD="$(__load_credential "${FREEIPA_CRED_FILE}" INSTALL_DM_PASSWORD)" || {
__warn "FreeIPA installed but DM password not found in ${FREEIPA_CRED_FILE}"
}
if [[ "${INSTALL_USE_LETSENCRYPT}" == "true" ]]; then
# Nginx may have owned ports 80/443 and kept Apache stopped on an existing host.
# Start and verify FreeIPA's HTTP endpoint before certinstall calls its HTTPS API.
__configure_freeipa_http
__configure_letsencrypt_renewal
fi
return 0
fi
__log "Starting FreeIPA server installation..."
# Load or generate admin password
INSTALL_ADMIN_PASSWORD="$(__load_credential "${FREEIPA_CRED_FILE}" INSTALL_ADMIN_PASSWORD)" || {
INSTALL_ADMIN_PASSWORD="$(__random_password 25)"
__save_credential "${FREEIPA_CRED_FILE}" INSTALL_ADMIN_PASSWORD "${INSTALL_ADMIN_PASSWORD}"
}
# Load or generate Directory Manager password
INSTALL_DM_PASSWORD="$(__load_credential "${FREEIPA_CRED_FILE}" INSTALL_DM_PASSWORD)" || {
INSTALL_DM_PASSWORD="$(__random_password 25)"
__save_credential "${FREEIPA_CRED_FILE}" INSTALL_DM_PASSWORD "${INSTALL_DM_PASSWORD}"
}
# Build installation command as an array to avoid quoting/eval issues
local -a install_cmd
install_cmd=(
\ipa-server-install
--unattended
"--realm=${FREEIPA_REALM}"
"--domain=${FREEIPA_DOMAIN}"
"--hostname=${FREEIPA_FQDN}"
"--admin-password=${INSTALL_ADMIN_PASSWORD}"
"--ds-password=${INSTALL_DM_PASSWORD}"
)
if [[ "${INSTALL_NO_NTP}" == "true" ]]; then
install_cmd+=( --no-ntp )
fi