├── overlay ├── etc │ ├── .default_boot_services │ ├── runlevels │ │ └── default │ │ │ └── headless_bootstrap │ └── init.d │ │ └── headless_bootstrap └── tmp │ └── .ALHB │ ├── ssh_host_ed25519_key.pub │ ├── ssh_host_ed25519_key │ ├── ssh_host_rsa_key.pub │ ├── ssh_host_rsa_key │ └── headless_bootstrap ├── .gitmodules ├── headless.apkovl.tar.gz ├── .github └── FUNDING.yml ├── headless.apkovl.tar.gz.sha512 ├── LICENSE.spdx ├── sample_wpa_supplicant.conf ├── sample_auto-updt ├── sample_interfaces ├── LICENSE ├── make_ALHB.sh ├── sample_unattended.sh └── README.md /overlay/etc/.default_boot_services: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /overlay/etc/runlevels/default/headless_bootstrap: -------------------------------------------------------------------------------- 1 | ../../init.d/headless_bootstrap -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "xg_multi"] 2 | path = xg_multi 3 | url = ../xg_multi 4 | branch = main 5 | -------------------------------------------------------------------------------- /headless.apkovl.tar.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/macmpi/alpine-linux-headless-bootstrap/HEAD/headless.apkovl.tar.gz -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | ko_fi: macmpi 4 | open_collective: alpinelinux 5 | -------------------------------------------------------------------------------- /overlay/tmp/.ALHB/ssh_host_ed25519_key.pub: -------------------------------------------------------------------------------- 1 | ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFDkOG8bPwDdg9UfbXxEJh2+zPpisCsPFtF0z5CiD5x4 root@alpine-headless 2 | -------------------------------------------------------------------------------- /headless.apkovl.tar.gz.sha512: -------------------------------------------------------------------------------- 1 | 9660a18afecfeea5c955bff9bbebb5c711076938457fcec0d3e6dd1b4e0598137e77aa6809911711bfb9d7c1e5e4d008d1fd0313bfb9f279f413e3a177873501 headless.apkovl.tar.gz 2 | -------------------------------------------------------------------------------- /LICENSE.spdx: -------------------------------------------------------------------------------- 1 | SPDXVersion: SPDX-2.1 2 | DataLicense: CC0-1.0 3 | PackageName: alpine-linux-headless-bootstrap 4 | PackageOriginator: macmpi 5 | PackageHomePage: https://github.com/macmpi/alpine-linux-headless-bootstrap 6 | PackageLicenseDeclared: MIT 7 | 8 | -------------------------------------------------------------------------------- /sample_wpa_supplicant.conf: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright 2022-2025, macmpi 2 | # SPDX-License-Identifier: MIT 3 | 4 | # Sample wpa_supplicant.conf 5 | country=FR 6 | 7 | network={ 8 | key_mgmt=WPA-PSK 9 | ssid="mySSID" 10 | psk="myPassPhrase" 11 | } 12 | -------------------------------------------------------------------------------- /sample_auto-updt: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright 2022-2025, macmpi 2 | # SPDX-License-Identifier: MIT 3 | 4 | # Automated reboot after update is cancelled if: 5 | # - there is an opened ssh session 6 | # - unattended.sh script is provided 7 | 8 | # Uncomment line below to enable reboot after update 9 | #reboot 10 | 11 | -------------------------------------------------------------------------------- /overlay/etc/init.d/headless_bootstrap: -------------------------------------------------------------------------------- 1 | #!/sbin/openrc-run 2 | 3 | # SPDX-FileCopyrightText: Copyright 2022-2025, macmpi 4 | # SPDX-License-Identifier: MIT 5 | 6 | description="Headless main boostrappring script" 7 | name="Headless bootstrap" 8 | 9 | command="/tmp/.ALHB/headless_bootstrap" 10 | command_background=true 11 | pidfile="/run/${RC_SVCNAME}.pid" 12 | 13 | -------------------------------------------------------------------------------- /sample_interfaces: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: Copyright 2022-2025, macmpi 2 | # SPDX-License-Identifier: MIT 3 | 4 | # Sample network interfaces file 5 | 6 | auto lo 7 | iface lo inet loopback 8 | 9 | auto eth0 10 | iface eth0 inet dhcp 11 | 12 | auto wlan0 13 | iface wlan0 inet dhcp 14 | 15 | auto usb0 16 | iface usb0 inet static 17 | address 10.42.0.2/24 18 | gateway 10.42.0.1 19 | 20 | -------------------------------------------------------------------------------- /overlay/tmp/.ALHB/ssh_host_ed25519_key: -------------------------------------------------------------------------------- 1 | -----BEGIN OPENSSH PRIVATE KEY----- 2 | b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW 3 | QyNTUxOQAAACBQ5DhvGz8A3YPVH218RCYdvsz6YrArDxbRdM+Qog+ceAAAAJiMSpNejEqT 4 | XgAAAAtzc2gtZWQyNTUxOQAAACBQ5DhvGz8A3YPVH218RCYdvsz6YrArDxbRdM+Qog+ceA 5 | AAAECmulBcMfFgxjUrIiuPnOjEkwNcHp9+NOtggzitv4d4F1DkOG8bPwDdg9UfbXxEJh2+ 6 | zPpisCsPFtF0z5CiD5x4AAAAFHJvb3RAYWxwaW5lLWhlYWRsZXNzAQ== 7 | -----END OPENSSH PRIVATE KEY----- 8 | -------------------------------------------------------------------------------- /overlay/tmp/.ALHB/ssh_host_rsa_key.pub: -------------------------------------------------------------------------------- 1 | ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDBSPBqlg0Lg43IXL5id3DfyFvwtm3kqSnEYBI0LHhiuTvV0j4R5+QVlYghSgYPkSfTJuSCMZkjc7C9BHV/CdJ9MhbFIOgOJhkRkxnSCiRcu2C2u/M6csD+VH7N9xj7LWtxVvu6UsyV81w4dLRIuKAAJjIcySQHXOCC77dvrrGWCEXKVJPv00jOjO2KOCwK8xuTphn2W4QRz0Fh+GL5jDhReqG2TtInFJ1ih0jmE/JHq5BcdsNvgu/E3guKb1I2SOQbO1KjIOXreKvAKdqn4/eBRJvv6HsCk/hmiodKVqGfpkuBSNNOU9RHHVg8+251iVcNojrayuVlt+Hmp1sDJcEyiEyvdt3z9aETzRbaTV3BTGCenGMwzUW3yelbb2zSKMz7ZeedvdTobIlWsgVfF+fPt0O61BJ0cFT7QBOb95xREvE8JcJm7hoeQK+s5qxF+lCbxegIKiC5D8JDbmSjWkq/f3cvmW80YjZPrxk00xZowlYKkVhu3bEt8HbLLyjsT3s= root@alpine-headless 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022-2025, macmpi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /make_ALHB.sh: -------------------------------------------------------------------------------- 1 | #!/bin/busybox sh 2 | 3 | # SPDX-FileCopyrightText: Copyright 2022-2025, macmpi 4 | # SPDX-License-Identifier: MIT 5 | 6 | # Script meant to be run on Alpine (busybox) or on Ubuntu. 7 | # Check busybox version & options if eventually using other platforms. 8 | 9 | # Ubuntu LTS busybox 1.30.1 tar does NOT support setting owner/group/mtime 10 | # probably available after busybox 1.31.1, following 2019-08-01 change: 11 | # https://git.busybox.net/busybox/commit/?id=e6a87e74837ba5f2f2207a75cd825acf8cf28afb 12 | # This limitation requires copying files and setting owner/group/mtime before archiving. 13 | 14 | command -v doas > /dev/null || alias doas="/usr/bin/sudo" 15 | 16 | build_path="$(mktemp -d)" 17 | if [ -n "$build_path" ]; then 18 | # prefer timestamp option for touch as it works on directories too 19 | t_stamp="$( TZ=UTC date +%Y%m%d0000.00 )" 20 | cp -a overlay "$build_path"/. 21 | cp -a LICENSE "$build_path"/overlay/tmp/ALHB_LICENSE 22 | cp -a xg_multi/xg_multi "$build_path"/overlay/tmp/.ALHB/. 23 | find "$build_path"/overlay/ -exec sh -c 'TZ=UTC touch -chm -t "$0" "$1"' "$t_stamp" {} \; 24 | # setting modes and owner/groups for runtime (won't affect mtime) 25 | find "$build_path"/overlay/etc -type d -exec chmod 755 {} \; 26 | chmod 755 "$build_path"/overlay/etc/init.d/* 27 | chmod 755 "$build_path"/overlay/etc/runlevels/default/* 28 | chmod 777 "$build_path"/overlay/tmp 29 | chmod 644 "$build_path"/overlay/tmp/ALHB_LICENSE 30 | chmod 700 "$build_path"/overlay/tmp/.ALHB 31 | chmod 755 "$build_path"/overlay/tmp/.ALHB/* 32 | chmod 600 "$build_path"/overlay/tmp/.ALHB/ssh_host_*_key 33 | chmod 644 "$build_path"/overlay/tmp/.ALHB/ssh_host_*_key.pub 34 | doas chown -Rh 0:0 "$build_path"/overlay/* 35 | 36 | # busybox config on Alpine & Ubuntu has FEATURE_TAR_GNU_EXTENSIONS 37 | # (will preserve user/group/modes & mtime) and FEATURE_TAR_LONG_OPTIONS 38 | # shellcheck disable=SC2046 # we want word splitting as result of find 39 | doas tar cv -C "$build_path"/overlay --no-recursion \ 40 | $(doas find "$build_path"/overlay/ | sed "s|$build_path/overlay/||" | sort | xargs ) | \ 41 | gzip -c9n > headless.apkovl.tar.gz 42 | sha512sum headless.apkovl.tar.gz > headless.apkovl.tar.gz.sha512 43 | TZ=UTC touch -cm -t "$t_stamp" headless.apkovl.tar.gz* 44 | doas rm -rf "$build_path" 45 | fi 46 | 47 | -------------------------------------------------------------------------------- /overlay/tmp/.ALHB/ssh_host_rsa_key: -------------------------------------------------------------------------------- 1 | -----BEGIN OPENSSH PRIVATE KEY----- 2 | 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 38 | -----END OPENSSH PRIVATE KEY----- 39 | -------------------------------------------------------------------------------- /sample_unattended.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # SPDX-FileCopyrightText: Copyright 2022-2025, macmpi 4 | # SPDX-License-Identifier: MIT 5 | 6 | ## collection of few code snippets as sample unnatteded actions some may find usefull 7 | 8 | ## will run encapusated within headless_unattended OpenRC service 9 | 10 | # To prevent headless bootstrap script from starting sshd 11 | # only keep a single starting # on the line below 12 | ##NO_SSH 13 | 14 | # Uncomment to enable stdout and errors redirection to console (service won't show messages) 15 | # exec 1>/dev/console 2>&1 16 | 17 | # shellcheck disable=SC2142 # known special case 18 | alias _logger='logger -st "${0##*/}"' 19 | 20 | ## Obvious one; reminder: is run as background service 21 | _logger "hello world !!" 22 | sleep 60 23 | _logger "Finished script" 24 | ######################################################## 25 | 26 | 27 | ## This snippet removes apkovl file on volume after initial boot 28 | # grab used ovl filename from dmesg 29 | ovl="$( dmesg | grep -o 'Loading user settings from .*:' | awk '{print $5}' | sed 's/:.*$//' )" 30 | if [ -f "${ovl}" ]; then 31 | ovlpath="$( dirname "$ovl" )" 32 | else 33 | # search path again as mountpoint have been changed later in the boot process... 34 | ovl="$( basename "${ovl}" )" 35 | ovlpath=$( find /media -maxdepth 2 -type d -path '*/.*' -prune -o -type f -name "${ovl}" -exec dirname {} \; | head -1 ) 36 | ovl="${ovlpath}/${ovl}" 37 | fi 38 | 39 | # also works in case volume is mounted read-only 40 | grep -q "${ovlpath}.*[[:space:]]ro[[:space:],]" /proc/mounts; is_ro=$? 41 | _is_ro() { return "$is_ro"; } 42 | _is_ro && mount -o remount,rw "${ovlpath}" 43 | rm -f "${ovl}" 44 | _is_ro && mount -o remount,ro "${ovlpath}" 45 | 46 | ######################################################## 47 | 48 | 49 | ## This snippet configures Minimal diskless environment 50 | # note: with INTERFACESOPTS=none, no networking will be setup so it won't work after reboot! 51 | # Change it or run setup-interfaces in interractive mode afterwards (and lbu commit -d thenafter) 52 | 53 | _logger "Setting-up minimal environment" 54 | 55 | cat <<-EOF > /tmp/ANSWERFILE 56 | # base answer file for setup-alpine script 57 | 58 | # Do not set keyboard layout 59 | KEYMAPOPTS=none 60 | 61 | # Keep hostname 62 | HOSTNAMEOPTS="$(hostname)" 63 | 64 | # Set device manager to mdev 65 | DEVDOPTS=mdev 66 | 67 | # Contents of /etc/network/interfaces 68 | INTERFACESOPTS=none 69 | 70 | # Set Public nameserver 71 | DNSOPTS="-n 9.9.9.9" 72 | 73 | # Set timezone to UTC 74 | TIMEZONEOPTS="UTC" 75 | 76 | # set http/ftp proxy 77 | PROXYOPTS=none 78 | 79 | # Add first mirror (CDN) 80 | APKREPOSOPTS="-1" 81 | 82 | # Do not create any user 83 | USEROPTS=none 84 | 85 | # No Openssh 86 | SSHDOPTS=none 87 | 88 | # Use openntpd 89 | NTPOPTS="chrony" 90 | 91 | # No disk install (diskless) 92 | DISKOPTS=none 93 | 94 | # Setup storage for diskless (find boot directory in /media/xxxx/apk/.boot_repository) 95 | LBUOPTS="$( find /media -maxdepth 3 -type d -path '*/.*' -prune -o -type f -name '.boot_repository' -exec dirname {} \; | head -1 | xargs dirname )" 96 | APKCACHEOPTS="\$LBUOPTS/cache" 97 | 98 | EOF 99 | 100 | # trick setup-alpine to pretend existing SSH connection 101 | # and therefore keep (do not reset) network interfaces while running in background 102 | # requires alpine-conf 3.15.1 and later, available from Alpine 3.17 103 | SSH_CONNECTION="FAKE" setup-alpine -ef /tmp/ANSWERFILE 104 | lbu commit -d 105 | 106 | ######################################################## 107 | 108 | 109 | _logger "Finished unattended script" 110 | 111 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Bootstrap Alpine Linux on a headless system 2 | 3 | [Alpine Linux documentation](https://docs.alpinelinux.org/user-handbook/0.1a/Installing/setup_alpine.html) assumes **initial setup** is carried-out on a system with a keyboard & display.\ 4 | However, in many cases one might want to deploy a headless system that is only available through a network connection (ethernet, wifi or as USB ethernet gadget). 5 | 6 | This repo provides an **overlay file** to initially bootstrap[^1] such headless system (leveraging Alpine distro's `initramfs` feature): it starts a ssh server to log-into from another Computer, so that actual install on fresh system (or rescue on existing disk-based system[^2]) can then be performed remotely.\ 7 | An optional script may also be launched during that same initial bootstrap, to perform fully automated setup. 8 | 9 | 10 | ## Setup procedure: 11 | Please follow [Alpine Linux Wiki](https://wiki.alpinelinux.org/wiki/Installation#Installation_Overview) to download & create installation media for the target platform.\ 12 | Tools provided here can be used on any hardware platform to prepare for any install modes (diskless, data disk, system disk). 13 | 14 | Just add [**headless.apkovl.tar.gz**](https://is.gd/apkovl_master) overlay file *as-is* at the root of Alpine Linux boot media (or onto any custom side-media) and boot-up the system.\ 15 | With default DCHP-based network interface definitions (and [SSID/pass](#extra-configuration) file if using wifi), system can then be remotely accessed with: `ssh root@`\ 16 | (system IP address may be determined with any IP scanning tools such as `nmap`). 17 | 18 | As with Alpine Linux initial bring-up, `root` account has no password initially.\ 19 | From there, actual system install can be performed as usual with `setup-alpine` for instance (check Alpine [wiki](https://wiki.alpinelinux.org/wiki/Alpine_setup_scripts#setup-alpine) for details). 20 | 21 | ## Extra configuration: 22 | Extra files may be added next to `headless.apkovl.tar.gz` to customise boostrapping configuration (check `sample_*` files): 23 | - `wpa_supplicant.conf`[^3] (*mandatory for wifi*): define wifi SSID, password and regulatory country [code](https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2). 24 | - `unattended.sh`[^3] (*optional*): provide a deployment script to automate setup & customizations during initial bootstrap *(check users' contributed [samples](https://github.com/macmpi/alpine-linux-headless-bootstrap/discussions/categories/unattended-sh-samples) and share yours)*. 25 | - `interfaces`[^3] (*optional*): define network interfaces at will, if defaults DCHP-based are not suitable. 26 | - `authorized_keys` (*optional*): provide client's public SSH key to secure `root` ssh login. 27 | - `ssh_host_*_key*` (*optional*): provide server's custom ssh keys to be injected (may be stored), instead of using temporarily bundled ones[^4] (not stored). Providing an empty key file will trigger new keys generation (ssh server may take longer to start). 28 | - `opt-out` (*optional*): dummy file to opt-out internet features (connection status, version check, auto-update) and related links usage anonymous [telemetry](https://is.gd/privacy.php). 29 | - `auto-updt` (*optional*): enable automatic `headless.apkovl.tar.gz` file update with latest from master branch. If it contains `reboot` keyword all in one line, system will reboot after succesful update (unless ssh session is active or `unattended.sh` script is available). 30 | 31 | Main execution steps are logged: `cat /var/log/messages | grep headless`. 32 | 33 | ## Seamless USB-gadget mode: 34 | Devices with UDC controller (*e.g., PiZero*) can expose the following features over USB port: serial console, ethernet interface and mass-storage 35 | 36 | To enable them, make sure `dwc2` (or `dwc3`) driver is **previously loaded** on capable device, **and** configuration is set to **OTG peripheral** mode: depending on devices, this may be driven by hardware (including cable) and/or software.\ 37 | (e.g., on supporting Pi devices[^5], just add `dtoverlay=dwc2,dr_mode=peripheral` in `usercfg.txt` to force both by software) 38 | 39 | Plug USB cable into host Computer port before booting device. 40 | - serial terminal can then be connected-to from host Computer (e.g. `cu -l ttyACM0` on Linux. *115200 baud, xon/xoff flow control*). 41 | - alternatively, with host Computer ECM/RNDIS interface set-up as `10.42.0.1` (sharing internet or not), one can log into device from host with: `ssh root@10.42.0.2`. 42 | - volume containing `headless.apkovl.tar.gz` file may be accessed/mounted from host, and config files easily edited. Make sure to unmount properly from host before shutting-down device and removing USB plug. 43 | 44 | _Note:_ optionally, same USB-gadget feature may be easily enabled on final system by installing `xg_multi` Alpine [package](https://pkgs.alpinelinux.org/packages?name=xg_multi&branch=edge&repo=&arch=&origin=&flagged=&maintainer=) and service during system setup phase (refer to [`xg_multi`](https://github.com/macmpi/xg_multi/) project for details). 45 | 46 | ## 47 | [![ko-fi](https://www.ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/macmpi) 48 | 49 | ## Want to tweak more ? 50 | This repository may be forked/cloned/downloaded.\ 51 | Main script file is [`headless_bootstrap`](https://github.com/macmpi/alpine-linux-headless-bootstrap/tree/main/overlay/tmp/.ALHB/headless_bootstrap).\ 52 | Execute `./make_ALHB.sh` to rebuild `headless.apkovl.tar.gz` after changes.\ 53 | (requires `busybox`; check `busybox` build options if not running from Alpine or Ubuntu) 54 | 55 | ## Credits 56 | Thanks for the initial guides & scripts from @sodface and @davidmytton. 57 | 58 | [^1]: Initial boot fully preserves system's original state (config files & installed packages): a fresh system will therefore come-up as unconfigured. 59 | [^2]: Temporarily remove `root=*` statement from kernel command-line parameters list to disable disk-based boot mode. 60 | [^3]: These files are linux text files: Windows/macOS users need to use text editors supporting linux text line-ending (such as [notepad++](https://notepad-plus-plus.org/), BBEdit or any similar). 61 | [^4]: About temporarily bundled ssh keys: this overlay is meant to **quickly bootstrap** system in order to then proceed with proper install; therefore it purposely embeds [some ssh keys](https://github.com/macmpi/alpine-linux-headless-bootstrap/tree/main/overlay/tmp/.ALHB) so that bootstrapping is as fast as possible. Those temporary keys are in RAM `/tmp`: they **are discarded** once actual system install is rebooted (whether or not ssh server is installed in final setup). 62 | [^5]: OTG capable Pi devices include Zero serie/A/A+/3A+/4B/400/5/500/Compute-Modules 63 | -------------------------------------------------------------------------------- /overlay/tmp/.ALHB/headless_bootstrap: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # SPDX-FileCopyrightText: Copyright 2022-2025, macmpi 4 | # SPDX-License-Identifier: MIT 5 | 6 | ALHB_VERSION="1.6" 7 | 8 | _apk() { 9 | local cmd="$1" 10 | local pkg="$2" 11 | 12 | case $cmd in 13 | add) # install only if not already present 14 | if ! apk info | grep -wq "${pkg}"; then 15 | apk add "$pkg" && printf '%s ' "${pkg}" >>/tmp/.ALHB/installed 16 | fi 17 | ;; 18 | del) # delete only if previously installed 19 | if grep -wq "$pkg" /tmp/.ALHB/installed >/dev/null 2>&1; then 20 | apk del "$pkg" && sed -i 's/\b'"${pkg}"'\b//' /tmp/.ALHB/installed 21 | fi 22 | ;; 23 | *) 24 | echo "only add/del: wrong usage"; exit 25 | ;; 26 | esac 27 | } 28 | 29 | _preserve() { 30 | # Create a back-up of element (file, folder, symlink). 31 | [ -z "${1}" ] && return 1 32 | [ -e "${1}" ] && cp -a "${1}" "${1}".orig 33 | } 34 | 35 | _restore() { 36 | # Remove element (file, folder, symlink) and replace by 37 | # previous back-up if available. 38 | # shellcheck disable=SC2317 # known special case 39 | [ -z "${1}" ] && return 1 40 | # shellcheck disable=SC2317 # known special case 41 | rm -rf "${1}" 42 | # shellcheck disable=SC2317 # known special case 43 | [ -e "${1}".orig ] && mv -f "${1}".orig "${1}" 44 | } 45 | 46 | # shellcheck disable=SC2142 # known special case 47 | alias _logger='logger -st "${0##*/}"' 48 | 49 | ##### End of part to be duplicated into headless_cleanup (do not alter!) 50 | 51 | _prep_cleanup() { 52 | ## Prep for final headless_cleanup: 53 | # clears any installed packages and settings. 54 | # Copy begininng of this file to keep functions. 55 | sed -n '/^#* End .*alter!)$/q;p' /tmp/.ALHB/headless_bootstrap >/tmp/.ALHB/headless_cleanup 56 | cat <<-EOF >>/tmp/.ALHB/headless_cleanup 57 | # Redirect stdout and errors to console as service won't show messages 58 | exec 1>/dev/console 2>&1 59 | 60 | _logger "Cleaning-up..." 61 | _restore "/etc/ssh/sshd_config" 62 | _restore "/etc/conf.d/sshd" 63 | _apk del openssh-server 64 | _restore "/etc/wpa_supplicant/wpa_supplicant.conf" 65 | _apk del wpa_supplicant 66 | _restore "/etc/network/interfaces" 67 | _restore "/etc/hostname" 68 | 69 | # Remove from boot service to avoid spurious openrc recalls from unattended script. 70 | rm -f /etc/runlevels/default/headless_bootstrap 71 | 72 | # Run unattended script if available. 73 | if [ -f /tmp/headless_unattended ]; then 74 | _logger "Starting headless_unattended service" 75 | rc-service headless_unattended start 76 | fi 77 | rm -f /etc/init.d/headless_* 78 | _logger "Clean-up done, enjoy !" 79 | cat /tmp/.ALHB/banner >/dev/console 80 | if [ -c /dev/ttyGS0 ]; then 81 | # Enabling terminal login into valid serial port: 82 | # no choice than making permanent change to /etc/securetty (Alpine 3.19 already has ttyGS0). 83 | grep -q "ttyGS0" /etc/securetty || echo "ttyGS0" >>/etc/securetty 84 | /sbin/getty -L 115200 /dev/ttyGS0 vt100 & 85 | fi 86 | exit 0 87 | EOF 88 | chmod +x /tmp/.ALHB/headless_cleanup 89 | cat <<-EOF >/etc/init.d/headless_cleanup 90 | #!/sbin/openrc-run 91 | 92 | # SPDX-FileCopyrightText: Copyright 2022-2025, macmpi 93 | # SPDX-License-Identifier: MIT 94 | 95 | description="Headless cleanup script" 96 | name="Headless cleanup" 97 | 98 | command="/tmp/.ALHB/headless_cleanup" 99 | command_background=true 100 | pidfile="/run/headless_cleanup.pid" 101 | EOF 102 | chmod +x /etc/init.d/headless_cleanup 103 | 104 | if install -m755 ${ovlpath}/unattended.sh /tmp/headless_unattended >/dev/null 2>&1; then 105 | cat <<-EOF >/etc/init.d/headless_unattended 106 | #!/sbin/openrc-run 107 | 108 | # SPDX-FileCopyrightText: Copyright 2022-2025, macmpi 109 | # SPDX-License-Identifier: MIT 110 | 111 | description="Headless unattended setup script (optional)" 112 | name="Headless unattended" 113 | 114 | command="/tmp/headless_unattended" 115 | command_background=true 116 | pidfile="/run/headless_unattended.pid" 117 | EOF 118 | chmod +x /etc/init.d/headless_unattended 119 | fi 120 | 121 | # force service dependency tree update 122 | rc-update --update 123 | } 124 | 125 | _setup_sshd() { 126 | ## Setup temporary SSH server (root login, no password): 127 | # we use some bundled (or optionaly provided) keys to avoid generation at startup and save time. 128 | _apk add openssh-server 129 | # Preserve sshd-session & al binaries before uninstall 130 | [ -d /usr/lib/ssh ] && cp -a /usr/lib/ssh /tmp/.ALHB/. 131 | 132 | _preserve "/etc/ssh/sshd_config" 133 | _preserve "/etc/conf.d/sshd" 134 | 135 | cat <<-EOF >/etc/ssh/sshd_config 136 | PermitRootLogin yes 137 | Banner /tmp/.ALHB/banner 138 | EOF 139 | 140 | # Client authorized_keys or no authentication. 141 | if install -m600 "${ovlpath}"/authorized_keys /tmp/.ALHB/authorized_keys >/dev/null 2>&1; then 142 | _logger "Enabling public key SSH authentication..." 143 | cat <<-EOF >>/etc/ssh/sshd_config 144 | AuthenticationMethods publickey 145 | AuthorizedKeysFile /tmp/.ALHB/authorized_keys 146 | # relax strict mode as authorized_keys are inside /tmp 147 | StrictModes no 148 | EOF 149 | else 150 | _logger "No SSH authentication." 151 | cat <<-EOF >>/etc/ssh/sshd_config 152 | AuthenticationMethods none 153 | PermitEmptyPasswords yes 154 | EOF 155 | fi 156 | 157 | # Define sshd-session & al files new location into sshd_config 158 | for f in /tmp/.ALHB/ssh/sshd-*; do 159 | [ -e "$f" ] || continue # protect failing glob 160 | name=$(basename "$f" | cut -c6-) 161 | # shellcheck disable=SC2018,SC2019 162 | initial=$(echo "$name" | cut -c1 | tr 'a-z' 'A-Z') 163 | final=$(echo "$name" | cut -c2-) 164 | echo "Sshd${initial}${final}Path $f" >>/etc/ssh/sshd_config 165 | done 166 | 167 | # Server keys: inject optional custom keys, or generate new (might be stored), 168 | # or use bundeled ones (not stored) 169 | local keygen_stance="sshd_disable_keygen=yes" 170 | if install -m600 "${ovlpath}"/ssh_host_*_key* /etc/ssh/ >/dev/null 2>&1; then 171 | # Check for empty key within injected ones: if found, generate new keys. 172 | if find /etc/ssh/ -maxdepth 1 -type f -name 'ssh_host_*_key*' -empty | grep -q .; then 173 | rm /etc/ssh/ssh_host_*_key* 174 | keygen_stance="" 175 | _logger "Will generate new SSH keys..." 176 | else 177 | chmod 644 /etc/ssh/ssh_host_*_key.pub 178 | _logger "Using injected SSH keys..." 179 | fi 180 | else 181 | _logger "Using bundled ssh keys from RAM..." 182 | cat <<-EOF >>/etc/ssh/sshd_config 183 | HostKey /tmp/.ALHB/ssh_host_ed25519_key 184 | HostKey /tmp/.ALHB/ssh_host_rsa_key 185 | EOF 186 | fi 187 | 188 | echo "$keygen_stance" >>/etc/conf.d/sshd 189 | 190 | rc-service sshd restart 191 | } 192 | 193 | _updt_apkovl() { 194 | ## Update apkovl overlay file & eventually reboot 195 | # URL redirects to apkovl file on github master: is.gd shortener provides basic analytics. 196 | # Analytics are public and can be checked at https://is.gd/stats.php?url=apkovl_master 197 | # Privacy policy: https://is.gd/privacy.php 198 | local updt_status="failed, keeping original version" 199 | local sha_url="https://github.com/macmpi/alpine-linux-headless-bootstrap/raw/main/headless.apkovl.tar.gz.sha512" 200 | local file_url="https://is.gd/apkovl_master" 201 | 202 | # Ensure system date is correct to allow SSL transactions 203 | ntpd -N -p pool.ntp.org -n -q 204 | 205 | _is_ro && mount -o remount,rw "${ovlpath}" 206 | if wget -q -O "${ovl}_new" -T 10 "$file_url" >/dev/null 2>&1 && \ 207 | wget -q -O /tmp/sha -T 10 "$sha_url" >/dev/null 2>&1 && \ 208 | [ "$( sha512sum "${ovl}_new" | awk '{print $1}' )" = "$( awk '{print $1}' /tmp/sha )" ]; then 209 | mv -f "${ovl}_new" "${ovl}" 210 | updt_status="successful" 211 | fi 212 | rm -f "${ovl}_new" /tmp/sha 213 | _is_ro && mount -o remount,ro "${ovlpath}" 214 | _logger "Update $updt_status" 215 | 216 | if [ "$updt_status" = "successful" ]; then 217 | printf '%s\n\n' "Updated (Read release notes!)" >>/tmp/.ALHB/banner 218 | else 219 | printf '\n' >>/tmp/.ALHB/banner 220 | return 1 221 | fi 222 | # Reboot if specified in auto-updt file (and no ssh session ongoing nor unattended.sh script available). 223 | ! pgrep -a -P "$( cat /run/sshd.pid 2>/dev/null )" 2>/dev/null | grep -q "sshd: root@pts" && \ 224 | ! [ -f /tmp/headless_unattended ] && \ 225 | grep -q "^reboot$" "${ovlpath}"/auto-updt && \ 226 | _logger "Will reboot in 3sec..." && sleep 3 && reboot 227 | exit 0 228 | } 229 | 230 | _tst_version() { 231 | ## Compare current version with latest online, notify & eventally calls for update 232 | # URL redirects to github project page: is.gd shortener provides basic analytics. 233 | # Analytics are public and can be checked at https://is.gd/stats.php?url=apkovl_run 234 | # Privacy policy: https://is.gd/privacy.php 235 | local vers="" 236 | local ref="/macmpi/alpine-linux-headless-bootstrap/releases/tag/v" 237 | local url="https://is.gd/apkovl_run" 238 | 239 | if wget -q -O /tmp/homepg -T 10 --no-check-certificate "$url" >/dev/null 2>&1; then 240 | _logger "Internet access: success" 241 | vers="$( grep -o "$ref.*\"" /tmp/homepg | grep -Eo '[0-9]+[\.[0-9]+]*' )" 242 | rm -f /tmp/homepg 243 | if [ -n "$vers" ] && ! [ "$vers" = "$ALHB_VERSION" ]; then 244 | vers="!! Version $vers is available on Github project page !!" 245 | _logger "$vers" 246 | printf '%s\n' "$vers" >>/tmp/.ALHB/banner 247 | # Optionally update apkovl if key-file allows it. 248 | if [ -f "${ovlpath}"/auto-updt ]; then 249 | _logger "Updating overlay file..." 250 | _updt_apkovl & 251 | else 252 | _logger "(check doc to enable auto-update)" 253 | printf '%s\n\n' "(check doc to enable auto-update)" >>/tmp/.ALHB/banner 254 | fi 255 | fi 256 | else 257 | _logger "Internet access: failed" 258 | fi 259 | } 260 | 261 | _setup_networking() { 262 | ## Setup network interfaces. 263 | local has_wifi wlan_lst 264 | _has_wifi() { return "$has_wifi"; } 265 | 266 | # workaround some brcmfmac issues with wpa_supplicant (not Pi only issue) 267 | # https://github.com/RPi-Distro/firmware-nonfree/commit/2788cb549a19bf2e77901c4071ef88c2ad683b7c 268 | if lsmod | grep -q brcmfmac; then 269 | rmmod brcmfmac && modprobe brcmfmac roamoff=1 feature_disable=0x282000 270 | _logger "Applied brcmfmac workaround for wpa_supplicant" 271 | fi 272 | 273 | wlan_lst="$( find /sys/class/net/*/phy80211 -exec \ 274 | sh -c 'printf %s\| "$( basename "$( dirname "$0" )" )"' {} \; 2>/dev/null )" 275 | wlan_lst="${wlan_lst%\|}" 276 | [ -n "$wlan_lst" ] && [ -f "${ovlpath}"/wpa_supplicant.conf ] 277 | has_wifi=$? 278 | 279 | _preserve "/etc/network/interfaces" 280 | if ! install -m644 "${ovlpath}"/interfaces /etc/network/interfaces >/dev/null 2>&1; then 281 | _logger "No interfaces file supplied, building defaults..." 282 | cat <<-EOF >/etc/network/interfaces 283 | auto lo 284 | iface lo inet loopback 285 | 286 | EOF 287 | for dev in /sys/class/net/*; do 288 | [ -e "$dev" ] || continue # protect failing glob 289 | # shellcheck disable=SC2034 # Unused IFINDEX while still sourced from uevent. 290 | local DEVTYPE INTERFACE IFINDEX 291 | DEVTYPE="" 292 | # shellcheck source=/dev/null 293 | . "$dev"/uevent 294 | case ${INTERFACE%%[0-9]*} in 295 | lo) 296 | ;; 297 | eth) 298 | cat <<-EOF >>/etc/network/interfaces 299 | auto $INTERFACE 300 | iface $INTERFACE inet dhcp 301 | 302 | EOF 303 | ;; 304 | *) 305 | # According to below we could rely on DEVTYPE for wlan devices 306 | # https://lists.freedesktop.org/archives/systemd-devel/2014-January/015999.html 307 | # but...some wlan might still be ill-behaved: use wlan_lst 308 | # shellcheck disable=SC3060 # ash does support string replacement. 309 | _has_wifi && ! [ "${wlan_lst/$INTERFACE/}" = "$wlan_lst" ] && \ 310 | cat <<-EOF >>/etc/network/interfaces 311 | auto $INTERFACE 312 | iface $INTERFACE inet dhcp 313 | 314 | EOF 315 | # Ensure considered gadget interface is actually the connected one (may have several). 316 | [ "$DEVTYPE" = "gadget" ] && \ 317 | grep -vwq "0" /sys/class/net/"$INTERFACE"/carrier_up_count && \ 318 | cat <<-EOF1 >>/etc/network/interfaces && cat <<-EOF2 >/etc/resolv.conf 319 | auto $INTERFACE 320 | iface $INTERFACE inet static 321 | address 10.42.0.2/24 322 | gateway 10.42.0.1 323 | 324 | EOF1 325 | nameserver 1.1.1.1 326 | nameserver 1.0.0.1 327 | 328 | EOF2 329 | ;; 330 | esac 331 | done 332 | fi 333 | 334 | echo "###################################" 335 | echo "Using following network interfaces:" 336 | cat /etc/network/interfaces 337 | echo "###################################" 338 | 339 | if _has_wifi && grep -qE "$wlan_lst" /etc/network/interfaces; then 340 | _logger "Configuring wifi..." 341 | _apk add wpa_supplicant 342 | _preserve "/etc/wpa_supplicant/wpa_supplicant.conf" 343 | install -m600 "${ovlpath}"/wpa_supplicant.conf /etc/wpa_supplicant/wpa_supplicant.conf 344 | rc-service wpa_supplicant restart 345 | else 346 | _logger "No wifi interface or SSID/pass file supplied" 347 | fi 348 | 349 | _preserve "/etc/hostname" 350 | echo "alpine-headless" >/etc/hostname 351 | hostname -F /etc/hostname 352 | 353 | rc-service networking restart 354 | } 355 | 356 | 357 | 358 | 359 | ############################################################################# 360 | ## Main 361 | 362 | # Redirect stdout and errors to console as service won't show messages 363 | exec 1>/dev/console 2>&1 364 | _logger "Alpine Linux headless bootstrap v$ALHB_VERSION by macmpi" 365 | 366 | # Determine ovl file location. 367 | # Grab used ovl filename from dmesg. 368 | ovl="$( dmesg | grep -o 'Loading user settings from .*:' | awk '{print $5}' | sed 's/:.*$//' )" 369 | if [ -f "${ovl}" ]; then 370 | ovlpath="$( dirname "$ovl" )" 371 | else 372 | # Search path again as mountpoint have been changed later in the boot process... 373 | ovl="$( basename "${ovl}" )" 374 | ovlpath=$( find /media -maxdepth 2 -type d -path '*/.*' -prune -o -type f -name "${ovl}" -exec dirname {} \; | head -1 ) 375 | ovl="${ovlpath}/${ovl}" 376 | fi 377 | 378 | ## Setup USB gadget ports if any connected 379 | /tmp/.ALHB/xg_multi -V "$( df | grep "$ovlpath" | awk '{print $1}' )" >/dev/null 2>&1 380 | # Setting console to connected serial port when available 381 | if [ -c /dev/ttyGS0 ]; then 382 | # Default serial config: xon/xoff flow control. 383 | stty -F /dev/ttyGS0 384 | setconsole /dev/ttyGS0 385 | fi 386 | 387 | _prep_cleanup 388 | 389 | # Help randomness for wpa_supplicant and sshd (urandom until 3.16). 390 | rc-service seedrng restart || rc-service urandom restart 391 | 392 | # Detect apkovl volume ro/rw state 393 | grep -q "${ovlpath}.*[[:space:]]ro[[:space:],]" /proc/mounts; is_ro=$? 394 | _is_ro() { return "$is_ro"; } 395 | 396 | # Create banner file. 397 | warn="" 398 | _is_ro && warn="(remount partition rw!)" 399 | cat <<-EOF >/tmp/.ALHB/banner 400 | 401 | Alpine Linux headless bootstrap v$ALHB_VERSION by macmpi 402 | 403 | You may want to delete/rename .apkovl file before reboot ${warn}: 404 | ${ovl} 405 | (can be done automatically with unattended script - see sample snippet) 406 | 407 | 408 | EOF 409 | 410 | _setup_networking 411 | 412 | # Test latest available version online. 413 | # Can be skipped by creating a 'opt-out'-named dummy file aside apkovl file. 414 | [ -f "${ovlpath}"/opt-out ] || _tst_version & 415 | 416 | # Setup sshd unless unattended.sh script prevents it. 417 | grep -q "^#NO_SSH$" /tmp/headless_unattended >/dev/null 2>&1 \ 418 | || _setup_sshd 419 | 420 | _logger "Initial setup done, handing-over to clean-up" 421 | rc-service headless_cleanup start 422 | exit 0 423 | 424 | --------------------------------------------------------------------------------