This excellent technique, which is now being used to handle a 70-node Raspberry Pi deployment, makes keeping your OSes up to date as simple as possible.
Ben Akrin, a Raspberry Pi guru, has put up a tutorial for automated, fully-headless imaging, with the goal of getting Raspberry Pi single-board computers up and running as soon as possible and with as little human input as feasible.
Simply connect two Raspberry Pis to the same network. The Netboot Server provides images to other Pis on the network, whereas the Pi to image will request an image from the Netboot Server and write it to its own SD card. The Netboot Server doesn't have to be another Pi, and it doesn't require any special Pi sauce, but I like to use Pis for everything. The Pi to be imaged, on the other hand, must also be a Pi; we're imaging Pis here.
Configuring the NetBoot Server
Any current vanilla RaspiOS server may be used. Under /home/pi/script.sh, copy the following script.
#!/bin/bash
# ascii text from https://patorjk.com/software/taag/#p=display&f=Standard
if
[ -z
"$1"
]
then
echo
"ERROR: I need one argument: the raspi image file"
exit
1
fi
if
[ ! -f
"$1"
];
then
echo
"ERROR: Image file $1 doesn't exist"
exit
1
fi
echo
"> installing needed packages"
apt-get update >
/dev/null
apt-get
install
-y
bc
nfs-kernel-server tftpd-hpa apache2 php php-curl php-xml libapache2-mod-php ipcalc curl wget
screen
lsof
>
/dev/null
myip=`
ifconfig
|
grep
-Eo
'inet (addr:)?([0-9]*\.){3}[0-9]*'
|
grep
-Eo
'([0-9]*\.){3}[0-9]*'
|
grep
-
v
'127.0.0.1'
`
mymask=`
ifconfig
|
grep
$myip |
grep
-Eo
'netmask (addr:)?([0-9]*\.){3}[0-9]*'
|
grep
-Eo
'([0-9]*\.){3}[0-9]*'
|
grep
-
v
'127.0.0.1'
`
mynetwork=`ipcalc $myip/$mymask |
grep
"Network:"
|
sed
's/\s\{1,\}/ /g'
|
cut
-d
' '
-f2`
echo
"> my ip: $myip"
echo
"> my network: $mynetwork"
echo
"> starting services"
echo
"> tftp"
service tftpd-hpa start
echo
"> nfs"
service nfs-kernel-server start
echo
"> web"
service apache2 start
echo
"> clean slate"
rm
-rf
/srv/tftp/
*
rm
-rf
/srv/nfs/
*
rm
-rf
/var/www/html/
*
echo
"> installing web components"
echo
"> bootstrap.sh"
# _ _ _ _
# | |__ ___ ___ | |_ ___| |_ _ __ __ _ _ __ ___| |__
# | '_ \ / _ \ / _ \| __/ __| __| '__/ _` | '_ \ / __| '_ \
# | |_) | (_) | (_) | |_\__ \ |_| | | (_| | |_) |\__ \ | | |
# |_.__/ \___/ \___/ \__|___/\__|_| \__,_| .__(_)___/_| |_|
# |_|
cat
<< EOF >
/var/www/html/bootstrap
.sh
disk_count=\`lsblk -d -l -p -n |
cut
-d
' '
-f1 |
wc
-l\`
if
[ \$disk_count -
ne
1 ]
then
echo
"ERROR: I need to have exactly 1 disk to write to"
exit
1
fi
echo
"> writing image to disk"
disk=\`lsblk -d -l -p -n |
cut
-d
' '
-f1\`
dd
if
=
/tmp/img
of=\$disk bs=1M status=progress
if
[ \$? -
ne
0 ]
then
echo
"> ERROR: writing image to disk failed"
echo
"> rebooting in 300 seconds"
sleep
300
reboot
exit
1
fi
echo
"> rebooting in 10 seconds"
sleep
10
wget -qO- http:
//
${myip}
/reboot
.php &>
/dev/null
reboot
EOF
# _ _ _
# (_)_ __ __| | _____ __ _ __ | |__ _ __
# | | '_ \ / _` |/ _ \ \/ / | '_ \| '_ \| '_ \
# | | | | | (_| | __/> < _| |_) | | | | |_) |
# |_|_| |_|\__,_|\___/_/\_(_) .__/|_| |_| .__/
# |_| |_|
#
echo
"> index.php"
cat
<< EOF >
/var/www/html/index
.php
<?php
echo
"Hi!"
;
exit
( 0 ) ;
?>
EOF
# _ _
# (_)_ __ ___ __ _ _ __ | |__ _ __
# | | '_ ` _ \ / _` | | '_ \| '_ \| '_ \
# | | | | | | | (_| |_| |_) | | | | |_) |
# |_|_| |_| |_|\__, (_) .__/|_| |_| .__/
# |___/ |_| |_|
echo
"> img.php"
cat
<< EOF >
/var/www/html/img
.php
<?php
header(
"Cache-Control: no-store, no-cache, must-revalidate"
) ;
header(
"Cache-Control: post-check=0, pre-check=0"
,
false
) ;
header(
"Pragma: no-cache"
) ;
header(
"Expires: "
.gmdate(
"D, d M Y H:i:s"
, mktime(
date
(
"H"
)+2,
date
(
"i"
),
date
(
"s"
),
date
(
"m"
),
date
(
"d"
),
date
(
"Y"
))).
" GMT"
) ;
header(
"Last-Modified: "
.gmdate(
"D, d M Y H:i:s"
).
" GMT"
) ;
header(
"Content-Type: application/octet-stream"
) ;
header(
"Content-Length: "
.(string)(filesize(
"/var/img"
)) ) ;
header(
"Content-Transfer-Encoding: binary\n"
) ;
\$handle = fopen(
"/var/img"
,
"rb"
) ;
if
( \$handle===
false
) {
echo
"can't read image"
;
exit
( 1 ) ;
}
while
( !feof(\$handle) ) {
echo
fread( \$handle, 8192 ) ;
}
fclose( \$handle ) ;
?>
EOF
# _ _ _
# _ __ ___| |__ ___ ___ | |_ _ __ | |__ _ __
# | '__/ _ \ '_ \ / _ \ / _ \| __| | '_ \| '_ \| '_ \
# | | | __/ |_) | (_) | (_) | |_ _| |_) | | | | |_) |
# |_| \___|_.__/ \___/ \___/ \__(_) .__/|_| |_| .__/
# |_| |_|
echo
"> reboot.php"
cat
<< EOF >
/var/www/html/reboot
.php
<?php
echo
shell_exec(
"sudo /usr/local/bin/enable_eeprom_sdboot.sh 2>&1; screen -S disable_eeprom_sdboot -dm sh -c \"sleep 240; sudo /usr/local/bin/disable_eeprom_sdboot.sh\""
) ;
?>
EOF
echo
"> dissecting image file"
offset=`
fdisk
-l $1 |
grep
Linux |
tr
-s
' '
|
tr
'\t'
' '
|
cut
-d
' '
-f2`
real_linux_offset=`
echo
"$offset*512"
|
bc
`
offset=`
fdisk
-l $1 |
grep
W95 |
tr
-s
' '
|
tr
'\t'
' '
|
cut
-d
' '
-f2`
real_boot_offset=`
echo
"$offset*512"
|
bc
`
mkdir
/tmp/boot
2>
/dev/null
mount
-o loop,offset=$real_boot_offset $1
/tmp/boot
rm
-rf
/srv/tftp/
*
cp
-rpf
/tmp/boot/
*
/srv/tftp/
umount
/tmp/boot
mkdir
/tmp/root
2>
/dev/null
mount
-o loop,offset=$real_linux_offset $1
/tmp/root
mkdir
/srv/nfs
2>
/dev/null
cp
-rpf
/tmp/root/
*
/srv/nfs/
echo
"> getting sdboot eeprom ready"
cat
<< EOF >
/tmp/eeprom_config
.sdboot
[all]
DHCP_TIMEOUT=45000
DHCP_REQ_TIMEOUT=4000
TFTP_FILE_TIMEOUT=30000
TFTP_IP=${myip}
TFTP_PREFIX=1
TFTP_PREFIX_STR=
BOOT_ORDER=0xf21
ENABLE_SELF_UPDATE=1
SD_BOOT_MAX_RETRIES=3
NET_BOOT_MAX_RETRIES=2
EOF
latest_eeprom=`
ls
-1
/tmp/root/lib/firmware/raspberrypi/bootloader/stable/pieeprom-
*.bin |
sort
-u |
tail
-1`
cp
$latest_eeprom
/tmp/pieeprom
.bin
rpi-eeprom-config --out
/tmp/pieeprom-out
.bin --config
/tmp/eeprom_config
.sdboot
/tmp/pieeprom
.bin && rpi-eeprom-update -d -f
/tmp/pieeprom-out
.bin
mv
/boot/pieeprom
.sig
/srv/tftp/pieeprom
.sig.inert
mv
/boot/pieeprom
.upd
/srv/tftp/pieeprom
.upd.inert
mv
/boot/recovery
.bin
/srv/tftp/recovery
.bin.inert
cat
<< EOF >
/usr/local/bin/enable_eeprom_sdboot
.sh
sed
-i
"s/ts:.*/ts: \`date +%s\`/g"
/srv/tftp/pieeprom
.sig.inert
mv
/srv/tftp/pieeprom
.sig.inert
/srv/tftp/pieeprom
.sig
mv
/srv/tftp/pieeprom
.upd.inert
/srv/tftp/pieeprom
.upd
mv
/srv/tftp/recovery
.bin.inert
/srv/tftp/recovery
.bin
EOF
chmod
755
/usr/local/bin/enable_eeprom_sdboot
.sh
cat
<< EOF >
/usr/local/bin/disable_eeprom_sdboot
.sh
mv
/srv/tftp/pieeprom
.sig
/srv/tftp/pieeprom
.sig.inert
mv
/srv/tftp/pieeprom
.upd
/srv/tftp/pieeprom
.upd.inert
mv
/srv/tftp/recovery
.bin
/srv/tftp/recovery
.bin.inert
EOF
chmod
755
/usr/local/bin/disable_eeprom_sdboot
.sh
cat
<< EOF >
/etc/sudoers
.d
/010_www-data_eeprom
www-data ALL=(ALL) NOPASSWD:
/usr/local/bin/enable_eeprom_sdboot
.sh
www-data ALL=(ALL) NOPASSWD:
/usr/local/bin/disable_eeprom_sdboot
.sh
EOF
umount
/tmp/root
echo
"/srv/nfs $mynetwork(rw,sync,no_subtree_check,no_root_squash)"
>
/etc/exports
exportfs -rav
echo
"console=serial0,115200 console=tty1 root=/dev/nfs nfsroot="
${myip}
":/srv/nfs,nfsvers=3 ip=dhcp rw elevator=deadline fsck.repair=yes rootwait"
>
/srv/tftp/cmdline
.txt
echo
"proc /proc proc defaults 0 0"
>
/srv/nfs/etc/fstab
echo
"> deploying image"
if
[ ! -f
/var/img
];
then
cp
-f $1
/var/img
else
cp
-f $1
/var/img
.new
if
[
"`lsof | grep "
/var/img
" | wc -l`"
-
ne
0 ];
then
echo
"> waiting for file handle release on /var/img"
while
[
"`lsof | grep "
/var/img
" | wc -l`"
-
ne
0 ];
do
echo
-n
"."
sleep
1
done
echo
""
fi
mv
/var/img
/var/img
.old
mv
/var/img
.new
/var/img
rm
/var/img
.old
fi
echo
"> adding web bootstrap"
echo
'#!/bin/sh -e'
>
/srv/nfs/etc/rc
.
local
echo
"echo \"> disabling cron\""
>>
/srv/nfs/etc/rc
.
local
# I've seen this command hang several times before
echo
"/usr/bin/timeout --kill-after=10s 5s /usr/sbin/service cron stop || :"
>>
/srv/nfs/etc/rc
.
local
echo
"echo \"> sleeping until we have connectivity\""
>>
/srv/nfs/etc/rc
.
local
echo
"bash -c 'count=0; res=1; while [ \$res -ne 0 -a \$count -lt 120 ]; do echo "
.
"; wget -q --spider http://${myip}; res=\$?; sleep 1; count=\$((count+1)); done'"
>>
/srv/nfs/etc/rc
.
local
echo
"echo \"> retrieving imaging image\""
>>
/srv/nfs/etc/rc
.
local
echo
"curl -s http://${myip}/img.php --output /tmp/img"
>>
/srv/nfs/etc/rc
.
local
echo
"echo \"> launching bootstraping process\""
>>
/srv/nfs/etc/rc
.
local
echo
"curl -s http://${myip}/bootstrap.sh --output /tmp/bootstrap.sh"
>>
/srv/nfs/etc/rc
.
local
echo
"bash /tmp/bootstrap.sh"
>>
/srv/nfs/etc/rc
.
local
echo
"> all done"
Make is a program that may be run with:
1 | chmod 755 /home/pi/script .sh |
Download a RaspiOS image, the one you wish to deliver to the Pi, and save it to /home/pi on the server.
Use the RaspiOS image as the parameter in the script:
1 | sudo /home/pi/script .sh /home/pi/2022-01-28-raspios-bullseye-armhf-lite .img |
Depending on the speed of your SD card, this may take some time. The IP address of our NetBoot Server is 192.168.1.116.
Reimaging the Pi by sending it to the image
Once your server is up and running, you may tell the Pi to reimage itself by telling it to network boot from 192.168.1.116. The following script will change the EEPROM to reflect this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | #!/bin/bash cat << EOF > /tmp/eeprom_config .netboot [all] DHCP_TIMEOUT=45000 DHCP_REQ_TIMEOUT=4000 TFTP_FILE_TIMEOUT=30000 TFTP_IP=192.168.1.116 TFTP_PREFIX=1 TFTP_PREFIX_STR= BOOT_ORDER=0xf12 ENABLE_SELF_UPDATE=1 SD_BOOT_MAX_RETRIES=3 NET_BOOT_MAX_RETRIES=2 EOF /usr/bin/rpi-eeprom-config --apply /tmp/eeprom_config .netboot sleep 1 /usr/sbin/reboot |
Reimaging the Pi by sending it to the image
Once your server is up and running, you may tell the Pi to reimage itself by telling it to network boot from 192.168.1.116. The following script will change the EEPROM to reflect this:
A RaspiOS image is grabbed and served over NFS by a script on the NetBoot Server. In addition, it hosts a TFTP server that connects to the NFS share. You could operate your Pis totally off the network and ignore the SD card, but I think it's more practical to simply burn the image locally on the Pi's SD card and run it. In /etc/rc.local, the image includes instructions for obtaining the original RaspiOS image through HTTP and burning it on the SD card. It reboots after being burned, but not before notifying the NetBoot Server that it is doing so. This is because the NetBoot Server will have to catch that Pi restarting and ask it to return to booting from its newly burned SD card for a little period of time.
While the Pi is reimaging, you may see issues; this is because we're providing a whole OS via NFS, and parts of its filesystems are read-only. We just need enough kernel to perform wget, dd, and reboot, so that's ok. A better solution could be to serve a more purposed OS over NFS, but in this instance I like providing the OS we're imaging, not only because it's more convenient, but also because it provides me access to the important EEPROM binaries.
Source: Ben Akrin