dotfiles/boot-setup.org

479 lines
16 KiB
Org Mode
Raw Permalink Normal View History

2026-08-09 14:31:49 -04:00
#+title: Boot Setup & Low-Level Configuration on Slackware
#+author: Ian Keane
#+date: 2026-08-08
#+options: toc:t num:nil
* Overview
This is a writeup of how I have my Slackware laptop configured at the low level —
boot services, input devices, audio, remote filesystems, and window manager. The
goal throughout has been to keep everything in =~/.dotfiles= as static files,
managed by a pair of symlink scripts (=link.sh= for user configs, =link-root.sh=
for system configs), and to avoid =systemd= or any init system that requires
service descriptors to express dependencies.
I run Slackware at runlevel 3 (multi-user, no X). I log in on a TTY and start
=stumpwm= or =kde= manually via =startx=, which means my X session is entirely
optional and can be restarted or switched without rebooting. Several services —
keyboard remapping, VPN, a network filesystem, and a music daemon — start at boot
via =rc.local=, before any user session begins.
* The Boot Sequence
Slackware uses BSD-style =rc.d= shell scripts. There's no dependency tracking, no
parallelism, no =After== directives. Everything in =/etc/rc.d/rc.local= runs
sequentially, in order, as root.
My =rc.local= looks like this:
#+begin_src bash
#!/bin/bash
# Log all boot output to file while still showing on console
exec > >(tee /var/log/rc.local.log) 2>&1
if [ -x /etc/rc.d/rc.kanata ]; then
/etc/rc.d/rc.kanata start
fi
if [ -x /etc/rc.d/rc.tailscale ]; then
/etc/rc.d/rc.tailscale start
fi
if [ -x /etc/rc.d/rc.autofs ]; then
/etc/rc.d/rc.autofs start
fi
if [ -x /etc/rc.d/rc.mpd ]; then
/etc/rc.d/rc.mpd start
fi
#+end_src
The =exec > >(tee ...)= line redirects all subsequent stdout and stderr to both
the console and =/var/log/rc.local.log=, which is invaluable for debugging boot
failures without having to attach a serial console or dig through kernel logs.
The services start in dependency order: kanata first (it only needs the kernel
input device), then tailscale (needs the network), then autofs (needs tailscale
to reach the NFS server), then MPD (needs autofs to have mounted the music
library).
* Keyboard Remapping with Kanata
I use [[https://github.com/jtroo/kanata][kanata]] for keyboard remapping. It operates at the kernel input layer via
=evdev=, entirely independent of X — which means my remaps work in TTYs, in
=tmux= over SSH, everywhere.
The rc script at =~/.dotfiles/config/kanata/rc.kanata= is symlinked to
=/etc/rc.d/rc.kanata= by =link-root.sh=:
#+begin_src bash
#!/bin/bash
KANATA_BIN="/home/green/.cargo/bin/kanata"
KANATA_CFG="/home/green/.config/kanata/kanata.kbd"
PIDFILE="/var/run/kanata.pid"
case "$1" in
start)
echo "Starting kanata..."
# Clear caps lock state before grabbing device
DISPLAY=:0 XAUTHORITY=/home/green/.Xauthority xset led off 2>/dev/null || true
sleep 0.2
$KANATA_BIN --cfg $KANATA_CFG &
echo $! > $PIDFILE
;;
stop)
kill $(cat $PIDFILE) && rm -f $PIDFILE
;;
restart)
$0 stop; sleep 1; $0 start
;;
esac
#+end_src
Kanata is installed from source via cargo. Because it's a Rust binary in
=~/.cargo/bin=, the full path is hardcoded in the rc script — no =PATH= games at
boot.
* Tailscale VPN
[[https://tailscale.com][Tailscale]] provides a WireGuard-based mesh VPN. I use it primarily to reach my
home server (=tower=) from anywhere without port forwarding or a fixed IP.
=tailscaled= is the userspace daemon; I installed the binary directly from a
release tarball into =/usr/sbin/= and =/usr/bin/=.
The rc script at =~/.dotfiles/config/tailscale/rc.tailscale=:
#+begin_src bash
#!/bin/bash
TAILSCALED=/usr/sbin/tailscaled
STATEDIR=/var/lib/tailscale
PIDFILE=/var/run/tailscaled.pid
start() {
if [ -f "$PIDFILE" ] && kill -0 "$(cat $PIDFILE)" 2>/dev/null; then
echo "tailscaled is already running"; return
fi
echo "Starting tailscaled..."
mkdir -p /var/run/tailscale
$TAILSCALED --state=$STATEDIR/tailscaled.state \
--socket=/var/run/tailscale/tailscaled.sock \
--port=41641 \
&>/var/log/tailscaled.log &
echo $! > $PIDFILE
echo "tailscaled started (pid $(cat $PIDFILE))"
}
stop() {
[ -f "$PIDFILE" ] && kill "$(cat $PIDFILE)" 2>/dev/null && rm -f "$PIDFILE"
}
case "$1" in
start) start ;;
stop) stop ;;
restart) stop; sleep 1; start ;;
status)
if [ -f "$PIDFILE" ] && kill -0 "$(cat $PIDFILE)" 2>/dev/null; then
echo "tailscaled is running (pid $(cat $PIDFILE))"
else
echo "tailscaled is not running"
fi ;;
esac
#+end_src
** DNS Caveat
Tailscale's MagicDNS (which would let you =ssh tower= instead of =ssh
100.73.64.64=) relies on the system DNS resolver being configured to use
Tailscale's nameserver. On Slackware without =systemd-resolved=, this doesn't
happen automatically, and =tailscaled= exits with status 2 when it tries to set
it up.
Compounding this, my router's DHCP server advertises =dump.town= as a domain
search suffix. So when I type =tower=, the resolver tries =tower.dump.town= first
— which exists and resolves to the public IP of my server, not its Tailscale IP.
The short-term fix is to use the Tailscale IP directly (=100.73.64.64=) anywhere
I need to reach =tower=. I also added this to =/etc/dhcpcd.conf= to suppress the
rogue domain search from DHCP:
#+begin_src conf
nooption domain_name, domain_search
#+end_src
Getting MagicDNS working properly on Slackware is a longer project involving
either patching =/etc/resolv.conf= from a hook or running a local resolver like
=dnsmasq=.
* Network Filesystem via autofs
My music library lives on =tower= and is exported over NFS. Rather than mount it
in =/etc/fstab= (which would block boot if the VPN wasn't up yet), I use
[[https://www.kernel.org/doc/Documentation/filesystems/autofs.txt][autofs]] to mount it on demand.
autofs works by presenting a directory that triggers an NFS mount the first time
it's accessed. If the mount fails, the directory just looks empty. This is much
cleaner than retry loops or =nofail= in fstab.
=~/.dotfiles/config/autofs/auto.master=:
#+begin_src conf
/mnt /etc/auto.mnt --timeout=300
/misc /etc/auto.misc
/net -hosts
#+end_src
=~/.dotfiles/config/autofs/auto.mnt=:
#+begin_src conf
media -fstype=nfs,nolock,soft,timeo=30 100.73.64.64:/mnt/user/media
#+end_src
This makes =/mnt/media= appear as a directory at all times. The NFS share from
=tower= is mounted into it the first time something accesses the path, and
unmounted after 300 seconds of inactivity.
Key NFS options:
- =nolock= — required because =rpc.statd= is not running on this Slackware install
- =soft= — NFS operations time out instead of hanging forever if the server is unreachable
- =timeo=30= — 3-second timeout (value is in tenths of a second)
* Music Player Daemon
MPD runs as my user, started at boot by root, before any X session. It serves
music from the NFS share over a local socket that =ncmpcpp= connects to.
The tricky part: MPD exits immediately if its =music_directory= is inaccessible.
It does not retry. So the rc script needs to ensure the NFS share is actually
mounted before starting MPD.
=~/.dotfiles/config/mpd/rc.mpd=:
#+begin_src bash
#!/bin/bash
MPD=/usr/bin/mpd
CONF=/home/green/.config/mpd/mpd.conf
PIDFILE=/var/run/mpd/mpd.pid
start() {
[ -f "$PIDFILE" ] && kill -0 "$(cat $PIDFILE)" 2>/dev/null && return
echo "Starting mpd..."
mkdir -p /var/run/mpd
chown green:users /var/run/mpd
# XDG_RUNTIME_DIR is normally created by pam_systemd on login.
# Since we start before login, we create it manually.
mkdir -p /run/user/1000
chown green:users /run/user/1000
chmod 700 /run/user/1000
# Wait for Tailscale to have connectivity to tower
echo "Waiting for Tailscale connectivity to tower..."
for i in $(seq 1 30); do
tailscale ping --c 1 100.73.64.64 &>/dev/null && break
sleep 1
done
# Trigger autofs mount and wait for it to succeed
echo "Waiting for /mnt/media/Music..."
for i in $(seq 1 15); do
ls /mnt/media/Music &>/dev/null && break
sleep 1
done
if ! ls /mnt/media/Music &>/dev/null; then
echo "WARNING: /mnt/media/Music not accessible, starting MPD anyway"
fi
sudo -u green XDG_RUNTIME_DIR=/run/user/1000 $MPD $CONF
echo "mpd started"
}
#+end_src
The two-phase wait is important: first we ping tower via Tailscale (to ensure the
VPN peer is reachable), then we =ls= the music directory (to trigger the autofs
mount and confirm it succeeded). In practice both loops complete in 1-2 seconds
once the network is up.
MPD's config at =~/.dotfiles/config/mpd/mpd.conf= uses PulseAudio for output.
Since I'm also running PipeWire with =pipewire-pulse=, this just works — PulseAudio
clients connect to PipeWire's compatibility layer.
#+begin_src conf
music_directory "/mnt/media/Music"
playlist_directory "/mnt/media/Music/playlists"
db_file "~/.config/mpd/database"
pid_file "/var/run/mpd/mpd.pid"
user "green"
bind_to_address "localhost"
port "6600"
audio_output {
type "pulse"
name "PulseAudio Output"
}
#+end_src
* X Session: Runlevel 3 + startx
I boot to runlevel 3 and log in on a TTY. X is started manually with =startx=,
which reads =~/.dotfiles/config/xinitrc= (symlinked to =~/.xinitrc=):
#+begin_src bash
#!/bin/sh
[ -f ~/.profile ] && . ~/.profile
# XDG_RUNTIME_DIR not set by systemd at runlevel 3
mkdir -p /run/user/$(id -u)
chmod 700 /run/user/$(id -u)
# Start PipeWire audio stack
pipewire &
pipewire-pulse &
wireplumber &
exec stumpwm
#+end_src
PipeWire starts here rather than at boot because it's a user service that needs
=XDG_RUNTIME_DIR= set correctly. It runs within the X session lifetime.
I also keep KDE Plasma installed and can switch to it by changing =exec stumpwm=
to =exec startplasma-x11=. Since I'm at runlevel 3, there's no display manager
involved — it's just which process I exec at the end of =xinitrc=.
* Input: libinput Touchpad
My touchpad is driven by =libinput= rather than the legacy Synaptics driver.
=libinput= supports =DisableWhileTyping= natively via the X server, without
needing a separate =syndaemon= process:
=~/.dotfiles/config/xorg.conf.d/70-touchpad.conf=:
#+begin_src conf
Section "InputClass"
Identifier "touchpad"
MatchIsTouchpad "on"
Driver "libinput"
Option "DisableWhileTyping" "true"
Option "Tapping" "on"
Option "NaturalScrolling" "false"
EndSection
#+end_src
This is symlinked to =/etc/X11/xorg.conf.d/70-touchpad.conf= by =link-root.sh=.
The Synaptics driver is still installed but doesn't match the touchpad because the
=libinput= rule takes precedence (the =70-= prefix puts it after the default =10-=
synaptics rules).
* WiFi Power Management
NetworkManager's default behavior is to enable power saving on WiFi, which causes
packet loss and latency spikes. One drop config file fixes it:
=~/.dotfiles/config/NetworkManager/wifi-powersave-off.conf=:
#+begin_src conf
[connection]
wifi.powersave = 2
#+end_src
Value =2= means "disable power saving". This is symlinked to
=/etc/NetworkManager/conf.d/wifi-powersave-off.conf=.
* StumpWM: Window Manager Configuration
StumpWM is a tiling window manager written and configured in Common Lisp. It runs
on SBCL and exposes the full language for configuration — no DSL, no limitations.
** Floating Terminal Scratchpads
The most interesting thing I've set up in StumpWM is a floating scratchpad system.
The goal: press a keybinding, get a floating terminal running a specific program,
positioned at a fixed location on screen. Press the binding again (or focus
another window), and it goes away.
The key insight was =define-frame-preference= with =:float= as the frame number:
#+begin_src lisp
(define-frame-preference "Default"
(:float t t :class "alsamixer-scratch")
(:float t t :class "ncmpcpp-scratch"))
#+end_src
When StumpWM adds a new window to a group, it consults =frame-preference= rules
to decide where to place it. =:float= as the frame argument causes the window to
be floated /during/ =group-add-window=, before any tiling logic runs. This is the
only reliable way to float a window from config — by the time =*new-window-hook*=
fires, the window has already been tiled.
We use the WM_CLASS property to identify windows. =wezterm --class my-class= sets
both fields of WM_CLASS to =my-class=, so the rule matches reliably.
The full scratchpad system in =~/.dotfiles/config/stumpwm/config.lisp=:
#+begin_src lisp
(defvar *float-term-rules* '())
(defun register-float-term (class command x y width height &key persistent)
"Register a floating terminal. Add a matching define-frame-preference rule too.
If PERSISTENT is t, smart-kill will hide the window instead of killing it."
(setf *float-term-rules*
(cons (list class command x y width height :persistent persistent)
(remove class *float-term-rules* :key #'car :test #'string=))))
;; Position floats after they appear (define-frame-preference handles the float,
;; the hook handles the geometry)
(add-hook *new-window-hook*
(lambda (win)
(let ((rule (assoc (window-class win) *float-term-rules* :test #'string=)))
(when rule
(stumpwm::float-window-move-resize win
:x (nth 2 rule) :y (nth 3 rule)
:width (nth 4 rule) :height (nth 5 rule))))))
(defun spawn-or-focus (class command)
"Focus existing window with CLASS, or spawn COMMAND if none exists."
(let ((win (find-if (lambda (w) (string= (window-class w) class))
(screen-windows (current-screen)))))
(cond ((null win) (run-shell-command command))
((eq win (current-window)) nil)
(t (focus-window win)))))
(defcommand smart-kill () ()
"Kill window, or hide it if it's a persistent float."
(let* ((win (current-window))
(class (when win (window-class win)))
(rule (when class (assoc class *float-term-rules* :test #'string=))))
(if (and rule (getf (nthcdr 6 rule) :persistent))
(hide-window win)
(kill-window-or-frame))))
;; Registrations
(register-float-term "alsamixer-scratch"
"wezterm start --class alsamixer-scratch -- alsamixer"
628 450 800 400)
(register-float-term "ncmpcpp-scratch"
"wezterm start --class ncmpcpp-scratch -- ncmpcpp"
628 300 900 500 :persistent t)
#+end_src
=ncmpcpp= is registered as =:persistent t=, so =s-q= (=smart-kill=) hides it
rather than closing it — preserving the MPD connection. =alsamixer= is not
persistent, so =s-q= just quits it. =s-F= (=select-floating-window=) lets you
recover a hidden float.
** Keybindings
StumpWM supports both a prefix-key map (=C-t= by default) and direct top-level
bindings via =*top-map*=. I prefer Super-key bindings for common actions:
| Binding | Action |
|-----------+---------------------------------|
| =s-d= | rofi run launcher |
| =s-w= | rofi window picker |
| =s-hjkl= | focus frame (vim directions) |
| =s-q= | smart-kill |
| =s-f= | toggle fullscreen |
| =s-F= | select floating window |
| =s-1..4= | switch group |
| =s--= | vertical split |
| =s-\= | horizontal split |
| =s-c a= | alsamixer float |
| =s-m p= | ncmpcpp float |
| =s-m v= | pick video with mpv |
| =s-m d= | download URL from clipboard |
Brightness is via a sub-map: =s-c b 1= through =s-c b 0= set 10%-100%.
** Gaps
=swm-gaps= module provides inner and outer gap support. I have keybindings to
switch between gap presets (=s-W g 0/1/2/3= for none/small/medium/large).
* Dotfiles Management
All of the above is stored in =~/.dotfiles= and managed by two scripts:
- =link.sh= — user-level symlinks (run as self)
- =link-root.sh= — system-level symlinks (run as root)
Both scripts use a =backup_and_link= function that moves any existing file to
=.bak= before creating the symlink, so they're safe to run on a system that
already has config files in place.
=link-root.sh= also idempotently appends service invocations to =/etc/rc.local=
if they're not already present, and =chmod +x='s the rc scripts it symlinks.
The advantage of this approach over Nix or stow: it's plain shell, readable,
debuggable, and doesn't require any tooling to be installed first. The
disadvantage: no atomicity, no rollback. For a single personal machine that's a
fine tradeoff.