16 KiB
Boot Setup & Low-Level Configuration on Slackware
- Overview
- The Boot Sequence
- Keyboard Remapping with Kanata
- Tailscale VPN
- Network Filesystem via autofs
- Music Player Daemon
- X Session: Runlevel 3 + startx
- Input: libinput Touchpad
- WiFi Power Management
- StumpWM: Window Manager Configuration
- Dotfiles Management
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:
#!/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
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 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:
#!/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
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
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:
#!/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
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:
nooption domain_name, domain_search
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
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:
/mnt /etc/auto.mnt --timeout=300
/misc /etc/auto.misc
/net -hosts
~/.dotfiles/config/autofs/auto.mnt:
media -fstype=nfs,nolock,soft,timeo=30 100.73.64.64:/mnt/user/media
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 becauserpc.statdis not running on this Slackware installsoft— NFS operations time out instead of hanging forever if the server is unreachabletimeo=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:
#!/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"
}
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.
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"
}
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):
#!/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
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:
Section "InputClass"
Identifier "touchpad"
MatchIsTouchpad "on"
Driver "libinput"
Option "DisableWhileTyping" "true"
Option "Tapping" "on"
Option "NaturalScrolling" "false"
EndSection
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:
[connection]
wifi.powersave = 2
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:
(define-frame-preference "Default"
(:float t t :class "alsamixer-scratch")
(:float t t :class "ncmpcpp-scratch"))
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:
(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)
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.