Compare commits

...
22 Commits
Author SHA1 Message Date
mk ce5041bc50 todo md 2026-07-28 07:23:10 +03:00
mk 9f164f61ef angie(debian): pin VERSION_ID in apt repo URL — needs angie/<ID>/<VERSION_ID>/ (debian/13/dists/trixie/) not angie/<ID>/ (debian/dists/trixie/ -> 404 'no Release file') on trixie; suite stays VERSION_CODENAME
packages(debian): rm stale /etc/apt/sources.list.d/eza.list + gierens.gpg left by the pre-fix run that used the now-dead apt.fury.io PPA — otherwise 'apt-get update' keeps hitting the 401 forever even though we now install eza from GH releases
2026-07-28 07:11:22 +03:00
mk 0cfe10e387 packages(debian): fix eza+starship+zoxide installs
- eza: apt.fury.io PPA is dead (401 Unauthorized); install the single binary from the upstream GitHub release tarball (eza_x86_64-unknown-linux-gnu.tar.gz) into /usr/local/bin — no apt repo, system-wide
- starship: scrub $ARCH/$PLATFORM from the installer env — our lib/detect.sh exports ARCH=amd64, which the starship installer reuses (it checks ${ARCH-}) and skips its own amd64->x86_64 mapping, producing an unsupported 'amd64-unknown-linux-musl' target. Default bin dir is /usr/local/bin anyway
- zoxide: install system-wide (--bin-dir /usr/local/bin) instead of ~/.local/bin — when run as root the default landed in /root (wrong user for the create-user path) and wasn't on $PATH
2026-07-28 07:08:04 +03:00
mk efdf056352 hardening(angie): default_server needs a cert on its 443 listener or 'angie -t' fails with 'no ssl_certificate defined' — generate a one-time self-signed dummy (CN=bootstrap-default, 10y) in /etc/angie/ssl so unknown-SNI clients handshake then get 444; mark listen 443 as ssl explicitly 2026-07-28 06:55:31 +03:00
mk 435d66aad2 shell-zsh: derive ZSH/ZDOTDIR from $TARGET_HOME unconditionally — ignore inherited $ZSH env var (omz exports it, which pinned paths to the old home e.g. /root while the chown step retargeted to the new user, causing 'insecure completion dirs' owned by the wrong user) 2026-07-28 06:16:45 +03:00
mk 94a0bac9f4 hardening(angie): resolve mime.types from whichever path the package ships (angie-bin=/etc/angie, Arch source=/etc/nginx via mailcap dep) — fixes 'angie -t' 'open() mime.types failed' on source path; fallback ships a minimal mime.types; ensure modules/ include dirs exist before glob 2026-07-28 06:04:57 +03:00
mk 26185978ac hardening(angie): source-angie bridge — symlink /etc/nginx/nginx.conf -> /etc/angie/angie.conf so the _on/targets layout actually loads under the source AUR package's '/usr/bin/nginx' service (angie-bin/Debian already read /etc/angie directly); backup original once 2026-07-28 05:59:08 +03:00
mk cb0e517a7d hardening(angie): fix 'angie_user: unbound variable' under set -u — init extraction locals to empty; also read source AUR package's /etc/nginx/nginx.conf as alt stock config path 2026-07-28 05:57:44 +03:00
mk b653191de2 angie(arch): prefer angie-bin (repackaged upstream .deb, instant) over source build; verified http_acme/ssl/v2/v3/realip all compiled in; fall back to aur/angie if bin unavailable 2026-07-28 05:53:43 +03:00
mk ca8f01083c packages(arch): route byobu through yay (AUR-only); split official vs AUR lists so pacman --needed can't abort on a missing target; as_user prefers TARGET_USER for AUR builds 2026-07-28 05:44:45 +03:00
mk ff9fc4db4e fix(sanity): hostname missing on minimal Arch (not in coreutils) — use uname -n with fallbacks; refresh TLS follow-up to angie-issue 2026-07-28 05:35:40 +03:00
mk f7e4bb96d7 fix(zsh plugins): correct clone orgs — zsh-users/zsh-history-substring-search, Aloxaf/fzf-tab (were typos causing github auth prompts) 2026-07-28 05:28:20 +03:00
mk 146c79a57e docs: Angie built-in ACME (no certbot) section + --yes/--username notes 2026-07-28 05:21:23 +03:00
mk 8930e7aa76 non-interactive --yes: ask() assumes prompt default (no read); timezone defaults UTC; --username implies force-create; create-user name defaults to mk under --yes 2026-07-28 05:21:23 +03:00
mk 34137d185e docs: document sudo-user creation + retarget + new flags 2026-07-28 05:05:03 +03:00
mk e7a9349292 modules: target $TARGET_USER (docker group, zsh/nvim/nvm homes, chown tree) 2026-07-28 05:05:03 +03:00
mk 8a516f039c create-user: passwordless sudo user (NOPASSWD, !tty_tickets, 7d cache) + retarget config; --create-user/--username flags 2026-07-28 05:05:03 +03:00
mk a9fff32255 docs: document apt/pac aliases + Angie config layout 2026-07-28 04:50:08 +03:00
mk 0116ac60b3 hardening: angie config layout (_on/targets/modules) + sane root config + enable/disable helpers 2026-07-28 04:50:08 +03:00
mk 247d33ae8a zsh: add distro-guarded apt alias set (apt/apti/aptu/apts/aptsh) mirroring pacman 2026-07-28 04:50:08 +03:00
mk 3b25d8d4a4 docs: README + gitignore 2026-07-28 04:32:38 +03:00
mk 8bb8a44dbd zsh config: zshrc, path, aliases (ported from fish), extras, functions, plugins 2026-07-28 04:32:38 +03:00
21 changed files with 1086 additions and 89 deletions

No files matched your search

