Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,27 @@ cd bootimus
docker-compose up -d
```

### Archlinux aur packages

[![bootimus](https://img.shields.io/aur/version/bootimus?label=bootimus)](https://aur.archlinux.org/packages/bootimus/)
[![bootimus-bin](https://img.shields.io/aur/version/bootimus-bin?label=bootimus-bin)](https://aur.archlinux.org/packages/bootimus-bin/)

bootimus is available on the [AUR](https://wiki.archlinux.org/index.php/Arch_User_Repository):
- [bootimus](https://aur.archlinux.org/packages/bootimus/) (release package with systemd integration)
- [bootimus-bin](https://aur.archlinux.org/packages/bootimus-bin/) (standalone binary release package)

You can install it using your [AUR helper](https://wiki.archlinux.org/index.php/AUR_helpers) of choice.

Example:
```shell
$ yay -Sy bootimus

# Edit /etc/bootimus/bootimus.yaml with your preference

# And start the service
$ systemctl start bootimus
```

## Documentation

- **[Deployment Guide](docs/en/deployment.md)** - Docker, binary, networking, and storage
Expand Down Expand Up @@ -139,6 +160,12 @@ Bootimus ships with embedded iPXE bootloaders for UEFI (x86_64, ARM64) and Legac

The built-in set is always available as a fallback. Files not present in the active custom set are served from the built-in set automatically.

When the built-in proxyDHCP server is enabled, clients can override the global
set from the **Clients** page. Client settings take precedence over client-group
settings, which take precedence over the globally active set. External DHCP
servers cannot apply MAC-specific Bootimus settings and continue to use the
globally active set.

## Supported Distributions

### Arch-based
Expand Down
6 changes: 3 additions & 3 deletions distro-profiles.json
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@
"kernel_paths": ["/images/pxeboot/vmlinuz"],
"initrd_paths": ["/images/pxeboot/initrd.img"],
"squashfs_paths": ["/LiveOS/squashfs.img"],
"default_boot_params": "initrd=initrd root=live:{{BASE_URL}}/isos/{{FILENAME}} rd.live.image inst.repo={{BASE_URL}}/boot/{{CACHE_DIR}}/iso/ inst.stage2={{BASE_URL}}/boot/{{CACHE_DIR}}/iso/ rd.neednet=1",
"default_boot_params": "initrd=initrd ip=dhcp rd.neednet=1 inst.stage2={{BASE_URL}}/boot/{{CACHE_DIR}}/iso/",
"auto_install_type": "kickstart",
"boot_method": "kernel",
"mirrors": [
Expand Down Expand Up @@ -470,7 +470,7 @@
"display_name": "Windows 7",
"family": "windows",
"filename_patterns": ["windows_7", "win7", "windows7"],
"kernel_paths": ["/sources/boot.wim"],
"kernel_paths": [],
"initrd_paths": [],
"squashfs_paths": [],
"default_boot_params": "rawbcd",
Expand All @@ -482,7 +482,7 @@
"display_name": "Windows",
"family": "windows",
"filename_patterns": ["windows", "win10", "win11", "win8", "server2022", "server2019", "server2016"],
"kernel_paths": ["/sources/boot.wim"],
"kernel_paths": [],
"initrd_paths": [],
"squashfs_paths": [],
"default_boot_params": "",
Expand Down
17 changes: 17 additions & 0 deletions docs/en/clients.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ Bootimus uses MAC address-based access control to manage which clients can boot
- **Assigned Images**: When a client has images assigned, it sees **only those images** (not the full public list)
- **Show Public Images**: When enabled alongside assigned images, client sees both assigned and public images
- **Next Boot Action**: A one-time boot image override that auto-clears after use
- **Bootloader Set**: An optional initial bootloader override for this client

### Client Auto-Discovery

Expand Down Expand Up @@ -86,6 +87,22 @@ All formats are normalized to colon-separated lowercase.

## Client Permissions

### Select a Bootloader Set

The bootloader set used for the initial PXE download is resolved in this order:

1. Client bootloader set
2. Client-group bootloader set
3. Globally active bootloader set

Select a set in the client's edit dialog, or leave **Default (global setting)**
to inherit from its group and then the global setting.

Per-client and client-group selection requires Bootimus's built-in proxyDHCP
server, because it uses the PXE request's MAC address to advertise a
set-qualified boot filename. External DHCP servers continue to advertise their
statically configured filename and therefore use the globally active set.

### Assign Images to Client

**Via Web Interface**:
Expand Down
76 changes: 54 additions & 22 deletions internal/admin/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"strconv"
"strings"
"sync"
"text/template"
"time"

"bootimus/bootloaders"
Expand Down Expand Up @@ -191,8 +192,19 @@ func buildStartnetScript(serverAddr, shareName string, smbPort, httpPort int, is
httpPort = 8080
}

base := fmt.Sprintf(`@echo off
// NB to use a non-default smb port, windows 11 24H2 (or later) or windows server 2025 (or later) is required.
// see https://learn.microsoft.com/en-us/windows-server/storage/file-server/smb-ports?tabs=command-line
scriptTemplate := template.Must(template.New("script").Parse(`@echo off
setlocal EnableDelayedExpansion

if exist x:\drivers (
echo Loading drivers...
reg add "HKLM\Software\Microsoft\Windows NT\CurrentVersion\UnattendSettings\PnPUnattend\DriverPaths\1" /v Path /t REG_SZ /d x:\drivers /f >nul 2>&1
PnPUnattend auditSystem /l
)

wpeinit

rem Windows 11 24H2+ WinPE ships with insecure guest auth disabled and SMB
rem signing required; guest sessions cannot sign, so mapping the read-only
rem guest share fails with access denied. Re-enable guest SMB for this
Expand All @@ -210,15 +222,15 @@ ipconfig /renew >nul 2>&1
echo Waiting for network...
set /a TRIES=0
:waitnet
ping -n 1 -w 1000 %s >nul 2>&1
ping -n 1 -w 1000 {{ .ServerAddr }} >nul 2>&1
if not errorlevel 1 goto netready
set /a TRIES+=1
if %%TRIES%% geq 60 goto netfail
if %TRIES% geq 60 goto netfail
ping 127.0.0.1 -n 2 >nul 2>&1
goto waitnet
:netfail
echo ERROR: Could not reach %s after 60 seconds.
echo Dropping to shell. Try: ipconfig, ping %s
echo ERROR: Could not reach {{ .ServerAddr }} after 60 seconds.
echo Dropping to shell. Try: ipconfig, ping {{ .ServerAddr }}
echo Type 'exit' to reboot.
cmd.exe
exit /b 1
Expand All @@ -229,17 +241,17 @@ set /a TRIES=0
:mapshare
rem Expected to fail with "System error 53" for the first few tries while the
rem SMB client stack and the server's 445 path come up — the loop handles it.
net use Z: \\%s\%s /persistent:no >nul 2>&1
net use Z: \\{{ .ServerAddr }}\{{ .ShareName }} /persistent:no{{ if ne .SmbPort 445 }} /tcpport:{{ .SmbPort }}{{ end }} >nul 2>&1
if not errorlevel 1 goto mapped
set /a TRIES+=1
if %%TRIES%% geq 30 goto mapfail
echo Still trying to connect (%%TRIES%%/30), please wait...
if %TRIES% geq 30 goto mapfail
echo Still trying to connect (%TRIES%/30), please wait...
ping 127.0.0.1 -n 4 >nul 2>&1
goto mapshare
:mapfail
echo.
echo ERROR: Failed to connect to \\%s\%s after 90 seconds (SMB port %d)
echo Dropping to shell for debugging. Try: net use Z: \\%s\%s
echo ERROR: Failed to connect to \\{{ .ServerAddr }}\{{ .ShareName }} after 90 seconds (SMB port {{ .SmbPort }})
echo Dropping to shell for debugging. Try: net use Z: \\{{ .ServerAddr }}\{{ .ShareName }} /persistent:no{{ if ne .SmbPort 445 }} /tcpport:{{ .SmbPort }}{{ end }}
echo Type 'exit' to reboot.
cmd.exe
exit /b 1
Expand All @@ -253,22 +265,42 @@ if not exist Z:\setup.exe (
cmd.exe
exit /b 1
)
`, serverAddr, serverAddr, serverAddr, serverAddr, shareName, serverAddr, shareName, smbPort, serverAddr, shareName)

launch := "echo Starting Windows Setup...\r\nZ:\\setup.exe\r\n"
if autoInstall {
launch = `copy /Y Z:\AutoUnattend.xml X:\AutoUnattend.xml >nul
if not exist X:\AutoUnattend.xml (
set SETUP_ARGS=

if exist x:\drivers set SETUP_ARGS=!SETUP_ARGS! /installdrivers x:\drivers

{{- if .AutoInstall }}

if exist Z:\AutoUnattend.xml (
copy /Y Z:\AutoUnattend.xml X:\AutoUnattend.xml >nul
set SETUP_ARGS=!SETUP_ARGS! /unattend X:\AutoUnattend.xml
) else (
echo WARNING: AutoUnattend.xml not on share, running interactive setup.
Z:\setup.exe
exit /b 0
)
echo Starting Windows Setup (unattended)...
Z:\setup.exe /unattend:X:\AutoUnattend.xml
`
}

return base + launch
{{- end }}

echo Starting Windows Setup...
Z:\setup.exe !SETUP_ARGS!
`))

var buf bytes.Buffer
err := scriptTemplate.Execute(&buf, &struct {
ServerAddr string
ShareName string
SmbPort int
AutoInstall bool
}{
ServerAddr: serverAddr,
ShareName: shareName,
SmbPort: smbPort,
AutoInstall: autoInstall,
})
if err != nil {
return ""
}
return buf.String()
}

func isRunningInDocker() bool {
Expand Down
2 changes: 1 addition & 1 deletion internal/admin/windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ func (h *Handler) RebuildBootWim(imageID uint) error {

log.Printf(" Updating image %d...", idx)
extractCmd := exec.Command("wimupdate", bootWimPath, fmt.Sprintf("%d", idx))
extractCmd.Stdin = strings.NewReader(fmt.Sprintf("add \"%s\" \"/Windows/System32/DriverStore/FileRepository\"\n", driversDir))
extractCmd.Stdin = strings.NewReader(fmt.Sprintf("add \"%s\" \"/drivers\"\n", driversDir))
if output, err := extractCmd.CombinedOutput(); err != nil {
log.Printf("wimupdate output: %s", string(output))
return fmt.Errorf("failed to update WIM image %d: %w", idx, err)
Expand Down
2 changes: 1 addition & 1 deletion internal/profiles/distro-profiles.json
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@
"kernel_paths": ["/images/pxeboot/vmlinuz"],
"initrd_paths": ["/images/pxeboot/initrd.img"],
"squashfs_paths": ["/LiveOS/squashfs.img"],
"default_boot_params": "initrd=initrd root=live:{{BASE_URL}}/isos/{{FILENAME}} rd.live.image inst.repo={{BASE_URL}}/boot/{{CACHE_DIR}}/iso/ inst.stage2={{BASE_URL}}/boot/{{CACHE_DIR}}/iso/ rd.neednet=1",
"default_boot_params": "initrd=initrd ip=dhcp rd.neednet=1 inst.stage2={{BASE_URL}}/boot/{{CACHE_DIR}}/iso/",
"auto_install_type": "kickstart",
"boot_method": "kernel",
"mirrors": [
Expand Down
29 changes: 29 additions & 0 deletions internal/profiles/manager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package profiles

import (
"encoding/json"
"strings"
"testing"

"bootimus/internal/models"
Expand Down Expand Up @@ -65,6 +66,34 @@ func TestMatchProfile_CaseInsensitive(t *testing.T) {
}
}

func TestAlmaProfileUsesAnacondaStage2(t *testing.T) {
profiles := loadEmbeddedForTest(t)

var alma *models.DistroProfile
for _, profile := range profiles {
if profile.ProfileID == "alma" {
alma = profile
break
}
}
if alma == nil {
t.Fatal("embedded AlmaLinux profile not found")
}

params := alma.DefaultBootParams
if strings.Contains(params, "root=live:") || strings.Contains(params, "rd.live.image") {
t.Fatalf("AlmaLinux installer profile must not use a live root: %q", params)
}
if strings.Contains(params, "inst.repo=") {
t.Fatalf("AlmaLinux Boot ISO profile must leave package sources to Kickstart: %q", params)
}
for _, required := range []string{"initrd=initrd", "ip=dhcp", "rd.neednet=1", "inst.stage2="} {
if !strings.Contains(params, required) {
t.Errorf("AlmaLinux installer profile missing %q in %q", required, params)
}
}
}

func TestMatchProfile_CustomBeatsBuiltin(t *testing.T) {
profiles := []*models.DistroProfile{
{ProfileID: "ubuntu", Custom: false, FilenamePatterns: models.StringSlice{"ubuntu"}},
Expand Down
17 changes: 9 additions & 8 deletions internal/proxydhcp/proxydhcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,11 @@ type Config struct {
BootfileBIOS string
BootfileUEFI string
BootfileARM64 string
// Bootfiles, when set, is consulted on every request; any non-empty value
// it returns overrides the static Bootfile* fields. This lets the server
// switch bootloader sets at runtime without restarting proxyDHCP.
Bootfiles func() (bios, uefi, arm64 string)
// Bootfiles, when set, is consulted on every request with the client's
// hardware address; any non-empty value it returns overrides the static
// Bootfile* fields. This lets the server switch global or client-specific
// bootloader sets at runtime without restarting proxyDHCP.
Bootfiles func(clientHWAddr net.HardwareAddr) (bios, uefi, arm64 string)
}

type Server struct {
Expand Down Expand Up @@ -86,7 +87,7 @@ func (s *Server) Start() error {
}
s.conn4011 = conn4011

bios, uefi, arm64 := s.effectiveBootfiles()
bios, uefi, arm64 := s.effectiveBootfiles(nil)
log.Printf("proxyDHCP: listening on UDP/67 + UDP/4011, advertising next-server=%s (BIOS=%s, UEFI=%s, ARM64=%s)",
s.cfg.ServerIP, bios, uefi, arm64)

Expand Down Expand Up @@ -193,10 +194,10 @@ func pxeVendorOptions() []byte {
}
}

func (s *Server) effectiveBootfiles() (bios, uefi, arm64 string) {
func (s *Server) effectiveBootfiles(clientHWAddr net.HardwareAddr) (bios, uefi, arm64 string) {
bios, uefi, arm64 = s.cfg.BootfileBIOS, s.cfg.BootfileUEFI, s.cfg.BootfileARM64
if s.cfg.Bootfiles != nil {
overrideBIOS, overrideUEFI, overrideARM64 := s.cfg.Bootfiles()
overrideBIOS, overrideUEFI, overrideARM64 := s.cfg.Bootfiles(clientHWAddr)
if overrideBIOS != "" {
bios = overrideBIOS
}
Expand All @@ -211,7 +212,7 @@ func (s *Server) effectiveBootfiles() (bios, uefi, arm64 string) {
}

func (s *Server) bootfileFor(req *dhcpv4.DHCPv4) string {
bios, uefi, arm64 := s.effectiveBootfiles()
bios, uefi, arm64 := s.effectiveBootfiles(req.ClientHWAddr)
switch clientArch(req) {
case iana.EFI_IA32, iana.EFI_X86_64, iana.EFI_BC:
return uefi
Expand Down
39 changes: 39 additions & 0 deletions internal/proxydhcp/proxydhcp_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package proxydhcp

import (
"net"
"testing"

"github.com/insomniacslk/dhcp/dhcpv4"
"github.com/insomniacslk/dhcp/iana"
)

func TestBootfileForPassesClientHardwareAddress(t *testing.T) {
mac, err := net.ParseMAC("02:00:00:00:00:01")
if err != nil {
t.Fatalf("ParseMAC: %v", err)
}
request, err := dhcpv4.New(
dhcpv4.WithHwAddr(mac),
dhcpv4.WithOption(dhcpv4.OptClientArch(iana.EFI_X86_64)),
)
if err != nil {
t.Fatalf("New DHCP request: %v", err)
}

server := &Server{cfg: Config{
BootfileBIOS: DefaultBootfileBIOS,
BootfileUEFI: DefaultBootfileUEFI,
BootfileARM64: DefaultBootfileARM64,
Bootfiles: func(clientHWAddr net.HardwareAddr) (string, string, string) {
if clientHWAddr.String() != mac.String() {
t.Fatalf("callback MAC = %q, want %q", clientHWAddr, mac)
}
return "custom/legacy.kpxe", "custom/client.efi", "custom/client-arm64.efi"
},
}}

if got := server.bootfileFor(request); got != "custom/client.efi" {
t.Fatalf("bootfileFor = %q, want custom/client.efi", got)
}
}
Loading
Loading