Why these scripts are built the way they are. The README covers usage; this covers the reasoning, so that a change doesn't quietly undo a decision that was made for a reason.
Applies to kali-deploy-physical, kali-deploy-remote, kali-deploy-vm and
kali-deploy-wsl. The Ubuntu scripts in archive/ predate most of it.
The four split on target, not on options: hardware you sit at, a headless box you
reach over Tailscale, a guest you reach over a remote-desktop agent, and a WSL2
instance you enter with wsl.exe. That is why kali-deploy-physical hardcodes
picom --backend glx and is left that way — it is correct on a real GPU, and a
VM's graphics stack is kali-deploy-vm's problem to have. Teaching one script to
detect the other's platform is how both end up carrying accommodations neither
target needs.
It was the third script's detected profile first, and the header there argued the case: a WSL instance is a headless Kali, so the headless script should own it.
The test of "same target" is not whether the phases happen to fit, though. It is
whether the script's defining feature applies. kali-deploy-remote is its
access layer — Tailscale SSH, a hardened OpenSSH, ufw — and on WSL all three are
skipped by default. Running it there meant running it with the entire reason it
exists switched off, and a target that needs that is a different target. So the
access layer is absent from kali-deploy-wsl rather than skipped, and the rule
at the top of this file is what decided it, not an exception to it.
The split is complete: kali-deploy-remote no longer knows what WSL is. No
is_wsl(), no default skips, no WSL-only package array. Run it on a WSL box and
it will install a tailscaled and a listening sshd, because you asked it to.
That is the point. A detection left in "just in case" is a second implementation of the decision, and the two drift — one script's idea of WSL stops matching the other's, and the bug surfaces on the machine neither was tested on.
is_wsl() therefore lives in exactly one place now, kali-deploy-wsl, where it
gates the whole script rather than three phases. It is worth reading before
touching: it keys on WSL's userspace (/run/WSL, /mnt/wsl, /init) and never
on the kernel name, because a container shares its host's kernel and
/proc/sys/kernel/osrelease says microsoft inside every container on a WSL2
host — including the one this suite runs in. A grep microsoft there would pass
the gate inside CI containers on a maintainer's WSL laptop and nowhere else.
R8 was inverted rather than deleted. It used to assert the WSL profile fired;
it now creates /run/WSL, runs kali-deploy-remote, and asserts that nothing
changes — the access layer still runs, no WSL target is reported, and the web
tooling is absent from --print-packages. A removed special case needs a test
more than a present one does, because nothing else stops it being reintroduced
one convenience commit at a time.
These scripts exist to bring up a machine quickly. A run that dies halfway leaves a half-configured box, and the usual recovery is to reinstall the OS and start over — which costs more time than the script ever saved.
So the goal is not "install the tools". It is finish, first time, or degrade into something still usable. Every rule below is downstream of that.
An installer should keep going when an optional piece fails. set -u and
pipefail stay on; set -e does not. Every fallible step ends in || warn,
|| true, or sits inside an if.
This is not a style preference. Under set -e, v4 died at the first
apt-get install because one package in a 40-name list had no installation
candidate on that release — and installed nothing at all. Partial success beats
an abort.
kali-deploy-physical installs kali-tools-* metapackages rather than
naming individual tools. Kali curates them and resolves their dependencies, so
they survive rolling-release churn that a hand-listed set does not. A package
name that no longer resolves is the single most common way a deploy dies.
Where a name must be listed individually, probe it with apt_available first so
a rename is a warning, not a failure.
apt_available gates every install. apt_install falls back from a bulk
install to one-at-a-time so a single bad name cannot take the batch with it.
Re-running must always be safe, so that a failure is recoverable by running the
script again rather than by reimaging. Config writes are overwrite-with-backup;
appends are marker-guarded; --only and --skip let a single failed phase be
retried in isolation.
A backup is taken once, the first time we are about to replace a file. Copying on every run means the second run overwrites the backup with the config the first run generated, so the original is recoverable exactly once — which is to say, not when you actually need it.
Overwrite-with-backup is right for a file the script owns — ~/.tmux.conf has no
author but this repo. It is wrong for /etc/wsl.conf, which is how you set the
default user, systemd, and mount behaviour, and which is therefore usually
hand-edited before any deploy script runs.
So the wslconf phase creates the file when absent, appends a section that is
entirely absent, and never rewrites a section that is already there. Where a
section exists but lacks a key it wants, it prints the line to add rather than
editing around it. A [boot] that says something we would not have written is a
decision someone made, and silently reversing it is the kind of surprise that
costs more trust than the fix was worth.
The same reasoning is why WSL_HOSTNAME is opt-in and does nothing if
[network] already exists.
kali-deploy-remote will not disable SSH password authentication or enable
the firewall unless it can prove another way in exists — an installed key, or
Tailscale already up. A box that cannot reach itself is worse than one with
password auth enabled.
kali-deploy-physical inverts this: you have the keyboard, so default-deny
inbound is enabled unconditionally.
Everything comes from the distribution's own archive. No /opt virtualenv, no
pipx-from-git, no setcap on a venv symlink. Mixing third-party repos into a
rolling release is a known way to break it, and every tool these scripts need
is already packaged.
The exception is Tailscale, in kali-deploy-remote: it is not in Kali's archive
and it is the entire access layer, so the script adds Tailscale's signed apt repo
(pinned to the bookworm channel, which doesn't churn) and falls back to the
official installer only if that fails. It is called out here rather than left as
a surprise, and it is the only one.
The end-of-run check list is the last thing you read before trusting the box,
so it must not be able to lie in either direction. Checking for a binary nothing
installs is worse than not checking at all: it trains you to skim past red.
The lists live in VERIFY_BINS_* arrays, are exposed by
--print-checked-binaries, and tests/ T9 asserts every entry is delivered by
the closure of --print-packages. Note these are binary names, not package
names — certipy-ad ships /usr/bin/certipy-ad, while /usr/bin/certipy
belongs to python3-certipy, an unrelated library.
kali-linux-default ships with the Kali installer, so a tool it contains looks
present whether or not the script installs it. That is luck, and it evaporates on
a netinst or a minimal image. Anything the script depends on, the script names.
/etc/os-release has no VERSION_ID on kali-rolling, so version-aware logic
crashes under set -u. Probe for the capability instead of inferring it from a
version number.
Kali ships a tuned zsh. Both scripts append a marker-guarded block rather than
installing a framework over the top — faster shell startup, cleanly removable,
and no sed against a template that may not be there. v4 shipped exactly that
bug: its theme and plugin edits silently no-opped whenever the expected template
was absent, and kali-deploy-remote carried the same sed until it was rewritten.
The agnoster theme went with it. It needs powerline glyphs in the terminal you
are connecting from, which on a headless box is not a thing the deploy script
can install.
The dots phase pulls a repo that DOTS_REPO may point at a private fork
of. A freshly imaged machine has no SSH key and no gh token, so the common
case is that it cannot authenticate at all. It tries DOTS_TOKEN, then SSH,
then gh, then a plain anonymous clone, and when none work it warns, prints the
one command that finishes the job later, and lets the run continue. The
governing requirement applies here as everywhere: nothing optional gets to be
the reason an install is redone.
The anonymous fallback is load-bearing, not a courtesy. The default DOTS_REPO
is public, and while it was not tried, a stock box — the exact machine this
phase exists to serve — skipped the phase on every deploy and came up with
Kali's default shell, because "no credentials" was being read as "unreachable"
for a repo that needed none.
Two supporting rules fall out of it:
- git must never block on a prompt.
GIT_TERMINAL_PROMPT=0andBatchMode=yesare set for every git call in the phase. Without them an unknown host key or a private HTTPS URL waits forever for input, on a box nobody is watching — the failure mode the whole spec exists to avoid. - A token never goes in a URL. It is passed as a per-invocation
http.extraHeader. A URL-embedded credential is persisted into.git/configbycloneand repeated in git's error output, and this script's transcript is written to/var/log. The suite asserts a canary token never reaches the log.
When both the deploy script and a shell kit define serve, the loser is
whichever the shell resolves second — and in zsh an alias beats a function, so
alias serve='python3 -m http.server' would permanently shadow dots' richer
serve(). The overlapping definitions are therefore wrapped in a
"dots is not installed" guard rather than deleted outright, because the dots
phase is allowed to fail and the box must be fully equipped either way.
Where the two genuinely mean different things, they get different names:
myip is local interfaces, extip is what the internet sees.
Some things are only observable on real hardware — whether i3 actually starts,
whether polybar finds BAT0, whether an adapter supports monitor mode. The
script prints these as a checklist instead of reporting success it cannot
confirm.
Both scripts follow the same shape:
header/usage -> arg parsing -> logging -> helpers -> pre-flight
-> numbered phases -> verification
Shared helpers: msg/warn/err (warnings and errors to stderr, so they
are never captured by a $(...) substitution), get_real_user /
get_user_home, apt_available / apt_install / apt_install_first, run()
and write_file() so --dry-run is honoured in one place rather than at every
call site, backup_once(), and check for verification.
Both scripts take the same flags and both are phase-based. That symmetry is deliberate: two scripts with different argument grammars is two things to remember at 3am.
A deploy script's failure mode is a broken machine, which makes testing on the
target useless — by the time you learn, you have already paid the cost. So
everything testable is tested in a throwaway Kali container first, via
tests/.
Both scripts are exercised. The suite deliberately covers the failure paths,
not just the happy one: injected unavailable packages, a missing systemd, bad
arguments, repeated runs, and the lockout guards. It reads its package list from
--print-packages and its binary list from --print-checked-binaries so neither
can drift from the script.
Its value is not theoretical. It has caught, before any hardware was touched:
kali-desktop-i3depends onbetterlockscreen, which depends oni3lock-color, which both Conflicts and Providesi3lock— so namingi3lockexplicitly made the entire package set unresolvable.- Seven tools the physical script ticked and never installed, three of them missing on every install path.
dnsutils, which the remote script had installed for as long as it existed, has no candidate on current Kali — it isbind9-dnsutilsnow. The box came up withoutdigand said nothing about it.
The package-level checks run against a pristine image, before the suite
installs its own prerequisites. apt-get install --simulate only reports what it
would newly install, so a package the test harness pulled in first vanishes
from the closure and T9 reports it as missing. A test that cries wolf about the
one thing it uniquely catches is worse than no test.
- Configuring the user's dotfiles. That is a separate concern with its own repo.
- Supporting every distribution. Kali for the current scripts, and the
archive/ones were Ubuntu. - Unattended interactive prompts.
DEBIAN_FRONTEND=noninteractiveplus explicit debconf pre-seeding, because a package that asks a question hangs the run. - Reproducing a specific tool version set. These track the distribution.