-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathroot-ubuntu.sh
More file actions
executable file
·3941 lines (3537 loc) · 161 KB
/
Copy pathroot-ubuntu.sh
File metadata and controls
executable file
·3941 lines (3537 loc) · 161 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. dotfiles (.zsh*, .bash*, .ssh*, ...) from $DOTFILES_REPO, if you have one
# 5. oh-my-zsh + plugins, oh-my-tmux, irssi configs
# 6. mise (curl https://mise.run | sh)
# 7. moshcode (curl https://moshcode.sh/install.sh | sh)
# 8. motd from $MOTD_URL
# 9. nginx per-user pages, per-user dev apps, TLS
#
# 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
#
# 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
# --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
# ASSUME_YES=1 don't prompt (defaults: groups sudo,admin; no privkey copy)
# 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)
# 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
# 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)
DEFAULT_GROUPS="sudo,admin"
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}"
# 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.
_owner_desc() {
local me="${SUDO_USER:-root}"
[[ "$me" == root ]] && printf 'root' || printf '%s and root' "$me"
}
# Open a directory to one account, or to everyone in $SHARE_GROUP.
#
# A volume the team is meant to share cannot be 0700 owned by whoever happened
# to run the mount -- that is how you get `touch foo` -> Permission denied on a
# 200G disk sitting empty. Shared mode hands the directory to the group instead:
#
# 2775 directories: rwx for owner and group, r-x for everyone else, and
# setgid so every file and directory created inside inherits
# $SHARE_GROUP rather than the creator's private group. Without the
# setgid bit the first person to write locks the next one out, which
# looks exactly like the bug this is meant to fix.
# 0664 files: rw for owner and group, r for everyone else.
#
# The group is who may WRITE; the world r bit only lets other accounts read.
# That is deliberate -- nginx serving out of a shared volume is the common case
# and does not justify putting www-data in $SHARE_GROUP. Override with
# SHARE_DIR_MODE / SHARE_FILE_MODE if a volume needs to be group-only (2770 and
# 0660), which is the right call for anything actually sensitive.
#
# setgid fixes the group a new file lands in, not its mode -- the group WRITE
# bit comes from the writer's umask. Ubuntu's default 002 grants it (safe here
# because USERGROUPS_ENAB gives each account its own private group). A user who
# has set umask 022 will still create files their colleagues cannot write.
_share_perms() {
local dir="$1" shared="$2"
if [[ "$shared" == 1 ]]; then
getent group "$SHARE_GROUP" >/dev/null \
|| die "share: group '$SHARE_GROUP' does not exist (groupadd $SHARE_GROUP, or set SHARE_GROUP=)"
chgrp "$SHARE_GROUP" "$dir" || warn "could not set group $SHARE_GROUP on $dir"
chmod "$SHARE_DIR_MODE" "$dir" || warn "could not open $dir to $SHARE_GROUP"
info "shared: anyone in '$SHARE_GROUP' can read and write $dir"
else
chown "${SUDO_USER:-root}" "$dir" 2>/dev/null || true
# 00700, not 0700: chmod leaves a directory's setgid bit alone unless a
# numeric mode carries the extra leading zero. Going shared -> private
# with 0700 lands on 2700 -- harmless while the group has no bits, but
# it comes back the moment someone loosens them again.
chmod 00700 "$dir" || warn "could not lock down $dir"
info "private: only $(_owner_desc) can traverse $dir"
fi
}
# /mnt stays the single source of truth; ~/share/<name> is the short path a
# human actually types. A symlink rather than a second mountpoint, so findmnt
# and `mounts` keep showing exactly one location for the data.
#
# Who can reach a share is decided by one directory: the one above the
# mountpoint in /mnt. Without execute there, no other account can traverse to
# the data, whatever the server says the files are. Putting the mount in $HOME
# would not have given that control on its own; /home/<user> is 0751 on these
# boxes, so every account can already walk through it.
#
# That directory is $SHARE_DIR_MODE root:$SHARE_GROUP by default (_share_perms) --
# these are team boxes and a mount nobody but the person who ran it can write to
# is the common failure, not a safe default. Pass --private for a share that
# genuinely belongs to one account and it goes back to 0700.
#
# The symlink has no say in either case -- a symlink cannot grant what the
# directory withholds. ~/share is a shortcut someone types, not the permission
# boundary, so it must not be what withholds traversal from whoever was
# legitimately pointed at one of these links.
#
# A symlink is resolved by whoever follows it, and the kernel then checks EVERY
# component of the path it expands to. So a 0700 ~/share silently becomes a
# second permission boundary for anything that walks in from outside -- nginx
# following ~/public_html/done -> ~/share/seed gets EACCES on ~/share and 403s,
# while the /mnt directory that is supposed to be making that call sits there
# world-traversable.
#
# What needs to traverse is one account, so name it: the directory goes to
# $WEB_GROUP with 0710. www-data gets the x bit, other accounts get nothing --
# 0711 would have worked too, but it hands traversal to every process on the box
# to solve a problem only nginx has. Group execute is the same fix, scoped.
#
# Withholding r keeps it per-user either way: nginx can walk through to a link
# it was pointed at, but cannot list which shares exist.
#
# No $WEB_GROUP means no web server, so nothing needs to traverse and it stays
# 0700. That is the safe direction to fail -- a --skip-web box is not silently
# opened up.
_share_link() {
local mp="$1" name="$2" owner="${SUDO_USER:-root}" home grp mode
home="$(getent passwd "$owner" | cut -d: -f6)"
[[ -n "$home" && -d "$home" ]] || { warn "no home for $owner -- skipping the ~/share link"; return 0; }
if getent group "$WEB_GROUP" >/dev/null; then
grp="$WEB_GROUP" mode=0710
else
grp="$owner" mode=0700
info "no group '$WEB_GROUP' -- $home/share stays private to $owner"
fi
# -m and -g re-apply to an existing directory too, so a ~/share left 0700 by
# an earlier run is repaired by the next mount rather than staying broken.
install -d -o "$owner" -g "$grp" -m "$mode" "$home/share" || return 0
ln -sfn "$mp" "$home/share/$name" || return 0
chown -h "$owner:$owner" "$home/share/$name" 2>/dev/null || true
info "link: $home/share/$name -> $mp"
}
# ubuntu + ~/Downloads/done -> /home/ubuntu/Downloads/done (what NFS exports)
_expand_remote() {
local user="$1" path="$2"
case "$path" in
'~/'*) printf '/home/%s/%s' "$user" "${path#\~/}" ;;
'~') printf '/home/%s' "$user" ;;
/*) printf '%s' "$path" ;;
*) printf '/home/%s/%s' "$user" "$path" ;;
esac
}
mount_usage() {
cat <<-'EOF'
Usage:
root-ubuntu.sh mount [user@]host:/remote/path [options]
root-ubuntu.sh umount <mountpoint|host>
root-ubuntu.sh mounts
root-ubuntu.sh share <mountpoint>... [--private] [-R]
Mounts a remote share at /mnt/<how>.<host>/<remote/path> and adds an fstab
entry so it comes back after a reboot. Re-running for the same share just
rewrites the entry, so it is safe to repeat.
Shares are shared by default: the directory above the mountpoint becomes
2775 root:$SHARE_GROUP (default: users), so every human account on the box
can read and write it. The setgid bit keeps new files in the group, so the
first writer does not lock everyone else out. Other accounts get read only.
The short path is linked as ~/share/<name>.
Modes come from $SHARE_DIR_MODE (2775) and $SHARE_FILE_MODE (0664); set both
in the environment for a volume that should be group-only (2770 and 0660).
Pass --private for a share that belongs to one account: that directory goes
to 0700 instead and nobody else on the box can traverse to the data.
`share` applies the same thing to a volume that is already mounted -- a
provider-attached block volume, say, which lands root:root 0755 with its own
fstab line and was never touched by this script. Add -R to sweep contents
that are already there.
Options:
--link NAME name for the ~/share/ symlink (default: first label of the
host, e.g. files.example.com -> files)
--no-link do not create the ~/share symlink at all
--via PEER reach the host over this tailnet peer (from `tailscale status`).
Use when the DNS name resolves to a public IP but you want the
traffic on the tailnet -- the mount is then labelled tailscale.
--nfs force NFS (default when the host answers on 2049)
--sshfs force sshfs (default otherwise)
--name PATH override the derived mountpoint entirely
--shared open the share to the $SHARE_GROUP group (2775, setgid) -- the default
--private keep it to one account (0700)
--ro mount read-only
--no-fstab mount now, do not persist across reboots
--dry-run print the mountpoint, fstab line and unit, change nothing
Examples:
root-ubuntu.sh mount ubuntu@files.example.com:~/Downloads/done --via ubuntu
root-ubuntu.sh mount media.example.com:/srv/media --ro
root-ubuntu.sh umount /mnt/tailscale.files.example.com/Downloads/done
EOF
}
cmd_mount() {
local spec="" via="" proto="" override="" persist=1 ro=0 dry=0
local link_name="" want_link=1 shared=1
while [[ $# -gt 0 ]]; do
case "$1" in
--via) via="${2:-}"; shift ;;
--nfs) proto=nfs ;;
--sshfs) proto=sshfs ;;
--name) override="${2:-}"; shift ;;
--link) link_name="${2:-}"; shift ;;
--no-link) want_link=0 ;;
--shared) shared=1 ;;
--private) shared=0 ;;
--ro) ro=1 ;;
--no-fstab) persist=0 ;;
--dry-run) dry=1 ;;
-h|--help) mount_usage; return 0 ;;
-*) die "mount: unknown option: $1" ;;
*) [[ -z "$spec" ]] && spec="$1" || die "mount: unexpected argument: $1" ;;
esac
shift
done
[[ -n "$spec" ]] || { mount_usage; return 2; }
[[ "$spec" == *:* ]] || die "mount: expected [user@]host:/remote/path, got '$spec'"
local hostpart="${spec%%:*}" rpath="${spec#*:}" user host
if [[ "$hostpart" == *@* ]]; then
user="${hostpart%%@*}"; host="${hostpart#*@}"
else
user=root; host="$hostpart"
fi
[[ -n "$host" && -n "$rpath" ]] || die "mount: could not parse '$spec'"
local remote; remote="$(_expand_remote "$user" "$rpath")"
# Where we actually talk to the box, and what we therefore call the mount.
local target label=""
if [[ -n "$via" ]]; then
target="$(_tailnet_ip "$via")" \
|| die "mount: '$via' is not a peer in 'tailscale status'"
label=tailscale
info "routing over the tailnet: $via = $target"
elif target="$(_tailnet_ip "$host")"; then
label=tailscale
info "$host is a tailnet peer ($target)"
else
target="$host"
fi
# NFS if the box is exporting, sshfs if it is not. Probing beats guessing.
if [[ -z "$proto" ]]; then
if _port_open "$target" 2049; then proto=nfs; else proto=sshfs; fi
info "detected transport: $proto"
fi
[[ -n "$label" ]] || label="$proto"
# The mountpoint mirrors the path as TYPED (~/Downloads/done -> Downloads/done),
# not the expanded one -- /mnt/... /home/ubuntu/Downloads/done reads terribly.
local mpath="${rpath#\~/}"; mpath="${mpath#/}"
local mp="${override:-$MNT_ROOT/$label.$host/$mpath}"
local rw=rw; [[ "$ro" == 1 ]] && rw=ro
local tsreq=""
[[ "$label" == tailscale ]] && tsreq=",x-systemd.requires=tailscaled.service"
local src opts fstype
case "$proto" in
nfs)
fstype=nfs
src="$target:$remote"
opts="nfsvers=4.1,proto=tcp,$rw,hard,_netdev,nofail,x-systemd.automount$tsreq"
;;
sshfs)
# A dry run must not install anything -- just say it would.
if ! command -v sshfs >/dev/null 2>&1; then
if [[ "$dry" == 1 ]]; then
info "sshfs is not installed; a real run would apt-get install it"
else
log "installing sshfs"
apt-get install -y sshfs >/dev/null || die "mount: could not install sshfs"
fi
fi
fstype=fuse.sshfs
src="$user@$target:$remote"
# root does the mounting, so it is root's key that has to be authorised
# on the far side -- not the invoking user's.
opts="$rw,_netdev,nofail,x-systemd.automount$tsreq,allow_other,reconnect,ServerAliveInterval=15,IdentityFile=/root/.ssh/id_ed25519"
[[ -r /root/.ssh/id_ed25519 ]] \
|| warn "no /root/.ssh/id_ed25519 -- ssh-keygen and copy it to $user@$host first"
;;
*) die "mount: unknown protocol '$proto'" ;;
esac
# files.example.com -> files. Short, and it is the name you already say out loud.
[[ -n "$link_name" ]] || link_name="${host%%.*}"
# The gate is the first directory under /mnt, not the mountpoint itself --
# once NFS is mounted, the mountpoint's own mode comes from the server.
local rel="${mp#"$MNT_ROOT"/}" share_root
share_root="$MNT_ROOT/${rel%%/*}"
if [[ "$dry" == 1 ]]; then
log "dry run -- nothing was changed"
info "mountpoint : $mp"
info "fstab : $src $mp $fstype $opts 0 0"
info "unit : $(systemd-escape -p --suffix=automount "$mp")"
if [[ "$shared" == 1 ]]; then
info "shared : $share_root becomes $SHARE_DIR_MODE root:$SHARE_GROUP"
else
info "private : $share_root becomes 0700, reachable by $(_owner_desc)"
fi
[[ "$want_link" == 1 ]] && info "link : ~/share/$link_name -> $mp"
return 0
fi
[[ $EUID -eq 0 ]] || die "mount: must run as root (try: sudo $0 mount ...)"
log "mounting $src -> $mp"
install -d -m 0755 "$mp" || die "mount: could not create $mp"
_share_perms "$share_root" "$shared"
if [[ "$persist" == 1 ]]; then
cp -a /etc/fstab "/etc/fstab.bak.$(date +%Y%m%d-%H%M%S)"
# Drop any previous entry for this mountpoint or this source, so a repeat
# run replaces its own line instead of stacking a second one beside it.
local tmp; tmp="$(mktemp)"
awk -v mp="$mp" -v src="$src" '$1 == src || $2 == mp { next } { print }' \
/etc/fstab >"$tmp" && cat "$tmp" >/etc/fstab
rm -f "$tmp"
printf '%s %s %s %s 0 0\n' "$src" "$mp" "$fstype" "$opts" >>/etc/fstab
systemctl daemon-reload
local unit; unit="$(systemd-escape -p --suffix=automount "$mp")"
systemctl start "$unit" 2>/dev/null || true
fi
mountpoint -q "$mp" || mount "$mp" 2>/dev/null || ls "$mp" >/dev/null 2>&1
if ! mountpoint -q "$mp"; then
warn "not mounted yet -- check: systemctl status $(systemd-escape -p --suffix=mount "$mp")"
return 1
fi
info "mounted: $(ls -1 "$mp" 2>/dev/null | wc -l) entries, $(df -h --output=used "$mp" | tail -1 | tr -d ' ') used"
[[ "$persist" == 1 ]] && info "persisted in /etc/fstab (survives reboot)"
[[ "$want_link" == 1 ]] && _share_link "$mp" "$link_name"
return 0
}
cmd_umount() {
local what="${1:-}"
[[ -n "$what" ]] || { mount_usage; return 2; }
[[ $EUID -eq 0 ]] || die "umount: must run as root (try: sudo $0 umount ...)"
# Accept either the mountpoint itself or the host it came from.
local mp="$what"
if [[ ! -d "$mp" ]]; then
mp="$(awk -v h="$what" -v root="$MNT_ROOT" '$2 ~ ("^" root "/[^/]*\\." h "/") { print $2; exit }' /etc/fstab)"