Boot fixes, scratch windows
This commit is contained in:
parent
8aa1f8a8a6
commit
d3de780583
9 changed files with 740 additions and 0 deletions
478
boot-setup.org
Normal file
478
boot-setup.org
Normal file
|
|
@ -0,0 +1,478 @@
|
|||
#+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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue