Repository navigation
Expand file tree
/
Copy pathroot-w3bs-os.sh
More file actions
executable file
·7429 lines (6757 loc) · 305 KB
/
Copy pathroot-w3bs-os.sh
File metadata and controls
executable file
·7429 lines (6757 loc) · 305 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
#
# root-w3bs-os.sh -- bootstrap AND maintain a W3BS OS (Alpine) server as root.
#
# The W3BS OS counterpart of root-ubuntu.sh: the same accounts, confinement,
# dotfiles, tools and per-user web hosting, on Alpine Linux (musl, apk, OpenRC)
# with the w3bs CLI as the front door for packages and Caddy as the web server.
# One file, so it can be curled onto a machine that has nothing on it yet:
#
# curl -fsSL https://raw.githubusercontent.com/profullstack/cli-tools/master/root-w3bs-os.sh | bash -s -- --refresh
#
# bash, NOT sh. /bin/sh on W3BS OS is busybox ash and this script is bash
# throughout. A stock Alpine image has no bash at all, so the guard below
# installs it (apk add bash) and re-runs itself when it was started from a
# file; piped into sh it cannot re-run itself, and says so in one sentence.
#
# Piping it gives a NON-INTERACTIVE run: stdin is the script, so there is no
# terminal to prompt at, and every prompt in here is guarded on one (see
# `interactive`). To be asked the questions, download it and run it as a file:
#
# curl -fsSLO https://raw.githubusercontent.com/profullstack/cli-tools/master/root-w3bs-os.sh
# chmod +x root-w3bs-os.sh && ./root-w3bs-os.sh # as root
#
# Safe to re-run, and re-running is the supported way to pick up updates: it
# upgrades packages and tooling, refreshes anything it owns, and leaves anything
# a user has since edited alone (see install_managed below).
#
# Runs on W3BS OS (/etc/w3bs-os-release: ID=w3bs-os, EDITION=iso|docker-server|
# docker-desktop) and on plain Alpine. Anything else is refused unless --force:
# Ubuntu and Debian have root-ubuntu.sh. OpenRC is the init it is written for;
# systemd is used instead wherever it is the running init (desktop editions may
# move to it), and a container with no init at all still gets the packages, the
# accounts and the dotfiles.
#
# Packages flow through the w3bs CLI. `w3bs dist-upgrade --yes` (or `w3bs os
# update --yes`, once the CLI has it) upgrades the base system and every W3BS
# package; apk adds the base packages; catalog tools (moshcode, threatcrush,
# hqterm/hqsh, sh1pt) are `w3bs install <id>` per account, falling back to the
# publisher's curl installer only where w3bs is missing.
#
# 1. accounts + groups; users provisioned by an earlier run are picked up
# automatically and refreshed
# 2. w3bs dist-upgrade + apk base packages + a daily unattended upgrade
# 3. ufw (Alpine packages it; dual-stack, IPV6=yes)
# 4. a 2G swapfile, if the box has no swap at all; then zram (zram-init) on
# top of it and earlyoom watching
# 5. dotfiles (.zsh*, .bash*, .ssh*, ...) from $DOTFILES_REPO, if you have one
# 6. oh-my-zsh + plugins, oh-my-tmux, irssi configs; zsh is every login shell
# 7. mise (curl https://mise.run | sh)
# 8. moshcode (w3bs install moshcode.sh/moshcode; --without-moshcode skips)
# + threatcrush CLI (w3bs install threatcrush.com/threatcrush)
# + hqterm/hqsh (w3bs install hqterm.sh/hqterm), so `hqterm connect` works
# + sh1pt (w3bs install sh1pt.com/sh1pt), with --with-sh1pt
# 9. a per-user ssh-agent: a systemd user unit under systemd, one long-lived
# agent per account on a fixed socket everywhere else
# 10. motd from $MOTD_URL
# 11. Caddy: per-user pages, per-user dev apps, automatic HTTPS (one Caddyfile
# per user under /etc/caddy/users, admin API on localhost only)
# 12. confinement: every non-admin account confined (home, /proc, memory,
# tasks, ssh forwarding). Runs after the accounts exist, since which
# side of the line someone is on is decided by their groups.
#
# Usage, as root:
# ./root-w3bs-os.sh # first run, or a refresh
# ./root-w3bs-os.sh alice bob # ...and provision two accounts
# ./root-w3bs-os.sh alice --groups wheel
# ./root-w3bs-os.sh --refresh # update everything, ask nothing
#
# Remote shares (see "remote shares" below for the full options):
# ./root-w3bs-os.sh mount user@host:~/data --via peer
# ./root-w3bs-os.sh mounts # list what is mounted, and who can reach it
# ./root-w3bs-os.sh umount host
# ./root-w3bs-os.sh share /mnt/volume -R # open an existing volume
# ./root-w3bs-os.sh share /mnt/volume --group caddy -R
#
# Confinement (see "confinement" below, or `confine --help`):
# ./root-w3bs-os.sh confine # what is confined on this box, and what is not
# ./root-w3bs-os.sh confine apply # re-apply it without a full run
#
# Every account that is not an admin is confined, by default, with nothing to
# remember. root and anyone in wheel/sudo/admin are exempt. It never takes
# admin rights away from an account that already has them; `confine` names
# those and leaves the decision to you. CONFINE=0 turns the whole thing off.
#
# Sandboxed tenants -- an account that gets its own machine (incus) instead of
# a shell on this one (see "sandboxes" below, or `sandbox --help`):
# ./root-w3bs-os.sh alice --sandbox # alice gets a container, no host shell
# ./root-w3bs-os.sh bob --sandbox=vm # ...and bob gets a real VM
# ./root-w3bs-os.sh sandbox # who the tenants are, and their ports
#
# Accounts and groups (see "accounts" below, or `groups --help`):
# ./root-w3bs-os.sh groups # every account, and the groups it is in
# ./root-w3bs-os.sh groups add alice docker
# ./root-w3bs-os.sh groups rm alice docker
# ./root-w3bs-os.sh groups set alice wheel,users
#
# Flags:
# --refresh non-interactive update pass over the existing box
# --groups LIST groups for the accounts named on this run (no prompt)
# --force run on something that is not Alpine (unsupported)
# --force-dotfiles overwrite user-edited dotfiles (a .bak is kept)
# --no-reboot never reboot, whatever was upgraded
# --reboot reboot at the end if the kernel asked for one
# --with-moshcode / --without-moshcode moshcode for every account (default on)
# --with-sh1pt / --without-sh1pt sh1pt for every account (default off)
# --sandbox[=container|vm] accounts named on this run become tenants
# --no-sandbox / --skip-sandbox
# --skip-apk / --skip-web / --skip-tailscale / --skip-tools / --skip-dotfiles
# --skip-firewall
# -h | --help
#
# Configuration, in order of precedence: the environment, then $SERVER_CONFIG
# (default /etc/cli-tools/server.conf), the same file root-ubuntu.sh reads.
# KEY=value, one per line, # for comments -- read rather than sourced, so
# nothing in it executes and the environment still wins:
#
# WEB_DOMAIN=dev.example.com
# ACME_EMAIL=ops@example.com
# DOTFILES_REPO=git@github.com:example/dotfiles.git
#
# Env overrides (everything root-ubuntu.sh documents applies here too, minus
# the nginx/certbot/apt-only keys; these are the ones that differ):
# SSH_PORT=22 port to open in ufw
# SWAP_SIZE=2G / SWAP_FILE=/swapfile / SWAPPINESS=10
# ZRAM_ENABLE=1 ZRAM_SIZE_MB= (default min(RAM/2, 8192)) ZRAM_ALGO=zstd
# EARLYOOM_ENABLE=1 EARLYOOM_MEM=10,5 EARLYOOM_SWAP=10,5 EARLYOOM_AVOID=...
# AUTO_UPGRADE=1 daily unattended upgrade from /etc/periodic/daily (0 = off)
# LOGROTATE_ENABLE=1 / LOGROTATE_MAXSIZE=200M / LOGROTATE_KEEP=14
# FAIL2BAN_ENABLE=1 (+ the FAIL2BAN_* knobs root-ubuntu.sh has)
# ASSUME_YES=1 don't prompt
# DEFAULT_GROUPS=... groups an account lands in when --groups is not passed
# (default: users when confinement is on, wheel when it is not)
# CONFINE=0 do not confine accounts at all (default: 1)
# CONFINE_EXEMPT_GROUPS=wheel,sudo,admin who counts as an admin
# CONFINE_CGROUP=1 per-account cgroup v2 caps (memory/pids), see confinement
# WITH_MOSHCODE=1 / WITH_SH1PT=0
# W3BS_INSTALL=1 route catalog tools through `w3bs install` (0 = curl installers)
# WEB_DOMAIN=... domain for the per-user pages
# WEB_TLS=auto|internal|off auto: Caddy gets real certificates (ACME);
# internal: Caddy's own local CA (no public DNS needed);
# off: plain http only
# CADDY_ADMIN=localhost:2019 the admin API; never anything but loopback
# DEV_APPS=0 turn off <app>.<user>.$WEB_DOMAIN hosting
# DOTFILES_REPO=... git URL of the dotfiles to install (optional)
# PORKBUN_API_KEY=... PORKBUN_SECRET_API_KEY=... create the DNS records
# (Caddy does the certificates itself; no DNS-01 needed)
# TS_AUTHKEY=... tailscale auth key, to join the tailnet unattended
#
# --- on being re-runnable -----------------------------------------------
# Every step is written to converge, not to assume a blank machine:
# * files we own are rewritten only when the content actually changes, so
# Caddy is not reloaded and services are not restarted for nothing
# * files a USER owns (.zshrc, .gitconfig, .irssi/config, ~/public_html)
# are never clobbered once they have diverged from what we shipped
# * no reboot unless the box says one is required AND you agree to it
# * a lock file makes two concurrent runs impossible
#
# --- on secrets ---------------------------------------------------------
# There are none in this file and there must never be. It is public, it is
# curled onto machines by strangers, and every credential it can use is read
# from the environment or from $SERVER_CONFIG.
# Deliberately POSIX so that busybox ash can parse and run it: this is the one
# thing in the file that has to work in the wrong shell, because its whole job
# is to get us into the right one. Everything past it is bash.
if [ -z "${BASH_VERSION:-}" ]; then
# Started from a file (sh root-w3bs-os.sh): install bash if this is Alpine
# and we are root, then run the same file again under it. Piped (curl | sh),
# $0 is the shell itself and there is no file to re-run.
if [ -f "$0" ] && grep -q '^# root-w3bs-os.sh --' "$0" 2>/dev/null; then
if ! command -v bash >/dev/null 2>&1 && [ "$(id -u)" = 0 ] && command -v apk >/dev/null 2>&1; then
echo "root-w3bs-os.sh: installing bash (apk add bash) to run under it" >&2
apk add -q bash >/dev/null 2>&1 || true
fi
if command -v bash >/dev/null 2>&1; then
exec bash "$0" "$@"
fi
fi
echo "root-w3bs-os.sh: this is a bash script and you are running it under sh." >&2
echo " apk add bash && curl -fsSL <url>/root-w3bs-os.sh | bash -s -- --refresh" >&2
echo " ...or: sh root-w3bs-os.sh (installs bash itself when run as root)" >&2
exit 1
fi
set -uo pipefail
# The packaged release (w3bs install profullstack.com/root-w3bs-os). Bump with every
# change that ships, and tag the commit root-w3bs-os-v<version>.
ROOT_W3BS_OS_VERSION=0.1.1
case "${1:-}" in
--version|-V) printf 'root-w3bs-os %s\n' "$ROOT_W3BS_OS_VERSION"; exit 0 ;;
esac
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)" || SCRIPT_DIR=""
# ------------------------------------------------------------------ config ---
# Read before anything else looks at a variable, so the file can supply any of
# the defaults below.
#
# READ, not sourced, for two reasons. The environment has to win over the file
# -- that is the rule everywhere else in this repository -- and `.` assigns
# unconditionally, so a sourced config would quietly beat the variable someone
# just put on the command line. And this runs as root: sourcing hands whatever
# is in /etc/cli-tools/server.conf the whole machine, where a config file only
# needs to carry values.
#
# So it is KEY=value, one per line, # for comments, surrounding quotes stripped.
# No expansion, no substitution, nothing executed.
SERVER_CONFIG="${SERVER_CONFIG:-/etc/cli-tools/server.conf}"
read_server_config() {
local file="$1" line key val
[[ -r "$file" ]] || return 0
while IFS= read -r line || [[ -n "$line" ]]; do
[[ "$line" =~ ^[[:space:]]*# ]] && continue
[[ "$line" =~ ^[[:space:]]*([A-Za-z_][A-Za-z0-9_]*)[[:space:]]*=(.*)$ ]] || continue
key="${BASH_REMATCH[1]}"
val="${BASH_REMATCH[2]}"
# Already in the environment? Then that is the answer, and this line is
# only what the file would have said.
[[ -n "${!key+set}" ]] && continue
# trailing whitespace, then one layer of matching quotes
val="${val%"${val##*[![:space:]]}"}"
if [[ "$val" == \"*\" && ${#val} -ge 2 ]]; then
val="${val:1:${#val}-2}"
elif [[ "$val" == \'*\' && ${#val} -ge 2 ]]; then
val="${val:1:${#val}-2}"
fi
printf -v "$key" '%s' "$val"
done <"$file"
return 0
}
read_server_config "$SERVER_CONFIG"
SSH_PORT="${SSH_PORT:-22}"
ASSUME_YES="${ASSUME_YES:-0}"
MOTD_URL="${MOTD_URL:-https://profullstack.com/motd}"
MOTD_CACHE=/var/cache/profullstack-motd
# Everything this script remembers between runs lives here: which users it
# provisioned, and the checksum of each file it installed into their homes.
# The same directory root-ubuntu.sh uses, so the record means the same thing
# whichever of the two wrote it.
STATE_DIR="${STATE_DIR:-/var/lib/profullstack}"
USERS_STATE="$STATE_DIR/users"
# /run/lock is not a given on Alpine (/var/lock is a symlink to it only once
# OpenRC has booted), so the lock lives in /run directly.
LOCK_FILE="${LOCK_FILE:-/run/root-w3bs-os.lock}"
LOG_FILE="${LOG_FILE:-/var/log/root-w3bs-os.log}"
FORCE_OS="${FORCE_OS:-0}"
FORCE_DOTFILES="${FORCE_DOTFILES:-0}"
SKIP_APK="${SKIP_APK:-0}"
SKIP_WEB="${SKIP_WEB:-0}"
SKIP_TAILSCALE="${SKIP_TAILSCALE:-0}"
SKIP_TOOLS="${SKIP_TOOLS:-0}"
SKIP_DOTFILES="${SKIP_DOTFILES:-0}"
SKIP_FIREWALL="${SKIP_FIREWALL:-0}"
# 0 = never, 1 = only if the box says a reboot is required, 2 = always ask
REBOOT_POLICY=1
# The catalog tools. moshcode is on by default, as it is in root-ubuntu.sh;
# sh1pt is opt-in (--with-sh1pt), since not every box ships anything.
WITH_MOSHCODE="${WITH_MOSHCODE:-1}"
WITH_SH1PT="${WITH_SH1PT:-0}"
# Route the catalog tools through `w3bs install` (receipts, `w3bs update`
# keeps them current, the scan verdict is checked) when the CLI is present.
# 0 forces the publishers' curl installers, which is also the fallback when
# w3bs is missing or a tool is not in the catalog.
W3BS_INSTALL="${W3BS_INSTALL:-1}"
W3BS_CATALOG_URL="${W3BS_CATALOG_URL:-https://w3bs.org/api/packages}"
W3BS_INSTALLER_URL="${W3BS_INSTALLER_URL:-https://w3bs.org/install.sh}"
# Catalog ids, checked against $W3BS_CATALOG_URL at run time.
W3BS_ID_MOSHCODE="${W3BS_ID_MOSHCODE:-moshcode.sh/moshcode}"
W3BS_ID_THREATCRUSH="${W3BS_ID_THREATCRUSH:-threatcrush.com/threatcrush}"
W3BS_ID_HQTERM="${W3BS_ID_HQTERM:-hqterm.sh/hqterm}"
W3BS_ID_SH1PT="${W3BS_ID_SH1PT:-sh1pt.com/sh1pt}"
# Daily unattended upgrade, the Alpine stand-in for unattended-upgrades: within
# a stable branch every apk upgrade is a security or bug fix, so the whole
# upgrade is the safe set.
AUTO_UPGRADE="${AUTO_UPGRADE:-1}"
# Dotfiles are OPTIONAL and they are not in this repository.
#
# They cannot be: a dotfiles tree carries ssh config, known_hosts, sometimes
# keys, and this file is public. So the shell/editor/tmux/irssi configuration a
# team wants on its boxes lives in that team's own repo, and this clones it if
# you name one. With no DOTFILES_REPO the box still gets everything else --
# packages, firewall, accounts, zsh, oh-my-zsh, mise, moshcode, Caddy, TLS --
# and simply keeps whatever dotfiles each account already had.
#
# DOTFILES_DIR points at an existing checkout instead, which is what a run from
# inside such a repo wants: put this script beside the dotfiles and it uses
# them without cloning anything.
DOTFILES_REPO="${DOTFILES_REPO:-}"
DOTFILES_DIR="${DOTFILES_DIR:-}"
DOTFILES_CACHE="${DOTFILES_CACHE:-$STATE_DIR/dotfiles-src}"
# Where a pasted public key is filed so that re-runs and rebuilds keep working.
KEYS_DIR="${KEYS_DIR:-}"
# Tailscale. TS_AUTHKEY joins the tailnet unattended; without it the script
# prints the command to run by hand.
TS_AUTHKEY="${TS_AUTHKEY:-}"
# set_box_hostname renames the box; a tailnet name nobody chose follows it.
TS_HOSTNAME_GIVEN="${TS_HOSTNAME:+1}"
TS_HOSTNAME="${TS_HOSTNAME:-$(hostname -s 2>/dev/null || hostname)}"
BOX_HOSTNAME="${BOX_HOSTNAME:-}"
# Per-user web hosting: https://WEB_DOMAIN/~user and https://user.WEB_DOMAIN
WEB_DOMAIN="${WEB_DOMAIN:-dev.profullstack.com}"
# each user's address is <login>@MAIL_DOMAIN
MAIL_DOMAIN="${MAIL_DOMAIN:-profullstack.com}"
MAIL_URL="${MAIL_URL:-https://forwardemail.net/}"
# the comms network, reached over ssh
BBS_DOMAIN="${BBS_DOMAIN:-bbs.profullstack.com}"
WEBMAIL_URL="${WEBMAIL_URL:-https://mail.forwardemail.net/}"
PORKBUN_API_KEY="${PORKBUN_API_KEY:-}"
PORKBUN_SECRET_API_KEY="${PORKBUN_SECRET_API_KEY:-}"
# No default, and no personal address baked in. Caddy uses it only as the ACME
# account contact (expiry warnings); issuance works without one.
ACME_EMAIL="${ACME_EMAIL:-}"
# Where a Moshpit name with nothing pointed at it is sent -- see the port-80
# catch-all in the Caddyfile. Empty turns parking off.
MOSHPIT_PARKING_URL="${MOSHPIT_PARKING_URL-https://pit.moshcode.sh/n}"
COPY_SSH_PRIVATE_KEYS="${COPY_SSH_PRIVATE_KEYS:-0}"
# Caddy, the W3BS OS web server. It replaces root-ubuntu.sh's nginx + certbot
# + acme.sh: every hostname a site block names gets a certificate on its own
# (HTTP-01/TLS-ALPN, dual-stack), so there is no wildcard, no DNS-01 and no
# renewal job to look after.
#
# auto real certificates from a public ACME CA. Needs the names to
# resolve here and ports 80/443 reachable.
# internal Caddy's own local CA: https everywhere, a browser warning until
# the CA is trusted. For a box with no public DNS.
# off plain http on port 80, no certificates at all.
WEB_TLS="${WEB_TLS:-auto}"
CADDY_ADMIN="${CADDY_ADMIN:-localhost:2019}"
CADDY_DIR="${CADDY_DIR:-/etc/caddy}"
CADDYFILE="$CADDY_DIR/Caddyfile"
# One import file per account: its page and its dev apps. Rewritten by the
# devapps generator, so a new ~/apps/<name> goes live without root.
CADDY_USERS_DIR="$CADDY_DIR/users"
CADDY_CONF_DIR="$CADDY_DIR/conf.d"
CADDY_LOG_DIR="${CADDY_LOG_DIR:-/var/log/caddy}"
# Per-user dev apps: https://<app>.<user>.$WEB_DOMAIN
# Static from ~/apps/<app>/public, or reverse-proxied to 127.0.0.1:<port>
# when ~/apps/<app>/.port holds a port number.
DEV_APPS="${DEV_APPS:-1}"
# --- sandboxed tenants ---------------------------------------------------
#
# See "sandboxes" further down. In one line: an account with SANDBOX=1 gets its
# own container (or VM) and no shell on this box at all.
SANDBOX="${SANDBOX:-0}"
# container = unprivileged LXC, shared kernel, instant. vm = KVM, own kernel.
SANDBOX_KIND="${SANDBOX_KIND:-container}"
SKIP_SANDBOX="${SKIP_SANDBOX:-0}"
SANDBOX_PREFIX="${SANDBOX_PREFIX:-dev-}"
# The tenant's group on the HOST. Deliberately not `users`: that group is what
# `share` opens every mounted volume to, and a tenant is exactly who must not
# be in it.
SANDBOX_GROUP="${SANDBOX_GROUP:-tenants}"
SANDBOX_NOLOGIN="${SANDBOX_NOLOGIN:-/sbin/nologin}"
SANDBOX_IMAGE="${SANDBOX_IMAGE:-images:ubuntu/24.04/cloud}"
SANDBOX_POOL="${SANDBOX_POOL:-tenants}"
SANDBOX_POOL_DRIVER="${SANDBOX_POOL_DRIVER:-}"
SANDBOX_POOL_SIZE="${SANDBOX_POOL_SIZE:-100GiB}"
SANDBOX_BRIDGE="${SANDBOX_BRIDGE:-tenantbr0}"
SANDBOX_SUBNET="${SANDBOX_SUBNET:-10.171.0.1/24}"
SANDBOX_DNS="${SANDBOX_DNS:-1.1.1.1,9.9.9.9}"
SANDBOX_CPU="${SANDBOX_CPU:-2}"
SANDBOX_MEMORY="${SANDBOX_MEMORY:-2GiB}"
SANDBOX_DISK="${SANDBOX_DISK:-20GiB}"
SANDBOX_PROCESSES="${SANDBOX_PROCESSES:-2048}"
SANDBOX_NESTING="${SANDBOX_NESTING:-0}"
SANDBOX_SSH_PORT_BASE="${SANDBOX_SSH_PORT_BASE:-2200}"
SANDBOX_PORT_BASE="${SANDBOX_PORT_BASE:-21000}"
SANDBOX_PORT_SPAN="${SANDBOX_PORT_SPAN:-100}"
SANDBOX_MOSH_PORTS="${SANDBOX_MOSH_PORTS:-10}"
SANDBOX_MAX_SLOTS="${SANDBOX_MAX_SLOTS:-200}"
SANDBOX_DENY_NETS="${SANDBOX_DENY_NETS:-10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 169.254.0.0/16 100.64.0.0/10 fc00::/7 fe80::/10}"
SANDBOX_ACL="${SANDBOX_ACL:-tenants}"
SANDBOX_STATE="$STATE_DIR/sandbox"
SANDBOX_TENANTS="$SANDBOX_STATE/tenants"
# Bump to re-run the in-instance setup for every tenant on the next pass.
SANDBOX_BOOTSTRAP_REV=1
SANDBOX_REV_FILE=/var/lib/profullstack/tenant-rev
SANDBOX_BOOT_TIMEOUT="${SANDBOX_BOOT_TIMEOUT:-120}"
# Block AI/LLM crawlers and aggressive scrapers by User-Agent. Search engines
# are deliberately NOT in the list: blocking Googlebot/Bingbot would deindex
# the box rather than protect it.
BLOCK_AI_BOTS="${BLOCK_AI_BOTS:-1}"
AI_CRAWLER_AGENTS=(
# OpenAI
GPTBot OAI-SearchBot ChatGPT-User
# Anthropic
ClaudeBot Claude-Web Claude-User Claude-SearchBot anthropic-ai
# Google / Apple opt-out crawlers (NOT Googlebot itself)
Google-Extended Applebot-Extended
# Perplexity
PerplexityBot Perplexity-User
# Meta
meta-externalagent meta-externalfetcher FacebookBot
# Common Crawl -- the corpus most models train on
CCBot
# ByteDance / Amazon / others
Bytespider Amazonbot cohere-ai Diffbot omgili omgilibot
ImagesiftBot YouBot AI2Bot Timpibot iaskspider DuckAssistBot
PanguBot "Kangaroo Bot" Webzio-Extended Scrapy
# generic scraper stacks that ignore robots.txt
python-requests python-httpx libwww-perl HTTrack Nutch
)
# The sponsor ad root-ubuntu.sh can put on the per-user pages is an nginx
# feature (SSI + random_index + add_before_body) and is NOT ported to Caddy.
# It is off unless a slot is configured there too, so the only thing to do
# with a configured slot here is say that it does nothing.
SPONSOR_AD_SLOT="${SPONSOR_AD_SLOT:-}"
# Logo shown at the top of the landing page. Cached locally so the page does
# not depend on profullstack.com being up.
LOGO_URL="${LOGO_URL:-https://profullstack.com/assets/logo.svg}"
LOGO_FILE=/var/www/userdirs/assets/logo.svg
# Group menu offered when creating a user. Alpine's admin group is wheel
# (doas and sudo both point at it); sudo/admin are honoured where they exist.
GROUP_CHOICES=(wheel docker adm caddy users)
# Was it said out loud, or is it just the default? Confinement turns the
# default from wheel into users, and the difference between "nobody chose
# this" and "the config file chose this" is the whole basis for being allowed
# to change it underneath them.
DEFAULT_GROUPS_EXPLICIT="${DEFAULT_GROUPS+1}"
DEFAULT_GROUPS="${DEFAULT_GROUPS:-wheel}"
# ------------------------------------------------------- confinement ---
#
# These boxes are multi-tenant, so the default has to be that an account is
# confined and an admin is let out, rather than the other way round.
#
# root untouched, always. Never capped, never blinded, never denied.
# admin anyone in $CONFINE_EXEMPT_GROUPS. Exempt from the resource
# caps, and kept able to see every process on the box.
# confined everybody else, by default, with nothing to remember.
#
# What each mechanism is actually worth on Alpine is written up at
# configure_confinement below. CONFINE=0 turns all of it off.
CONFINE="${CONFINE:-1}"
CONFINE_GROUP="${CONFINE_GROUP:-confined}"
CONFINE_EXEMPT_GROUPS="${CONFINE_EXEMPT_GROUPS:-wheel,sudo,admin}"
CONFINE_PROC_GROUP="${CONFINE_PROC_GROUP:-proc}"
CONFINE_HIDEPID="${CONFINE_HIDEPID:-1}"
CONFINE_HOME_MODE="${CONFINE_HOME_MODE:-0700}"
CONFINE_UMASK="${CONFINE_UMASK:-027}"
CONFINE_SYSCTL="${CONFINE_SYSCTL:-1}"
# Resource caps. There is no systemd user slice under OpenRC, so they are a
# cgroup v2 group per account (/sys/fs/cgroup/w3bs.confined/<login>) that
# every process of a confined account is moved into: by pam_exec at login
# where sshd runs with PAM, and by a once-a-minute sweep from crond either way.
# Memory and tasks are hard caps; CPU is a weight.
CONFINE_CGROUP="${CONFINE_CGROUP:-1}"
CONFINE_CGROUP_ROOT="${CONFINE_CGROUP_ROOT:-/sys/fs/cgroup/w3bs.confined}"
CONFINE_MEMORY_HIGH="${CONFINE_MEMORY_HIGH:-35%}"
CONFINE_MEMORY_MAX="${CONFINE_MEMORY_MAX:-50%}"
CONFINE_TASKS_MAX="${CONFINE_TASKS_MAX:-4096}"
CONFINE_CPU_WEIGHT="${CONFINE_CPU_WEIGHT:-100}"
# A hard CPU ceiling (systemd slices only: CPUQuota=, e.g. 200%).
CONFINE_CPU_QUOTA="${CONFINE_CPU_QUOTA:-}"
# PAM limits. They only bite where sshd is built with PAM (openssh-server-pam,
# UsePAM yes); Alpine's default sshd is not, and `confine` says which it is.
CONFINE_NPROC="${CONFINE_NPROC:-4096}"
CONFINE_NOFILE="${CONFINE_NOFILE:-16384}"
CONFINE_MAXLOGINS="${CONFINE_MAXLOGINS:-20}"
# local -- ssh -L to their own app still works, ssh -R relays do not
# no -- no forwarding at all
# yes -- off; this box is a jump host on purpose
CONFINE_SSH_FORWARDING="${CONFINE_SSH_FORWARDING:-local}"
CONFINE_SSH="${CONFINE_SSH:-1}"
# An unattended run creates accounts with $DEFAULT_GROUPS, and wheel on a
# confined box would hand every new tenant the way out of confinement. Only
# the default moves: a DEFAULT_GROUPS in the environment or server.conf stands.
if [[ "$CONFINE" == 1 && -z "$DEFAULT_GROUPS_EXPLICIT" ]]; then
DEFAULT_GROUPS="users"
fi
USERS=() # alice@example -- new this run, get the full treatment
USER_GROUPS=() # wheel -- index-matched to USERS
KNOWN_USERS=() # logins provisioned by an earlier run, refreshed not created
FAILED=()
PRESERVED=() # files left alone because the user had edited them
CHANGED=() # things this run actually altered (for the closing summary)
# ---------------------------------------------------------------- helpers ---
log() { printf '\n\033[1;32m==>\033[0m %s\n' "$*"; }
info() { printf ' %s\n' "$*"; }
warn() { printf '\033[1;33m[warn]\033[0m %s\n' "$*" >&2; }
die() { printf '\033[1;31m[fail]\033[0m %s\n' "$*" >&2; exit 1; }
# Run a step; failures are collected and reported at the end instead of
# aborting. The old script died halfway through on one bad package.
try() {
local desc="$1"; shift
info "$desc"
if ! "$@"; then
warn "$desc -- failed (continuing)"
FAILED+=("$desc")
return 1
fi
}
note() { CHANGED+=("$*"); info "$*"; }
interactive() { [[ -t 0 && "$ASSUME_YES" != 1 ]]; }
confirm() {
local prompt="$1" default="${2:-n}" ans
interactive || { [[ "$default" == y ]]; return; }
read -r -p "$prompt " ans
ans="${ans:-$default}"
[[ "$ans" =~ ^[Yy] ]]
}
user_login() { printf '%s' "${1%%@*}"; } # alice@example -> alice
user_home() { getent passwd "$1" | cut -d: -f6; }
valid_login() { [[ "$1" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]]; }
# Run a command as $1 with a login-ish env (installers write into ~).
#
# runuser -u keeps the caller's environment AND working directory, so both
# have to be replaced:
# HOME -- otherwise installers run for alice still write into /root
# cwd -- otherwise anything touching the cwd dies when the script is run
# from a directory the target user cannot reach, e.g.
# /root/provision ("sh: cd: can't cd to /root/provision")
as_user() {
local login="$1"; shift
local home v
home="$(user_home "$login")"
[[ -n "$home" ]] || { warn "no home dir for $login"; return 1; }
# env -i, NOT the inherited environment. runuser -u keeps the caller's
# variables, and root's shell exports plenty that are wrong for anybody
# else. This script points root's shell at our .zshrc, which does
# 'export ZSH="$HOME/.oh-my-zsh"' -- so from the second run onwards root
# carries ZSH=/root/.oh-my-zsh, the oh-my-zsh installer honours it over
# $HOME, and the clone dies with
# fatal: cannot mkdir /root/.oh-my-zsh: Permission denied
# while ostensibly installing for someone else. NVM_DIR, ZDOTDIR,
# CARGO_HOME and the MISE_* family all leak the same way.
#
# Starting clean and letting bash -l rebuild from /etc/profile is the only
# version of this that stays correct as people add exports to the dotfiles.
local -a envs=(
HOME="$home" USER="$login" LOGNAME="$login" SHELL=/bin/bash
PATH="$home/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
TERM="${TERM:-dumb}"
)
# a box behind a proxy still has to reach the network
for v in http_proxy https_proxy no_proxy HTTP_PROXY HTTPS_PROXY NO_PROXY; do
[[ -n "${!v:-}" ]] && envs+=("$v=${!v}")
done
# cd happens here, in the parent, while still root -- root can enter any
# directory, and the child then inherits a cwd its own user can reach.
# stdin from /dev/null: these run inside 'while read ... done < <(...)'
# loops, and anything that decides to prompt (a git credential helper on a
# 401, say) would otherwise eat the rest of the list being iterated.
if [[ "$login" == root ]]; then
( cd -- "$home" && env -i "${envs[@]}" bash -lc "$*" </dev/null 9>&- )
else
# runuser is its own apk (bootstrap_tools installs it). su is the
# fallback, with -s so a nologin or zsh login shell does not decide
# what interprets the command.
if command -v runuser >/dev/null 2>&1; then
( cd -- "$home" && runuser -u "$login" -- \
env -i "${envs[@]}" bash -lc "$*" </dev/null 9>&- )
else
( cd -- "$home" && su -s /bin/sh "$login" -c \
"$(printf '%q ' env -i "${envs[@]}" bash -lc "$*")" </dev/null 9>&- )
fi
fi
}
# git clone, or fast-forward if it's already there. Keeps re-runs cheap.
#
# --depth 1 clones cannot always fast-forward (the new tip may not descend from
# the shallow tip), so fetch+reset onto the remote head instead of pull.
clone_or_pull() {
local login="$1" url="$2" dest="$3"
if as_user "$login" "test -d '$dest/.git'"; then
as_user "$login" "git -C '$dest' fetch --quiet --depth 1 origin HEAD \
&& git -C '$dest' reset --quiet --hard FETCH_HEAD" \
|| as_user "$login" "git -C '$dest' pull --ff-only --quiet" || true
return
fi
# A directory that exists but is not a git checkout belongs to the user --
# ~/.tmux full of tpm plugins and saved sessions, say. The old 'rm -rf and
# clone' would delete the lot on the first re-run, so refuse instead.
if [[ -e "$dest" ]] && [[ -n "$(ls -A "$dest" 2>/dev/null)" ]]; then
warn "$dest exists and is not a git checkout -- left alone (not installing $url)"
return 1
fi
as_user "$login" "rm -rf '$dest' && git clone --depth 1 --quiet '$url' '$dest'"
}
# ------------------------------------------------- converge, don't clobber ---
file_sha() { [[ -f "$1" ]] && sha256sum "$1" 2>/dev/null | cut -d' ' -f1; }
# Write stdin to $1 only if the content differs. Returns 0 when it changed, 1
# when it did not -- so callers can reload a service only when there is a
# reason to. Re-running the script should not bounce Caddy for nothing.
write_if_changed() {
local dest="$1" mode="${2:-0644}" tmp
tmp="$(mktemp)" || return 1
cat >"$tmp"
if [[ -f "$dest" ]] && cmp -s "$tmp" "$dest"; then
rm -f "$tmp"
chmod "$mode" "$dest"
return 1
fi
install -m "$mode" "$tmp" "$dest"
rm -f "$tmp"
return 0
}
# Where we remember the checksum of the copy we installed for a user.
_state_path() {
local login="$1" dest="$2"
printf '%s/dotfiles/%s/%s' "$STATE_DIR" "$login" "${dest//\//%}"
}
# Has this exact content ever been shipped by this repo?
#
# The state file only knows about runs of the NEW script. On a box provisioned
# before it existed there is no record, and every dotfile would look
# user-edited -- which would freeze those boxes forever. So also ask git: if
# the file matches ANY revision of the template in this checkout's history,
# nobody has hand-edited it and updating is safe.
_matches_repo_history() {
local dest="$1" rel="$2" blob want
# No checkout, no history to compare against -- every file then looks
# user-edited, which is the safe answer rather than a wrong one.
[[ -n "$DOTFILES_DIR" ]] || return 1
command -v git >/dev/null || return 1
git -C "$DOTFILES_DIR" rev-parse --git-dir >/dev/null 2>&1 || return 1
blob="$(git -C "$DOTFILES_DIR" hash-object "$dest" 2>/dev/null)" || return 1
[[ -n "$blob" ]] || return 1
want="$(git -C "$DOTFILES_DIR" log --format='%H' --all -- "$rel" 2>/dev/null \
| sed "s|\$|:$rel|" \
| git -C "$DOTFILES_DIR" cat-file --batch-check='%(objectname)' 2>/dev/null \
| grep -qxF "$blob" && echo yes)"
[[ "$want" == yes ]]
}
# Install $src at $dest for $owner, unless the user has made it theirs.
#
# dest missing .................. install
# dest already identical ........ nothing to do (just record it)
# dest == what we last wrote .... ours, safe to update
# dest is some older template ... ours, safe to update
# anything else ................. THEIRS: leave it, drop a .new beside it
#
# --force-dotfiles overrides the last case, keeping a .bak.
install_managed() {
local src="$1" dest="$2" owner="$3" mode="${4:-0644}" rel="${5:-}"
local src_sha dest_sha recorded state
[[ -f "$src" ]] || return 0
# repo cloned into the very home we are installing to: same file
[[ "$src" -ef "$dest" ]] && return 0
rel="${rel:-$(basename "$dest")}"
state="$(_state_path "$owner" "$dest")"
src_sha="$(file_sha "$src")"
if [[ -e "$dest" ]]; then
dest_sha="$(file_sha "$dest")"
if [[ -n "$dest_sha" && "$dest_sha" == "$src_sha" ]]; then
_record_managed "$state" "$src_sha"
chown "$owner:$owner" "$dest" 2>/dev/null
return 0
fi
recorded="$(cat "$state" 2>/dev/null || true)"
if [[ "$FORCE_DOTFILES" == 1 ]]; then
cp -p "$dest" "$dest.bak" 2>/dev/null
warn "overwriting $dest (backup: $dest.bak)"
elif [[ -n "$recorded" && "$dest_sha" == "$recorded" ]]; then
: # we wrote it and it has not been touched since
elif _matches_repo_history "$dest" "$rel"; then
: # an older version of this same template
else
# theirs. Show them the new version without taking anything away.
if ! cmp -s "$src" "$dest.new" 2>/dev/null; then
install -m "$mode" -o "$owner" -g "$owner" "$src" "$dest.new" 2>/dev/null
fi
PRESERVED+=("$dest")
return 0
fi
fi
install -D -m "$mode" -o "$owner" -g "$owner" "$src" "$dest" \
|| { warn "could not install $dest"; return 1; }
_record_managed "$state" "$src_sha"
note "updated $dest"
return 0
}
_record_managed() {
local state="$1" sha="$2"
[[ -n "$sha" ]] || return 0
install -d -m 0700 "$(dirname "$state")" 2>/dev/null
printf '%s\n' "$sha" >"$state" 2>/dev/null || true
}
# --------------------------------------------------------- managed users ---
# The set of accounts this script looks after. Recorded so that a bare re-run
# refreshes everyone instead of only the users named on the command line.
load_known_users() {
local l
if [[ -s "$USERS_STATE" ]]; then
while read -r l; do
[[ -n "$l" ]] && id -u "$l" >/dev/null 2>&1 && KNOWN_USERS+=("$l")
done <"$USERS_STATE"
fi
# Nothing recorded: this is either a fresh box or one provisioned by an
# older version of the script. Adopt the real humans already on it --
# regular uids, a home under /home, an actual login shell.
if [[ ${#KNOWN_USERS[@]} -eq 0 ]]; then
while IFS=: read -r l _ uid _ _ home shell; do
[[ "$uid" -ge 1000 && "$uid" -lt 65534 ]] || continue
[[ "$home" == /home/* && -d "$home" ]] || continue
[[ "$shell" == */nologin || "$shell" == */false ]] && continue
KNOWN_USERS+=("$l")
done < <(getent passwd)
fi
}
remember_user() {
local login="$1"
install -d -m 0755 "$STATE_DIR"
touch "$USERS_STATE"
grep -qxF "$login" "$USERS_STATE" 2>/dev/null || printf '%s\n' "$login" >>"$USERS_STATE"
}
# every login this run should touch: previously known + newly created
all_logins() {
local out=() u l
for l in ${KNOWN_USERS[@]+"${KNOWN_USERS[@]}"}; do out+=("$l"); done
for u in ${USERS[@]+"${USERS[@]}"}; do
l="$(user_login "$u")"
printf '%s\n' "${out[@]+"${out[@]}"}" | grep -qxF "$l" || out+=("$l")
done
printf '%s\n' "${out[@]+"${out[@]}"}"
}
# --------------------------------------------------------------- platform ---
#
# Everything root-ubuntu.sh could take for granted and this cannot: which init
# is running, whether we are in a container, and how to install a package.
# Kept in one place so the sections below read like their Ubuntu originals.
# The init actually running, not the one installed. Asked once.
#
# systemd /run/systemd/system exists only while systemd is PID 1
# openrc the OpenRC run directory exists once it has booted; a container
# started without /sbin/init has openrc installed and not running,
# and rc-service there fails in ways that read like broken units
# none no init we can talk to (a plain `docker run`): services are not
# managed, everything else still converges
INIT_SYSTEM=""
detect_init() {
[[ -n "$INIT_SYSTEM" ]] && { printf '%s' "$INIT_SYSTEM"; return; }
if [[ -d /run/systemd/system ]]; then
INIT_SYSTEM=systemd
elif command -v rc-service >/dev/null 2>&1 && [[ -d /run/openrc ]]; then
INIT_SYSTEM=openrc
else
INIT_SYSTEM=none
fi
printf '%s' "$INIT_SYSTEM"
}
# One name per service across both inits. Alpine and systemd mostly agree; the
# exceptions are listed rather than guessed.
_svc_name() {
local name="$1"
if [[ "$(detect_init)" == systemd ]]; then
case "$name" in
crond) printf 'crond' ;;
*) printf '%s' "$name" ;;
esac
else
case "$name" in
incus) printf 'incusd' ;;
*) printf '%s' "$name" ;;
esac
fi
}
# Enabled at boot? Converges silently when it already is.
svc_enable() {
local s runlevel="${2:-default}"
s="$(_svc_name "$1")"
case "$(detect_init)" in
systemd) systemctl is-enabled --quiet "$s" 2>/dev/null || systemctl enable --quiet "$s" 2>/dev/null ;;
openrc)
[[ -e "/etc/init.d/$s" ]] || return 1
rc-update show "$runlevel" 2>/dev/null | awk '{print $1}' | grep -qxF "$s" && return 0
rc-update add "$s" "$runlevel" >/dev/null 2>&1
;;
*) return 0 ;;
esac
}
svc_disable() {
local s; s="$(_svc_name "$1")"
case "$(detect_init)" in
systemd) systemctl disable --now --quiet "$s" 2>/dev/null ;;
openrc)
rc-service "$s" stop 9>&- >/dev/null 2>&1
rc-update del "$s" >/dev/null 2>&1
;;
esac
return 0
}
svc_is_active() {
local s; s="$(_svc_name "$1")"
case "$(detect_init)" in
systemd) systemctl is-active --quiet "$s" 2>/dev/null ;;
openrc) rc-service "$s" status >/dev/null 2>&1 ;;
*) return 1 ;;
esac
}
svc_start() {
local s; s="$(_svc_name "$1")"
svc_is_active "$1" && return 0
case "$(detect_init)" in
systemd) systemctl start "$s" 9>&- ;;
openrc) rc-service "$s" start 9>&- >/dev/null 2>&1 ;;
*) return 1 ;;
esac
}
svc_restart() {
local s; s="$(_svc_name "$1")"
case "$(detect_init)" in
systemd) systemctl restart "$s" 9>&- ;;
openrc)
if svc_is_active "$1"; then rc-service "$s" restart 9>&- >/dev/null 2>&1
else rc-service "$s" start 9>&- >/dev/null 2>&1; fi
;;
*) return 1 ;;
esac
}
svc_reload() {
local s; s="$(_svc_name "$1")"
case "$(detect_init)" in
systemd) systemctl reload "$s" 9>&- 2>/dev/null || systemctl restart "$s" 9>&- ;;
openrc)
svc_is_active "$1" || { svc_start "$1"; return; }
rc-service "$s" reload 9>&- >/dev/null 2>&1 || rc-service "$s" restart 9>&- >/dev/null 2>&1
;;
*) return 1 ;;
esac
}
# Are we in a container? Alpine has no systemd-detect-virt, so ask the places
# a runtime leaves its mark. The W3BS OS docker editions also say so in
# /etc/w3bs-os-release, which is the most honest answer of all.
_in_container() {
[[ -e /.dockerenv || -e /run/.containerenv ]] && return 0
[[ "${W3BS_OS_EDITION:-}" == docker-* ]] && return 0
grep -qaE 'container=(docker|podman|lxc)' /proc/1/environ 2>/dev/null && return 0
[[ -r /proc/1/cgroup ]] && grep -qE '/(docker|lxc|kubepods|libpod)' /proc/1/cgroup && return 0
return 1
}
# none | container | vm -- what root-ubuntu.sh got from systemd-detect-virt,
# coarser, but the only question ever asked of it is "is there real hardware".
_virt() {
if _in_container; then printf 'container'; return; fi
if command -v systemd-detect-virt >/dev/null 2>&1; then
local v; v="$(systemd-detect-virt 2>/dev/null || true)"
[[ -n "$v" && "$v" != none ]] && { printf '%s' "$v"; return; }
printf 'none'; return
fi
grep -qw hypervisor /proc/cpuinfo 2>/dev/null && { printf 'vm'; return; }
printf 'none'
}
# Installed? apk info -e is exact and needs no network.
apk_has() { apk info -e "$1" >/dev/null 2>&1; }
# apk add, quietly, of whatever is not there yet. Returns 0 if everything ends
# up installed. The community repository is where half of these live (caddy,
# ufw, earlyoom, fail2ban), so it is enabled first -- see ensure_apk_repos.
apk_add() {
local p missing=()
for p in "$@"; do apk_has "$p" || missing+=("$p"); done
[[ ${#missing[@]} -eq 0 ]] && return 0
apk add -q "${missing[@]}" >/dev/null 2>&1 && return 0
# one at a time, so one unavailable package cannot take the batch down
local rc=0
for p in "${missing[@]}"; do
apk add -q "$p" >/dev/null 2>&1 || { warn "apk add $p failed"; rc=1; }
done
return $rc
}
# The tools every other section assumes. busybox has most of them, but not
# with the flags this script (and root-ubuntu.sh before it) leans on:
# coreutils df --output, stat -c, date -Is/-d, install -D, who
# findutils find -printf
# util-linux swapon --show, mountpoint, zramctl, flock
# shadow useradd/usermod/gpasswd/groupadd/chsh/chpasswd, /etc/login.defs
# runuser as_user
# sed/grep/gawk GNU forms (0,/re/ addresses, -P is never used)
# iproute2 ss, ip
# bind-tools dig (SPF checks)
# procps-ng free, pgrep -U, sysctl -n for every key
BOOTSTRAP_PACKAGES=(
bash coreutils findutils util-linux util-linux-misc shadow runuser sed grep gawk
iproute2 procps-ng curl ca-certificates git openssl jq acl tzdata bind-tools
)
# The W3BS OS edition, from /etc/w3bs-os-release (KEY=VALUE). Empty on plain
# Alpine.
W3BS_OS_ID=""
W3BS_OS_EDITION=""
read_w3bs_os_release() {
local f="${1:-/etc/w3bs-os-release}" line
[[ -r "$f" ]] || return 0
while IFS= read -r line; do
case "$line" in
ID=*) W3BS_OS_ID="${line#ID=}"; W3BS_OS_ID="${W3BS_OS_ID//\"/}" ;;
EDITION=*) W3BS_OS_EDITION="${line#EDITION=}"; W3BS_OS_EDITION="${W3BS_OS_EDITION//\"/}" ;;
esac
done <"$f"
return 0
}
# ------------------------------------------------------------------- w3bs ---
#
# The w3bs CLI is the front door for packages on W3BS OS. It is installed on
# every edition at /usr/local/bin/w3bs; on plain Alpine we install it there.
W3BS_BIN=""
w3bs_bin() {
[[ -n "$W3BS_BIN" && -x "$W3BS_BIN" ]] && { printf '%s' "$W3BS_BIN"; return 0; }
local c
for c in /usr/local/bin/w3bs /usr/bin/w3bs /root/.local/bin/w3bs "$(command -v w3bs 2>/dev/null)"; do
[[ -n "$c" && -x "$c" ]] && { W3BS_BIN="$c"; printf '%s' "$c"; return 0; }
done
return 1
}
# Put the CLI on a box that does not have it. The publisher installer (the
# same one w3bs.org links) verifies its download and lets w3bs install itself
# from its signed repository; W3BS_BIN_DIR puts the binary where every account
# finds it, so one install serves the whole box.
ensure_w3bs() {
w3bs_bin >/dev/null && { info "w3bs $("$(w3bs_bin)" --version 2>/dev/null | awk '{print $2}') at $(w3bs_bin)"; return 0; }
info "installing the w3bs CLI from $W3BS_INSTALLER_URL"
curl -fsSL --max-time 120 "$W3BS_INSTALLER_URL" \
| env HOME=/root W3BS_YES=1 W3BS_BIN_DIR=/usr/local/bin sh >/dev/null 2>&1 \
|| { warn "could not install the w3bs CLI -- catalog tools fall back to their curl installers"; return 1; }
W3BS_BIN=""
w3bs_bin >/dev/null || { warn "the w3bs installer ran but no w3bs binary appeared"; return 1; }
note "installed w3bs $("$(w3bs_bin)" --version 2>/dev/null | awk '{print $2}')"
return 0
}
# The catalog ids w3bs.org lists, one per line, fetched once per run. Empty when
# the catalog cannot be reached -- in which case every tool is assumed to be in
# it and `w3bs install` itself decides, which is the answer it would give.
W3BS_CATALOG=""
W3BS_CATALOG_FETCHED=0
w3bs_catalog_has() {
local id="$1"
if [[ "$W3BS_CATALOG_FETCHED" == 0 ]]; then
W3BS_CATALOG_FETCHED=1
W3BS_CATALOG="$(curl -fsSL --max-time 20 "$W3BS_CATALOG_URL" 2>/dev/null \
| jq -r '.packages[]?.id' 2>/dev/null)" || W3BS_CATALOG=""
fi