Repository navigation
Expand file tree
/
Copy pathinstall.sh
More file actions
1248 lines (1185 loc) · 70.6 KB
/
Copy pathinstall.sh
File metadata and controls
1248 lines (1185 loc) · 70.6 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
#!/bin/bash
# =====================================================================
# Installer for the system integrations of the Roundcube panel (run as root).
# Target: Debian 13 (trixie) — Dovecot 2.4, Postfix 3.10, Fail2Ban 1.1.
# Idempotent, with a confirmation gate per section and backups.
# Running it again does NOT rotate existing secrets (DB accounts that already exist do not get a new password),
# does NOT duplicate configuration (jails live in /etc/fail2ban/jail.d/, sections are detected) and overwrites nothing without asking.
#
# 1.1.0 — security rules of this file:
# * secrets read FROM FILES (passwords from Postfix maps, from mailpanel-policy.cf, from the plugin configuration) are data,
# not code: read strictly (file_kv), checked with the regex ^[A-Za-z0-9._~+-]{16,128}$ and inserted into templates by awk
# through environment variables (no sed, no interpretation of special characters). The application's .env file is NOT read
# at all — in 1.0.x its contents ended up in SQL and in sed run as root, and that file is writable by www-data when the
# code belongs to www-data. Never `source` such a file.
# * section 0 does not change the passwords of existing DB accounts (previously ALTER USER on every run could cut Postfix
# and Dovecot off from the database);
# * the application code belongs to root; the panel's PHP user can write only Roundcube's temp/ and logs/
# (and its log_dir, if that lies outside the tree).
# * Roundcube and the panel get THEIR OWN PHP-FPM pool and their own user (section 1b, PANEL_USER, default mailweb):
# other sites on the server (the [www] pool, www-data) cannot read the panel's secrets and do not have its sudo rules.
#
# Variables (optional): RC_DIR=/var/www/roundcube PANEL_USER=mailweb APACHE_USER=www-data PHP_VER=8.5
# PANEL_USER=www-data (or the old WEB_USER=www-data) = no separate pool: Roundcube stays in the shared [www] pool.
# The application layer (.env, default language, first administrator) is installed separately, AFTER section 0 here
# (database, accounts, schema, grants; it prints the mailpanel password once): sudo php install.php in the Roundcube
# directory. The other sections can run before or after it.
# =====================================================================
set -uo pipefail
SRC="$(cd "$(dirname "$0")" && pwd)"
RC_DIR="${RC_DIR:-/var/www/roundcube}"
APACHE_USER="${APACHE_USER:-www-data}" # użytkownik Apache: łączy się z gniazdem puli, czyta pliki statyczne
PANEL_USER="${PANEL_USER:-${WEB_USER:-mailweb}}" # użytkownik puli PHP-FPM Roundcube + panelu (WEB_USER: nazwa z 1.0.x)
PHP_VER="${PHP_VER:-}" # wersja PHP-FPM (puste = wykryj z /etc/php/*/fpm)
RUN_USER="$APACHE_USER" # kto NAPRAWDĘ wykonuje PHP Roundcube (ustala sekcja 1b)
c_grn='\033[32m'; c_red='\033[31m'; c_yel='\033[33m'; c_rst='\033[0m'; c_b='\033[1m'
ok() { echo -e "${c_grn}✓${c_rst} $*"; }
warn() { echo -e "${c_yel}!${c_rst} $*"; }
die() { echo -e "${c_red}✗ $*${c_rst}"; exit 1; }
hr() { printf '%.0s─' {1..60}; echo; }
# y/Y/yes confirm; t/T/tak (the old Polish habit) are still accepted. Anything else = no.
ask() { local a; read -rp "$1 [y/N]: " a; [[ "$a" =~ ^[tTyY] ]]; }
# --- helpers begin (tests/install_helpers_test.sh sources exactly this block) -------------------------------------
# Sekret dozwolony w konfiguracjach: bez znaków specjalnych powłoki / SQL / sed (hex z `openssl rand -hex 24` pasuje).
valid_secret() { [[ "${1:-}" =~ ^[A-Za-z0-9._~+-]{16,128}$ ]]; }
# Wartość klucza z pliku KLUCZ=WARTOŚĆ (INI / .cnf / mapa Postfixa). Plik to DANE: nigdy nie jest wykonywany ani
# `source`-owany. Pierwsze dopasowanie, jedna para cudzysłowów zdejmowana. Dla dowiązania symbolicznego: błąd.
# $2 to stała nazwa klucza podana przez ten skrypt, nie zawartość pliku.
file_kv() {
local file=$1 key=$2 line val
[ -f "$file" ] && [ ! -L "$file" ] || return 1
line=$(grep -m1 -E "^[[:space:]]*${key}[[:space:]]*=" -- "$file") || return 1
val=${line#*=}
val=${val%$'\r'}
val="${val#"${val%%[![:space:]]*}"}"
val="${val%"${val##*[![:space:]]}"}"
case "$val" in
\"*\") val=${val#\"}; val=${val%\"} ;;
\'*\') val=${val#\'}; val=${val%\'} ;;
esac
printf '%s' "$val"
}
# Wypełnia szablon: zamienia KAŻDE wystąpienie tekstu $2 na $3 (wartości literalne — bez regexów, bez & i \).
render_template() {
RENDER_PH="$2" RENDER_VAL="$3" awk '{
ph = ENVIRON["RENDER_PH"]; val = ENVIRON["RENDER_VAL"]; out = ""
while ((i = index($0, ph)) > 0) { out = out substr($0, 1, i - 1) val; $0 = substr($0, i + length(ph)) }
print out $0
}' "$1"
}
# Uruchamia SQL przez klienta mysql (SQL na standardowym wejściu); przy błędzie ostrzega i zwraca 1.
run_sql() {
local out
if ! out=$(mysql 2>&1 <<<"$1"); then
warn "MySQL error: ${out:-unknown} — section not completed."
return 1
fi
}
db_user_exists() { # $1 = stała nazwa konta (z tego skryptu)
[ "$(mysql -N -B -e "SELECT COUNT(*) FROM mysql.user WHERE User='$1' AND Host='localhost'" 2>/dev/null)" = "1" ]
}
gen_pw() { openssl rand -hex 24; }
# --- helpers end ----------------------------------------------------------------------------------------------------
# Helper Fail2Ban. /etc/fail2ban/jail.local należy do roota, a panel (www-data) przekazuje PROPOZYCJĘ na standardowe
# wejście root-owego helpera (przez sudo, bez argumentów) — helper sprawdza, że zmieniły się wyłącznie wartości opcji
# edytowanych z panelu, zapisuje plik W MIEJSCU (pod ProtectSystem=full w php-fpm nie da się utworzyć pliku obok ani
# zrobić rename), czyta go z powrotem, robi fail2ban-client -t i przeładowuje Fail2Ban. Idempotentne.
install_f2b_helper() {
command -v php >/dev/null || warn "php not found in PATH — the Fail2Ban helper needs php-cli (apt install php-cli)."
install -o root -g root -m 755 "$SRC/scripts/mailpanel-f2b-apply.php" /usr/local/sbin/mailpanel-f2b-apply || return 1
if command -v php >/dev/null && ! php -l /usr/local/sbin/mailpanel-f2b-apply >/dev/null 2>&1; then
warn "helper mailpanel-f2b-apply fails php -l — check the file"
return 1
fi
# 1.0.x zostawiało propozycję w pliku w katalogu www-data; od 1.1.0 propozycja idzie na stdin
if [ -d /var/lib/mailpanel-f2b ]; then
rm -f /var/lib/mailpanel-f2b/jail.local.proposed
rmdir /var/lib/mailpanel-f2b 2>/dev/null && ok "removed the unused directory /var/lib/mailpanel-f2b"
fi
ok "helper /usr/local/sbin/mailpanel-f2b-apply (755 root)"
}
# Kopie plików z sekretami zostawione ręcznie albo przez starsze instalatory (config.inc.php.bak-*, config.old.php,
# .env.bak ...): tylko dla roota (600). Proces PHP ich nie potrzebuje, a czytelne dla wszystkich (755 po kopiowaniu)
# zdradzają hasło bazy i des_key Roundcube każdemu lokalnemu kontu.
# *.pre-*: copies taken before an upgrade step, e.g. config.inc.php.pre-password left by the old
# scripts/install-password-plugin.sh (a plain cp: the source mode, 0644 before section 7).
SECRET_COPY_NAMES=(-name 'config.inc.php.bak*' -o -name 'config.inc.php.save' -o -name 'config.inc.php.orig'
-o -name 'config.old.php' -o -name '*.pre-*' -o -name '.env.*' ! -name '.env.example')
secure_secret_copies() {
local d f n=0
for d in "$RC_DIR" "$RC_DIR/config" "$RC_DIR/plugins/password"; do
[ -d "$d" ] || continue
while IFS= read -r -d '' f; do
chown root:root "$f" && chmod 600 "$f" && n=$((n + 1))
done < <(find "$d" -maxdepth 1 -type f \( "${SECRET_COPY_NAMES[@]}" \) -print0 2>/dev/null)
done
ok "copies of files with secrets (config.inc.php.bak*, config.old.php, *.pre-*, .env.*): $n file(s) — root:root 600"
}
# --- pool helpers begin (tests/scripts_test.php sources exactly this block) ----------------------------------------
# osobna pula PHP-FPM (sekcja 1b) i własność plików. Ścieżki systemowe są tu stałymi (testy podmieniają je po wczytaniu).
APACHE_SITES=/etc/apache2/sites-enabled
LOGROTATE_D=/etc/logrotate.d
RC_BACKUP_ROOT=/root
# Wersja PHP-FPM: jedyna zainstalowana albo ta, której usługa działa.
detect_php_ver() {
local d v cand=()
for d in /etc/php/*/fpm/pool.d; do
[ -d "$d" ] || continue
v=${d#/etc/php/}; v=${v%%/*}
[[ "$v" =~ ^[0-9]+\.[0-9]+$ ]] && cand+=("$v")
done
[ "${#cand[@]}" -eq 1 ] && { printf '%s' "${cand[0]}"; return 0; }
for v in "${cand[@]}"; do
systemctl is-active --quiet "php$v-fpm" && { printf '%s' "$v"; return 0; }
done
return 1
}
pool_sock() { printf '/run/php/php%s-fpm-%s.sock' "$PHP_VER" "$PANEL_USER"; }
pool_conf() { printf '/etc/php/%s/fpm/pool.d/%s.conf' "$PHP_VER" "$PANEL_USER"; }
# Pliki vhostów Apache (sites-enabled, po rozwinięciu dowiązań), których DocumentRoot to RC_DIR albo RC_DIR/public_html.
rc_vhost_files() {
local f real seen=" "
for f in "$APACHE_SITES"/*; do
[ -e "$f" ] || continue
real=$(readlink -f -- "$f") || continue
case "$seen" in *" $real "*) continue ;; esac
seen="$seen$real "
grep -qE "^[[:space:]]*DocumentRoot[[:space:]]+\"?${RC_DIR}(/public_html)?/?\"?[[:space:]]*$" "$real" && printf '%s\n' "$real"
done
}
# Czy Roundcube NAPRAWDĘ działa w osobnej puli: użytkownik i plik puli istnieją, a vhost kieruje PHP na jej gniazdo.
pool_ready() {
local f found=0
[ "$PANEL_USER" != "$APACHE_USER" ] && [ -n "$PHP_VER" ] && [ -f "$(pool_conf)" ] && id -u "$PANEL_USER" >/dev/null 2>&1 || return 1
while IFS= read -r f; do
grep -qF "$(pool_sock)" "$f" && found=1
done < <(rc_vhost_files)
[ "$found" -eq 1 ]
}
set_run_user() { if pool_ready; then RUN_USER=$PANEL_USER; else RUN_USER=$APACHE_USER; fi; }
# log_dir Roundcube, jeśli leży POZA drzewem (np. /var/log/roundcube) i jest wyraźnie jego własnym katalogiem; inaczej
# pusto. Czytane z config.inc.php jako tekst (nigdy nie wykonywane). Katalog o obcej nazwie (np. /var/log) — nie ruszamy.
rc_log_dir() {
local cfg="$RC_DIR/config/config.inc.php" v
[ -f "$cfg" ] || return 0
v=$(grep -E "^[[:space:]]*\\\$config\[['\"]log_dir['\"]\][[:space:]]*=" "$cfg" | tail -1 \
| sed -nE "s/.*=[[:space:]]*['\"]([^'\"]+)['\"][[:space:]]*;.*/\1/p")
v=${v%/}
[[ "$v" =~ ^/[A-Za-z0-9._/-]+$ ]] && [[ "$v" != *..* ]] || return 0
case "$v" in "$RC_DIR" | "$RC_DIR"/*) return 0 ;; esac # w drzewie: logs/ obsługuje runtime_ownership
if [[ ! "${v##*/}" =~ ^(roundcube|rcube|webmail) ]]; then
warn "Roundcube's log_dir ($v) does not look like its own directory — give it to the PHP user manually: chown -R <user>: $v" >&2
return 0
fi
printf '%s' "$v"
}
# logrotate dla katalogu $1 ma odtwarzać pliki dla użytkownika $2 (linia `create MODE USER GROUP`); kopia + logrotate -d.
logrotate_user() {
local dir=$1 u=$2 f tmp bdir
[ -n "$dir" ] && [ -d "$LOGROTATE_D" ] || return 0
for f in "$LOGROTATE_D"/*; do
[ -f "$f" ] && grep -qF -- "$dir" "$f" || continue
tmp=$(mktemp)
sed -E "s/^([[:space:]]*create[[:space:]]+[0-7]+)[[:space:]]+[A-Za-z0-9_-]+[[:space:]]+[A-Za-z0-9_-]+[[:space:]]*$/\1 $u $u/" "$f" > "$tmp"
if cmp -s "$tmp" "$f"; then rm -f "$tmp"; continue; fi
bdir="$RC_BACKUP_ROOT/roundcontrol-logrotate-backup-$(date +%Y%m%d%H%M%S)"
mkdir -p "$bdir" && cp -p "$f" "$bdir/" && cat "$tmp" > "$f"
rm -f "$tmp"
if command -v logrotate >/dev/null && ! logrotate -d "$f" >/dev/null 2>&1; then
cp -p "$bdir/$(basename "$f")" "$f"
warn "logrotate -d rejects the modified $f — restored (change the create line manually to: create 0640 $u $u)"
else
ok "$f: logs are recreated for $u (create … $u $u; backup in $bdir)"
fi
done
}
# Wszystko, co proces PHP Roundcube/panelu ZAPISUJE (temp/, logs/, log_dir poza drzewem), należy do $1; sekrety, które
# CZYTA, są root:$1 640. Wywoływane przy przełączaniu puli (1b) i po porządkowaniu własności (7).
runtime_ownership() {
local u=$1 d f ld
for d in "$RC_DIR/temp" "$RC_DIR/logs"; do
[ -d "$d" ] && chown -R "$u:$u" "$d"
done
ld=$(rc_log_dir)
if [ -n "$ld" ] && [ -d "$ld" ]; then
chown -R "$u:$u" "$ld"
logrotate_user "$ld" "$u"
fi
for f in "$RC_DIR/config/config.inc.php" "$RC_DIR/plugins/password/config.inc.php" "$RC_DIR/.env" /etc/mailpanel/panel.env; do
[ -f "$f" ] && [ ! -L "$f" ] && { chown root:"$u" "$f"; chmod 640 "$f"; }
done
[ -d /etc/mailpanel ] && { chown root:"$u" /etc/mailpanel; chmod 750 /etc/mailpanel; }
ok "writable by $u: temp/ logs/${ld:+ $ld}; secrets (config.inc.php, password/config.inc.php, .env) root:$u 640"
}
# Wstawia blok SetHandler (gniazdo puli) do każdego <VirtualHost> z DocumentRoot = RC_DIR(/public_html), sprawdza
# apache2ctl configtest i przeładowuje Apache. Przy błędzie przywraca pliki. Idempotentne (gniazdo już jest = bez zmian).
switch_vhosts() {
local sock=$1 f tmp block bdir rc changed=() files=()
mapfile -t files < <(rc_vhost_files)
if [ "${#files[@]}" -eq 0 ]; then
warn "no Apache vhost with DocumentRoot $RC_DIR(/public_html) found in $APACHE_SITES — add the block from system/apache/roundcontrol-fpm-handler.conf by hand"
return 1
fi
block=$(render_template "$SRC/system/apache/roundcontrol-fpm-handler.conf" @SOCKET@ "$sock" | render_template - @PANEL_USER@ "$PANEL_USER" | render_template - @RC_DIR@ "$RC_DIR")
bdir="$RC_BACKUP_ROOT/roundcontrol-apache-backup-$(date +%Y%m%d%H%M%S)"
for f in "${files[@]}"; do
tmp=$(mktemp)
RC_ROOT="$RC_DIR" RC_SOCK="$sock" RC_BLOCK="$block" awk '
BEGIN { rc = ENVIRON["RC_ROOT"]; sock = ENVIRON["RC_SOCK"]; block = ENVIRON["RC_BLOCK"]; n = 0 }
/^[ \t]*<VirtualHost[ \t>]/ { inv = 1; hit = 0; has = 0 }
inv && /^[ \t]*DocumentRoot[ \t]/ { dr = $2; gsub(/"/, "", dr); sub(/\/+$/, "", dr); if (dr == rc || dr == rc "/public_html") hit = 1 }
inv && index($0, sock) > 0 { has = 1 }
/^[ \t]*<\/VirtualHost>/ { if (inv && hit && !has) { print block; n++ } inv = 0 }
{ print }
END { exit (n > 0 ? 0 : 3) }
' "$f" > "$tmp"; rc=$?
if [ "$rc" -eq 0 ]; then
mkdir -p "$bdir" && cp -p "$f" "$bdir/" && cat "$tmp" > "$f" && changed+=("$f")
elif [ "$rc" -ne 3 ]; then
warn "awk did not process $f"
fi
rm -f "$tmp"
done
if [ "${#changed[@]}" -eq 0 ]; then
ok "the Roundcube vhosts already send PHP to the $PANEL_USER pool ($sock)"
return 0
fi
if ! apache2ctl configtest >/dev/null 2>&1 || ! systemctl reload apache2; then
for f in "${changed[@]}"; do cp -p "$bdir/$(basename "$f")" "$f"; done
systemctl reload apache2 >/dev/null 2>&1
warn "apache2ctl configtest / reload failed with the new handler — vhosts restored from $bdir"
return 1
fi
ok "vhosts (${changed[*]}): PHP -> $sock (backups in $bdir); Apache reloaded"
}
# The pool handler (SetHandler "proxy:unix:<socket>|fcgi://localhost") needs mod_proxy and mod_proxy_fcgi. Without them
# apache2ctl configtest still passes — and Apache would hand out Roundcube's and the panel's PHP files as plain text.
# The module list is captured first: grep -q straight from apache2ctl's pipe could end it with SIGPIPE (pipefail).
apache_proxy_fcgi() {
local mods
mods=$(apache2ctl -M 2>/dev/null) || return 1
grep -qE '^[[:space:]]*proxy_module[[:space:]]' <<<"$mods" && grep -qE '^[[:space:]]*proxy_fcgi_module[[:space:]]' <<<"$mods"
}
# Sekcja 1b: użytkownik systemowy, katalog sesji, plik puli (php-fpm -t), reload php-fpm, własność plików dla puli,
# handler w vhostach. Przy błędzie po zmianie własności wraca ona do użytkownika Apache (Roundcube dalej działa w [www]).
setup_pool() {
local svc="php$PHP_VER-fpm" conf sock sess tmp bdir
conf=$(pool_conf); sock=$(pool_sock); sess="/var/lib/php/sessions-$PANEL_USER"
command -v "php-fpm$PHP_VER" >/dev/null || { warn "php-fpm$PHP_VER not found in PATH"; return 1; }
if ! apache_proxy_fcgi; then
warn "Apache's proxy and proxy_fcgi modules are not both enabled (apache2ctl -M) — with the pool handler Apache would serve the PHP files as text. Enable them (a2enmod proxy_fcgi && systemctl restart apache2), then run section 1b again; nothing was changed"
return 1
fi
if ! id -u "$PANEL_USER" >/dev/null 2>&1; then
useradd --system --user-group --no-create-home --home-dir /nonexistent --shell /usr/sbin/nologin "$PANEL_USER" \
|| { warn "useradd $PANEL_USER failed"; return 1; }
ok "system user $PANEL_USER (no shell, no home directory)"
fi
install -d -o "$PANEL_USER" -g "$PANEL_USER" -m 700 "$sess" || return 1
tmp=$(mktemp)
render_template "$SRC/system/php-fpm/roundcontrol-pool.conf" @POOL@ "$PANEL_USER" | render_template - @PANEL_USER@ "$PANEL_USER" \
| render_template - @APACHE_USER@ "$APACHE_USER" | render_template - @SOCKET@ "$sock" | render_template - @SESSION_DIR@ "$sess" > "$tmp"
if [ -f "$conf" ] && cmp -s "$tmp" "$conf"; then
ok "pool $conf unchanged"
else
bdir="$RC_BACKUP_ROOT/roundcontrol-pool-backup-$(date +%Y%m%d%H%M%S)"
mkdir -p "$bdir"; [ -f "$conf" ] && cp -p "$conf" "$bdir/"
install -o root -g root -m 644 "$tmp" "$conf"
if ! "php-fpm$PHP_VER" -t >/dev/null 2>&1 || ! systemctl reload "$svc"; then
if [ -f "$bdir/$(basename "$conf")" ]; then cp -p "$bdir/$(basename "$conf")" "$conf"; else rm -f "$conf"; fi
systemctl reload "$svc" >/dev/null 2>&1
rm -f "$tmp"
warn "php-fpm$PHP_VER -t / reload rejects the new pool — previous state restored ($("php-fpm$PHP_VER" -t 2>&1 | tail -1))"
return 1
fi
ok "pool [$PANEL_USER]: $conf (socket $sock, sessions $sess)"
fi
rm -f "$tmp"
for _ in $(seq 1 20); do [ -S "$sock" ] && break; sleep 0.5; done
[ -S "$sock" ] || { warn "socket $sock did not appear after reloading $svc"; return 1; }
runtime_ownership "$PANEL_USER"
if ! switch_vhosts "$sock"; then
runtime_ownership "$APACHE_USER"
warn "file ownership went back to $APACHE_USER (Roundcube keeps running in the shared pool)"
return 1
fi
return 0
}
# --- pool helpers end ------------------------------------------------------------------------------------------------
# --- section helpers begin (tests/scripts_test.php sources exactly this block) -------------------------------------
# Sections 3, 4, 6 and 9. System paths are constants here (the tests replace them after sourcing).
JAIL_LOCAL=/etc/fail2ban/jail.local
MAIL_LOG=/var/log/mail.log
LOCALTIME_LINK=/etc/localtime
TIMEZONE_FILE=/etc/timezone
ZONEINFO_DIR=/usr/share/zoneinfo
DOVECOT_CONF_D=/etc/dovecot/conf.d
POSTLOGIN_CONF=/etc/dovecot/conf.d/99-roundcontrol-postlogin.conf
POSTLOGIN_SCRIPT=/usr/local/bin/dovecot-postlogin.sh
POSTLOGIN_CNF=/etc/dovecot/mysql-mailpanel.cnf
# The value of a string option in Roundcube's config.inc.php (the last plain assignment) — read as text, never executed.
rc_cfg_str() {
local cfg="$RC_DIR/config/config.inc.php"
[ -f "$cfg" ] || return 0
grep -E "^[[:space:]]*\\\$config\[['\"]$1['\"]\][[:space:]]*=" "$cfg" | tail -1 \
| sed -nE "s/.*=[[:space:]]*['\"]([^'\"]*)['\"][[:space:]]*;.*/\1/p"
}
# Roundcube's errors log: <log_dir>/errors.log, log_dir from config.inc.php when it is an absolute path, else logs/
# (Roundcube's default, RCUBE_INSTALL_PATH . 'logs/').
rc_errors_log() {
local d
d=$(rc_cfg_str log_dir)
d=${d%/}
[[ "$d" =~ ^/[A-Za-z0-9._/-]+$ ]] && [[ "$d" != *..* ]] || d="$RC_DIR/logs"
printf '%s/errors.log' "$d"
}
# The server's time zone, the default install.php offers for APP_TIMEZONE: the /etc/localtime link, /etc/timezone,
# timedatectl; UTC when none of them names a zone of the tz database. Section 4 writes it into
# /etc/mailpanel-policy.cf: the policy service and the panel must agree about "today" (the daily send limit).
system_tz() {
local z="" link=""
link=$(readlink "$LOCALTIME_LINK" 2>/dev/null) || link=""
case "$link" in *zoneinfo/*) z=${link##*zoneinfo/}; z=${z#posix/}; z=${z#right/} ;; esac
if [ -z "$z" ] && [ -f "$TIMEZONE_FILE" ]; then
z=$(head -n 1 "$TIMEZONE_FILE" | tr -d '[:space:]')
fi
if [ -z "$z" ] && command -v timedatectl >/dev/null; then
z=$(timedatectl show -p Timezone --value 2>/dev/null) || z=""
fi
case "$z" in Etc/UTC|Etc/UCT|Etc/Universal|Etc/Zulu|Etc/GMT|Etc/GMT0|UCT|Universal|Zulu|GMT) z=UTC ;; esac
if [ "$z" != "UTC" ] && { [[ ! "$z" =~ ^[A-Za-z0-9_+-]+(/[A-Za-z0-9_+-]+)*$ ]] || [ ! -f "$ZONEINFO_DIR/$z" ]; }; then
z=UTC
fi
printf '%s' "$z"
}
# Section 9: adds plugins that are not in the first $config['plugins'] list of config.inc.php yet (either quote style),
# in front, in the order given, and prints the names it added. Exit: 0 added, 2 nothing to add, 1 no list found,
# 3 the file could not be written. PHP edits the file as text; it is never included.
rc_add_plugins() {
local cfg=$1
shift
RC_CFG="$cfg" RC_PLUGINS="$*" php <<'PHP'
<?php
$f = (string) getenv('RC_CFG');
$s = (string) file_get_contents($f);
if (preg_match('/^[ \t]*\$config\[[\'"]plugins[\'"]\][ \t]*=[ \t]*(?:\[|array[ \t]*\()(.*?)(?:\]|\))[ \t]*;/ms', $s, $m, PREG_OFFSET_CAPTURE) !== 1) {
exit(1);
}
$add = [];
foreach (preg_split('/\s+/', trim((string) getenv('RC_PLUGINS')), -1, PREG_SPLIT_NO_EMPTY) ?: [] as $p) {
if (preg_match('/[\'"]' . preg_quote($p, '/') . '[\'"]/', $m[1][0]) !== 1) {
$add[] = $p;
}
}
if ($add === []) {
exit(2);
}
$at = $m[1][1]; // right after "[" or "array("
$n = substr($s, 0, $at) . implode('', array_map(static fn(string $p): string => "'$p', ", $add)) . substr($s, $at);
if (file_put_contents($f, $n) !== strlen($n)) {
exit(3);
}
echo implode(' ', $add);
PHP
}
# Can Fail2Ban read the systemd journal? (backend = systemd needs python3-systemd)
f2b_journal_ok() {
command -v python3 >/dev/null && python3 -c 'import systemd.journal' >/dev/null 2>&1
}
# Section 3: /etc/fail2ban/jail.local from system/fail2ban/jail.local.example when there is none (Debian 13 ships none;
# an existing file is never touched). The template holds exactly the options the "Fail2Ban" tab edits — the tab only
# rewrites lines that exist — plus the backends: roundcube-auth reads Roundcube's errors.log (polling) or the journal
# (log_driver 'syslog'); dovecot and postfix-sasl read the journal (Debian 13 has no /var/log/mail.log without
# rsyslog); recidive reads /var/log/fail2ban.log. fail2ban-client -t must pass before and with the new file; a file it
# rejects is removed again.
create_jail_local() {
local tmp out rcbe=polling mailbe=systemd logfile
if [ -e "$JAIL_LOCAL" ] || [ -L "$JAIL_LOCAL" ]; then
ok "$JAIL_LOCAL exists — left as it is"
return 0
fi
if command -v fail2ban-client >/dev/null && ! out=$(fail2ban-client -t 2>&1); then
warn "fail2ban-client -t reports an error BEFORE any change — not creating $JAIL_LOCAL: $(tail -n 2 <<<"$out" | tr '\n' ' ')"
return 1
fi
if [ "$(rc_cfg_str log_driver)" = "syslog" ]; then
rcbe=systemd
fi
logfile=$(rc_errors_log)
if ! f2b_journal_ok; then
if [ -f "$MAIL_LOG" ]; then
mailbe=auto
warn "python3-systemd is missing: the dovecot and postfix-sasl jails read $MAIL_LOG instead of the journal"
else
warn "python3-systemd is missing and there is no $MAIL_LOG: the dovecot and postfix-sasl jails need the journal — apt install python3-systemd"
fi
fi
tmp=$(mktemp) || return 1
if ! render_template "$SRC/system/fail2ban/jail.local.example" @RC_LOG@ "$logfile" | render_template - @RC_BACKEND@ "$rcbe" \
| render_template - @MAIL_BACKEND@ "$mailbe" > "$tmp" || grep -qE '@[A-Z_]+@' "$tmp"; then
rm -f "$tmp"
warn "could not render $SRC/system/fail2ban/jail.local.example"
return 1
fi
# the roundcube-auth jail needs the file it watches (fail2ban-client -t refuses a missing log): an empty one, owned
# like the rest of logs/ by the PHP user that writes it
if [ "$rcbe" = "polling" ] && [ ! -e "$logfile" ] && [ -d "${logfile%/*}" ]; then
install -o "$RUN_USER" -g "$RUN_USER" -m 640 /dev/null "$logfile" && ok "empty $logfile for the roundcube-auth jail (owner $RUN_USER)"
fi
if ! install -o root -g root -m 644 "$tmp" "$JAIL_LOCAL"; then
rm -f "$tmp"
return 1
fi
rm -f "$tmp"
if command -v fail2ban-client >/dev/null && ! out=$(fail2ban-client -t 2>&1); then
rm -f "$JAIL_LOCAL"
warn "fail2ban-client -t rejects the new jail.local — removed again: $(tail -n 3 <<<"$out" | tr '\n' ' ')"
return 1
fi
ok "$JAIL_LOCAL (root:root 644): [DEFAULT], roundcube-auth ($rcbe, $logfile), dovecot and postfix-sasl ($mailbe), recidive — the values are edited in the panel's Fail2Ban tab"
}
# Does Dovecot already run the post-login script (wired by hand in dovecot.conf or by this installer)?
dovecot_postlogin_active() {
local conf
command -v doveconf >/dev/null || return 1
conf=$(doveconf -n 2>/dev/null) || return 1
grep -qF -- "$POSTLOGIN_SCRIPT" <<<"$conf"
}
# Section 6: wires the post-login script into Dovecot as a conf.d file of its own (system/dovecot/postlogin.conf.snippet).
# Dovecot then starts every IMAP/POP3 session through the script (as the dovecot user): if the script cannot run, no
# IMAP/POP3 login works. So the script, its database file and the listeners' user are checked first, and the file is
# removed again when doveconf -n fails with it, when dovecot.conf does not read conf.d/, or when the reload fails.
wire_dovecot_postlogin() {
local out
command -v doveconf >/dev/null || { warn "doveconf not found — is Dovecot installed?"; return 1; }
if ! out=$(doveconf -n 2>&1); then
warn "doveconf -n reports an error BEFORE any change — not touching Dovecot: $(head -n 2 <<<"$out" | tr '\n' ' ')"
return 1
fi
if grep -qF -- "$POSTLOGIN_SCRIPT" <<<"$out"; then
ok "Dovecot already runs $POSTLOGIN_SCRIPT after IMAP/POP3 logins"
return 0
fi
if [ -e "$POSTLOGIN_CONF" ]; then
warn "$POSTLOGIN_CONF exists but doveconf -n does not show it (dovecot.conf does not include conf.d/*.conf) — paste system/dovecot/postlogin.conf.snippet into /etc/dovecot/dovecot.conf by hand"
return 1
fi
if [ ! -f "$POSTLOGIN_SCRIPT" ] || ! sudo -u dovecot test -x "$POSTLOGIN_SCRIPT"; then
warn "$POSTLOGIN_SCRIPT is missing or not executable for the dovecot user — run section 1 first (every IMAP/POP3 login would fail)"
return 1
fi
if [ ! -f "$POSTLOGIN_CNF" ]; then
warn "$POSTLOGIN_CNF is missing — the script could not record anything"
return 1
fi
if ! id -u vmail >/dev/null 2>&1; then
warn "user vmail does not exist — the snippet's listeners belong to vmail (the user that runs imap/pop3); adapt it and install it by hand"
return 1
fi
if [ ! -d "$DOVECOT_CONF_D" ]; then
warn "$DOVECOT_CONF_D not found — paste system/dovecot/postlogin.conf.snippet into /etc/dovecot/dovecot.conf by hand"
return 1
fi
install -o root -g root -m 644 "$SRC/system/dovecot/postlogin.conf.snippet" "$POSTLOGIN_CONF" || return 1
if ! out=$(doveconf -n 2>&1); then
rm -f "$POSTLOGIN_CONF"
warn "doveconf -n rejects the configuration with $POSTLOGIN_CONF — removed again: $(head -n 2 <<<"$out" | tr '\n' ' ')"
return 1
fi
if ! grep -qF -- "$POSTLOGIN_SCRIPT" <<<"$out"; then
rm -f "$POSTLOGIN_CONF"
warn "dovecot.conf does not include conf.d/*.conf — $POSTLOGIN_CONF removed again; paste system/dovecot/postlogin.conf.snippet into /etc/dovecot/dovecot.conf by hand"
return 1
fi
if ! systemctl reload dovecot; then
rm -f "$POSTLOGIN_CONF"
systemctl reload dovecot >/dev/null 2>&1
warn "systemctl reload dovecot failed — $POSTLOGIN_CONF removed again and the previous configuration reloaded"
return 1
fi
ok "Dovecot: $POSTLOGIN_CONF (IMAP/POP3 post-login -> $POSTLOGIN_SCRIPT), doveconf -n OK, reloaded"
echo " Check now: log in over IMAP from a mail client — it must work, and the login appears in the panel (last login, IPs)."
echo " If IMAP or POP3 logins fail: rm $POSTLOGIN_CONF && systemctl reload dovecot"
}
# Section 3 (1.4.0): the bantime of [panel-tempban] in FILE ('' = no such section or option).
tempban_bantime() {
[ -f "$1" ] || return 0
awk '/^[ \t]*\[/ { s = $0 } s ~ /^[ \t]*\[panel-tempban\][ \t]*$/ && /^[ \t]*bantime[ \t]*=/ { sub(/^[^=]*=[ \t]*/, ""); sub(/[ \t]+$/, ""); print; exit }' "$1"
}
# Section 3 (1.4.0): exactly `bantime = 90d` in [panel-tempban] of FILE (the bantime line replaced, or added right under
# the section header; every other line untouched). Written IN PLACE (php-fpm holds jail.local as a bind mount of that
# inode), after a copy FILE.bak-<time>. Returns 1 when FILE has no [panel-tempban].
tempban_set_90d() {
local f=$1 tmp
grep -qE '^[[:space:]]*\[panel-tempban\][[:space:]]*$' "$f" 2>/dev/null || return 1
tmp=$(mktemp) || return 1
awk '
NR == FNR { if ($0 ~ /^[ \t]*\[/) s = ($0 ~ /^[ \t]*\[panel-tempban\][ \t]*$/); else if (s && $0 ~ /^[ \t]*bantime[ \t]*=/) has = 1; next }
/^[ \t]*\[/ { insec = ($0 ~ /^[ \t]*\[panel-tempban\][ \t]*$/); print; if (insec && !has) print "bantime = 90d"; next }
insec && /^[ \t]*bantime[ \t]*=/ { if (!done) { print "bantime = 90d"; done = 1 } next }
{ print }
' "$f" "$f" > "$tmp" || { rm -f "$tmp"; return 1; }
cp -p "$f" "$f.bak-$(date +%s)" && cat "$tmp" > "$f"
local rc=$?
rm -f "$tmp"
return $rc
}
# --- section helpers end ---------------------------------------------------------------------------------------------
# Instaluje plik reguł jako /etc/sudoers.d/mailpanel tak, żeby sudo NIGDY nie zostało zepsute: plik sprawdzany sam
# (visudo -cf), kładziony pod nazwą z kropką (sudo takie pliki pomija), przenoszony na miejsce i dopiero wtedy sprawdzana
# jest CAŁA konfiguracja (visudo -c widzi np. zduplikowany alias w dwóch plikach — tego visudo -cf PLIK nie zobaczy, a to
# błąd składni całego sudo). Przy błędzie wraca poprzedni plik (albo nowy jest usuwany) i funkcja zwraca 1.
install_sudoers_file() {
local src=$1 dst=/etc/sudoers.d/mailpanel stage=/etc/sudoers.d/mailpanel.rc-new prev="" why
if ! visudo -c >/dev/null 2>&1; then
warn "visudo -c reports an error BEFORE any change — not touching sudoers, fix it first: visudo -c"
return 1
fi
visudo -cf "$src" >/dev/null 2>&1 || { warn "visudo -cf rejects the rules file ($src)"; return 1; }
install -o root -g root -m 440 "$src" "$stage" || { rm -f "$stage"; return 1; }
if [ -f "$dst" ]; then
prev=$(mktemp /root/sudoers-mailpanel.prev.XXXXXX) && cp -p "$dst" "$prev" || { rm -f "$stage"; return 1; }
fi
mv -f "$stage" "$dst" || { rm -f "$stage"; return 1; }
if visudo -c >/dev/null 2>&1; then
[ -n "$prev" ] && rm -f "$prev"
return 0
fi
why=$(visudo -c 2>&1 | grep -v -E 'parsed OK$' | head -3 | tr '\n' ' ')
if [ -n "$prev" ]; then cp -p "$prev" "$dst" && rm -f "$prev"; else rm -f "$dst"; fi
warn "with the new file the WHOLE sudo configuration fails visudo -c — previous state restored. Reason: $why"
visudo -c >/dev/null 2>&1 || warn "WARNING: visudo -c still reports an error — fix it NOW, before you close the root shell"
return 1
}
[ "$(id -u)" -eq 0 ] || die "Run as root (sudo bash install.sh)."
[[ "$PANEL_USER" =~ ^[a-z_][a-z0-9_-]{0,30}$ ]] || die "PANEL_USER='$PANEL_USER' is not a plain user name."
[[ "$APACHE_USER" =~ ^[a-z_][a-z0-9_-]{0,30}$ ]] || die "APACHE_USER='$APACHE_USER' is not a plain user name."
id -u "$APACHE_USER" >/dev/null 2>&1 || die "The Apache user '$APACHE_USER' does not exist (set APACHE_USER=...)."
case "$PANEL_USER" in root|daemon|nobody|bin|sys|vmail|dovecot|postfix|mysql) die "PANEL_USER=$PANEL_USER — choose a separate user (e.g. mailweb).";; esac
[ -n "$PHP_VER" ] || PHP_VER=$(detect_php_ver || true)
[ -z "$PHP_VER" ] || [[ "$PHP_VER" =~ ^[0-9]+\.[0-9]+$ ]] || die "PHP_VER='$PHP_VER' — expected e.g. 8.5"
[[ "$RC_DIR" =~ ^/[A-Za-z0-9._/-]+$ ]] || die "RC_DIR='$RC_DIR' must be an absolute path without spaces or special characters."
[ -d "$RC_DIR" ] || die "$RC_DIR not found (set RC_DIR=... if the path is different)."
command -v mysql >/dev/null || warn "mysql client not found in PATH — sections 0/4/5/6 need MariaDB/MySQL."
# Skrypt celuje w Debiana 13 (składnia Dovecot 2.4 itd.) — na innym systemie
# tylko ostrzegamy, decyzja należy do operatora.
if [ -r /etc/os-release ]; then
. /etc/os-release
if [ "${ID:-}" != "debian" ] || [ "${VERSION_ID:-}" != "13" ]; then
warn "Detected: ${PRETTY_NAME:-unknown system}. The installer is tested on Debian 13."
ask "Continue anyway" || die "Aborted."
fi
fi
echo -e "${c_b} Roundcube panel — system integrations${c_rst}"
hr
set_run_user
echo "Roundcube: $RC_DIR | panel PHP pool: $PANEL_USER | Apache: $APACHE_USER | PHP-FPM: ${PHP_VER:-not detected}"
echo "Roundcube PHP is currently running as: $RUN_USER"
hr
declare -A NEWPW=() # hasła kont DB utworzonych w tym uruchomieniu (nigdzie indziej nie są zapisane)
# --- 0. Baza danych (mailserver + konta + schemat + granty) ---
if ask "0) Create the mailserver database and the mailuser/mailpanel/mailpostlogin accounts, import the schema and set the grants"; then
if ! mysql -e 'SELECT 1' >/dev/null 2>&1; then
warn "the mysql client cannot connect as root (socket) — skipping section 0."
else
S0=1
run_sql "CREATE DATABASE IF NOT EXISTS mailserver CHARACTER SET utf8mb4;" || S0=0
# Konto, które już istnieje, zostaje NIETKNIĘTE (hasło, granty mailuser). Nowe dostaje losowe hasło, wypisane
# niżej — to jedyne miejsce, gdzie je zobaczysz. (1.0.x robiło ALTER USER przy każdym uruchomieniu.)
for u in mailuser mailpanel mailpostlogin; do
if db_user_exists "$u"; then
ok "DB account $u exists — password and (for mailuser) grants unchanged"
else
pw=$(gen_pw)
if run_sql "CREATE USER '$u'@'localhost' IDENTIFIED BY '$pw';"; then
NEWPW[$u]=$pw
else
S0=0
fi
fi
done
if [ "$S0" -eq 1 ] && [ -f "$SRC/sql/panel_schema.sql" ]; then
ERRF=$(mktemp)
if mysql mailserver < "$SRC/sql/panel_schema.sql" 2>"$ERRF"; then
ok "schema panel_schema.sql imported (IF NOT EXISTS — existing data untouched)"
if [ -n "${NEWPW[mailuser]:-}" ]; then
run_sql "GRANT SELECT ON mailserver.virtual_domains TO 'mailuser'@'localhost';
GRANT SELECT ON mailserver.virtual_users TO 'mailuser'@'localhost';
GRANT SELECT ON mailserver.virtual_aliases TO 'mailuser'@'localhost';
FLUSH PRIVILEGES;" && ok "new mailuser account: SELECT on virtual_domains / virtual_users / virtual_aliases"
fi
# granty aplikacji: per tabela, odbierane i nadawane od nowa (idempotentnie; to również sposób na zawężenie istniejącego konta)
if mysql < "$SRC/sql/panel_grants.sql" 2>"$ERRF"; then
ok "mailpanel grants (sql/panel_grants.sql): per table, no INSERT/UPDATE rights on aliases"
else
warn "sql/panel_grants.sql failed: $(head -1 "$ERRF")"
fi
if mysql < "$SRC/sql/postlogin_grants.sql" 2>"$ERRF"; then
ok "mailpostlogin grants: only EXECUTE on sp_register_login"
else
warn "sql/postlogin_grants.sql failed: $(head -1 "$ERRF")"
fi
else
warn "schema import failed: $(head -1 "$ERRF")"
fi
rm -f "$ERRF"
elif [ "$S0" -eq 1 ]; then
warn "Missing $SRC/sql/panel_schema.sql — import the schema by hand, then sql/panel_grants.sql and sql/postlogin_grants.sql."
fi
[ -n "${NEWPW[mailuser]:-}" ] && warn "NEW mailuser password (put it in /etc/postfix/mysql-virtual-*.cf and dovecot.conf): ${NEWPW[mailuser]}"
[ -n "${NEWPW[mailpanel]:-}" ] && warn "NEW mailpanel password (enter it in install.php / put it in .env as PANEL_DB_PASS): ${NEWPW[mailpanel]}"
[ -n "${NEWPW[mailpostlogin]:-}" ] && ok "new mailpostlogin account — section 6 will save its password to /etc/dovecot/mysql-mailpanel.cnf"
[ "${#NEWPW[@]}" -gt 0 ] && warn "The passwords above are shown ONLY NOW (except mailpostlogin, whose password goes to a file in section 6)."
fi
fi
hr
# --- 1. Skrypty /usr/local/bin ---
if ask "1) Install the scripts to /usr/local/bin"; then
install -o root -g root -m 755 "$SRC/scripts/mailpanel-scan.sh" /usr/local/bin/ && ok "mailpanel-scan.sh (755)"
install -o root -g root -m 700 "$SRC/scripts/mailpanel-delete-user.sh" /usr/local/bin/ && ok "mailpanel-delete-user.sh (700)"
install -o root -g root -m 755 "$SRC/scripts/update-postfix-limits.sh" /usr/local/bin/ && ok "update-postfix-limits.sh (755)"
install -o root -g root -m 755 "$SRC/scripts/mailpanel-policy-daily.php" /usr/local/bin/ && ok "mailpanel-policy-daily.php (755)"
if getent group dovecot >/dev/null; then
install -o root -g dovecot -m 750 "$SRC/scripts/dovecot-postlogin.sh" /usr/local/bin/ && ok "dovecot-postlogin.sh (750 root:dovecot)"
else
install -o root -g root -m 755 "$SRC/scripts/dovecot-postlogin.sh" /usr/local/bin/ && warn "dovecot-postlogin.sh (the dovecot group does not exist)"
fi
fi
# --- 1b. Osobna pula PHP-FPM dla Roundcube i panelu ---
# Domyślnie jedna pula [www] (www-data) obsługuje WSZYSTKIE strony serwera: luka w dowolnej z nich czyta wtedy hasło
# bazy panelu, des_key Roundcube i używa reguł sudo panelu. Osobna pula = osobny użytkownik ($PANEL_USER): sekrety
# root:$PANEL_USER 640, reguły sudo dla $PANEL_USER, sesje w /var/lib/php/sessions-$PANEL_USER. Inne strony bez zmian.
hr
if pool_ready; then
ok "1b) the separate pool [$PANEL_USER] is already running ($(pool_conf), vhost -> $(pool_sock))"
if ask " Refresh it (pool file from the template, file ownership, handler in the vhosts)"; then
setup_pool || warn "refreshing the pool failed (see the messages above)"
fi
elif [ "$PANEL_USER" = "$APACHE_USER" ]; then
warn "1b) PANEL_USER=$PANEL_USER is the Apache user — Roundcube and the panel stay in the shared pool (no separate pool)"
elif [ -z "$PHP_VER" ]; then
warn "1b) PHP-FPM version not detected (/etc/php/*/fpm) — set PHP_VER=8.x; Roundcube stays in the shared pool"
elif ask "1b) Create a separate PHP-FPM pool [$PANEL_USER] for Roundcube and the panel and switch the Apache vhost(s) to it"; then
setup_pool || warn "the separate pool was NOT enabled — Roundcube keeps running in the shared pool (see the messages above)"
fi
set_run_user
ok "PHP of Roundcube and the panel runs as: $RUN_USER (this user gets the sudo rules, the secrets and the write directories)"
# --- 2. sudoers ---
hr
if ask "2) Install the Fail2Ban helper and the sudoers rules for $RUN_USER (/etc/sudoers.d/mailpanel), remove the old rules with wildcards"; then
# Reguły mają DOKŁADNE argumenty (^regex$) — wymagają sudo >= 1.9.10 (Debian 13: 1.9.16).
# Starsze sudo traktuje ^...$ dosłownie, więc nic by nie pasowało; nie instalujemy wtedy niczego.
SV=$(sudo -V 2>/dev/null | head -1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true)
if [ -z "$SV" ] || [ "$(printf '%s\n1.9.10\n' "$SV" | sort -V | head -1)" != "1.9.10" ]; then
warn "sudo ${SV:-unknown} < 1.9.10 — no ^regex\$ arguments; sudoers NOT installed (upgrade sudo)"
elif install_f2b_helper; then # helper najpierw — reguła sudoers wskazuje na niego
# jedyna zmienna linia pliku: User_Alias z użytkownikiem, który NAPRAWDĘ wykonuje PHP panelu (sekcja 1b)
tmp=$(mktemp)
render_template "$SRC/system/sudoers.d/mailpanel" "User_Alias ROUNDCONTROL_USER = mailweb" "User_Alias ROUNDCONTROL_USER = $RUN_USER" > "$tmp"
SUDOERS_OK=0
if ! grep -qx "User_Alias ROUNDCONTROL_USER = $RUN_USER" "$tmp"; then
warn "no User_Alias line found in $SRC/system/sudoers.d/mailpanel — sudoers NOT installed"
elif install_sudoers_file "$tmp"; then
SUDOERS_OK=1
ok "sudoers for $RUN_USER installed: the file (visudo -cf) and the WHOLE configuration (visudo -c) are valid; exact arguments"
else
warn "sudoers NOT installed (see the message above; check $SRC/system/sudoers.d/mailpanel and: visudo -c)"
fi
rm -f "$tmp"
# sudo przyznaje polecenie, gdy pasuje KTÓRAKOLWIEK reguła: jedna stara reguła z wildcardem (1.0: fail2ban-client,
# doveadm, mailpanel-scan.sh; w /etc/sudoers albo w osobnych plikach mailpanel-*) unieważnia wszystkie dokładne.
# Sprawdzamy użytkownika Apache (stare reguły www-data; z osobną pulą www-data nie ma mieć ŻADNEJ reguły panelu)
# i użytkownika puli (gdyby ktoś dopisał mu coś ręcznie poza /etc/sudoers.d/mailpanel).
if [ "$SUDOERS_OK" -eq 1 ]; then
for CU in $(printf '%s\n' "$APACHE_USER" "$RUN_USER" | sort -u); do
OLD=$(WEB_USER="$CU" bash "$SRC/scripts/mailpanel-sudoers-cleanup.sh" 2>&1); rc=$?
if [ "$rc" -ne 0 ]; then
echo -e "${c_red}✗ OLD panel sudo rules for $CU outside /etc/sudoers.d/mailpanel — wildcards give a path to root:${c_rst}"
echo "$OLD" | sed 's/^/ /'
if ask " Comment them out now (backups in /root/sudoers-backup-*; every file and the whole configuration validated by visudo)"; then
WEB_USER="$CU" bash "$SRC/scripts/mailpanel-sudoers-cleanup.sh" --apply || warn "some rules need manual editing (visudo) — see above"
fi
else
ok "no old panel sudo rules for $CU outside /etc/sudoers.d/mailpanel"
fi
done
else
warn "The new sudoers file was not installed, so I am not touching the old rules."
fi
else
warn "Installing the Fail2Ban helper failed — sudoers NOT installed."
fi
fi
# --- 3. Fail2Ban ---
hr
if ask "3) Configure Fail2Ban (panel filters + jails)"; then
install -o root -g root -m 644 "$SRC/system/fail2ban/filter.d/panel-permaban.conf" /etc/fail2ban/filter.d/ && ok "filter panel-permaban"
install -o root -g root -m 644 "$SRC/system/fail2ban/filter.d/panel-tempban.conf" /etc/fail2ban/filter.d/ && ok "filter panel-tempban"
install -o root -g root -m 644 "$SRC/system/fail2ban/filter.d/mailpanel-portal.conf" /etc/fail2ban/filter.d/ && ok "filter mailpanel-portal (jail disabled by default)"
[ -f /etc/fail2ban/filter.d/roundcube-auth.conf ] || install -o root -g root -m 644 "$SRC/system/fail2ban/filter.d/roundcube-auth.conf" /etc/fail2ban/filter.d/
touch /var/log/mailpanel-permaban.log /var/log/mailpanel-tempban.log
ok "permaban/tempban logs ready"
# Jaile panelu żyją w OSOBNYM pliku jail.d/ — nadpisanie = idempotencja,
# jail.local zostaje czysty dla jaili standardowych (edytowanych z panelu).
# 1.4.0: [panel-tempban] keeps bantime = 90d — Fail2Ban ends every manual ban by the jail's CURRENT bantime and the
# panel lifts its timed bans itself (section 11b), so a shorter value would cut every longer ban from the panel.
TEMPBAN_FILE=/etc/fail2ban/jail.d/mailpanel.local
if [ -f /etc/fail2ban/jail.local ] && grep -q '\[panel-permaban\]' /etc/fail2ban/jail.local; then
warn "the panel jails are (legacy) in jail.local — leaving them there; consider moving them to jail.d/mailpanel.local"
TEMPBAN_FILE=/etc/fail2ban/jail.local
fi
TEMPBAN_WAS=$(tempban_bantime "$TEMPBAN_FILE")
if [ "$TEMPBAN_FILE" = /etc/fail2ban/jail.local ]; then
if grep -qE '^[[:space:]]*\[panel-tempban\][[:space:]]*$' "$TEMPBAN_FILE" && [ "$TEMPBAN_WAS" != "90d" ]; then
tempban_set_90d "$TEMPBAN_FILE" && ok "jail.local: [panel-tempban] bantime = 90d (was ${TEMPBAN_WAS:-not set}; a copy jail.local.bak-*)" \
|| warn "could not set bantime = 90d in [panel-tempban] of jail.local — set it by hand (README: Upgrading from 1.3.2)"
TEMPBAN_WAS=${TEMPBAN_WAS:-unset}
fi
else
install -o root -g root -m 644 "$SRC/system/fail2ban/jail.d/mailpanel.local" /etc/fail2ban/jail.d/mailpanel.local
ok "panel jails: /etc/fail2ban/jail.d/mailpanel.local"
fi
TEMPBAN_NOW=$(tempban_bantime "$TEMPBAN_FILE")
# Debian 13 ships no jail.local, and the Fail2Ban tab edits only lines that exist: offer the panel's template.
if [ ! -e /etc/fail2ban/jail.local ] && [ ! -L /etc/fail2ban/jail.local ]; then
if ask " Create /etc/fail2ban/jail.local from system/fail2ban/jail.local.example ([DEFAULT] + the jails roundcube-auth, dovecot, postfix-sasl, recidive with the options the Fail2Ban tab edits; root:root 644)"; then
create_jail_local || warn "jail.local NOT created (see the message above)"
fi
fi
# jail.local należy do ROOTA (root:root 644) — panel go nie zapisuje (patrz install_f2b_helper).
# Stare kopie jail.local.bak-* (robione kiedyś przez panel) też przechodzą na roota, 600.
if [ -f /etc/fail2ban/jail.local ]; then
install_f2b_helper
chown root:root /etc/fail2ban/jail.local && chmod 644 /etc/fail2ban/jail.local
for b in /etc/fail2ban/jail.local.bak-*; do [ -e "$b" ] && chown root:root "$b" && chmod 600 "$b"; done
ok "jail.local: root:root 644 (the panel only proposes changes; the mailpanel-f2b-apply helper writes them)"
else
warn "Missing /etc/fail2ban/jail.local — the Fail2Ban tab has nothing to edit. Run section 3 again and let it create one from system/fail2ban/jail.local.example (root:root 644)."
fi
if command -v fail2ban-client >/dev/null; then
if fail2ban-client -t >/dev/null 2>&1; then
fail2ban-client reload >/dev/null 2>&1 && ok "configuration valid, fail2ban reloaded"
# a changed bantime of panel-tempban: RESTART that jail only — a starting jail restores its bans from Fail2Ban's
# database, each with its own stored time (with 24 h, up to 1.3.2, the longer bans had been dropped on a restart)
if [ -n "$TEMPBAN_WAS" ] && [ "$TEMPBAN_WAS" != "$TEMPBAN_NOW" ] && fail2ban-client status panel-tempban >/dev/null 2>&1; then
if fail2ban-client reload --restart panel-tempban >/dev/null 2>&1; then
ok "panel-tempban restarted with bantime $TEMPBAN_NOW (the bans still within their own time came back)"
else
warn "fail2ban-client reload --restart panel-tempban failed — run it by hand"
fi
fi
else
warn "fail2ban-client -t reports a configuration error — NOT reloaded (check: fail2ban-client -t)"
fi
fi
if [ -f /etc/fail2ban/jail.local ] && [ -x /usr/local/sbin/mailpanel-f2b-apply ]; then
# (a) zapis W MIEJSCU pod tym samym sandboxem co php-fpm (ProtectSystem=full + ReadWritePaths=jail.local) —
# dokładnie to, czego 1.0.x nigdy nie sprawdzało (test z roota nie widział read-only /etc)
if command -v systemd-run >/dev/null; then
R=$(systemd-run --quiet --wait --pipe --collect -p ProtectSystem=full -p ReadWritePaths=/etc/fail2ban/jail.local \
/usr/local/sbin/mailpanel-f2b-apply --selftest 2>&1)
if [ "$R" = "selftest ok" ]; then
ok "helper under the php-fpm sandbox (ProtectSystem=full, ReadWritePaths=jail.local): in-place write + backups work"
else
warn "helper under the sandbox did NOT pass the selftest: ${R:-no response}"
fi
else
warn "systemd-run not found — skipped testing the helper under the php-fpm sandbox"
fi
# (b) przez sudo jako użytkownik panelu: propozycja identyczna z jail.local => "unchanged" (przed 1.3.1: "bez zmian"; nic nie zapisuje).
# Sprawdza regułę sudoers, argumenty (""), czytanie ze stdin i uprawnienia.
if grep -q 'mailpanel-f2b-apply' /etc/sudoers.d/mailpanel 2>/dev/null; then
R=$(sudo -u "$RUN_USER" sudo -n /usr/local/sbin/mailpanel-f2b-apply < /etc/fail2ban/jail.local 2>&1)
if [ "$R" = "unchanged" ] || [ "$R" = "bez zmian" ]; then # "bez zmian" = output of helpers before 1.3.1
ok "helper test as $RUN_USER via sudo: 'unchanged' (sudoers + helper + stdin work)"
else
warn "helper test as $RUN_USER did not pass: ${R:-no response} (check step 2 and: sudo -l -U $RUN_USER)"
fi
fi
fi
fi
# --- 4. Policy service (dzienny limit wysyłki) ---
hr
if ask "4) Install the policy service (daily sending limit — counts recipients)"; then
# Idempotencja: istniejące hasło z /etc/mailpanel-policy.cf jest używane ponownie (po sprawdzeniu kształtu), konto
# DB istniejące — nietknięte. Nowe konto/hasło tylko, gdy ich brak.
PW=""
if [ -f /etc/mailpanel-policy.cf ]; then
PW=$(file_kv /etc/mailpanel-policy.cf password || true)
if [ -n "$PW" ] && ! valid_secret "$PW"; then
warn "the password in /etc/mailpanel-policy.cf contains disallowed characters (allowed: A-Za-z0-9._~+-, 16-128) — NOT using it, a new one is needed"
PW=""
fi
fi
POLICY_OK=1
if db_user_exists mailpolicy; then
if [ -z "$PW" ]; then
if ask " DB account mailpolicy exists, but I do not know its password — set a new one (its only user: the file I am about to write)"; then
PW=$(gen_pw)
run_sql "ALTER USER 'mailpolicy'@'localhost' IDENTIFIED BY '$PW';" || POLICY_OK=0
else
POLICY_OK=0
fi
fi
else
[ -n "$PW" ] || PW=$(gen_pw)
run_sql "CREATE USER 'mailpolicy'@'localhost' IDENTIFIED BY '$PW';" || POLICY_OK=0
fi
if [ "$POLICY_OK" -eq 1 ] && run_sql "GRANT SELECT ON mailserver.virtual_users TO 'mailpolicy'@'localhost';
GRANT SELECT ON mailserver.app_settings TO 'mailpolicy'@'localhost';
GRANT SELECT, INSERT, UPDATE ON mailserver.send_counters TO 'mailpolicy'@'localhost';
FLUSH PRIVILEGES;"; then
ok "DB user mailpolicy + grants"
if [ ! -f /etc/mailpanel-policy.cf ] || [ "$(file_kv /etc/mailpanel-policy.cf password || true)" != "$PW" ]; then
[ -f /etc/mailpanel-policy.cf ] && cp -p /etc/mailpanel-policy.cf "/etc/mailpanel-policy.cf.bak-$(date +%s)"
# the script's PHP time zone = the server's (the default install.php offers for APP_TIMEZONE as well); the day of
# the counter itself comes from the database clock since 1.4.0, the same one the panel reads
( umask 077; render_template "$SRC/system/mailpanel-policy.cf.example" HASLO_MAILPOLICY "$PW" \
| render_template - @TIMEZONE@ "$(system_tz)" > /etc/mailpanel-policy.cf.new ) \
&& mv /etc/mailpanel-policy.cf.new /etc/mailpanel-policy.cf
fi
chown root:nogroup /etc/mailpanel-policy.cf && chmod 640 /etc/mailpanel-policy.cf
PTZ=$(file_kv /etc/mailpanel-policy.cf timezone || true)
ok "/etc/mailpanel-policy.cf (640 root:nogroup; timezone ${PTZ:-Europe/Warsaw, the default of the script}; the day of the counter = the database clock)"
install -o root -g root -m 755 "$SRC/scripts/mailpanel-policy-daily.php" /usr/local/bin/
echo " Postfix: finish by hand following system/postfix/master.cf.snippet:"
echo " - in submission/submissions add -o smtpd_data_restrictions=\$mua_data_restrictions and -o smtpd_recipient_limit=50"
echo " - main.cf: mua_data_restrictions = reject_unauth_pipelining, check_policy_service unix:private/policy-daily"
echo " - master.cf: the spawn service policy-daily"
echo " - Roundcube must send through 587 with authentication (smtp_host tls://localhost:587, smtp_user %u)"
echo " - postfix check && systemctl reload postfix"
fi
fi
# --- 5. Plugin password (samoobsługa zmiany hasła) ---
hr
if ask "5) Configure the 'password' plugin (password change in webmail)"; then
PCFG="$RC_DIR/plugins/password/config.inc.php"
if [ -d "$RC_DIR/plugins/password" ]; then
# Idempotencja: hasło z istniejącego config.inc.php jest zachowywane (po sprawdzeniu kształtu).
PW=""
if [ -f "$PCFG" ] && [ ! -L "$PCFG" ]; then
PW=$(grep -oP "mysql://roundcubepw:\K[^@]+" "$PCFG" | head -1 || true)
if [ -n "$PW" ] && ! valid_secret "$PW"; then
warn "the password in $PCFG contains disallowed characters — NOT using it"
PW=""
fi
fi
PLUG_OK=1
if db_user_exists roundcubepw; then
if [ -z "$PW" ]; then
if ask " DB account roundcubepw exists, but I do not know its password — set a new one (its only user: the plugin file)"; then
PW=$(gen_pw)
run_sql "ALTER USER 'roundcubepw'@'localhost' IDENTIFIED BY '$PW';" || PLUG_OK=0
else
PLUG_OK=0
fi
fi
else
[ -n "$PW" ] || PW=$(gen_pw)
run_sql "CREATE USER 'roundcubepw'@'localhost' IDENTIFIED BY '$PW';" || PLUG_OK=0
fi
if [ "$PLUG_OK" -eq 1 ] && run_sql "GRANT SELECT, UPDATE ON mailserver.virtual_users TO 'roundcubepw'@'localhost';
FLUSH PRIVILEGES;"; then
ok "DB user roundcubepw + grants"
if [ ! -f "$PCFG" ] || ! grep -qF "mysql://roundcubepw:$PW@" "$PCFG"; then
[ -f "$PCFG" ] && cp -p "$PCFG" "$PCFG.bak-$(date +%s)"
( umask 077; render_template "$SRC/plugins/password/config.inc.php.example" SILNE_HASLO_POLICY "$PW" > "$PCFG.new" ) \
&& mv "$PCFG.new" "$PCFG"
fi
chown root:"$RUN_USER" "$PCFG" && chmod 640 "$PCFG"
ok "$PCFG (640 root:$RUN_USER)"
grep -q "'password'" "$RC_DIR/config/config.inc.php" \
&& ok "'password' is already in \$config['plugins']" \
|| warn "'password' is not in \$config['plugins'] — section 9 will add it"
rm -rf "$RC_DIR/temp/"* 2>/dev/null
fi
else
warn "Missing $RC_DIR/plugins/password — the plugin ships with Roundcube; install Roundcube with its plugins."
fi
fi
# --- 6. Rejestracja logowań IMAP/POP3 (dovecot postlogin -> sp_register_login) ---
hr
if ask "6) Configure /etc/dovecot/mysql-mailpanel.cnf (mailpostlogin account: EXECUTE on a single procedure only)"; then
CNF=/etc/dovecot/mysql-mailpanel.cnf
CUR_USER=""
[ -f "$CNF" ] && CUR_USER=$(file_kv "$CNF" user || true)
if [ "$CUR_USER" = "mailpostlogin" ]; then
ok "$CNF already uses the mailpostlogin account — unchanged"
else
# Nowy plik albo MIGRACJA z 1.0.x, gdzie skrypt używał konta `mailpanel` (pełny dostęp do bazy panelu).
[ -n "$CUR_USER" ] && warn "$CNF uses the account '$CUR_USER' — switching to mailpostlogin (the file will be backed up)"
PLPW="${NEWPW[mailpostlogin]:-}"
PL_OK=1
if [ -z "$PLPW" ]; then
PLPW=$(gen_pw)
if db_user_exists mailpostlogin; then
# konto istnieje, ale jego hasło znał tylko plik, którego nie ma / który zaraz zastępujemy
ask " DB account mailpostlogin exists, but I do not know its password — set a new one (its only user: this file)" \
&& { run_sql "ALTER USER 'mailpostlogin'@'localhost' IDENTIFIED BY '$PLPW';" || PL_OK=0; } || PL_OK=0
else
run_sql "CREATE USER 'mailpostlogin'@'localhost' IDENTIFIED BY '$PLPW';" || PL_OK=0
fi
fi
if [ "$PL_OK" -eq 1 ]; then
mysql < "$SRC/sql/postlogin_grants.sql" 2>/dev/null || warn "sql/postlogin_grants.sql failed (run section 0 first — schema and procedure)"
[ -f "$CNF" ] && cp -p "$CNF" "$CNF.bak-$(date +%s)"
( umask 077; render_template "$SRC/system/dovecot/mysql-mailpanel.cnf.example" HASLO_MAILPOSTLOGIN "$PLPW" > "$CNF.new" ) \
&& mv "$CNF.new" "$CNF"
chown dovecot:dovecot "$CNF" && chmod 400 "$CNF"
ok "$CNF (400 dovecot:dovecot, mailpostlogin account)"
if mysql --defaults-extra-file="$CNF" -e 'SELECT 1' >/dev/null 2>&1; then
ok "the database connection as mailpostlogin works"
else
warn "the connection as mailpostlogin does NOT work — check the account and $CNF"
fi
fi
fi
# the imap-postlogin / pop3-postlogin services that run the script (without them IMAP/POP3 logins are not recorded)
if dovecot_postlogin_active; then
ok "Dovecot runs $POSTLOGIN_SCRIPT after IMAP/POP3 logins (doveconf -n)"
elif [ -f "$CNF" ] && ask " Wire the script into Dovecot now: $POSTLOGIN_CONF (imap/pop3 post-login services; doveconf -n checked, removed again on any error)"; then
wire_dovecot_postlogin || warn "Dovecot post-login NOT wired (see the message above) — by hand: system/dovecot/postlogin.conf.snippet"
else
echo " Without the imap-postlogin/pop3-postlogin services IMAP/POP3 logins are not recorded (last login, IP history):"
echo " system/dovecot/postlogin.conf.snippet — this section installs it as $POSTLOGIN_CONF."
fi
fi
# --- 7. Symlinki w webroot + właściciel plików ---