Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
53 changes: 53 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
name: CI

# Runs once per change: pull requests test the result of merging into the
# target branch; pushes to main verify the merge itself (and build the
# container image). Branches without an open PR don't run CI. Tag pushes are
# handled by release.yml, which stamps the version and pushes the image.

on:
push:
branches:
- main
pull_request:

permissions:
contents: read

jobs:
test:
name: Vet, test & build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true

- name: Vet
run: go vet ./...

- name: Test
run: go test ./...

- name: Build
run: make build

docker:
name: Docker build (no push)
runs-on: ubuntu-latest
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4

- uses: docker/setup-buildx-action@v3

- name: Build image
uses: docker/build-push-action@v6
with:
context: .
push: false
cache-from: type=gha
cache-to: type=gha,mode=max
109 changes: 109 additions & 0 deletions internal/admin/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"net/http"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
Expand Down Expand Up @@ -858,6 +859,22 @@ func (h *Handler) UpdateImage(w http.ResponseWriter, r *http.Request) {
image.AutoInstallFile = aiFile
image.AutoInstallEnabled = aiFile != "" || image.AutoInstallScript != ""
}
if kernelOverride, ok := updates["kernel_override"].(string); ok {
resolved, err := h.resolveBootFileOverride(filename, kernelOverride)
if err != nil {
h.sendJSON(w, http.StatusBadRequest, Response{Success: false, Error: fmt.Sprintf("Invalid kernel override: %v", err)})
return
}
image.KernelOverride = resolved
}
if initrdOverride, ok := updates["initrd_override"].(string); ok {
resolved, err := h.resolveBootFileOverride(filename, initrdOverride)
if err != nil {
h.sendJSON(w, http.StatusBadRequest, Response{Success: false, Error: fmt.Sprintf("Invalid initrd override: %v", err)})
return
}
image.InitrdOverride = resolved
}

if err := h.storage.UpdateImage(filename, image); err != nil {
h.sendJSON(w, http.StatusInternalServerError, Response{Success: false, Error: err.Error()})
Expand All @@ -868,6 +885,98 @@ func (h *Handler) UpdateImage(w http.ResponseWriter, r *http.Request) {
h.sendJSON(w, http.StatusOK, Response{Success: true, Message: "Image updated", Data: image})
}

func (h *Handler) resolveBootFileOverride(filename, rel string) (string, error) {
if rel == "" {
return "", nil
}
rel = strings.Trim(filepath.ToSlash(rel), "/")
if rel == "" || strings.Contains(rel, "..") {
return "", fmt.Errorf("invalid path")
}
isoBase := strings.TrimSuffix(filename, filepath.Ext(filename))
baseDir := filepath.Clean(filepath.Join(h.isoDir, isoBase))
fullPath := filepath.Clean(filepath.Join(baseDir, filepath.FromSlash(rel)))
if !strings.HasPrefix(fullPath, baseDir+string(filepath.Separator)) {
return "", fmt.Errorf("invalid path")
}
info, err := os.Stat(fullPath)
if err != nil {
return "", fmt.Errorf("file not found: %s", rel)
}
if info.IsDir() {
return "", fmt.Errorf("not a file: %s", rel)
}
return rel, nil
}

func (h *Handler) BootFileCandidates(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
h.sendJSON(w, http.StatusMethodNotAllowed, Response{Success: false, Error: "Method not allowed"})
return
}

filename := r.URL.Query().Get("filename")
if filename == "" {
h.sendJSON(w, http.StatusBadRequest, Response{Success: false, Error: "Missing filename parameter"})
return
}

image, err := h.storage.GetImage(filename)
if err != nil {
h.sendJSON(w, http.StatusNotFound, Response{Success: false, Error: "Image not found"})
return
}

type bootFileCandidate struct {
Path string `json:"path"`
Size int64 `json:"size"`
}

isoBase := strings.TrimSuffix(filename, filepath.Ext(filename))
baseDir := filepath.Join(h.isoDir, isoBase)
extractedDir := filepath.Join(baseDir, "iso")

excludedExts := map[string]bool{".deb": true, ".udeb": true, ".rpm": true, ".mod": true, ".txt": true, ".sig": true}

kernels := []bootFileCandidate{}
initrds := []bootFileCandidate{}
filepath.Walk(extractedDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
if info.IsDir() {
if strings.EqualFold(info.Name(), "pool") || strings.EqualFold(info.Name(), "pool_udeb") {
return filepath.SkipDir
}
return nil
}
if excludedExts[strings.ToLower(filepath.Ext(info.Name()))] {
return nil
}
rel, relErr := filepath.Rel(baseDir, path)
if relErr != nil {
return nil
}
candidate := bootFileCandidate{Path: filepath.ToSlash(rel), Size: info.Size()}
if extractor.IsKernelFileName(info.Name()) {
kernels = append(kernels, candidate)
} else if extractor.IsInitrdFileName(info.Name()) {
initrds = append(initrds, candidate)
}
return nil
})

sort.Slice(kernels, func(i, j int) bool { return kernels[i].Path < kernels[j].Path })
sort.Slice(initrds, func(i, j int) bool { return initrds[i].Path < initrds[j].Path })