+1
View File
@@ -0,0 +1 @@
*.bak.*
+178
View File
@@ -0,0 +1,178 @@
# bootstrap
Turn a fresh Debian/Ubuntu/Arch VPS into a familiar workstation in one command. Detects the distro, installs a curated CLI package set, sets up zsh + oh-my-zsh with your ported config, brings your nvim config, hardens sane system defaults, and reports state.
## Usage
```sh
curl -fsSL <shortener> | bash
# run a single step:
curl -fsSL <shortener> | bash -s -- --only packages
# run everything non-interactively and create a sudo user 'mk':
curl -fsSL <shortener> | bash -s -- --yes --username mk
```
The shortener points at the **raw `install.sh`** in this repo, which clones the
full repo to `~/.bootstrap` and runs `setup.sh`. To update: `cd ~/.bootstrap &&
git pull` (or just re-run the one-liner — it pulls existing clones).
Re-run individual steps locally after the initial bootstrap:
```sh
bash ~/.bootstrap/setup.sh --only shell-zsh
bash ~/.bootstrap/setup.sh --only hardening
```
## Steps
| # | step | what it does |
|---|-------------|--------------------------------------------------------------------|
| 0 | preflight | verify distro + network + privileges |
| 1 | create-user | (when direct root) create a passwordless sudo user, copy root's authorized_keys, retarget config at them |
| 2 | packages | git, byobu, ufw, mosh, btop, eza, fzf, ripgrep, bat, zoxide, jq, fd, tree, ncdu, lazygit, starship, … (+ `yay` on Arch) |
| 3 | docker | Docker engine + compose v2 plugin, `$TARGET_USER` in `docker` group |
| 4 | angie | Angie (nginx fork) web server |
| 5 | shell-zsh | zsh + oh-my-zsh + plugins, set default shell, ship config (to `~$TARGET_USER/.config/zsh`) |
| 6 | nvm | nvm → `~$TARGET_USER/.nvm` + Node LTS |
| 7 | nvim | clone `g.mk.fo/bootstrap/nvim-minimal` → `~$TARGET_USER/.config/nvim` |
| 8 | hardening | ssh (key-only root), ufw, updates, unattended-upgrades, tz, hostname, swapfile, locale, fail2ban, **angie config layout + built-in ACME** |
| 9 | sanity | read-only report + recommended manual follow-ups |
Every step is idempotent — re-running is safe.
## Sudo user (when running as direct root)
Running the one-liner as **direct root** (no `SUDO_USER`) is the common
provider-provisioned-VPS case. The `create-user` step then:
- **Arch**: auto-creates a non-root sudoer (AUR/`makepkg` can't run as root),
with a `y/N` confirm on the username (default `mk`).
- **Debian/Ubuntu**: asks `y/N` whether to create one (optional — apt works
as root).
- Always when sudoed-root (`SUDO_USER` set): uses that invoking user as the target,
syncing their sudoers + ssh keys instead of creating a new account.
- Never when not root: you're already a normal user; yourself is the target.
Flags:
- `--create-user` force the prompt regardless of distro,
- `--username NAME` use that name and skip the name prompt.
The created user:
- is added to `wheel` (Arch) or `sudo`+`adm` (Debian), shell set to zsh,
- has **no password** (`passwd -d`) — SSH key login only (you'll disable ssh
password auth in the hardening step),
- gets `/root/.ssh/authorized_keys` copied to `~user/.ssh/` (chmod 600/700),
with the fingerprints printed so you can sanity-check they're yours,
- gets `/etc/sudoers.d/<user>`: `Defaults:<user> !tty_tickets`,
`Defaults:<user> timestamp_timeout=10080` (7-day cache), and
`<user> ALL=(ALL) NOPASSWD:ALL`. Validated with `visudo -c` before install.
All later config steps (zsh/omz, nvim, nvm, docker group) target
`~$TARGET_USER`. After bootstrap, **test `ssh <user>@<host>` from a new
terminal before closing the root session.**
## Zsh plugins enabled (oh-my-zsh)
`git sudo command-not-found colored-man-pages extract copypath dirhistory`
plus, cloned into `$ZSH_CUSTOM`: `zsh-autosuggestions`,
`zsh-syntax-highlighting` (loaded last), `zsh-completions`,
`history-substring-search`, `fzf-tab`. Prompt: **starship**.
## Config layout (shipped to `~/.config/zsh`)
```
config/zsh/
zshrc # main config (sources the pieces below)
path.zsh # PATH + env (XDG, EDITOR, LANG, nvm, pnpm)
aliases.zsh # ported from fish + curated defaults
aliases-extras.zsh # your personal additions (preserved across re-runs)
functions.zsh # mcd, conf, so, ssh-wrapper, dot-expansion
plugins.zsh # omz plugin list + load oh-my-zsh
```
`~/.zshrc` is a tiny shim that sets `ZDOTDIR=~/.config/zsh` and sources the
real config there — keeps omz "happy" while your config lives under XDG.
## Hardening choices
- **SSH**: `PermitRootLogin prohibit-password` + `PasswordAuthentication no`.
Root key login stays available; password login is disabled. A backup is
made and `sshd -t` runs before reload. **Always keep your current session
open and test a new login before closing it.**
- **ufw**: deny incoming, allow `22/tcp`, `60000:61000/udp` (mosh),
`80,443/tcp` (angie).
- **updates + unattended-upgrades** (Debian), system upgrade on Arch.
- **timezone** (default UTC), **hostname** (optional).
- **swapfile** 2G if RAM < 2G and no swap present.
- **locale** en_US.UTF-8.
- **fail2ban** with an sshd jail.
- **angie config** — see [Angie config layout](#angie-config-layout) below.
## Supported hosts
- Debian / Ubuntu (via apt)
- Arch (via pacman + yay for AUR)
- Architectures: `amd64`, `arm64`
## Angie config layout
The hardening step sets up a sane `/etc/angie/` structure (Angie is an nginx fork; same config syntax):
```
/etc/angie/
angie.conf # managed by bootstrap (written once; not overwritten on re-runs)
mime.types # shipped by the package — left alone
_on/*.conf # enabled hosts (sites-enabled alt.) ← included from http{}
targets/*.conf # all host definitions (sites-available alt.)
modules/*.conf # top-level context snippets (stream{}, env, load_module, ...)
modules/http/*.conf # http{}-level snippets (upstreams, maps, real_ip, proxy defaults)
```
Convention: write each host in `targets/<name>.conf`, then enable it with
`angie-enable <name>` (symlinks `targets/<name>.conf` → `_on/<name>.conf`, validates,
reloads). Disable with `angie-disable <name>`. Both helpers are installed to
`/usr/local/bin/`.
The root `angie.conf` ships: `worker_processes auto`, `server_tokens off`,
gzip, `client_max_body_size 16m`, a main `log_format`, and includes
`modules/http/*.conf` then `_on/*.conf`. `user`/`pid`/`error_log`/`load_module`
directives are extracted from the original packaged `angie.conf` and preserved
(so Debian's `user angie` vs Arch's `user http` is carried over). A backup is
kept at `/etc/angie/angie.conf.orig.bootstrap`.
Defaults shipped:
- `modules/http/00-common.conf` — `real_ip` from private ranges (Cloudflare commented), `proxy_set_header` defaults (inherited by every server/location).
- `_on/00-default.conf` — `default_server` returning `444` for requests that match no enabled host (drops drive-by scanners / bare-IP probes).
- `targets/example-https.conf.example` — a TLS server template using Angie's built-in ACME.
- `angie-issue` helper — create a TLS host with a Let's Encrypt cert (no certbot).
## TLS / Let's Encrypt (Angie built-in ACME — no certbot)
Angie ships the `http_acme` module, so it obtains **and renews** TLS certificates
itself — no certbot, no separate renewal cron/timer. The hardening step ships an
example TLS host and an `angie-issue` helper:
```sh
angie-issue example.com you@email.com # first call: writes the shared
# acme_client (Let's Encrypt) + email
angie-issue www.example.com # later domains reuse the same client
```
All enabled hosts using `acme default` share one certificate covering every
`server_name` that references it; Angie requests it on reload and renews before
expiry. The shared client lives at `/etc/angie/modules/http/acme.conf` (with a
`resolver`, required by `acme_client`). HTTP→HTTPS redirect, `http2`, and the cert
are wired into the target template — replace `location /` with your app/proxy.
## Notes
- Distro-guarded package-manager aliases ship in `aliases.zsh`:
`pac`/`pacs`/`pacu`/`pacy`/`pa` (Arch, `sudo pacman …`) and
`apt`/`apti`/`aptu`/`apts`/`aptsh` (Debian/Ubuntu, `sudo apt …`).
- `aliases-extras.zsh` is seeded once and then left alone — re-running the
shell-zsh step won't clobber your personal aliases.
- On Debian, `fd`/`bat` ship as `fdfind`/`batcat`; symlinks to the canonical
names are created in `~/.local/bin` (which is on `PATH` via `path.zsh`).
- Some Go/Rust CLIs (`dust`, `duf`, `procs`, `lazygit`) are fetched from
the latest GitHub release tarball when the distro package is missing; this
is best-effort and warned (not fatal) on failure.
+11
View File
@@ -0,0 +1,11 @@
# aliases-extras.zsh — YOUR personal additions go here.
# This file is preserved across re-runs of the shell-zsh step (not overwritten
# from the repo). Add your taskwarrior / neomutt / cargo / eso aliases here.
# Examples (uncomment + edit):
# alias tw='taskwarrior-tui'
# alias ta='task add'
# alias tl='task list'
# alias neomutt='neomutt -f ~/.local/share/mail/you/All\ Mail/'
# alias carwash="cargo-watch -x run"
# alias wttr="curl v2.wttr.in/59.95,30.35?FQM" # your city coords
+76
View File
@@ -0,0 +1,76 @@
# aliases.zsh — ported from ~/.config/fish/config.fish (server-relevant subset)
# Plus curated defaults. Edit aliases-extras.zsh for personal additions.
# ── editor ──────────────────────────────────────────────────────────────────
alias v="$EDITOR"
alias vi="$EDITOR"
alias vim="$EDITOR"
# ── ls / eza ───────────────────────────────────────────────────────────────
alias lso="/bin/ls" # plain ls escape hatch
if command -v eza >/dev/null 2>&1; then
alias ls="eza -l --group-directories-first --icons"
alias ll="eza -la --group-directories-first --icons"
alias la="eza -a --group-directories-first --icons"
alias lt="eza --tree --level=2 --icons"
else
alias ls="ls --color=auto -l"
alias ll="ls -la --color=auto"
alias la="ls -a --color=auto"
fi
# ── cat / bat ───────────────────────────────────────────────────────────────
if command -v bat >/dev/null 2>&1; then
alias cat="bat --paging=never"
fi
alias catn="/bin/cat"
# ── file ops (verbose + interactive safety) ────────────────────────────────
alias cp="cp -iv"
alias mv="mv -iv"
alias rm="rm -iv"
alias ln="ln -sv"
alias mkdir="mkdir -pv"
alias md="mkdir"
# ── git ─────────────────────────────────────────────────────────────────────
alias g="git"
alias gcl="git clone"
alias gst="git status"
alias gd="git diff"
alias gco="git checkout"
# ── systemd ──────────────────────────────────────────────────────────────────
alias sy="sudo systemctl"
alias srv="sudo systemctl"
# ── sudo / misc ──────────────────────────────────────────────────────────────
alias sudoo="sudo -E -s"
alias chmox="chmod +x"
# ── network ─────────────────────────────────────────────────────────────────
alias whoamip="curl --silent https://ipinfo.io"
alias wttr="curl v2.wttr.in"
alias wtr="wttr"
# ── byobu ────────────────────────────────────────────────────────────────────
alias byb="byobu"
alias bybou="byobu"
# ── pacman (Arch only) ──────────────────────────────────────────────────────
if command -v pacman >/dev/null 2>&1; then
alias pac="sudo pacman"
alias pacs="sudo pacman -Sy"
alias pacu="sudo pacman -Syyu"
alias pacy="sudo pacman -Sy"
alias pa="sudo pacman"
fi
# ── apt (Debian/Ubuntu only) ───────────────────────────────────────────────
if command -v apt-get >/dev/null 2>&1; then
alias apt="sudo apt"
alias apti="sudo apt install"
alias aptu="sudo apt update && sudo apt upgrade"
alias apts="sudo apt search"
alias aptsh="sudo apt show"
fi
+28
View File
@@ -0,0 +1,28 @@
# functions.zsh — ported from fish functions (server-relevant subset)
# make dir + cd into it
mcd() { mkdir -p "$1" && cd "$1"; }
# reload zsh config
so() { source "${ZDOTDIR:-$HOME/.config/zsh}/zshrc" 2>/dev/null || source "$HOME/.zshrc"; }
# cd into ~/.config, edit/open the named file/dir (interactive)
conf() {
local base="${XDG_CONFIG_HOME:-$HOME/.config}"
cd "$base" || return
if [[ -f "$1" ]]; then
echo "Editing $1"; $EDITOR "$1"
elif [[ -d "$1" ]]; then
echo "Opening config dir $1"; cd "$1" && ls -al
else
read "confirm?Can't find $1, open in \$EDITOR? (Y/n) "
if [[ -z "$confirm" || "$confirm" =~ ^[Yy]$ ]]; then
$EDITOR "$1"
else
echo "Exiting…"
fi
fi
}
# ssh onward with a sane TERM (avoids terminfo breakage on older boxes)
ssh() { TERM=xterm command ssh "$@"; }
+35
View File
@@ -0,0 +1,35 @@
# path.zsh — PATH + environment (ported from ~/.config/fish/config.fish, server subset)
# XDG
export XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}"
export XDG_CACHE_HOME="${XDG_CACHE_HOME:-$HOME/.cache}"
export XDG_DATA_HOME="${XDG_DATA_HOME:-$HOME/.local/share}"
export XDG_STATE_HOME="${XDG_STATE_HOME:-$HOME/.local/state}"
export EDITOR="nvim"
export LANG="en_US.UTF-8"
export LC_COLLATE="C"
export PAGER="less"
export LESS="-R" # render ANSI colors in less
export BAT_THEME="base16"
export LESSHISTFILE="$XDG_STATE_HOME/less/history"
[[ -d "$XDG_STATE_HOME/less" ]] || mkdir -p "$XDG_STATE_HOME/less"
# user-local binaries (fd/bat shims, gh-release binaries, pip installs, ...)
case ":$PATH:" in
*":$HOME/.local/bin:"*) ;;
*) export PATH="$HOME/.local/bin:$PATH" ;;
esac
case ":$PATH:" in
*":/usr/local/bin:"*) ;;
*) export PATH="/usr/local/bin:$PATH" ;;
esac
# nvm (installed by the nvm step) — loaded lazily via its init snippet below
export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"
[[ -s "$NVM_DIR/nvm.sh" ]] && source "$NVM_DIR/nvm.sh"
# pnpm (if present)
if [[ -d "$HOME/.local/share/pnpm" ]] && ! [[ ":$PATH:" == *":$HOME/.local/share/pnpm:"* ]]; then
export PATH="$HOME/.local/share/pnpm:$PATH"
fi
+23
View File
@@ -0,0 +1,23 @@
# plugins.zsh — enable oh-my-zsh builtins + external plugins (cloned by the
# shell-zsh module into $ZSH_CUSTOM/plugins).
plugins=(
git
sudo
command-not-found
colored-man-pages
extract
copypath
dirhistory
zsh-autosuggestions
zsh-syntax-highlighting # must be last among syntax plugins
history-substring-search
fzf-tab
zsh-completions
)
source "$ZSH/oh-my-zsh.sh"
# history-substring-search keybindings (after omz is loaded)
bindkey '^[[A' history-substring-search-up
bindkey '^[[B' history-substring-search-down
+59
View File
@@ -0,0 +1,59 @@
# ~/.zshrc shim (managed by bootstrap) sources this file via ZDOTDIR.
# Edit freely; re-run the shell-zsh step to re-sync from the repo if needed.
# ── oh-my-zsh bootstrap ────────────────────────────────────────────────────
export ZSH="${ZSH:-$HOME/.oh-my-zsh}"
export ZSH_CUSTOM="${ZSH_CUSTOM:-$ZSH/custom}"
# theme disabled — we use starship (init at the bottom)
ZSH_THEME=""
# casewise history
HISTFILE="$HOME/.local/state/zsh/history"
HISTSIZE=100000
SAVEHIST=100000
setopt EXTENDED_HISTORY SHARE_HISTORY HIST_IGNORE_DUPS HIST_IGNORE_SPACE HIST_VERIFY
setopt AUTO_CD PUSHD_IGNORE_DUPS PROMPT_SUBST
setopt INTERACTIVE_COMMENTS
# load the modular pieces (order matters)
[[ -f "$ZDOTDIR/path.zsh" ]] && source "$ZDOTDIR/path.zsh"
[[ -f "$ZDOTDIR/aliases.zsh" ]] && source "$ZDOTDIR/aliases.zsh"
[[ -f "$ZDOTDIR/aliases-extras.zsh" ]] && source "$ZDOTDIR/aliases-extras.zsh"
[[ -f "$ZDOTDIR/functions.zsh" ]] && source "$ZDOTDIR/functions.zsh"
# ── completion styling (set before omz runs compinit) ───────────────
autoload -Uz compinit
zstyle ':completion:*' menu select
zstyle ':completion:*' matcher-list 'm:{a-zA-Z}={A-Za-z}'
zstyle ':completion:*' list-colors ${(s.:.)LS_COLORS}
# oh-my-zsh + plugins (omz runs compinit internally; lists our plugins)
[[ -f "$ZDOTDIR/plugins.zsh" ]] && source "$ZDOTDIR/plugins.zsh"
# ── standard zsh niceties ─────────────────────────────────────────────
autoload -U edit-command-line
zle -N edit-command-line
bindkey '^X^E' edit-command-line
# ... -> ../.. expansion (ported from fish)
rationalise-dot() {
if [[ $LBUFFER = *.. ]]; then LBUFFER+=/; else LBUFFER+=.; fi
}
zle -N rationalise-dot
bindkey . rationalise-dot
# ── keychain (commented) — fill in the key labels you use on this box ────
# if command -v keychain >/dev/null 2>&1; then
# keychain --quiet --nogui id_ed25519 # <- your server-relevant key(s) here
# [[ -f "$HOME/.keychain/$(hostname)-sh" ]] && source "$HOME/.keychain/$(hostname)-sh"
# fi
# ── integrations ────────────────────────────────────────────────────────────
command -v zoxide >/dev/null 2>&1 && eval "$(zoxide init zsh)"
command -v fzf >/dev/null 2>&1 && [[ -f "$HOME/.fzf.zsh" ]] && source "$HOME/.fzf.zsh"
# ── starship prompt ─────────────────────────────────────────────────────────
if command -v starship >/dev/null 2>&1; then
eval "$(starship init zsh)"
fi
+5 -4
View File
@@ -9,12 +9,13 @@ ok() { printf '\033[1;32mok\033[0m %s\n' "$*"; }
# run a command as root when not already root
sudo_() { if (( EUID == 0 )); then "$@"; else sudo "$@"; fi; }
# run a command AS the invoking (non-root) user — used for AUR builds, etc.
# when root: drop to SUDO_USER (or USER) via runuser; when not root: run directly.
# run a command AS the target non-root user — used for AUR builds, nvm, etc.
# precedence (when running as root): TARGET_USER (set by create-user) > SUDO_USER > USER.
# when not root: run directly as ourselves.
as_user() {
local u="$USER"
local u="${SUDO_USER:-$USER}"
if (( EUID == 0 )); then
u="${SUDO_USER:-$USER}"
u="${TARGET_USER:-${SUDO_USER:-$USER}}"
command -v runuser >/dev/null 2>&1 && runuser -u "$u" -- "$@" || su "$u" -c "$*"
else
"$@"
+6
View File
@@ -2,9 +2,15 @@
# prompt.sh — plain y/N prompts.
# ask "prompt?" [default y|n] -> sets REPLY
# Under ${ALL:-0} (the --yes flag) assume the stated default without
# reading stdin, so a fully non-interactive run never blocks on a read.
ask() {
local p="$1" d="${2:-y}" hint
if [[ "$d" == "y" ]]; then hint="[Y/n]"; else hint="[y/N]"; fi
if (( ${ALL:-0} )); then
REPLY="$d"
return
fi
read -rp "$p $hint " REPLY
[[ -z "$REPLY" ]] && REPLY="$d"
}
+157
View File
@@ -0,0 +1,157 @@
#!/usr/bin/env bash
# 05-create-user — create a daily-driver sudo user when running as direct root.
#
# Why this exists: `makepkg` (yay / AUR) refuses to run as root. On a fresh VPS
# that lands you in as root with no SUDO_USER, we create a non-root sudoer,
# copy root's authorized_keys to it, configure passwordless sudo with
# !tty_tickets + a long timestamp_timeout, and retarget the config steps
# (zsh, nvim, nvm, docker group) at that user's $HOME.
#
# Behaviour:
# - Not root -> skip (you're already a normal user).
# - Root + SUDO_USER set -> skip (you sudoed in; that user is the target).
# - Root + no SUDO_USER + Arch -> auto-create (AUR needs it), confirm name.
# - Root + no SUDO_USER + Debian/Ubuntu -> ask y/N (optional, apt works as root).
# - --create-user flag -> force prompt regardless.
# - --username NAME -> override default name, no prompt.
# - --yes (no --username) -> default to 'mk' silently (no stall).
#
# Passwordless, no user password (NOPASSWD). Idempotent: re-runs re-sync
# authorized_keys + sudoers rather than failing.
step_name="create-user"
step_desc="create a sudo user (passwordless) + copy root's ssh keys + retarget config"
step_run="run_create_user"
run_create_user() {
log "create-user step"
# not root -> nothing to do, target stays as current user
if (( EUID != 0 )); then
echo " not root — target stays $USER ($HOME)"
export TARGET_USER="$USER" TARGET_HOME="$HOME"
return
fi
# root but came in via sudo -> use that invoking user, don't create a new one
if [[ -z "${SUDO_USER:-}" ]]; then _handle_direct_root; else _handle_sudoed_root; fi
}
# ── direct root (logged in as root; the case that actually needs a new user) ─
_handle_direct_root() {
local should_create=0
if (( CREATE_USER )) || [[ -n "${USERNAME_ARG:-}" ]]; then
[[ -n "${USERNAME_ARG:-}" ]] && log "--username given: forcing user creation"
should_create=1
elif [[ "$DISTRO" == "arch" ]]; then
# AUR/makepkg cannot run as root — we genuinely need a non-root sudoer.
echo " running as direct root on Arch: a non-root sudoer is needed for yay/AUR."
yn "Create a sudo user now? (recommended)" y && should_create=1
else
# Debian/Ubuntu: apt works as root, so this is convenience (key-first login).
yn "Create a daily-driver sudo user? (optional — apt works as root)" n && should_create=1
fi
if (( ! should_create )); then
echo " skipping user creation — config will target root ($HOME)"
warn "on Arch you won't be able to build AUR packages (yay/angie) as root"
export TARGET_USER="root" TARGET_HOME="$HOME"
return
fi
local user="${USERNAME_ARG:-}"
if [[ -z "$user" ]]; then
local default="mk"
read -rp " username [$default]: " user
[[ -z "$user" ]] && user="$default"
fi
_create_or_sync_user "$user"
}
# ── root via sudo: use the invoking user, don't create another ───────────────
_handle_sudoed_root() {
local user="$SUDO_USER"
local home; home="$(getent passwd "$user" | cut -d: -f6)"
log "running as root via sudo — using invoking user '$user' as target"
# ensure they have passwordless sudo + the ssh key + sudo group, then retarget
_create_or_sync_user "$user" skip_create
export TARGET_USER="$user" TARGET_HOME="$home"
}
# ── the meat: create if missing, sync sudoers, sync authorized_keys ──────────
_create_or_sync_user() { # username [skip_create]
local user="$1"
local skip_create="${2:-}"
if [[ "$skip_create" != "skip_create" ]]; then
if id "$user" >/dev/null 2>&1; then
ok "user '$user' already exists — syncing config only"
else
local shell_bin; shell_bin="$(command -v zsh 2>/dev/null || echo /bin/bash)"
local groups
case "$DISTRO" in
arch) groups="wheel" ;;
debian) groups="sudo,adm" ;;
esac
log "creating user '$user' (shell=$shell_bin, groups=$groups)"
useradd -m -s "$shell_bin" -G "$groups" "$user"
# passwordless account: clear the password field entirely
passwd -d "$user" >/dev/null 2>&1 || true
# unlock the account so SSH key login still works after passwd -d
usermod -p "" "$user" 2>/dev/null || true
ok "user '$user' created (no password set; ssh-key login only)"
fi
fi
local home; home="$(getent passwd "$user" | cut -d: -f6)"
_sync_authorized_keys "$user" "$home"
_install_sudoers "$user"
export TARGET_USER="$user" TARGET_HOME="$home"
ok "config target retargeted: user=$user home=$home"
}
# ── copy root's authorized_keys to the target user (idempotent) ──────────────
_sync_authorized_keys() { # user home
local user="$1" home="$2"
local src="/root/.ssh/authorized_keys"
if [[ ! -f "$src" ]]; then
warn "no /root/.ssh/authorized_keys — add your key manually:"
echo " install -d -m 700 -o $user -g $user $home/.ssh"
echo " # then: paste your pubkey into $home/.ssh/authorized_keys (chmod 600)"
return
fi
install -d -m 700 -o "$user" -g "$user" "$home/.ssh"
install -m 600 -o "$user" -g "$user" "$src" "$home/.ssh/authorized_keys"
local n; n=$(grep -cE '^[^#]' "$src" 2>/dev/null || echo 0)
ok "copied $n authorized key(s) to $home/.ssh/authorized_keys"
warn "sanity-check these are YOUR keys (fingerprints):"
ssh-keygen -lf "$src" 2>/dev/null | sed 's/^/ /' || true
}
# ── write /etc/sudoers.d/<user>: NOPASSWD, !tty_tickets, 7-day cache ─────────
_install_sudoers() { # user
local user="$1" file="/etc/sudoers.d/${user}"
log "configuring sudoers for '$user' (passwordless, !tty_tickets, 7d timeout)"
# write to a temp, validate with visudo -c, then move into place atomically
local tmp; tmp="$(mktemp)"
cat > "$tmp" <<EOF
# managed by bootstrap — passwordless sudo for $user
Defaults:$user !tty_tickets
Defaults:$user timestamp_timeout=10080
$user ALL=(ALL) NOPASSWD:ALL
EOF
# visudo must see proper perms (0440) to be happy
chmod 0440 "$tmp"
if visudo -cf "$tmp" >/dev/null 2>&1; then
install -m 0440 -o root -g root "$tmp" "$file"
ok "sudoers installed: $file"
else
err "visudo -c rejected the sudoers snippet — NOT installed, inspect aborted"
cat "$tmp" | sed 's/^/ /'
fi
rm -f "$tmp"
}
+62 -19
View File
@@ -20,32 +20,54 @@ run_packages() {
}
# ── arch ─────────────────────────────────────────────────────────────────────
# official-repo packages (verified in extra/core). Any name not in the official
# repos would abort the whole `pacman --needed` batch, so keep this list clean
# and route AUR-only tools through _install_aur_packages instead.
ARCH_OFFICIAL=(git ufw mosh btop fzf ripgrep fd bat dust procs jq yq tree
ncdu duf tldr httpie rsync lazygit neovim curl unzip less starship
eza zoxide openssh)
# AUR-only packages (not in official repos) — installed through yay after build.
ARCH_AUR=(byobu)
_pkg_arch() {
sudo_ pacman -Sy --noconfirm --needed base-devel openssh
# most tools live in extra/community (the merged repos); names map 1:1
local p=(git byobu ufw mosh btop fzf ripgrep fd bat dust procs jq yq tree
ncdu duf tldr httpie rsync lazygit neovim curl unzip less starship
eza zoxide openssh)
sudo_ pacman -Sy --noconfirm --needed "${p[@]}"
sudo_ pacman -Sy --noconfirm --needed "${ARCH_OFFICIAL[@]}"
_install_yay
_install_aur_packages
# fd/bat ship with their real names on arch — no shims needed
}
_install_yay() {
if command -v yay >/dev/null 2>&1; then ok "yay already installed"; return; fi
log "building yay from AUR (requires a non-root sudoer)"
if (( EUID == 0 )) && [[ -z "${SUDO_USER:-}" ]]; then
err "running as root with no SUDO_USER — yay cannot build. Re-run bootstrap as a normal user with sudo."
if (( EUID == 0 )) && [[ -z "${TARGET_USER:-${SUDO_USER:-}}" ]]; then
err "running as root with no target/sudo user — yay cannot build. Run the create-user step first, or re-run bootstrap as a normal user with sudo."
return 1
fi
local tmp; tmp="$(mktemp -d)"
# chown the build dir so the non-root build user can write into it
if (( EUID == 0 )); then
chown -R "${TARGET_USER:-${SUDO_USER}}" "$tmp"
fi
as_user git clone --depth 1 https://aur.archlinux.org/yay.git "$tmp/yay"
( cd "$tmp/yay" && as_user makepkg -si --noconfirm )
rm -rf "$tmp"
command -v yay >/dev/null 2>&1 && ok "yay installed" || warn "yay install may need a manual pass"
}
_install_aur_packages() {
[[ "${#ARCH_AUR[@]}" -eq 0 ]] && return
if ! command -v yay >/dev/null 2>&1; then
warn "yay not available — skipping AUR packages: ${ARCH_AUR[*]}"
return
fi
log "installing AUR-only packages: ${ARCH_AUR[*]}"
# yay -S as the non-root target user (makepkg refuses root; TARGET_USER via as_user).
as_user yay -S --noconfirm --needed "${ARCH_AUR[@]}" \
|| warn "some AUR packages failed: ${ARCH_AUR[*]} (install manually with: yay -S ${ARCH_AUR[*]})"
}
# ── debian/ubuntu ────────────────────────────────────────────────────────────
_pkg_debian() {
sudo_ apt-get update -y
@@ -56,24 +78,45 @@ _pkg_debian() {
# rename packages: fd -> fdfind, bat -> batcat (fix via aliases in zsh)
sudo_ apt-get install -y fdfind batcat 2>/dev/null || true
# eza via the gierens PPA (debs not in base apt)
# eza: the old apt.fury.io/gierens PPA is dead (401 Unauthorized). Install the
# single binary from the upstream GitHub release tarball into /usr/local/bin
# (system-wide — works for root and every sudo user; no HOME dependency).
# Remove a stale eza.list left by the old (pre-fix) run that used the now-dead
# apt.fury.io PPA — otherwise 'apt-get update' keeps hitting the 401 forever.
sudo_ rm -f /etc/apt/sources.list.d/eza.list /etc/apt/keyrings/gierens.gpg
if ! command -v eza >/dev/null 2>&1; then
sudo_ install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://raw.githubusercontent.com/eza-community/eza/main/deb.asc \
| sudo_ tee /etc/apt/keyrings/gierens.gpg >/dev/null
echo "deb [signed-by=/etc/apt/keyrings/gierens.gpg] http://apt.fury.io/eza/ /" \
| sudo_ tee /etc/apt/sources.list.d/eza.list >/dev/null
sudo_ apt-get update -y && sudo_ apt-get install -y eza || warn "eza PPA failed"
local eza_arch; case "$ARCH" in
amd64) eza_arch=x86_64-unknown-linux-gnu ;;
arm64) eza_arch=aarch64-unknown-linux-gnu ;;
*) eza_arch="${ARCH}-unknown-linux-gnu" ;;
esac
local url="https://github.com/eza-community/eza/releases/latest/download/eza_${eza_arch}.tar.gz"
local tmp; tmp="$(mktemp -d)"
if wget -qO "$tmp/eza.tgz" "$url" && tar xzf "$tmp/eza.tgz" -C "$tmp"; then
sudo_ install -m 0755 "$tmp/eza" /usr/local/bin/eza
else
warn "eza download failed ($url)"
fi
rm -rf "$tmp"
fi
# starship: official installer
# starship: official installer. NOTE — scrub $ARCH/$PLATFORM from the env first:
# the installer reuses a pre-set ARCH (our lib/detect.sh exports ARCH=amd64) and
# skips its own amd64->x86_64 mapping, yielding an unsupported 'amd64-unknown-linux-
# musl' target ('builds for amd64-unknown-linux-musl are not yet available').
# Default bin dir is /usr/local/bin (system-wide).
if ! command -v starship >/dev/null 2>&1; then
curl -sS https://starship.rs/install.sh | sh -s -- -y || warn "starship install failed"
curl -sS https://starship.rs/install.sh \
| env -u ARCH -u PLATFORM sh -s -- -y || warn "starship install failed"
fi
# zoxide: official installer
# zoxide: official installer. Install system-wide (/usr/local/bin) instead of the
# default ~/.local/bin — when run as root the default lands in /root (wrong user
# for the create-user path) and isn't on $PATH; /usr/local/bin is on PATH for all.
if ! command -v zoxide >/dev/null 2>&1; then
curl -sS https://raw.githubusercontent.com/ajeetdsouza/zoxide/main/install.sh | bash || warn "zoxide install failed"
curl -sS https://raw.githubusercontent.com/ajeetdsouza/zoxide/main/install.sh \
| env -u ARCH sh -s -- --bin-dir /usr/local/bin --man-dir /usr/local/share/man \
|| warn "zoxide install failed"
fi
# yq: direct binary from mikefarah/yq releases (stable URL)
+5 -5
View File
@@ -24,13 +24,13 @@ run_docker() {
warn "systemctl unavailable — start docker manually if needed"
fi
# add current user to docker group
# add the config target user to the docker group (created-user or self)
if getent group docker >/dev/null 2>&1; then
if id -nG "$USER" | grep -qw docker; then
ok "$USER already in docker group"
if id -nG "${TARGET_USER:-$USER}" | grep -qw docker; then
ok "${TARGET_USER:-$USER} already in docker group"
else
sudo_ usermod -aG docker "$USER"
ok "$USER added to docker group (log out/in or 'newgrp docker' to use it)"
sudo_ usermod -aG docker "${TARGET_USER:-$USER}"
ok "${TARGET_USER:-$USER} added to docker group (log out/in or 'newgrp docker' to use it)"
fi
fi
}
+23 -6
View File
@@ -27,12 +27,25 @@ run_angie() {
}
_angie_arch() {
if command -v yay >/dev/null 2>&1; then
as_user yay -S --noconfirm angie 2>/dev/null \
|| as_user yay -S --noconfirm aur/angie 2>/dev/null \
|| warn "angie AUR build failed — try: yay -S angie"
if ! command -v yay >/dev/null 2>&1; then
warn "yay not available — install angie manually from AUR: yay -S angie-bin"
return
fi
# Prefer angie-bin: it repackages the upstream official Ubuntu .deb, so it's
# instant (no source compile) and carries the same compiled-in modules our
# config relies on — http_acme, http_ssl, http_v2/v3, http_realip, stream*
# (verified via the .deb's 'angie -V'). Drop back to the source 'angie'
# package only if the binary package is unavailable.
log "AUR install: angie-bin first, fall back to angie (source build)"
if as_user yay -S --noconfirm --needed angie-bin 2>/dev/null; then
ok "angie-bin installed from AUR"
return
fi
log "angie-bin unavailable/failed — building angie from source (may take a few minutes)"
if as_user yay -S --noconfirm --needed aur/angie 2>/dev/null; then
ok "angie (source) installed from AUR"
else
warn "yay not available — install angie manually from AUR: yay -S angie"
warn "angie install failed — try manually: yay -S angie-bin (or: yay -S angie)"
fi
}
@@ -41,7 +54,11 @@ _angie_debian() {
sudo_ install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://angie.software/keys/angie-signing.gpg \
| sudo_ tee /etc/apt/keyrings/angie-signing.gpg >/dev/null
echo "deb [signed-by=/etc/apt/keyrings/angie-signing.gpg] https://download.angie.software/angie/${ID}/ ${VERSION_CODENAME} main" \
# Repo path is angie/<ID>/<VERSION_ID>/ with the codename as the apt suite, e.g.
# debian/13/dists/trixie/ or ubuntu/22.04/dists/jammy/. Omitting VERSION_ID (just
# angie/debian/) fetches debian/dists/trixie -> 404 'no Release file' — must pin the
# numeric version in the URL path.
echo "deb [signed-by=/etc/apt/keyrings/angie-signing.gpg] https://download.angie.software/angie/${ID}/${VERSION_ID}/ ${VERSION_CODENAME} main" \
| sudo_ tee /etc/apt/sources.list.d/angie.list >/dev/null
sudo_ apt-get update -y
sudo_ apt-get install -y angie
+35 -15
View File
@@ -5,18 +5,35 @@ step_name="shell-zsh"
step_desc="zsh + oh-my-zsh + plugins, set default shell, ship config"
step_run="run_zsh"
ZSH="${ZSH:-$HOME/.oh-my-zsh}"
ZSH_CUSTOM="${ZSH_CUSTOM:-$ZSH/custom}"
ZDOTDIR_TARGET="${ZDOTDIR_TARGET:-$HOME/.config/zsh}"
# All paths resolve against the config TARGET (set by 05-create-user).
# NOTE: we deliberately do NOT honor an inherited $ZSH env var — oh-my-zsh exports
# ZSH when it loads, so a setup.sh run from an omz-equipped shell would otherwise
# pin these to the *old* home (e.g. /root) while the chown step retargets to the
# *new* user, mis-owning the wrong tree (the 'insecure completion dirs' bug).
ZSH="$TARGET_HOME/.oh-my-zsh"
ZSH_CUSTOM="$ZSH/custom"
ZDOTDIR_TARGET="$TARGET_HOME/.config/zsh"
run_zsh() {
log "installing zsh + oh-my-zsh"
_install_zsh
_install_omz
_install_plugins
_chown_target_tree
_ship_config
_set_default_shell
ok "zsh ready — start it with: exec zsh"
if [[ "$TARGET_USER" != "$USER" ]]; then
log "zsh ready. Start it as $TARGET_USER: sudo -iu $TARGET_USER"
else
ok "zsh ready — start it with: exec zsh"
fi
}
_chown_target_tree() {
# when running as root for a created user, make omz + plugins + config owned by them
if [[ "$TARGET_USER" == "$USER" ]]; then return; fi
local grp; grp="$(id -gn "$TARGET_USER" 2>/dev/null || echo "$TARGET_USER")"
chown -R "$TARGET_USER:$grp" "$ZSH" "$ZDOTDIR_TARGET" "$TARGET_HOME/.zshrc" 2>/dev/null || true
}
_install_zsh() {
@@ -40,8 +57,8 @@ _install_plugins() {
_clone_plugin zsh-users/zsh-autosuggestions "$ZSH_CUSTOM/plugins/zsh-autosuggestions"
_clone_plugin zsh-users/zsh-syntax-highlighting "$ZSH_CUSTOM/plugins/zsh-syntax-highlighting"
_clone_plugin zsh-users/zsh-completions "$ZSH_CUSTOM/plugins/zsh-completions"
_clone_plugin Aloofenlore/history-substring-search "$ZSH_CUSTOM/plugins/history-substring-search"
_clone_plugin Aloof98/fzf-tab "$ZSH_CUSTOM/plugins/fzf-tab"
_clone_plugin zsh-users/zsh-history-substring-search "$ZSH_CUSTOM/plugins/history-substring-search"
_clone_plugin Aloxaf/fzf-tab "$ZSH_CUSTOM/plugins/fzf-tab"
}
_clone_plugin() { # repo dest
@@ -55,7 +72,6 @@ _ship_config() {
log "shipping zsh config to $ZDOTDIR_TARGET"
mkdir -p "$ZDOTDIR_TARGET"
local f
local f
for f in zshrc aliases.zsh functions.zsh path.zsh plugins.zsh; do
local src="$BOOT_DIR/config/zsh/$f"
[[ -f "$src" ]] || continue
@@ -67,8 +83,8 @@ _ship_config() {
if [[ ! -f "$ZDOTDIR_TARGET/aliases-extras.zsh" ]]; then
cp "$BOOT_DIR/config/zsh/aliases-extras.zsh" "$ZDOTDIR_TARGET/aliases-extras.zsh"
fi
# ~/.zshrc shim that loads our real config via ZDOTDIR (so omz $ZSH + plugins resolve)
local shim="$HOME/.zshrc"
# ~/.zshrc shim that loads our real config via ZDOTDIR
local shim="$TARGET_HOME/.zshrc"
if ! grep -q 'BOOTSTRAP ZDOTDIR' "$shim" 2>/dev/null; then
cat >> "$shim" <<'EOF'
@@ -77,7 +93,11 @@ export ZDOTDIR="${ZDOTDIR:-$HOME/.config/zsh}"
[[ -f "$ZDOTDIR/zshrc" ]] && source "$ZDOTDIR/zshrc"
EOF
fi
ok "zsh config installed ($ZDOTDIR_TARGET) + ~/.zshrc shim"
# ensure the whole shipped tree is owned by the target user
if [[ "$TARGET_USER" != "$USER" ]]; then
chown -R "$TARGET_USER:$(id -gn "$TARGET_USER" 2>/dev/null || echo "$TARGET_USER")" "$ZDOTDIR_TARGET" "$shim"
fi
ok "zsh config installed ($ZDOTDIR_TARGET) + $shim shim"
}
_set_default_shell() {
@@ -87,13 +107,13 @@ _set_default_shell() {
if ! grep -qx "$zsh_bin" /etc/shells 2>/dev/null; then
echo "$zsh_bin" | sudo_ tee -a /etc/shells >/dev/null
fi
if [[ "$SHELL" != "$zsh_bin" ]] && [[ "$(getent passwd "$USER" | cut -d: -f7)" != "$zsh_bin" ]]; then
if yn "Set zsh as default login shell for $USER?" y; then
sudo_ chsh -s "$zsh_bin" "$USER" && ok "default shell set to zsh"
if [[ "$(getent passwd "$TARGET_USER" | cut -d: -f7)" != "$zsh_bin" ]]; then
if yn "Set zsh as default login shell for $TARGET_USER?" y; then
sudo_ chsh -s "$zsh_bin" "$TARGET_USER" && ok "default shell set to zsh for $TARGET_USER"
else
warn "zsh not set as default — run 'chsh -s $zsh_bin' later"
warn "zsh not set as default — run 'chsh -s $zsh_bin' for $TARGET_USER' later"
fi
else
ok "zsh already your default shell"
ok "zsh already the default shell for $TARGET_USER"
fi
}
+10 -15
View File
@@ -2,30 +2,25 @@
# 35-nvm — Node Version Manager + Node LTS.
step_name="nvm"
step_desc="nvm into ~/.nvm, install Node LTS"
step_desc="nvm into ~$TARGET_USER/.nvm, install Node LTS"
step_run="run_nvm"
run_nvm() {
log "installing nvm"
export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"
log "installing nvm for $TARGET_USER"
local nvm_home="$TARGET_HOME/.nvm"
export NVM_DIR="$nvm_home"
if [[ -s "$NVM_DIR/nvm.sh" ]]; then
ok "nvm already present at $NVM_DIR"
else
# fetch the install script at a pinned NVM version (update here to upgrade)
curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash \
as_user env NVM_DIR="$nvm_home" bash -c 'curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash' \
|| { warn "nvm install failed"; return; }
ok "nvm installed to $NVM_DIR"
fi
# shellcheck disable=SC1091
source "$NVM_DIR/nvm.sh"
if command -v nvm >/dev/null 2>&1; then
log "installing Node LTS via nvm"
nvm install --lts || warn "nvm install --lts failed"
nvm use --lts 2>/dev/null || true
nvm alias default 'lts/*' 2>/dev/null || true
command -v node >/dev/null 2>&1 && ok "node $(node -v) active"
else
warn "nvm.sh sourced but nvm not available — check $NVM_DIR"
fi
# run node/lts install as the target user so binary ownership + default are theirs
as_user bash -lc 'export NVM_DIR='"$NVM_DIR"'; source "$NVM_DIR/nvm.sh"; nvm install --lts && nvm use --lts && nvm alias default lts/*' \
|| warn "node lts install had issues (continue; run 'nvm install --lts' as $TARGET_USER)"
as_user bash -lc 'export NVM_DIR='"$NVM_DIR"'; source "$NVM_DIR/nvm.sh"; command -v node >/dev/null && echo " node \$(node -v) active for $TARGET_USER"' 2>/dev/null || true
}
+9 -11
View File
@@ -1,8 +1,8 @@
#!/usr/bin/env bash
# 40-nvim — clone your nvim-minimal config from gitea to ~/.config/nvim.
# 40-nvim — clone your nvim-minimal config from gitea to ~$TARGET_USER/.config/nvim.
step_name="nvim"
step_desc="clone g.mk.fo/bootstrap/nvim-minimal -> ~/.config/nvim"
step_desc="clone g.mk.fo/bootstrap/nvim-minimal -> ~$TARGET_USER/.config/nvim"
step_run="run_nvim"
NVIM_REPO_URL="${NVIM_REPO_URL:-https://g.mk.fo/bootstrap/nvim-minimal.git}"
@@ -11,24 +11,22 @@ run_nvim() {
log "installing nvim config"
command -v nvim >/dev/null 2>&1 || warn "nvim binary not found — install the packages step first"
local target="${XDG_CONFIG_HOME:-$HOME/.config}/nvim"
local target="$TARGET_HOME/.config/nvim"
if [[ -d "$target/.git" ]]; then
log "existing nvim config is a git repo — pulling"
git -C "$target" pull --ff-only || warn "could not pull existing nvim config"
as_user git -C "$target" pull --ff-only || warn "could not pull existing nvim config"
ok "nvim config updated at $target"
return
fi
if [[ -e "$target" ]]; then
local bak="${target}.bak.$(date +%Y%m%d-%H%M%S)"
mv "$target" "$bak"
as_user mv "$target" "$bak" 2>/dev/null || sudo_ mv "$target" "$bak"
warn "backed up existing $target -> $bak"
fi
if git clone "$NVIM_REPO_URL" "$target"; then
ok "nvim config cloned to $target"
else
err "failed to clone $NVIM_REPO_URL"
return 1
fi
as_user git clone "$NVIM_REPO_URL" "$target" && ok "nvim config cloned to $target" \
|| { err "failed to clone $NVIM_REPO_URL"; sudo_ git clone "$NVIM_REPO_URL" "$target" \
&& chown -R "$TARGET_USER:$(id -gn "$TARGET_USER" 2>/dev/null || echo "$TARGET_USER")" "$target" \
&& ok "nvim config cloned to $target (as root, then chowned)"; }
}
+337 -3
View File
@@ -2,7 +2,7 @@
# 50-hardening — system defaults. Each sub-step y/N, all idempotent.
step_name="hardening"
step_desc="ssh(ufw-safe), ufw, updates, unattended, tz, hostname, swap, locale, fail2ban"
step_desc="ssh, ufw, updates, unattended, tz, hostname, swap, locale, fail2ban, angie config + built-in ACME"
step_run="run_hardening"
run_hardening() {
@@ -14,6 +14,8 @@ run_hardening() {
_h_swap
_h_locale
_h_fail2ban
_h_angie_config
_h_angie_acme
ok "hardening pass complete"
}
@@ -103,8 +105,12 @@ _h_tz_hostname() {
if command -v timedatectl >/dev/null 2>&1; then
if yn "Set timezone (default UTC)?" y; then
local tz="${REPLY_TZ:-}"
read -rp " timezone [UTC]: " tz
[[ -z "$tz" ]] && tz="UTC"
if (( ${ALL:-0} )); then
tz="UTC"; ok "--yes: timezone defaulting to UTC"
else
read -rp " timezone [UTC]: " tz
[[ -z "$tz" ]] && tz="UTC"
fi
sudo_ timedatectl set-timezone "$tz" && ok "timezone set to $tz"
fi
else
@@ -186,3 +192,331 @@ EOF
fi
ok "fail2ban installed + sshd jail enabled"
}
# ── angie config: _on/ targets/ modules/ + sane root config ────────────────
_h_angie_config() {
if ! command -v angie >/dev/null 2>&1; then
echo " skip angie config (angie not installed — run the angie step first)"; return
fi
if ! yn "Set up Angie config layout (/etc/angie/_on targets modules) + sane root config?" y; then
echo " skip angie config"; return
fi
local d=/etc/angie
sudo_ install -d -m 0755 "$d/_on" "$d/targets" "$d/modules" "$d/modules/http"
# ── extract packaging-specific values from the stock angie.conf ──
# Different Angie packages set user/pid/error_log differently (Arch source
# package → /etc/nginx/nginx.conf with 'user http'; angie-bin / Debian .deb
# → /etc/angie/angie.conf with 'user angie'). Preserve them when present,
# fall back to sane defaults otherwise (defaults cover all distros here).
local oldcfg="$d/angie.conf"
local altcfg="/etc/nginx/nginx.conf" # the source AUR package's path
local stock="$oldcfg"
[[ -f "$oldcfg" ]] || stock="$altcfg"
local angie_user="" angie_pid="" angie_err="" angie_modules=""
if [[ -f "$stock" ]]; then
angie_user=$(grep -E '^\s*user\s+' "$stock" | head -1 | awk '{print $2}' | tr -d ';')
angie_pid=$(grep -E '^\s*pid\s+' "$stock" | head -1 | awk '{print $2}' | tr -d ';')
angie_err=$(grep -E '^\s*error_log\s+' "$stock" | head -1 | awk '{print $2}' | tr -d ';')
# collect any load_module lines (dynamic modules)
angie_modules=$(grep -E '^\s*load_module\s+' "$stock" || true)
fi
# sane defaults if not found
[[ -z "$angie_user" ]] && angie_user=$({ [[ "${DISTRO:-}" == "arch" ]] && echo http || echo angie; })
[[ -z "$angie_pid" ]] && angie_pid=/run/angie.pid
[[ -z "$angie_err" ]] && angie_err=/var/log/angie/error.log
# locate mime.types: angie-bin/Debian ship /etc/angie/mime.types; the Arch source
# 'angie' package instead pulls it from the 'mailcap' dep at /etc/nginx/mime.types
# (and deletes its own). Pick whichever exists so 'angie -t' doesn't fail on include.
local angie_mime=""
for m in "$d/mime.types" /etc/nginx/mime.types /etc/mime.types; do
[[ -f "$m" ]] && { angie_mime="$m"; break; }
done
[[ -z "$angie_mime" ]] && { warn "no mime.types found; installing a minimal one"; _angie_install_mime "$d/mime.types"; angie_mime="$d/mime.types"; }
# the modules/ include dir must exist or the glob fails the config test
sudo_ install -d -m 0755 "$d/modules" "$d/modules/http"
# backup the original once
if [[ -f "$oldcfg" && ! -f "$oldcfg.orig.bootstrap" ]]; then
sudo_ cp -a "$oldcfg" "$oldcfg.orig.bootstrap"
fi
# ── write our root angie.conf (once — guarded, won't clobber later edits) ──
if grep -q '# managed by bootstrap' "$oldcfg" 2>/dev/null; then
ok "$oldcfg already bootstrap-managed — leaving it (keeps your edits)"
else
sudo_ tee "$oldcfg" >/dev/null <<EOF
# angie.conf — managed by bootstrap. Original backup: angie.conf.orig.bootstrap
# Edit freely; re-running the hardening step will NOT overwrite (it writes once).
user $angie_user;
worker_processes auto;
pid $angie_pid;
error_log $angie_err warn;
# dynamic modules carried over from the original package config (if any)
$angie_modules
# top-level context snippets (stream{}, env, etc.)
include /etc/angie/modules/*.conf;
events {
worker_connections 1024;
}
http {
include $angie_mime;
default_type application/octet-stream;
log_format main '\$remote_addr - \$remote_user [\$time_local] "\$request" '
'\$status \$body_bytes_sent "\$http_referer" "\$http_user_agent"';
access_log /var/log/angie/access.log main;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
server_tokens off;
client_max_body_size 16m;
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 5;
gzip_min_length 256;
gzip_types text/plain text/css application/json application/javascript
text/xml application/xml application/xml+rss text/javascript;
# http-level snippets: upstreams, maps, real_ip, proxy defaults
include /etc/angie/modules/http/*.conf;
# enabled hosts (symlinks from targets/*). _on/*.conf only — targets/ is staging.
include /etc/angie/_on/*.conf;
}
EOF
fi
# ── http-level common snippet (real_ip + proxy defaults) ──
if [[ ! -f "$d/modules/http/00-common.conf" ]]; then
sudo_ tee "$d/modules/http/00-common.conf" >/dev/null <<'EOF'
# real_ip — trust private ranges + (commented Cloudflare). Uncomment/edit for your upstream.
set_real_ip_from 10.0.0.0/8;
set_real_ip_from 172.16.0.0/12;
set_real_ip_from 192.168.0.0/16;
set_real_ip_from 169.254.0.0/16;
# set_real_ip_from 173.245.48.0/20; # Cloudflare, see https://www.cloudflare.com/ips/
real_ip_header X-Forwarded-For;
real_ip_recursive on;
# proxy defaults (inherited by every server/location unless overridden)
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
EOF
fi
# ── default catch-all server: unmatched Host -> drop ──
if [[ ! -f "$d/_on/00-default.conf" ]]; then
# The default_server also listens on 443 to catch unknown-SNI TLS clients.
# It needs SOME cert to complete the handshake before `return 444` drops them;
# angie won't start a 443 listener without ssl_certificate. Generate a self-signed
# dummy once (CN='bootstrap-default', 10y) so unknown-SNI clients get dropped
# after connecting while real vhosts are picked by SNI.
if [[ ! -f "$d/ssl/bootstrap-default.crt" ]]; then
sudo_ install -d -m 0755 "$d/ssl"
sudo_ openssl req -x509 -newkey rsa:2048 -nodes -days 3650 -keyout "$d/ssl/bootstrap-default.key" -out "$d/ssl/bootstrap-default.crt" -subj "/CN=bootstrap-default/O=bootstrap" 2>/dev/null
fi
sudo_ tee "$d/_on/00-default.conf" >/dev/null <<'EOF'
# default_server: drop requests that match no enabled host (drive-by scanners, bare-IP)
server {
listen 80 default_server;
listen [::]:80 default_server;
listen 443 ssl default_server;
listen [::]:443 ssl default_server;
server_name _;
ssl_certificate /etc/angie/ssl/bootstrap-default.crt;
ssl_certificate_key /etc/angie/ssl/bootstrap-default.key;
return 444;
}
EOF
fi
# ── angie-enable / angie-disable helpers ──
_angie_helpers
# ── source-angie bridge: point /etc/nginx/nginx.conf at our /etc/angie layout ─
# The source AUR 'angie' package compiles --conf-path=/etc/nginx/nginx.conf and
# its service runs '/usr/bin/nginx' with no -c flag, so it ignores /etc/angie.
# When that path is where angie actually reads, back it up once and symlink it to
# our managed angie.conf so the _on/targetsmodules layout takes effect. The
# angie-bin / Debian packages already read /etc/angie/angie.conf directly.
if [[ -f /etc/nginx/nginx.conf && -f "$d/angie.conf" ]]; then
local nginxconf=/etc/nginx/nginx.conf
if [[ "$(readlink -f "$nginxconf" 2>/dev/null)" == "$d/angie.conf" ]]; then
ok "$nginxconf already symlinks to $d/angie.conf"
else
if [[ ! -f "${nginxconf}.orig.bootstrap" && ! -L "$nginxconf" ]]; then
sudo_ cp -a "$nginxconf" "${nginxconf}.orig.bootstrap"
fi
sudo_ ln -sfn "$d/angie.conf" "$nginxconf"
ok "symlinked $nginxconf -> $d/angie.conf (source-angie bridge); backup at ${nginxconf}.orig.bootstrap"
fi
fi
if sudo_ angie -t 2>&1; then
ok "angie config valid"
if command -v systemctl >/dev/null 2>&1 && systemctl is-active angie >/dev/null 2>&1; then
sudo_ systemctl reload angie 2>/dev/null && ok "angie reloaded"
else
echo " start angie when ready: sudo systemctl enable --now angie"
fi
else
err "angie -t failed — review $d/angie.conf (backup at $oldcfg.orig.bootstrap)"
fi
ok "angie layout ready: $d/{angie.conf,_on/,targets/,modules/,modules/http/}"
echo " enable a site: angie-enable <name> (after writing $d/targets/<name>.conf)"
echo " disable a site: angie-disable <name>"
# ufw already opened 80/443 in _h_ufw
}
_angie_install_mime() { # dest — write a minimal mime.types if none exists on the host
sudo_ tee "$1" >/dev/null <<'EOF'
types {
text/html html htm shtml;
text/css css;
text/xml xml;
image/gif gif;
image/jpeg jpeg jpg;
image/png png;
image/svg+xml svg svgz;
application/javascript js;
application/json json;
application/pdf pdf;
application/zip zip;
application/octet-stream bin exe dll so deb dmg iso img;
}
EOF
}
_angie_helpers() {
sudo_ tee /usr/local/bin/angie-enable >/dev/null <<'EOF'
#!/usr/bin/env bash
# angie-enable <name> — symlink targets/<name>.conf into _on/ and validate.
set -euo pipefail
name="${1:-}"
[ -n "$name" ] || { echo "usage: angie-enable <name>" >&2; exit 2; }
target="/etc/angie/targets/${name}.conf"
link="/etc/angie/_on/${name}.conf"
[ -f "$target" ] || { echo "no such target: $target" >&2; exit 1; }
ln -sfn "../targets/${name}.conf" "$link"
echo "enabled: $name -> $link"
if command -v angie >/dev/null 2>&1; then
sudo angie -t || { echo "config invalid — review before reload" >&2; exit 1; }
sudo systemctl reload angie && echo "reloaded"
fi
EOF
sudo_ tee /usr/local/bin/angie-disable >/dev/null <<'EOF'
#!/usr/bin/env bash
# angie-disable <name> — remove the _on/<name>.conf symlink and reload.
set -euo pipefail
name="${1:-}"
[ -n "$name" ] || { echo "usage: angie-disable <name>" >&2; exit 2; }
link="/etc/angie/_on/${name}.conf"
[ -L "$link" ] || [ -e "$link" ] || { echo "$name not enabled ($link missing)" >&2; exit 1; }
rm -f "$link"
echo "disabled: $name"
if command -v angie >/dev/null 2>&1; then
sudo angie -t || true
sudo systemctl reload angie && echo "reloaded"
fi
EOF
sudo_ chmod +x /usr/local/bin/angie-enable /usr/local/bin/angie-disable
}
# ── angie built-in ACME (Let's Encrypt) ────────────────────────────────────
# No certbot: Angie ships http_acme and fetches/renews certs itself.
# Bootstrap can't know your domain/email, so we ship an example TLS server
# target + an `angie-issue` helper that creates the live acme_client on demand.
_h_angie_acme() {
if ! command -v angie >/dev/null 2>&1; then
echo " skip angie ACME (angie not installed)"; return
fi
if ! yn "Ship Angie built-in ACME template + angie-issue helper?" y; then
echo " skip angie ACME"; return
fi
local d=/etc/angie
# example TLS server target — NOT auto-enabled (.example, so angie-enable won't glob it)
if [[ ! -f "$d/targets/example-https.conf.example" ]]; then
sudo_ tee "$d/targets/example-https.conf.example" >/dev/null <<'EOF'
# Template for a TLS host using Angie built-in ACME.
# Use it via the helper: angie-issue <domain> [you@email]
# Or by hand: copy to targets/<domain>.conf, fill <DOMAIN>, then: angie-enable <domain>
server {
listen 80;
listen [::]:80;
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name <DOMAIN>;
acme default; # add this server_name to the shared 'default' ACME certificate
ssl_certificate $acme_cert_default;
ssl_certificate_key $acme_cert_key_default;
# http -> https redirect
if ($scheme = http) { return 301 https://$host$request_uri; }
location / {
# replace with your app / proxy_pass upstream;
return 200 "angie + builtin ACME ok\n";
add_header Content-Type text/plain;
}
}
EOF
fi
sudo_ tee /usr/local/bin/angie-issue >/dev/null <<'EOF'
#!/usr/bin/env bash
# angie-issue <domain> [email] — create a TLS host backed by Angie built-in ACME.
# First call (needs email) writes /etc/angie/modules/http/acme.conf with the
# shared 'default' acme_client; every call writes targets/<domain>.conf and enables it.
# All enabled domains using `acme default` share one cert covering all their server_names.
set -euo pipefail
domain="${1:-}"; email="${2:-}"
[ -n "$domain" ] || { echo "usage: angie-issue <domain> [email]" >&2; exit 2; }
acme_conf=/etc/angie/modules/http/acme.conf
if [ ! -f "$acme_conf" ]; then
[ -n "$email" ] || { echo "first-time: also pass your email for Let's Encrypt" >&2; exit 2; }
cat > "$acme_conf" <<ACME
# managed by angie-issue — Angie built-in ACME (Let's Encrypt)
# resolver is required by the acme_client directive (http context).
resolver 1.1.1.1 8.8.8.8 valid=300s ipv6=off;
acme_client default https://acme-v02.api.letsencrypt.org/directory email=${email};
ACME
echo "wrote $acme_conf (email=${email})"
else
echo "_existing $acme_conf (shared default client)"
fi
target=/etc/angie/targets/${domain}.conf
sed -e "s|<DOMAIN>|${domain}|g" /etc/angie/targets/example-https.conf.example > "$target"
echo "wrote $target"
ln -sfn "../targets/${domain}.conf" /etc/angie/_on/${domain}.conf
echo "enabled: $domain"
sudo angie -t
sudo systemctl reload angie && echo "angie reloaded — certificate is obtained automatically."
EOF
sudo_ chmod +x /usr/local/bin/angie-issue
ok "angie ACME ready: example host at $d/targets/example-https.conf.example"
echo " issue a cert: angie-issue <domain> you@email"
echo " then reload picks it up; Angie renews automatically before expiry."
}
+2 -2
View File
@@ -15,7 +15,7 @@ run_sanity() {
echo " memory : $(free -h | awk '/^Mem/ {print $2}') total"
echo " swap : $(swapon --show 2>/dev/null | awk 'NR>1{print $3; found=1} END{if(!found) print "none"}')"
echo " timezone : $(timedatectl show -p Timezone --value 2>/dev/null || cat /etc/timezone 2>/dev/null || echo '?')"
echo " hostname : $(hostname)"
echo " hostname : $(uname -n 2>/dev/null || hostnamectl --static 2>/dev/null || cat /etc/hostname 2>/dev/null || echo '?')"
echo " locale : $(locale 2>/dev/null | awk -F= '/^LANG/ {print $2}')"
echo " ufw : $(sudo_ ufw status 2>/dev/null | head -1 || echo 'not installed')"
echo " fail2ban : $(sudo_ fail2ban-client status 2>/dev/null | head -2 || echo 'not running')"
@@ -32,7 +32,7 @@ run_sanity() {
- Add starship config if you want a custom prompt: starship preset list
(defaults ship with the bootstrap).
- Consider 'newgrp docker' or re-login to activate the docker group.
- Install domain + TLS for angie separately (certbot/acme.sh), not handled here.
- Issue TLS certs for angie hosts with: angie-issue <domain> you@email.com
EOF
ok "bootstrap sanity report done."
}
+23 -9
View File
@@ -4,13 +4,20 @@
# Detects distro/arch, lists modular steps, asks y/N per step, runs them.
#
# Flags:
# --yes run all steps without prompting
# --only NAME run only the step matching NAME
# --skip NAME run all steps except NAME (repeatable in future)
# -h|--help usage
# --yes run all steps without prompting
# --only NAME run only the step matching NAME
# --skip NAME run all steps except NAME
# --create-user force creating a sudo user (otherwise Arch auto-creates, Debian asks)
# --username NAME override the sudo user name (default: mk)
# -h|--help usage
set -euo pipefail
# Default config target = whoever is running this. The create-user step
# (05-create-user) may override these to point at a freshly-created sudoer.
export TARGET_USER="$USER"
export TARGET_HOME="$HOME"
BOOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck disable=SC1091
@@ -18,18 +25,25 @@ source "$BOOT_DIR/lib/detect.sh"
source "$BOOT_DIR/lib/log.sh"
source "$BOOT_DIR/lib/prompt.sh"
ALL=0; ONLY=""; SKIP=""
# helper: resolve the target user's real home even when we're root operating
# on a created user (whose home isn't $HOME).
target_home() { getent passwd "${TARGET_USER:-$USER}" | cut -d: -f6; }
ALL=0; ONLY=""; SKIP=""; CREATE_USER=0; USERNAME_ARG=""
while (( $# )); do
case "$1" in
--yes|-y) ALL=1; shift ;;
--only) ONLY="${2:-}"; shift 2 ;;
--skip) SKIP="${2:-}"; shift 2 ;;
--yes|-y) ALL=1; shift ;;
--only) ONLY="${2:-}"; shift 2 ;;
--skip) SKIP="${2:-}"; shift 2 ;;
--create-user) CREATE_USER=1; shift ;;
--username) USERNAME_ARG="${2:-}"; shift 2 ;;
-h|--help)
echo "usage: setup.sh [--yes] [--only NAME] [--skip NAME]"
grep -E '^# --' "$0" | sed 's/^# //'
exit 0 ;;
*) echo "unknown arg: $1" >&2; shift ;;
esac
done
export CREATE_USER USERNAME_ARG
# ---------------------------------------------------------------- collect steps
NAMES=(); DESCS=(); RUNS=()
+1
View File
@@ -0,0 +1 @@
- [ ] pm2