-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathroot-ubuntu.sh
More file actions
executable file
·6955 lines (6279 loc) · 285 KB
/
Copy pathroot-ubuntu.sh
File metadata and controls
executable file
·6955 lines (6279 loc) · 285 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-ubuntu.sh -- bootstrap AND maintain an Ubuntu/Debian server as root.
#
# Sets up the standard dev environment and manages the accounts on the box.
# One file, no dependencies beyond what a stock Ubuntu image already has, 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-ubuntu.sh | bash -s -- --refresh
#
# bash, NOT sh. /bin/sh on Ubuntu is dash, this script is bash throughout, and
# piping it into sh fails on the first [[ with a syntax error that names a line
# nobody typed. There is a guard below that says so in one sentence instead.
#
# 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`). That is the safe direction to fail -- an unattended run takes
# defaults rather than reading answers out of its own source. To be asked the
# questions, download it and run it as a file:
#
# curl -fsSLO https://raw.githubusercontent.com/profullstack/cli-tools/master/root-ubuntu.sh
# chmod +x root-ubuntu.sh && ./root-ubuntu.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).
#
# Deliberately minimal. Language runtimes/tools come from mise, not apt.
# 1. accounts + groups; users provisioned by an earlier run are picked up
# automatically and refreshed
# 2. apt update/upgrade + unattended security updates
# 3. ufw
# 4. a 2G swapfile, if the box has no swap at all; then zram on top of it and
# earlyoom watching, so a memory spike stalls instead of wedging the box
# 5. dotfiles (.zsh*, .bash*, .ssh*, ...) from $DOTFILES_REPO, if you have one
# 6. oh-my-zsh + plugins, oh-my-tmux, irssi configs
# 7. mise (curl https://mise.run | sh)
# 8. moshcode (curl https://moshcode.sh/install.sh | sh)
# 9. a per-user ssh-agent as a systemd user service
# 10. motd from $MOTD_URL
# 11. nginx per-user pages, per-user dev apps, TLS
# 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-ubuntu.sh # first run, or a refresh
# ./root-ubuntu.sh alice bob # ...and provision two accounts
# ./root-ubuntu.sh alice --groups sudo,docker
# ./root-ubuntu.sh --refresh # update everything, ask nothing
#
# Remote shares (see "remote shares" below for the full options):
# ./root-ubuntu.sh mount user@host:~/data --via peer
# ./root-ubuntu.sh mounts # list what is mounted, and who can reach it
# ./root-ubuntu.sh umount host
# ./root-ubuntu.sh share /mnt/volume -R # open an existing volume
# ./root-ubuntu.sh share /mnt/volume --group www-data -R
# # ...to a second group as well (acl)
#
# Confinement (see "confinement" below, or `confine --help`):
# ./root-ubuntu.sh confine # what is confined on this box, and what is not
# ./root-ubuntu.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: its home is 0700, it sees only its own processes, its memory and
# task count are capped, and it cannot relay out over ssh. root and anyone in
# sudo/admin are exempt -- explicitly, by uid, because the account you fix a
# wedged box with must not be subject to the cap that is wedging it. It never
# takes sudo away from an account that already has it; `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 instead of a shell
# on this one (see "sandboxes" below, or `sandbox --help`):
# ./root-ubuntu.sh alice --sandbox # alice gets a container, no host shell
# ./root-ubuntu.sh bob --sandbox=vm # ...and bob gets a real VM
# ./root-ubuntu.sh sandbox # who the tenants are, and their ports
# ./root-ubuntu.sh sandbox create carol --vm
# ./root-ubuntu.sh sandbox enter alice # a root shell inside her machine
# ./root-ubuntu.sh sandbox rm alice # delete the instance, keep her home
#
# Accounts and groups (see "accounts" below, or `groups --help`):
# ./root-ubuntu.sh groups # every account, and the groups it is in
# ./root-ubuntu.sh groups add alice docker
# ./root-ubuntu.sh groups rm alice docker
# ./root-ubuntu.sh groups set alice sudo,admin,users
# ./root-ubuntu.sh groups create|delete|members <group>...
#
# Mounts land at /mnt/<how>.<host>/<remote/path> -- e.g.
# /mnt/tailscale.host/data -- so a remote share is never mistaken for local
# disk, and are persisted to /etc/fstab. They are reachable at the short path
# ~/share/<name>.
#
# Mounts are shared (2775 root:users, 0664 files): every human account can write
# to them. These are team boxes, and a volume only the person who ran the mount
# can write to is the failure that keeps happening -- provider-attached block
# volumes especially, which arrive root:root 0755 and stay that way. Use
# `share` to fix one that is already mounted, and mount --private for a share
# that really does belong to one account.
#
# Flags:
# --refresh non-interactive update pass over the existing box
# --groups LIST groups for the accounts named on this run (no prompt)
# --force-dotfiles overwrite user-edited dotfiles (a .bak is kept)
# --no-reboot never reboot, whatever apt says
# --reboot reboot at the end if the kernel/libc asked for one
# --sandbox[=container|vm] accounts named on this run become tenants
# --no-sandbox / --skip-sandbox
# --skip-apt / --skip-web / --skip-tailscale / --skip-tools / --skip-dotfiles
# -h | --help
#
# Configuration, in order of precedence: the environment, then $SERVER_CONFIG
# (default /etc/cli-tools/server.conf). The file is KEY=value, one per line, #
# for comments -- read rather than sourced, so nothing in it executes and the
# environment still wins. Deliberately not JSON, because this runs before apt
# has put jq on the box and a bootstrap script that cannot read its own config
# until it has installed a parser is a bootstrap script with a hole in it.
# Every value below can go in it, and a re-run then needs no environment at all:
#
# WEB_DOMAIN=dev.example.com
# ACME_EMAIL=ops@example.com
# DOTFILES_REPO=git@github.com:example/dotfiles.git
#
# Env overrides:
# SSH_PORT=22 port to open in ufw
# SWAP_SIZE=2G swapfile to create when the box has no swap (0 = never)
# SWAP_FILE=/swapfile where that file goes
# SWAPPINESS=10 vm.swappiness once there is swap to speak of
# ZRAM_ENABLE=1 compressed RAM swap ahead of the file (0 skips it)
# ZRAM_SIZE='min(ram / 2, 8192)' zram-generator expression, in MiB
# ZRAM_ALGO=zstd ...falling back to lzo-rle where the kernel lacks it
# ZRAM_SWAPPINESS=100 overrides SWAPPINESS, but only where zram came up
# EARLYOOM_ENABLE=1 kill the biggest hog early instead of at the wall
# EARLYOOM_MEM=10,5 / EARLYOOM_SWAP=10,5 SIGTERM% , SIGKILL%
# EARLYOOM_AVOID=... / EARLYOOM_PREFER= unquoted regexes (see the note)
# ASSUME_YES=1 don't prompt (defaults: $DEFAULT_GROUPS; no privkey copy)
# DEFAULT_GROUPS=... groups an account lands in when --groups is not passed.
# An unattended run never prompts, so this is what every
# account it creates gets. Default: users when confinement is
# on, sudo,admin when it is not.
# CONFINE=0 do not confine accounts at all (default: 1)
# CONFINE_EXEMPT_GROUPS=sudo,admin who counts as an admin, and is exempt
# CONFINE_HOME_MODE=0700 mode every human home is set to
# CONFINE_UMASK=027 umask for accounts created from here on
# CONFINE_HIDEPID=0 leave /proc world-readable (default: hidepid)
# CONFINE_MEMORY_MAX=50% / CONFINE_MEMORY_HIGH=35% / CONFINE_TASKS_MAX=4096
# per-account caps on the systemd user slice
# CONFINE_CPU_QUOTA=200% a hard CPU ceiling; by default CPU is a weight,
# so a build gets the whole box when the box is idle
# CONFINE_NPROC=4096 / CONFINE_NOFILE=16384 / CONFINE_MAXLOGINS=20
# CONFINE_SSH_FORWARDING=local|no|yes ssh -L yes, ssh -R no (default local)
# CONFINE_PROC_UNITS=polkit.service units that must still read /proc
# NO_REBOOT=1 skip the reboot at the end
# MOTD_URL=... override the motd endpoint
# TS_AUTHKEY=... tailscale auth key, to join the tailnet unattended
# TS_HOSTNAME=.. name this node takes on the tailnet (default: short hostname)
# WEB_DOMAIN=... domain for the per-user pages
# DEV_APPS=0 turn off <app>.<user>.$WEB_DOMAIN hosting
# DOTFILES_REPO=... git URL of the dotfiles to install (optional)
# SANDBOX=1 every account named on the run becomes a tenant
# SANDBOX_KIND=container|vm which tier they get (default container)
# SANDBOX_CPU=2 SANDBOX_MEMORY=2GiB SANDBOX_DISK=20GiB SANDBOX_PROCESSES=2048
# per-tenant caps. The disk one is only enforced on a pool
# driver that can (btrfs/zfs, not dir)
# SANDBOX_NESTING=0 allow docker inside a tenant container (weakens it;
# sell a --vm instead)
# SANDBOX_SSH_PORT_BASE=2200 tenant N reaches their box on 2200+N
# SANDBOX_PORT_BASE=21000 SANDBOX_PORT_SPAN=100
# the loopback band each tenant may publish dev apps on
# SANDBOX_IMAGE=images:ubuntu/24.04/cloud
# SANDBOX_POOL / SANDBOX_POOL_SIZE / SANDBOX_BRIDGE / SANDBOX_SUBNET
# SANDBOX_DENY_NETS=... where a tenant may not send a packet (RFC1918, the
# cloud metadata address, the tailnet)
# SPONSOR_AD_SLOT=... ad slot id; the ad is off until one is set
# PORKBUN_API_KEY=... PORKBUN_SECRET_API_KEY=...
# DNS-01 credentials for the wildcard cert. Without them:
# http only, no wildcard.
# CLOUDFLARE_API_TOKEN=... same, for zones hosted at Cloudflare instead
#
# --- 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
# nginx 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. In particular there is no
# default ad slot: a shared slot bills every install's impressions to one
# account, which is somebody else's bill.
# Deliberately POSIX so that dash 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 say
# so. Everything past it is bash.
if [ -z "${BASH_VERSION:-}" ]; then
echo "root-ubuntu.sh: this is a bash script and you are running it under sh." >&2
echo " curl -fsSL <url>/root-ubuntu.sh | bash -s -- --refresh" >&2
echo " ...or: bash root-ubuntu.sh" >&2
exit 1
fi
set -uo pipefail
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.
# Without that record a re-run cannot tell "we wrote this" from "the user
# wrote this", and the only safe answer would be to never update anything.
STATE_DIR="${STATE_DIR:-/var/lib/profullstack}"
USERS_STATE="$STATE_DIR/users"
LOCK_FILE=/var/lock/root-ubuntu.lock
LOG_FILE="${LOG_FILE:-/var/log/root-ubuntu.log}"
FORCE_DOTFILES="${FORCE_DOTFILES:-0}"
SKIP_APT="${SKIP_APT:-0}"
SKIP_WEB="${SKIP_WEB:-0}"
SKIP_TAILSCALE="${SKIP_TAILSCALE:-0}"
SKIP_TOOLS="${SKIP_TOOLS:-0}"
SKIP_DOTFILES="${SKIP_DOTFILES:-0}"
# 0 = never, 1 = only if the box says a reboot is required, 2 = always ask
REBOOT_POLICY=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, nginx, 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.
# In a dotfiles checkout it belongs with the dotfiles, so the whole team's keys
# travel together; without one it still has to persist somewhere, and that is
# the state directory.
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:-}"
TS_HOSTNAME="${TS_HOSTNAME:-$(hostname -s)}"
# 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}"
# Where the landing page sends people for mail and webmail. Both are only
# links, so a box for a different domain needs nothing here but these two.
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/}"
# Wildcard certs require a DNS-01 challenge. Provide a Cloudflare API token
# either in the environment or in CF_CREDENTIALS (ini format certbot expects).
CF_CREDENTIALS="${CF_CREDENTIALS:-/etc/letsencrypt/cloudflare.ini}"
CLOUDFLARE_API_TOKEN="${CLOUDFLARE_API_TOKEN:-}"
PORKBUN_API_KEY="${PORKBUN_API_KEY:-}"
PORKBUN_SECRET_API_KEY="${PORKBUN_SECRET_API_KEY:-}"
ACME_HOME="${ACME_HOME:-/root/.acme.sh}"
# No default, and no personal address baked in. Let's Encrypt uses it only for
# expiry warnings; issuance works without one, and the _issue_cert_* helpers
# say so once rather than failing.
ACME_EMAIL="${ACME_EMAIL:-}"
ACME_WEBROOT="${ACME_WEBROOT:-/var/www/acme}"
CERT_DIR="/etc/letsencrypt/live/$WEB_DOMAIN"
# reissue once the cert has this little life left
CERT_RENEW_DAYS="${CERT_RENEW_DAYS:-30}"
COPY_SSH_PRIVATE_KEYS="${COPY_SSH_PRIVATE_KEYS:-0}"
# 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}"
DEV_APPS_MAP=/etc/nginx/conf.d/profullstack-devapps.conf
# --- sandboxed tenants ---------------------------------------------------
#
# See "sandboxes" further down for what this actually does. 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}"
# Instance names are prefixed so `incus list` reads as a list of people rather
# than a list of hostnames that happen to collide with ours.
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:-/usr/sbin/nologin}"
SANDBOX_IMAGE="${SANDBOX_IMAGE:-images:ubuntu/24.04/cloud}"
# Storage. The driver decides whether $SANDBOX_DISK is a quota or a wish: dir
# cannot enforce one, btrfs and zfs can. Leave the driver empty to pick the
# best one actually present on the box.
SANDBOX_POOL="${SANDBOX_POOL:-tenants}"
SANDBOX_POOL_DRIVER="${SANDBOX_POOL_DRIVER:-}"
SANDBOX_POOL_SIZE="${SANDBOX_POOL_SIZE:-100GiB}"
# Our own bridge rather than incusbr0, so a box that already runs incus for
# something else keeps its network and its rules.
SANDBOX_BRIDGE="${SANDBOX_BRIDGE:-tenantbr0}"
SANDBOX_SUBNET="${SANDBOX_SUBNET:-10.171.0.1/24}"
# Handed to tenants over DHCP. Not the bridge's own dnsmasq -- see _sandbox_network.
SANDBOX_DNS="${SANDBOX_DNS:-1.1.1.1,9.9.9.9}"
# Per-tenant caps. limits.processes is what makes a fork bomb somebody else's
# problem only inside their own instance.
SANDBOX_CPU="${SANDBOX_CPU:-2}"
SANDBOX_MEMORY="${SANDBOX_MEMORY:-2GiB}"
SANDBOX_DISK="${SANDBOX_DISK:-20GiB}"
SANDBOX_PROCESSES="${SANDBOX_PROCESSES:-2048}"
# Nested containers (docker inside the tenant's container) need this, and it
# weakens the container boundary. Off by default: someone who needs docker
# should be sold a --vm, where nesting costs nothing and risks nothing of ours.
SANDBOX_NESTING="${SANDBOX_NESTING:-0}"
# Where a tenant's ports come from. Slot 0 gets ssh on 2200 and 21000-21099;
# slot 1 gets 2201 and 21100-21199, and so on. The last $SANDBOX_MOSH_PORTS of
# each band are udp, for mosh.
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}"
# Where a tenant may NOT send a packet. Everything private, plus the cloud
# metadata address (169.254.169.254, which hands out instance credentials) and
# the tailnet range -- so a tenant cannot reach this box, its neighbours, the
# LAN, or anything we have joined over tailscale.
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}"
# Only used where the distro has no incus of its own (22.04, Debian 12).
SANDBOX_INCUS_REPO="${SANDBOX_INCUS_REPO:-https://pkgs.zabbly.com/incus/stable}"
SANDBOX_INCUS_KEY="${SANDBOX_INCUS_KEY:-https://pkgs.zabbly.com/key.asc}"
# 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. What gets blocked is the
# training/scraping crawlers, which ignore robots.txt often enough that the
# polite file alone is not a control.
#
# The map is written even when this is 0 (with no entries, so $bad_bot is
# always empty). nginx refuses to start when a vhost references a variable no
# map defines -- the same trap DEV_APPS_MAP documents -- so the variable must
# exist unconditionally, and only its contents are conditional.
BLOCK_AI_BOTS="${BLOCK_AI_BOTS:-1}"
BAD_BOTS_MAP=/etc/nginx/conf.d/profullstack-badbots.conf
# Substrings matched case-insensitively against the User-Agent. Grouped so it
# is obvious what each entry is and nothing gets removed by guesswork.
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
)
# Sponsor ad shown at the top of the per-user pages: the directory listings
# under ~/public_html, and the default ~/public_html/index.html.
#
# The endpoint returns plain ASCII sized to a column count -- it is the same
# feed the terminal/motd banners use, and it hands back a different creative
# each time you ask.
#
# The ad rotates per page load, but it is NOT fetched per page load: that would
# put an external host in the critical path of every request, and one slow
# response would stall the page. Instead a timer keeps a pool of $SPONSOR_AD_POOL
# pre-rendered creatives on disk and nginx picks one at random per request
# (random_index). Rotation costs one open(); a dead endpoint just stops the pool
# from refreshing and the existing ads keep serving.
#
# Two mechanisms, because the two pages differ in kind:
# listings -- generated by autoindex, so there is no file to edit. nginx
# prepends the fragment with add_before_body.
# index.html -- a real file, so the default page carries an SSI include and
# nginx expands it. That also means a user can move the token,
# and a user who replaces the page entirely drops the ad.
# OFF until a slot id is configured, and there is deliberately no default one.
# An ad slot is an account: baking one in here would bill every box that ever
# runs this script to whoever owns that slot, and the impressions would look
# like traffic they did not have. So SPONSOR_AD_SLOT is the switch -- set it in
# $SERVER_CONFIG to turn the ad on, leave it alone to never see one.
SPONSOR_AD_SLOT="${SPONSOR_AD_SLOT:-}"
SPONSOR_AD="${SPONSOR_AD:-1}"
[[ -z "$SPONSOR_AD_SLOT" ]] && SPONSOR_AD=0
SPONSOR_AD_ENDPOINT="${SPONSOR_AD_ENDPOINT:-https://crawlproof.com/api/ads/motd}"
# Total width of the ad box, and it has a floor. The endpoint only draws the
# click URL inside the border when it fits -- otherwise it drops it onto a bare
# line underneath, which reads as a stray link rather than part of the ad. The
# URL is 25 chars of prefix + a 36-char id + "?s=$SPONSOR_AD_SRC", and the
# border costs 4 more, so 72 was one short of holding it and 76 is the exact
# floor. 80 leaves headroom for a longer src tag, and matches the ~79-char
# width of the autoindex listing it sits above.
SPONSOR_AD_COLS="${SPONSOR_AD_COLS:-80}"
# rides through to the click URL, so these views are told apart from the motd
SPONSOR_AD_SRC="${SPONSOR_AD_SRC:-userdirs}"
# How many pre-rendered creatives to keep. This is the rotation: nginx picks
# one at random per request, so it also bounds how repetitive a reload feels.
# Duplicates are left in rather than deduped -- the endpoint weights its own
# rotation, and collapsing that here would flatten it.
SPONSOR_AD_POOL="${SPONSOR_AD_POOL:-12}"
SPONSOR_AD_DIR=/var/www/sponsor
SPONSOR_AD_POOL_DIR=/var/www/sponsor/ads
# superseded by the pool; removed on upgrade
SPONSOR_AD_LEGACY_FILE=/var/www/sponsor/ad.html
# nginx URI the pool is served at. Internal, so it is only ever reachable
# through the SSI/add_before_body subrequests -- never fetched directly. The
# trailing slash matters: random_index only fires on a URI that ends in one.
SPONSOR_AD_URI=/.sponsor-ad/
SPONSOR_AD_BLANK_URI=/.sponsor-ad-blank
# chawan -- TUI browser and pager. Not in apt: the author ships a .deb, so the
# current version is read off the homepage ("the latest release (vX.Y.Z)") and
# the matching .deb is pulled from SourceHut. Set CHAWAN_VERSION to pin one.
# lynx is in BASE_PACKAGES as the fallback for when chawan cannot be installed
# at all -- non-amd64, or the download is unreachable.
CHAWAN_INDEX="${CHAWAN_INDEX:-https://chawan.net/index.html}"
CHAWAN_VERSION="${CHAWAN_VERSION:-}"
# only used when the homepage cannot be reached and nothing is installed yet
CHAWAN_FALLBACK_VERSION=0.4.4
# 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. Default selection is 1,2.
GROUP_CHOICES=(sudo admin docker adm www-data users)
# Overridable like every other setting -- server.conf documents this key, and an
# unattended run takes it verbatim for every account it creates. Assigning it
# unconditionally (as this line used to) meant a box that had configured, say,
# www-data,users,docker still got its new accounts put in sudo,admin.
# Was it said out loud, or is it just the default? Confinement turns the
# default from sudo,admin 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:-sudo,admin}"
# ------------------------------------------------------- confinement ---
#
# These boxes are multi-tenant. Several people share one dev server, and the
# root VPSes we sell hand a customer an ACCOUNT, never the root password. So
# the default has to be that an account is confined and an admin is let out,
# rather than the other way round -- a model where you have to remember to
# lock someone down is a model that eventually forgets.
#
# Three tiers, and the boundary is group membership:
#
# root untouched, always. Never capped, never blinded, never denied.
# Every limit below is explicitly lifted for uid 0, because the
# account you use to fix a wedged box must not be the account the
# wedge applies to.
# admin anyone in $CONFINE_EXEMPT_GROUPS. Us. Exempt from the resource
# caps, and kept able to see every process on the box.
# confined everybody else, by default, with nothing to remember. Confined
# home, own processes only, capped memory and tasks, no relay out
# over ssh.
#
# What each mechanism is actually worth is written up at configure_confinement
# below. CONFINE=0 turns all of it off and gives back the older behaviour.
CONFINE="${CONFINE:-1}"
# The group that carries the confinement. Membership is recomputed on every run
# from "is this account in an exempt group", so it converges rather than
# drifting: promote someone and the next run takes them back out of it.
CONFINE_GROUP="${CONFINE_GROUP:-confined}"
CONFINE_EXEMPT_GROUPS="${CONFINE_EXEMPT_GROUPS:-sudo,admin}"
# Members still see every process once /proc is mounted hidepid. Admins go in
# here; a monitoring agent that reads /proc should too.
CONFINE_PROC_GROUP="${CONFINE_PROC_GROUP:-proc}"
CONFINE_HIDEPID="${CONFINE_HIDEPID:-1}"
# Units that must keep seeing other people's processes. polkit runs as polkitd
# rather than as root and reads /proc/<pid> of whoever is asking it for
# authorisation, so it goes blind under hidepid without this. Anything else you
# run that reads /proc as a non-root user belongs on this list.
CONFINE_PROC_UNITS="${CONFINE_PROC_UNITS:-polkit.service}"
# 0700, so a home is the account's own business. This is the single biggest
# item in here: useradd on Ubuntu makes a home 0750 and this script then
# chmod o+x'd it, which is enough for anyone with a shell on the box to walk
# into someone else's ~/.config and read whatever is world-readable in there.
CONFINE_HOME_MODE="${CONFINE_HOME_MODE:-0700}"
# ...and 027, so what gets CREATED in there from now on is not world-readable
# either. Fixing the mode of the home does not fix the mode of the files
# already inside it, but it does close the path to them.
CONFINE_UMASK="${CONFINE_UMASK:-027}"
CONFINE_SYSCTL="${CONFINE_SYSCTL:-1}"
# Resource caps on the per-user systemd slice. Memory and tasks are HARD caps,
# because their failure mode is the whole box going down with one account. CPU
# is a weight rather than a quota, because ITS failure mode is only slowness,
# and throttling a build while the box is otherwise idle is a bad trade. Set
# CONFINE_CPU_QUOTA (e.g. 200%) when you would rather have the ceiling.
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}"
CONFINE_CPU_QUOTA="${CONFINE_CPU_QUOTA:-}"
# PAM limits, which bite at login rather than in the cgroup: a fork bomb is
# stopped by nproc long before MemoryMax notices it happening.
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, for a box whose tenants only need a shell
# 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 that default was
# sudo,admin -- which on a confined box would hand every new tenant the way
# out of confinement on the first --refresh. Only the default moves: a
# DEFAULT_GROUPS in the environment or in server.conf is an answer, and 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=() # sudo,admin -- 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=/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 )
else
( cd -- "$home" && runuser -u "$login" -- \
env -i "${envs[@]}" bash -lc "$*" </dev/null )
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 nginx 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[@]}"}"
}
# -------------------------------------------------------- remote shares ---
#
# Mount a share from another box, and keep it mounted across reboots.
#
# The mountpoint is named after where the data actually lives. A remote share
# sitting at a path that reads like local disk is genuinely dangerous: someone
# eventually runs mv or rm -rf against what they believe is a spare local
# volume, and it is in fact the only copy, on another machine, over the wire.
#
# ubuntu@files.example.com:~/Downloads/done
# -> /mnt/tailscale.files.example.com/Downloads/done
# └ how we reach it ┘└ which box ┘└ the remote path, verbatim ┘
#
# The first label is HOW the box is reached -- "tailscale" for a tailnet peer,
# otherwise the protocol ("nfs" or "sshfs"). Never just the remote username:
# "ubuntu" names an account, not a machine, and there is one on every box.
MNT_ROOT="${MNT_ROOT:-/mnt}"
# Who a --shared mount is opened to. Not "everyone": every human account on
# these boxes is in `users` (it is one of GROUP_CHOICES above), and daemons are
# not, so the group is already the line between a person and a service.
SHARE_GROUP="${SHARE_GROUP:-users}"
# What a shared directory and the files under it end up as. Directories need the
# execute bit to be traversable at all, and the setgid bit to keep new entries in
# the group -- which is why these are not the same number with a digit moved.
SHARE_DIR_MODE="${SHARE_DIR_MODE:-2775}"
SHARE_FILE_MODE="${SHARE_FILE_MODE:-0664}"
# The account nginx runs as. Only used to let it traverse ~/share (_share_link);
# it is deliberately NOT $SHARE_GROUP, which is who may write to a mount.
WEB_GROUP="${WEB_GROUP:-www-data}"
# Groups beyond $SHARE_GROUP that also need to WRITE to a share. Comma or space
# separated in the environment; `share --group NAME` adds one for a single run.
#
# A directory has exactly one group, so a second one cannot be said in a mode at
# all -- it takes a POSIX ACL. That has a real cost: `ls -l` then shows a mode
# that is no longer the whole truth, marked only by a trailing `+`, and getfacl
# is the only way to read what is actually granted. So it stays opt-in, and the
# plain mode remains the entire story for every share that does not ask for it.
#
# The case it exists for is a volume that both people and a daemon write to: the
# humans are in `users`, nginx is www-data, and neither belongs in the other's
# group. Putting www-data in `users` hands the web server every other `users`
# share on the box; putting the humans in www-data is the same trade backwards.
# An ACL on the one directory that needs it grants exactly what was meant.
SHARE_EXTRA_GROUPS="${SHARE_EXTRA_GROUPS:-}"
# The extra groups, one per line, deduplicated, with $SHARE_GROUP itself dropped
# -- it is already the owning group, and an ACL entry restating that is noise
# that whoever reads getfacl later has to work out is redundant.
_extra_share_groups() {
local raw="${SHARE_EXTRA_GROUPS//,/ }" g seen=" "
for g in $raw; do
[[ -n "$g" && "$g" != "$SHARE_GROUP" ]] || continue
[[ "$seen" == *" $g "* ]] && continue
seen+="$g "
printf '%s\n' "$g"
done
}
# Grant $SHARE_EXTRA_GROUPS on a directory. Two entries per group, not one:
#
# g:NAME:rwx what NAME may do to this directory as it stands
# d:g:NAME:rwx the default, inherited by whatever is created inside it later
#
# Without the default entry the grant covers the directory and nothing that ever
# lands in it -- the same "the first writer locks everyone else out" failure the
# setgid bit exists to prevent, one level down and invisible in `ls -l`.
#
# setfacl recomputes the mask from the entries it is handed, and the mask caps
# every named entry. Spelling out rwx is what keeps the mask open; a plain
# `chmod g+w` afterwards narrows it again, which is why _share_perms sets the
# mode BEFORE calling this and never the other way round.
_share_acl() {
local dir="$1" recurse="${2:-0}" grp ok rc=0
local -a extras=()
while read -r grp; do [[ -n "$grp" ]] && extras+=("$grp"); done < <(_extra_share_groups)
[[ ${#extras[@]} -gt 0 ]] || return 0
if ! command -v setfacl >/dev/null 2>&1; then
warn "share: setfacl is missing -- '${extras[*]}' not granted on $dir (apt install acl)"
return 1
fi
for grp in "${extras[@]}"; do
if ! getent group "$grp" >/dev/null; then
warn "share: no group '$grp' -- not granted on $dir"
rc=1; continue
fi
ok=1
if [[ "$recurse" == 1 ]]; then
# rwX with a capital X: execute for directories and for files that
# already had it, nothing else. A flat rwx here would make every
# data file on the volume executable, which is the thing the
# separate $SHARE_FILE_MODE exists to avoid.
setfacl -R -m "g:$grp:rwX" "$dir" 2>/dev/null || ok=0
# Default entries are a directory-only concept, so they cannot ride
# along on the -R above -- it would fail on the first plain file.
[[ "$ok" == 1 ]] && { find "$dir" -type d -exec setfacl -m "d:g:$grp:rwx" {} + 2>/dev/null || ok=0; }
else
setfacl -m "g:$grp:rwx" -m "d:g:$grp:rwx" "$dir" 2>/dev/null || ok=0
fi
if [[ "$ok" == 0 ]]; then
warn "share: could not grant '$grp' on $dir -- is the filesystem mounted with ACL support?"
rc=1; continue
fi
info "also writable by group '$grp' (acl)"
done
return "$rc"
}
# Resolve a tailnet peer name, as it appears in `tailscale status`, to its IP.
_tailnet_ip() {
local peer="$1" ip
command -v tailscale >/dev/null 2>&1 || return 1
ip="$(tailscale status 2>/dev/null | awk -v p="$peer" '$2 == p { print $1; exit }')"
[[ -n "$ip" ]] || return 1
printf '%s' "$ip"
}
_port_open() { timeout 3 bash -c "exec 3<>/dev/tcp/$1/$2" 2>/dev/null; }
# "alice and root", or just "root" when that is already who we are.