h.sendJSON(w, http.StatusOK, Response{Success: true, Data: map[string]any{
"kernels": kernels,
"initrds": initrds,
"kernel_override": image.KernelOverride,
"initrd_override": image.InitrdOverride,
}})
}

func (h *Handler) DeleteImage(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
h.sendJSON(w, http.StatusMethodNotAllowed, Response{Success: false, Error: "Method not allowed"})
Expand Down
8 changes: 5 additions & 3 deletions internal/auth/admin_authz_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,11 @@ type fakeUserStore struct {
users map[string]*models.User
}

func (f *fakeUserStore) EnsureAdminUser() (string, string, bool, error) { return "admin", "", false, nil }
func (f *fakeUserStore) ResetAdminPassword() (string, error) { return "", nil }
func (f *fakeUserStore) UpdateUserLastLogin(string) error { return nil }
func (f *fakeUserStore) EnsureAdminUser() (string, string, bool, error) {
return "admin", "", false, nil
}
func (f *fakeUserStore) ResetAdminPassword() (string, error) { return "", nil }
func (f *fakeUserStore) UpdateUserLastLogin(string) error { return nil }
func (f *fakeUserStore) GetUser(username string) (*models.User, error) {
u, ok := f.users[username]
if !ok {
Expand Down
8 changes: 8 additions & 0 deletions internal/extractor/detect_generic.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,14 @@ func (e *Extractor) detectGenericUnified(reader FileSystemReader) (*BootFiles, e
}, nil
}

func IsKernelFileName(name string) bool {
return isKernelFile(strings.ToLower(name))
}

func IsInitrdFileName(name string) bool {
return isInitrdFile(strings.ToLower(name))
}

func isKernelFile(name string) bool {
kernelPatterns := []string{
"vmlinuz",
Expand Down
95 changes: 95 additions & 0 deletions internal/extractor/detect_unified.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,59 @@ func detectDistroNameUnified(reader FileSystemReader, isoPath string) string {
return ""
}

func (e *Extractor) detectLiveDebianUnified(reader FileSystemReader) *BootFiles {
entries, err := reader.ListDirectory("/live")
if err != nil || entries == nil {
return nil
}

var kernel, versionedKernel, initrd, versionedInitrd, squashfs string
for _, entry := range entries {
if entry.IsDir {
continue
}
name := strings.ToLower(entry.Name)
path := "/live/" + entry.Name
switch {
case name == "vmlinuz":
kernel = path
case strings.HasPrefix(name, "vmlinuz-") && versionedKernel == "":
versionedKernel = path
case name == "initrd.img" || name == "initrd":
initrd = path
case (strings.HasPrefix(name, "initrd.img-") || strings.HasPrefix(name, "initrd-")) && versionedInitrd == "":
versionedInitrd = path
case strings.HasPrefix(name, "filesystem.squashfs"):
squashfs = path
case strings.HasSuffix(name, ".squashfs") && squashfs == "":
squashfs = path
}
}

if versionedKernel != "" {
kernel = versionedKernel
}
if versionedInitrd != "" {
initrd = versionedInitrd
}
if kernel == "" || initrd == "" || squashfs == "" {
return nil
}

log.Printf("Detected Debian live system: kernel=%s initrd=%s squashfs=%s", kernel, initrd, squashfs)
return &BootFiles{
Kernel: kernel,
Initrd: initrd,
Distro: "debian",
SquashfsPath: squashfs,
}
}

func (e *Extractor) detectUbuntuDebianUnified(reader FileSystemReader) (*BootFiles, error) {
if files := e.detectLiveDebianUnified(reader); files != nil {
return files, nil
}

paths := []struct {
kernel string
initrd string
Expand Down Expand Up @@ -142,6 +194,10 @@ func (e *Extractor) detectUbuntuDebianUnified(reader FileSystemReader) (*BootFil
NetbootRequired: p.netboot,
NetbootURL: p.netbootURL,
}
squashfs := parentDir(p.kernel) + "/filesystem.squashfs"
if reader.FileExists(squashfs) {
bootFiles.SquashfsPath = squashfs
}
return bootFiles, nil
}
}
Expand Down Expand Up @@ -524,5 +580,44 @@ func (e *Extractor) cacheBootFilesUnified(files *BootFiles, reader FileSystemRea

files.ExtractedDir = extractedDir

if files.SquashfsPath != "" {
if rel := resolveExtractedRelPath(bootFilesDir, files.SquashfsPath); rel != "" {
files.SquashfsPath = rel
} else {
log.Printf("Warning: squashfs %s not found in extracted ISO contents", files.SquashfsPath)
files.SquashfsPath = ""
}
}

return nil
}

func resolveExtractedRelPath(bootFilesDir, isoPath string) string {
rel := "iso"
cur := filepath.Join(bootFilesDir, "iso")
for _, part := range strings.Split(strings.TrimPrefix(isoPath, "/"), "/") {
if part == "" {
continue
}
entries, err := os.ReadDir(cur)
if err != nil {
return ""
}
match := ""
for _, entry := range entries {
if entry.Name() == part {
match = part
break
}
if match == "" && strings.EqualFold(entry.Name(), part) {
match = entry.Name()
}
}
if match == "" {
return ""
}
rel = rel + "/" + match
cur = filepath.Join(cur, match)
}
return rel
}
Loading
Loading