docs(setup): add Debian 13 dev-VM setup procedure + scripts + devcontainer (#220)
CI / check (push) Successful in 3m7s
CI / commits (push) Has been skipped
CI / scan (push) Successful in 3m3s
CI / a11y (push) Successful in 3m41s
CI / perf (push) Successful in 5m24s
Docs site / build (push) Successful in 5m8s

## Summary

Adds a full Debian 13 dev-VM setup procedure ([docs/setup/01-dev-debian-vm-setup.md](docs/setup/01-dev-debian-vm-setup.md)) + 10 modular idempotent setup scripts + a systemd template + a `.devcontainer/` spec, in preparation for the new dev VM (`10.100.201.21`) replacing the WSL-based workflow. Both IDE flows (VSCode Remote-SSH + Devcontainer) and both Node toolchains (nvm on host + devcontainer image) are available — devs pick per task.

Adjacent context (does not ship here, planned follow-up):

- **Preview infra on the GitLab VM (`10.100.201.10`)** — same `dev.compose.yml`, deployed by CI on `main`. Doc placeholder in §8.6.
- **GitLab Runner migration** (act_runner Gitea → GitLab Runner Docker executor) — bundled with the Gitea → GitLab cutover.
- **Private dotfiles repo** (`apf/dotfiles`) — `~/.zshrc`, `~/.p10k.zsh`, `~/.tmux.conf` versioned. `80-dotfiles.sh` is already structured to fall back to a `~/.dotfiles/` clone when present.

No application-code changes. No CI gate impact (doc + scripts + devcontainer spec only).

## What lands

| Path | Change |
| --- | --- |
| `docs/setup/01-dev-debian-vm-setup.md` | **New.** Step-by-step doc: workstation prep (SSH agent + VSCode Remote-SSH + fonts), bootstrap orchestrator, per-script effects, project clone, infra boot, IDE flow A / B / C, apf-ai-service .NET appendix, troubleshooting. |
| `docs/setup/02-wsl-terminal-setup.md` | Renamed from `01-wsl-terminal-setup.md`. No content change. |
| `docs/setup/03-dev-web-stack.md` | Renamed from `02-dev-web-stack.md`. No content change. |
| `docs/setup/04-angular-nx-monorepo.md` | Renamed from `03-angular-nx-monorepo.md`. No content change. |
| `docs/setup/README.md` | **New.** Index of the `docs/setup/` folder. |
| `docs/setup/scripts/lib.sh` | **New.** Shared helpers — colour-coded log/ok/warn/err/skip, `apt_install` skipping already-installed, `ensure_line` idempotent append, `confirm` prompt. |
| `docs/setup/scripts/bootstrap.sh` | **New.** Orchestrator running scripts 10..80 in order with confirmation prompts. |
| `docs/setup/scripts/10-base-packages.sh` | **New.** apt update + base packages (curl, wget, git, build-essential, …). |
| `docs/setup/scripts/20-zsh.sh` | **New.** zsh + Oh My Zsh (RUNZSH=no, no shell hijack) + Powerlevel10k + `zsh-autosuggestions` + `zsh-syntax-highlighting`. Patches `~/.zshrc` (theme, plugins, fzf hook). |
| `docs/setup/scripts/30-cli-tools.sh` | **New.** `bat eza fd-find ripgrep fzf zoxide ncdu keychain` + `jq yq httpie make tree htop tmux direnv dnsutils unzip rsync`. Symlinks Debian-renamed binaries (`batcat`→`bat`, `fdfind`→`fd`) into `~/.local/bin` so notes/aliases.zsh works as-is. |
| `docs/setup/scripts/40-node.sh` | **New.** nvm v0.40.1 + Node from `.nvmrc` (currently 24) + corepack enable + pnpm warmed from `package.json#packageManager`. |
| `docs/setup/scripts/50-docker.sh` | **New.** Docker CE + compose plugin from docker.com apt repo, user added to `docker` group, `docker.service` enabled at boot. Pinned GPG key + repo line for Debian Trixie. |
| `docs/setup/scripts/60-tuning.sh` | **New.** `fs.inotify.max_user_watches=524288` (Vite/Nx watch ceiling), optional 4 GB swapfile, optional hostname rename (only prompts on generic hostnames like `debian13`). |
| `docs/setup/scripts/70-hardening.sh` | **New.** Best-effort UFW (`allow OpenSSH` only) + unattended-upgrades on security channel + fail2ban + sshd drop-in (`PermitRootLogin no`, `PasswordAuthentication no`, `AllowAgentForwarding yes`). Probes before applying, validates `sshd -t` before reloading, skips cleanly if infra already locked the box down. |
| `docs/setup/scripts/80-dotfiles.sh` | **New.** Symlinks `notes/aliases.zsh` → `~/.oh-my-zsh/custom/aliases.zsh` (backs up an existing target). Copies `notes/gitconfig.txt` to `~/.gitconfig`, prompts for identity, applies via `git config --global user.name/email`. |
| `docs/setup/systemd/apf-portal-infra@.service` | **New.** Template systemd unit auto-starting `./infra/local/dev.sh up` at boot. Install: `enable apf-portal-infra@$USER.service`. |
| `.devcontainer/devcontainer.json` | **New.** VSCode Dev Container spec on `mcr.microsoft.com/devcontainers/typescript-node:1-24-bookworm`. `docker-outside-of-docker` feature + `--network=apf-portal-dev` so DNS to `postgres`/`redis`/`otel-collector` works. `initializeCommand` fails fast if `dev.sh up` hasn't been run yet. Forwarded ports labelled. Six dev extensions pre-installed. |
| `.devcontainer/post-create.sh` | **New.** `corepack enable && pnpm install --frozen-lockfile`. |
| `CLAUDE.md` | "Environment conventions" rewritten: documents the two envs (`local` / `development`) + hybrid sub-mode + the two IDE flows (Remote-SSH / Devcontainer), points at the new VM setup doc and the legacy WSL doc. |

## Key choices

- **Idempotent scripts, individually runnable.** Each script probes before doing anything (apt package already installed? plugin already cloned? UFW already active? sshd drop-in already present?). Bootstrap is the orchestrator; each script also runs standalone. Re-running after a partial setup is safe and reports `↪ skip` for the no-op cases.
- **No private key on the VM — SSH agent forwarding instead.** `~/.ssh/config` on the workstation carries `ForwardAgent yes`; the VM never holds long-lived secrets. Same future pattern for GPG signing (covered in §8.5 as appendix). `keychain` is installed by `30-cli-tools.sh` as a fallback for scenarios where agent forwarding is not available (CI runners, scripts).
- **Hardening is "best-effort, probe-first".** Some infra teams ship pre-hardened VMs; this script doesn't fight that. UFW already active? Print rules and skip. unattended-upgrades already on? Skip. SSH already locked down? Skip. Each section validates before reloading so a misconfig can't take SSH offline.
- **Devcontainer assumes infra-on-host.** The container runs on the VM but talks to postgres / redis / otel **on the same VM's host docker daemon** through the shared `apf-portal-dev` Compose network. `initializeCommand` fails fast with a clear message if `./infra/local/dev.sh up` hasn't been run yet — better than puzzling `ECONNREFUSED` errors at runtime.
- **`60-tuning.sh` raises inotify to 524288.** Default Debian limit is 8K; Vite/Nx in this monorepo blow past that. The setting is persisted in `/etc/sysctl.d/99-apf-portal.conf` so it survives reboot.
- **Hybrid mode (workstation IDE + VM infra) is a documented sub-mode.** SSH `LocalForward` directives on 5432/6379/4317/4318 expose the VM's infra services as `localhost:*` on the workstation. Latency cost: 5-15 ms per query, fine for daily work; for long-running flows, wrap in `tmux` or use `autossh`.

## Notes for the reviewer

- **Renaming the existing setup docs (01→02, 02→03, 03→04) is the only "destructive" change.** `git log --follow` still works because of `git mv`. Diff shows up as renames, not delete-and-add.
- **The brief asked for `bat eza fd-find ripgrep fzf zoxide docker keychain ncdu git`.** `git` is installed by `10-base-packages.sh` (every other script needs it). Everything else lives in `30-cli-tools.sh` + `50-docker.sh`. The "fullstack-dev extras" (`jq yq httpie make tree htop tmux direnv dnsutils unzip rsync`) are additions I proposed in the lock-in question and that you greenlit (full scope) — easy to trim if any of them turn out to be unwanted.
- **Both Node toolchains in parallel** — nvm on the VM (via `40-node.sh`) **and** devcontainer in the repo. Devs can use either; both read the same `.nvmrc` + `packageManager` pin so the version stays consistent.
- **The systemd unit is a TEMPLATE** (`apf-portal-infra@.service`) — install once, enable per-user (`enable apf-portal-infra@$USER.service`). This is the right shape for a shared VM with multiple devs eventually, even if today only one user uses it.
- **No PR-body Co-Authored-By trailer, no Generated-with-Claude footer**, per the project rule.

## Test plan

Manual (no automated test exists for this kind of setup work):

- [ ] On a fresh Debian 13 VM: `git clone …`, `./docs/setup/scripts/bootstrap.sh`, answer prompts, end up with zsh + Powerlevel10k + all the requested CLI tools + Node 24 + pnpm 10.33.4 + Docker on PATH.
- [ ] `./infra/local/dev.sh up` boots successfully against the VM's local docker daemon.
- [ ] `pnpm install` + `pnpm exec nx run-many -t lint test --parallel=3` passes on the VM.
- [ ] VSCode Remote-SSH from a workstation: connect, open `~/Works/apf_portal`, run `pnpm exec nx serve portal-bff`, confirm reaches `postgres:5432`.
- [ ] VSCode Dev Containers from the same workstation: `Reopen in Container`, image builds, `postCreateCommand` runs `pnpm install`, dev server reaches postgres through the `apf-portal-dev` network.
- [ ] Hybrid mode: SSH tunnel from workstation, `pnpm exec nx serve portal-bff` locally, confirm postgres reachable via `localhost:5432`.
- [x] `pnpm exec prettier --check` clean on the touched markdown files.
- [x] Scripts pass `bash -n` (syntax check) — verified during writing.

## What's next

- Validate by walking through this doc on the actual VM `10.100.201.21`. Any friction surfaced becomes a follow-up PR (`docs(setup): ...`).
- Once the dev VM is operational, return to **ADR-0027 Implementation PR 1** (Region / Delegation / Structure Prisma schema + inline reference-data migration) — paused since the start of this PR.
- Set up the **private dotfiles repo** (`apf/dotfiles`) as a small follow-up, then teach `80-dotfiles.sh` to prefer the dotfiles repo over `notes/`.
- When migrating to GitLab: PR pair — (a) `git remote set-url` doc updates here, (b) `infra/gitlab-runners/` replacing `infra/ci-runners.compose.yml`.
- Future: deploy the **shared preview infra** on `vm-gitlab` (10.100.201.10) — CI-driven, separate PR.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #220
This commit was merged in pull request #220.
This commit is contained in:
2026-05-24 18:40:07 +02:00
parent 0d8b3712fb
commit 8a04540410
19 changed files with 1236 additions and 22 deletions
+17
View File
@@ -0,0 +1,17 @@
#!/usr/bin/env bash
# apt update + install base packages every other script assumes.
set -euo pipefail
# shellcheck source=./lib.sh
source "$(cd "$(dirname "$0")" && pwd)/lib.sh"
require_debian
require_not_root
log "apt update"
sudo apt-get update -qq
apt_install \
curl wget git ca-certificates gnupg lsb-release \
software-properties-common apt-transport-https \
build-essential pkg-config
ok "Base packages ready."
+93
View File
@@ -0,0 +1,93 @@
#!/usr/bin/env bash
# zsh + Oh My Zsh + Powerlevel10k + plugins (autosuggestions, syntax-highlighting).
# Patches ~/.zshrc to set the theme, plugin list, fzf hook.
set -euo pipefail
# shellcheck source=./lib.sh
source "$(cd "$(dirname "$0")" && pwd)/lib.sh"
require_debian
require_not_root
apt_install zsh
# Make zsh the default shell for $USER (idempotent).
current_shell="$(getent passwd "$USER" | cut -d: -f7)"
zsh_path="$(command -v zsh)"
if [[ "$current_shell" != "$zsh_path" ]]; then
log "Setting zsh as default shell for $USER (sudo will prompt)."
sudo chsh -s "$zsh_path" "$USER"
ok "Default shell set to $zsh_path. Takes effect on next login."
else
skip "zsh already default shell."
fi
# Oh My Zsh — install with RUNZSH=no to avoid spawning zsh during the script.
OMZ_DIR="$HOME/.oh-my-zsh"
if [[ -d "$OMZ_DIR" ]]; then
skip "Oh My Zsh already installed."
else
log "Installing Oh My Zsh."
RUNZSH=no CHSH=no KEEP_ZSHRC=yes sh -c \
"$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)"
ok "Oh My Zsh installed."
fi
ZSH_CUSTOM_DIR="${ZSH_CUSTOM:-$OMZ_DIR/custom}"
clone_or_skip() {
local name="$1" url="$2" dir="$3"
if [[ -d "$dir" ]]; then
skip "$name already cloned at $dir."
else
log "Cloning $name."
git clone --depth=1 "$url" "$dir"
ok "$name installed."
fi
}
# Powerlevel10k theme.
clone_or_skip "Powerlevel10k" \
"https://github.com/romkatv/powerlevel10k.git" \
"$ZSH_CUSTOM_DIR/themes/powerlevel10k"
# Plugins.
clone_or_skip "zsh-autosuggestions" \
"https://github.com/zsh-users/zsh-autosuggestions.git" \
"$ZSH_CUSTOM_DIR/plugins/zsh-autosuggestions"
clone_or_skip "zsh-syntax-highlighting" \
"https://github.com/zsh-users/zsh-syntax-highlighting.git" \
"$ZSH_CUSTOM_DIR/plugins/zsh-syntax-highlighting"
# Patch ~/.zshrc — theme + plugin list + fzf hook.
ZSHRC="$HOME/.zshrc"
[[ -f "$ZSHRC" ]] || touch "$ZSHRC"
if grep -q '^ZSH_THEME=' "$ZSHRC"; then
if grep -q '^ZSH_THEME="powerlevel10k/powerlevel10k"' "$ZSHRC"; then
skip "ZSH_THEME already powerlevel10k."
else
sed -i 's#^ZSH_THEME=.*#ZSH_THEME="powerlevel10k/powerlevel10k"#' "$ZSHRC"
ok "ZSH_THEME → powerlevel10k/powerlevel10k."
fi
else
echo 'ZSH_THEME="powerlevel10k/powerlevel10k"' >> "$ZSHRC"
ok 'Set ZSH_THEME=powerlevel10k/powerlevel10k.'
fi
PLUGINS_LINE='plugins=(git fzf zoxide direnv zsh-autosuggestions zsh-syntax-highlighting)'
if grep -q '^plugins=' "$ZSHRC"; then
if grep -q "^$PLUGINS_LINE" "$ZSHRC"; then
skip "plugins=(...) already configured."
else
sed -i "s#^plugins=.*#$PLUGINS_LINE#" "$ZSHRC"
ok "plugins=(...) updated."
fi
else
echo "$PLUGINS_LINE" >> "$ZSHRC"
ok "plugins=(...) added."
fi
ensure_line '[ -f ~/.fzf.zsh ] && source ~/.fzf.zsh' "$ZSHRC"
ok "zsh + Oh My Zsh + Powerlevel10k ready."
log "Next: run 'exec zsh -l' then 'p10k configure' to pick a Powerlevel10k preset."
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env bash
# Modern CLI tools — what notes/aliases.zsh assumes + a fullstack-dev kit.
set -euo pipefail
# shellcheck source=./lib.sh
source "$(cd "$(dirname "$0")" && pwd)/lib.sh"
require_debian
require_not_root
# Requested in the project brief.
apt_install bat eza fd-find ripgrep fzf zoxide ncdu keychain
# Useful extras for a fullstack dev workflow.
apt_install jq yq httpie make tree htop tmux direnv dnsutils unzip rsync
# Debian renames `bat`→`batcat` and `fd`→`fdfind` to avoid binary
# conflicts. notes/aliases.zsh aliases the Debian names back, but a
# direct binary on PATH is more robust (works for non-interactive
# scripts and other shells). Symlink in ~/.local/bin.
mkdir -p "$HOME/.local/bin"
for pair in "batcat:bat" "fdfind:fd"; do
src="${pair%%:*}"
dst="${pair##*:}"
src_path="$(command -v "$src" 2>/dev/null || true)"
if [[ -z "$src_path" ]]; then
warn "$src not found on PATH — skipping symlink."
continue
fi
if [[ -L "$HOME/.local/bin/$dst" && "$(readlink "$HOME/.local/bin/$dst")" == "$src_path" ]]; then
skip "$HOME/.local/bin/$dst already linked to $src."
elif [[ -e "$HOME/.local/bin/$dst" ]]; then
skip "$HOME/.local/bin/$dst exists (not a managed symlink) — leaving as-is."
else
ln -s "$src_path" "$HOME/.local/bin/$dst"
ok "Linked $HOME/.local/bin/$dst$src."
fi
done
# Make sure ~/.local/bin is on PATH for both bash and zsh.
for rcfile in "$HOME/.bashrc" "$HOME/.zshrc"; do
[[ -f "$rcfile" ]] || touch "$rcfile"
ensure_line 'export PATH="$HOME/.local/bin:$PATH"' "$rcfile"
done
ok "CLI tools installed."
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env bash
# nvm + Node from the repo's .nvmrc + corepack + pnpm at the version pinned
# in package.json's packageManager field.
set -euo pipefail
# shellcheck source=./lib.sh
source "$(cd "$(dirname "$0")" && pwd)/lib.sh"
require_debian
require_not_root
NVM_VERSION="v0.40.1"
NVM_DIR="$HOME/.nvm"
if [[ -d "$NVM_DIR" ]]; then
skip "nvm already installed at $NVM_DIR."
else
log "Installing nvm $NVM_VERSION."
curl -fsSL "https://raw.githubusercontent.com/nvm-sh/nvm/$NVM_VERSION/install.sh" | bash
ok "nvm installed."
fi
# Source nvm so we can use it in this shell.
export NVM_DIR
# shellcheck disable=SC1091
source "$NVM_DIR/nvm.sh"
# Read .nvmrc from the repo root.
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || true)"
if [[ -z "$REPO_ROOT" ]]; then
err "Run from inside a git checkout of apf_portal (no repo root found)."
exit 1
fi
if [[ ! -f "$REPO_ROOT/.nvmrc" ]]; then
warn "$REPO_ROOT/.nvmrc not found — installing Node LTS as fallback."
nvm install --lts
nvm alias default 'lts/*'
else
NODE_VER="$(<"$REPO_ROOT/.nvmrc")"
log "Installing Node $NODE_VER (from $REPO_ROOT/.nvmrc)."
nvm install "$NODE_VER"
nvm alias default "$NODE_VER"
ok "Node $(node --version) active."
fi
# corepack — provides pnpm + yarn shims, reads packageManager from package.json.
corepack enable
ok "corepack enabled."
# Warm pnpm to download the pinned version.
if [[ -f "$REPO_ROOT/package.json" ]]; then
pushd "$REPO_ROOT" >/dev/null
pnpm --version >/dev/null
ok "pnpm $(pnpm --version) ready (from package.json packageManager)."
popd >/dev/null
fi
log "Reload your shell (exec zsh -l) so nvm + node + pnpm are on PATH."
+58
View File
@@ -0,0 +1,58 @@
#!/usr/bin/env bash
# Docker CE + compose plugin from the docker.com apt repo. Adds the
# current user to the `docker` group and enables the daemon at boot.
set -euo pipefail
# shellcheck source=./lib.sh
source "$(cd "$(dirname "$0")" && pwd)/lib.sh"
require_debian
require_not_root
if command -v docker >/dev/null 2>&1 \
&& docker compose version >/dev/null 2>&1; then
skip "docker + compose plugin already present."
else
log "Installing Docker CE from docker.com apt repo."
# GPG key.
sudo install -m 0755 -d /etc/apt/keyrings
if [[ ! -f /etc/apt/keyrings/docker.asc ]]; then
curl -fsSL https://download.docker.com/linux/debian/gpg \
| sudo tee /etc/apt/keyrings/docker.asc >/dev/null
sudo chmod a+r /etc/apt/keyrings/docker.asc
fi
# Repo definition.
ARCH="$(dpkg --print-architecture)"
# shellcheck disable=SC1091
CODENAME="$(. /etc/os-release && echo "${VERSION_CODENAME}")"
REPO_LINE="deb [arch=${ARCH} signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian ${CODENAME} stable"
if [[ -f /etc/apt/sources.list.d/docker.list ]] \
&& grep -qxF "$REPO_LINE" /etc/apt/sources.list.d/docker.list; then
skip "docker.list already configured for $CODENAME."
else
echo "$REPO_LINE" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null
ok "Wrote /etc/apt/sources.list.d/docker.list."
fi
sudo apt-get update -qq
apt_install docker-ce docker-ce-cli containerd.io \
docker-buildx-plugin docker-compose-plugin
ok "Docker installed."
fi
# Add user to docker group.
if id -nG "$USER" | grep -qw docker; then
skip "User $USER already in docker group."
else
sudo usermod -aG docker "$USER"
warn "Added $USER to docker group — log out and back in for it to apply."
fi
# Enable docker.service at boot.
if systemctl is-enabled docker.service >/dev/null 2>&1; then
skip "docker.service already enabled at boot."
else
sudo systemctl enable --now docker.service
ok "docker.service enabled and started."
fi
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env bash
# System-level tuning for Node / Vite / Nx development on a Debian VM:
# - inotify watches limit (default 8K is too low for monorepos)
# - hostname (prompt only if generic)
# - swapfile (4 GB, only if none exists)
set -euo pipefail
# shellcheck source=./lib.sh
source "$(cd "$(dirname "$0")" && pwd)/lib.sh"
require_debian
require_not_root
# 1) inotify watches.
SYSCTL_FILE="/etc/sysctl.d/99-apf-portal.conf"
INOTIFY_LINE="fs.inotify.max_user_watches=524288"
if sudo grep -qxF "$INOTIFY_LINE" "$SYSCTL_FILE" 2>/dev/null; then
skip "inotify watches already configured in $SYSCTL_FILE."
else
echo "$INOTIFY_LINE" | sudo tee "$SYSCTL_FILE" >/dev/null
sudo sysctl --system >/dev/null
ok "fs.inotify.max_user_watches → 524288."
fi
# 2) Hostname — only prompt if it looks generic.
current_hostname="$(hostname)"
case "$current_hostname" in
debian|debian13|localhost|trixie|debian12)
read -r -p "Current hostname '$current_hostname' looks generic. New hostname (blank to keep): " new_hostname
if [[ -n "$new_hostname" ]]; then
sudo hostnamectl set-hostname "$new_hostname"
ok "Hostname set to '$new_hostname'. Will reflect on next login."
else
skip "Hostname kept as '$current_hostname'."
fi
;;
*)
skip "Hostname '$current_hostname' looks intentional — keeping."
;;
esac
# 3) Swap.
if swapon --show | grep -q .; then
skip "Swap already active: $(swapon --show --noheadings | head -1 | awk '{print $1, $3}')."
else
if confirm "No swap detected. Add a 4 GB swapfile at /swapfile?"; then
sudo fallocate -l 4G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
ensure_line_sudo "/swapfile none swap sw 0 0" /etc/fstab
ok "4 GB swapfile active."
else
skip "No swap configured (user choice)."
fi
fi
+92
View File
@@ -0,0 +1,92 @@
#!/usr/bin/env bash
# Best-effort VM hardening — probes current state, only applies what's missing.
# Skips cleanly when infra has already configured a step.
# - UFW (allow SSH only)
# - unattended-upgrades (security channel)
# - fail2ban
# - sshd: disable root login + password auth (always validates first)
set -euo pipefail
# shellcheck source=./lib.sh
source "$(cd "$(dirname "$0")" && pwd)/lib.sh"
require_debian
require_not_root
log "Hardening pass — each section probes and skips if already done."
# ─── 1. UFW ────────────────────────────────────────────────────────────
if ! command -v ufw >/dev/null; then
apt_install ufw
fi
if sudo ufw status | head -1 | grep -q 'Status: active'; then
skip "UFW already active. Current rules:"
sudo ufw status numbered | sed 's/^/ /'
else
if confirm "Enable UFW with 'deny incoming, allow SSH'?"; then
sudo ufw default deny incoming >/dev/null
sudo ufw default allow outgoing >/dev/null
sudo ufw allow OpenSSH >/dev/null
sudo ufw --force enable
ok "UFW enabled (SSH-only ingress)."
else
skip "UFW left disabled."
fi
fi
# ─── 2. unattended-upgrades ────────────────────────────────────────────
apt_install unattended-upgrades
AUTO_UP="/etc/apt/apt.conf.d/20auto-upgrades"
if [[ -f "$AUTO_UP" ]] \
&& sudo grep -q 'APT::Periodic::Update-Package-Lists "1"' "$AUTO_UP" \
&& sudo grep -q 'APT::Periodic::Unattended-Upgrade "1"' "$AUTO_UP"; then
skip "unattended-upgrades already enabled."
else
cat <<'EOF' | sudo tee "$AUTO_UP" >/dev/null
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
EOF
ok "unattended-upgrades enabled."
fi
# ─── 3. fail2ban ───────────────────────────────────────────────────────
apt_install fail2ban
if systemctl is-active fail2ban.service >/dev/null 2>&1; then
skip "fail2ban already running."
else
sudo systemctl enable --now fail2ban.service
ok "fail2ban started + enabled at boot."
fi
# ─── 4. sshd hardening ────────────────────────────────────────────────
# Drop-in file rather than editing /etc/ssh/sshd_config directly.
# We always validate with `sshd -t` before reloading the daemon.
SSHD_DROPIN="/etc/ssh/sshd_config.d/99-apf-portal-hardening.conf"
if sudo test -f "$SSHD_DROPIN"; then
skip "sshd hardening drop-in already at $SSHD_DROPIN."
else
warn "About to disable password auth + root login on SSH."
warn "Make sure your SSH key already works from your workstation."
if confirm "Continue?"; then
cat <<'EOF' | sudo tee "$SSHD_DROPIN" >/dev/null
# Managed by docs/setup/scripts/70-hardening.sh.
# To override or extend, drop another file in /etc/ssh/sshd_config.d/.
PermitRootLogin no
PasswordAuthentication no
ChallengeResponseAuthentication no
KbdInteractiveAuthentication no
UsePAM yes
AllowAgentForwarding yes
EOF
if sudo sshd -t 2>/dev/null; then
sudo systemctl reload ssh.service 2>/dev/null || sudo systemctl reload sshd.service
ok "sshd reloaded with hardened config."
else
err "sshd config validation failed — removing $SSHD_DROPIN."
sudo rm -f "$SSHD_DROPIN"
exit 1
fi
else
skip "sshd hardening declined."
fi
fi
ok "Hardening pass complete."
+78
View File
@@ -0,0 +1,78 @@
#!/usr/bin/env bash
# Wire up the project-specific dotfiles:
# - symlink notes/aliases.zsh → ~/.oh-my-zsh/custom/aliases.zsh
# - copy notes/gitconfig.txt to ~/.gitconfig and overlay your identity
set -euo pipefail
# shellcheck source=./lib.sh
source "$(cd "$(dirname "$0")" && pwd)/lib.sh"
require_debian
require_not_root
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || true)"
if [[ -z "$REPO_ROOT" ]]; then
err "Run from inside a git checkout of apf_portal."
exit 1
fi
# ─── 1. Aliases ───────────────────────────────────────────────────────
SOURCE_ALIASES="$REPO_ROOT/notes/aliases.zsh"
if [[ ! -f "$SOURCE_ALIASES" ]]; then
err "$SOURCE_ALIASES not found."
exit 1
fi
OMZ_CUSTOM="${ZSH_CUSTOM:-$HOME/.oh-my-zsh/custom}"
if [[ ! -d "$OMZ_CUSTOM" ]]; then
err "$OMZ_CUSTOM not found — run 20-zsh.sh first."
exit 1
fi
TARGET_ALIASES="$OMZ_CUSTOM/aliases.zsh"
if [[ -L "$TARGET_ALIASES" \
&& "$(readlink -f "$TARGET_ALIASES")" == "$(readlink -f "$SOURCE_ALIASES")" ]]; then
skip "aliases.zsh symlink already correct."
else
if [[ -e "$TARGET_ALIASES" || -L "$TARGET_ALIASES" ]]; then
backup="$TARGET_ALIASES.bak.$(date +%s)"
mv "$TARGET_ALIASES" "$backup"
warn "Existing $TARGET_ALIASES backed up to $backup."
fi
ln -s "$SOURCE_ALIASES" "$TARGET_ALIASES"
ok "Linked $TARGET_ALIASES$SOURCE_ALIASES."
fi
# ─── 2. gitconfig — copy + identity overlay ───────────────────────────
SOURCE_GITCONFIG="$REPO_ROOT/notes/gitconfig.txt"
if [[ ! -f "$SOURCE_GITCONFIG" ]]; then
err "$SOURCE_GITCONFIG not found."
exit 1
fi
TARGET_GITCONFIG="$HOME/.gitconfig"
if [[ -f "$TARGET_GITCONFIG" ]] && ! confirm "$TARGET_GITCONFIG exists — overwrite (existing backed up)?"; then
skip "Keeping existing $TARGET_GITCONFIG."
else
if [[ -f "$TARGET_GITCONFIG" ]]; then
cp "$TARGET_GITCONFIG" "$TARGET_GITCONFIG.bak.$(date +%s)"
fi
# Prompt for identity. Defaults: pre-existing config values, else blank.
current_name="$(git config --global user.name 2>/dev/null || true)"
current_email="$(git config --global user.email 2>/dev/null || true)"
read -r -p "Git user.name [${current_name:-<required>}]: " new_name
new_name="${new_name:-$current_name}"
read -r -p "Git user.email [${current_email:-<required>}]: " new_email
new_email="${new_email:-$current_email}"
if [[ -z "$new_name" || -z "$new_email" ]]; then
err "Both user.name and user.email are required."
exit 1
fi
cp "$SOURCE_GITCONFIG" "$TARGET_GITCONFIG"
# `git config --global` finds and rewrites the [user] section
# cleanly even when the source carries a different identity.
git config --global user.name "$new_name"
git config --global user.email "$new_email"
ok "Wrote $TARGET_GITCONFIG with identity $new_name <$new_email>."
fi
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env bash
# Orchestrator — runs every numbered setup script in order with a confirmation prompt.
# Each script is also runnable individually:
# ./docs/setup/scripts/40-node.sh
# See docs/setup/01-dev-debian-vm-setup.md for the step-by-step doc.
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=./lib.sh
source "$HERE/lib.sh"
require_debian
require_not_root
SCRIPTS=(
10-base-packages.sh
20-zsh.sh
30-cli-tools.sh
40-node.sh
50-docker.sh
60-tuning.sh
70-hardening.sh
80-dotfiles.sh
)
log "Bootstrap will run, in order:"
for s in "${SCRIPTS[@]}"; do
printf ' - %s\n' "$s"
done
echo
if ! confirm "Continue?"; then
log "Aborted before any change."
exit 0
fi
for s in "${SCRIPTS[@]}"; do
echo
log "═══ $s ═══"
if confirm "Run $s now?"; then
"$HERE/$s"
ok "$s done"
else
skip "$s"
fi
done
echo
ok "Bootstrap complete."
log "Next steps:"
printf ' - exec zsh -l # reload shell to pick up zsh + PATH\n'
printf ' - p10k configure # only if you skipped during 20-zsh.sh\n'
printf ' - cp infra/local/.env.example infra/local/.env && $EDITOR infra/local/.env\n'
printf ' - ./infra/local/dev.sh up\n'
printf ' - pnpm install\n'
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env bash
# Shared helpers for docs/setup/scripts/*.sh — sourced from each script.
# Idempotency utilities + nice logging.
set -euo pipefail
if [[ -t 1 ]]; then
C_GREEN=$'\033[1;32m'
C_YELLOW=$'\033[1;33m'
C_RED=$'\033[1;31m'
C_BLUE=$'\033[1;34m'
C_DIM=$'\033[2m'
C_RESET=$'\033[0m'
else
C_GREEN= C_YELLOW= C_RED= C_BLUE= C_DIM= C_RESET=
fi
log() { printf '%s[%s]%s %s\n' "$C_BLUE" "$(date +%H:%M:%S)" "$C_RESET" "$*"; }
ok() { printf '%s✓%s %s\n' "$C_GREEN" "$C_RESET" "$*"; }
warn() { printf '%s⚠%s %s\n' "$C_YELLOW" "$C_RESET" "$*" >&2; }
err() { printf '%s✗%s %s\n' "$C_RED" "$C_RESET" "$*" >&2; }
skip() { printf '%s↪ skip%s %s\n' "$C_DIM" "$C_RESET" "$*"; }
require_debian() {
if ! grep -q '^ID=debian' /etc/os-release 2>/dev/null; then
err "This script targets Debian (read from /etc/os-release)."
exit 1
fi
}
require_not_root() {
if [[ $EUID -eq 0 ]]; then
err "Run as your regular user (sudo is invoked where needed)."
exit 1
fi
}
confirm() {
local prompt="${1:-Proceed?}"
read -r -p "$prompt [y/N] " ans
[[ "$ans" =~ ^[yY]$ ]]
}
# Install apt packages, skipping those already present. Idempotent.
apt_install() {
local missing=()
for pkg in "$@"; do
if ! dpkg -s "$pkg" >/dev/null 2>&1; then
missing+=("$pkg")
fi
done
if (( ${#missing[@]} == 0 )); then
skip "All packages already present: $*"
return 0
fi
log "apt install: ${missing[*]}"
sudo apt-get install -y "${missing[@]}"
ok "Installed: ${missing[*]}"
}
# Append a line to a file iff not already present (exact match). Idempotent.
ensure_line() {
local line="$1"
local file="$2"
if grep -qxF -- "$line" "$file" 2>/dev/null; then
skip "Already in $(basename "$file"): $line"
else
echo "$line" >> "$file"
ok "Appended to $file: $line"
fi
}
# Same but with sudo (system files).
ensure_line_sudo() {
local line="$1"
local file="$2"
if sudo grep -qxF -- "$line" "$file" 2>/dev/null; then
skip "Already in $(basename "$file"): $line"
else
echo "$line" | sudo tee -a "$file" >/dev/null
ok "Appended to $file: $line"
fi
}