diff --git a/app/application.go b/app/application.go
index b001863..5aa15b0 100644
--- a/app/application.go
+++ b/app/application.go
@@ -18,11 +18,13 @@ import (
// Application represents the main application with embedded static files
type Application struct {
- container *Container
- httpServer *http.Server
- tftpServer *tftp.Server
- staticFS embed.FS
- staticHandler *handlers.StaticHandlers
+ container *Container
+ handlerContainer *handlers.Container
+ httpServer *http.Server
+ httpErrCh chan error
+ tftpServer *tftp.Server
+ staticFS embed.FS
+ staticHandler *handlers.StaticHandlers
}
// NewApplicationWithStatic creates a new application instance with embedded static files
@@ -35,10 +37,22 @@ func NewApplicationWithStatic(staticFS embed.FS) (*Application, error) {
// Create static file handler
staticHandler := handlers.NewStaticHandlers(staticFS, container.Config.HTTP.Dir)
+ // Build the handler container once and reuse it for Start/GetContainer.
+ handlerContainer := &handlers.Container{
+ ServerService: container.ServerService,
+ LeaseService: container.LeaseService,
+ OSImageService: container.OSImageService,
+ SyslinuxService: container.SyslinuxService,
+ IPXEService: container.IPXEService,
+ Config: container.Config,
+ }
+
return &Application{
- container: container,
- staticFS: staticFS,
- staticHandler: staticHandler,
+ container: container,
+ handlerContainer: handlerContainer,
+ httpErrCh: make(chan error, 1),
+ staticFS: staticFS,
+ staticHandler: staticHandler,
}, nil
}
@@ -51,18 +65,8 @@ func (a *Application) Start() error {
}
log.Printf("TFTP server started on port 69, serving from %s", a.container.Config.TFTP.Dir)
- // Setup HTTP handlers with dependency injection
- handlerContainer := &handlers.Container{
- ServerService: a.container.ServerService,
- LeaseService: a.container.LeaseService,
- OSImageService: a.container.OSImageService,
- SyslinuxService: a.container.SyslinuxService,
- IPXEService: a.container.IPXEService,
- Config: a.container.Config,
- }
-
// Create HTTP router with injected dependencies and static file handling
- router := routes.SetupWithContainerAndStatic(handlerContainer, a.staticHandler)
+ router := routes.SetupWithContainerAndStatic(a.handlerContainer, a.staticHandler)
log.Printf("Embedded HTTP server configured")
// Create HTTP server
@@ -71,11 +75,15 @@ func (a *Application) Start() error {
Handler: router,
}
- // Start HTTP server
+ // Start HTTP server. Failures are propagated via httpErrCh instead of
+ // killing the process from inside the goroutine.
go func() {
log.Printf("HTTP API server started on port %s", a.container.Config.HTTP.Port)
if err := a.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
- log.Fatalf("HTTP server failed: %v", err)
+ select {
+ case a.httpErrCh <- err:
+ default:
+ }
}
}()
@@ -84,6 +92,24 @@ func (a *Application) Start() error {
// Rest of the Application methods remain the same...
func (a *Application) Stop() error {
+ // Stop any running DHCP servers first
+ if a.container.ServerService != nil {
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ servers, err := a.container.ServerService.GetAllServers(ctx)
+ if err != nil {
+ log.Printf("Error listing DHCP servers for shutdown: %v", err)
+ } else {
+ for _, server := range servers {
+ if server.Started {
+ if err := a.container.ServerService.StopServer(ctx, server.ID); err != nil {
+ log.Printf("Error stopping DHCP server %s: %v", server.ID, err)
+ }
+ }
+ }
+ }
+ cancel()
+ }
+
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
@@ -113,8 +139,17 @@ func (a *Application) Run() error {
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
log.Println("Application started. Press Ctrl+C to stop.")
- <-quit
- log.Println("Shutting down application...")
+ select {
+ case <-quit:
+ log.Println("Shutting down application...")
+ case err := <-a.httpErrCh:
+ // The HTTP server died on its own; shut everything else down and
+ // report the failure instead of log.Fatalf-ing from a goroutine.
+ if stopErr := a.Stop(); stopErr != nil {
+ log.Printf("Error during shutdown after HTTP failure: %v", stopErr)
+ }
+ return fmt.Errorf("HTTP server failed: %w", err)
+ }
if err := a.Stop(); err != nil {
return fmt.Errorf("failed to stop application: %w", err)
@@ -124,14 +159,7 @@ func (a *Application) Run() error {
return nil
}
-// GetContainer returns the application's container for access to services
+// GetContainer returns the application's shared handler container
func (a *Application) GetContainer() *handlers.Container {
- return &handlers.Container{
- ServerService: a.container.ServerService,
- LeaseService: a.container.LeaseService,
- OSImageService: a.container.OSImageService,
- SyslinuxService: a.container.SyslinuxService,
- IPXEService: a.container.IPXEService,
- Config: a.container.Config,
- }
+ return a.handlerContainer
}
diff --git a/app/container.go b/app/container.go
index 60bce72..cf3a4c1 100644
--- a/app/container.go
+++ b/app/container.go
@@ -27,7 +27,7 @@ type Container struct {
}
// NewContainer creates and wires up all dependencies
-func NewContainer() (*Container, error) {
+func NewContainer() (c *Container, err error) {
// Load configuration
cfg, err := config.LoadDefault()
if err != nil {
@@ -39,13 +39,19 @@ func NewContainer() (*Container, error) {
if err != nil {
return nil, fmt.Errorf("failed to initialize database: %w", err)
}
+ // Don't leak the database if a later construction step fails.
+ defer func() {
+ if err != nil {
+ _ = database.Close()
+ }
+ }()
// Create repositories
serverRepo := dhcp.NewBoltServerRepository(database, cfg.DB.Bucket+"_servers")
leaseRepo := dhcp.NewBoltLeaseRepository(database, cfg.DB.Bucket+"_leases")
osImageRepo := osimage.NewOSImageRepository(database)
downloadStatusRepo := osimage.NewDownloadStatusRepository(database)
- syslinuxRepo, err := syslinux.NewBoltRepository(database.GetDB())
+ syslinuxRepo, err := syslinux.NewBoltRepository(database)
if err != nil {
return nil, fmt.Errorf("failed to create syslinux repository: %w", err)
}
@@ -57,7 +63,7 @@ func NewContainer() (*Container, error) {
syslinuxService := syslinux.NewService(syslinuxRepo, syslinux.GetDefaultConfig())
ipxeService := ipxe.NewService(cfg, osImageService)
- return &Container{
+ c = &Container{
Config: cfg,
Database: database,
ServerRepo: serverRepo,
@@ -70,7 +76,8 @@ func NewContainer() (*Container, error) {
SyslinuxRepo: syslinuxRepo,
SyslinuxService: syslinuxService,
IPXEService: ipxeService,
- }, nil
+ }
+ return c, nil
}
// Close closes all resources held by the container
diff --git a/testdata/README.md b/cli/README.md
similarity index 100%
rename from testdata/README.md
rename to cli/README.md
diff --git a/testdata/cli.go b/cli/cli.go
similarity index 99%
rename from testdata/cli.go
rename to cli/cli.go
index 910ec2b..a2ad54d 100644
--- a/testdata/cli.go
+++ b/cli/cli.go
@@ -1,4 +1,4 @@
-package testdata
+package cli
import (
"context"
diff --git a/testdata/cli_test.go b/cli/cli_test.go
similarity index 98%
rename from testdata/cli_test.go
rename to cli/cli_test.go
index c2fd064..03a0951 100644
--- a/testdata/cli_test.go
+++ b/cli/cli_test.go
@@ -1,4 +1,4 @@
-package testdata
+package cli
import (
"flag"
diff --git a/testdata/mock.go b/cli/mock.go
similarity index 99%
rename from testdata/mock.go
rename to cli/mock.go
index 1a911a8..db8943a 100644
--- a/testdata/mock.go
+++ b/cli/mock.go
@@ -1,4 +1,4 @@
-package testdata
+package cli
import (
"context"
diff --git a/config/config.go b/config/config.go
old mode 100755
new mode 100644
index 91effa4..efbea43
--- a/config/config.go
+++ b/config/config.go
@@ -3,6 +3,7 @@ package config
import (
"fmt"
"os"
+ "strconv"
)
// Config represents the application configuration with immutable design
@@ -13,6 +14,10 @@ type Config struct {
HTTP HTTPConfig
Provision ProvisionConfig
OSImages OSImageConfig
+ // ServerIP overrides the IP address advertised to network-boot clients.
+ // Empty means auto-detect; can also be set via the SERVER_IP environment
+ // variable, which takes precedence.
+ ServerIP string
}
type DBConfig struct {
@@ -51,9 +56,10 @@ type OSDefinition struct {
}
type OSVersion struct {
- DisplayName string `json:"display_name"`
- BaseURL string `json:"base_url"`
- Architectures []string `json:"architectures"`
+ DisplayName string `json:"display_name"`
+ BaseURL string `json:"base_url"`
+ Architectures []string `json:"architectures"`
+ ExpectedChecksum string `json:"expected_checksum,omitempty"` // Optional SHA256 hex digest; when set, each downloaded file for this version must match it
}
// ConfigBuilder provides a builder pattern for configuration
@@ -85,6 +91,7 @@ func NewConfigBuilder() *ConfigBuilder {
Dir: getEnv("PROV_DIR", "./public/provision"),
},
OSImages: getDefaultOSImageConfig(),
+ ServerIP: getEnv("SERVER_IP", ""),
},
}
}
@@ -113,11 +120,38 @@ func (cb *ConfigBuilder) Build() (*Config, error) {
return nil, fmt.Errorf("invalid configuration: %w", err)
}
- // Return a copy to maintain immutability
+ // Return a copy to maintain immutability. The OS image catalog holds
+ // nested maps and slices, so it needs a deep copy.
config := cb.config
+ config.OSImages = deepCopyOSImageConfig(cb.config.OSImages)
return &config, nil
}
+// deepCopyOSImageConfig deep-copies the OS image catalog: sources, nested
+// version maps, and architecture slices.
+func deepCopyOSImageConfig(src OSImageConfig) OSImageConfig {
+ dst := OSImageConfig{
+ Sources: make(map[string]OSDefinition, len(src.Sources)),
+ }
+ for osName, def := range src.Sources {
+ defCopy := OSDefinition{
+ DisplayName: def.DisplayName,
+ KernelFile: def.KernelFile,
+ InitrdFile: def.InitrdFile,
+ Versions: make(map[string]OSVersion, len(def.Versions)),
+ }
+ for ver, v := range def.Versions {
+ vCopy := v
+ if v.Architectures != nil {
+ vCopy.Architectures = append([]string(nil), v.Architectures...)
+ }
+ defCopy.Versions[ver] = vCopy
+ }
+ dst.Sources[osName] = defCopy
+ }
+ return dst
+}
+
// validate ensures the configuration is valid
func (cb *ConfigBuilder) validate() error {
if cb.config.DB.DBPath == "" {
@@ -129,6 +163,18 @@ func (cb *ConfigBuilder) validate() error {
if cb.config.DB.Bucket == "" {
return fmt.Errorf("database bucket cannot be empty")
}
+ if port, err := strconv.Atoi(cb.config.HTTP.Port); err != nil || port < 1 || port > 65535 {
+ return fmt.Errorf("HTTP port must be a number between 1 and 65535, got %q", cb.config.HTTP.Port)
+ }
+ if cb.config.TFTP.Dir == "" {
+ return fmt.Errorf("TFTP directory cannot be empty")
+ }
+ if cb.config.HTTP.Dir == "" {
+ return fmt.Errorf("HTTP directory cannot be empty")
+ }
+ if cb.config.Provision.Dir == "" {
+ return fmt.Errorf("provision directory cannot be empty")
+ }
return nil
}
diff --git a/config/config_test.go b/config/config_test.go
index 590b193..51c551b 100644
--- a/config/config_test.go
+++ b/config/config_test.go
@@ -31,3 +31,82 @@ func TestConfigBuilder(t *testing.T) {
assert.Equal(t, "test.db", cfg.DB.DBFile)
assert.Equal(t, "test", cfg.DB.Bucket)
}
+
+func TestConfigBuilder_InvalidHTTPPort(t *testing.T) {
+ for _, port := range []string{"", "abc", "0", "65536", "-1", "80.5"} {
+ b := NewConfigBuilder()
+ b.config.HTTP.Port = port
+ _, err := b.Build()
+ assert.Error(t, err, "port %q should be rejected", port)
+ }
+}
+
+func TestConfigBuilder_ValidHTTPPort(t *testing.T) {
+ for _, port := range []string{"1", "80", "8080", "65535"} {
+ b := NewConfigBuilder()
+ b.config.HTTP.Port = port
+ cfg, err := b.Build()
+ assert.NoError(t, err, "port %q should be accepted", port)
+ assert.Equal(t, port, cfg.HTTP.Port)
+ }
+}
+
+func TestConfigBuilder_EmptyDirsRejected(t *testing.T) {
+ b := NewConfigBuilder()
+ b.config.TFTP.Dir = ""
+ _, err := b.Build()
+ assert.Error(t, err)
+
+ b = NewConfigBuilder()
+ b.config.HTTP.Dir = ""
+ _, err = b.Build()
+ assert.Error(t, err)
+
+ b = NewConfigBuilder()
+ b.config.Provision.Dir = ""
+ _, err = b.Build()
+ assert.Error(t, err)
+}
+
+func TestConfigBuilder_DeepCopyOSImages(t *testing.T) {
+ b := NewConfigBuilder()
+ cfg, err := b.Build()
+ assert.NoError(t, err)
+
+ // Mutating the built config's catalog must not affect the builder's
+ // internal catalog (or a subsequently built config).
+ cfg.OSImages.Sources["ubuntu"].Versions["22.04"] = OSVersion{DisplayName: "mutated"}
+ archs := cfg.OSImages.Sources["ubuntu"].Versions["20.04"].Architectures
+ archs[0] = "mutated-arch"
+
+ cfg2, err := b.Build()
+ assert.NoError(t, err)
+ assert.Equal(t, "22.04 LTS", cfg2.OSImages.Sources["ubuntu"].Versions["22.04"].DisplayName)
+ assert.Equal(t, []string{"x86_64"}, cfg2.OSImages.Sources["ubuntu"].Versions["20.04"].Architectures)
+}
+
+func TestOSVersion_ExpectedChecksum(t *testing.T) {
+ v := OSVersion{
+ DisplayName: "22.04 LTS",
+ BaseURL: "https://example.com/",
+ Architectures: []string{"x86_64"},
+ ExpectedChecksum: "abc123",
+ }
+ assert.Equal(t, "abc123", v.ExpectedChecksum)
+
+ // Optional: zero value is empty
+ var empty OSVersion
+ assert.Empty(t, empty.ExpectedChecksum)
+}
+
+func TestLoadDefault_ServerIPEnv(t *testing.T) {
+ t.Setenv("SERVER_IP", "10.11.12.13")
+ cfg, err := LoadDefault()
+ assert.NoError(t, err)
+ assert.Equal(t, "10.11.12.13", cfg.ServerIP)
+
+ t.Setenv("SERVER_IP", "")
+ cfg, err = LoadDefault()
+ assert.NoError(t, err)
+ assert.Empty(t, cfg.ServerIP)
+}
diff --git a/db/bolt.go b/db/bolt.go
old mode 100755
new mode 100644
index 64ecca2..d63e1a7
--- a/db/bolt.go
+++ b/db/bolt.go
@@ -11,20 +11,20 @@ import (
// BoltDB implements the Database interface
type BoltDB struct {
- *bolt.DB
+ db *bolt.DB
bucket string
}
// NewBoltDB creates a new BoltDB instance
func NewBoltDB(cfg *config.Config) (*BoltDB, error) {
path := filepath.Join(cfg.DB.DBPath, cfg.DB.DBFile)
- db, err := bolt.Open(path, 0744, nil)
+ db, err := bolt.Open(path, 0600, nil)
if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err)
}
boltDB := &BoltDB{
- DB: db,
+ db: db,
bucket: cfg.DB.Bucket,
}
@@ -45,14 +45,14 @@ func NewBoltDB(cfg *config.Config) (*BoltDB, error) {
return boltDB, nil
}
-// GetDB returns the underlying bolt.DB instance
-func (b *BoltDB) GetDB() *bolt.DB {
- return b.DB
+// Close closes the underlying database.
+func (b *BoltDB) Close() error {
+ return b.db.Close()
}
// GetOrCreateBucket creates a bucket if it doesn't exist
func (b *BoltDB) GetOrCreateBucket(ctx context.Context, name string) error {
- return b.Update(func(tx *bolt.Tx) error {
+ return b.db.Update(func(tx *bolt.Tx) error {
_, err := tx.CreateBucketIfNotExists([]byte(name))
return err
})
@@ -61,7 +61,7 @@ func (b *BoltDB) GetOrCreateBucket(ctx context.Context, name string) error {
// GetKV retrieves a value by key from the specified bucket
func (b *BoltDB) GetKV(ctx context.Context, bucket string, key []byte) ([]byte, error) {
var value []byte
- err := b.View(func(tx *bolt.Tx) error {
+ err := b.db.View(func(tx *bolt.Tx) error {
bkt := tx.Bucket([]byte(bucket))
if bkt == nil {
return fmt.Errorf("bucket %q not found", bucket)
@@ -80,7 +80,7 @@ func (b *BoltDB) GetKV(ctx context.Context, bucket string, key []byte) ([]byte,
// PutKV stores a key-value pair in the specified bucket
func (b *BoltDB) PutKV(ctx context.Context, bucket string, key, value []byte) error {
- return b.Update(func(tx *bolt.Tx) error {
+ return b.db.Update(func(tx *bolt.Tx) error {
bkt, err := tx.CreateBucketIfNotExists([]byte(bucket))
if err != nil {
return fmt.Errorf("create bucket: %w", err)
@@ -91,7 +91,7 @@ func (b *BoltDB) PutKV(ctx context.Context, bucket string, key, value []byte) er
// DeleteKV removes a key-value pair from the specified bucket
func (b *BoltDB) DeleteKV(ctx context.Context, bucket string, key []byte) error {
- return b.Update(func(tx *bolt.Tx) error {
+ return b.db.Update(func(tx *bolt.Tx) error {
bkt := tx.Bucket([]byte(bucket))
if bkt == nil {
return fmt.Errorf("bucket %q not found", bucket)
@@ -103,11 +103,10 @@ func (b *BoltDB) DeleteKV(ctx context.Context, bucket string, key []byte) error
// GetAllKV retrieves all key-value pairs in the specified bucket
func (b *BoltDB) GetAllKV(ctx context.Context, bucket string) (map[string][]byte, error) {
result := make(map[string][]byte)
- err := b.View(func(tx *bolt.Tx) error {
+ err := b.db.View(func(tx *bolt.Tx) error {
bkt := tx.Bucket([]byte(bucket))
if bkt == nil {
- // Return empty map if bucket doesn't exist
- return nil
+ return fmt.Errorf("bucket %q not found", bucket)
}
return bkt.ForEach(func(k, v []byte) error {
@@ -123,13 +122,44 @@ func (b *BoltDB) GetAllKV(ctx context.Context, bucket string) (map[string][]byte
// DeleteAllKV removes all key-value pairs from the specified bucket
func (b *BoltDB) DeleteAllKV(ctx context.Context, bucket string) error {
- return b.Update(func(tx *bolt.Tx) error {
+ return b.db.Update(func(tx *bolt.Tx) error {
bkt := tx.Bucket([]byte(bucket))
if bkt == nil {
return fmt.Errorf("bucket %q not found", bucket)
}
- return bkt.ForEach(func(k, v []byte) error {
- return bkt.Delete(k)
- })
+ // bbolt forbids mutating a bucket inside ForEach (undefined behavior),
+ // so collect the keys first and delete them afterwards.
+ var keys [][]byte
+ if err := bkt.ForEach(func(k, v []byte) error {
+ keyCopy := make([]byte, len(k))
+ copy(keyCopy, k)
+ keys = append(keys, keyCopy)
+ return nil
+ }); err != nil {
+ return err
+ }
+ for _, k := range keys {
+ if err := bkt.Delete(k); err != nil {
+ return err
+ }
+ }
+ return nil
+ })
+}
+
+// BatchPut stores multiple key-value pairs in a single bucket atomically,
+// i.e. inside one bbolt read-write transaction.
+func (b *BoltDB) BatchPut(ctx context.Context, bucket string, kvs map[string][]byte) error {
+ return b.db.Update(func(tx *bolt.Tx) error {
+ bkt, err := tx.CreateBucketIfNotExists([]byte(bucket))
+ if err != nil {
+ return fmt.Errorf("create bucket: %w", err)
+ }
+ for k, v := range kvs {
+ if err := bkt.Put([]byte(k), v); err != nil {
+ return fmt.Errorf("batch put key %q: %w", k, err)
+ }
+ }
+ return nil
})
}
diff --git a/db/fixes_test.go b/db/fixes_test.go
new file mode 100644
index 0000000..71226b1
--- /dev/null
+++ b/db/fixes_test.go
@@ -0,0 +1,96 @@
+package db
+
+import (
+ "context"
+ "testing"
+
+ "ignite/config"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func newTestDB(t *testing.T, file string) Database {
+ t.Helper()
+ dir := t.TempDir()
+ cfg, err := config.NewConfigBuilder().
+ WithDBPath(dir).
+ WithDBFile(file).
+ WithBucket("test").
+ Build()
+ require.NoError(t, err)
+ database, err := NewBoltDB(cfg)
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = database.Close() })
+ return database
+}
+
+// TestBoltDB_MissingBucketConsistent verifies that all KV operations agree:
+// a missing bucket is an error, not an empty result.
+func TestBoltDB_MissingBucketConsistent(t *testing.T) {
+ ctx := context.Background()
+ database := newTestDB(t, "missing.db")
+
+ _, err := database.GetKV(ctx, "no-such-bucket", []byte("k"))
+ assert.Error(t, err)
+
+ _, err = database.GetAllKV(ctx, "no-such-bucket")
+ assert.Error(t, err)
+
+ assert.Error(t, database.DeleteKV(ctx, "no-such-bucket", []byte("k")))
+ assert.Error(t, database.DeleteAllKV(ctx, "no-such-bucket"))
+}
+
+// TestBoltDB_DeleteAllKV verifies that deleting all keys works and does not
+// mutate the bucket while iterating it.
+func TestBoltDB_DeleteAllKV(t *testing.T) {
+ ctx := context.Background()
+ database := newTestDB(t, "deleteall.db")
+
+ for _, k := range []string{"a", "b", "c", "d", "e"} {
+ require.NoError(t, database.PutKV(ctx, "test", []byte(k), []byte("v-"+k)))
+ }
+
+ require.NoError(t, database.DeleteAllKV(ctx, "test"))
+
+ all, err := database.GetAllKV(ctx, "test")
+ require.NoError(t, err)
+ assert.Empty(t, all)
+
+ // Deleting again on the now-empty bucket still works.
+ assert.NoError(t, database.DeleteAllKV(ctx, "test"))
+}
+
+// TestGenericRepository_GetNotFound verifies the typed not-found error.
+func TestGenericRepository_GetNotFound(t *testing.T) {
+ ctx := context.Background()
+ database := newTestDB(t, "notfound.db")
+
+ type Ent struct {
+ ID string `json:"id"`
+ }
+ repo := NewGenericRepository[*Ent](database, "test")
+
+ _, err := repo.Get(ctx, "missing")
+ require.Error(t, err)
+ assert.ErrorIs(t, err, ErrNotFound)
+}
+
+// TestGenericRepository_GetAllCorrupt verifies that a corrupt row fails the
+// whole read instead of being silently skipped.
+func TestGenericRepository_GetAllCorrupt(t *testing.T) {
+ ctx := context.Background()
+ database := newTestDB(t, "corrupt.db")
+
+ type Ent struct {
+ ID string `json:"id"`
+ }
+ repo := NewGenericRepository[*Ent](database, "test")
+
+ require.NoError(t, repo.Save(ctx, "good", &Ent{ID: "good"}))
+ require.NoError(t, database.PutKV(ctx, "test", []byte("bad"), []byte("{not json")))
+
+ _, err := repo.GetAll(ctx)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), `"bad"`)
+}
diff --git a/db/generic_repository.go b/db/generic_repository.go
index c349c7f..fed6ce4 100644
--- a/db/generic_repository.go
+++ b/db/generic_repository.go
@@ -4,7 +4,6 @@ import (
"context"
"encoding/json"
"fmt"
- "log"
)
// GenericRepository provides a generic repository implementation
@@ -41,7 +40,7 @@ func (r *GenericRepository[T]) Get(ctx context.Context, key string) (T, error) {
}
if data == nil {
- return entity, fmt.Errorf("entity not found")
+ return entity, fmt.Errorf("%w: key %q", ErrNotFound, key)
}
if err := json.Unmarshal(data, &entity); err != nil {
@@ -62,8 +61,7 @@ func (r *GenericRepository[T]) GetAll(ctx context.Context) (map[string]T, error)
for key, value := range data {
var entity T
if err := json.Unmarshal(value, &entity); err != nil {
- log.Printf("Failed to unmarshal entity for key %s: %v", key, err)
- continue
+ return nil, fmt.Errorf("failed to unmarshal entity for key %q: %w", key, err)
}
result[key] = entity
}
diff --git a/db/interfaces.go b/db/interfaces.go
index 18989c0..da5d8a5 100644
--- a/db/interfaces.go
+++ b/db/interfaces.go
@@ -2,8 +2,13 @@ package db
import (
"context"
+ "errors"
)
+// ErrNotFound is returned when a requested entity does not exist.
+// Use errors.Is to distinguish it from real backend failures.
+var ErrNotFound = errors.New("entity not found")
+
// Database defines the interface for database operations
type Database interface {
Close() error
diff --git a/dhcp/dhcp_test.go b/dhcp/dhcp_test.go
index 468717f..e6ae482 100644
--- a/dhcp/dhcp_test.go
+++ b/dhcp/dhcp_test.go
@@ -6,6 +6,8 @@ import (
"testing"
"time"
+ "ignite/db"
+
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
@@ -114,8 +116,8 @@ func TestDHCPServerService_CreateServer(t *testing.T) {
LeaseDuration: 2 * time.Hour,
}
- // Mock expectations
- mockServerRepo.On("GetByIP", ctx, config.IP).Return(nil, assert.AnError)
+ // Mock expectations - no existing server with this IP
+ mockServerRepo.On("GetByIP", ctx, config.IP).Return(nil, db.ErrNotFound)
mockServerRepo.On("Save", ctx, mock.AnythingOfType("*dhcp.Server")).Return(nil)
// Execute
@@ -131,6 +133,34 @@ func TestDHCPServerService_CreateServer(t *testing.T) {
mockServerRepo.AssertExpectations(t)
}
+func TestDHCPServerService_CreateServer_GetByIPFailure(t *testing.T) {
+ // A real backend failure from GetByIP must propagate rather than being
+ // misread as "no existing server".
+ ctx := context.Background()
+ mockServerRepo := &MockServerRepository{}
+ mockLeaseRepo := &MockLeaseRepository{}
+
+ service := NewDHCPServerService(mockServerRepo, mockLeaseRepo)
+
+ config := ServerConfig{
+ IP: net.ParseIP("192.168.1.10"),
+ SubnetMask: net.ParseIP("255.255.255.0"),
+ Gateway: net.ParseIP("192.168.1.1"),
+ DNS: net.ParseIP("8.8.8.8"),
+ StartIP: net.ParseIP("192.168.1.100"),
+ LeaseRange: 50,
+ LeaseDuration: 2 * time.Hour,
+ }
+
+ mockServerRepo.On("GetByIP", ctx, config.IP).Return(nil, assert.AnError)
+
+ server, err := service.CreateServer(ctx, config)
+
+ assert.Error(t, err)
+ assert.Nil(t, server)
+ mockServerRepo.AssertExpectations(t)
+}
+
// TestDHCPServerService_CreateServer_DuplicateIP tests duplicate IP handling
func TestDHCPServerService_CreateServer_DuplicateIP(t *testing.T) {
ctx := context.Background()
@@ -190,7 +220,7 @@ func TestDHCPLeaseService_AssignLease(t *testing.T) {
// Mock expectations
mockServerRepo.On("Get", ctx, serverID).Return(server, nil)
- mockLeaseRepo.On("GetByMAC", ctx, mac).Return(nil, assert.AnError) // No existing lease
+ mockLeaseRepo.On("GetByMAC", ctx, mac).Return(nil, db.ErrNotFound) // No existing lease
mockLeaseRepo.On("GetByServerID", ctx, serverID).Return([]*Lease{}, nil) // No existing leases
mockLeaseRepo.On("Save", ctx, mock.AnythingOfType("*dhcp.Lease")).Return(nil)
@@ -201,7 +231,7 @@ func TestDHCPLeaseService_AssignLease(t *testing.T) {
assert.NoError(t, err)
assert.NotNil(t, lease)
assert.Equal(t, mac, lease.MAC)
- assert.Equal(t, requestedIP, lease.IP)
+ assert.True(t, lease.IP.Equal(requestedIP), "expected %v, got %v", requestedIP, lease.IP)
assert.Equal(t, serverID, lease.ServerID)
assert.False(t, lease.Reserved)
diff --git a/dhcp/fixes_test.go b/dhcp/fixes_test.go
new file mode 100644
index 0000000..5913853
--- /dev/null
+++ b/dhcp/fixes_test.go
@@ -0,0 +1,203 @@
+package dhcp
+
+import (
+ "context"
+ "errors"
+ "net"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/mock"
+ "github.com/stretchr/testify/require"
+)
+
+func TestIncrementIP(t *testing.T) {
+ // Normal increment
+ ip, err := incrementIP(net.ParseIP("192.168.1.1"), 5)
+ require.NoError(t, err)
+ assert.Equal(t, "192.168.1.6", ip.String())
+ assert.Len(t, ip, 4, "result should be normalized to 4 bytes")
+
+ // Accepts 16-byte IPv4-in-IPv6 form
+ ip, err = incrementIP(net.ParseIP("192.168.1.1").To16(), 1)
+ require.NoError(t, err)
+ assert.Equal(t, "192.168.1.2", ip.String())
+
+ // Rejects IPv6
+ _, err = incrementIP(net.ParseIP("2001:db8::1"), 1)
+ assert.Error(t, err)
+
+ // Rejects nil
+ _, err = incrementIP(nil, 1)
+ assert.Error(t, err)
+
+ // Rejects negative increments
+ _, err = incrementIP(net.ParseIP("192.168.1.10"), -1)
+ assert.Error(t, err)
+
+ // Rejects overflow past 255.255.255.255 instead of wrapping
+ _, err = incrementIP(net.ParseIP("255.255.255.255"), 1)
+ assert.Error(t, err)
+}
+
+func TestIpToInt(t *testing.T) {
+ n, err := ipToInt(net.ParseIP("192.168.1.1"))
+ require.NoError(t, err)
+ assert.Equal(t, uint32(0xC0A80101), n)
+
+ _, err = ipToInt(net.ParseIP("2001:db8::1"))
+ assert.Error(t, err)
+
+ _, err = ipToInt(nil)
+ assert.Error(t, err)
+}
+
+func TestServer_IsInRange(t *testing.T) {
+ server := &Server{
+ IPStart: net.ParseIP("192.168.1.100"),
+ LeaseRange: 50,
+ }
+
+ // Works with both 16-byte and 4-byte forms of the candidate IP
+ assert.True(t, server.IsInRange(net.ParseIP("192.168.1.120")))
+ assert.True(t, server.IsInRange(net.ParseIP("192.168.1.120").To4()))
+ assert.True(t, server.IsInRange(net.ParseIP("192.168.1.100")))
+ assert.False(t, server.IsInRange(net.ParseIP("192.168.1.99")))
+ assert.False(t, server.IsInRange(net.ParseIP("192.168.1.150")))
+ assert.False(t, server.IsInRange(net.ParseIP("10.0.0.1")))
+ assert.False(t, server.IsInRange(nil))
+ assert.False(t, server.IsInRange(net.ParseIP("2001:db8::1")))
+
+ // A huge LeaseRange cannot wrap around past 255.255.255.255
+ wide := &Server{IPStart: net.ParseIP("255.255.255.250"), LeaseRange: 1 << 30}
+ assert.False(t, wide.IsInRange(net.ParseIP("0.0.0.5")))
+ assert.True(t, wide.IsInRange(net.ParseIP("255.255.255.255")))
+}
+
+func TestServer_GetNetworkAddress(t *testing.T) {
+ server := &Server{
+ IP: net.ParseIP("192.168.1.10"),
+ Options: DHCPOptions{SubnetMask: net.ParseIP("255.255.255.0")},
+ }
+ assert.Equal(t, "192.168.1.0", server.GetNetworkAddress().String())
+
+ broken := &Server{IP: net.ParseIP("2001:db8::1"), Options: DHCPOptions{SubnetMask: net.ParseIP("255.255.255.0")}}
+ assert.Nil(t, broken.GetNetworkAddress())
+}
+
+func TestValidateServerConfig(t *testing.T) {
+ service := NewDHCPServerService(nil, nil)
+ valid := ServerConfig{
+ IP: net.ParseIP("192.168.1.10"),
+ SubnetMask: net.ParseIP("255.255.255.0"),
+ Gateway: net.ParseIP("192.168.1.1"),
+ DNS: net.ParseIP("8.8.8.8"),
+ StartIP: net.ParseIP("192.168.1.100"),
+ LeaseRange: 50,
+ LeaseDuration: 2 * time.Hour,
+ }
+ assert.NoError(t, service.validateServerConfig(valid))
+
+ cases := map[string]func(*ServerConfig){
+ "IPv6 server IP": func(c *ServerConfig) { c.IP = net.ParseIP("2001:db8::1") },
+ "non-contiguous mask": func(c *ServerConfig) { c.SubnetMask = net.ParseIP("255.0.255.0") },
+ "IPv6 gateway": func(c *ServerConfig) { c.Gateway = net.ParseIP("2001:db8::1") },
+ "start IP outside subnet": func(c *ServerConfig) {
+ c.StartIP = net.ParseIP("192.168.2.100")
+ },
+ "range overflows IPv4": func(c *ServerConfig) {
+ c.StartIP = net.ParseIP("255.255.255.250")
+ c.LeaseRange = 10
+ },
+ "non-positive range": func(c *ServerConfig) { c.LeaseRange = 0 },
+ }
+ for name, mutate := range cases {
+ cfg := valid
+ mutate(&cfg)
+ assert.Error(t, service.validateServerConfig(cfg), name)
+ }
+}
+
+func TestLease_UpdateState(t *testing.T) {
+ lease := &Lease{State: StateAssigned}
+ before := time.Now()
+ lease.UpdateState(StateBooting, "dhcp")
+ after := time.Now()
+
+ assert.Equal(t, StateBooting, lease.State)
+ require.Len(t, lease.StateHistory, 1)
+ tr := lease.StateHistory[0]
+ assert.Equal(t, StateAssigned, tr.FromState)
+ assert.Equal(t, StateBooting, tr.ToState)
+ assert.Equal(t, "dhcp", tr.Source)
+
+ // One timestamp drives the transition, StateUpdatedAt, and LastSeen.
+ assert.False(t, tr.Timestamp.Before(before))
+ assert.False(t, tr.Timestamp.After(after))
+ assert.Equal(t, tr.Timestamp, lease.StateUpdatedAt)
+ assert.Equal(t, tr.Timestamp, lease.LastSeen)
+
+ // Re-setting the same state records no new transition but refreshes LastSeen.
+ lease.UpdateState(StateBooting, "dhcp")
+ assert.Len(t, lease.StateHistory, 1)
+}
+
+func TestLease_IsActive(t *testing.T) {
+ assert.True(t, (&Lease{State: StateAssigned}).IsActive())
+ assert.True(t, (&Lease{State: StateImaging}).IsActive())
+ assert.False(t, (&Lease{State: StateComplete}).IsActive(), "complete is not active")
+ assert.False(t, (&Lease{State: StateOffline}).IsActive())
+ assert.False(t, (&Lease{State: StateFailed}).IsActive())
+}
+
+func TestProtocolHandler_StopBeforeStart(t *testing.T) {
+ h := NewProtocolHandler(&Server{}, nil)
+ err := h.Stop()
+ assert.True(t, errors.Is(err, ErrHandlerNotRunning))
+}
+
+func TestProtocolHandler_DoubleStart(t *testing.T) {
+ h := NewProtocolHandler(&Server{IP: net.ParseIP("127.0.0.1")}, nil)
+ if err := h.Start(); err != nil {
+ t.Skipf("cannot bind UDP :67 in this environment: %v", err)
+ }
+ defer func() { _ = h.Stop() }()
+
+ // A second start must fail without disturbing the original listener.
+ err := h.Start()
+ assert.Error(t, err)
+ assert.NoError(t, h.Stop())
+}
+
+func TestDHCPLeaseService_AssignLease_RejectsForeignServerLease(t *testing.T) {
+ ctx := context.Background()
+ mockServerRepo := &MockServerRepository{}
+ mockLeaseRepo := &MockLeaseRepository{}
+
+ service := NewDHCPLeaseService(mockLeaseRepo, mockServerRepo)
+
+ server := &Server{
+ ID: "server-a",
+ IP: net.ParseIP("192.168.1.1"),
+ IPStart: net.ParseIP("192.168.1.100"),
+ LeaseRange: 50,
+ LeaseDuration: 2 * time.Hour,
+ }
+ foreignLease := &Lease{
+ ID: "lease-1",
+ MAC: "00:11:22:33:44:55",
+ IP: net.ParseIP("10.0.0.50"),
+ ServerID: "server-b",
+ Expiry: time.Now().Add(time.Hour),
+ State: StateAssigned,
+ }
+
+ mockServerRepo.On("Get", ctx, "server-a").Return(server, nil)
+ mockLeaseRepo.On("GetByMAC", ctx, foreignLease.MAC).Return(foreignLease, nil)
+
+ lease, err := service.AssignLease(ctx, "server-a", foreignLease.MAC, nil)
+ assert.Error(t, err, "must not extend a lease owned by another server")
+ assert.Nil(t, lease)
+ mockLeaseRepo.AssertNotCalled(t, "Save", ctx, mock.Anything)
+}
diff --git a/dhcp/lease_service.go b/dhcp/lease_service.go
index 272f139..8c19060 100644
--- a/dhcp/lease_service.go
+++ b/dhcp/lease_service.go
@@ -2,10 +2,14 @@ package dhcp
import (
"context"
+ "errors"
"fmt"
"net"
+ "sync"
"time"
+ "ignite/db"
+
"github.com/google/uuid"
)
@@ -13,6 +17,9 @@ import (
type DHCPLeaseService struct {
leaseRepo LeaseRepository
serverRepo ServerRepository
+ // allocMu serializes IP allocation (scan -> select -> save) across the
+ // service path and the DHCP packet path (ProtocolHandler).
+ allocMu *sync.Mutex
}
// NewDHCPLeaseService creates a new lease service
@@ -20,11 +27,17 @@ func NewDHCPLeaseService(leaseRepo LeaseRepository, serverRepo ServerRepository)
return &DHCPLeaseService{
leaseRepo: leaseRepo,
serverRepo: serverRepo,
+ allocMu: &sharedAllocMu,
}
}
// AssignLease assigns an IP lease to a MAC address
func (s *DHCPLeaseService) AssignLease(ctx context.Context, serverID string, mac string, requestedIP net.IP) (*Lease, error) {
+ // Hold the shared allocation lock across scan -> select -> save so the
+ // packet path (ProtocolHandler) cannot interleave an allocation.
+ s.allocMu.Lock()
+ defer s.allocMu.Unlock()
+
server, err := s.serverRepo.Get(ctx, serverID)
if err != nil {
return nil, fmt.Errorf("failed to get server: %w", err)
@@ -32,7 +45,12 @@ func (s *DHCPLeaseService) AssignLease(ctx context.Context, serverID string, mac
// Check if MAC already has a lease
existingLease, err := s.leaseRepo.GetByMAC(ctx, mac)
- if err == nil && existingLease != nil {
+ if err == nil {
+ // An existing lease may only be extended when it belongs to the
+ // requested server; a lease from another server is not ours to renew.
+ if existingLease.ServerID != serverID {
+ return nil, fmt.Errorf("MAC %s already has a lease on another server", mac)
+ }
if !existingLease.IsExpired() {
// Extend existing lease if not expired
existingLease.Extend(server.LeaseDuration)
@@ -41,14 +59,21 @@ func (s *DHCPLeaseService) AssignLease(ctx context.Context, serverID string, mac
}
return existingLease, nil
}
+ // The old lease is expired: delete it before creating its replacement.
+ // A not-found here just means someone else already removed it.
+ if err := s.leaseRepo.DeleteByMAC(ctx, mac); err != nil && !errors.Is(err, db.ErrNotFound) {
+ return nil, fmt.Errorf("failed to delete expired lease: %w", err)
+ }
+ } else if !errors.Is(err, db.ErrNotFound) {
+ return nil, fmt.Errorf("failed to look up existing lease: %w", err)
}
// Determine IP to assign
var assignIP net.IP
- if requestedIP != nil && server.IsInRange(requestedIP) {
+ if requestedIP.To4() != nil && server.IsInRange(requestedIP) {
// Check if requested IP is available
if s.isIPAvailable(ctx, serverID, requestedIP, mac) {
- assignIP = requestedIP
+ assignIP = requestedIP.To4()
}
}
@@ -60,22 +85,24 @@ func (s *DHCPLeaseService) AssignLease(ctx context.Context, serverID string, mac
}
}
+ // Create new lease
+ now := time.Now()
lease := &Lease{
ID: uuid.New().String(),
- IP: assignIP,
MAC: mac,
- Expiry: time.Now().Add(server.LeaseDuration),
- Reserved: false,
+ IP: assignIP,
ServerID: serverID,
+ Expiry: now.Add(server.LeaseDuration),
+ Reserved: false,
State: StateAssigned,
- StateUpdatedAt: time.Now(),
- LastSeen: time.Now(),
- StateHistory: []StateTransition{},
+ StateUpdatedAt: now,
+ LastSeen: now,
+ StateHistory: []StateTransition{
+ {FromState: "", ToState: StateAssigned, Timestamp: now, Source: "dhcp"},
+ },
}
- // Record initial state
- lease.UpdateState(StateAssigned, "dhcp")
-
+ // Save lease
if err := s.leaseRepo.Save(ctx, lease); err != nil {
return nil, fmt.Errorf("failed to save lease: %w", err)
}
@@ -85,10 +112,13 @@ func (s *DHCPLeaseService) AssignLease(ctx context.Context, serverID string, mac
// ReleaseLease releases a lease by MAC address
func (s *DHCPLeaseService) ReleaseLease(ctx context.Context, mac string) error {
- return s.leaseRepo.DeleteByMAC(ctx, mac)
+ if err := s.leaseRepo.DeleteByMAC(ctx, mac); err != nil {
+ return fmt.Errorf("failed to release lease: %w", err)
+ }
+ return nil
}
-// ReserveLease creates a reserved lease for a specific MAC and IP
+// ReserveLease reserves a specific IP for a MAC address
func (s *DHCPLeaseService) ReserveLease(ctx context.Context, serverID string, mac string, ip net.IP) error {
server, err := s.serverRepo.Get(ctx, serverID)
if err != nil {
@@ -96,48 +126,57 @@ func (s *DHCPLeaseService) ReserveLease(ctx context.Context, serverID string, ma
}
if !server.IsInRange(ip) {
- return fmt.Errorf("IP %s is not in server range", ip)
+ return fmt.Errorf("IP %s is not in server range", ip.String())
}
+ // Check if IP is already reserved
if !s.isIPAvailable(ctx, serverID, ip, mac) {
- return fmt.Errorf("IP %s is already in use", ip)
+ return fmt.Errorf("IP %s is already in use", ip.String())
}
- // Remove any existing lease for this MAC
- s.leaseRepo.DeleteByMAC(ctx, mac)
+ // Remove any existing lease for this MAC first.
+ // Only a genuine "not found" is fine; anything else is a real failure.
+ if err := s.leaseRepo.DeleteByMAC(ctx, mac); err != nil && !errors.Is(err, db.ErrNotFound) {
+ return fmt.Errorf("failed to remove existing lease for MAC %s: %w", mac, err)
+ }
+ // Create reserved lease
+ now := time.Now()
lease := &Lease{
ID: uuid.New().String(),
- IP: ip,
MAC: mac,
- Expiry: time.Now().Add(server.LeaseDuration),
- Reserved: true,
+ IP: ip.To4(),
ServerID: serverID,
+ Expiry: now.Add(server.LeaseDuration),
+ Reserved: true,
State: StateAssigned,
- StateUpdatedAt: time.Now(),
- LastSeen: time.Now(),
- StateHistory: []StateTransition{},
+ StateUpdatedAt: now,
+ LastSeen: now,
+ StateHistory: []StateTransition{
+ {FromState: "", ToState: StateAssigned, Timestamp: now, Source: "manual"},
+ },
}
- // Record initial state for reserved lease
- lease.UpdateState(StateAssigned, "manual")
+ if err := s.leaseRepo.Save(ctx, lease); err != nil {
+ return fmt.Errorf("failed to save reserved lease: %w", err)
+ }
- return s.leaseRepo.Save(ctx, lease)
+ return nil
}
-// UnreserveLease removes a reservation for a MAC address
+// UnreserveLease removes a reservation by MAC address
func (s *DHCPLeaseService) UnreserveLease(ctx context.Context, mac string) error {
lease, err := s.leaseRepo.GetByMAC(ctx, mac)
if err != nil {
- return fmt.Errorf("lease not found for MAC %s: %w", mac, err)
+ return fmt.Errorf("failed to get lease: %w", err)
}
- if !lease.Reserved {
- return fmt.Errorf("lease for MAC %s is not reserved", mac)
+ lease.Reserved = false
+ if err := s.leaseRepo.Save(ctx, lease); err != nil {
+ return fmt.Errorf("failed to unreserve lease: %w", err)
}
- lease.Reserved = false
- return s.leaseRepo.Save(ctx, lease)
+ return nil
}
// GetLeaseByMAC retrieves a lease by MAC address
@@ -150,114 +189,46 @@ func (s *DHCPLeaseService) GetLeasesByServer(ctx context.Context, serverID strin
return s.leaseRepo.GetByServerID(ctx, serverID)
}
-// CleanupExpiredLeases removes all expired leases
+// CleanupExpiredLeases removes expired leases
func (s *DHCPLeaseService) CleanupExpiredLeases(ctx context.Context) error {
return s.leaseRepo.CleanupExpired(ctx)
}
// UpdateLease updates an existing lease
func (s *DHCPLeaseService) UpdateLease(ctx context.Context, lease *Lease) error {
- return s.leaseRepo.Save(ctx, lease)
-}
-
-// isIPAvailable checks if an IP is available for assignment
-func (s *DHCPLeaseService) isIPAvailable(ctx context.Context, serverID string, ip net.IP, excludeMAC string) bool {
- leases, err := s.leaseRepo.GetByServerID(ctx, serverID)
- if err != nil {
- return false
- }
-
- for _, lease := range leases {
- if lease.IP.Equal(ip) && lease.MAC != excludeMAC && !lease.IsExpired() {
- return false
- }
- }
- return true
-}
-
-// findAvailableIP finds the next available IP in the server's range
-func (s *DHCPLeaseService) findAvailableIP(ctx context.Context, serverID string, excludeMAC string) (net.IP, error) {
- server, err := s.serverRepo.Get(ctx, serverID)
- if err != nil {
- return nil, fmt.Errorf("failed to get server: %w", err)
- }
-
- leases, err := s.leaseRepo.GetByServerID(ctx, serverID)
- if err != nil {
- return nil, fmt.Errorf("failed to get leases: %w", err)
- }
-
- // Create a map of used IPs
- usedIPs := make(map[string]bool)
- for _, lease := range leases {
- if !lease.IsExpired() && lease.MAC != excludeMAC {
- usedIPs[lease.IP.String()] = true
- }
- }
-
- // Find first available IP in range
- for i := 0; i < server.LeaseRange; i++ {
- candidate := incrementIP(server.IPStart, i)
- if !usedIPs[candidate.String()] {
- return candidate, nil
- }
- }
-
- return nil, fmt.Errorf("no available IP addresses in range")
-}
-
-// incrementIP increments an IP address by the given amount
-func incrementIP(ip net.IP, increment int) net.IP {
- result := make(net.IP, len(ip))
- copy(result, ip)
-
- // Convert to IPv4 if needed
- if len(result) == 16 {
- result = result[12:16]
+ if err := s.leaseRepo.Save(ctx, lease); err != nil {
+ return fmt.Errorf("failed to update lease: %w", err)
}
-
- // Convert to uint32, add increment, convert back
- ipInt := uint32(result[0])<<24 + uint32(result[1])<<16 + uint32(result[2])<<8 + uint32(result[3])
- ipInt += uint32(increment)
-
- result[0] = byte(ipInt >> 24)
- result[1] = byte(ipInt >> 16)
- result[2] = byte(ipInt >> 8)
- result[3] = byte(ipInt)
-
- return result
+ return nil
}
-// UpdateLeaseState updates the state of a lease and records the transition
+// UpdateLeaseState updates the state of a lease with state tracking
func (s *DHCPLeaseService) UpdateLeaseState(ctx context.Context, mac string, newState string, source string) error {
lease, err := s.leaseRepo.GetByMAC(ctx, mac)
if err != nil {
- return fmt.Errorf("failed to get lease for MAC %s: %w", mac, err)
+ return fmt.Errorf("failed to get lease: %w", err)
}
- if lease == nil {
- return fmt.Errorf("lease not found for MAC %s", mac)
- }
-
- lease.UpdateState(newState, source)
+ lease.UpdateState(LeaseState(newState), source)
if err := s.leaseRepo.Save(ctx, lease); err != nil {
- return fmt.Errorf("failed to save lease state update: %w", err)
+ return fmt.Errorf("failed to save lease state: %w", err)
}
return nil
}
-// RecordHeartbeat updates the last seen time for a lease
+// RecordHeartbeat records a heartbeat from a machine, updating LastSeen
func (s *DHCPLeaseService) RecordHeartbeat(ctx context.Context, mac string) error {
lease, err := s.leaseRepo.GetByMAC(ctx, mac)
if err != nil {
- return fmt.Errorf("failed to get lease for MAC %s: %w", mac, err)
- }
- if lease == nil {
- return fmt.Errorf("lease not found for MAC %s", mac)
+ return fmt.Errorf("failed to get lease: %w", err)
}
lease.LastSeen = time.Now()
+ // If lease was offline and now checking in, mark it as needing attention
+ if lease.State == StateOffline {
+ lease.UpdateState(StateAssigned, "heartbeat")
+ }
if err := s.leaseRepo.Save(ctx, lease); err != nil {
return fmt.Errorf("failed to save heartbeat: %w", err)
@@ -266,14 +237,11 @@ func (s *DHCPLeaseService) RecordHeartbeat(ctx context.Context, mac string) erro
return nil
}
-// GetLeaseStateHistory returns the state history for a lease
+// GetLeaseStateHistory returns the state transition history for a lease
func (s *DHCPLeaseService) GetLeaseStateHistory(ctx context.Context, mac string) ([]StateTransition, error) {
lease, err := s.leaseRepo.GetByMAC(ctx, mac)
if err != nil {
- return nil, fmt.Errorf("failed to get lease for MAC %s: %w", mac, err)
- }
- if lease == nil {
- return nil, fmt.Errorf("lease not found for MAC %s", mac)
+ return nil, fmt.Errorf("failed to get lease: %w", err)
}
return lease.StateHistory, nil
@@ -281,28 +249,26 @@ func (s *DHCPLeaseService) GetLeaseStateHistory(ctx context.Context, mac string)
// GetLeasesByState returns all leases in a specific state
func (s *DHCPLeaseService) GetLeasesByState(ctx context.Context, state string) ([]*Lease, error) {
- // This would require extending the repository interface to support state filtering
- // For now, we'll get all leases and filter in memory
+ wanted := LeaseState(state)
servers, err := s.serverRepo.GetAll(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get servers: %w", err)
}
- var filteredLeases []*Lease
+ var result []*Lease
for _, server := range servers {
leases, err := s.leaseRepo.GetByServerID(ctx, server.ID)
if err != nil {
- continue // Skip servers that can't be queried
+ return nil, fmt.Errorf("failed to get leases for server %s: %w", server.ID, err)
}
-
for _, lease := range leases {
- if lease.State == state {
- filteredLeases = append(filteredLeases, lease)
+ if lease.State == wanted {
+ result = append(result, lease)
}
}
}
- return filteredLeases, nil
+ return result, nil
}
// MarkOfflineLeases marks leases as offline if they haven't been seen recently
@@ -317,16 +283,75 @@ func (s *DHCPLeaseService) MarkOfflineLeases(ctx context.Context, offlineThresho
for _, server := range servers {
leases, err := s.leaseRepo.GetByServerID(ctx, server.ID)
if err != nil {
- continue // Skip servers that can't be queried
+ return fmt.Errorf("failed to get leases for server %s: %w", server.ID, err)
}
for _, lease := range leases {
if lease.IsActive() && lease.LastSeen.Before(cutoffTime) {
lease.UpdateState(StateOffline, "heartbeat")
- s.leaseRepo.Save(ctx, lease) // Ignore error and continue processing
+ if err := s.leaseRepo.Save(ctx, lease); err != nil {
+ return fmt.Errorf("failed to mark lease %s offline: %w", lease.ID, err)
+ }
}
}
}
return nil
}
+
+// isIPAvailable checks if an IP address is available for assignment
+func (s *DHCPLeaseService) isIPAvailable(ctx context.Context, serverID string, ip net.IP, excludeMAC string) bool {
+ leases, err := s.leaseRepo.GetByServerID(ctx, serverID)
+ if err != nil {
+ return false
+ }
+
+ for _, lease := range leases {
+ if lease.IP.Equal(ip) && lease.MAC != excludeMAC && !lease.IsExpired() {
+ return false
+ }
+ }
+ return true
+}
+
+// findAvailableIP finds the next available IP in the server's range
+func (s *DHCPLeaseService) findAvailableIP(ctx context.Context, serverID string, excludeMAC string) (net.IP, error) {
+ server, err := s.serverRepo.Get(ctx, serverID)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get server: %w", err)
+ }
+
+ for i := 0; i < server.LeaseRange; i++ {
+ candidate, err := incrementIP(server.IPStart, i)
+ if err != nil {
+ return nil, err
+ }
+ if s.isIPAvailable(ctx, serverID, candidate, excludeMAC) {
+ return candidate, nil
+ }
+ }
+
+ return nil, fmt.Errorf("no available IP addresses in range")
+}
+
+// incrementIP increments an IPv4 address by the given amount.
+// It rejects non-IPv4 input and refuses to wrap past 255.255.255.255.
+func incrementIP(ip net.IP, increment int) (net.IP, error) {
+ ip4 := ip.To4()
+ if ip4 == nil {
+ return nil, fmt.Errorf("incrementIP requires an IPv4 address")
+ }
+ if increment < 0 {
+ return nil, fmt.Errorf("incrementIP requires a non-negative increment")
+ }
+ val, err := ipToInt(ip4)
+ if err != nil {
+ return nil, err
+ }
+ if newVal := uint64(val) + uint64(increment); newVal > 0xFFFFFFFF {
+ return nil, fmt.Errorf("IP increment overflows IPv4 address space")
+ } else {
+ val = uint32(newVal)
+ }
+ return net.IPv4(byte(val>>24), byte(val>>16), byte(val>>8), byte(val)).To4(), nil
+}
diff --git a/dhcp/models.go b/dhcp/models.go
index fdd3b07..4fe69b6 100644
--- a/dhcp/models.go
+++ b/dhcp/models.go
@@ -1,6 +1,7 @@
package dhcp
import (
+ "fmt"
"net"
"time"
)
@@ -36,7 +37,7 @@ type Lease struct {
ServerID string `json:"server_id"`
Menu BootMenu `json:"menu"`
IPMI IPMI `json:"ipmi"`
- State string `json:"state"`
+ State LeaseState `json:"state"`
StateUpdatedAt time.Time `json:"state_updated_at"`
LastSeen time.Time `json:"last_seen"`
StateHistory []StateTransition `json:"state_history"`
@@ -44,50 +45,31 @@ type Lease struct {
// StateTransition represents a state change event
type StateTransition struct {
- FromState string `json:"from_state"`
- ToState string `json:"to_state"`
- Timestamp time.Time `json:"timestamp"`
- Source string `json:"source"` // "dhcp", "pxe", "imaging", "manual", "heartbeat"
+ FromState LeaseState `json:"from_state"`
+ ToState LeaseState `json:"to_state"`
+ Timestamp time.Time `json:"timestamp"`
+ Source string `json:"source"` // "dhcp", "pxe", "imaging", "manual", "heartbeat"
}
+// LeaseState is the provisioning state of a lease.
+type LeaseState string
+
// LeaseState constants
const (
- StateAssigned = "assigned" // DHCP lease created, waiting for PXE request
- StatePXERequested = "pxe_requested" // Machine requested PXE boot configuration
- StateBooting = "booting" // PXE config delivered, machine is booting
- StateImaging = "imaging" // OS installation/imaging in progress
- StateImaged = "imaged" // OS imaging completed successfully
- StateConfiguring = "configuring" // Post-install configuration running
- StateComplete = "complete" // Machine fully provisioned and operational
- StateFailed = "failed" // Error occurred in any stage
- StateOffline = "offline" // Machine hasn't checked in recently
+ StateAssigned LeaseState = "assigned" // DHCP lease created, waiting for PXE request
+ StatePXERequested LeaseState = "pxe_requested" // Machine requested PXE boot configuration
+ StateBooting LeaseState = "booting" // PXE config delivered, machine is booting
+ StateImaging LeaseState = "imaging" // OS installation/imaging in progress
+ StateImaged LeaseState = "imaged" // OS imaging completed successfully
+ StateConfiguring LeaseState = "configuring" // Post-install configuration running
+ StateComplete LeaseState = "complete" // Machine fully provisioned and operational
+ StateFailed LeaseState = "failed" // Error occurred in any stage
+ StateOffline LeaseState = "offline" // Machine hasn't checked in recently
)
-// GetStateBadgeClass returns the CSS class for state display
-func (l *Lease) GetStateBadgeClass() string {
- switch l.State {
- case StateAssigned:
- return "badge-info"
- case StatePXERequested:
- return "badge-warning"
- case StateBooting:
- return "badge-warning"
- case StateImaging:
- return "badge-accent"
- case StateImaged:
- return "badge-success"
- case StateConfiguring:
- return "badge-accent"
- case StateComplete:
- return "badge-success"
- case StateFailed:
- return "badge-error"
- case StateOffline:
- return "badge-ghost"
- default:
- return "badge-neutral"
- }
-}
+// NOTE: GetStateBadgeClass (CSS classes for state display) was removed from
+// this package — presentation belongs in handlers, not in the dhcp domain
+// model. handlers/dhcp.go must carry its own copy (integration item).
// GetStateDisplayName returns a human-readable state name
func (l *Lease) GetStateDisplayName() string {
@@ -116,25 +98,28 @@ func (l *Lease) GetStateDisplayName() string {
}
// UpdateState transitions the lease to a new state and records the transition
-func (l *Lease) UpdateState(newState, source string) {
+func (l *Lease) UpdateState(newState LeaseState, source string) {
+ now := time.Now()
if l.State != newState {
transition := StateTransition{
FromState: l.State,
ToState: newState,
- Timestamp: time.Now(),
+ Timestamp: now,
Source: source,
}
l.StateHistory = append(l.StateHistory, transition)
l.State = newState
- l.StateUpdatedAt = time.Now()
+ l.StateUpdatedAt = now
}
- l.LastSeen = time.Now()
+ l.LastSeen = now
}
-// IsActive returns true if the lease is in an active state
+// IsActive returns true if the lease is in an active state.
+// A fully provisioned machine (StateComplete) is not "active" for the
+// purposes of heartbeat/offline tracking.
func (l *Lease) IsActive() bool {
- return l.State != StateOffline && l.State != StateFailed
+ return l.State != StateOffline && l.State != StateFailed && l.State != StateComplete
}
// BootMenu contains PXE boot configuration
@@ -173,26 +158,45 @@ func (l *Lease) Extend(duration time.Duration) {
// GetNetworkAddress returns the network address for the server
func (s *Server) GetNetworkAddress() net.IP {
- return s.IP.Mask(net.IPMask(s.Options.SubnetMask))
+ ip4 := s.IP.To4()
+ mask := net.IPMask(s.Options.SubnetMask.To4())
+ if ip4 == nil || mask == nil {
+ return nil
+ }
+ return ip4.Mask(mask)
}
// IsInRange checks if an IP is within the server's lease range
func (s *Server) IsInRange(ip net.IP) bool {
- if s.IPStart == nil || len(s.IPStart) != len(ip) {
+ start := s.IPStart.To4()
+ ip4 := ip.To4()
+ if start == nil || ip4 == nil {
return false
}
- startInt := ipToInt(s.IPStart)
- ipInt := ipToInt(ip)
- endInt := startInt + uint32(s.LeaseRange)
+ startInt, err := ipToInt(start)
+ if err != nil {
+ return false
+ }
+ ipInt, err := ipToInt(ip4)
+ if err != nil {
+ return false
+ }
+ if s.LeaseRange <= 0 {
+ return false
+ }
- return ipInt >= startInt && ipInt < endInt
+ // Use 64-bit math so a large LeaseRange can't wrap past 255.255.255.255.
+ end := uint64(startInt) + uint64(s.LeaseRange)
+ return uint64(ipInt) >= uint64(startInt) && uint64(ipInt) < end
}
-// Helper function to convert IP to uint32
-func ipToInt(ip net.IP) uint32 {
- if len(ip) == 16 {
- ip = ip[12:16] // Convert IPv6 to IPv4 if needed
+// ipToInt converts an IPv4 address to a uint32.
+// It returns an error instead of panicking on unexpected input.
+func ipToInt(ip net.IP) (uint32, error) {
+ ip4 := ip.To4()
+ if ip4 == nil {
+ return 0, fmt.Errorf("not an IPv4 address")
}
- return uint32(ip[0])<<24 + uint32(ip[1])<<16 + uint32(ip[2])<<8 + uint32(ip[3])
+ return uint32(ip4[0])<<24 | uint32(ip4[1])<<16 | uint32(ip4[2])<<8 | uint32(ip4[3]), nil
}
diff --git a/dhcp/protocol_handler.go b/dhcp/protocol_handler.go
index 56be47d..beee2a5 100644
--- a/dhcp/protocol_handler.go
+++ b/dhcp/protocol_handler.go
@@ -3,85 +3,161 @@ package dhcp
import (
"context"
+ "errors"
"fmt"
"log"
"net"
+ "sync"
"time"
- "ignite/config"
-
+ "github.com/google/uuid"
d4 "github.com/krolaw/dhcp4"
)
-// ProtocolHandler handles DHCP protocol packets for a specific server
+// ErrHandlerNotRunning is returned by Stop when the handler was never started
+// (or has already been stopped).
+var ErrHandlerNotRunning = errors.New("DHCP handler is not running")
+
+// ProtocolBootFiles carries the boot-file settings a ProtocolHandler needs.
+// The values are loaded once at construction time (see WithBootFiles) so the
+// packet path never pays for config.LoadDefault() per packet.
+type ProtocolBootFiles struct {
+ BiosFile string
+ EFIFile string
+}
+
+// ProtocolHandlerOption configures a ProtocolHandler.
+type ProtocolHandlerOption func(*ProtocolHandler)
+
+// WithAllocationMutex sets the mutex used to serialize IP allocation.
+// Handlers that share a mutex (via the DHCPServerService/DHCPLeaseService)
+// close the check-then-act race between scan, select, and save.
+func WithAllocationMutex(mu *sync.Mutex) ProtocolHandlerOption {
+ return func(h *ProtocolHandler) {
+ if mu != nil {
+ h.allocMu = mu
+ }
+ }
+}
+
+// WithBootFiles sets the boot-file settings for the handler.
+func WithBootFiles(files ProtocolBootFiles) ProtocolHandlerOption {
+ return func(h *ProtocolHandler) {
+ h.bootFiles = files
+ }
+}
+
+// ProtocolHandler implements the DHCP protocol handling
type ProtocolHandler struct {
server *Server
leaseRepo LeaseRepository
- listener net.PacketConn
- ctx context.Context
- cancel context.CancelFunc
+
+ // allocMu serializes IP allocation (scan -> select -> save) across the
+ // packet path and the service path. It is shared with
+ // DHCPLeaseService so both allocate from the same critical section.
+ allocMu *sync.Mutex
+ // bootFiles holds the boot-file settings loaded once at construction.
+ bootFiles ProtocolBootFiles
+
+ // mu guards the lifecycle fields below.
+ mu sync.Mutex
+ running bool
+ ctx context.Context
+ cancel context.CancelFunc
+ conn net.PacketConn
+ serveErr error
}
-// NewProtocolHandler creates a new DHCP protocol handler
-func NewProtocolHandler(server *Server, leaseRepo LeaseRepository) *ProtocolHandler {
- return &ProtocolHandler{
+// NewProtocolHandler creates a new DHCP protocol handler.
+// The (server, leaseRepo) call shape is preserved for backward
+// compatibility; functional options are optional.
+func NewProtocolHandler(server *Server, leaseRepo LeaseRepository, opts ...ProtocolHandlerOption) *ProtocolHandler {
+ h := &ProtocolHandler{
server: server,
leaseRepo: leaseRepo,
+ allocMu: &sharedAllocMu,
+ }
+ for _, opt := range opts {
+ opt(h)
}
+ return h
}
-// Start starts the DHCP protocol handler
+// Start binds the DHCP listener and begins serving. It refuses a second
+// start without an intervening Stop and never overwrites the original
+// listener: the bind happens before any lifecycle state is published, so a
+// failed Start leaves the handler exactly as it was.
func (h *ProtocolHandler) Start() error {
- if h.server.IP == nil {
- return fmt.Errorf("server IP is not set")
- }
+ h.mu.Lock()
+ defer h.mu.Unlock()
- h.ctx, h.cancel = context.WithCancel(context.Background())
+ if h.running {
+ return fmt.Errorf("DHCP handler is already running")
+ }
- var err error
- addr := &net.UDPAddr{IP: h.server.IP, Port: 67}
- h.listener, err = net.ListenUDP("udp4", addr)
+ // Bind first; only publish state after the bind succeeds.
+ ctx, cancel := context.WithCancel(context.Background())
+ conn, err := net.ListenPacket("udp4", ":67")
if err != nil {
- return fmt.Errorf("failed to listen on %s: %w", addr, err)
+ cancel()
+ return fmt.Errorf("failed to listen on UDP port 67: %w", err)
}
- go func() {
- defer func() {
- if err := h.listener.Close(); err != nil {
- log.Printf("Failed to close listener: %v", err)
- }
- }()
-
- if err := d4.Serve(h.listener, h); err != nil {
- log.Printf("Error serving DHCP requests: %v", err)
- }
- }()
+ h.ctx = ctx
+ h.cancel = cancel
+ h.conn = conn
+ h.running = true
+ h.serveErr = nil
+ go h.serveLoop()
return nil
}
-// Stop stops the DHCP protocol handler
+// Stop shuts the handler down. Stopping a handler that was never started
+// (or is already stopped) is a clean ErrHandlerNotRunning, not a hang.
func (h *ProtocolHandler) Stop() error {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+
+ if !h.running {
+ return ErrHandlerNotRunning
+ }
+
+ h.running = false
+ if h.conn != nil {
+ // Closing the socket unblocks the serve loop's ReadFrom.
+ _ = h.conn.Close()
+ }
if h.cancel != nil {
h.cancel()
}
+ return nil
+}
- if h.listener != nil {
- if err := h.listener.Close(); err != nil {
- return fmt.Errorf("failed to close listener: %w", err)
- }
+// ServeError returns the last error from the serve loop, if any.
+func (h *ProtocolHandler) ServeError() error {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ return h.serveErr
+}
+
+// serveLoop runs the DHCP serve loop until the socket closes.
+func (h *ProtocolHandler) serveLoop() {
+ err := d4.Serve(h.conn, h)
+
+ h.mu.Lock()
+ h.running = false
+ if err != nil {
+ h.serveErr = err
}
+ h.mu.Unlock()
- // Wait for context to be done or timeout
- select {
- case <-h.ctx.Done():
- return nil
- case <-time.After(5 * time.Second):
- return fmt.Errorf("timeout waiting for server to stop")
+ if err != nil {
+ log.Printf("DHCP server stopped with error: %v", err)
}
}
-// ServeDHCP implements the DHCP packet handler interface
+// ServeDHCP handles DHCP protocol messages
func (h *ProtocolHandler) ServeDHCP(p d4.Packet, msgType d4.MessageType, options d4.Options) d4.Packet {
switch msgType {
case d4.Discover:
@@ -96,83 +172,154 @@ func (h *ProtocolHandler) ServeDHCP(p d4.Packet, msgType d4.MessageType, options
}
}
+// newLease builds a fully-initialized lease record for a fresh allocation:
+// unique ID, assigned state, state-history entry, and timestamps.
+func (h *ProtocolHandler) newLease(mac string, ip net.IP) *Lease {
+ now := time.Now()
+ return &Lease{
+ ID: uuid.New().String(),
+ MAC: mac,
+ IP: ip,
+ ServerID: h.server.ID,
+ Expiry: now.Add(h.server.LeaseDuration),
+ Reserved: false,
+ State: StateAssigned,
+ StateUpdatedAt: now,
+ LastSeen: now,
+ StateHistory: []StateTransition{
+ {FromState: "", ToState: StateAssigned, Timestamp: now, Source: "dhcp"},
+ },
+ }
+}
+
// handleDiscover processes DHCP Discover messages
func (h *ProtocolHandler) handleDiscover(p d4.Packet, options d4.Options) d4.Packet {
+ // Serialize with the allocation path so the scan below can't interleave
+ // with a concurrent request's save.
+ h.allocMu.Lock()
+ defer h.allocMu.Unlock()
+
ctx := context.Background()
mac := p.CHAddr().String()
- // Determine boot type and filename
- filename := h.getBootFilename(options)
- dhcpOptions := h.buildDHCPOptions(filename)
-
- // Check for existing reserved lease
- lease, err := h.leaseRepo.GetByMAC(ctx, mac)
- if err == nil && lease != nil && lease.Reserved && lease.ServerID == h.server.ID {
- return h.createOfferPacket(p, lease.IP, dhcpOptions)
+ // Re-offer the current IP when the MAC already has a live lease — or a
+ // static reservation, which never expires for offer purposes — on this
+ // server. No row is persisted for an offer; the lease is created on
+ // request, where conflicts are resolved under the same lock.
+ if lease, err := h.leaseRepo.GetByMAC(ctx, mac); err == nil && lease != nil {
+ if lease.ServerID == h.server.ID && (lease.Reserved || !lease.IsExpired()) {
+ return h.createOfferPacket(p, lease.IP, options)
+ }
}
// Find available IP
- availableIP := h.findAvailableIP(ctx, mac)
- if availableIP == nil {
+ ip := h.findAvailableIP(ctx, mac)
+ if ip == nil {
log.Printf("No available IP for MAC %s", mac)
return nil
}
- return h.createOfferPacket(p, availableIP, dhcpOptions)
+ return h.createOfferPacket(p, ip, options)
}
// handleRequest processes DHCP Request messages
func (h *ProtocolHandler) handleRequest(p d4.Packet, options d4.Options) d4.Packet {
+ // Serialize scan -> select -> save against the service allocation path.
+ h.allocMu.Lock()
+ defer h.allocMu.Unlock()
+
ctx := context.Background()
mac := p.CHAddr().String()
- requestedIP := h.getRequestedIP(options, p)
+ requestedIP := net.IP(options[d4.OptionRequestedIPAddress])
if requestedIP == nil {
- return h.createNakPacket(p)
+ // Some clients put it in CIAddr
+ requestedIP = p.CIAddr()
}
- // Check if request is for another server
+ // Silently ignore requests addressed to a different DHCP server.
if h.isRequestForAnotherServer(options) {
return nil
}
- // Check existing lease
+ // validRequest is true when the requested address is a usable IPv4
+ // address inside this server's range and not taken by another client.
+ validRequest := requestedIP.To4() != nil &&
+ h.server.IsInRange(requestedIP) &&
+ h.isIPAvailable(ctx, requestedIP, mac)
+
+ // Check for existing lease
lease, err := h.leaseRepo.GetByMAC(ctx, mac)
- if err == nil && lease != nil && lease.ServerID == h.server.ID {
- if lease.Reserved && !requestedIP.Equal(lease.IP) {
+ if err == nil && lease != nil {
+ // An existing lease may only be extended when it belongs to this
+ // server; otherwise NAK so the client restarts cleanly.
+ if lease.ServerID != h.server.ID {
+ log.Printf("Lease for MAC %s belongs to another server; sending NAK", mac)
return h.createNakPacket(p)
}
-
- if requestedIP.Equal(lease.IP) {
- // Update lease expiry
+ // A static reservation pins the MAC to its IP: NAK anything else.
+ if lease.Reserved && !lease.IP.Equal(requestedIP) {
+ log.Printf("MAC %s has reserved IP %s; NAKing request for %s", mac, lease.IP, requestedIP)
+ return h.createNakPacket(p)
+ }
+ // Existing lease - extend if requested IP matches or no specific IP requested
+ if requestedIP == nil || lease.IP.Equal(requestedIP) {
lease.Extend(h.server.LeaseDuration)
+ lease.LastSeen = time.Now()
+ // Never ACK an unpersisted lease.
if err := h.leaseRepo.Save(ctx, lease); err != nil {
- log.Printf("Failed to update lease: %v", err)
+ log.Printf("Failed to save extended lease for MAC %s: %v", mac, err)
+ return h.createNakPacket(p)
}
- return h.createAckPacket(p, requestedIP)
+ return h.createAckPacket(p, lease.IP)
+ }
+ // The client wants a different IP: only replace the existing lease
+ // when the requested IP is actually valid — never delete a good
+ // lease just to NAK.
+ if !validRequest {
+ log.Printf("Requested IP %s not available for MAC %s", requestedIP, mac)
+ return h.createNakPacket(p)
}
+ if derr := h.leaseRepo.DeleteByMAC(ctx, mac); derr != nil {
+ log.Printf("Failed to delete stale lease for MAC %s: %v", mac, derr)
+ }
+ lease = h.newLease(mac, requestedIP.To4())
+ // Never ACK an unpersisted lease.
+ if err := h.leaseRepo.Save(ctx, lease); err != nil {
+ log.Printf("Failed to save lease for MAC %s: %v", mac, err)
+ return h.createNakPacket(p)
+ }
+ return h.createAckPacket(p, requestedIP.To4())
+ }
+
+ // New lease request - use the requested IP when it is valid.
+ if validRequest {
+ lease := h.newLease(mac, requestedIP.To4())
+ // Never ACK an unpersisted lease.
+ if err := h.leaseRepo.Save(ctx, lease); err != nil {
+ log.Printf("Failed to save lease for MAC %s: %v", mac, err)
+ return h.createNakPacket(p)
+ }
+ return h.createAckPacket(p, requestedIP.To4())
}
- // Check if IP is available and in range
- if !h.server.IsInRange(requestedIP) || !h.isIPAvailable(ctx, requestedIP, mac) {
+ // Find available IP for new lease
+ ip := h.findAvailableIP(ctx, mac)
+ if ip == nil {
+ log.Printf("No available IP for MAC %s", mac)
return h.createNakPacket(p)
}
// Create new lease
- newLease := &Lease{
- IP: requestedIP,
- MAC: mac,
- Expiry: time.Now().Add(h.server.LeaseDuration),
- Reserved: false,
- ServerID: h.server.ID,
- }
+ lease = h.newLease(mac, ip)
- if err := h.leaseRepo.Save(ctx, newLease); err != nil {
- log.Printf("Failed to save new lease: %v", err)
+ // Never ACK an unpersisted lease.
+ if err := h.leaseRepo.Save(ctx, lease); err != nil {
+ log.Printf("Failed to save lease for MAC %s: %v", mac, err)
return h.createNakPacket(p)
}
- return h.createAckPacket(p, requestedIP)
+ return h.createAckPacket(p, ip)
}
// handleRelease processes DHCP Release/Decline messages
@@ -185,10 +332,10 @@ func (h *ProtocolHandler) handleRelease(p d4.Packet, options d4.Options) {
}
}
-// getBootFilename determines the boot filename based on client type
+// getBootFilename determines the boot filename based on client type.
+// The boot-file settings were loaded once at handler construction, so this
+// never touches the config system on the packet path.
func (h *ProtocolHandler) getBootFilename(options d4.Options) string {
- cfg, _ := config.LoadDefault() // Should be injected in real implementation
-
bootType := "BIOS"
if vendorOption, ok := options[d4.OptionVendorClassIdentifier]; ok {
if string(vendorOption) == "iPXE" || string(vendorOption) == "gPXE" {
@@ -197,25 +344,37 @@ func (h *ProtocolHandler) getBootFilename(options d4.Options) string {
}
if bootType == "iPXE" {
- return cfg.DHCP.EFIFile
+ return h.bootFiles.EFIFile
}
- return cfg.DHCP.BiosFile
+ return h.bootFiles.BiosFile
+}
+
+// ipv4Option normalizes an address to its 4-byte IPv4 form for DHCP wire
+// options. DHCP options carry 4-byte IPv4 addresses; encoding a 16-byte
+// IPv4-in-IPv6 form would put garbage on the wire.
+func ipv4Option(ip net.IP) []byte {
+ if ip4 := ip.To4(); ip4 != nil {
+ out := make([]byte, 4)
+ copy(out, ip4)
+ return out
+ }
+ return nil
}
// buildDHCPOptions creates DHCP options for responses
func (h *ProtocolHandler) buildDHCPOptions(filename string) d4.Options {
return d4.Options{
- d4.OptionTFTPServerName: []byte(h.server.IP),
- d4.OptionSubnetMask: []byte(h.server.Options.SubnetMask),
- d4.OptionRouter: []byte(h.server.Options.Gateway),
- d4.OptionDomainNameServer: []byte(h.server.Options.DNS),
+ d4.OptionTFTPServerName: ipv4Option(h.server.IP),
+ d4.OptionSubnetMask: ipv4Option(h.server.Options.SubnetMask),
+ d4.OptionRouter: ipv4Option(h.server.Options.Gateway),
+ d4.OptionDomainNameServer: ipv4Option(h.server.Options.DNS),
d4.OptionBootFileName: []byte(filename),
}
}
// createOfferPacket creates a DHCP Offer packet
func (h *ProtocolHandler) createOfferPacket(p d4.Packet, ip net.IP, options d4.Options) d4.Packet {
- return d4.ReplyPacket(p, d4.Offer, h.server.IP, ip, h.server.LeaseDuration,
+ return d4.ReplyPacket(p, d4.Offer, h.server.IP.To4(), ip.To4(), h.server.LeaseDuration,
options.SelectOrderOrAll(options[d4.OptionParameterRequestList]))
}
@@ -224,13 +383,13 @@ func (h *ProtocolHandler) createAckPacket(p d4.Packet, ip net.IP) d4.Packet {
filename := h.getBootFilename(p.ParseOptions())
options := h.buildDHCPOptions(filename)
- return d4.ReplyPacket(p, d4.ACK, h.server.IP, ip, h.server.LeaseDuration,
+ return d4.ReplyPacket(p, d4.ACK, h.server.IP.To4(), ip.To4(), h.server.LeaseDuration,
options.SelectOrderOrAll(options[d4.OptionParameterRequestList]))
}
// createNakPacket creates a DHCP NAK packet
func (h *ProtocolHandler) createNakPacket(p d4.Packet) d4.Packet {
- return d4.ReplyPacket(p, d4.NAK, h.server.IP, nil, 0, nil)
+ return d4.ReplyPacket(p, d4.NAK, h.server.IP.To4(), nil, 0, nil)
}
// getRequestedIP extracts the requested IP from DHCP options or packet
@@ -252,7 +411,10 @@ func (h *ProtocolHandler) isRequestForAnotherServer(options d4.Options) bool {
// findAvailableIP finds an available IP for assignment
func (h *ProtocolHandler) findAvailableIP(ctx context.Context, excludeMAC string) net.IP {
for i := 0; i < h.server.LeaseRange; i++ {
- candidate := incrementIP(h.server.IPStart, i)
+ candidate, err := incrementIP(h.server.IPStart, i)
+ if err != nil {
+ return nil
+ }
if h.isIPAvailable(ctx, candidate, excludeMAC) {
return candidate
}
diff --git a/dhcp/repository.go b/dhcp/repository.go
index 5320972..27a2395 100644
--- a/dhcp/repository.go
+++ b/dhcp/repository.go
@@ -66,24 +66,44 @@ func (r *BoltServerRepository) GetByIP(ctx context.Context, ip net.IP) (*Server,
}
}
- return nil, fmt.Errorf("server with IP %s not found", ip.String())
+ return nil, fmt.Errorf("%w: server with IP %s", db.ErrNotFound, ip.String())
}
// BoltLeaseRepository implements LeaseRepository using BoltDB
type BoltLeaseRepository struct {
- repo *db.GenericRepository[*Lease]
+ repo *db.GenericRepository[*Lease]
+ db db.Database
+ bucket string
}
// NewBoltLeaseRepository creates a new BoltDB lease repository
func NewBoltLeaseRepository(database db.Database, bucket string) *BoltLeaseRepository {
return &BoltLeaseRepository{
- repo: db.NewGenericRepository[*Lease](database, bucket),
+ repo: db.NewGenericRepository[*Lease](database, bucket),
+ db: database,
+ bucket: bucket,
}
}
-// Save saves a lease to the repository
+// macIndexBucket returns the bucket holding the MAC -> lease ID secondary index.
+// It is created on demand by PutKV, so readers must tolerate it being absent.
+func (r *BoltLeaseRepository) macIndexBucket() string {
+ return r.bucket + "_by_mac"
+}
+
+// Save saves a lease to the repository and maintains the MAC index
func (r *BoltLeaseRepository) Save(ctx context.Context, lease *Lease) error {
- return r.repo.Save(ctx, lease.ID, lease)
+ // Drop any stale index entry if the lease's MAC changed since it was stored.
+ if existing, err := r.Get(ctx, lease.ID); err == nil && existing != nil && existing.MAC != lease.MAC {
+ _ = r.db.DeleteKV(ctx, r.macIndexBucket(), []byte(existing.MAC))
+ }
+ if err := r.repo.Save(ctx, lease.ID, lease); err != nil {
+ return err
+ }
+ if err := r.db.PutKV(ctx, r.macIndexBucket(), []byte(lease.MAC), []byte(lease.ID)); err != nil {
+ return fmt.Errorf("failed to update MAC index: %w", err)
+ }
+ return nil
}
// Get retrieves a lease by ID
@@ -91,8 +111,17 @@ func (r *BoltLeaseRepository) Get(ctx context.Context, id string) (*Lease, error
return r.repo.Get(ctx, id)
}
-// GetByMAC retrieves a lease by MAC address
+// GetByMAC retrieves a lease by MAC address, using the secondary index.
+// Falls back to a full scan (which repairs the index) when the index
+// misses — e.g. for leases written before the index existed.
func (r *BoltLeaseRepository) GetByMAC(ctx context.Context, mac string) (*Lease, error) {
+ if id, err := r.db.GetKV(ctx, r.macIndexBucket(), []byte(mac)); err == nil && len(id) > 0 {
+ if lease, gerr := r.Get(ctx, string(id)); gerr == nil && lease != nil && lease.MAC == mac {
+ return lease, nil
+ }
+ // Stale index entry: fall through to the full scan below.
+ }
+
leases, err := r.GetAll(ctx)
if err != nil {
return nil, err
@@ -100,11 +129,13 @@ func (r *BoltLeaseRepository) GetByMAC(ctx context.Context, mac string) (*Lease,
for _, lease := range leases {
if lease.MAC == mac {
+ // Repair the index for next time (best effort).
+ _ = r.db.PutKV(ctx, r.macIndexBucket(), []byte(mac), []byte(lease.ID))
return lease, nil
}
}
- return nil, fmt.Errorf("lease for MAC %s not found", mac)
+ return nil, fmt.Errorf("%w: lease for MAC %s", db.ErrNotFound, mac)
}
// GetByServerID retrieves all leases for a specific server
@@ -139,8 +170,11 @@ func (r *BoltLeaseRepository) GetAll(ctx context.Context) ([]*Lease, error) {
return leases, nil
}
-// Delete removes a lease by ID
+// Delete removes a lease by ID and cleans up its MAC index entry
func (r *BoltLeaseRepository) Delete(ctx context.Context, id string) error {
+ if existing, err := r.Get(ctx, id); err == nil && existing != nil {
+ _ = r.db.DeleteKV(ctx, r.macIndexBucket(), []byte(existing.MAC))
+ }
return r.repo.Delete(ctx, id)
}
diff --git a/dhcp/repository_test.go b/dhcp/repository_test.go
new file mode 100644
index 0000000..a90e9a2
--- /dev/null
+++ b/dhcp/repository_test.go
@@ -0,0 +1,86 @@
+package dhcp
+
+import (
+ "context"
+ "errors"
+ "net"
+ "testing"
+ "time"
+
+ "ignite/config"
+ "ignite/db"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func newTestLeaseRepo(t *testing.T) (*BoltLeaseRepository, db.Database) {
+ t.Helper()
+ cfg, err := config.NewConfigBuilder().
+ WithDBPath(t.TempDir()).
+ WithDBFile("leases.db").
+ WithBucket("test").
+ Build()
+ require.NoError(t, err)
+ database, err := db.NewBoltDB(cfg)
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = database.Close() })
+ return NewBoltLeaseRepository(database, "leases"), database
+}
+
+func testLease(id, mac, ip string) *Lease {
+ now := time.Now()
+ return &Lease{
+ ID: id,
+ MAC: mac,
+ IP: net.ParseIP(ip).To4(),
+ ServerID: "server-1",
+ Expiry: now.Add(time.Hour),
+ State: StateAssigned,
+ StateUpdatedAt: now,
+ LastSeen: now,
+ StateHistory: []StateTransition{
+ {ToState: StateAssigned, Timestamp: now, Source: "dhcp"},
+ },
+ }
+}
+
+// TestBoltLeaseRepository_MACIndex verifies the MAC -> ID secondary index:
+// lookups hit the index, deletes maintain it, and a missing index (old DB)
+// falls back to a full scan that repairs the index.
+func TestBoltLeaseRepository_MACIndex(t *testing.T) {
+ ctx := context.Background()
+ repo, database := newTestLeaseRepo(t)
+
+ l1 := testLease("id-1", "aa:bb:cc:dd:ee:01", "192.168.1.101")
+ l2 := testLease("id-2", "aa:bb:cc:dd:ee:02", "192.168.1.102")
+ require.NoError(t, repo.Save(ctx, l1))
+ require.NoError(t, repo.Save(ctx, l2))
+
+ // Index hit
+ got, err := repo.GetByMAC(ctx, l1.MAC)
+ require.NoError(t, err)
+ assert.Equal(t, l1.ID, got.ID)
+
+ // Missing MAC -> typed not-found
+ _, err = repo.GetByMAC(ctx, "aa:bb:cc:dd:ee:ff")
+ require.Error(t, err)
+ assert.True(t, errors.Is(err, db.ErrNotFound))
+
+ // Delete maintains the index: no stale entry afterwards
+ require.NoError(t, repo.Delete(ctx, l1.ID))
+ _, err = repo.GetByMAC(ctx, l1.MAC)
+ assert.True(t, errors.Is(err, db.ErrNotFound))
+
+ // Simulate a database written before the index existed: drop the index
+ // bucket entirely. GetByMAC must fall back to a scan and repair the index.
+ require.NoError(t, database.DeleteAllKV(ctx, "leases_by_mac"))
+ got, err = repo.GetByMAC(ctx, l2.MAC)
+ require.NoError(t, err)
+ assert.Equal(t, l2.ID, got.ID)
+
+ // The index was repaired: the bucket exists again with the entry.
+ id, err := database.GetKV(ctx, "leases_by_mac", []byte(l2.MAC))
+ require.NoError(t, err)
+ assert.Equal(t, l2.ID, string(id))
+}
diff --git a/dhcp/service.go b/dhcp/service.go
index b9b0e59..ed359fd 100644
--- a/dhcp/service.go
+++ b/dhcp/service.go
@@ -2,18 +2,36 @@ package dhcp
import (
"context"
+ "errors"
"fmt"
"log"
+ "net"
+ "sync"
"time"
+ "ignite/config"
+ "ignite/db"
+
"github.com/google/uuid"
)
+// sharedAllocMu serializes IP allocation across the DHCP packet path
+// (ProtocolHandler) and the service path (DHCPLeaseService). Both services
+// are constructed independently, so the shared mutex lives at package scope;
+// each service holds a pointer to it.
+var sharedAllocMu sync.Mutex
+
// DHCPServerService implements the ServerService interface
type DHCPServerService struct {
serverRepo ServerRepository
leaseRepo LeaseRepository
handlers map[string]*ProtocolHandler
+ // handlersMu guards the handlers map: HTTP handlers run on concurrent
+ // goroutines, and concurrent map access is a fatal runtime panic.
+ handlersMu sync.RWMutex
+ // allocMu is shared with every ProtocolHandler and with
+ // DHCPLeaseService to close the check-then-act allocation race.
+ allocMu *sync.Mutex
}
// NewDHCPServerService creates a new DHCP server service
@@ -22,6 +40,7 @@ func NewDHCPServerService(serverRepo ServerRepository, leaseRepo LeaseRepository
serverRepo: serverRepo,
leaseRepo: leaseRepo,
handlers: make(map[string]*ProtocolHandler),
+ allocMu: &sharedAllocMu,
}
}
@@ -32,11 +51,16 @@ func (s *DHCPServerService) CreateServer(ctx context.Context, config ServerConfi
return nil, fmt.Errorf("invalid server configuration: %w", err)
}
- // Check if server with this IP already exists
+ // Check if server with this IP already exists.
+ // Only a typed "not found" means "no such server" — any other error is a
+ // real backend failure and must not be misread as absent.
existing, err := s.serverRepo.GetByIP(ctx, config.IP)
if err == nil && existing != nil {
return nil, fmt.Errorf("server with IP %s already exists", config.IP)
}
+ if err != nil && !errors.Is(err, db.ErrNotFound) {
+ return nil, fmt.Errorf("failed to check for existing server: %w", err)
+ }
server := &Server{
ID: uuid.New().String(),
@@ -75,9 +99,12 @@ func (s *DHCPServerService) UpdateServer(ctx context.Context, serverID string, c
return fmt.Errorf("failed to get server: %w", err)
}
- // For updates, we don't allow changing the network IP to prevent conflicts
- // The UI should prevent this, but we enforce it here as well
- config.IP = server.IP
+ // The network IP is immutable on update: changing the bound address behind
+ // the listener would orphan it. Reject it explicitly instead of silently
+ // discarding the submitted value.
+ if !config.IP.Equal(server.IP) {
+ return fmt.Errorf("changing the server network IP on update is not allowed")
+ }
// If server is running, we need to stop and restart it
wasRunning := server.Started
@@ -87,8 +114,7 @@ func (s *DHCPServerService) UpdateServer(ctx context.Context, serverID string, c
}
}
- // Update server configuration
- server.IP = config.IP
+ // Update server configuration (the network IP is immutable on update)
server.IPStart = config.StartIP
server.LeaseRange = config.LeaseRange
server.LeaseDuration = config.LeaseDuration
@@ -97,7 +123,7 @@ func (s *DHCPServerService) UpdateServer(ctx context.Context, serverID string, c
SubnetMask: config.SubnetMask,
Gateway: config.Gateway,
DNS: config.DNS,
- TFTPServer: config.IP,
+ TFTPServer: server.IP,
}
// Save updated server
@@ -108,8 +134,7 @@ func (s *DHCPServerService) UpdateServer(ctx context.Context, serverID string, c
// Restart server if it was running
if wasRunning {
if err := s.StartServer(ctx, serverID); err != nil {
- log.Printf("Warning: failed to restart server after update: %v", err)
- // Don't return error here, the update was successful
+ return fmt.Errorf("server configuration updated but failed to restart server: %w", err)
}
}
@@ -127,13 +152,29 @@ func (s *DHCPServerService) StartServer(ctx context.Context, serverID string) er
return fmt.Errorf("server is already running")
}
+ // Load the boot-file settings once at construction time so the packet
+ // path never pays for config.LoadDefault() per packet.
+ appCfg, err := config.LoadDefault()
+ if err != nil {
+ return fmt.Errorf("failed to load application config: %w", err)
+ }
+ bootFiles := ProtocolBootFiles{
+ BiosFile: appCfg.DHCP.BiosFile,
+ EFIFile: appCfg.DHCP.EFIFile,
+ }
+
// Create and start protocol handler
- handler := NewProtocolHandler(server, s.leaseRepo)
+ handler := NewProtocolHandler(server, s.leaseRepo,
+ WithAllocationMutex(s.allocMu),
+ WithBootFiles(bootFiles),
+ )
if err := handler.Start(); err != nil {
return fmt.Errorf("failed to start DHCP handler: %w", err)
}
+ s.handlersMu.Lock()
s.handlers[serverID] = handler
+ s.handlersMu.Unlock()
// Update server state
server.Started = true
@@ -141,8 +182,10 @@ func (s *DHCPServerService) StartServer(ctx context.Context, serverID string) er
if err := s.serverRepo.Save(ctx, server); err != nil {
// Try to stop the handler if we can't save the state
- handler.Stop()
+ _ = handler.Stop()
+ s.handlersMu.Lock()
delete(s.handlers, serverID)
+ s.handlersMu.Unlock()
return fmt.Errorf("failed to update server state: %w", err)
}
@@ -161,12 +204,14 @@ func (s *DHCPServerService) StopServer(ctx context.Context, serverID string) err
}
// Stop protocol handler
+ s.handlersMu.Lock()
if handler, exists := s.handlers[serverID]; exists {
if err := handler.Stop(); err != nil {
log.Printf("Error stopping DHCP handler: %v", err)
}
delete(s.handlers, serverID)
}
+ s.handlersMu.Unlock()
// Update server state
server.Started = false
@@ -182,10 +227,12 @@ func (s *DHCPServerService) StopServer(ctx context.Context, serverID string) err
// DeleteServer deletes a DHCP server and all its leases
func (s *DHCPServerService) DeleteServer(ctx context.Context, serverID string) error {
// Stop server if running
+ s.handlersMu.Lock()
if handler, exists := s.handlers[serverID]; exists {
- handler.Stop()
+ _ = handler.Stop()
delete(s.handlers, serverID)
}
+ s.handlersMu.Unlock()
// Delete all leases for this server
if err := s.leaseRepo.DeleteByServerID(ctx, serverID); err != nil {
@@ -212,20 +259,20 @@ func (s *DHCPServerService) GetAllServers(ctx context.Context) ([]*Server, error
// validateServerConfig validates server configuration
func (s *DHCPServerService) validateServerConfig(config ServerConfig) error {
- if config.IP == nil {
- return fmt.Errorf("server IP cannot be nil")
+ if config.IP.To4() == nil {
+ return fmt.Errorf("server IP must be a valid IPv4 address")
}
- if config.SubnetMask == nil {
- return fmt.Errorf("subnet mask cannot be nil")
+ if config.SubnetMask.To4() == nil {
+ return fmt.Errorf("subnet mask must be a valid IPv4 address")
}
- if config.Gateway == nil {
- return fmt.Errorf("gateway cannot be nil")
+ if config.Gateway.To4() == nil {
+ return fmt.Errorf("gateway must be a valid IPv4 address")
}
- if config.DNS == nil {
- return fmt.Errorf("DNS cannot be nil")
+ if config.DNS.To4() == nil {
+ return fmt.Errorf("DNS must be a valid IPv4 address")
}
- if config.StartIP == nil {
- return fmt.Errorf("start IP cannot be nil")
+ if config.StartIP.To4() == nil {
+ return fmt.Errorf("start IP must be a valid IPv4 address")
}
if config.LeaseRange <= 0 {
return fmt.Errorf("lease range must be positive")
@@ -234,5 +281,27 @@ func (s *DHCPServerService) validateServerConfig(config ServerConfig) error {
return fmt.Errorf("lease duration must be positive")
}
+ // The netmask must be contiguous (e.g. 255.255.255.0).
+ mask := net.IPMask(config.SubnetMask.To4())
+ ones, bits := mask.Size()
+ if bits != 32 || ones < 0 || ones > 32 {
+ return fmt.Errorf("subnet mask %s is not a valid contiguous IPv4 netmask", config.SubnetMask)
+ }
+
+ // The lease range start must live inside the server's subnet.
+ network := config.IP.Mask(mask)
+ if !config.StartIP.Mask(mask).Equal(network) {
+ return fmt.Errorf("start IP %s is not in the server subnet %s", config.StartIP, network)
+ }
+
+ // The whole lease range must fit inside IPv4 space.
+ startInt, err := ipToInt(config.StartIP)
+ if err != nil {
+ return fmt.Errorf("start IP must be a valid IPv4 address")
+ }
+ if end := uint64(startInt) + uint64(config.LeaseRange); end > 0xFFFFFFFF {
+ return fmt.Errorf("lease range overflows IPv4 address space")
+ }
+
return nil
}
diff --git a/dlstatus/dlstatus.go b/dlstatus/dlstatus.go
new file mode 100644
index 0000000..a02ad97
--- /dev/null
+++ b/dlstatus/dlstatus.go
@@ -0,0 +1,55 @@
+// Package dlstatus provides a single, shared download-status type for the
+// whole application. The osimage and syslinux packages previously each
+// defined their own DownloadStatus struct with free-form status strings;
+// everything now uses this one struct and the typed Status constants below.
+package dlstatus
+
+import "time"
+
+// Status is the lifecycle state of a file download.
+type Status string
+
+// Lifecycle states for a download.
+const (
+ StatusQueued Status = "queued"
+ StatusDownloading Status = "downloading"
+ StatusExtracting Status = "extracting"
+ StatusInstalling Status = "installing"
+ StatusCompleted Status = "completed"
+ StatusFailed Status = "failed"
+ StatusCancelled Status = "cancelled"
+)
+
+// Active reports whether the download is still in progress (not terminal).
+func (s Status) Active() bool {
+ switch s {
+ case StatusQueued, StatusDownloading, StatusExtracting, StatusInstalling:
+ return true
+ default:
+ return false
+ }
+}
+
+// Terminal reports whether the status is a final state that must never be
+// overwritten by stale worker updates.
+func (s Status) Terminal() bool {
+ switch s {
+ case StatusCompleted, StatusFailed, StatusCancelled:
+ return true
+ default:
+ return false
+ }
+}
+
+// DownloadStatus tracks the progress of a file download initiated through
+// the OS image or syslinux services.
+type DownloadStatus struct {
+ ID string `json:"id"`
+ OS string `json:"os,omitempty"`
+ Version string `json:"version"`
+ Status Status `json:"status"`
+ Progress int `json:"progress"` // 0-100
+ ErrorMessage string `json:"error_message,omitempty"`
+ StartedAt time.Time `json:"started_at"`
+ CompletedAt *time.Time `json:"completed_at,omitempty"`
+}
diff --git a/dlstatus/dlstatus_test.go b/dlstatus/dlstatus_test.go
new file mode 100644
index 0000000..6994cc5
--- /dev/null
+++ b/dlstatus/dlstatus_test.go
@@ -0,0 +1,56 @@
+package dlstatus
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestStatusValues(t *testing.T) {
+ assert.Equal(t, Status("queued"), StatusQueued)
+ assert.Equal(t, Status("downloading"), StatusDownloading)
+ assert.Equal(t, Status("extracting"), StatusExtracting)
+ assert.Equal(t, Status("installing"), StatusInstalling)
+ assert.Equal(t, Status("completed"), StatusCompleted)
+ assert.Equal(t, Status("failed"), StatusFailed)
+ assert.Equal(t, Status("cancelled"), StatusCancelled)
+}
+
+func TestStatusActive(t *testing.T) {
+ for _, s := range []Status{StatusQueued, StatusDownloading, StatusExtracting, StatusInstalling} {
+ assert.True(t, s.Active(), "status %q should be active", s)
+ }
+ for _, s := range []Status{StatusCompleted, StatusFailed, StatusCancelled} {
+ assert.False(t, s.Active(), "status %q should not be active", s)
+ }
+}
+
+func TestStatusTerminal(t *testing.T) {
+ for _, s := range []Status{StatusCompleted, StatusFailed, StatusCancelled} {
+ assert.True(t, s.Terminal(), "status %q should be terminal", s)
+ }
+ for _, s := range []Status{StatusQueued, StatusDownloading, StatusExtracting, StatusInstalling} {
+ assert.False(t, s.Terminal(), "status %q should not be terminal", s)
+ }
+}
+
+func TestActiveAndTerminalAreComplementary(t *testing.T) {
+ all := []Status{
+ StatusQueued, StatusDownloading, StatusExtracting, StatusInstalling,
+ StatusCompleted, StatusFailed, StatusCancelled,
+ }
+ for _, s := range all {
+ assert.NotEqual(t, s.Active(), s.Terminal(), "status %q must be exactly one of active/terminal", s)
+ }
+}
+
+func TestDownloadStatusStruct(t *testing.T) {
+ ds := DownloadStatus{
+ ID: "test-id",
+ Status: StatusDownloading,
+ Progress: 42,
+ }
+ assert.Equal(t, "test-id", ds.ID)
+ assert.True(t, ds.Status.Active())
+ assert.False(t, ds.Status.Terminal())
+}
diff --git a/handlers/auth.go b/handlers/auth.go
index dbd016e..31127e9 100644
--- a/handlers/auth.go
+++ b/handlers/auth.go
@@ -1,33 +1,121 @@
package handlers
import (
+ "crypto/rand"
+ "encoding/hex"
"encoding/json"
+ "log"
"net/http"
"strings"
+ "sync"
"time"
)
-// AuthHandlers handles authentication requests
-type AuthHandlers struct {
- container *Container
+// Admin credentials. Guarded by credentialMu so concurrent logins and password
+// changes never race.
+var (
+ credentialMu sync.RWMutex
+ defaultUsername = "admin"
+ defaultPassword = "admin"
+)
+
+// sessionStore holds the server-side session table: token -> expiry.
+// A session is valid only if its token is present here and not expired,
+// so logout (delete) and expiry are enforced server-side.
+var sessionStore = struct {
+ sync.RWMutex
+ tokens map[string]time.Time
+}{tokens: make(map[string]time.Time)}
+
+// sessionTTL is how long a login session stays valid.
+const sessionTTL = 24 * time.Hour
+
+func init() {
+ credentialMu.RLock()
+ user, pass := defaultUsername, defaultPassword
+ credentialMu.RUnlock()
+ if user == "admin" && pass == "admin" {
+ log.Println("WARNING: Ignite is running with the default admin/admin credentials. " +
+ "Change the password immediately via POST /auth/change-password.")
+ }
}
-// NewAuthHandlers creates a new AuthHandlers instance
-func NewAuthHandlers(container *Container) *AuthHandlers {
- return &AuthHandlers{container: container}
+// generateSessionToken creates a 32-byte cryptographically random session token.
+func generateSessionToken() (string, error) {
+ b := make([]byte, 32)
+ if _, err := rand.Read(b); err != nil {
+ return "", err
+ }
+ return hex.EncodeToString(b), nil
}
-// LoginPage serves the login page
-func (h *AuthHandlers) LoginPage(w http.ResponseWriter, r *http.Request) {
- templates := LoadTemplates()
+// createSession mints a new random token, stores it server-side, and returns it.
+func createSession() (string, error) {
+ token, err := generateSessionToken()
+ if err != nil {
+ return "", err
+ }
+ sessionStore.Lock()
+ sessionStore.tokens[token] = time.Now().Add(sessionTTL)
+ sessionStore.Unlock()
+ return token, nil
+}
- data := map[string]string{
- "Title": "Login - Ignite",
+// destroySession removes a token from the server-side store.
+func destroySession(token string) {
+ if token == "" {
+ return
+ }
+ sessionStore.Lock()
+ delete(sessionStore.tokens, token)
+ sessionStore.Unlock()
+}
+
+// validSession reports whether token is a live server-side session.
+func validSession(token string) bool {
+ if token == "" {
+ return false
}
+ sessionStore.RLock()
+ expiry, ok := sessionStore.tokens[token]
+ sessionStore.RUnlock()
+ if !ok {
+ return false
+ }
+ if time.Now().After(expiry) {
+ destroySession(token)
+ return false
+ }
+ return true
+}
- if err := templates["login"].Execute(w, data); err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
+// sessionCookie builds the session cookie with secure flags.
+func sessionCookie(token string, expired bool) *http.Cookie {
+ c := &http.Cookie{
+ Name: "ignite_session",
+ Value: token,
+ Path: "/",
+ HttpOnly: true,
+ Secure: true,
+ SameSite: http.SameSiteLaxMode,
+ }
+ if expired {
+ c.Expires = time.Now().Add(-time.Hour)
+ c.MaxAge = -1
+ } else {
+ c.Expires = time.Now().Add(sessionTTL)
}
+ return c
+}
+
+// AuthHandlers handles authentication-related HTTP requests
+type AuthHandlers struct {
+ container *Container
+}
+
+// NewAuthHandlers creates a new AuthHandlers instance
+func NewAuthHandlers(container *Container) *AuthHandlers {
+ return &AuthHandlers{container: container}
}
// LoginRequest represents a login request
@@ -43,82 +131,102 @@ type LoginResponse struct {
Token string `json:"token,omitempty"`
}
-// ChangePasswordRequest represents a password change request
-type ChangePasswordRequest struct {
- CurrentPassword string `json:"current_password"`
- NewPassword string `json:"new_password"`
-}
+// Login handles user login
+func (h *AuthHandlers) Login(w http.ResponseWriter, r *http.Request) {
+ var req LoginRequest
-// Default credentials - in production, these should be stored securely
-var defaultUsername = "admin"
-var defaultPassword = "admin"
+ // Try to parse JSON first, then fall back to form data
+ contentType := r.Header.Get("Content-Type")
+ if strings.Contains(contentType, "application/json") {
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusBadRequest)
+ json.NewEncoder(w).Encode(LoginResponse{
+ Success: false,
+ Message: "Invalid request format",
+ })
+ return
+ }
+ } else {
+ // Handle form data
+ if err := r.ParseForm(); err != nil {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusBadRequest)
+ json.NewEncoder(w).Encode(LoginResponse{
+ Success: false,
+ Message: "Invalid form data",
+ })
+ return
+ }
+ req.Username = r.FormValue("username")
+ req.Password = r.FormValue("password")
+ }
-// Login handles login requests
-func (h *AuthHandlers) Login(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Content-Type", "application/json")
+ credentialMu.RLock()
+ validUser, validPass := defaultUsername, defaultPassword
+ credentialMu.RUnlock()
- var req LoginRequest
- if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
- w.WriteHeader(http.StatusBadRequest)
+ // Validate credentials
+ if req.Username != validUser || req.Password != validPass {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(LoginResponse{
Success: false,
- Message: "Invalid request format",
+ Message: "Invalid username or password",
})
return
}
- // Simple authentication check
- if req.Username == defaultUsername && req.Password == defaultPassword {
- // Create a simple session token (in production, use proper JWT or session management)
- token := generateSimpleToken(req.Username)
-
- // Set session cookie
- http.SetCookie(w, &http.Cookie{
- Name: "ignite_session",
- Value: token,
- Expires: time.Now().Add(24 * time.Hour),
- HttpOnly: true,
- Path: "/",
- })
-
- json.NewEncoder(w).Encode(LoginResponse{
- Success: true,
- Message: "Login successful",
- Token: token,
- })
- } else {
- w.WriteHeader(http.StatusUnauthorized)
+ // Generate a random server-side session token
+ token, err := createSession()
+ if err != nil {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(LoginResponse{
Success: false,
- Message: "Invalid username or password",
+ Message: "Failed to create session",
})
+ return
}
+
+ // Set session cookie with secure flags
+ http.SetCookie(w, sessionCookie(token, false))
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(LoginResponse{
+ Success: true,
+ Message: "Login successful",
+ Token: token,
+ })
}
-// Logout handles logout requests
+// Logout handles user logout by deleting the server-side session and expiring the cookie
func (h *AuthHandlers) Logout(w http.ResponseWriter, r *http.Request) {
+ if cookie, err := r.Cookie("ignite_session"); err == nil {
+ destroySession(cookie.Value)
+ }
+
// Clear the session cookie
- http.SetCookie(w, &http.Cookie{
- Name: "ignite_session",
- Value: "",
- Expires: time.Now().Add(-time.Hour),
- HttpOnly: true,
- Path: "/",
- })
+ http.SetCookie(w, sessionCookie("", true))
w.Header().Set("Content-Type", "application/json")
- json.NewEncoder(w).Encode(map[string]interface{}{
- "success": true,
- "message": "Logged out successfully",
+ json.NewEncoder(w).Encode(LoginResponse{
+ Success: true,
+ Message: "Logout successful",
})
}
-// ChangePassword handles password change requests
-func (h *AuthHandlers) ChangePassword(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Content-Type", "application/json")
+// ChangePasswordRequest represents a password change request
+type ChangePasswordRequest struct {
+ CurrentPassword string `json:"current_password"`
+ NewPassword string `json:"new_password"`
+}
+// ChangePassword handles password changes
+func (h *AuthHandlers) ChangePassword(w http.ResponseWriter, r *http.Request) {
// Check if user is authenticated
if !isAuthenticated(r) {
+ w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(map[string]interface{}{
"success": false,
@@ -129,6 +237,7 @@ func (h *AuthHandlers) ChangePassword(w http.ResponseWriter, r *http.Request) {
var req ChangePasswordRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]interface{}{
"success": false,
@@ -137,8 +246,22 @@ func (h *AuthHandlers) ChangePassword(w http.ResponseWriter, r *http.Request) {
return
}
+ if strings.TrimSpace(req.NewPassword) == "" {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusBadRequest)
+ json.NewEncoder(w).Encode(map[string]interface{}{
+ "success": false,
+ "message": "New password cannot be empty",
+ })
+ return
+ }
+
+ credentialMu.Lock()
+ defer credentialMu.Unlock()
+
// Verify current password
if req.CurrentPassword != defaultPassword {
+ w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(map[string]interface{}{
"success": false,
@@ -147,59 +270,75 @@ func (h *AuthHandlers) ChangePassword(w http.ResponseWriter, r *http.Request) {
return
}
- // Update password (in production, hash the password)
+ // Update password
defaultPassword = req.NewPassword
+ w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"message": "Password changed successfully",
})
}
-// generateSimpleToken creates a simple session token
-func generateSimpleToken(username string) string {
- // This is a very basic token generation for demo purposes
- // In production, use proper JWT or secure session tokens
- return username + "_" + time.Now().Format("20060102150405")
+// LoginPage serves the login page
+func (h *AuthHandlers) LoginPage(w http.ResponseWriter, r *http.Request) {
+ // If already authenticated, redirect to dashboard
+ if isAuthenticated(r) {
+ http.Redirect(w, r, "/", http.StatusFound)
+ return
+ }
+
+ renderCachedTemplate(w, r, "login", nil, "Unable to render the login page")
}
-// isAuthenticated checks if the request has a valid session
+// isAuthenticated checks if the request carries a valid server-side session.
func isAuthenticated(r *http.Request) bool {
cookie, err := r.Cookie("ignite_session")
if err != nil {
return false
}
+ return validSession(cookie.Value)
+}
- // Basic token validation (in production, properly validate JWT or session)
- return cookie.Value != "" && len(cookie.Value) > 0
+// isAPIRequest reports whether the request expects a JSON 401 instead of a
+// login redirect: anything under /api/ or explicitly asking for JSON.
+func isAPIRequest(r *http.Request) bool {
+ if strings.HasPrefix(r.URL.Path, "/api/") {
+ return true
+ }
+ return strings.Contains(r.Header.Get("Accept"), "application/json")
}
-// AuthMiddleware is a middleware that checks authentication
+// AuthMiddleware checks authentication for protected routes.
+// API/JSON clients get a 401 JSON response; browsers get a login redirect.
func AuthMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- // List of public paths that don't require authentication
- publicPaths := []string{
- "/login",
- "/auth/login",
- "/auth/logout",
+ // Skip auth for login page and auth endpoints
+ if r.URL.Path == "/login" ||
+ r.URL.Path == "/auth/login" ||
+ r.URL.Path == "/auth/logout" {
+ next.ServeHTTP(w, r)
+ return
}
- // Also allow static files
- if strings.HasPrefix(r.URL.Path, "/public/") {
+ // Skip auth for static files and PXE boot files (needed for network booting)
+ if strings.HasPrefix(r.URL.Path, "/public/") ||
+ strings.HasPrefix(r.URL.Path, "/tftp/serve") {
next.ServeHTTP(w, r)
return
}
- // Check if the current path is in the public paths
- for _, path := range publicPaths {
- if r.URL.Path == path {
- next.ServeHTTP(w, r)
+ // Check authentication
+ if !isAuthenticated(r) {
+ if isAPIRequest(r) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusUnauthorized)
+ json.NewEncoder(w).Encode(map[string]interface{}{
+ "success": false,
+ "message": "Authentication required",
+ })
return
}
- }
-
- // Check if user is authenticated
- if !isAuthenticated(r) {
http.Redirect(w, r, "/login", http.StatusFound)
return
}
diff --git a/handlers/auth_integration_test.go b/handlers/auth_integration_test.go
index 5023f37..170ef99 100644
--- a/handlers/auth_integration_test.go
+++ b/handlers/auth_integration_test.go
@@ -10,8 +10,7 @@ import (
func TestCompleteLoginLogoutFlow(t *testing.T) {
// Reset to default credentials for test
- defaultUsername = "admin"
- defaultPassword = "admin"
+ resetTestCredentials()
container := &Container{}
authHandlers := NewAuthHandlers(container)
@@ -133,11 +132,8 @@ func TestCompleteLoginLogoutFlow(t *testing.T) {
func TestCompletePasswordChangeFlow(t *testing.T) {
// Reset to default credentials for test
- defaultUsername = "admin"
- defaultPassword = "admin"
- defer func() {
- defaultPassword = "admin" // Reset after test
- }()
+ resetTestCredentials()
+ defer resetTestCredentials() // Reset after test
container := &Container{}
authHandlers := NewAuthHandlers(container)
@@ -248,19 +244,14 @@ func TestCompletePasswordChangeFlow(t *testing.T) {
func TestPasswordChangeSecurityChecks(t *testing.T) {
// Reset to default credentials for test
- defaultPassword = "admin"
- defer func() {
- defaultPassword = "admin" // Reset after test
- }()
+ resetTestCredentials()
+ defer resetTestCredentials() // Reset after test
container := &Container{}
authHandlers := NewAuthHandlers(container)
// Create valid session cookie
- sessionCookie := &http.Cookie{
- Name: "ignite_session",
- Value: "admin_12345",
- }
+ sessionCookie := testSessionCookie(t)
tests := []struct {
name string
@@ -299,7 +290,7 @@ func TestPasswordChangeSecurityChecks(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Reset password for each test
- defaultPassword = "admin"
+ resetTestCredentials()
changeReq := ChangePasswordRequest{
CurrentPassword: tt.currentPass,
@@ -339,8 +330,7 @@ func TestPasswordChangeSecurityChecks(t *testing.T) {
func TestSessionPersistence(t *testing.T) {
// Reset to default credentials for test
- defaultUsername = "admin"
- defaultPassword = "admin"
+ resetTestCredentials()
container := &Container{}
authHandlers := NewAuthHandlers(container)
diff --git a/handlers/auth_middleware_test.go b/handlers/auth_middleware_test.go
index 39133aa..27f0eb3 100644
--- a/handlers/auth_middleware_test.go
+++ b/handlers/auth_middleware_test.go
@@ -1,6 +1,8 @@
package handlers
import (
+ "bytes"
+ "encoding/json"
"net/http"
"net/http/httptest"
"testing"
@@ -108,10 +110,7 @@ func TestAuthMiddleware_ProtectedPaths_Authenticated(t *testing.T) {
t.Run("Protected path authenticated: "+path, func(t *testing.T) {
req := httptest.NewRequest("GET", path, nil)
// Add valid session cookie
- req.AddCookie(&http.Cookie{
- Name: "ignite_session",
- Value: "admin_12345",
- })
+ req.AddCookie(testSessionCookie(t))
w := httptest.NewRecorder()
middlewareHandler.ServeHTTP(w, req)
@@ -233,10 +232,7 @@ func TestAuthMiddleware_POSTRequests(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest("POST", tt.path, nil)
if tt.authenticated {
- req.AddCookie(&http.Cookie{
- Name: "ignite_session",
- Value: "admin_12345",
- })
+ req.AddCookie(testSessionCookie(t))
}
w := httptest.NewRecorder()
@@ -276,10 +272,7 @@ func TestAuthMiddleware_ChainedMiddleware(t *testing.T) {
t.Run("Chained middleware with authentication", func(t *testing.T) {
req := httptest.NewRequest("GET", "/dhcp", nil)
- req.AddCookie(&http.Cookie{
- Name: "ignite_session",
- Value: "admin_12345",
- })
+ req.AddCookie(testSessionCookie(t))
w := httptest.NewRecorder()
chainedHandler.ServeHTTP(w, req)
@@ -317,3 +310,93 @@ func TestAuthMiddleware_ChainedMiddleware(t *testing.T) {
}
})
}
+
+func TestAuthMiddleware_APIRequestsGetJSON401(t *testing.T) {
+ testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ })
+ middlewareHandler := AuthMiddleware(testHandler)
+
+ cases := []struct {
+ name string
+ path string
+ accept string
+ }{
+ {"API path", "/api/servers", ""},
+ {"JSON Accept header", "/dhcp", "application/json"},
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ req := httptest.NewRequest("GET", tc.path, nil)
+ if tc.accept != "" {
+ req.Header.Set("Accept", tc.accept)
+ }
+ w := httptest.NewRecorder()
+
+ middlewareHandler.ServeHTTP(w, req)
+
+ if w.Code != http.StatusUnauthorized {
+ t.Errorf("Expected status code %d, got %d", http.StatusUnauthorized, w.Code)
+ }
+ if ct := w.Header().Get("Content-Type"); ct != "application/json" {
+ t.Errorf("Expected Content-Type application/json, got %s", ct)
+ }
+ if loc := w.Header().Get("Location"); loc != "" {
+ t.Errorf("Expected no redirect for API request, got Location: %s", loc)
+ }
+ })
+ }
+}
+
+func TestAuthMiddleware_BrowserGetsLoginRedirect(t *testing.T) {
+ testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ })
+ middlewareHandler := AuthMiddleware(testHandler)
+
+ req := httptest.NewRequest("GET", "/dhcp", nil)
+ req.Header.Set("Accept", "text/html")
+ w := httptest.NewRecorder()
+
+ middlewareHandler.ServeHTTP(w, req)
+
+ if w.Code != http.StatusFound {
+ t.Errorf("Expected status code %d, got %d", http.StatusFound, w.Code)
+ }
+ if location := w.Header().Get("Location"); location != "/login" {
+ t.Errorf("Expected redirect to /login, got %s", location)
+ }
+}
+
+func TestSessionCookie_SecureFlags(t *testing.T) {
+ resetTestCredentials()
+ authHandlers := NewAuthHandlers(&Container{})
+
+ loginReq := LoginRequest{Username: "admin", Password: "admin"}
+ jsonData, _ := json.Marshal(loginReq)
+ req := httptest.NewRequest("POST", "/auth/login", bytes.NewBuffer(jsonData))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+
+ authHandlers.Login(w, req)
+
+ var sessionCookie *http.Cookie
+ for _, c := range w.Result().Cookies() {
+ if c.Name == "ignite_session" {
+ sessionCookie = c
+ }
+ }
+ if sessionCookie == nil {
+ t.Fatal("Expected session cookie to be set")
+ }
+ if !sessionCookie.HttpOnly {
+ t.Error("Expected session cookie to be HttpOnly")
+ }
+ if !sessionCookie.Secure {
+ t.Error("Expected session cookie to be Secure")
+ }
+ if sessionCookie.SameSite != http.SameSiteLaxMode {
+ t.Errorf("Expected SameSite=Lax, got %v", sessionCookie.SameSite)
+ }
+}
diff --git a/handlers/auth_test.go b/handlers/auth_test.go
index 6b85b72..8e601db 100644
--- a/handlers/auth_test.go
+++ b/handlers/auth_test.go
@@ -16,10 +16,29 @@ func TestAuthHandlers_LoginPage(t *testing.T) {
t.Skip("Template rendering test requires running from project root")
}
-func TestAuthHandlers_Login_Success(t *testing.T) {
- // Reset to default credentials for test
+// resetTestCredentials restores the default admin/admin credentials under lock.
+func resetTestCredentials() {
+ credentialMu.Lock()
defaultUsername = "admin"
defaultPassword = "admin"
+ credentialMu.Unlock()
+}
+
+// testSessionCookie creates a real server-side session and returns a cookie
+// carrying it. The session is destroyed when the test finishes.
+func testSessionCookie(t *testing.T) *http.Cookie {
+ t.Helper()
+ token, err := createSession()
+ if err != nil {
+ t.Fatalf("failed to create test session: %v", err)
+ }
+ t.Cleanup(func() { destroySession(token) })
+ return &http.Cookie{Name: "ignite_session", Value: token}
+}
+
+func TestAuthHandlers_Login_Success(t *testing.T) {
+ // Reset to default credentials for test
+ resetTestCredentials()
container := &Container{}
authHandlers := NewAuthHandlers(container)
@@ -80,8 +99,7 @@ func TestAuthHandlers_Login_Success(t *testing.T) {
func TestAuthHandlers_Login_InvalidCredentials(t *testing.T) {
// Reset to default credentials for test
- defaultUsername = "admin"
- defaultPassword = "admin"
+ resetTestCredentials()
container := &Container{}
authHandlers := NewAuthHandlers(container)
@@ -146,7 +164,14 @@ func TestAuthHandlers_Logout(t *testing.T) {
container := &Container{}
authHandlers := NewAuthHandlers(container)
+ // Log in to get a real session
+ token, err := createSession()
+ if err != nil {
+ t.Fatalf("failed to create session: %v", err)
+ }
+
req := httptest.NewRequest("POST", "/auth/logout", nil)
+ req.AddCookie(&http.Cookie{Name: "ignite_session", Value: token})
w := httptest.NewRecorder()
authHandlers.Logout(w, req)
@@ -155,6 +180,11 @@ func TestAuthHandlers_Logout(t *testing.T) {
t.Errorf("Expected status code %d, got %d", http.StatusOK, w.Code)
}
+ // The server-side session must be destroyed, not just the cookie expired
+ if validSession(token) {
+ t.Error("Expected server-side session to be destroyed on logout")
+ }
+
// Check if session cookie is cleared
cookies := w.Result().Cookies()
var sessionCookie *http.Cookie
@@ -182,18 +212,14 @@ func TestAuthHandlers_Logout(t *testing.T) {
func TestAuthHandlers_ChangePassword_Success(t *testing.T) {
// Reset to default credentials for test
- defaultUsername = "admin"
- defaultPassword = "admin"
+ resetTestCredentials()
container := &Container{}
authHandlers := NewAuthHandlers(container)
// Create a request with valid session cookie
req := httptest.NewRequest("POST", "/auth/change-password", nil)
- req.AddCookie(&http.Cookie{
- Name: "ignite_session",
- Value: "admin_12345",
- })
+ req.AddCookie(testSessionCookie(t))
changeReq := ChangePasswordRequest{
CurrentPassword: "admin",
@@ -203,10 +229,7 @@ func TestAuthHandlers_ChangePassword_Success(t *testing.T) {
jsonData, _ := json.Marshal(changeReq)
req = httptest.NewRequest("POST", "/auth/change-password", bytes.NewBuffer(jsonData))
req.Header.Set("Content-Type", "application/json")
- req.AddCookie(&http.Cookie{
- Name: "ignite_session",
- Value: "admin_12345",
- })
+ req.AddCookie(testSessionCookie(t))
w := httptest.NewRecorder()
@@ -227,12 +250,15 @@ func TestAuthHandlers_ChangePassword_Success(t *testing.T) {
}
// Verify password was actually changed
- if defaultPassword != "newpassword123" {
+ credentialMu.RLock()
+ changed := defaultPassword == "newpassword123"
+ credentialMu.RUnlock()
+ if !changed {
t.Error("Expected password to be updated")
}
// Reset for other tests
- defaultPassword = "admin"
+ resetTestCredentials()
}
func TestAuthHandlers_ChangePassword_NotAuthenticated(t *testing.T) {
@@ -259,7 +285,7 @@ func TestAuthHandlers_ChangePassword_NotAuthenticated(t *testing.T) {
func TestAuthHandlers_ChangePassword_WrongCurrentPassword(t *testing.T) {
// Reset to default credentials for test
- defaultPassword = "admin"
+ resetTestCredentials()
container := &Container{}
authHandlers := NewAuthHandlers(container)
@@ -272,10 +298,7 @@ func TestAuthHandlers_ChangePassword_WrongCurrentPassword(t *testing.T) {
jsonData, _ := json.Marshal(changeReq)
req := httptest.NewRequest("POST", "/auth/change-password", bytes.NewBuffer(jsonData))
req.Header.Set("Content-Type", "application/json")
- req.AddCookie(&http.Cookie{
- Name: "ignite_session",
- Value: "admin_12345",
- })
+ req.AddCookie(testSessionCookie(t))
w := httptest.NewRecorder()
@@ -293,11 +316,8 @@ func TestIsAuthenticated(t *testing.T) {
expectedResult bool
}{
{
- name: "Valid session cookie",
- cookie: &http.Cookie{
- Name: "ignite_session",
- Value: "admin_12345",
- },
+ name: "Valid session cookie",
+ cookie: nil, // filled in below with a real session
expectedResult: true,
},
{
@@ -318,7 +338,9 @@ func TestIsAuthenticated(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest("GET", "/", nil)
- if tt.cookie != nil {
+ if tt.name == "Valid session cookie" {
+ req.AddCookie(testSessionCookie(t))
+ } else if tt.cookie != nil {
req.AddCookie(tt.cookie)
}
@@ -330,34 +352,46 @@ func TestIsAuthenticated(t *testing.T) {
}
}
-func TestGenerateSimpleToken(t *testing.T) {
- username := "testuser"
- token := generateSimpleToken(username)
+func TestGenerateSessionToken(t *testing.T) {
+ token, err := generateSessionToken()
+ if err != nil {
+ t.Fatalf("generateSessionToken returned error: %v", err)
+ }
if token == "" {
t.Error("Expected token to be generated")
}
- if !strings.Contains(token, username) {
- t.Error("Expected token to contain username")
+ // 32 random bytes hex-encoded = 64 chars
+ if len(token) != 64 {
+ t.Errorf("Expected 64-char hex token, got %d chars", len(token))
}
- // Test that token contains a timestamp (should have format username_YYYYMMDDHHMMSS)
- parts := strings.Split(token, "_")
- if len(parts) != 2 {
- t.Error("Expected token to have format username_timestamp")
+ // Tokens must be unique and unpredictable
+ token2, err := generateSessionToken()
+ if err != nil {
+ t.Fatalf("generateSessionToken returned error: %v", err)
}
-
- // Check that timestamp part looks like a date (14 digits for YYYYMMDDHHMMSS)
- timestamp := parts[1]
- if len(timestamp) != 14 {
- t.Error("Expected timestamp to be 14 digits (YYYYMMDDHHMMSS)")
+ if token == token2 {
+ t.Error("Expected tokens to be unique")
}
- // Test with different username should produce different token even at same time
- token2 := generateSimpleToken("different_user")
+ // A minted session must validate server-side
+ token3, err := createSession()
+ if err != nil {
+ t.Fatalf("createSession returned error: %v", err)
+ }
+ defer destroySession(token3)
+ if !validSession(token3) {
+ t.Error("Expected freshly created session to be valid")
+ }
+ if validSession(token) {
+ t.Error("Expected unregistered token to be invalid")
+ }
- if token == token2 {
- t.Error("Expected tokens with different usernames to be different")
+ // Destroyed sessions must stop validating
+ destroySession(token3)
+ if validSession(token3) {
+ t.Error("Expected destroyed session to be invalid")
}
}
diff --git a/handlers/bootmenu.go b/handlers/bootmenu.go
index f5acbb2..103fb5d 100644
--- a/handlers/bootmenu.go
+++ b/handlers/bootmenu.go
@@ -11,8 +11,6 @@ import (
"os"
"path/filepath"
"strings"
-
- "ignite/config"
)
// BootMenuHandlers handles boot menu-related requests
@@ -35,8 +33,11 @@ type BootMenuData struct {
// SubmitBootMenu handles boot menu submission
func (h *BootMenuHandlers) SubmitBootMenu(w http.ResponseWriter, r *http.Request) {
+ if !requireConfig(w, r, h.container) {
+ return
+ }
if err := r.ParseForm(); err != nil {
- http.Error(w, "Failed to parse form data", http.StatusBadRequest)
+ HandleError(w, r, NewValidationError("Failed to parse form data", "The submitted form could not be parsed"))
return
}
@@ -59,22 +60,37 @@ func (h *BootMenuHandlers) SubmitBootMenu(w http.ResponseWriter, r *http.Request
requiredFields := []string{"tftpip", "mac", "os", "version", "typeSelect", "template_name", "hostname", "ip", "subnet", "gateway", "dns"}
for _, field := range requiredFields {
if formData[field] == "" {
- http.Error(w, fmt.Sprintf("Missing required field: %s", field), http.StatusBadRequest)
+ HandleError(w, r, NewValidationError(
+ fmt.Sprintf("Missing required field: %s", field),
+ fmt.Sprintf("The %s field is required", field),
+ ))
return
}
}
- // Load configuration to get provision directory
- cfg, err := config.LoadDefault()
- if err != nil {
- http.Error(w, "Failed to load configuration", http.StatusInternalServerError)
+ // The MAC address becomes part of file names — reject anything that is
+ // not a real MAC so it cannot smuggle path separators.
+ if _, err := net.ParseMAC(formData["mac"]); err != nil {
+ HandleError(w, r, NewValidationError(
+ fmt.Sprintf("Invalid MAC address: %v", err),
+ "The MAC address is invalid",
+ ))
return
}
+ macDashes := strings.ReplaceAll(formData["mac"], ":", "-")
+
+ provisionDir := h.container.Config.Provision.Dir
- // Build PXE file paths
- buildpxe := fmt.Sprintf("pxelinux.cfg/01-%s", strings.ReplaceAll(formData["mac"], ":", "-"))
- pxefile := filepath.Join(TFTPDir, buildpxe)
- pxetempl := filepath.Join(cfg.Provision.Dir, "templates/bootmenu/default.templ")
+ // Build PXE file paths (join-then-check: user input stays inside the roots).
+ pxefile, err := safeJoin(TFTPDir, filepath.Join("pxelinux.cfg", "01-"+macDashes))
+ if err != nil {
+ HandleError(w, r, NewValidationError(
+ fmt.Sprintf("Invalid PXE file path: %v", err),
+ "The PXE file path is not allowed",
+ ))
+ return
+ }
+ pxetempl := filepath.Join(provisionDir, "templates", "bootmenu", "default.templ")
// Create BootMenu struct
bootMenu := dhcp.BootMenu{
@@ -91,35 +107,60 @@ func (h *BootMenuHandlers) SubmitBootMenu(w http.ResponseWriter, r *http.Request
KernelOptions: formData["kernel_options"],
}
- // Build config file paths
- buildconfig := fmt.Sprintf("configs/%s/%s", formData["typeSelect"], strings.ReplaceAll(formData["mac"], ":", "-"))
- configFile := filepath.Join(cfg.Provision.Dir, buildconfig)
- templBuild := fmt.Sprintf("templates/%s/%s", formData["typeSelect"], formData["template_name"])
- configTempl := filepath.Join(cfg.Provision.Dir, templBuild)
+ // Build config file paths (join-then-check).
+ configFile, err := safeJoin(provisionDir, filepath.Join("configs", formData["typeSelect"], macDashes))
+ if err != nil {
+ HandleError(w, r, NewValidationError(
+ fmt.Sprintf("Invalid config file path: %v", err),
+ "The config file path is not allowed",
+ ))
+ return
+ }
+ configTempl, err := safeJoin(provisionDir, filepath.Join("templates", formData["typeSelect"], formData["template_name"]))
+ if err != nil {
+ HandleError(w, r, NewValidationError(
+ fmt.Sprintf("Invalid template path: %v", err),
+ "The template path is not allowed",
+ ))
+ return
+ }
// Generate boot data (use buildconfig for HTTP URL, configFile for filesystem path)
+ buildconfig := fmt.Sprintf("configs/%s/%s", formData["typeSelect"], macDashes)
pxedata := h.generateBootData(formData, buildconfig)
// Ensure directories exist
if err := os.MkdirAll(filepath.Dir(pxefile), 0755); err != nil {
- http.Error(w, fmt.Sprintf("Failed to create PXE directory: %v", err), http.StatusInternalServerError)
+ HandleError(w, r, NewInternalError(
+ fmt.Sprintf("Failed to create PXE directory: %v", err),
+ "Unable to prepare the PXE directory",
+ ))
return
}
if err := os.MkdirAll(filepath.Dir(configFile), 0755); err != nil {
- http.Error(w, fmt.Sprintf("Failed to create config directory: %v", err), http.StatusInternalServerError)
+ HandleError(w, r, NewInternalError(
+ fmt.Sprintf("Failed to create config directory: %v", err),
+ "Unable to prepare the config directory",
+ ))
return
}
// Write PXE template to disk
if err := h.writeTemplateToDisk(pxetempl, pxefile, pxedata); err != nil {
- http.Error(w, fmt.Sprintf("Error writing PXE menu: %v", err), http.StatusInternalServerError)
+ HandleError(w, r, NewInternalError(
+ fmt.Sprintf("Error writing PXE menu: %v", err),
+ "Unable to write the PXE boot menu",
+ ))
return
}
// Write config template to disk
if err := h.writeTemplateToDisk(configTempl, configFile, formData); err != nil {
- http.Error(w, fmt.Sprintf("Error writing config file: %v", err), http.StatusInternalServerError)
+ HandleError(w, r, NewInternalError(
+ fmt.Sprintf("Error writing config file: %v", err),
+ "Unable to write the config file",
+ ))
return
}
@@ -270,10 +311,12 @@ func (h *BootMenuHandlers) osToInitrd(os, version string) string {
return fmt.Sprintf("%s/initrd.img", h.osToName(os))
}
-// writeTemplateToDisk writes the template to disk.
+// writeTemplateToDisk parses the template file and writes the rendered
+// output to disk. Parse failures are returned as errors — never panics.
func (h *BootMenuHandlers) writeTemplateToDisk(templpath string, filepath string, data interface{}) error {
- templates := map[string]*template.Template{
- "templ": template.Must(template.ParseFiles(templpath)),
+ tmpl, err := template.ParseFiles(templpath)
+ if err != nil {
+ return fmt.Errorf("failed to parse template %s: %w", templpath, err)
}
file, err := os.Create(filepath)
@@ -282,7 +325,7 @@ func (h *BootMenuHandlers) writeTemplateToDisk(templpath string, filepath string
}
defer file.Close()
- if err := templates["templ"].Execute(file, data); err != nil {
+ if err := tmpl.Execute(file, data); err != nil {
return fmt.Errorf("failed to execute template: %w", err)
}
@@ -293,27 +336,9 @@ func (h *BootMenuHandlers) writeTemplateToDisk(templpath string, filepath string
func (h *BootMenuHandlers) updateDHCPLease(tftpip, mac string, menu dhcp.BootMenu) error {
ctx := context.Background()
- // Find server by IP to get server ID
- networkIP := net.ParseIP(tftpip)
- if networkIP == nil {
- return fmt.Errorf("invalid network IP: %s", tftpip)
- }
-
- servers, err := h.container.ServerService.GetAllServers(ctx)
- if err != nil {
- return fmt.Errorf("failed to get servers: %w", err)
- }
-
- var serverID string
- for _, server := range servers {
- if server.IP.Equal(networkIP) {
- serverID = server.ID
- break
- }
- }
-
- if serverID == "" {
- return fmt.Errorf("server not found for IP: %s", tftpip)
+ // Find server by IP to get server ID via the shared lookup helper.
+ if _, err := findServerByIP(ctx, h.container.ServerService, tftpip); err != nil {
+ return err
}
// Get lease by MAC
diff --git a/handlers/common.go b/handlers/common.go
old mode 100755
new mode 100644
index 439f8b0..6695b72
--- a/handlers/common.go
+++ b/handlers/common.go
@@ -1,15 +1,18 @@
package handlers
import (
+ "context"
"fmt"
"html/template"
"log"
"net"
"net/http"
"os"
- "path/filepath"
+ "strings"
+ "sync"
"ignite/config"
+ "ignite/dhcp"
)
// TFTPDir holds the directory path for TFTP server operations.
@@ -26,28 +29,79 @@ func init() {
}
}
-// LoadTemplates initializes and returns a map of templates for different pages.
+// templateCache holds the parsed templates, built once and shared by all
+// requests so template parsing (and any parse failure) never happens on the
+// request path.
+var (
+ templatesOnce sync.Once
+ templates map[string]*template.Template
+)
+
+// LoadTemplates returns the parsed template set, parsing once and caching the
+// result for all subsequent calls.
func LoadTemplates() map[string]*template.Template {
+ templatesOnce.Do(func() {
+ templates = parseTemplates()
+ })
+ return templates
+}
+
+// parseTemplates parses every page/modal template exactly once.
+// A template that fails to parse is logged and left out of the map —
+// callers treat a missing entry as "template not available" instead of
+// panicking on the first request that needs it.
+func parseTemplates() map[string]*template.Template {
const baseTemplate = "templates/base.templ"
- return map[string]*template.Template{
- "index": template.Must(template.ParseFiles(baseTemplate, "templates/pages/index.templ")),
- "login": template.Must(template.ParseFiles("templates/base-login.templ", "templates/pages/login.templ")),
- "dhcp": template.Must(template.ParseFiles(baseTemplate, "templates/pages/dhcp.templ")),
- "tftp": template.Must(template.ParseFiles(baseTemplate, "templates/pages/tftp.templ", "templates/modals/uploadmodal.templ")),
- "status": template.Must(template.ParseFiles(baseTemplate, "templates/pages/status.templ")),
- "status-content": template.Must(template.ParseFiles("templates/partials/status-content.templ")),
- "provision": template.Must(template.ParseFiles(baseTemplate, "templates/pages/provision.templ")),
- "osimages": template.Must(template.ParseFiles(baseTemplate, "templates/pages/osimages.templ")),
- "syslinux": template.Must(template.ParseFiles(baseTemplate, "templates/pages/syslinux.templ")),
- "dhcpmodal": template.Must(template.ParseFiles("templates/modals/dhcpmodal.templ")),
- "reservemodal": template.Must(template.ParseFiles("templates/modals/reservemodal.templ")),
- "bootmodal": template.Must(template.ParseFiles("templates/modals/bootmodal.templ")),
- "ipmimodal": template.Must(template.ParseFiles("templates/modals/ipmimodal.templ")),
- "uploadmodal": template.Must(template.ParseFiles("templates/modals/uploadmodal.templ")),
- "viewmodal": template.Must(template.ParseFiles("templates/modals/viewmodal.templ")),
- "provision-new-file": template.Must(template.ParseFiles("templates/modals/provision-new-file.templ")),
- "manualleasemodal": template.Must(template.ParseFiles("templates/modals/manualleasemodal.templ")),
+ files := map[string][]string{
+ "index": {baseTemplate, "templates/pages/index.templ"},
+ "login": {"templates/base-login.templ", "templates/pages/login.templ"},
+ "dhcp": {baseTemplate, "templates/pages/dhcp.templ"},
+ "tftp": {baseTemplate, "templates/pages/tftp.templ", "templates/modals/uploadmodal.templ"},
+ "status": {baseTemplate, "templates/pages/status.templ"},
+ "status-content": {"templates/partials/status-content.templ"},
+ "provision": {baseTemplate, "templates/pages/provision.templ"},
+ "osimages": {baseTemplate, "templates/pages/osimages.templ"},
+ "syslinux": {baseTemplate, "templates/pages/syslinux.templ"},
+ "dhcpmodal": {"templates/modals/dhcpmodal.templ"},
+ "reservemodal": {"templates/modals/reservemodal.templ"},
+ "bootmodal": {"templates/modals/bootmodal.templ"},
+ "ipmimodal": {"templates/modals/ipmimodal.templ"},
+ "uploadmodal": {"templates/modals/uploadmodal.templ"},
+ "viewmodal": {"templates/modals/viewmodal.templ"},
+ "provision-new-file": {"templates/modals/provision-new-file.templ"},
+ "manualleasemodal": {"templates/modals/manualleasemodal.templ"},
+ }
+
+ parsed := make(map[string]*template.Template, len(files))
+ for name, paths := range files {
+ tmpl, err := template.ParseFiles(paths...)
+ if err != nil {
+ log.Printf("Error parsing template %s: %v", name, err)
+ continue
+ }
+ parsed[name] = tmpl
+ }
+ return parsed
+}
+
+// renderCachedTemplate executes a cached template by name. A missing entry
+// (parse failure) or an execution error produces a generic error response
+// instead of a nil-pointer panic or an internal-error leak.
+func renderCachedTemplate(w http.ResponseWriter, r *http.Request, name string, data any, userMessage string) {
+ tmpl, ok := LoadTemplates()[name]
+ if !ok || tmpl == nil {
+ HandleError(w, r, NewInternalError(
+ fmt.Sprintf("template %q not available", name),
+ userMessage,
+ ))
+ return
+ }
+ if err := tmpl.Execute(w, data); err != nil {
+ HandleError(w, r, NewInternalError(
+ fmt.Sprintf("template %q error: %v", name, err),
+ userMessage,
+ ))
}
}
@@ -87,15 +141,16 @@ func (h *ModalHandlers) CloseModalHandler(w http.ResponseWriter, r *http.Request
func (h *ModalHandlers) OpenModalHandler(w http.ResponseWriter, r *http.Request) {
template, err := GetQueryParam(r, "template")
if err != nil {
- log.Printf("Error retrieving template parameter: %v", err)
- http.Error(w, "Invalid template parameter", http.StatusBadRequest)
+ HandleError(w, r, NewValidationError("Invalid template parameter", "The template parameter is invalid"))
return
}
templates := LoadTemplates()
if t, ok := templates[template]; !ok {
- log.Printf("Template %s not found", template)
- http.Error(w, fmt.Sprintf("Template %s not found", template), http.StatusNotFound)
+ HandleError(w, r, NewNotFoundError(
+ fmt.Sprintf("Template %s not found", template),
+ "The requested dialog does not exist",
+ ))
return
} else {
var data map[string]any
@@ -105,29 +160,25 @@ func (h *ModalHandlers) OpenModalHandler(w http.ResponseWriter, r *http.Request)
case "dhcpmodal":
data, err = NewDHCPModal(w, r, h.container)
if err != nil {
- log.Printf("Error creating DHCP modal data: %v", err)
- http.Error(w, "Failed to prepare DHCP data: "+err.Error(), http.StatusInternalServerError)
+ HandleError(w, r, NewInternalError("Failed to prepare DHCP data: "+err.Error(), "Unable to prepare the DHCP form"))
return
}
case "reservemodal":
data, err = NewReserveModal(w, r, h.container)
if err != nil {
- log.Printf("Error creating reserve modal data: %v", err)
- http.Error(w, "Failed to prepare modal data: "+err.Error(), http.StatusInternalServerError)
+ HandleError(w, r, NewInternalError("Failed to prepare modal data: "+err.Error(), "Unable to prepare the reservation form"))
return
}
case "bootmodal":
data, err = NewBootModal(w, r, h.container)
if err != nil {
- log.Printf("Error creating boot modal data: %v", err)
- http.Error(w, "Failed to prepare boot data: "+err.Error(), http.StatusInternalServerError)
+ HandleError(w, r, NewInternalError("Failed to prepare boot data: "+err.Error(), "Unable to prepare the boot menu form"))
return
}
case "ipmimodal":
data, err = NewIPMIModal(w, r, h.container)
if err != nil {
- log.Printf("Error creating ipmi modal data: %v", err)
- http.Error(w, "Failed to prepare ipmi data: "+err.Error(), http.StatusInternalServerError)
+ HandleError(w, r, NewInternalError("Failed to prepare ipmi data: "+err.Error(), "Unable to prepare the IPMI form"))
return
}
case "upload":
@@ -135,8 +186,7 @@ func (h *ModalHandlers) OpenModalHandler(w http.ResponseWriter, r *http.Request)
case "viewmodal":
data, err = NewViewModal(w, r)
if err != nil {
- log.Printf("Error creating view modal data: %v", err)
- http.Error(w, "Failed to prepare view data: "+err.Error(), http.StatusInternalServerError)
+ HandleError(w, r, NewInternalError("Failed to prepare view data: "+err.Error(), "Unable to prepare the file view"))
return
}
case "provision-new-file":
@@ -144,20 +194,20 @@ func (h *ModalHandlers) OpenModalHandler(w http.ResponseWriter, r *http.Request)
case "manualleasemodal":
data, err = NewManualLeaseModal(w, r, h.container)
if err != nil {
- log.Printf("Error creating manual lease modal data: %v", err)
- http.Error(w, "Failed to prepare manual lease data: "+err.Error(), http.StatusInternalServerError)
+ HandleError(w, r, NewInternalError("Failed to prepare manual lease data: "+err.Error(), "Unable to prepare the manual lease form"))
return
}
default:
- log.Printf("Unhandled template type: %s", template)
- http.Error(w, "Unhandled template type", http.StatusInternalServerError)
+ HandleError(w, r, NewInternalError("Unhandled template type: "+template, "Unable to open the requested dialog"))
return
}
w.Header().Set("Content-Type", "text/html")
if err := t.Execute(w, data); err != nil {
- log.Printf("Error executing template %s: %v", template, err)
- http.Error(w, "Could not render template", http.StatusInternalServerError)
+ HandleError(w, r, NewInternalError(
+ fmt.Sprintf("Error executing template %s: %v", template, err),
+ "Unable to render the dialog",
+ ))
}
}
}
@@ -196,7 +246,10 @@ func NewDHCPModal(w http.ResponseWriter, r *http.Request, container *Container)
data["startip"] = server.IPStart.String()
// Calculate end IP from start IP and lease range
- startInt := ipToInt(server.IPStart)
+ startInt, err := ipToInt(server.IPStart)
+ if err != nil {
+ return nil, fmt.Errorf("invalid server start IP: %w", err)
+ }
endInt := startInt + uint32(server.LeaseRange) - 1
endIP := net.IPv4(byte(endInt>>24), byte(endInt>>16), byte(endInt>>8), byte(endInt))
data["endip"] = endIP.String()
@@ -457,7 +510,10 @@ func NewViewModal(w http.ResponseWriter, r *http.Request) (map[string]any, error
return nil, fmt.Errorf("file parameter is required")
}
- filePath := filepath.Join(TFTPDir, fileName)
+ filePath, err := safeJoin(TFTPDir, fileName)
+ if err != nil {
+ return nil, fmt.Errorf("invalid file name: %v", err)
+ }
content, err := os.ReadFile(filePath)
if err != nil {
if os.IsNotExist(err) {
@@ -479,12 +535,46 @@ func NewProvisionNewFileModal() map[string]any {
}
}
-// ipToInt converts IP to uint32 (helper function for IP calculations)
-func ipToInt(ip net.IP) uint32 {
- if len(ip) == 16 {
- ip = ip[12:16] // Convert IPv6 to IPv4 if needed
+// ipToInt converts an IPv4 address to uint32. Non-IPv4 input returns an
+// error instead of panicking or silently truncating.
+func ipToInt(ip net.IP) (uint32, error) {
+ ip4 := ip.To4()
+ if ip4 == nil {
+ return 0, fmt.Errorf("not an IPv4 address: %s", ip.String())
+ }
+ return uint32(ip4[0])<<24 | uint32(ip4[1])<<16 | uint32(ip4[2])<<8 | uint32(ip4[3]), nil
+}
+
+// findServerByIP looks up a DHCP server by its interface IP. It is shared by
+// the DHCP, boot menu, and IPMI handlers so the lookup logic lives in one place.
+func findServerByIP(ctx context.Context, serverService dhcp.ServerService, ipStr string) (*dhcp.Server, error) {
+ if serverService == nil {
+ return nil, fmt.Errorf("DHCP server service not initialized")
+ }
+ ip := net.ParseIP(strings.TrimSpace(ipStr))
+ if ip == nil {
+ return nil, fmt.Errorf("invalid IP address: %s", ipStr)
+ }
+ servers, err := serverService.GetAllServers(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to list DHCP servers: %w", err)
+ }
+ for _, server := range servers {
+ if server != nil && server.IP.Equal(ip) {
+ return server, nil
+ }
+ }
+ return nil, fmt.Errorf("no DHCP server found for IP: %s", ipStr)
+}
+
+// requireConfig writes a generic 500 and reports false when the container's
+// config is nil, so handlers never nil-dereference container.Config.
+func requireConfig(w http.ResponseWriter, r *http.Request, container *Container) bool {
+ if container == nil || container.Config == nil {
+ HandleError(w, r, NewInternalError("server configuration not initialized", "Server is not initialized. Please try again later."))
+ return false
}
- return uint32(ip[0])<<24 + uint32(ip[1])<<16 + uint32(ip[2])<<8 + uint32(ip[3])
+ return true
}
// NewManualLeaseModal creates data for manual lease modal
diff --git a/handlers/dhcp.go b/handlers/dhcp.go
old mode 100755
new mode 100644
index 7260e40..97898ab
--- a/handlers/dhcp.go
+++ b/handlers/dhcp.go
@@ -1,11 +1,14 @@
package handlers
import (
+ "context"
"encoding/json"
"fmt"
"net"
"net/http"
"sort"
+ "strconv"
+ "strings"
"time"
"ignite/config"
@@ -28,45 +31,61 @@ func NewDHCPHandlers(container *Container) *DHCPHandlers {
}
}
+// convertServerToView converts a DHCP server model to its template view,
+// loading the server's leases. It is the single server-to-view conversion
+// used by every handler that lists servers.
+func (h *DHCPHandlers) convertServerToView(ctx context.Context, server *dhcp.Server) (DHCPServerView, error) {
+ leases, err := h.leaseService.GetLeasesByServer(ctx, server.ID)
+ if err != nil {
+ return DHCPServerView{}, fmt.Errorf("failed to get leases for server %s: %w", server.ID, err)
+ }
+
+ return DHCPServerView{
+ ID: server.ID,
+ TFTPIP: server.IP.String(),
+ Status: h.getServerStatusBadge(isDHCPRunning(server.ID, server.Started)),
+ Leases: h.convertLeasesToViews(leases),
+ }, nil
+}
+
+// serverViews builds sorted view models for a list of DHCP servers.
+func (h *DHCPHandlers) serverViews(ctx context.Context, servers []*dhcp.Server) ([]DHCPServerView, error) {
+ serverViews := make([]DHCPServerView, 0, len(servers))
+ for _, server := range servers {
+ view, err := h.convertServerToView(ctx, server)
+ if err != nil {
+ return nil, err
+ }
+ serverViews = append(serverViews, view)
+ }
+
+ // Sort servers by IP address for consistent ordering
+ h.sortServerViewsByIP(serverViews)
+ return serverViews, nil
+}
+
// HandleDHCPPage serves the DHCP management page
func (h *DHCPHandlers) HandleDHCPPage(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
servers, err := h.serverService.GetAllServers(ctx)
if err != nil {
- appErr := NewInternalError(
+ HandleError(w, r, NewInternalError(
fmt.Sprintf("Failed to get DHCP servers: %v", err),
"Unable to load DHCP servers. Please try again later.",
- )
- HandleError(w, r, appErr)
+ ))
return
}
- // Convert to view models for template rendering
- serverViews := make([]DHCPServerView, 0, len(servers))
- for _, server := range servers {
- leases, err := h.leaseService.GetLeasesByServer(ctx, server.ID)
- if err != nil {
- appErr := NewInternalError(
- fmt.Sprintf("Failed to get leases for server %s: %v", server.ID, err),
- "Unable to load DHCP leases. Please try again later.",
- )
- HandleError(w, r, appErr)
- return
- }
-
- serverView := DHCPServerView{
- ID: server.ID,
- TFTPIP: server.IP.String(),
- Status: h.getServerStatusBadge(server.Started),
- Leases: h.convertLeasesToViews(leases),
- }
- serverViews = append(serverViews, serverView)
+ serverViews, err := h.serverViews(ctx, servers)
+ if err != nil {
+ HandleError(w, r, NewInternalError(
+ fmt.Sprintf("Failed to load DHCP server details: %v", err),
+ "Unable to load DHCP servers. Please try again later.",
+ ))
+ return
}
- // Sort servers by IP address for consistent ordering
- h.sortServerViewsByIP(serverViews)
-
data := struct {
Title string
Servers []DHCPServerView
@@ -75,10 +94,7 @@ func (h *DHCPHandlers) HandleDHCPPage(w http.ResponseWriter, r *http.Request) {
Servers: serverViews,
}
- templates := LoadTemplates()
- if err := templates["dhcp"].Execute(w, data); err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- }
+ renderCachedTemplate(w, r, "dhcp", data, "Unable to render the DHCP page")
}
// GetDHCPServers handles GET /dhcp/servers
@@ -87,31 +103,22 @@ func (h *DHCPHandlers) GetDHCPServers(w http.ResponseWriter, r *http.Request) {
servers, err := h.serverService.GetAllServers(ctx)
if err != nil {
- http.Error(w, fmt.Sprintf("Failed to get servers: %v", err), http.StatusInternalServerError)
+ HandleError(w, r, NewInternalError(
+ fmt.Sprintf("Failed to get servers: %v", err),
+ "Unable to load DHCP servers. Please try again later.",
+ ))
return
}
- // Convert to view models for template rendering
- serverViews := make([]DHCPServerView, 0, len(servers))
- for _, server := range servers {
- leases, err := h.leaseService.GetLeasesByServer(ctx, server.ID)
- if err != nil {
- http.Error(w, fmt.Sprintf("Failed to get leases: %v", err), http.StatusInternalServerError)
- return
- }
-
- serverView := DHCPServerView{
- ID: server.ID,
- TFTPIP: server.IP.String(),
- Status: h.getServerStatusBadge(server.Started),
- Leases: h.convertLeasesToViews(leases),
- }
- serverViews = append(serverViews, serverView)
+ serverViews, err := h.serverViews(ctx, servers)
+ if err != nil {
+ HandleError(w, r, NewInternalError(
+ fmt.Sprintf("Failed to load DHCP server details: %v", err),
+ "Unable to load DHCP servers. Please try again later.",
+ ))
+ return
}
- // Sort servers by IP address for consistent ordering
- h.sortServerViewsByIP(serverViews)
-
data := struct {
Title string
Servers []DHCPServerView
@@ -129,14 +136,18 @@ func (h *DHCPHandlers) StartDHCPServer(w http.ResponseWriter, r *http.Request) {
serverID := r.URL.Query().Get("server_id")
if serverID == "" {
- http.Error(w, "Server ID is required", http.StatusBadRequest)
+ HandleError(w, r, NewValidationError("Missing server_id", "Server ID is required"))
return
}
if err := h.serverService.StartServer(ctx, serverID); err != nil {
- http.Error(w, fmt.Sprintf("Failed to start server: %v", err), http.StatusInternalServerError)
+ HandleError(w, r, NewInternalError(
+ fmt.Sprintf("Failed to start server: %v", err),
+ "Unable to start the DHCP server. Please try again later.",
+ ))
return
}
+ setDHCPRunning(serverID, true)
// Redirect back to DHCP page to show the updated server list
w.Header().Set("HX-Redirect", "/dhcp")
@@ -150,14 +161,18 @@ func (h *DHCPHandlers) StopDHCPServer(w http.ResponseWriter, r *http.Request) {
serverID := r.URL.Query().Get("server_id")
if serverID == "" {
- http.Error(w, "Server ID is required", http.StatusBadRequest)
+ HandleError(w, r, NewValidationError("Missing server_id", "Server ID is required"))
return
}
if err := h.serverService.StopServer(ctx, serverID); err != nil {
- http.Error(w, fmt.Sprintf("Failed to stop server: %v", err), http.StatusInternalServerError)
+ HandleError(w, r, NewInternalError(
+ fmt.Sprintf("Failed to stop server: %v", err),
+ "Unable to stop the DHCP server. Please try again later.",
+ ))
return
}
+ setDHCPRunning(serverID, false)
// Redirect back to DHCP page to show the updated server list
w.Header().Set("HX-Redirect", "/dhcp")
@@ -171,14 +186,18 @@ func (h *DHCPHandlers) DeleteDHCPServer(w http.ResponseWriter, r *http.Request)
serverID := r.URL.Query().Get("server_id")
if serverID == "" {
- http.Error(w, "Server ID is required", http.StatusBadRequest)
+ HandleError(w, r, NewValidationError("Missing server_id", "Server ID is required"))
return
}
if err := h.serverService.DeleteServer(ctx, serverID); err != nil {
- http.Error(w, fmt.Sprintf("Failed to delete server: %v", err), http.StatusInternalServerError)
+ HandleError(w, r, NewInternalError(
+ fmt.Sprintf("Failed to delete server: %v", err),
+ "Unable to delete the DHCP server. Please try again later.",
+ ))
return
}
+ forgetDHCPServer(serverID)
// Redirect back to DHCP page to show the updated server list
w.Header().Set("HX-Redirect", "/dhcp")
@@ -191,7 +210,7 @@ func (h *DHCPHandlers) SubmitDHCPServer(w http.ResponseWriter, r *http.Request)
ctx := r.Context()
if err := r.ParseForm(); err != nil {
- http.Error(w, "Failed to parse form", http.StatusBadRequest)
+ HandleError(w, r, NewValidationError("Failed to parse form", "The submitted form could not be parsed"))
return
}
@@ -206,13 +225,24 @@ func (h *DHCPHandlers) SubmitDHCPServer(w http.ResponseWriter, r *http.Request)
dnsStr := r.FormValue("dns")
startIPStr := r.FormValue("startIP")
endIPStr := r.FormValue("endIP")
+ leaseTimeStr := r.FormValue("lease_time")
+
+ // The subnet mask must be valid IPv4 and contiguous — never silently /24.
+ maskBits, err := getMaskBits(subnetStr)
+ if err != nil {
+ HandleError(w, r, NewValidationError(
+ fmt.Sprintf("Invalid subnet mask: %v", err),
+ "The subnet mask is invalid. Use a valid IPv4 mask like 255.255.255.0.",
+ ))
+ return
+ }
// Create DHCP configuration validator
validator := NewDHCPConfigValidator()
// Prepare configuration for validation
validationConfig := map[string]string{
- "subnet": networkStr + "/" + getMaskBits(subnetStr), // Convert to CIDR
+ "subnet": networkStr + "/" + maskBits, // Convert to CIDR
"range": startIPStr + "-" + endIPStr,
"router": gatewayStr,
"dns": dnsStr,
@@ -231,11 +261,42 @@ func (h *DHCPHandlers) SubmitDHCPServer(w http.ResponseWriter, r *http.Request)
subnet := net.ParseIP(subnetStr)
gateway := net.ParseIP(gatewayStr)
- // Calculate numLeases from start and end IP
- startInt := ipToInt(startIP)
- endInt := ipToInt(endIP)
+ // Calculate numLeases from start and end IP, guarding against underflow:
+ // end must not precede start.
+ startInt, err := ipToInt(startIP)
+ if err != nil {
+ HandleError(w, r, NewValidationError("Invalid start IP", "The start IP address is invalid"))
+ return
+ }
+ endInt, err := ipToInt(endIP)
+ if err != nil {
+ HandleError(w, r, NewValidationError("Invalid end IP", "The end IP address is invalid"))
+ return
+ }
+ if endInt < startInt {
+ HandleError(w, r, NewValidationError(
+ "End IP precedes start IP",
+ "The end IP must be greater than or equal to the start IP",
+ ))
+ return
+ }
numLeases := int(endInt - startInt + 1)
+ // Honor the submitted lease time. The UI displays it in hours, so parse
+ // hours; fall back to the 2h default when empty.
+ leaseDuration := 2 * time.Hour
+ if strings.TrimSpace(leaseTimeStr) != "" {
+ hours, err := strconv.ParseFloat(strings.TrimSpace(leaseTimeStr), 64)
+ if err != nil || hours <= 0 {
+ HandleError(w, r, NewValidationError(
+ fmt.Sprintf("Invalid lease time %q", leaseTimeStr),
+ "Lease time must be a positive number of hours",
+ ))
+ return
+ }
+ leaseDuration = time.Duration(hours * float64(time.Hour))
+ }
+
// Parse DNS (already validated above)
dns := net.ParseIP(dnsStr)
@@ -247,18 +308,17 @@ func (h *DHCPHandlers) SubmitDHCPServer(w http.ResponseWriter, r *http.Request)
DNS: dns,
StartIP: startIP,
LeaseRange: numLeases,
- LeaseDuration: 2 * time.Hour, // Default lease duration
+ LeaseDuration: leaseDuration,
}
if isEdit {
// Update existing server
err := h.serverService.UpdateServer(ctx, serverID, config)
if err != nil {
- appErr := NewInternalError(
+ HandleError(w, r, NewInternalError(
fmt.Sprintf("Failed to update DHCP server %s: %v", serverID, err),
"Unable to update DHCP server. Please try again later.",
- )
- HandleError(w, r, appErr)
+ ))
return
}
@@ -270,11 +330,10 @@ func (h *DHCPHandlers) SubmitDHCPServer(w http.ResponseWriter, r *http.Request)
// Create new server
server, err := h.serverService.CreateServer(ctx, config)
if err != nil {
- appErr := NewInternalError(
+ HandleError(w, r, NewInternalError(
fmt.Sprintf("Failed to create DHCP server: %v", err),
"Unable to create DHCP server. Please check your configuration and try again.",
- )
- HandleError(w, r, appErr)
+ ))
return
}
@@ -294,18 +353,21 @@ func (h *DHCPHandlers) ReserveLease(w http.ResponseWriter, r *http.Request) {
ipStr := r.URL.Query().Get("ip")
if serverID == "" || mac == "" || ipStr == "" {
- http.Error(w, "Server ID, MAC, and IP are required", http.StatusBadRequest)
+ HandleError(w, r, NewValidationError("Missing parameters", "Server ID, MAC, and IP are required"))
return
}
ip := net.ParseIP(ipStr)
if ip == nil {
- http.Error(w, "Invalid IP address", http.StatusBadRequest)
+ HandleError(w, r, NewValidationError("Invalid IP address", "The IP address is invalid"))
return
}
if err := h.leaseService.ReserveLease(ctx, serverID, mac, ip); err != nil {
- http.Error(w, fmt.Sprintf("Failed to reserve lease: %v", err), http.StatusInternalServerError)
+ HandleError(w, r, NewInternalError(
+ fmt.Sprintf("Failed to reserve lease: %v", err),
+ "Unable to reserve the lease. Please try again later.",
+ ))
return
}
@@ -321,12 +383,15 @@ func (h *DHCPHandlers) UnreserveLease(w http.ResponseWriter, r *http.Request) {
mac := r.URL.Query().Get("mac")
if mac == "" {
- http.Error(w, "MAC address is required", http.StatusBadRequest)
+ HandleError(w, r, NewValidationError("Missing MAC address", "MAC address is required"))
return
}
if err := h.leaseService.UnreserveLease(ctx, mac); err != nil {
- http.Error(w, fmt.Sprintf("Failed to unreserve lease: %v", err), http.StatusInternalServerError)
+ HandleError(w, r, NewInternalError(
+ fmt.Sprintf("Failed to unreserve lease: %v", err),
+ "Unable to unreserve the lease. Please try again later.",
+ ))
return
}
@@ -342,12 +407,15 @@ func (h *DHCPHandlers) DeleteLease(w http.ResponseWriter, r *http.Request) {
mac := r.URL.Query().Get("mac")
if mac == "" {
- http.Error(w, "MAC address is required", http.StatusBadRequest)
+ HandleError(w, r, NewValidationError("Missing MAC address", "MAC address is required"))
return
}
if err := h.leaseService.ReleaseLease(ctx, mac); err != nil {
- http.Error(w, fmt.Sprintf("Failed to delete lease: %v", err), http.StatusInternalServerError)
+ HandleError(w, r, NewInternalError(
+ fmt.Sprintf("Failed to delete lease: %v", err),
+ "Unable to delete the lease. Please try again later.",
+ ))
return
}
@@ -367,34 +435,25 @@ func (h *DHCPHandlers) AddManualLease(w http.ResponseWriter, r *http.Request) {
staticStr := r.FormValue("static")
if networkStr == "" || macStr == "" || ipStr == "" {
- http.Error(w, "Network, MAC address, and IP address are required", http.StatusBadRequest)
+ HandleError(w, r, NewValidationError("Missing parameters", "Network, MAC address, and IP address are required"))
return
}
- // Find the server by network IP
- servers, err := h.serverService.GetAllServers(ctx)
+ // Find the server by network IP via the shared lookup helper.
+ server, err := findServerByIP(ctx, h.serverService, networkStr)
if err != nil {
- http.Error(w, fmt.Sprintf("Failed to get servers: %v", err), http.StatusInternalServerError)
- return
- }
-
- var serverID string
- for _, server := range servers {
- if server.IP.String() == networkStr {
- serverID = server.ID
- break
- }
- }
-
- if serverID == "" {
- http.Error(w, "Server not found for network", http.StatusBadRequest)
+ HandleError(w, r, NewValidationError(
+ fmt.Sprintf("Server lookup failed: %v", err),
+ "No DHCP server found for that network",
+ ))
return
}
+ serverID := server.ID
// Parse IP address
ip := net.ParseIP(ipStr)
if ip == nil {
- http.Error(w, "Invalid IP address", http.StatusBadRequest)
+ HandleError(w, r, NewValidationError("Invalid IP address", "The IP address is invalid"))
return
}
@@ -405,14 +464,20 @@ func (h *DHCPHandlers) AddManualLease(w http.ResponseWriter, r *http.Request) {
// Create a reserved lease
err = h.leaseService.ReserveLease(ctx, serverID, macStr, ip)
if err != nil {
- http.Error(w, fmt.Sprintf("Failed to create reserved lease: %v", err), http.StatusInternalServerError)
+ HandleError(w, r, NewInternalError(
+ fmt.Sprintf("Failed to create reserved lease: %v", err),
+ "Unable to create the reserved lease. Please try again later.",
+ ))
return
}
} else {
// Create a regular lease
_, err = h.leaseService.AssignLease(ctx, serverID, macStr, ip)
if err != nil {
- http.Error(w, fmt.Sprintf("Failed to create lease: %v", err), http.StatusInternalServerError)
+ HandleError(w, r, NewInternalError(
+ fmt.Sprintf("Failed to create lease: %v", err),
+ "Unable to create the lease. Please try again later.",
+ ))
return
}
}
@@ -423,7 +488,20 @@ func (h *DHCPHandlers) AddManualLease(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Manual DHCP entry added successfully"))
}
-// UpdateLeaseState handles POST /dhcp/lease/{mac}/state
+// validLeaseStates is the allowlist of lease states accepted by UpdateLeaseState.
+var validLeaseStates = map[string]bool{
+ string(dhcp.StateAssigned): true,
+ string(dhcp.StatePXERequested): true,
+ string(dhcp.StateBooting): true,
+ string(dhcp.StateImaging): true,
+ string(dhcp.StateImaged): true,
+ string(dhcp.StateConfiguring): true,
+ string(dhcp.StateComplete): true,
+ string(dhcp.StateFailed): true,
+ string(dhcp.StateOffline): true,
+}
+
+// UpdateLeaseState handles POST /dhcp/lease/state
func (h *DHCPHandlers) UpdateLeaseState(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
@@ -432,7 +510,16 @@ func (h *DHCPHandlers) UpdateLeaseState(w http.ResponseWriter, r *http.Request)
source := r.FormValue("source")
if mac == "" || newState == "" {
- http.Error(w, "MAC address and state are required", http.StatusBadRequest)
+ HandleError(w, r, NewValidationError("Missing parameters", "MAC address and state are required"))
+ return
+ }
+
+ // Only real DHCP provisioning states are accepted.
+ if !validLeaseStates[newState] {
+ HandleError(w, r, NewValidationError(
+ fmt.Sprintf("Unknown lease state %q", newState),
+ "The requested lease state is not valid",
+ ))
return
}
@@ -442,7 +529,10 @@ func (h *DHCPHandlers) UpdateLeaseState(w http.ResponseWriter, r *http.Request)
err := h.leaseService.UpdateLeaseState(ctx, mac, newState, source)
if err != nil {
- http.Error(w, fmt.Sprintf("Failed to update lease state: %v", err), http.StatusInternalServerError)
+ HandleError(w, r, NewInternalError(
+ fmt.Sprintf("Failed to update lease state: %v", err),
+ "Unable to update the lease state. Please try again later.",
+ ))
return
}
@@ -457,13 +547,16 @@ func (h *DHCPHandlers) RecordHeartbeat(w http.ResponseWriter, r *http.Request) {
mac := r.URL.Query().Get("mac")
if mac == "" {
- http.Error(w, "MAC address is required", http.StatusBadRequest)
+ HandleError(w, r, NewValidationError("Missing MAC address", "MAC address is required"))
return
}
err := h.leaseService.RecordHeartbeat(ctx, mac)
if err != nil {
- http.Error(w, fmt.Sprintf("Failed to record heartbeat: %v", err), http.StatusInternalServerError)
+ HandleError(w, r, NewInternalError(
+ fmt.Sprintf("Failed to record heartbeat: %v", err),
+ "Unable to record the heartbeat. Please try again later.",
+ ))
return
}
@@ -472,19 +565,22 @@ func (h *DHCPHandlers) RecordHeartbeat(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"status": "success", "message": "Heartbeat recorded"}`))
}
-// GetLeaseStateHistory handles GET /dhcp/lease/{mac}/history
+// GetLeaseStateHistory handles GET /dhcp/lease/history
func (h *DHCPHandlers) GetLeaseStateHistory(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
mac := r.URL.Query().Get("mac")
if mac == "" {
- http.Error(w, "MAC address is required", http.StatusBadRequest)
+ HandleError(w, r, NewValidationError("Missing MAC address", "MAC address is required"))
return
}
history, err := h.leaseService.GetLeaseStateHistory(ctx, mac)
if err != nil {
- http.Error(w, fmt.Sprintf("Failed to get lease history: %v", err), http.StatusInternalServerError)
+ HandleError(w, r, NewInternalError(
+ fmt.Sprintf("Failed to get lease history: %v", err),
+ "Unable to load lease history. Please try again later.",
+ ))
return
}
@@ -507,29 +603,11 @@ func (h *DHCPHandlers) getServerStatusBadge(started bool) string {
return "badge-error"
}
-// sortServerViewsByIP sorts server views by IP address for consistent ordering
+// sortServerViewsByIP sorts server views by IP address for consistent ordering.
+// It uses the shared nil-guarded IP comparator.
func (h *DHCPHandlers) sortServerViewsByIP(serverViews []DHCPServerView) {
sort.Slice(serverViews, func(i, j int) bool {
- ipA := net.ParseIP(serverViews[i].TFTPIP)
- ipB := net.ParseIP(serverViews[j].TFTPIP)
-
- // Convert IPs to 4-byte representation for comparison
- if ipA.To4() != nil {
- ipA = ipA.To4()
- }
- if ipB.To4() != nil {
- ipB = ipB.To4()
- }
-
- // Compare byte by byte
- for k := 0; k < len(ipA) && k < len(ipB); k++ {
- if ipA[k] != ipB[k] {
- return ipA[k] < ipB[k]
- }
- }
-
- // If all compared bytes are equal, shorter IP comes first
- return len(ipA) < len(ipB)
+ return compareIPs(net.ParseIP(serverViews[i].TFTPIP), net.ParseIP(serverViews[j].TFTPIP)) < 0
})
}
@@ -542,8 +620,8 @@ func (h *DHCPHandlers) convertLeasesToViews(leases []*dhcp.Lease) []LeaseView {
Static: lease.Reserved,
Menu: lease.Menu,
IPMI: lease.IPMI,
- State: lease.State,
- StateBadgeClass: lease.GetStateBadgeClass(),
+ State: string(lease.State),
+ StateBadgeClass: getStateBadgeClass(lease.State),
StateDisplayName: lease.GetStateDisplayName(),
LastSeen: lease.LastSeen,
})
@@ -551,31 +629,39 @@ func (h *DHCPHandlers) convertLeasesToViews(leases []*dhcp.Lease) []LeaseView {
// Sort leases by IP address for consistent ordering
sort.Slice(views, func(i, j int) bool {
- ipA := net.ParseIP(views[i].IP)
- ipB := net.ParseIP(views[j].IP)
-
- // Convert IPs to 4-byte representation for comparison
- if ipA.To4() != nil {
- ipA = ipA.To4()
- }
- if ipB.To4() != nil {
- ipB = ipB.To4()
- }
-
- // Compare byte by byte
- for k := 0; k < len(ipA) && k < len(ipB); k++ {
- if ipA[k] != ipB[k] {
- return ipA[k] < ipB[k]
- }
- }
-
- // If all compared bytes are equal, shorter IP comes first
- return len(ipA) < len(ipB)
+ return compareIPs(net.ParseIP(views[i].IP), net.ParseIP(views[j].IP)) < 0
})
return views
}
+// getStateBadgeClass returns the CSS class for a lease state badge.
+// Presentation lives in handlers, not in the dhcp domain model.
+func getStateBadgeClass(state dhcp.LeaseState) string {
+ switch state {
+ case dhcp.StateAssigned:
+ return "badge-info"
+ case dhcp.StatePXERequested:
+ return "badge-warning"
+ case dhcp.StateBooting:
+ return "badge-warning"
+ case dhcp.StateImaging:
+ return "badge-accent"
+ case dhcp.StateImaged:
+ return "badge-success"
+ case dhcp.StateConfiguring:
+ return "badge-accent"
+ case dhcp.StateComplete:
+ return "badge-success"
+ case dhcp.StateFailed:
+ return "badge-error"
+ case dhcp.StateOffline:
+ return "badge-ghost"
+ default:
+ return "badge-neutral"
+ }
+}
+
// View models for templates
type DHCPServerView struct {
ID string `json:"id"`
@@ -604,29 +690,22 @@ func renderTemplate(w http.ResponseWriter, templateName string, data interface{}
fmt.Fprintf(w, "Template: %s with data: %+v", templateName, data)
}
-// getMaskBits converts a subnet mask to CIDR bits
-func getMaskBits(mask string) string {
- ip := net.ParseIP(mask)
+// getMaskBits converts a dotted-decimal subnet mask to its CIDR prefix length.
+// Invalid IPv4 input and non-contiguous masks are rejected as errors instead
+// of silently falling back to /24.
+func getMaskBits(mask string) (string, error) {
+ trimmed := strings.TrimSpace(mask)
+ ip := net.ParseIP(trimmed)
if ip == nil {
- return "24" // Default fallback
+ return "", fmt.Errorf("not a valid IP address: %q", mask)
}
-
- // Convert IPv4 mask to CIDR bits
mask4 := ip.To4()
if mask4 == nil {
- return "24" // Default fallback
+ return "", fmt.Errorf("not an IPv4 mask: %q", mask)
}
-
- // Count the number of set bits
- var bits int
- for _, b := range mask4 {
- for b != 0 {
- if b&1 == 1 {
- bits++
- }
- b >>= 1
- }
+ ones, bits := net.IPv4Mask(mask4[0], mask4[1], mask4[2], mask4[3]).Size()
+ if bits != 32 {
+ return "", fmt.Errorf("not a contiguous subnet mask: %q", mask)
}
-
- return fmt.Sprintf("%d", bits)
+ return strconv.Itoa(ones), nil
}
diff --git a/handlers/errors.go b/handlers/errors.go
index 0729dfc..0041670 100644
--- a/handlers/errors.go
+++ b/handlers/errors.go
@@ -77,7 +77,9 @@ type ErrorResponse struct {
Error AppError `json:"error"`
}
-// SendError sends a structured error response
+// SendError sends a structured error response.
+// The internal Message is logged server-side only; the response carries the
+// generic UserMessage so internal details never leak to clients.
func SendError(w http.ResponseWriter, r *http.Request, err *AppError) {
// Log the internal error message
log.Printf("Error [%s] %s: %s", r.Method, r.URL.Path, err.Message)
@@ -89,10 +91,16 @@ func SendError(w http.ResponseWriter, r *http.Request, err *AppError) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(err.StatusCode)
- // Create error response
+ // Create error response — expose only the user-facing message
response := ErrorResponse{
Success: false,
- Error: *err,
+ Error: AppError{
+ Type: err.Type,
+ Message: err.UserMessage,
+ UserMessage: err.UserMessage,
+ Code: err.Code,
+ StatusCode: err.StatusCode,
+ },
}
// Send JSON response
@@ -159,12 +167,16 @@ func SendValidationError(w http.ResponseWriter, r *http.Request, errors Validati
}
}
-// IsHTMLRequest checks if the request expects HTML response
+// IsHTMLRequest checks if the request expects HTML response.
+// A wildcard Accept of */* (curl/fetch default) does NOT count as HTML —
+// only an explicit text/html or application/xhtml+xml does.
func IsHTMLRequest(r *http.Request) bool {
accept := r.Header.Get("Accept")
- return accept != "" && (contains(accept, "text/html") ||
- contains(accept, "application/xhtml+xml") ||
- contains(accept, "*/*"))
+ if accept == "" {
+ return true // Default to HTML for browser-like requests
+ }
+ return contains(accept, "text/html") ||
+ contains(accept, "application/xhtml+xml")
}
// contains checks if a string contains a substring (case-insensitive)
@@ -172,8 +184,14 @@ func contains(s, substr string) bool {
return strings.Contains(strings.ToLower(s), strings.ToLower(substr))
}
-// HandleError is a utility function that automatically chooses between JSON and HTML error responses
+// HandleError is a utility function that automatically chooses between JSON and HTML error responses.
+// API paths and explicit application/json clients always get JSON; HTML only
+// when the Accept header actually asks for it.
func HandleError(w http.ResponseWriter, r *http.Request, err *AppError) {
+ if wantsJSONResponse(r) {
+ SendError(w, r, err)
+ return
+ }
if IsHTMLRequest(r) && r.Header.Get("HX-Request") == "" { // Not an HTMX request
SendHTMLError(w, r, err)
} else {
@@ -181,6 +199,14 @@ func HandleError(w http.ResponseWriter, r *http.Request, err *AppError) {
}
}
+// wantsJSONResponse reports whether the request should get a JSON error body.
+func wantsJSONResponse(r *http.Request) bool {
+ if strings.HasPrefix(r.URL.Path, "/api/") {
+ return true
+ }
+ return contains(r.Header.Get("Accept"), "application/json")
+}
+
// Common error messages
var (
ErrInvalidInput = "Invalid input provided"
diff --git a/handlers/index.go b/handlers/index.go
old mode 100755
new mode 100644
index bd689b7..ea521cf
--- a/handlers/index.go
+++ b/handlers/index.go
@@ -16,10 +16,6 @@ func NewIndexHandlers(container *Container) *IndexHandlers {
// Index serves the main index page of the application.
func (h *IndexHandlers) Index(w http.ResponseWriter, r *http.Request) {
- templates := LoadTemplates()
-
data := map[string]string{"title": "Ignite"}
- if err := templates["index"].Execute(w, data); err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- }
+ renderCachedTemplate(w, r, "index", data, "Unable to render the home page")
}
diff --git a/handlers/ipmi.go b/handlers/ipmi.go
index 047309e..adbe5ad 100644
--- a/handlers/ipmi.go
+++ b/handlers/ipmi.go
@@ -25,7 +25,7 @@ func NewIPMIHandlers(container *Container) *IPMIHandlers {
// SubmitIPMI handles IPMI submission
func (h *IPMIHandlers) SubmitIPMI(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
- http.Error(w, "Failed to parse form data", http.StatusBadRequest)
+ HandleError(w, r, NewValidationError("Failed to parse form data", "The submitted form could not be parsed"))
return
}
@@ -36,7 +36,16 @@ func (h *IPMIHandlers) SubmitIPMI(w http.ResponseWriter, r *http.Request) {
password := r.Form.Get("password")
if ip == "" || username == "" || password == "" {
- http.Error(w, "IP, username, and password are required", http.StatusBadRequest)
+ HandleError(w, r, NewValidationError("IP, username, and password are required", "IP, username, and password are required"))
+ return
+ }
+
+ // The BMC IP ends up in the Redfish endpoint URL — validate it up front.
+ if parsedIP := net.ParseIP(ip); parsedIP == nil {
+ HandleError(w, r, NewValidationError(
+ fmt.Sprintf("Invalid BMC IP address: %q", ip),
+ "The BMC IP address is invalid",
+ ))
return
}
@@ -61,7 +70,10 @@ func (h *IPMIHandlers) SubmitIPMI(w http.ResponseWriter, r *http.Request) {
client, err := gofish.Connect(clientConfig)
if err != nil {
- http.Error(w, fmt.Sprintf("Failed to connect to Redfish service: %s", err.Error()), http.StatusInternalServerError)
+ HandleError(w, r, NewInternalError(
+ fmt.Sprintf("Failed to connect to Redfish service: %s", err.Error()),
+ "Unable to connect to the Redfish service on the target system",
+ ))
return
}
defer client.Logout()
@@ -70,7 +82,7 @@ func (h *IPMIHandlers) SubmitIPMI(w http.ResponseWriter, r *http.Request) {
service := client.Service
systems, err := service.Systems()
if err != nil || len(systems) == 0 {
- http.Error(w, "No systems found or error retrieving systems", http.StatusNotFound)
+ HandleError(w, r, NewNotFoundError("No systems found or error retrieving systems", "No manageable systems were found on the target"))
return
}
@@ -83,7 +95,10 @@ func (h *IPMIHandlers) SubmitIPMI(w http.ResponseWriter, r *http.Request) {
// Set PXE boot if checked
if bootConfigChecked {
if err := system.SetBoot(bootConfig); err != nil {
- http.Error(w, fmt.Sprintf("Failed to set PXE boot: %s", err.Error()), http.StatusInternalServerError)
+ HandleError(w, r, NewInternalError(
+ fmt.Sprintf("Failed to set PXE boot: %s", err.Error()),
+ "Unable to set the PXE boot order on the target system",
+ ))
return
}
}
@@ -91,7 +106,10 @@ func (h *IPMIHandlers) SubmitIPMI(w http.ResponseWriter, r *http.Request) {
// Reboot system if checked
if rebootChecked {
if err := system.Reset(redfish.ForceRestartResetType); err != nil {
- http.Error(w, fmt.Sprintf("Failed to reboot system: %s", err.Error()), http.StatusInternalServerError)
+ HandleError(w, r, NewInternalError(
+ fmt.Sprintf("Failed to reboot system: %s", err.Error()),
+ "Unable to reboot the target system",
+ ))
return
}
}
@@ -102,27 +120,9 @@ func (h *IPMIHandlers) SubmitIPMI(w http.ResponseWriter, r *http.Request) {
// updateDHCPLeaseWithIPMI updates the DHCP lease with IPMI configuration
func (h *IPMIHandlers) updateDHCPLeaseWithIPMI(ctx context.Context, tftpip, mac, ip, username string, pxeboot, reboot bool) error {
- // Find server by IP to get server ID
- networkIP := net.ParseIP(tftpip)
- if networkIP == nil {
- return fmt.Errorf("invalid network IP: %s", tftpip)
- }
-
- servers, err := h.container.ServerService.GetAllServers(ctx)
- if err != nil {
- return fmt.Errorf("failed to get servers: %w", err)
- }
-
- var serverID string
- for _, server := range servers {
- if server.IP.Equal(networkIP) {
- serverID = server.ID
- break
- }
- }
-
- if serverID == "" {
- return fmt.Errorf("server not found for IP: %s", tftpip)
+ // Find server by IP via the shared lookup helper (validates the TFTP IP too).
+ if _, err := findServerByIP(ctx, h.container.ServerService, tftpip); err != nil {
+ return err
}
// Get lease by MAC
diff --git a/handlers/ipxe.go b/handlers/ipxe.go
index f18dffe..d58cc00 100644
--- a/handlers/ipxe.go
+++ b/handlers/ipxe.go
@@ -22,7 +22,10 @@ func (h *IPXEHandlers) GenerateConfig(w http.ResponseWriter, r *http.Request) {
config, err := h.container.IPXEService.GenerateConfig(ctx)
if err != nil {
- http.Error(w, "Failed to generate iPXE config: "+err.Error(), http.StatusInternalServerError)
+ HandleError(w, r, NewInternalError(
+ "Failed to generate iPXE config: "+err.Error(),
+ "Unable to generate the iPXE configuration",
+ ))
return
}
@@ -36,7 +39,10 @@ func (h *IPXEHandlers) UpdateConfigFile(w http.ResponseWriter, r *http.Request)
ctx := r.Context()
if err := h.container.IPXEService.WriteConfigToFile(ctx); err != nil {
- http.Error(w, "Failed to update iPXE config file: "+err.Error(), http.StatusInternalServerError)
+ HandleError(w, r, NewInternalError(
+ "Failed to update iPXE config file: "+err.Error(),
+ "Unable to update the iPXE configuration file",
+ ))
return
}
diff --git a/handlers/osimage.go b/handlers/osimage.go
index 0f99202..24e3f3c 100644
--- a/handlers/osimage.go
+++ b/handlers/osimage.go
@@ -3,6 +3,7 @@ package handlers
import (
"encoding/json"
"fmt"
+ "ignite/config"
"ignite/osimage"
"net/http"
@@ -21,12 +22,15 @@ func NewOSImageHandlers(container *Container) *OSImageHandlers {
// OSImagesPage serves the OS images management page
func (h *OSImageHandlers) OSImagesPage(w http.ResponseWriter, r *http.Request) {
+ if !requireConfig(w, r, h.container) {
+ return
+ }
ctx := r.Context()
// Get all OS images
images, err := h.container.OSImageService.GetAllOSImages(ctx)
if err != nil {
- http.Error(w, fmt.Sprintf("Failed to get OS images: %v", err), http.StatusInternalServerError)
+ HandleError(w, r, NewInternalError(fmt.Sprintf("Failed to get OS images: %v", err), "Unable to load OS images"))
return
}
@@ -68,19 +72,19 @@ func (h *OSImageHandlers) OSImagesPage(w http.ResponseWriter, r *http.Request) {
Architectures: supportedArchs,
}
- templates := LoadTemplates()
- if err := templates["osimages"].Execute(w, data); err != nil {
- http.Error(w, fmt.Sprintf("Template error: %v", err), http.StatusInternalServerError)
- }
+ renderCachedTemplate(w, r, "osimages", data, "Unable to render the OS images page")
}
// DownloadOSImage starts downloading an OS image
func (h *OSImageHandlers) DownloadOSImage(w http.ResponseWriter, r *http.Request) {
+ if !requireConfig(w, r, h.container) {
+ return
+ }
ctx := r.Context()
// Parse form data
if err := r.ParseForm(); err != nil {
- http.Error(w, "Failed to parse form data", http.StatusBadRequest)
+ HandleError(w, r, NewValidationError("Failed to parse form data", "The submitted form could not be parsed"))
return
}
@@ -89,7 +93,7 @@ func (h *OSImageHandlers) DownloadOSImage(w http.ResponseWriter, r *http.Request
architecture := r.Form.Get("architecture")
if os == "" || version == "" {
- http.Error(w, "OS and version are required", http.StatusBadRequest)
+ HandleError(w, r, NewValidationError("OS and version are required", "OS and version are required"))
return
}
@@ -97,6 +101,16 @@ func (h *OSImageHandlers) DownloadOSImage(w http.ResponseWriter, r *http.Request
architecture = "x86_64" // Default architecture
}
+ // Validate the OS, version, and architecture against the configured
+ // sources before starting any download.
+ if err := validateOSImageRequest(h.container.Config.OSImages.Sources, os, version, architecture); err != nil {
+ HandleError(w, r, NewValidationError(
+ fmt.Sprintf("Invalid OS image request: %v", err),
+ err.Error(),
+ ))
+ return
+ }
+
// Create download config
config := osimage.OSImageConfig{
OS: os,
@@ -107,7 +121,7 @@ func (h *OSImageHandlers) DownloadOSImage(w http.ResponseWriter, r *http.Request
// Start download
status, err := h.container.OSImageService.DownloadOSImage(ctx, config)
if err != nil {
- http.Error(w, fmt.Sprintf("Failed to start download: %v", err), http.StatusInternalServerError)
+ HandleError(w, r, NewInternalError(fmt.Sprintf("Failed to start download: %v", err), "Unable to start the download"))
return
}
@@ -120,6 +134,25 @@ func (h *OSImageHandlers) DownloadOSImage(w http.ResponseWriter, r *http.Request
})
}
+// validateOSImageRequest checks that the requested OS, version, and
+// architecture exist in the configured OS image sources.
+func validateOSImageRequest(sources map[string]config.OSDefinition, os, version, architecture string) error {
+ osDef, ok := sources[os]
+ if !ok {
+ return fmt.Errorf("unknown OS %q", os)
+ }
+ versionDef, ok := osDef.Versions[version]
+ if !ok {
+ return fmt.Errorf("unknown version %q for OS %q", version, os)
+ }
+ for _, arch := range versionDef.Architectures {
+ if arch == architecture {
+ return nil
+ }
+ }
+ return fmt.Errorf("unsupported architecture %q for %s %s", architecture, os, version)
+}
+
// GetDownloadStatus returns the status of a download
func (h *OSImageHandlers) GetDownloadStatus(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
@@ -127,13 +160,13 @@ func (h *OSImageHandlers) GetDownloadStatus(w http.ResponseWriter, r *http.Reque
downloadID := vars["id"]
if downloadID == "" {
- http.Error(w, "Download ID is required", http.StatusBadRequest)
+ HandleError(w, r, NewValidationError("Download ID is required", "A download id is required"))
return
}
status, err := h.container.OSImageService.GetDownloadStatus(ctx, downloadID)
if err != nil {
- http.Error(w, fmt.Sprintf("Failed to get download status: %v", err), http.StatusNotFound)
+ HandleError(w, r, NewNotFoundError(fmt.Sprintf("Failed to get download status: %v", err), "The requested download was not found"))
return
}
@@ -148,12 +181,12 @@ func (h *OSImageHandlers) SetDefaultVersion(w http.ResponseWriter, r *http.Reque
imageID := vars["id"]
if imageID == "" {
- http.Error(w, "Image ID is required", http.StatusBadRequest)
+ HandleError(w, r, NewValidationError("Image ID is required", "An image id is required"))
return
}
if err := h.container.OSImageService.SetDefaultVersion(ctx, imageID); err != nil {
- http.Error(w, fmt.Sprintf("Failed to set default version: %v", err), http.StatusInternalServerError)
+ HandleError(w, r, NewInternalError(fmt.Sprintf("Failed to set default version: %v", err), "Unable to set the default version"))
return
}
@@ -171,12 +204,12 @@ func (h *OSImageHandlers) DeleteOSImage(w http.ResponseWriter, r *http.Request)
imageID := vars["id"]
if imageID == "" {
- http.Error(w, "Image ID is required", http.StatusBadRequest)
+ HandleError(w, r, NewValidationError("Image ID is required", "An image id is required"))
return
}
if err := h.container.OSImageService.DeleteOSImage(ctx, imageID); err != nil {
- http.Error(w, fmt.Sprintf("Failed to delete OS image: %v", err), http.StatusInternalServerError)
+ HandleError(w, r, NewInternalError(fmt.Sprintf("Failed to delete OS image: %v", err), "Unable to delete the OS image"))
return
}
@@ -188,25 +221,28 @@ func (h *OSImageHandlers) DeleteOSImage(w http.ResponseWriter, r *http.Request)
// GetAvailableVersions returns available versions for an OS
func (h *OSImageHandlers) GetAvailableVersions(w http.ResponseWriter, r *http.Request) {
+ if !requireConfig(w, r, h.container) {
+ return
+ }
ctx := r.Context()
os := r.URL.Query().Get("os")
if os == "" {
- http.Error(w, "OS parameter is required", http.StatusBadRequest)
+ HandleError(w, r, NewValidationError("OS parameter is required", "An OS is required"))
return
}
// Use the service to get available versions
versions, err := h.container.OSImageService.GetAvailableVersions(ctx, os)
if err != nil {
- http.Error(w, fmt.Sprintf("Failed to get versions: %v", err), http.StatusInternalServerError)
+ HandleError(w, r, NewInternalError(fmt.Sprintf("Failed to get versions: %v", err), "Unable to load available versions"))
return
}
// Create a more detailed response with display names from config
osDef, exists := h.container.Config.OSImages.Sources[os]
if !exists {
- http.Error(w, fmt.Sprintf("OS configuration not found: %s", os), http.StatusInternalServerError)
+ HandleError(w, r, NewNotFoundError(fmt.Sprintf("OS configuration not found: %s", os), "The requested OS is not configured"))
return
}
@@ -240,13 +276,13 @@ func (h *OSImageHandlers) GetOSImageInfo(w http.ResponseWriter, r *http.Request)
imageID := vars["id"]
if imageID == "" {
- http.Error(w, "Image ID is required", http.StatusBadRequest)
+ HandleError(w, r, NewValidationError("Image ID is required", "An image id is required"))
return
}
image, err := h.container.OSImageService.GetOSImage(ctx, imageID)
if err != nil {
- http.Error(w, fmt.Sprintf("Failed to get OS image: %v", err), http.StatusNotFound)
+ HandleError(w, r, NewNotFoundError(fmt.Sprintf("Failed to get OS image: %v", err), "The requested OS image was not found"))
return
}
@@ -261,12 +297,12 @@ func (h *OSImageHandlers) CancelDownload(w http.ResponseWriter, r *http.Request)
downloadID := vars["id"]
if downloadID == "" {
- http.Error(w, "Download ID is required", http.StatusBadRequest)
+ HandleError(w, r, NewValidationError("Download ID is required", "A download id is required"))
return
}
if err := h.container.OSImageService.CancelDownload(ctx, downloadID); err != nil {
- http.Error(w, fmt.Sprintf("Failed to cancel download: %v", err), http.StatusInternalServerError)
+ HandleError(w, r, NewInternalError(fmt.Sprintf("Failed to cancel download: %v", err), "Unable to cancel the download"))
return
}
diff --git a/handlers/provision.go b/handlers/provision.go
index d889779..374c3e1 100644
--- a/handlers/provision.go
+++ b/handlers/provision.go
@@ -8,6 +8,7 @@ import (
"os"
"path/filepath"
"sort"
+ "strconv"
"strings"
"time"
)
@@ -56,12 +57,12 @@ func NewProvisionHandlers(container *Container) *ProvisionHandlers {
// HomeHandler serves the provision page
func (h *ProvisionHandlers) HomeHandler(w http.ResponseWriter, r *http.Request) {
+ if !requireConfig(w, r, h.container) {
+ return
+ }
data := h.getProvisionData()
- templates := LoadTemplates()
- if err := templates["provision"].Execute(w, data); err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- }
+ renderCachedTemplate(w, r, "provision", data, "Unable to render the provision page")
}
// getProvisionData collects all provision files and metadata
@@ -76,10 +77,10 @@ func (h *ProvisionHandlers) getProvisionData() *ProvisionData {
}
// Scan templates directory
- h.scanDirectory(filepath.Join(provisionDir, "templates"), "template", data)
+ h.scanDirectory(provisionDir, filepath.Join(provisionDir, "templates"), "template", data)
// Scan configs directory
- h.scanDirectory(filepath.Join(provisionDir, "configs"), "config", data)
+ h.scanDirectory(provisionDir, filepath.Join(provisionDir, "configs"), "config", data)
// Sort files by name
sort.Slice(data.Files, func(i, j int) bool {
@@ -89,8 +90,10 @@ func (h *ProvisionHandlers) getProvisionData() *ProvisionData {
return data
}
-// scanDirectory recursively scans a directory for provision files
-func (h *ProvisionHandlers) scanDirectory(dir, fileType string, data *ProvisionData) {
+// scanDirectory recursively scans a directory for provision files.
+// root is the provision root; file paths are stored relative to it so
+// absolute filesystem paths are never exposed.
+func (h *ProvisionHandlers) scanDirectory(root, dir, fileType string, data *ProvisionData) {
if _, err := os.Stat(dir); os.IsNotExist(err) {
return
}
@@ -118,9 +121,15 @@ func (h *ProvisionHandlers) scanDirectory(dir, fileType string, data *ProvisionD
return nil
}
+ // Expose the path relative to the provision root, never absolute.
+ relToRoot, err := filepath.Rel(root, path)
+ if err != nil {
+ relToRoot = info.Name()
+ }
+
fileInfo := &ProvisionFileInfo{
Name: info.Name(),
- Path: path,
+ Path: relToRoot,
Size: info.Size(),
ModTime: info.ModTime(),
IsDir: false, // We only include files now
@@ -173,45 +182,90 @@ func (h *ProvisionHandlers) detectLanguage(path string) string {
}
}
+// renderOptions renders a list of {{end}}",
+ )
+ if err != nil {
+ HandleError(w, r, NewInternalError(
+ fmt.Sprintf("Failed to parse options template: %v", err),
+ "Unable to render options",
+ ))
+ return
+ }
+
+ w.Header().Set("Content-Type", "text/html")
+ if err := tmpl.Execute(w, files); err != nil {
+ HandleError(w, r, NewInternalError(
+ fmt.Sprintf("Failed to render options template: %v", err),
+ "Unable to render options",
+ ))
+ }
+}
+
// HandleFileOptions handles file options - returns available files for a given type
func (h *ProvisionHandlers) HandleFileOptions(w http.ResponseWriter, r *http.Request) {
+ if !requireConfig(w, r, h.container) {
+ return
+ }
fileType := r.FormValue("typeSelect")
if fileType == "" {
- http.Error(w, "typeSelect parameter is required", http.StatusBadRequest)
+ HandleError(w, r, NewValidationError("Missing typeSelect", "typeSelect parameter is required"))
return
}
provisionDir := h.container.Config.Provision.Dir
- files := h.listFiles(filepath.Join(provisionDir, "templates", fileType))
-
- tmpl := template.Must(template.New("options").Parse(`
- {{range .}}
-
- {{end}}
- `))
-
- w.Header().Set("Content-Type", "text/html")
- if err := tmpl.Execute(w, files); err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
+ // Resolve the requested type directory inside templates (join-then-check).
+ typeDir, err := safeJoin(filepath.Join(provisionDir, "templates"), fileType)
+ if err != nil {
+ HandleError(w, r, NewValidationError(
+ fmt.Sprintf("Invalid file type: %v", err),
+ "The requested file type is not allowed",
+ ))
+ return
}
+ files := h.listFiles(typeDir)
+
+ renderOptions(w, r, files)
}
// LoadTemplate loads a template file content
func (h *ProvisionHandlers) LoadTemplate(w http.ResponseWriter, r *http.Request) {
+ if !requireConfig(w, r, h.container) {
+ return
+ }
templateType := r.FormValue("typeSelect")
templateName := r.FormValue("templateSelect")
if templateType == "" || templateName == "" {
- http.Error(w, "typeSelect and templateSelect parameters are required", http.StatusBadRequest)
+ HandleError(w, r, NewValidationError("Missing parameters", "typeSelect and templateSelect parameters are required"))
return
}
provisionDir := h.container.Config.Provision.Dir
- filePath := filepath.Join(provisionDir, "templates", templateType, templateName)
+ // Resolve inside the templates root (join-then-check).
+ filePath, err := safeJoin(filepath.Join(provisionDir, "templates"), filepath.Join(templateType, templateName))
+ if err != nil {
+ HandleError(w, r, NewValidationError(
+ fmt.Sprintf("Invalid template path: %v", err),
+ "The requested template path is not allowed",
+ ))
+ return
+ }
content, err := h.loadFileContent(filePath)
if err != nil {
- http.Error(w, fmt.Sprintf("Error loading template: %v", err), http.StatusInternalServerError)
+ if os.IsNotExist(err) {
+ HandleError(w, r, NewNotFoundError("Template not found", "The requested template does not exist"))
+ } else {
+ HandleError(w, r, NewInternalError(
+ fmt.Sprintf("Error loading template: %v", err),
+ "Unable to load the template",
+ ))
+ }
return
}
@@ -221,9 +275,12 @@ func (h *ProvisionHandlers) LoadTemplate(w http.ResponseWriter, r *http.Request)
// HandleConfigOptions handles config options - returns available configs for a given type
func (h *ProvisionHandlers) HandleConfigOptions(w http.ResponseWriter, r *http.Request) {
+ if !requireConfig(w, r, h.container) {
+ return
+ }
configType := r.FormValue("configTypeSelect")
if configType == "" {
- http.Error(w, "configTypeSelect parameter is required", http.StatusBadRequest)
+ HandleError(w, r, NewValidationError("Missing configTypeSelect", "configTypeSelect parameter is required"))
return
}
@@ -233,45 +290,63 @@ func (h *ProvisionHandlers) HandleConfigOptions(w http.ResponseWriter, r *http.R
// Boot menu configs are in TFTP directory
files = h.listFiles(filepath.Join(h.container.Config.TFTP.Dir, "pxelinux.cfg"))
} else {
- // Other configs are in provision configs directory
+ // Other configs are in provision configs directory — resolve
+ // the type directory inside configs (join-then-check).
provisionDir := h.container.Config.Provision.Dir
- files = h.listFiles(filepath.Join(provisionDir, "configs", configType))
+ configDir, err := safeJoin(filepath.Join(provisionDir, "configs"), configType)
+ if err != nil {
+ HandleError(w, r, NewValidationError(
+ fmt.Sprintf("Invalid config type: %v", err),
+ "The requested config type is not allowed",
+ ))
+ return
+ }
+ files = h.listFiles(configDir)
}
- tmpl := template.Must(template.New("options").Parse(`
- {{range .}}
-
- {{end}}
- `))
-
- w.Header().Set("Content-Type", "text/html")
- if err := tmpl.Execute(w, files); err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- }
+ renderOptions(w, r, files)
}
// LoadConfig loads a config file content
func (h *ProvisionHandlers) LoadConfig(w http.ResponseWriter, r *http.Request) {
+ if !requireConfig(w, r, h.container) {
+ return
+ }
configType := r.FormValue("configTypeSelect")
configName := r.FormValue("configSelect")
if configType == "" || configName == "" {
- http.Error(w, "configTypeSelect and configSelect parameters are required", http.StatusBadRequest)
+ HandleError(w, r, NewValidationError("Missing parameters", "configTypeSelect and configSelect parameters are required"))
return
}
var filePath string
+ var err error
if configType == "bootmenu" {
- filePath = filepath.Join(h.container.Config.TFTP.Dir, "pxelinux.cfg", configName)
+ filePath, err = safeJoin(filepath.Join(h.container.Config.TFTP.Dir, "pxelinux.cfg"), configName)
} else {
provisionDir := h.container.Config.Provision.Dir
- filePath = filepath.Join(provisionDir, "configs", configType, configName)
+ filePath, err = safeJoin(filepath.Join(provisionDir, "configs"), filepath.Join(configType, configName))
+ }
+ if err != nil {
+ HandleError(w, r, NewValidationError(
+ fmt.Sprintf("Invalid config path: %v", err),
+ "The requested config path is not allowed",
+ ))
+ return
}
content, err := h.loadFileContent(filePath)
if err != nil {
- http.Error(w, fmt.Sprintf("Error loading config: %v", err), http.StatusInternalServerError)
+ if os.IsNotExist(err) {
+ HandleError(w, r, NewNotFoundError("Config not found", "The requested config does not exist"))
+ } else {
+ HandleError(w, r, NewInternalError(
+ fmt.Sprintf("Error loading config: %v", err),
+ "Unable to load the config",
+ ))
+ }
return
}
@@ -292,63 +367,83 @@ func (h *ProvisionHandlers) UpdateFilename(w http.ResponseWriter, r *http.Reques
// HandleNewTemplate creates a new template file
func (h *ProvisionHandlers) HandleNewTemplate(w http.ResponseWriter, r *http.Request) {
+ if !requireConfig(w, r, h.container) {
+ return
+ }
templateType := r.FormValue("saveTypeSelect")
filename := r.FormValue("filenameInput")
if templateType == "" || filename == "" {
- http.Error(w, "saveTypeSelect and filenameInput parameters are required", http.StatusBadRequest)
+ HandleError(w, r, NewValidationError("Missing parameters", "saveTypeSelect and filenameInput parameters are required"))
return
}
provisionDir := h.container.Config.Provision.Dir
- filePath := filepath.Join(provisionDir, "templates", templateType, filename)
+ // Resolve the new file inside the templates root (join-then-check).
+ filePath, err := safeJoin(filepath.Join(provisionDir, "templates"), filepath.Join(templateType, filename))
+ if err != nil {
+ HandleError(w, r, NewValidationError(
+ fmt.Sprintf("Invalid template path: %v", err),
+ "The requested template path is not allowed",
+ ))
+ return
+ }
// Create directories if they don't exist
if err := os.MkdirAll(filepath.Dir(filePath), 0755); err != nil {
- http.Error(w, fmt.Sprintf("Error creating directory: %v", err), http.StatusInternalServerError)
+ HandleError(w, r, NewInternalError(
+ fmt.Sprintf("Error creating directory: %v", err),
+ "Unable to create the template directory",
+ ))
return
}
// Check if file already exists
if _, err := os.Stat(filePath); err == nil {
- http.Error(w, "File already exists", http.StatusConflict)
+ HandleError(w, r, NewAppError(ErrorTypeConflict, "File already exists", "A file with that name already exists", http.StatusConflict))
return
}
// Create empty file
file, err := os.Create(filePath)
if err != nil {
- http.Error(w, fmt.Sprintf("Error creating file: %v", err), http.StatusInternalServerError)
+ HandleError(w, r, NewInternalError(
+ fmt.Sprintf("Error creating file: %v", err),
+ "Unable to create the template file",
+ ))
return
}
defer file.Close()
+ // Quote the filename for safe interpolation into the JavaScript response.
w.Header().Set("Content-Type", "application/javascript")
fmt.Fprintf(w, `
- document.getElementById('currentFilename').textContent = 'Filename: %s';
+ document.getElementById('currentFilename').textContent = %s;
document.getElementById('editableTextarea').value = '';
alert('Template created successfully!');
- `, filename)
+ `, strconv.Quote("Filename: "+filename))
}
// HandleSave saves file content
func (h *ProvisionHandlers) HandleSave(w http.ResponseWriter, r *http.Request) {
+ if !requireConfig(w, r, h.container) {
+ return
+ }
content := r.FormValue("codeContent")
filename := r.FormValue("filename")
fileType := r.FormValue("type")
category := r.FormValue("category")
if content == "" {
- http.Error(w, "codeContent parameter is required", http.StatusBadRequest)
+ HandleError(w, r, NewValidationError("Missing codeContent", "codeContent parameter is required"))
return
}
if filename == "" || filename == "untitled" {
- http.Error(w, "Please specify a filename before saving", http.StatusBadRequest)
+ HandleError(w, r, NewValidationError("Missing filename", "Please specify a filename before saving"))
return
}
- var filePath string
provisionDir := h.container.Config.Provision.Dir
if fileType == "" {
@@ -358,17 +453,31 @@ func (h *ProvisionHandlers) HandleSave(w http.ResponseWriter, r *http.Request) {
category = "cloud-init" // default category
}
- filePath = filepath.Join(provisionDir, fileType, category, filename)
+ // Resolve the target inside the provision root (join-then-check).
+ filePath, err := safeJoin(provisionDir, filepath.Join(fileType, category, filename))
+ if err != nil {
+ HandleError(w, r, NewValidationError(
+ fmt.Sprintf("Invalid file path: %v", err),
+ "The requested file path is not allowed",
+ ))
+ return
+ }
// Create directories if they don't exist
if err := os.MkdirAll(filepath.Dir(filePath), 0755); err != nil {
- http.Error(w, fmt.Sprintf("Error creating directory: %v", err), http.StatusInternalServerError)
+ HandleError(w, r, NewInternalError(
+ fmt.Sprintf("Error creating directory: %v", err),
+ "Unable to create the target directory",
+ ))
return
}
// Write file
if err := os.WriteFile(filePath, []byte(content), 0644); err != nil {
- http.Error(w, fmt.Sprintf("Error saving file: %v", err), http.StatusInternalServerError)
+ HandleError(w, r, NewInternalError(
+ fmt.Sprintf("Error saving file: %v", err),
+ "Unable to save the file",
+ ))
return
}
@@ -398,10 +507,6 @@ func (h *ProvisionHandlers) listFiles(dir string) []string {
// loadFileContent reads and returns file content
func (h *ProvisionHandlers) loadFileContent(filePath string) (string, error) {
- if _, err := os.Stat(filePath); os.IsNotExist(err) {
- return "", fmt.Errorf("file not found: %s", filePath)
- }
-
content, err := os.ReadFile(filePath)
if err != nil {
return "", err
@@ -439,17 +544,80 @@ func (h *ProvisionHandlers) isPathAllowed(filePath string, allowedDirs ...string
// Additional API endpoints for the new interface
+// resolveProvisionPath resolves a user-supplied path to an absolute path
+// inside the provision or TFTP roots. Relative paths are joined-then-checked
+// against each root; absolute paths are honored only when already contained in
+// an allowed root (legacy clients send absolute paths).
+func (h *ProvisionHandlers) resolveProvisionPath(userPath string) (string, error) {
+ if strings.TrimSpace(userPath) == "" {
+ return "", fmt.Errorf("path parameter is required")
+ }
+
+ var bases []string
+ if dir := h.container.Config.Provision.Dir; dir != "" {
+ bases = append(bases, dir)
+ }
+ if dir := h.container.Config.TFTP.Dir; dir != "" {
+ bases = append(bases, dir)
+ }
+
+ // Relative paths: join-then-check against each allowed root.
+ if !filepath.IsAbs(userPath) {
+ for _, base := range bases {
+ if p, err := safeJoin(base, userPath); err == nil {
+ return p, nil
+ }
+ }
+ return "", fmt.Errorf("path is not within an allowed directory")
+ }
+
+ // Absolute paths: only honor when already inside an allowed root.
+ if h.isPathAllowed(userPath, bases...) {
+ return filepath.Clean(userPath), nil
+ }
+ return "", fmt.Errorf("path is not within an allowed directory")
+}
+
+// writeJSONError writes a generic JSON error without leaking internals.
+func writeJSONError(w http.ResponseWriter, status int, userMessage string) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(status)
+ json.NewEncoder(w).Encode(map[string]interface{}{
+ "success": false,
+ "error": userMessage,
+ })
+}
+
// LoadFileContent loads file content via API
func (h *ProvisionHandlers) LoadFileContent(w http.ResponseWriter, r *http.Request) {
- filePath := r.URL.Query().Get("path")
- if filePath == "" {
- http.Error(w, "path parameter is required", http.StatusBadRequest)
+ if !requireConfig(w, r, h.container) {
+ return
+ }
+ userPath := r.URL.Query().Get("path")
+ if userPath == "" {
+ HandleError(w, r, NewValidationError("Missing path", "path parameter is required"))
+ return
+ }
+
+ filePath, err := h.resolveProvisionPath(userPath)
+ if err != nil {
+ HandleError(w, r, NewValidationError(
+ fmt.Sprintf("Invalid path: %v", err),
+ "The requested path is not allowed",
+ ))
return
}
content, err := h.loadFileContent(filePath)
if err != nil {
- http.Error(w, fmt.Sprintf("Error loading file: %v", err), http.StatusInternalServerError)
+ if os.IsNotExist(err) {
+ HandleError(w, r, NewNotFoundError("File not found", "The requested file does not exist"))
+ } else {
+ HandleError(w, r, NewInternalError(
+ fmt.Sprintf("Error loading file: %v", err),
+ "Unable to load the file",
+ ))
+ }
return
}
@@ -459,36 +627,37 @@ func (h *ProvisionHandlers) LoadFileContent(w http.ResponseWriter, r *http.Reque
// SaveFileContent saves file content via API
func (h *ProvisionHandlers) SaveFileContent(w http.ResponseWriter, r *http.Request) {
- filePath := r.FormValue("path")
+ if !requireConfig(w, r, h.container) {
+ return
+ }
+ userPath := r.FormValue("path")
content := r.FormValue("content")
- if filePath == "" {
- http.Error(w, "path parameter is required", http.StatusBadRequest)
+ if userPath == "" {
+ writeJSONError(w, http.StatusBadRequest, "path parameter is required")
return
}
if content == "" {
- http.Error(w, "content parameter is required", http.StatusBadRequest)
+ writeJSONError(w, http.StatusBadRequest, "content parameter is required")
+ return
+ }
+
+ filePath, err := h.resolveProvisionPath(userPath)
+ if err != nil {
+ writeJSONError(w, http.StatusForbidden, "The requested path is not allowed")
return
}
// Create directories if they don't exist
if err := os.MkdirAll(filepath.Dir(filePath), 0755); err != nil {
- w.WriteHeader(http.StatusInternalServerError)
- json.NewEncoder(w).Encode(map[string]interface{}{
- "success": false,
- "error": fmt.Sprintf("Error creating directory: %v", err),
- })
+ writeJSONError(w, http.StatusInternalServerError, "Unable to create the target directory")
return
}
// Write file
if err := os.WriteFile(filePath, []byte(content), 0644); err != nil {
- w.WriteHeader(http.StatusInternalServerError)
- json.NewEncoder(w).Encode(map[string]interface{}{
- "success": false,
- "error": fmt.Sprintf("Error saving file: %v", err),
- })
+ writeJSONError(w, http.StatusInternalServerError, "Unable to save the file")
return
}
@@ -501,47 +670,32 @@ func (h *ProvisionHandlers) SaveFileContent(w http.ResponseWriter, r *http.Reque
// DeleteFile deletes a file via API
func (h *ProvisionHandlers) DeleteFile(w http.ResponseWriter, r *http.Request) {
- filePath := r.FormValue("path")
-
- if filePath == "" {
- w.WriteHeader(http.StatusBadRequest)
- json.NewEncoder(w).Encode(map[string]interface{}{
- "success": false,
- "error": "path parameter is required",
- })
+ if !requireConfig(w, r, h.container) {
return
}
+ userPath := r.FormValue("path")
- // Security check: ensure file is within allowed directories
- provisionDir := h.container.Config.Provision.Dir
- tftpDir := h.container.Config.TFTP.Dir
+ if userPath == "" {
+ writeJSONError(w, http.StatusBadRequest, "path parameter is required")
+ return
+ }
- if !h.isPathAllowed(filePath, provisionDir, tftpDir) {
- w.WriteHeader(http.StatusForbidden)
- json.NewEncoder(w).Encode(map[string]interface{}{
- "success": false,
- "error": "File deletion not allowed outside of provision directories",
- })
+ // Security check: resolve inside the allowed provision/TFTP roots.
+ filePath, err := h.resolveProvisionPath(userPath)
+ if err != nil {
+ writeJSONError(w, http.StatusForbidden, "File deletion not allowed outside of provision directories")
return
}
// Check if file exists
if _, err := os.Stat(filePath); os.IsNotExist(err) {
- w.WriteHeader(http.StatusNotFound)
- json.NewEncoder(w).Encode(map[string]interface{}{
- "success": false,
- "error": "File not found",
- })
+ writeJSONError(w, http.StatusNotFound, "File not found")
return
}
// Delete file
if err := os.Remove(filePath); err != nil {
- w.WriteHeader(http.StatusInternalServerError)
- json.NewEncoder(w).Encode(map[string]interface{}{
- "success": false,
- "error": fmt.Sprintf("Error deleting file: %v", err),
- })
+ writeJSONError(w, http.StatusInternalServerError, "Unable to delete the file")
return
}
@@ -558,7 +712,10 @@ func (h *ProvisionHandlers) GetTemplateGallery(w http.ResponseWriter, r *http.Re
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(gallery); err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
+ HandleError(w, r, NewInternalError(
+ fmt.Sprintf("Error encoding gallery: %v", err),
+ "Unable to load the template gallery",
+ ))
}
}
diff --git a/handlers/security.go b/handlers/security.go
index 6982e6c..55b2dd6 100644
--- a/handlers/security.go
+++ b/handlers/security.go
@@ -163,10 +163,10 @@ func (v *PathSecurityValidator) ValidateFileType(filename string, allowedExtensi
ext := strings.ToLower(filepath.Ext(filename))
baseFilename := strings.ToLower(filepath.Base(filename))
- // Known extensionless files that should be allowed
+ // Known extensionless files that should be allowed (exact basename match only)
knownFiles := []string{"vmlinuz", "initrd", "kernel", "boot"}
for _, known := range knownFiles {
- if baseFilename == known || strings.Contains(baseFilename, known) {
+ if baseFilename == known {
return nil
}
}
@@ -202,6 +202,47 @@ func (v *PathSecurityValidator) SanitizePath(path string) string {
return strings.Join(safeParts, string(filepath.Separator))
}
+// safeJoin joins a user-supplied relative name onto a base directory and
+// guarantees the result stays inside the base directory. It rejects empty,
+// absolute, and NUL-containing input, cleans the path, and verifies
+// containment with filepath.Rel so ".." escapes can never break out.
+// Use this at every file endpoint that turns user input into a filesystem path.
+func safeJoin(baseDir, userName string) (string, error) {
+ if strings.TrimSpace(userName) == "" {
+ return "", fmt.Errorf("file name is required")
+ }
+ if strings.ContainsRune(userName, 0) {
+ return "", fmt.Errorf("invalid file name")
+ }
+ cleaned := filepath.Clean(userName)
+ if filepath.IsAbs(cleaned) {
+ return "", fmt.Errorf("absolute paths are not allowed")
+ }
+ absBase, err := filepath.Abs(baseDir)
+ if err != nil {
+ return "", fmt.Errorf("invalid base directory: %v", err)
+ }
+ full := filepath.Join(absBase, cleaned)
+ rel, err := filepath.Rel(absBase, full)
+ if err != nil {
+ return "", fmt.Errorf("invalid path: %v", err)
+ }
+ if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
+ return "", fmt.Errorf("path escapes base directory")
+ }
+ return full, nil
+}
+
+// sanitizeDispositionFilename strips characters that could break out of a
+// Content-Disposition header value (quotes, CR, LF).
+func sanitizeDispositionFilename(name string) string {
+ name = filepath.Base(name)
+ name = strings.ReplaceAll(name, `"`, "")
+ name = strings.ReplaceAll(name, "\r", "")
+ name = strings.ReplaceAll(name, "\n", "")
+ return name
+}
+
// TFTPSecurityValidator provides TFTP-specific security validation
type TFTPSecurityValidator struct {
pathValidator *PathSecurityValidator
diff --git a/handlers/status.go b/handlers/status.go
old mode 100755
new mode 100644
index 71726e6..d371838
--- a/handlers/status.go
+++ b/handlers/status.go
@@ -1,235 +1,229 @@
package handlers
import (
- "context"
"fmt"
"net"
"net/http"
+ "strconv"
+ "sync"
"time"
)
-// StatusHandlers handles status-related requests
-type StatusHandlers struct {
- container *Container
+// tftpPort is the UDP port the TFTP server listens on (tftp/tftp.go binds
+// port 69; there is no longer a config knob for it).
+const tftpPort = 69
+
+// serviceRegistry tracks in-process service running state. The status handlers
+// read it instead of probing sockets: UDP dials cannot prove a TFTP/DHCP
+// listener is actually running. The DHCP server handlers update the DHCP
+// entries on start/stop/delete; the TFTP entry is updated via SetTFTPRunning
+// by whatever component starts or stops the TFTP server.
+var serviceRegistry = struct {
+ sync.RWMutex
+ tftpRunning bool
+ tftpKnown bool
+ dhcpRunning map[string]bool // DHCP server ID -> running
+}{dhcpRunning: make(map[string]bool)}
+
+// SetTFTPRunning records whether the TFTP server is running. It is exported so
+// the application layer that starts/stops the TFTP server can report state.
+func SetTFTPRunning(running bool) {
+ serviceRegistry.Lock()
+ serviceRegistry.tftpRunning = running
+ serviceRegistry.tftpKnown = true
+ serviceRegistry.Unlock()
}
-// ServiceStatus represents the status of a single service
-type ServiceStatus struct {
- Name string `json:"name"`
- Status string `json:"status"`
- Description string `json:"description"`
- LastCheck time.Time `json:"last_check"`
- Address string `json:"address,omitempty"`
- Port string `json:"port,omitempty"`
- Details string `json:"details,omitempty"`
+// isTFTPRunning reports the last known TFTP running state.
+func isTFTPRunning() bool {
+ serviceRegistry.RLock()
+ defer serviceRegistry.RUnlock()
+ return serviceRegistry.tftpKnown && serviceRegistry.tftpRunning
}
-// DHCPServerStatus represents the status of a DHCP server
-type DHCPServerStatus struct {
- ID string `json:"id"`
- IP string `json:"ip"`
- Status string `json:"status"`
- Description string `json:"description"`
- LastCheck time.Time `json:"last_check"`
- LeaseCount int `json:"lease_count"`
+// tftpStatusString renders the TFTP state honestly: "unknown" until something
+// reports it, never a guessed "running".
+func tftpStatusString() string {
+ serviceRegistry.RLock()
+ defer serviceRegistry.RUnlock()
+ if !serviceRegistry.tftpKnown {
+ return "unknown"
+ }
+ if serviceRegistry.tftpRunning {
+ return "running"
+ }
+ return "stopped"
}
-// SystemStatus represents the overall system status
-type SystemStatus struct {
- Title string `json:"title"`
- LastUpdated time.Time `json:"last_updated"`
- HTTPServer ServiceStatus `json:"http_server"`
- APIServer ServiceStatus `json:"api_server"`
- TFTPServer ServiceStatus `json:"tftp_server"`
- DHCPServers []DHCPServerStatus `json:"dhcp_servers"`
- OverallStatus string `json:"overall_status"`
+// setDHCPRunning records the running state of a DHCP server.
+func setDHCPRunning(serverID string, running bool) {
+ serviceRegistry.Lock()
+ serviceRegistry.dhcpRunning[serverID] = running
+ serviceRegistry.Unlock()
}
-// NewStatusHandlers creates a new StatusHandlers instance
-func NewStatusHandlers(container *Container) *StatusHandlers {
- return &StatusHandlers{container: container}
+// forgetDHCPServer drops the tracked state for a deleted DHCP server.
+func forgetDHCPServer(serverID string) {
+ serviceRegistry.Lock()
+ delete(serviceRegistry.dhcpRunning, serverID)
+ serviceRegistry.Unlock()
}
-// HandleStatusPage serves the status page
-func (h *StatusHandlers) HandleStatusPage(w http.ResponseWriter, r *http.Request) {
- status := h.getSystemStatus()
-
- templates := LoadTemplates()
- if err := templates["status"].Execute(w, status); err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
+// isDHCPRunning reports tracked running state for a DHCP server, falling back
+// to the server model's Started field when nothing was tracked.
+func isDHCPRunning(serverID string, modelStarted bool) bool {
+ serviceRegistry.RLock()
+ running, ok := serviceRegistry.dhcpRunning[serverID]
+ serviceRegistry.RUnlock()
+ if ok {
+ return running
}
+ return modelStarted
}
-// HandleStatusContent returns just the status content for HTMX updates
-func (h *StatusHandlers) HandleStatusContent(w http.ResponseWriter, r *http.Request) {
- status := h.getSystemStatus()
-
- templates := LoadTemplates()
- if err := templates["status-content"].Execute(w, status); err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- }
+// StatusHandlers handles status-related requests
+type StatusHandlers struct {
+ container *Container
}
-// getSystemStatus collects real-time status information for all services
-func (h *StatusHandlers) getSystemStatus() *SystemStatus {
- now := time.Now()
-
- status := &SystemStatus{
- Title: "Service Status Overview",
- LastUpdated: now,
- }
-
- // Check HTTP/API Server status (combined since they run on same server)
- status.HTTPServer = h.checkHTTPServerStatus()
- status.APIServer = status.HTTPServer // Same server, same status
-
- // Check TFTP Server status
- status.TFTPServer = h.checkTFTPServerStatus()
-
- // Check DHCP Server statuses
- status.DHCPServers = h.checkDHCPServersStatus()
+// NewStatusHandlers creates a new StatusHandlers instance
+func NewStatusHandlers(container *Container) *StatusHandlers {
+ return &StatusHandlers{container: container}
+}
- // Determine overall status
- status.OverallStatus = h.calculateOverallStatus(status)
+// ServiceStatus represents the status of a service
+type ServiceStatus struct {
+ Name string `json:"name"`
+ Status string `json:"status"`
+ Details string `json:"details"`
+ Port int `json:"port"`
+}
- return status
+// DHCPServerStatus represents DHCP server status for the status page
+type DHCPServerStatus struct {
+ ID string `json:"id"`
+ Network string `json:"network"`
+ Status string `json:"status"`
+ LeaseNum int `json:"lease_num"`
}
-// checkHTTPServerStatus checks if the HTTP/API server is running
-func (h *StatusHandlers) checkHTTPServerStatus() ServiceStatus {
- now := time.Now()
- port := h.container.Config.HTTP.Port
+// StatusPageData represents data for the status page
+type StatusPageData struct {
+ Title string `json:"title"`
+ HTTPServer ServiceStatus `json:"http_server"`
+ TFTPServer ServiceStatus `json:"tftp_server"`
+ DHCPServers []DHCPServerStatus `json:"dhcp_servers"`
+ LastUpdated string `json:"last_updated"`
+ AutoRefresh bool `json:"auto_refresh"`
+ RefreshSecs int `json:"refresh_secs"`
+}
- status := ServiceStatus{
- Name: "HTTP/API Server",
- LastCheck: now,
- Address: "localhost",
- Port: port,
+// getStatusData builds the status page data from tracked runtime state.
+func (h *StatusHandlers) getStatusData(r *http.Request) (*StatusPageData, error) {
+ ctx := r.Context()
+
+ // HTTP server status — a TCP dial to our own listener as a smoke check.
+ // The configured port is a string; an unparseable value fails the check.
+ httpPort, _ := strconv.Atoi(h.container.Config.HTTP.Port)
+ httpStatus := checkHTTPServerStatus(httpPort)
+
+ // TFTP server status — tracked runtime state, never a UDP guess.
+ tftpStatus := ServiceStatus{
+ Name: "TFTP Server",
+ Status: tftpStatusString(),
+ Details: fmt.Sprintf("Port %d", tftpPort),
+ Port: tftpPort,
}
- // Try to connect to the HTTP server
- conn, err := net.DialTimeout("tcp", fmt.Sprintf("localhost:%s", port), 2*time.Second)
+ // DHCP servers status — from the tracked running state, falling back to
+ // the server model's Started field.
+ servers, err := h.container.ServerService.GetAllServers(ctx)
if err != nil {
- status.Status = "stopped"
- status.Description = "HTTP/API Server is not responding"
- status.Details = fmt.Sprintf("Error: %v", err)
- } else {
- conn.Close()
- status.Status = "running"
- status.Description = "HTTP/API Server is running"
- status.Details = fmt.Sprintf("Listening on port %s", port)
+ return nil, fmt.Errorf("failed to get DHCP servers: %w", err)
}
- return status
-}
-
-// checkTFTPServerStatus checks if the TFTP server is running
-func (h *StatusHandlers) checkTFTPServerStatus() ServiceStatus {
- now := time.Now()
+ var dhcpServers []DHCPServerStatus
+ for _, server := range servers {
+ leases, _ := h.container.LeaseService.GetLeasesByServer(ctx, server.ID)
- status := ServiceStatus{
- Name: "TFTP Server",
- LastCheck: now,
- Address: "localhost",
- Port: "69",
- }
+ status := "configured"
+ if isDHCPRunning(server.ID, server.Started) {
+ status = "running"
+ }
- // Try to connect to the TFTP server on UDP port 69
- conn, err := net.DialTimeout("udp", "localhost:69", 2*time.Second)
- if err != nil {
- status.Status = "stopped"
- status.Description = "TFTP Server is not responding"
- status.Details = fmt.Sprintf("Error: %v", err)
- } else {
- conn.Close()
- status.Status = "running"
- status.Description = "TFTP Server is running"
- status.Details = fmt.Sprintf("Serving from %s", h.container.Config.TFTP.Dir)
+ dhcpServers = append(dhcpServers, DHCPServerStatus{
+ ID: server.ID,
+ Network: server.IP.String(),
+ Status: status,
+ LeaseNum: len(leases),
+ })
}
- return status
+ return &StatusPageData{
+ Title: "System Status",
+ HTTPServer: httpStatus,
+ TFTPServer: tftpStatus,
+ DHCPServers: dhcpServers,
+ LastUpdated: time.Now().Format("2006-01-02 15:04:05"),
+ AutoRefresh: true,
+ RefreshSecs: 5,
+ }, nil
}
-// checkDHCPServersStatus checks the status of all configured DHCP servers
-func (h *StatusHandlers) checkDHCPServersStatus() []DHCPServerStatus {
- ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
- defer cancel()
+// HandleStatusPage serves the status monitoring page
+func (h *StatusHandlers) HandleStatusPage(w http.ResponseWriter, r *http.Request) {
+ if !requireConfig(w, r, h.container) {
+ return
+ }
- servers, err := h.container.ServerService.GetAllServers(ctx)
+ data, err := h.getStatusData(r)
if err != nil {
- // Return empty array if we can't get servers
- return []DHCPServerStatus{}
+ HandleError(w, r, NewInternalError(
+ fmt.Sprintf("failed to get status data: %v", err),
+ "Unable to load system status",
+ ))
+ return
}
- var dhcpStatuses []DHCPServerStatus
- now := time.Now()
-
- for _, server := range servers {
- status := DHCPServerStatus{
- ID: server.ID,
- IP: server.IP.String(),
- LastCheck: now,
- }
-
- // Get lease count for this server
- leases, err := h.container.LeaseService.GetLeasesByServer(ctx, server.ID)
- if err == nil {
- status.LeaseCount = len(leases)
- }
+ renderCachedTemplate(w, r, "status", data, "Unable to render the status page")
+}
- // Check if server is marked as started in database
- if server.Started {
- // Try to verify the server is actually running by checking UDP port
- conn, err := net.DialTimeout("udp", fmt.Sprintf("%s:67", server.IP.String()), 2*time.Second)
- if err != nil {
- status.Status = "configured"
- status.Description = "Server configured but not responding"
- } else {
- conn.Close()
- status.Status = "running"
- status.Description = "DHCP Server is running"
- }
- } else {
- status.Status = "stopped"
- status.Description = "DHCP Server is stopped"
- }
+// HandleStatusContent serves status content for HTMX updates
+func (h *StatusHandlers) HandleStatusContent(w http.ResponseWriter, r *http.Request) {
+ if !requireConfig(w, r, h.container) {
+ return
+ }
- dhcpStatuses = append(dhcpStatuses, status)
+ data, err := h.getStatusData(r)
+ if err != nil {
+ HandleError(w, r, NewInternalError(
+ fmt.Sprintf("failed to get status data: %v", err),
+ "Unable to load system status",
+ ))
+ return
}
- return dhcpStatuses
+ renderCachedTemplate(w, r, "status-content", data, "Unable to render status content")
}
-// calculateOverallStatus determines the overall system status
-func (h *StatusHandlers) calculateOverallStatus(status *SystemStatus) string {
- runningCount := 0
- totalServices := 2 // HTTP and TFTP
-
- if status.HTTPServer.Status == "running" {
- runningCount++
- }
- if status.TFTPServer.Status == "running" {
- runningCount++
- }
-
- // Count running DHCP servers
- runningDHCP := 0
- for _, dhcp := range status.DHCPServers {
- if dhcp.Status == "running" {
- runningDHCP++
- }
+// checkHTTPServerStatus checks if the HTTP server is running by dialing its
+// own TCP listener — a smoke check that the port accepts connections.
+func checkHTTPServerStatus(port int) ServiceStatus {
+ status := ServiceStatus{
+ Name: "HTTP Server",
+ Status: "stopped",
+ Details: fmt.Sprintf("Port %d", port),
+ Port: port,
}
- if len(status.DHCPServers) > 0 {
- totalServices += len(status.DHCPServers)
- runningCount += runningDHCP
+ conn, err := net.DialTimeout("tcp", fmt.Sprintf("localhost:%d", port), 2*time.Second)
+ if err != nil {
+ status.Details = fmt.Sprintf("Port %d - not responding", port)
+ return status
}
+ conn.Close()
- if runningCount == totalServices {
- return "healthy"
- } else if runningCount > 0 {
- return "partial"
- } else {
- return "down"
- }
+ status.Status = "running"
+ status.Details = fmt.Sprintf("Port %d - responding", port)
+ return status
}
diff --git a/handlers/syslinux.go b/handlers/syslinux.go
index 0d374e2..b8054e4 100644
--- a/handlers/syslinux.go
+++ b/handlers/syslinux.go
@@ -1,11 +1,9 @@
package handlers
import (
- "context"
"encoding/json"
"fmt"
"net/http"
- "time"
"ignite/syslinux"
@@ -69,7 +67,7 @@ func (h *SyslinuxHandler) SyslinuxPage(w http.ResponseWriter, r *http.Request) {
// Get all available versions
versions, err := h.service.GetAvailableVersions(ctx)
if err != nil {
- http.Error(w, fmt.Sprintf("Failed to get versions: %v", err), http.StatusInternalServerError)
+ HandleError(w, r, NewInternalError(fmt.Sprintf("Failed to get versions: %v", err), "Unable to load syslinux versions"))
return
}
@@ -90,10 +88,10 @@ func (h *SyslinuxHandler) SyslinuxPage(w http.ResponseWriter, r *http.Request) {
templates := LoadTemplates()
if tmpl, ok := templates["syslinux"]; ok {
if err := tmpl.Execute(w, data); err != nil {
- http.Error(w, fmt.Sprintf("Failed to execute template: %v", err), http.StatusInternalServerError)
+ HandleError(w, r, NewInternalError(fmt.Sprintf("Failed to execute template: %v", err), "Unable to render the syslinux page"))
}
} else {
- http.Error(w, "Syslinux template not found", http.StatusInternalServerError)
+ HandleError(w, r, NewInternalError("Syslinux template not found", "Unable to render the syslinux page"))
}
}
@@ -101,7 +99,7 @@ func (h *SyslinuxHandler) SyslinuxPage(w http.ResponseWriter, r *http.Request) {
func (h *SyslinuxHandler) ListVersions(w http.ResponseWriter, r *http.Request) {
versions, err := h.service.GetAvailableVersions(r.Context())
if err != nil {
- http.Error(w, "Failed to get versions: "+err.Error(), http.StatusInternalServerError)
+ HandleError(w, r, NewInternalError("Failed to get versions: "+err.Error(), "Unable to load syslinux versions"))
return
}
@@ -115,7 +113,7 @@ func (h *SyslinuxHandler) ListVersions(w http.ResponseWriter, r *http.Request) {
// RefreshVersions scans the mirror for new versions
func (h *SyslinuxHandler) RefreshVersions(w http.ResponseWriter, r *http.Request) {
if err := h.service.RefreshAvailableVersions(r.Context()); err != nil {
- http.Error(w, "Failed to refresh versions: "+err.Error(), http.StatusInternalServerError)
+ HandleError(w, r, NewInternalError("Failed to refresh versions: "+err.Error(), "Unable to refresh syslinux versions"))
return
}
@@ -129,7 +127,7 @@ func (h *SyslinuxHandler) RefreshVersions(w http.ResponseWriter, r *http.Request
// ScanMirror scans the mirror for available Syslinux versions
func (h *SyslinuxHandler) ScanMirror(w http.ResponseWriter, r *http.Request) {
if err := h.service.RefreshAvailableVersions(r.Context()); err != nil {
- http.Error(w, "Failed to scan mirror: "+err.Error(), http.StatusInternalServerError)
+ HandleError(w, r, NewInternalError("Failed to scan mirror: "+err.Error(), "Unable to scan the syslinux mirror"))
return
}
@@ -143,7 +141,7 @@ func (h *SyslinuxHandler) DownloadAndInstallVersion(w http.ResponseWriter, r *ht
version := vars["version"]
if version == "" {
- http.Error(w, "Version parameter required", http.StatusBadRequest)
+ HandleError(w, r, NewValidationError("Version parameter required", "A version is required"))
return
}
@@ -151,7 +149,7 @@ func (h *SyslinuxHandler) DownloadAndInstallVersion(w http.ResponseWriter, r *ht
// The service now handles: cleanup -> download -> extract -> install -> activate
_, err := h.service.DownloadVersion(r.Context(), version)
if err != nil {
- http.Error(w, "Failed to start download: "+err.Error(), http.StatusInternalServerError)
+ HandleError(w, r, NewInternalError("Failed to start download: "+err.Error(), "Unable to start the download"))
return
}
@@ -159,41 +157,17 @@ func (h *SyslinuxHandler) DownloadAndInstallVersion(w http.ResponseWriter, r *ht
http.Redirect(w, r, "/syslinux", http.StatusSeeOther)
}
-// ActivateVersion sets a version as the active one
+// ActivateVersion marks an already-downloaded version as the active one.
+// The service layer exposes no standalone activation for an already
+// downloaded version (activation only happens inside the download flow),
+// so this honestly reports 501 instead of faking it.
func (h *SyslinuxHandler) ActivateVersion(w http.ResponseWriter, r *http.Request) {
- vars := mux.Vars(r)
- version := vars["version"]
-
- if version == "" {
- http.Error(w, "Version parameter required", http.StatusBadRequest)
- return
- }
-
- // First deactivate any currently active version
- if err := h.deactivateCurrentVersion(r.Context()); err != nil {
- http.Error(w, "Failed to deactivate current version: "+err.Error(), http.StatusInternalServerError)
- return
- }
-
- // Install both BIOS and EFI boot files
- if err := h.service.InstallBootFiles(r.Context(), version, "bios"); err != nil {
- http.Error(w, "Failed to install BIOS boot files: "+err.Error(), http.StatusInternalServerError)
- return
- }
-
- if err := h.service.InstallBootFiles(r.Context(), version, "efi"); err != nil {
- http.Error(w, "Failed to install EFI boot files: "+err.Error(), http.StatusInternalServerError)
- return
- }
-
- // Mark version as active
- if err := h.activateVersion(r.Context(), version); err != nil {
- http.Error(w, "Failed to activate version: "+err.Error(), http.StatusInternalServerError)
- return
- }
-
- // Redirect back to main page
- http.Redirect(w, r, "/syslinux", http.StatusSeeOther)
+ HandleError(w, r, NewAppError(
+ ErrorTypeServiceUnavail,
+ "syslinux version activation is not implemented in the service layer",
+ "Activating an already-downloaded version is not supported by this server",
+ http.StatusNotImplemented,
+ ))
}
// DeactivateVersion deactivates and removes the currently active version
@@ -202,13 +176,13 @@ func (h *SyslinuxHandler) DeactivateVersion(w http.ResponseWriter, r *http.Reque
version := vars["version"]
if version == "" {
- http.Error(w, "Version parameter required", http.StatusBadRequest)
+ HandleError(w, r, NewValidationError("Version parameter required", "A version is required"))
return
}
// Use the service method to properly deactivate the version
if err := h.service.DeactivateVersion(r.Context(), version); err != nil {
- http.Error(w, "Failed to deactivate version: "+err.Error(), http.StatusInternalServerError)
+ HandleError(w, r, NewInternalError("Failed to deactivate version: "+err.Error(), "Unable to deactivate the version"))
return
}
@@ -222,14 +196,14 @@ func (h *SyslinuxHandler) GetVersion(w http.ResponseWriter, r *http.Request) {
version := vars["version"]
if version == "" {
- http.Error(w, "Version parameter required", http.StatusBadRequest)
+ HandleError(w, r, NewValidationError("Version parameter required", "A version is required"))
return
}
// Get all variants of this version (BIOS and EFI)
versions, err := h.service.GetAvailableVersions(r.Context())
if err != nil {
- http.Error(w, "Failed to get versions: "+err.Error(), http.StatusInternalServerError)
+ HandleError(w, r, NewInternalError("Failed to get versions: "+err.Error(), "Unable to load syslinux versions"))
return
}
@@ -242,7 +216,7 @@ func (h *SyslinuxHandler) GetVersion(w http.ResponseWriter, r *http.Request) {
}
if matchedVersion == nil {
- http.Error(w, "Version not found", http.StatusNotFound)
+ HandleError(w, r, NewNotFoundError("Version not found", "The requested syslinux version does not exist"))
return
}
@@ -266,22 +240,16 @@ func (h *SyslinuxHandler) GetVersion(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(response)
}
-// DeleteVersion removes a version
+// DeleteVersion handles DELETE /api/syslinux/versions/{version}.
+// The service layer exposes no version deletion, so this honestly reports
+// 501 instead of pretending to delete.
func (h *SyslinuxHandler) DeleteVersion(w http.ResponseWriter, r *http.Request) {
- vars := mux.Vars(r)
- version := vars["version"]
-
- if version == "" {
- http.Error(w, "Version parameter required", http.StatusBadRequest)
- return
- }
-
- // This would need implementation in the service
- w.Header().Set("Content-Type", "application/json")
- json.NewEncoder(w).Encode(map[string]interface{}{
- "success": true,
- "message": fmt.Sprintf("Version %s deleted", version),
- })
+ HandleError(w, r, NewAppError(
+ ErrorTypeServiceUnavail,
+ "syslinux version deletion is not implemented in the service layer",
+ "Deleting syslinux versions is not supported by this server",
+ http.StatusNotImplemented,
+ ))
}
// ListBootFiles returns boot files, optionally filtered by version and boot type
@@ -291,7 +259,7 @@ func (h *SyslinuxHandler) ListBootFiles(w http.ResponseWriter, r *http.Request)
bootFiles, err := h.service.ListInstalledBootFiles(r.Context(), bootType)
if err != nil {
- http.Error(w, "Failed to get boot files: "+err.Error(), http.StatusInternalServerError)
+ HandleError(w, r, NewInternalError("Failed to get boot files: "+err.Error(), "Unable to load boot files"))
return
}
@@ -320,12 +288,12 @@ func (h *SyslinuxHandler) InstallBootFiles(w http.ResponseWriter, r *http.Reques
bootType := vars["bootType"]
if version == "" || bootType == "" {
- http.Error(w, "Version and bootType parameters required", http.StatusBadRequest)
+ HandleError(w, r, NewValidationError("Version and bootType parameters required", "A version and boot type are required"))
return
}
if err := h.service.InstallBootFiles(r.Context(), version, bootType); err != nil {
- http.Error(w, "Failed to install boot files: "+err.Error(), http.StatusInternalServerError)
+ HandleError(w, r, NewInternalError("Failed to install boot files: "+err.Error(), "Unable to install the boot files"))
return
}
@@ -343,12 +311,12 @@ func (h *SyslinuxHandler) RemoveBootFiles(w http.ResponseWriter, r *http.Request
bootType := vars["bootType"]
if version == "" || bootType == "" {
- http.Error(w, "Version and bootType parameters required", http.StatusBadRequest)
+ HandleError(w, r, NewValidationError("Version and bootType parameters required", "A version and boot type are required"))
return
}
if err := h.service.RemoveBootFiles(r.Context(), version, bootType); err != nil {
- http.Error(w, "Failed to remove boot files: "+err.Error(), http.StatusInternalServerError)
+ HandleError(w, r, NewInternalError("Failed to remove boot files: "+err.Error(), "Unable to remove the boot files"))
return
}
@@ -359,35 +327,28 @@ func (h *SyslinuxHandler) RemoveBootFiles(w http.ResponseWriter, r *http.Request
})
}
-// GetBootFile returns details for a specific boot file
+// GetBootFile returns details for a specific boot file.
+// The service layer exposes no single-boot-file lookup, so this honestly
+// reports 501 instead of echoing the id back.
func (h *SyslinuxHandler) GetBootFile(w http.ResponseWriter, r *http.Request) {
- vars := mux.Vars(r)
- id := vars["id"]
-
- if id == "" {
- http.Error(w, "ID parameter required", http.StatusBadRequest)
- return
- }
-
- // This would need implementation in the service
- w.Header().Set("Content-Type", "application/json")
- json.NewEncoder(w).Encode(map[string]interface{}{
- "success": true,
- "id": id,
- })
+ HandleError(w, r, NewAppError(
+ ErrorTypeServiceUnavail,
+ "syslinux boot file lookup is not implemented in the service layer",
+ "Looking up individual boot files is not supported by this server",
+ http.StatusNotImplemented,
+ ))
}
-// ListDownloadStatuses returns all download statuses
+// ListDownloadStatuses returns all download statuses.
+// The service layer only supports lookup by download id, so this honestly
+// reports 501 instead of returning an empty list.
func (h *SyslinuxHandler) ListDownloadStatuses(w http.ResponseWriter, r *http.Request) {
- // Get optional status filter
- statusFilter := r.URL.Query().Get("status")
-
- // This would need implementation in the service to get all statuses
- w.Header().Set("Content-Type", "application/json")
- json.NewEncoder(w).Encode(map[string]interface{}{
- "downloads": []interface{}{},
- "filter": statusFilter,
- })
+ HandleError(w, r, NewAppError(
+ ErrorTypeServiceUnavail,
+ "syslinux download listing is not implemented in the service layer",
+ "Listing all downloads is not supported by this server",
+ http.StatusNotImplemented,
+ ))
}
// GetDownloadStatus returns status for a specific download
@@ -396,13 +357,13 @@ func (h *SyslinuxHandler) GetDownloadStatus(w http.ResponseWriter, r *http.Reque
id := vars["id"]
if id == "" {
- http.Error(w, "ID parameter required", http.StatusBadRequest)
+ HandleError(w, r, NewValidationError("ID parameter required", "An id is required"))
return
}
status, err := h.service.GetDownloadStatus(r.Context(), id)
if err != nil {
- http.Error(w, "Failed to get download status: "+err.Error(), http.StatusInternalServerError)
+ HandleError(w, r, NewInternalError("Failed to get download status: "+err.Error(), "Unable to load the download status"))
return
}
@@ -416,12 +377,12 @@ func (h *SyslinuxHandler) CancelDownload(w http.ResponseWriter, r *http.Request)
id := vars["id"]
if id == "" {
- http.Error(w, "ID parameter required", http.StatusBadRequest)
+ HandleError(w, r, NewValidationError("ID parameter required", "An id is required"))
return
}
if err := h.service.CancelDownload(r.Context(), id); err != nil {
- http.Error(w, "Failed to cancel download: "+err.Error(), http.StatusInternalServerError)
+ HandleError(w, r, NewInternalError("Failed to cancel download: "+err.Error(), "Unable to cancel the download"))
return
}
@@ -436,7 +397,7 @@ func (h *SyslinuxHandler) CancelDownload(w http.ResponseWriter, r *http.Request)
func (h *SyslinuxHandler) GetSystemStatus(w http.ResponseWriter, r *http.Request) {
status, err := h.service.GetSystemStatus(r.Context())
if err != nil {
- http.Error(w, "Failed to get system status: "+err.Error(), http.StatusInternalServerError)
+ HandleError(w, r, NewInternalError("Failed to get system status: "+err.Error(), "Unable to load the system status"))
return
}
@@ -465,12 +426,12 @@ func (h *SyslinuxHandler) GetConfig(w http.ResponseWriter, r *http.Request) {
func (h *SyslinuxHandler) UpdateConfig(w http.ResponseWriter, r *http.Request) {
var config syslinux.SyslinuxConfig
if err := json.NewDecoder(r.Body).Decode(&config); err != nil {
- http.Error(w, "Invalid JSON: "+err.Error(), http.StatusBadRequest)
+ HandleError(w, r, NewValidationError("Invalid JSON: "+err.Error(), "The request body is not valid JSON"))
return
}
if err := h.service.UpdateConfig(r.Context(), config); err != nil {
- http.Error(w, "Failed to update config: "+err.Error(), http.StatusInternalServerError)
+ HandleError(w, r, NewInternalError("Failed to update config: "+err.Error(), "Unable to update the configuration"))
return
}
@@ -487,95 +448,21 @@ func (h *SyslinuxHandler) ValidateInstallation(w http.ResponseWriter, r *http.Re
bootType := vars["bootType"]
if bootType == "" {
- http.Error(w, "Boot type parameter required", http.StatusBadRequest)
+ HandleError(w, r, NewValidationError("Boot type parameter required", "A boot type is required"))
return
}
if bootType != "bios" && bootType != "efi" {
- http.Error(w, "Boot type must be 'bios' or 'efi'", http.StatusBadRequest)
+ HandleError(w, r, NewValidationError("Boot type must be 'bios' or 'efi'", "Boot type must be 'bios' or 'efi'"))
return
}
result, err := h.service.ValidateInstallation(r.Context(), bootType)
if err != nil {
- http.Error(w, "Failed to validate installation: "+err.Error(), http.StatusInternalServerError)
+ HandleError(w, r, NewInternalError("Failed to validate installation: "+err.Error(), "Unable to validate the installation"))
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(result)
}
-
-// Helper methods for version management
-
-// deactivateCurrentVersion finds and deactivates any currently active version
-func (h *SyslinuxHandler) deactivateCurrentVersion(ctx context.Context) error {
- versions, err := h.service.GetAvailableVersions(ctx)
- if err != nil {
- return err
- }
-
- for _, version := range versions {
- if version.Active {
- // Remove boot files
- if err := h.service.RemoveBootFiles(ctx, version.Version, "bios"); err != nil {
- return fmt.Errorf("failed to remove BIOS boot files for %s: %w", version.Version, err)
- }
- if err := h.service.RemoveBootFiles(ctx, version.Version, "efi"); err != nil {
- return fmt.Errorf("failed to remove EFI boot files for %s: %w", version.Version, err)
- }
-
- // Mark as inactive
- if err := h.deactivateVersion(ctx, version.Version); err != nil {
- return fmt.Errorf("failed to deactivate version %s: %w", version.Version, err)
- }
- }
- }
-
- return nil
-}
-
-// activateVersion marks a specific version as active
-func (h *SyslinuxHandler) activateVersion(ctx context.Context, version string) error {
- // This would need to be implemented in the service layer
- // For now, we'll need to directly update the database via repository
- versions, err := h.service.GetAvailableVersions(ctx)
- if err != nil {
- return err
- }
-
- for _, v := range versions {
- if v.Version == version {
- v.Active = true
- v.Downloaded = true
- v.UpdatedAt = time.Now()
- now := time.Now()
- v.DownloadedAt = &now
- // We would need a SaveVersion method in the service
- break
- }
- }
-
- return nil
-}
-
-// deactivateVersion marks a specific version as inactive
-func (h *SyslinuxHandler) deactivateVersion(ctx context.Context, version string) error {
- // This would need to be implemented in the service layer
- // For now, we'll need to directly update the database via repository
- versions, err := h.service.GetAvailableVersions(ctx)
- if err != nil {
- return err
- }
-
- for _, v := range versions {
- if v.Version == version {
- v.Active = false
- v.UpdatedAt = time.Now()
- // We would need a SaveVersion method in the service
- break
- }
- }
-
- return nil
-}
diff --git a/handlers/tftp.go b/handlers/tftp.go
index 9d32e5e..37fb7fe 100644
--- a/handlers/tftp.go
+++ b/handlers/tftp.go
@@ -3,12 +3,16 @@ package handlers
import (
"fmt"
"io"
+ "log"
"net/http"
"os"
"path/filepath"
"strings"
)
+// maxUploadBytes caps TFTP upload request bodies (512 MB).
+const maxUploadBytes = 512 << 20
+
// TFTPHandlers handles TFTP-related requests
type TFTPHandlers struct {
container *Container
@@ -21,7 +25,6 @@ func NewTFTPHandlers(container *Container) *TFTPHandlers {
// HandleTFTPPage serves the TFTP management page
func (h *TFTPHandlers) HandleTFTPPage(w http.ResponseWriter, r *http.Request) {
- templates := LoadTemplates()
var data *TFTPData
var err error
@@ -32,74 +35,47 @@ func (h *TFTPHandlers) HandleTFTPPage(w http.ResponseWriter, r *http.Request) {
}
if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
+ HandleError(w, r, NewInternalError(
+ fmt.Sprintf("failed to list TFTP directory: %v", err),
+ "Unable to list the TFTP directory",
+ ))
return
}
- data.ServerDirectory = strings.TrimPrefix(strings.TrimPrefix(data.ServerDirectory, TFTPDir), string(filepath.Separator))
- data.PrevDirectory = removeLastDir(TFTPDir, data.ServerDirectory)
- if err := templates["tftp"].Execute(w, data); err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- }
+ renderCachedTemplate(w, r, "tftp", data, "Unable to render the TFTP page")
}
// HandleDownload handles file downloads
func (h *TFTPHandlers) HandleDownload(w http.ResponseWriter, r *http.Request) {
fileName := r.URL.Query().Get("file")
if fileName == "" {
- appErr := NewValidationError("Missing file parameter", "File parameter is required")
- HandleError(w, r, appErr)
- return
- }
-
- // Create security validator
- validator := NewTFTPSecurityValidator(TFTPDir)
-
- // Validate the file path
- if err := validator.ValidateTFTPPath(fileName); err != nil {
- appErr := NewValidationError(
- fmt.Sprintf("Invalid file path: %v", err),
- "The requested file path is not allowed",
- )
- HandleError(w, r, appErr)
+ HandleError(w, r, NewValidationError("Missing file parameter", "File parameter is required"))
return
}
- // Get safe file path
- filePath, err := validator.GetSafePath(TFTPDir, fileName)
+ // Join-then-check: resolve the user-supplied name inside the TFTP root
+ // and reject anything that escapes it.
+ filePath, err := safeJoin(TFTPDir, fileName)
if err != nil {
- appErr := NewValidationError(
- fmt.Sprintf("Cannot resolve safe path: %v", err),
+ HandleError(w, r, NewValidationError(
+ fmt.Sprintf("Invalid file path: %v", err),
"The requested file path is not allowed",
- )
- HandleError(w, r, appErr)
- return
- }
-
- // Validate file size
- if err := validator.pathValidator.ValidateFileSize(filePath); err != nil {
- appErr := NewValidationError(
- fmt.Sprintf("File validation failed: %v", err),
- "The requested file is too large or invalid",
- )
- HandleError(w, r, appErr)
+ ))
return
}
file, err := os.Open(filePath)
if err != nil {
if os.IsNotExist(err) {
- appErr := NewNotFoundError(
+ HandleError(w, r, NewNotFoundError(
fmt.Sprintf("File not found: %s", fileName),
"The requested file does not exist",
- )
- HandleError(w, r, appErr)
+ ))
} else {
- appErr := NewInternalError(
+ HandleError(w, r, NewInternalError(
fmt.Sprintf("Error opening file %s: %v", fileName, err),
"Unable to open the requested file",
- )
- HandleError(w, r, appErr)
+ ))
}
return
}
@@ -107,16 +83,21 @@ func (h *TFTPHandlers) HandleDownload(w http.ResponseWriter, r *http.Request) {
fileInfo, err := file.Stat()
if err != nil {
- http.Error(w, "Error getting file info", http.StatusInternalServerError)
+ HandleError(w, r, NewInternalError("Error getting file info", "Unable to read the requested file"))
+ return
+ }
+ if fileInfo.IsDir() {
+ HandleError(w, r, NewValidationError("Path is a directory", "The requested path is a directory, not a file"))
return
}
- w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filepath.Base(fileName)))
+ w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", sanitizeDispositionFilename(fileName)))
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Length", fmt.Sprintf("%d", fileInfo.Size()))
if _, err := io.Copy(w, file); err != nil {
- http.Error(w, "Error serving file", http.StatusInternalServerError)
+ // Response already started; log only.
+ log.Printf("Error serving file %s: %v", fileName, err)
}
}
@@ -124,17 +105,32 @@ func (h *TFTPHandlers) HandleDownload(w http.ResponseWriter, r *http.Request) {
func (h *TFTPHandlers) ViewFile(w http.ResponseWriter, r *http.Request) {
fileName := r.URL.Query().Get("file")
if fileName == "" {
- http.Error(w, "File parameter is required", http.StatusBadRequest)
+ HandleError(w, r, NewValidationError("Missing file parameter", "File parameter is required"))
+ return
+ }
+
+ // Join-then-check: keep the resolved path inside the TFTP root.
+ filePath, err := safeJoin(TFTPDir, fileName)
+ if err != nil {
+ HandleError(w, r, NewValidationError(
+ fmt.Sprintf("Invalid file path: %v", err),
+ "The requested file path is not allowed",
+ ))
return
}
- filePath := filepath.Join(TFTPDir, fileName)
file, err := os.Open(filePath)
if err != nil {
if os.IsNotExist(err) {
- http.Error(w, "File not found", http.StatusNotFound)
+ HandleError(w, r, NewNotFoundError(
+ fmt.Sprintf("File not found: %s", fileName),
+ "The requested file does not exist",
+ ))
} else {
- http.Error(w, "Error opening file", http.StatusInternalServerError)
+ HandleError(w, r, NewInternalError(
+ fmt.Sprintf("Error opening file %s: %v", fileName, err),
+ "Unable to open the requested file",
+ ))
}
return
}
@@ -142,20 +138,20 @@ func (h *TFTPHandlers) ViewFile(w http.ResponseWriter, r *http.Request) {
fileInfo, err := file.Stat()
if err != nil {
- http.Error(w, "Error getting file info", http.StatusInternalServerError)
+ HandleError(w, r, NewInternalError("Error getting file info", "Unable to read the requested file"))
return
}
if fileInfo.IsDir() {
- http.Error(w, "Cannot view directory", http.StatusBadRequest)
+ HandleError(w, r, NewValidationError("Cannot view directory", "Directories cannot be previewed"))
return
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
- w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=\"%s\"", filepath.Base(fileName)))
+ w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=\"%s\"", sanitizeDispositionFilename(fileName)))
if _, err := io.Copy(w, file); err != nil {
- http.Error(w, "Error reading file", http.StatusInternalServerError)
+ log.Printf("Error streaming file %s: %v", fileName, err)
}
}
@@ -168,16 +164,31 @@ func (h *TFTPHandlers) ServeFile(w http.ResponseWriter, r *http.Request) {
func (h *TFTPHandlers) HandleDelete(w http.ResponseWriter, r *http.Request) {
fileName := r.URL.Query().Get("file")
if fileName == "" {
- http.Error(w, "File parameter is required", http.StatusBadRequest)
+ HandleError(w, r, NewValidationError("Missing file parameter", "File parameter is required"))
+ return
+ }
+
+ // Join-then-check: the deleted path must stay inside the TFTP root.
+ filePath, err := safeJoin(TFTPDir, fileName)
+ if err != nil {
+ HandleError(w, r, NewValidationError(
+ fmt.Sprintf("Invalid file path: %v", err),
+ "The requested file path is not allowed",
+ ))
return
}
- filePath := filepath.Join(TFTPDir, fileName)
if err := os.Remove(filePath); err != nil {
if os.IsNotExist(err) {
- http.Error(w, "File not found", http.StatusNotFound)
+ HandleError(w, r, NewNotFoundError(
+ fmt.Sprintf("File not found: %s", fileName),
+ "The requested file does not exist",
+ ))
} else {
- http.Error(w, "Error deleting file", http.StatusInternalServerError)
+ HandleError(w, r, NewInternalError(
+ fmt.Sprintf("Error deleting file %s: %v", fileName, err),
+ "Unable to delete the requested file",
+ ))
}
return
}
@@ -193,20 +204,39 @@ func (h *TFTPHandlers) HandleDelete(w http.ResponseWriter, r *http.Request) {
// HandleUpload handles file uploads
func (h *TFTPHandlers) HandleUpload(w http.ResponseWriter, r *http.Request) {
+ if !requireConfig(w, r, h.container) {
+ return
+ }
+
+ // Hard cap on the upload body before parsing the multipart form.
+ r.Body = http.MaxBytesReader(w, r.Body, maxUploadBytes)
+
// Parse multipart form with 32MB max memory
- err := r.ParseMultipartForm(32 << 20)
- if err != nil {
- http.Error(w, "Failed to parse upload form", http.StatusBadRequest)
+ if err := r.ParseMultipartForm(32 << 20); err != nil {
+ HandleError(w, r, NewValidationError(
+ fmt.Sprintf("Failed to parse upload form: %v", err),
+ "The upload was rejected (too large or malformed)",
+ ))
return
}
file, handler, err := r.FormFile("file")
if err != nil {
- http.Error(w, "Failed to get uploaded file", http.StatusBadRequest)
+ HandleError(w, r, NewValidationError("Missing file in upload", "No file was included in the upload"))
return
}
defer file.Close()
+ // Validate the upload against the TFTP security policy.
+ validator := NewTFTPSecurityValidator(TFTPDir)
+ if err := validator.ValidateTFTPUpload(handler.Filename, handler.Size); err != nil {
+ HandleError(w, r, NewValidationError(
+ fmt.Sprintf("Upload rejected: %v", err),
+ "The uploaded file is not allowed",
+ ))
+ return
+ }
+
// Create the uploads directory if it doesn't exist
tftpDir := h.container.Config.TFTP.Dir
if tftpDir == "" {
@@ -214,22 +244,40 @@ func (h *TFTPHandlers) HandleUpload(w http.ResponseWriter, r *http.Request) {
}
if err := os.MkdirAll(tftpDir, 0755); err != nil {
- http.Error(w, "Failed to create upload directory", http.StatusInternalServerError)
+ HandleError(w, r, NewInternalError(
+ fmt.Sprintf("Failed to create upload directory: %v", err),
+ "Unable to prepare the upload directory",
+ ))
+ return
+ }
+
+ // Resolve the destination inside the TFTP root (join-then-check).
+ dstPath, err := safeJoin(tftpDir, handler.Filename)
+ if err != nil {
+ HandleError(w, r, NewValidationError(
+ fmt.Sprintf("Invalid upload filename: %v", err),
+ "The uploaded file name is not allowed",
+ ))
return
}
// Create the destination file
- dst, err := os.Create(filepath.Join(tftpDir, handler.Filename))
+ dst, err := os.Create(dstPath)
if err != nil {
- http.Error(w, "Failed to create destination file", http.StatusInternalServerError)
+ HandleError(w, r, NewInternalError(
+ fmt.Sprintf("Failed to create destination file: %v", err),
+ "Unable to save the uploaded file",
+ ))
return
}
defer dst.Close()
// Copy the uploaded file to the destination
- _, err = io.Copy(dst, file)
- if err != nil {
- http.Error(w, "Failed to save uploaded file", http.StatusInternalServerError)
+ if _, err := io.Copy(dst, file); err != nil {
+ HandleError(w, r, NewInternalError(
+ fmt.Sprintf("Failed to save uploaded file: %v", err),
+ "Unable to save the uploaded file",
+ ))
return
}
@@ -266,11 +314,26 @@ func (h *TFTPHandlers) getTFTPDir(dir string) (*TFTPData, error) {
}
// getFileInfo reads the directory and returns file information for display.
+// The requested directory is resolved inside the TFTP root (join-then-check).
func (h *TFTPHandlers) getFileInfo(dir string) (*TFTPData, error) {
- entries, err := os.ReadDir(filepath.Join(TFTPDir, dir))
+ fullDir, err := safeJoin(TFTPDir, dir)
+ if err != nil {
+ return nil, err
+ }
+
+ entries, err := os.ReadDir(fullDir)
+ if err != nil {
+ return nil, err
+ }
+
+ // Path of the listed directory relative to the TFTP root ("" for root).
+ relDir, err := filepath.Rel(mustAbs(TFTPDir), fullDir)
if err != nil {
return nil, err
}
+ if relDir == "." {
+ relDir = ""
+ }
var fileInfos []FileInfo
for _, entry := range entries {
@@ -280,10 +343,8 @@ func (h *TFTPHandlers) getFileInfo(dir string) (*TFTPData, error) {
}
relativePath := entry.Name()
- if dir != "." && dir != "./" {
- subDirPath := strings.TrimPrefix(dir, TFTPDir)
- subDirPath = strings.TrimPrefix(subDirPath, string(filepath.Separator))
- relativePath = subDirPath + string(filepath.Separator) + entry.Name()
+ if relDir != "" {
+ relativePath = relDir + string(filepath.Separator) + entry.Name()
}
fileInfos = append(fileInfos, FileInfo{
@@ -294,17 +355,24 @@ func (h *TFTPHandlers) getFileInfo(dir string) (*TFTPData, error) {
})
}
- currentDir := strings.TrimPrefix(strings.TrimPrefix(dir, TFTPDir), string(filepath.Separator))
-
return &TFTPData{
Title: "TFTP Server Management",
- ServerRunning: true,
- ServerDirectory: currentDir,
- PrevDirectory: removeLastDir(TFTPDir, dir),
+ ServerRunning: isTFTPRunning(),
+ ServerDirectory: relDir,
+ PrevDirectory: removeLastDir(relDir),
Files: fileInfos,
}, nil
}
+// mustAbs returns the absolute form of p, falling back to p itself on error.
+func mustAbs(p string) string {
+ abs, err := filepath.Abs(p)
+ if err != nil {
+ return p
+ }
+ return abs
+}
+
// humanReadableSize converts bytes to a human-readable format.
func humanReadableSize(bytes int64) string {
const unit = 1024
@@ -319,18 +387,18 @@ func humanReadableSize(bytes int64) string {
return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
}
-// removeLastDir removes the last directory from a path.
-func removeLastDir(base, path string) string {
- if !strings.HasPrefix(path, base) {
- return ""
- }
-
- relPath := strings.TrimPrefix(path, base)
- if relPath == "" || relPath == string(filepath.Separator) {
+// removeLastDir removes the last directory from a base-relative path,
+// e.g. "a/b" -> "a", "a" -> "". It accepts paths with or without the TFTP
+// root prefix still attached.
+func removeLastDir(path string) string {
+ // Tolerate callers that pass a full path: strip the TFTP root first.
+ path = strings.TrimPrefix(path, TFTPDir)
+ path = strings.Trim(path, string(filepath.Separator))
+ if path == "" || path == "." {
return ""
}
- parts := strings.Split(strings.Trim(relPath, string(filepath.Separator)), string(filepath.Separator))
+ parts := strings.Split(path, string(filepath.Separator))
if len(parts) <= 1 {
return ""
}
diff --git a/handlers/validation.go b/handlers/validation.go
index 68a3b7a..3484bac 100644
--- a/handlers/validation.go
+++ b/handlers/validation.go
@@ -249,10 +249,10 @@ func (v *DHCPConfigValidator) ValidateDHCPConfig(config map[string]string) Valid
}
}
- // Validate lease time if provided
+ // Validate lease time if provided (the UI submits it in hours)
if leaseTime, ok := config["lease_time"]; ok && leaseTime != "" {
if _, err := strconv.Atoi(leaseTime); err != nil {
- errors.Add("lease_time", "lease time must be a valid number (seconds)")
+ errors.Add("lease_time", "lease time must be a valid number (hours)")
}
}
@@ -274,11 +274,22 @@ func (v *DHCPConfigValidator) ValidateReservation(ip, mac string) ValidationErro
return errors
}
-// Helper function to compare IPv4 addresses
+// Helper function to compare IPv4 addresses. Nil-safe: a nil IP sorts before
+// any valid IP, and two nil IPs compare equal.
func compareIPs(ip1, ip2 net.IP) int {
ip1 = ip1.To4()
ip2 = ip2.To4()
+ if ip1 == nil && ip2 == nil {
+ return 0
+ }
+ if ip1 == nil {
+ return -1
+ }
+ if ip2 == nil {
+ return 1
+ }
+
for i := 0; i < 4; i++ {
if ip1[i] < ip2[i] {
return -1
diff --git a/interfaces/service.go b/interfaces/service.go
deleted file mode 100644
index 649fd97..0000000
--- a/interfaces/service.go
+++ /dev/null
@@ -1,307 +0,0 @@
-package interfaces
-
-import (
- "context"
- "time"
-)
-
-// ServiceStatus represents the status of a service
-type ServiceStatus string
-
-const (
- StatusStopped ServiceStatus = "stopped"
- StatusStarting ServiceStatus = "starting"
- StatusRunning ServiceStatus = "running"
- StatusStopping ServiceStatus = "stopping"
- StatusError ServiceStatus = "error"
- StatusUnknown ServiceStatus = "unknown"
-)
-
-// ServiceInfo contains information about a service
-type ServiceInfo struct {
- ID string `json:"id"`
- Name string `json:"name"`
- Description string `json:"description"`
- Status ServiceStatus `json:"status"`
- Port int `json:"port,omitempty"`
- StartTime *time.Time `json:"start_time,omitempty"`
- Config map[string]interface{} `json:"config,omitempty"`
- Metrics map[string]interface{} `json:"metrics,omitempty"`
- Health *HealthCheck `json:"health,omitempty"`
-}
-
-// HealthCheck represents service health information
-type HealthCheck struct {
- Status string `json:"status"`
- LastCheck time.Time `json:"last_check"`
- Message string `json:"message,omitempty"`
- Details map[string]string `json:"details,omitempty"`
- CheckCount int `json:"check_count"`
- FailCount int `json:"fail_count"`
- Uptime time.Duration `json:"uptime"`
-}
-
-// ServiceOptions contains options for service configuration
-type ServiceOptions struct {
- AutoStart bool `json:"auto_start"`
- RestartOnFail bool `json:"restart_on_fail"`
- MaxRestarts int `json:"max_restarts"`
- Config map[string]interface{} `json:"config"`
- Environment map[string]string `json:"environment"`
- Dependencies []string `json:"dependencies"`
-}
-
-// Service defines the standard interface for all services
-type Service interface {
- // Basic service lifecycle
- Start(ctx context.Context) error
- Stop(ctx context.Context) error
- Restart(ctx context.Context) error
-
- // Service information
- GetInfo() ServiceInfo
- GetStatus() ServiceStatus
- IsRunning() bool
-
- // Health and monitoring
- HealthCheck() *HealthCheck
- GetMetrics() map[string]interface{}
-
- // Configuration
- Configure(options ServiceOptions) error
- GetConfig() map[string]interface{}
- ValidateConfig(config map[string]interface{}) error
-}
-
-// ManagedService extends Service with management capabilities
-type ManagedService interface {
- Service
-
- // Management operations
- Enable() error
- Disable() error
- IsEnabled() bool
-
- // Logging and monitoring
- GetLogs(ctx context.Context, lines int) ([]string, error)
- Subscribe() <-chan ServiceEvent
- Unsubscribe()
-}
-
-// ServiceEvent represents events that can occur in a service
-type ServiceEvent struct {
- Type string `json:"type"`
- Service string `json:"service"`
- Timestamp time.Time `json:"timestamp"`
- Message string `json:"message"`
- Data map[string]interface{} `json:"data,omitempty"`
- Level string `json:"level"` // info, warning, error
-}
-
-// ServiceManager manages multiple services
-type ServiceManager interface {
- // Service management
- RegisterService(name string, service Service) error
- UnregisterService(name string) error
- GetService(name string) (Service, error)
- ListServices() []ServiceInfo
-
- // Bulk operations
- StartAll(ctx context.Context) error
- StopAll(ctx context.Context) error
- RestartAll(ctx context.Context) error
-
- // Health monitoring
- HealthCheckAll() map[string]*HealthCheck
- GetOverallHealth() *HealthCheck
-
- // Events
- Subscribe() <-chan ServiceEvent
- Unsubscribe()
-}
-
-// ConfigurableService defines services that can be reconfigured
-type ConfigurableService interface {
- Service
-
- // Configuration management
- UpdateConfig(config map[string]interface{}) error
- ReloadConfig() error
- GetConfigSchema() map[string]interface{}
- ExportConfig() (map[string]interface{}, error)
- ImportConfig(config map[string]interface{}) error
-}
-
-// NetworkService defines services that use network resources
-type NetworkService interface {
- Service
-
- // Network information
- GetListenAddresses() []string
- GetPort() int
- IsPortInUse(port int) bool
-
- // Network configuration
- BindToAddress(address string) error
- ChangePort(port int) error
-}
-
-// StorageService defines services that manage storage
-type StorageService interface {
- Service
-
- // Storage operations
- GetStoragePath() string
- GetStorageUsage() (used, available int64, err error)
- CleanupStorage() error
- BackupData(destination string) error
- RestoreData(source string) error
-}
-
-// DHCPService defines DHCP-specific operations
-type DHCPService interface {
- NetworkService
- ConfigurableService
-
- // DHCP operations
- CreateServer(config map[string]interface{}) (string, error)
- DeleteServer(id string) error
- GetServers() ([]map[string]interface{}, error)
-
- // Lease management
- GetLeases(serverID string) ([]map[string]interface{}, error)
- ReserveLease(serverID, ip, mac string) error
- ReleaseLease(serverID, ip string) error
-
- // Configuration
- UpdateServerConfig(id string, config map[string]interface{}) error
-}
-
-// TFTPService defines TFTP-specific operations
-type TFTPService interface {
- NetworkService
- StorageService
-
- // File operations
- ListFiles(path string) ([]FileInfo, error)
- GetFile(path string) ([]byte, error)
- PutFile(path string, data []byte) error
- DeleteFile(path string) error
-
- // Directory operations
- CreateDirectory(path string) error
- DeleteDirectory(path string) error
- GetDirectorySize(path string) (int64, error)
-}
-
-// OSImageService defines OS image management operations
-type OSImageService interface {
- StorageService
-
- // Image management
- ListImages() ([]ImageInfo, error)
- DownloadImage(url, destination string) (string, error)
- DeleteImage(id string) error
- GetImageInfo(id string) (*ImageInfo, error)
-
- // Image operations
- ExtractImage(id, destination string) error
- VerifyImage(id string) error
- SetDefaultImage(id string) error
-}
-
-// ProvisionService defines provisioning operations
-type ProvisionService interface {
- ConfigurableService
-
- // Template management
- ListTemplates() ([]TemplateInfo, error)
- GetTemplate(name string) (*TemplateInfo, error)
- SaveTemplate(name string, content []byte) error
- DeleteTemplate(name string) error
-
- // Provisioning operations
- GenerateConfig(template string, variables map[string]interface{}) ([]byte, error)
- ValidateTemplate(content []byte) error
-}
-
-// FileInfo represents file information
-type FileInfo struct {
- Name string `json:"name"`
- Path string `json:"path"`
- Size int64 `json:"size"`
- IsDirectory bool `json:"is_directory"`
- ModTime time.Time `json:"mod_time"`
- Permissions string `json:"permissions"`
- Owner string `json:"owner,omitempty"`
- Group string `json:"group,omitempty"`
- ContentType string `json:"content_type,omitempty"`
- Metadata map[string]string `json:"metadata,omitempty"`
-}
-
-// ImageInfo represents OS image information
-type ImageInfo struct {
- ID string `json:"id"`
- Name string `json:"name"`
- Version string `json:"version"`
- Architecture string `json:"architecture"`
- Type string `json:"type"`
- Size int64 `json:"size"`
- Checksum string `json:"checksum"`
- Downloaded bool `json:"downloaded"`
- IsDefault bool `json:"is_default"`
- Metadata map[string]string `json:"metadata"`
- DownloadURL string `json:"download_url,omitempty"`
- CreatedAt time.Time `json:"created_at"`
- UpdatedAt time.Time `json:"updated_at"`
-}
-
-// Template type constants
-const (
- TemplateTypeKickstart = "kickstart" // Red Hat/CentOS/Fedora
- TemplateTypePreseed = "preseed" // Debian/Ubuntu
- TemplateTypeAutoYaST = "autoyast" // SUSE/openSUSE
- TemplateTypeCloudInit = "cloud-init" // Modern cloud images
- TemplateTypeIPXE = "ipxe" // Custom boot scripts
-)
-
-// TemplateInfo represents template information
-type TemplateInfo struct {
- Name string `json:"name"`
- Type string `json:"type"`
- Description string `json:"description"`
- Content string `json:"content"`
- Variables []VariableInfo `json:"variables"`
- Metadata map[string]string `json:"metadata"`
- CreatedAt time.Time `json:"created_at"`
- UpdatedAt time.Time `json:"updated_at"`
-}
-
-// VariableInfo represents template variable information
-type VariableInfo struct {
- Name string `json:"name"`
- Type string `json:"type"`
- Description string `json:"description"`
- Required bool `json:"required"`
- DefaultValue interface{} `json:"default_value,omitempty"`
- Options []string `json:"options,omitempty"`
-}
-
-// ServiceEvent types
-const (
- EventTypeStarted = "started"
- EventTypeStopped = "stopped"
- EventTypeRestarted = "restarted"
- EventTypeConfigured = "configured"
- EventTypeError = "error"
- EventTypeHealthCheck = "health_check"
- EventTypeMetrics = "metrics"
-)
-
-// Event levels
-const (
- LevelInfo = "info"
- LevelWarning = "warning"
- LevelError = "error"
- LevelDebug = "debug"
-)
diff --git a/interfaces/service_test.go b/interfaces/service_test.go
deleted file mode 100644
index 305afac..0000000
--- a/interfaces/service_test.go
+++ /dev/null
@@ -1,601 +0,0 @@
-package interfaces
-
-import (
- "context"
- "testing"
- "time"
-)
-
-// MockService implements the Service interface for testing
-type MockService struct {
- id string
- name string
- status ServiceStatus
- running bool
- config map[string]interface{}
- metrics map[string]interface{}
- health *HealthCheck
- startError error
- stopError error
- configError error
-}
-
-func NewMockService(id, name string) *MockService {
- return &MockService{
- id: id,
- name: name,
- status: StatusStopped,
- running: false,
- config: make(map[string]interface{}),
- metrics: make(map[string]interface{}),
- health: &HealthCheck{
- Status: "ok",
- LastCheck: time.Now(),
- Message: "Service is healthy",
- },
- }
-}
-
-func (m *MockService) Start(ctx context.Context) error {
- if m.startError != nil {
- return m.startError
- }
- m.status = StatusRunning
- m.running = true
- return nil
-}
-
-func (m *MockService) Stop(ctx context.Context) error {
- if m.stopError != nil {
- return m.stopError
- }
- m.status = StatusStopped
- m.running = false
- return nil
-}
-
-func (m *MockService) Restart(ctx context.Context) error {
- if err := m.Stop(ctx); err != nil {
- return err
- }
- return m.Start(ctx)
-}
-
-func (m *MockService) GetInfo() ServiceInfo {
- startTime := time.Now()
- return ServiceInfo{
- ID: m.id,
- Name: m.name,
- Description: "Mock service for testing",
- Status: m.status,
- StartTime: &startTime,
- Config: m.config,
- Metrics: m.metrics,
- Health: m.health,
- }
-}
-
-func (m *MockService) GetStatus() ServiceStatus {
- return m.status
-}
-
-func (m *MockService) IsRunning() bool {
- return m.running
-}
-
-func (m *MockService) HealthCheck() *HealthCheck {
- return m.health
-}
-
-func (m *MockService) GetMetrics() map[string]interface{} {
- return m.metrics
-}
-
-func (m *MockService) Configure(options ServiceOptions) error {
- if m.configError != nil {
- return m.configError
- }
- m.config = options.Config
- return nil
-}
-
-func (m *MockService) GetConfig() map[string]interface{} {
- return m.config
-}
-
-func (m *MockService) ValidateConfig(config map[string]interface{}) error {
- if m.configError != nil {
- return m.configError
- }
- return nil
-}
-
-// Test Service interface implementation
-func TestMockService_ImplementsService(t *testing.T) {
- var _ Service = &MockService{}
-}
-
-func TestMockService_Lifecycle(t *testing.T) {
- service := NewMockService("test-1", "Test Service")
- ctx := context.Background()
-
- // Initial state
- if service.GetStatus() != StatusStopped {
- t.Errorf("Expected initial status to be %s, got %s", StatusStopped, service.GetStatus())
- }
-
- if service.IsRunning() {
- t.Error("Expected service to not be running initially")
- }
-
- // Start service
- err := service.Start(ctx)
- if err != nil {
- t.Errorf("Failed to start service: %v", err)
- }
-
- if service.GetStatus() != StatusRunning {
- t.Errorf("Expected status to be %s after start, got %s", StatusRunning, service.GetStatus())
- }
-
- if !service.IsRunning() {
- t.Error("Expected service to be running after start")
- }
-
- // Stop service
- err = service.Stop(ctx)
- if err != nil {
- t.Errorf("Failed to stop service: %v", err)
- }
-
- if service.GetStatus() != StatusStopped {
- t.Errorf("Expected status to be %s after stop, got %s", StatusStopped, service.GetStatus())
- }
-
- if service.IsRunning() {
- t.Error("Expected service to not be running after stop")
- }
-
- // Restart service
- err = service.Restart(ctx)
- if err != nil {
- t.Errorf("Failed to restart service: %v", err)
- }
-
- if service.GetStatus() != StatusRunning {
- t.Errorf("Expected status to be %s after restart, got %s", StatusRunning, service.GetStatus())
- }
-}
-
-func TestMockService_Configuration(t *testing.T) {
- service := NewMockService("test-2", "Test Service 2")
-
- // Initial config should be empty
- config := service.GetConfig()
- if len(config) != 0 {
- t.Errorf("Expected empty initial config, got %d items", len(config))
- }
-
- // Configure service
- options := ServiceOptions{
- Config: map[string]interface{}{
- "timeout": 30,
- "retries": 3,
- },
- }
-
- err := service.Configure(options)
- if err != nil {
- t.Errorf("Failed to configure service: %v", err)
- }
-
- // Check config was applied
- newConfig := service.GetConfig()
- if len(newConfig) != 2 {
- t.Errorf("Expected 2 config items, got %d", len(newConfig))
- }
-
- if newConfig["timeout"] != 30 {
- t.Errorf("Expected timeout to be 30, got %v", newConfig["timeout"])
- }
-
- if newConfig["retries"] != 3 {
- t.Errorf("Expected retries to be 3, got %v", newConfig["retries"])
- }
-
- // Validate config
- err = service.ValidateConfig(newConfig)
- if err != nil {
- t.Errorf("Config validation failed: %v", err)
- }
-}
-
-func TestMockService_HealthAndMetrics(t *testing.T) {
- service := NewMockService("test-3", "Test Service 3")
-
- // Check health
- health := service.HealthCheck()
- if health == nil {
- t.Fatal("Expected health check to return non-nil")
- }
-
- if health.Status != "ok" {
- t.Errorf("Expected health status to be 'ok', got %s", health.Status)
- }
-
- // Check metrics
- metrics := service.GetMetrics()
- if metrics == nil {
- t.Fatal("Expected metrics to return non-nil map")
- }
-}
-
-func TestMockService_Info(t *testing.T) {
- service := NewMockService("test-4", "Test Service 4")
-
- info := service.GetInfo()
-
- if info.ID != "test-4" {
- t.Errorf("Expected ID to be 'test-4', got %s", info.ID)
- }
-
- if info.Name != "Test Service 4" {
- t.Errorf("Expected name to be 'Test Service 4', got %s", info.Name)
- }
-
- if info.Status != StatusStopped {
- t.Errorf("Expected status to be %s, got %s", StatusStopped, info.Status)
- }
-
- if info.StartTime == nil {
- t.Error("Expected start time to be set")
- }
-
- if info.Config == nil {
- t.Error("Expected config to be non-nil")
- }
-
- if info.Metrics == nil {
- t.Error("Expected metrics to be non-nil")
- }
-
- if info.Health == nil {
- t.Error("Expected health to be non-nil")
- }
-}
-
-// Test ServiceStatus constants
-func TestServiceStatus_Constants(t *testing.T) {
- statuses := []ServiceStatus{
- StatusStopped,
- StatusStarting,
- StatusRunning,
- StatusStopping,
- StatusError,
- StatusUnknown,
- }
-
- expectedValues := []string{
- "stopped",
- "starting",
- "running",
- "stopping",
- "error",
- "unknown",
- }
-
- for i, status := range statuses {
- if string(status) != expectedValues[i] {
- t.Errorf("Expected status %d to be %s, got %s", i, expectedValues[i], string(status))
- }
- }
-}
-
-// Test HealthCheck structure
-func TestHealthCheck_Structure(t *testing.T) {
- health := &HealthCheck{
- Status: "healthy",
- LastCheck: time.Now(),
- Message: "All systems operational",
- Details: map[string]string{"cpu": "50%", "memory": "60%"},
- CheckCount: 100,
- FailCount: 2,
- Uptime: time.Hour * 24,
- }
-
- if health.Status != "healthy" {
- t.Error("Health status not set correctly")
- }
-
- if health.Message == "" {
- t.Error("Health message should not be empty")
- }
-
- if health.Details == nil {
- t.Error("Health details should not be nil")
- }
-
- if len(health.Details) != 2 {
- t.Errorf("Expected 2 health details, got %d", len(health.Details))
- }
-
- if health.CheckCount != 100 {
- t.Errorf("Expected check count to be 100, got %d", health.CheckCount)
- }
-
- if health.FailCount != 2 {
- t.Errorf("Expected fail count to be 2, got %d", health.FailCount)
- }
-}
-
-// Test ServiceOptions structure
-func TestServiceOptions_Structure(t *testing.T) {
- options := ServiceOptions{
- AutoStart: true,
- RestartOnFail: true,
- MaxRestarts: 5,
- Config: map[string]interface{}{
- "port": 8080,
- "host": "localhost",
- },
- Environment: map[string]string{
- "ENV": "test",
- "DEBUG": "true",
- },
- Dependencies: []string{"database", "cache"},
- }
-
- if !options.AutoStart {
- t.Error("AutoStart should be true")
- }
-
- if !options.RestartOnFail {
- t.Error("RestartOnFail should be true")
- }
-
- if options.MaxRestarts != 5 {
- t.Errorf("Expected MaxRestarts to be 5, got %d", options.MaxRestarts)
- }
-
- if len(options.Config) != 2 {
- t.Errorf("Expected 2 config items, got %d", len(options.Config))
- }
-
- if len(options.Environment) != 2 {
- t.Errorf("Expected 2 environment variables, got %d", len(options.Environment))
- }
-
- if len(options.Dependencies) != 2 {
- t.Errorf("Expected 2 dependencies, got %d", len(options.Dependencies))
- }
-}
-
-// Test ServiceEvent structure
-func TestServiceEvent_Structure(t *testing.T) {
- event := ServiceEvent{
- Type: EventTypeStarted,
- Service: "test-service",
- Timestamp: time.Now(),
- Message: "Service started successfully",
- Data: map[string]interface{}{
- "port": 8080,
- },
- Level: LevelInfo,
- }
-
- if event.Type != EventTypeStarted {
- t.Errorf("Expected event type to be %s, got %s", EventTypeStarted, event.Type)
- }
-
- if event.Service != "test-service" {
- t.Errorf("Expected service to be 'test-service', got %s", event.Service)
- }
-
- if event.Message == "" {
- t.Error("Event message should not be empty")
- }
-
- if event.Level != LevelInfo {
- t.Errorf("Expected level to be %s, got %s", LevelInfo, event.Level)
- }
-}
-
-// Test event type constants
-func TestEventType_Constants(t *testing.T) {
- events := []string{
- EventTypeStarted,
- EventTypeStopped,
- EventTypeRestarted,
- EventTypeConfigured,
- EventTypeError,
- EventTypeHealthCheck,
- EventTypeMetrics,
- }
-
- expectedValues := []string{
- "started",
- "stopped",
- "restarted",
- "configured",
- "error",
- "health_check",
- "metrics",
- }
-
- for i, event := range events {
- if event != expectedValues[i] {
- t.Errorf("Expected event %d to be %s, got %s", i, expectedValues[i], event)
- }
- }
-}
-
-// Test log level constants
-func TestLogLevel_Constants(t *testing.T) {
- levels := []string{
- LevelInfo,
- LevelWarning,
- LevelError,
- LevelDebug,
- }
-
- expectedValues := []string{
- "info",
- "warning",
- "error",
- "debug",
- }
-
- for i, level := range levels {
- if level != expectedValues[i] {
- t.Errorf("Expected level %d to be %s, got %s", i, expectedValues[i], level)
- }
- }
-}
-
-// Test FileInfo structure
-func TestFileInfo_Structure(t *testing.T) {
- fileInfo := FileInfo{
- Name: "test.txt",
- Path: "/path/to/test.txt",
- Size: 1024,
- IsDirectory: false,
- ModTime: time.Now(),
- Permissions: "644",
- Owner: "user",
- Group: "group",
- ContentType: "text/plain",
- Metadata: map[string]string{
- "encoding": "utf-8",
- },
- }
-
- if fileInfo.Name != "test.txt" {
- t.Errorf("Expected name to be 'test.txt', got %s", fileInfo.Name)
- }
-
- if fileInfo.Size != 1024 {
- t.Errorf("Expected size to be 1024, got %d", fileInfo.Size)
- }
-
- if fileInfo.IsDirectory {
- t.Error("Expected IsDirectory to be false")
- }
-
- if fileInfo.Permissions != "644" {
- t.Errorf("Expected permissions to be '644', got %s", fileInfo.Permissions)
- }
-}
-
-// Test ImageInfo structure
-func TestImageInfo_Structure(t *testing.T) {
- imageInfo := ImageInfo{
- ID: "ubuntu-22.04",
- Name: "Ubuntu 22.04 LTS",
- Version: "22.04",
- Architecture: "amd64",
- Type: "iso",
- Size: 3221225472, // 3GB
- Checksum: "sha256:abc123",
- Downloaded: true,
- IsDefault: false,
- Metadata: map[string]string{
- "release_date": "2022-04-21",
- },
- DownloadURL: "https://releases.ubuntu.com/22.04/ubuntu-22.04-desktop-amd64.iso",
- CreatedAt: time.Now(),
- UpdatedAt: time.Now(),
- }
-
- if imageInfo.ID != "ubuntu-22.04" {
- t.Errorf("Expected ID to be 'ubuntu-22.04', got %s", imageInfo.ID)
- }
-
- if imageInfo.Architecture != "amd64" {
- t.Errorf("Expected architecture to be 'amd64', got %s", imageInfo.Architecture)
- }
-
- if !imageInfo.Downloaded {
- t.Error("Expected Downloaded to be true")
- }
-
- if imageInfo.IsDefault {
- t.Error("Expected IsDefault to be false")
- }
-}
-
-// Test TemplateInfo structure
-func TestTemplateInfo_Structure(t *testing.T) {
- templateInfo := TemplateInfo{
- Name: "ubuntu-kickstart",
- Type: "kickstart",
- Description: "Ubuntu kickstart template",
- Content: "#Ubuntu kickstart configuration",
- Variables: []VariableInfo{
- {
- Name: "hostname",
- Type: "string",
- Description: "System hostname",
- Required: true,
- DefaultValue: "ubuntu-server",
- },
- },
- Metadata: map[string]string{
- "author": "system",
- },
- CreatedAt: time.Now(),
- UpdatedAt: time.Now(),
- }
-
- if templateInfo.Name != "ubuntu-kickstart" {
- t.Errorf("Expected name to be 'ubuntu-kickstart', got %s", templateInfo.Name)
- }
-
- if templateInfo.Type != "kickstart" {
- t.Errorf("Expected type to be 'kickstart', got %s", templateInfo.Type)
- }
-
- if len(templateInfo.Variables) != 1 {
- t.Errorf("Expected 1 variable, got %d", len(templateInfo.Variables))
- }
-
- variable := templateInfo.Variables[0]
- if variable.Name != "hostname" {
- t.Errorf("Expected variable name to be 'hostname', got %s", variable.Name)
- }
-
- if !variable.Required {
- t.Error("Expected variable to be required")
- }
-}
-
-// Test VariableInfo structure
-func TestVariableInfo_Structure(t *testing.T) {
- variable := VariableInfo{
- Name: "disk_size",
- Type: "integer",
- Description: "Disk size in GB",
- Required: true,
- DefaultValue: 20,
- Options: []string{"10", "20", "50", "100"},
- }
-
- if variable.Name != "disk_size" {
- t.Errorf("Expected name to be 'disk_size', got %s", variable.Name)
- }
-
- if variable.Type != "integer" {
- t.Errorf("Expected type to be 'integer', got %s", variable.Type)
- }
-
- if !variable.Required {
- t.Error("Expected variable to be required")
- }
-
- if variable.DefaultValue != 20 {
- t.Errorf("Expected default value to be 20, got %v", variable.DefaultValue)
- }
-
- if len(variable.Options) != 4 {
- t.Errorf("Expected 4 options, got %d", len(variable.Options))
- }
-}
diff --git a/interfaces/specialized_test.go b/interfaces/specialized_test.go
deleted file mode 100644
index 7b41ba1..0000000
--- a/interfaces/specialized_test.go
+++ /dev/null
@@ -1,598 +0,0 @@
-package interfaces
-
-import (
- "context"
- "testing"
- "time"
-)
-
-// MockManagedService implements ManagedService for testing
-type MockManagedService struct {
- *MockService
- enabled bool
- subscribers []chan ServiceEvent
-}
-
-func NewMockManagedService(id, name string) *MockManagedService {
- return &MockManagedService{
- MockService: NewMockService(id, name),
- enabled: false,
- subscribers: make([]chan ServiceEvent, 0),
- }
-}
-
-func (m *MockManagedService) Enable() error {
- m.enabled = true
- return nil
-}
-
-func (m *MockManagedService) Disable() error {
- m.enabled = false
- return nil
-}
-
-func (m *MockManagedService) IsEnabled() bool {
- return m.enabled
-}
-
-func (m *MockManagedService) GetLogs(ctx context.Context, lines int) ([]string, error) {
- logs := make([]string, lines)
- for i := 0; i < lines; i++ {
- logs[i] = "Mock log line " + string(rune('0'+i))
- }
- return logs, nil
-}
-
-func (m *MockManagedService) Subscribe() <-chan ServiceEvent {
- ch := make(chan ServiceEvent, 10)
- m.subscribers = append(m.subscribers, ch)
- return ch
-}
-
-func (m *MockManagedService) Unsubscribe() {
- for _, ch := range m.subscribers {
- close(ch)
- }
- m.subscribers = make([]chan ServiceEvent, 0)
-}
-
-// MockNetworkService implements NetworkService for testing
-type MockNetworkService struct {
- *MockService
- addresses []string
- port int
-}
-
-func NewMockNetworkService(id, name string, port int) *MockNetworkService {
- return &MockNetworkService{
- MockService: NewMockService(id, name),
- addresses: []string{"127.0.0.1", "0.0.0.0"},
- port: port,
- }
-}
-
-func (m *MockNetworkService) GetListenAddresses() []string {
- return m.addresses
-}
-
-func (m *MockNetworkService) GetPort() int {
- return m.port
-}
-
-func (m *MockNetworkService) IsPortInUse(port int) bool {
- return port == m.port
-}
-
-func (m *MockNetworkService) BindToAddress(address string) error {
- m.addresses = []string{address}
- return nil
-}
-
-func (m *MockNetworkService) ChangePort(port int) error {
- m.port = port
- return nil
-}
-
-// MockStorageService implements StorageService for testing
-type MockStorageService struct {
- *MockService
- storagePath string
- used int64
- available int64
-}
-
-func NewMockStorageService(id, name, path string) *MockStorageService {
- return &MockStorageService{
- MockService: NewMockService(id, name),
- storagePath: path,
- used: 1024 * 1024 * 100, // 100MB used
- available: 1024 * 1024 * 900, // 900MB available
- }
-}
-
-func (m *MockStorageService) GetStoragePath() string {
- return m.storagePath
-}
-
-func (m *MockStorageService) GetStorageUsage() (used, available int64, err error) {
- return m.used, m.available, nil
-}
-
-func (m *MockStorageService) CleanupStorage() error {
- m.used = 0
- return nil
-}
-
-func (m *MockStorageService) BackupData(destination string) error {
- // Mock backup operation
- return nil
-}
-
-func (m *MockStorageService) RestoreData(source string) error {
- // Mock restore operation
- return nil
-}
-
-// MockServiceManager implements ServiceManager for testing
-type MockServiceManager struct {
- services map[string]Service
- subscribers []chan ServiceEvent
-}
-
-func NewMockServiceManager() *MockServiceManager {
- return &MockServiceManager{
- services: make(map[string]Service),
- subscribers: make([]chan ServiceEvent, 0),
- }
-}
-
-func (m *MockServiceManager) RegisterService(name string, service Service) error {
- m.services[name] = service
- return nil
-}
-
-func (m *MockServiceManager) UnregisterService(name string) error {
- delete(m.services, name)
- return nil
-}
-
-func (m *MockServiceManager) GetService(name string) (Service, error) {
- if service, exists := m.services[name]; exists {
- return service, nil
- }
- return nil, ErrServiceNotFound
-}
-
-func (m *MockServiceManager) ListServices() []ServiceInfo {
- infos := make([]ServiceInfo, 0, len(m.services))
- for _, service := range m.services {
- infos = append(infos, service.GetInfo())
- }
- return infos
-}
-
-func (m *MockServiceManager) StartAll(ctx context.Context) error {
- for _, service := range m.services {
- if err := service.Start(ctx); err != nil {
- return err
- }
- }
- return nil
-}
-
-func (m *MockServiceManager) StopAll(ctx context.Context) error {
- for _, service := range m.services {
- if err := service.Stop(ctx); err != nil {
- return err
- }
- }
- return nil
-}
-
-func (m *MockServiceManager) RestartAll(ctx context.Context) error {
- for _, service := range m.services {
- if err := service.Restart(ctx); err != nil {
- return err
- }
- }
- return nil
-}
-
-func (m *MockServiceManager) HealthCheckAll() map[string]*HealthCheck {
- health := make(map[string]*HealthCheck)
- for name, service := range m.services {
- health[name] = service.HealthCheck()
- }
- return health
-}
-
-func (m *MockServiceManager) GetOverallHealth() *HealthCheck {
- return &HealthCheck{
- Status: "healthy",
- LastCheck: time.Now(),
- Message: "All services are healthy",
- }
-}
-
-func (m *MockServiceManager) Subscribe() <-chan ServiceEvent {
- ch := make(chan ServiceEvent, 10)
- m.subscribers = append(m.subscribers, ch)
- return ch
-}
-
-func (m *MockServiceManager) Unsubscribe() {
- for _, ch := range m.subscribers {
- close(ch)
- }
- m.subscribers = make([]chan ServiceEvent, 0)
-}
-
-// Custom error for testing
-var ErrServiceNotFound = &ServiceError{
- Code: "SERVICE_NOT_FOUND",
- Message: "Service not found",
-}
-
-type ServiceError struct {
- Code string
- Message string
-}
-
-func (e *ServiceError) Error() string {
- return e.Message
-}
-
-// Test interface compliance
-func TestInterfaceCompliance(t *testing.T) {
- // Test Service interface
- var _ Service = &MockService{}
-
- // Test ManagedService interface
- var _ ManagedService = &MockManagedService{}
- var _ Service = &MockManagedService{} // ManagedService should also implement Service
-
- // Test NetworkService interface
- var _ NetworkService = &MockNetworkService{}
- var _ Service = &MockNetworkService{} // NetworkService should also implement Service
-
- // Test StorageService interface
- var _ StorageService = &MockStorageService{}
- var _ Service = &MockStorageService{} // StorageService should also implement Service
-
- // Test ServiceManager interface
- var _ ServiceManager = &MockServiceManager{}
-}
-
-func TestManagedService_EnableDisable(t *testing.T) {
- service := NewMockManagedService("managed-1", "Managed Service")
-
- // Initially disabled
- if service.IsEnabled() {
- t.Error("Expected service to be disabled initially")
- }
-
- // Enable service
- err := service.Enable()
- if err != nil {
- t.Errorf("Failed to enable service: %v", err)
- }
-
- if !service.IsEnabled() {
- t.Error("Expected service to be enabled")
- }
-
- // Disable service
- err = service.Disable()
- if err != nil {
- t.Errorf("Failed to disable service: %v", err)
- }
-
- if service.IsEnabled() {
- t.Error("Expected service to be disabled")
- }
-}
-
-func TestManagedService_Logs(t *testing.T) {
- service := NewMockManagedService("managed-2", "Managed Service 2")
- ctx := context.Background()
-
- logs, err := service.GetLogs(ctx, 5)
- if err != nil {
- t.Errorf("Failed to get logs: %v", err)
- }
-
- if len(logs) != 5 {
- t.Errorf("Expected 5 log lines, got %d", len(logs))
- }
-
- for i, log := range logs {
- expected := "Mock log line " + string(rune('0'+i))
- if log != expected {
- t.Errorf("Expected log line %d to be '%s', got '%s'", i, expected, log)
- }
- }
-}
-
-func TestManagedService_Subscribe(t *testing.T) {
- service := NewMockManagedService("managed-3", "Managed Service 3")
-
- // Subscribe to events
- eventChan := service.Subscribe()
- if eventChan == nil {
- t.Fatal("Expected non-nil event channel")
- }
-
- // Test that channel is ready to receive
- select {
- case <-eventChan:
- // Channel received something (unexpected for this test)
- default:
- // Channel is ready but no events (expected)
- }
-
- // Unsubscribe
- service.Unsubscribe()
-
- // After unsubscribe, channel should be closed
- select {
- case _, open := <-eventChan:
- if open {
- t.Error("Expected channel to be closed after unsubscribe")
- }
- case <-time.After(100 * time.Millisecond):
- t.Error("Channel should have been closed immediately")
- }
-}
-
-func TestNetworkService_AddressManagement(t *testing.T) {
- service := NewMockNetworkService("network-1", "Network Service", 8080)
-
- // Check initial addresses
- addresses := service.GetListenAddresses()
- if len(addresses) != 2 {
- t.Errorf("Expected 2 initial addresses, got %d", len(addresses))
- }
-
- // Check initial port
- if service.GetPort() != 8080 {
- t.Errorf("Expected initial port to be 8080, got %d", service.GetPort())
- }
-
- // Check port in use
- if !service.IsPortInUse(8080) {
- t.Error("Expected port 8080 to be in use")
- }
-
- if service.IsPortInUse(8081) {
- t.Error("Expected port 8081 to not be in use")
- }
-
- // Bind to new address
- err := service.BindToAddress("192.168.1.100")
- if err != nil {
- t.Errorf("Failed to bind to address: %v", err)
- }
-
- newAddresses := service.GetListenAddresses()
- if len(newAddresses) != 1 || newAddresses[0] != "192.168.1.100" {
- t.Errorf("Expected single address '192.168.1.100', got %v", newAddresses)
- }
-
- // Change port
- err = service.ChangePort(9090)
- if err != nil {
- t.Errorf("Failed to change port: %v", err)
- }
-
- if service.GetPort() != 9090 {
- t.Errorf("Expected port to be 9090, got %d", service.GetPort())
- }
-}
-
-func TestStorageService_StorageManagement(t *testing.T) {
- service := NewMockStorageService("storage-1", "Storage Service", "/data")
-
- // Check storage path
- if service.GetStoragePath() != "/data" {
- t.Errorf("Expected storage path to be '/data', got %s", service.GetStoragePath())
- }
-
- // Check storage usage
- used, available, err := service.GetStorageUsage()
- if err != nil {
- t.Errorf("Failed to get storage usage: %v", err)
- }
-
- expectedUsed := int64(1024 * 1024 * 100) // 100MB
- if used != expectedUsed {
- t.Errorf("Expected used storage to be %d, got %d", expectedUsed, used)
- }
-
- expectedAvailable := int64(1024 * 1024 * 900) // 900MB
- if available != expectedAvailable {
- t.Errorf("Expected available storage to be %d, got %d", expectedAvailable, available)
- }
-
- // Cleanup storage
- err = service.CleanupStorage()
- if err != nil {
- t.Errorf("Failed to cleanup storage: %v", err)
- }
-
- // Check that used storage is now 0
- used, _, err = service.GetStorageUsage()
- if err != nil {
- t.Errorf("Failed to get storage usage after cleanup: %v", err)
- }
-
- if used != 0 {
- t.Errorf("Expected used storage to be 0 after cleanup, got %d", used)
- }
-
- // Test backup and restore
- err = service.BackupData("/backup/location")
- if err != nil {
- t.Errorf("Failed to backup data: %v", err)
- }
-
- err = service.RestoreData("/backup/location")
- if err != nil {
- t.Errorf("Failed to restore data: %v", err)
- }
-}
-
-func TestServiceManager_ServiceManagement(t *testing.T) {
- manager := NewMockServiceManager()
- ctx := context.Background()
-
- // Initially no services
- services := manager.ListServices()
- if len(services) != 0 {
- t.Errorf("Expected 0 initial services, got %d", len(services))
- }
-
- // Register services
- service1 := NewMockService("svc1", "Service 1")
- service2 := NewMockService("svc2", "Service 2")
-
- err := manager.RegisterService("service1", service1)
- if err != nil {
- t.Errorf("Failed to register service1: %v", err)
- }
-
- err = manager.RegisterService("service2", service2)
- if err != nil {
- t.Errorf("Failed to register service2: %v", err)
- }
-
- // Check services are registered
- services = manager.ListServices()
- if len(services) != 2 {
- t.Errorf("Expected 2 services after registration, got %d", len(services))
- }
-
- // Get specific service
- retrievedService, err := manager.GetService("service1")
- if err != nil {
- t.Errorf("Failed to get service1: %v", err)
- }
-
- if retrievedService.GetInfo().ID != "svc1" {
- t.Errorf("Expected retrieved service ID to be 'svc1', got %s", retrievedService.GetInfo().ID)
- }
-
- // Get non-existent service
- _, err = manager.GetService("nonexistent")
- if err == nil {
- t.Error("Expected error when getting non-existent service")
- }
-
- // Start all services
- err = manager.StartAll(ctx)
- if err != nil {
- t.Errorf("Failed to start all services: %v", err)
- }
-
- // Check services are running
- if !service1.IsRunning() {
- t.Error("Expected service1 to be running")
- }
- if !service2.IsRunning() {
- t.Error("Expected service2 to be running")
- }
-
- // Stop all services
- err = manager.StopAll(ctx)
- if err != nil {
- t.Errorf("Failed to stop all services: %v", err)
- }
-
- // Check services are stopped
- if service1.IsRunning() {
- t.Error("Expected service1 to be stopped")
- }
- if service2.IsRunning() {
- t.Error("Expected service2 to be stopped")
- }
-
- // Restart all services
- err = manager.RestartAll(ctx)
- if err != nil {
- t.Errorf("Failed to restart all services: %v", err)
- }
-
- // Check services are running again
- if !service1.IsRunning() {
- t.Error("Expected service1 to be running after restart")
- }
- if !service2.IsRunning() {
- t.Error("Expected service2 to be running after restart")
- }
-
- // Health check all
- healthMap := manager.HealthCheckAll()
- if len(healthMap) != 2 {
- t.Errorf("Expected 2 health checks, got %d", len(healthMap))
- }
-
- // Overall health
- overallHealth := manager.GetOverallHealth()
- if overallHealth == nil {
- t.Fatal("Expected non-nil overall health")
- }
-
- if overallHealth.Status != "healthy" {
- t.Errorf("Expected overall health to be 'healthy', got %s", overallHealth.Status)
- }
-
- // Unregister service
- err = manager.UnregisterService("service1")
- if err != nil {
- t.Errorf("Failed to unregister service1: %v", err)
- }
-
- services = manager.ListServices()
- if len(services) != 1 {
- t.Errorf("Expected 1 service after unregistration, got %d", len(services))
- }
-}
-
-func TestServiceManager_Events(t *testing.T) {
- manager := NewMockServiceManager()
-
- // Subscribe to events
- eventChan := manager.Subscribe()
- if eventChan == nil {
- t.Fatal("Expected non-nil event channel")
- }
-
- // Unsubscribe
- manager.Unsubscribe()
-
- // Check channel is closed
- select {
- case _, open := <-eventChan:
- if open {
- t.Error("Expected channel to be closed after unsubscribe")
- }
- case <-time.After(100 * time.Millisecond):
- t.Error("Channel should have been closed immediately")
- }
-}
-
-// Test template type constants
-func TestTemplateType_Constants(t *testing.T) {
- expectedTypes := map[string]string{
- TemplateTypeKickstart: "kickstart",
- TemplateTypePreseed: "preseed",
- TemplateTypeAutoYaST: "autoyast",
- TemplateTypeCloudInit: "cloud-init",
- TemplateTypeIPXE: "ipxe",
- }
-
- for constant, expected := range expectedTypes {
- if constant != expected {
- t.Errorf("Expected template type constant to be '%s', got '%s'", expected, constant)
- }
- }
-}
diff --git a/ipxe/ipxe_test.go b/ipxe/ipxe_test.go
index 9f249bc..d5e3b76 100644
--- a/ipxe/ipxe_test.go
+++ b/ipxe/ipxe_test.go
@@ -562,3 +562,104 @@ func TestTemplateStructure(t *testing.T) {
assert.Contains(t, result, ":exit")
assert.Contains(t, result, ":memtest")
}
+
+// Test menuID generates unique, sanitized IDs per OS+version
+func TestMenuID(t *testing.T) {
+ assert.Equal(t, "ubuntu-22-04", menuID("ubuntu", "22.04"))
+ assert.Equal(t, "centos-stream-9", menuID("centos", "stream-9"))
+
+ // Two versions of the same OS must not collide
+ id1 := menuID("ubuntu", "22.04")
+ id2 := menuID("ubuntu", "20.04")
+ assert.NotEqual(t, id1, id2)
+
+ // IDs must be safe for iPXE labels (no spaces or slashes)
+ assert.Equal(t, "my-os-1-0-beta", menuID("My OS", "1.0/beta"))
+
+ // Degenerate inputs still produce a non-empty ID
+ assert.NotEmpty(t, menuID("", ""))
+}
+
+// Test getDisplayName with empty OS does not panic
+func TestGetDisplayName_EmptyOS(t *testing.T) {
+ cfg := createTestConfig()
+ service := NewService(cfg, nil)
+
+ assert.NotPanics(t, func() {
+ assert.Equal(t, "22.04", service.getDisplayName("", "22.04"))
+ assert.Equal(t, "Unknown OS", service.getDisplayName("", ""))
+ })
+}
+
+// Test getKernelArgs uses the configured HTTP port
+func TestGetKernelArgs_ConfigPort(t *testing.T) {
+ cfg := createTestConfig()
+ cfg.HTTP.Port = "9090"
+ service := NewService(cfg, nil)
+
+ args := service.getKernelArgs("ubuntu", "192.168.1.100")
+ assert.Contains(t, args, "http://192.168.1.100:9090/ubuntu/")
+ assert.NotContains(t, args, ":8080")
+}
+
+// Test getServerIP honors the SERVER_IP environment override
+func TestGetServerIP_EnvOverride(t *testing.T) {
+ t.Setenv("SERVER_IP", "10.20.30.40")
+
+ cfg := createTestConfig()
+ service := NewService(cfg, nil)
+
+ ip, err := service.getServerIP()
+ assert.NoError(t, err)
+ assert.Equal(t, "10.20.30.40", ip)
+}
+
+// Test GenerateConfig gives each OS version a unique menu label
+func TestGenerateConfig_UniqueMenuIDs(t *testing.T) {
+ ctx := context.Background()
+ cfg := createTestConfig()
+ mockOSService := &MockOSImageService{}
+ service := NewService(cfg, mockOSService)
+
+ osImages := []*osimage.OSImage{
+ {ID: "ubuntu-22.04", OS: "ubuntu", Version: "22.04"},
+ {ID: "ubuntu-20.04", OS: "ubuntu", Version: "20.04"},
+ }
+ mockOSService.On("GetAllOSImages", ctx).Return(osImages, nil)
+
+ config, err := service.GenerateConfig(ctx)
+
+ assert.NoError(t, err)
+ assert.Contains(t, config, ":ubuntu-22-04")
+ assert.Contains(t, config, ":ubuntu-20-04")
+ assert.Contains(t, config, "item ubuntu-22-04 Ubuntu 22.04")
+ assert.Contains(t, config, "item ubuntu-20-04 Ubuntu 20.04")
+
+ mockOSService.AssertExpectations(t)
+}
+
+// Test getServerIP prefers the configured ServerIP when the env var is unset
+func TestGetServerIP_ConfigValue(t *testing.T) {
+ t.Setenv("SERVER_IP", "")
+
+ cfg := createTestConfig()
+ cfg.ServerIP = "10.99.99.99"
+ service := NewService(cfg, nil)
+
+ ip, err := service.getServerIP()
+ assert.NoError(t, err)
+ assert.Equal(t, "10.99.99.99", ip)
+}
+
+// Test the SERVER_IP env var wins over the configured ServerIP
+func TestGetServerIP_EnvBeatsConfig(t *testing.T) {
+ t.Setenv("SERVER_IP", "10.20.30.40")
+
+ cfg := createTestConfig()
+ cfg.ServerIP = "10.99.99.99"
+ service := NewService(cfg, nil)
+
+ ip, err := service.getServerIP()
+ assert.NoError(t, err)
+ assert.Equal(t, "10.20.30.40", ip)
+}
diff --git a/ipxe/service.go b/ipxe/service.go
index 297177e..a93bc14 100644
--- a/ipxe/service.go
+++ b/ipxe/service.go
@@ -64,7 +64,7 @@ func (s *Service) GenerateConfig(ctx context.Context) (string, error) {
var entries []OSImageEntry
for _, img := range osImages {
entry := OSImageEntry{
- ID: strings.ToLower(img.OS),
+ ID: menuID(img.OS, img.Version),
Name: strings.ToLower(img.OS),
DisplayName: s.getDisplayName(img.OS, img.Version),
KernelPath: fmt.Sprintf("%s/%s/%s", img.OS, img.Version, s.getKernelFilename(img.OS)),
@@ -86,8 +86,46 @@ func (s *Service) GenerateConfig(ctx context.Context) (string, error) {
return s.renderTemplate(data)
}
-// getServerIP attempts to detect the server's IP address
+// menuID builds a unique, iPXE-label-safe menu ID from OS name and version.
+// IDs are unique per OS+version so multiple versions of the same OS each get
+// their own menu entry; anything outside [a-z0-9] is replaced with "-".
+func menuID(os, version string) string {
+ sanitize := func(s string) string {
+ s = strings.ToLower(s)
+ var b strings.Builder
+ for _, r := range s {
+ if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
+ b.WriteRune(r)
+ } else {
+ b.WriteRune('-')
+ }
+ }
+ return strings.Trim(b.String(), "-")
+ }
+ id := sanitize(os)
+ if v := sanitize(version); v != "" {
+ id += "-" + v
+ }
+ if id == "" {
+ id = "os"
+ }
+ return id
+}
+
+// getServerIP attempts to detect the server's IP address.
+// The SERVER_IP environment variable, when set, takes precedence.
func (s *Service) getServerIP() (string, error) {
+ // Explicit SERVER_IP environment variable wins, then the configured
+ // value, then interface auto-detection.
+ if ip := strings.TrimSpace(os.Getenv("SERVER_IP")); ip != "" {
+ return ip, nil
+ }
+ if s.config != nil {
+ if ip := strings.TrimSpace(s.config.ServerIP); ip != "" {
+ return ip, nil
+ }
+ }
+
interfaces, err := net.Interfaces()
if err != nil {
return "", err
@@ -117,6 +155,12 @@ func (s *Service) getServerIP() (string, error) {
// getDisplayName returns a user-friendly display name for OS/version
func (s *Service) getDisplayName(os, version string) string {
+ if os == "" {
+ if version == "" {
+ return "Unknown OS"
+ }
+ return version
+ }
switch os {
case "ubuntu":
return fmt.Sprintf("Ubuntu %s", version)
@@ -145,13 +189,15 @@ func (s *Service) getInitrdFilename(os string) string {
return "initrd.img" // fallback
}
-// getKernelArgs returns appropriate kernel arguments for an OS
+// getKernelArgs returns appropriate kernel arguments for an OS.
+// The HTTP port comes from configuration, never a hardcoded value.
func (s *Service) getKernelArgs(os, serverIP string) string {
+ port := s.config.HTTP.Port
switch os {
case "ubuntu":
- return "boot=casper netboot=url fetch=http://" + serverIP + ":8080/ubuntu/ quiet splash"
+ return "boot=casper netboot=url fetch=http://" + serverIP + ":" + port + "/ubuntu/ quiet splash"
case "centos":
- return "inst.repo=http://" + serverIP + ":8080/centos/ quiet"
+ return "inst.repo=http://" + serverIP + ":" + port + "/centos/ quiet"
case "nixos":
return "init=/nix/store/.../init boot.shell_on_fail console=ttyS0"
default:
diff --git a/main.go b/main.go
old mode 100755
new mode 100644
index 4689a19..d252299
--- a/main.go
+++ b/main.go
@@ -3,7 +3,7 @@ package main
import (
"embed"
"ignite/app"
- "ignite/testdata"
+ "ignite/cli"
"log"
)
@@ -14,7 +14,7 @@ var staticFS embed.FS
func main() {
// Parse command line flags
- config := testdata.ParseFlags()
+ config := cli.ParseFlags()
// Create application with embedded static files
application, err := app.NewApplicationWithStatic(staticFS)
@@ -23,7 +23,7 @@ func main() {
}
// Handle any CLI data operations (mock data, clear data, etc.)
- if !testdata.HandleDataOperations(config, application) {
+ if !cli.HandleDataOperations(config, application) {
return // Exit if only data operations were requested
}
diff --git a/main_test.go b/main_test.go
index 507a8e9..730589a 100644
--- a/main_test.go
+++ b/main_test.go
@@ -5,7 +5,7 @@ import (
"embed"
"flag"
"ignite/app"
- "ignite/testdata"
+ "ignite/cli"
"log"
"os"
"testing"
@@ -43,7 +43,7 @@ func TestMainIntegration(t *testing.T) {
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
os.Args = []string{"ignite"}
- config := testdata.ParseFlags()
+ config := cli.ParseFlags()
assert.NotNil(t, config)
// Test that app package is accessible (compilation test)
@@ -60,12 +60,12 @@ func TestCLIFlags(t *testing.T) {
tests := []struct {
name string
args []string
- expected testdata.Config
+ expected cli.Config
}{
{
name: "no flags",
args: []string{"ignite"},
- expected: testdata.Config{
+ expected: cli.Config{
MockData: false,
ClearData: false,
},
@@ -73,7 +73,7 @@ func TestCLIFlags(t *testing.T) {
{
name: "mock data flag",
args: []string{"ignite", "-mock-data"},
- expected: testdata.Config{
+ expected: cli.Config{
MockData: true,
ClearData: false,
},
@@ -81,7 +81,7 @@ func TestCLIFlags(t *testing.T) {
{
name: "clear data flag",
args: []string{"ignite", "-clear-data"},
- expected: testdata.Config{
+ expected: cli.Config{
MockData: false,
ClearData: true,
},
@@ -89,7 +89,7 @@ func TestCLIFlags(t *testing.T) {
{
name: "both flags",
args: []string{"ignite", "-mock-data", "-clear-data"},
- expected: testdata.Config{
+ expected: cli.Config{
MockData: true,
ClearData: true,
},
@@ -105,7 +105,7 @@ func TestCLIFlags(t *testing.T) {
os.Args = test.args
// Parse flags
- config := testdata.ParseFlags()
+ config := cli.ParseFlags()
assert.Equal(t, test.expected.MockData, config.MockData)
assert.Equal(t, test.expected.ClearData, config.ClearData)
@@ -121,14 +121,14 @@ func TestDataOperationsHandling(t *testing.T) {
tests := []struct {
name string
- config testdata.Config
+ config cli.Config
additionalArgs []string
shouldContinue bool
description string
}{
{
name: "no data operations",
- config: testdata.Config{
+ config: cli.Config{
MockData: false,
ClearData: false,
},
@@ -220,7 +220,7 @@ func TestMainFunctionBehavior(t *testing.T) {
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
os.Args = test.args
- config := testdata.ParseFlags()
+ config := cli.ParseFlags()
assert.NotNil(t, config)
// Verify flags are parsed correctly
@@ -253,7 +253,7 @@ func TestCompleteIntegrationFlow(t *testing.T) {
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
os.Args = []string{"ignite"}
- config := testdata.ParseFlags()
+ config := cli.ParseFlags()
assert.NotNil(t, config)
assert.False(t, config.MockData)
assert.False(t, config.ClearData)
@@ -262,7 +262,7 @@ func TestCompleteIntegrationFlow(t *testing.T) {
// without needing a real application
if !config.MockData && !config.ClearData {
// This should return true without needing an application
- // We'll test this logic by examining the testdata.HandleDataOperations function
+ // We'll test this logic by examining the cli.HandleDataOperations function
assert.False(t, config.MockData)
assert.False(t, config.ClearData)
}
@@ -298,8 +298,8 @@ func TestPackageLevelFunctionality(t *testing.T) {
// Test that the app package is importable
assert.NotNil(t, &app.Application{})
- // Test that testdata package is importable
- assert.NotNil(t, &testdata.Config{})
+ // Test that cli package is importable
+ assert.NotNil(t, &cli.Config{})
}
// Benchmark flag parsing
@@ -311,7 +311,7 @@ func BenchmarkFlagParsing(b *testing.B) {
flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError)
os.Args = []string{"ignite", "-mock-data"}
- config := testdata.ParseFlags()
+ config := cli.ParseFlags()
_ = config // Use the result
}
}
@@ -339,7 +339,7 @@ func TestCLIEdgeCases(t *testing.T) {
os.Args = []string{"ignite", "-unknown-flag"}
// This should handle unknown flags gracefully
- config := testdata.ParseFlags()
+ config := cli.ParseFlags()
assert.NotNil(t, config)
},
},
@@ -361,7 +361,7 @@ func TestCLIEdgeCases(t *testing.T) {
// This would normally print help and exit
// We'll just test that the flag parsing doesn't crash
assert.NotPanics(t, func() {
- testdata.ParseFlags()
+ cli.ParseFlags()
})
},
},
diff --git a/osimage/models.go b/osimage/models.go
index 0193b66..6fd76f7 100644
--- a/osimage/models.go
+++ b/osimage/models.go
@@ -2,6 +2,8 @@ package osimage
import (
"time"
+
+ "ignite/dlstatus"
)
// OSImage represents a bootable OS kernel and initrd combination
@@ -14,7 +16,7 @@ type OSImage struct {
InitrdPath string `json:"initrd_path"` // ubuntu/22.04/initrd.img
KernelSize int64 `json:"kernel_size"` // Size in bytes
InitrdSize int64 `json:"initrd_size"` // Size in bytes
- Checksum string `json:"checksum"` // SHA256 verification
+ Checksum string `json:"checksum"` // SHA256 fingerprints of the downloaded files (kernel:initrd); verified against config only when expected checksums are provided
Active bool `json:"active"` // Default version for OS
DownloadURL string `json:"download_url"` // Original download URL
CreatedAt time.Time `json:"created_at"`
@@ -29,14 +31,6 @@ type OSImageConfig struct {
Source string `json:"source"` // Download URL
}
-// DownloadStatus represents the status of an OS image download
-type DownloadStatus struct {
- ID string `json:"id"`
- OS string `json:"os"`
- Version string `json:"version"`
- Status string `json:"status"` // downloading, completed, failed
- Progress int `json:"progress"` // Percentage 0-100
- ErrorMessage string `json:"error_message,omitempty"`
- StartedAt time.Time `json:"started_at"`
- CompletedAt *time.Time `json:"completed_at,omitempty"`
-}
+// DownloadStatus is the shared download-status type (see ignite/dlstatus).
+// It is aliased so existing references keep compiling.
+type DownloadStatus = dlstatus.DownloadStatus
diff --git a/osimage/osimage_test.go b/osimage/osimage_test.go
index 75a4cac..d544514 100644
--- a/osimage/osimage_test.go
+++ b/osimage/osimage_test.go
@@ -7,6 +7,7 @@ import (
"time"
"ignite/config"
+ "ignite/dlstatus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
@@ -142,13 +143,13 @@ func createTestConfig() *config.Config {
}
// Helper function to create a service without starting the background worker
-func createTestService(mockRepo *MockOSImageRepository, mockDownloadRepo *MockDownloadStatusRepository, config *config.Config) *OSImageServiceImpl {
- service := &OSImageServiceImpl{
- repo: mockRepo,
- downloadRepo: mockDownloadRepo,
- config: config,
- downloadChan: make(chan OSImageConfig, 10),
- activeDownloads: make(map[string]*DownloadStatus),
+func createTestService(mockRepo *MockOSImageRepository, mockDownloadRepo *MockDownloadStatusRepository, config *config.Config) *osImageServiceImpl {
+ service := &osImageServiceImpl{
+ repo: mockRepo,
+ downloadRepo: mockDownloadRepo,
+ config: config,
+ downloadChan: make(chan downloadRequest, 10),
+ cancels: make(map[string]context.CancelFunc),
}
// Don't start the background worker for tests
return service
@@ -169,13 +170,14 @@ func TestNewOSImageService(t *testing.T) {
assert.NotNil(t, service)
// Cast to implementation to verify internal structure
- impl, ok := service.(*OSImageServiceImpl)
+ impl, ok := service.(*osImageServiceImpl)
assert.True(t, ok)
assert.Equal(t, mockRepo, impl.repo)
assert.Equal(t, mockDownloadRepo, impl.downloadRepo)
assert.Equal(t, config, impl.config)
assert.NotNil(t, impl.downloadChan)
- assert.NotNil(t, impl.activeDownloads)
+ assert.NotNil(t, impl.cancels)
+ assert.NotNil(t, impl.httpClient)
}
// Test GetAllOSImages
@@ -350,8 +352,8 @@ func TestDownloadOSImage_Valid(t *testing.T) {
// Mock that the OS/version doesn't exist yet
mockRepo.On("GetByOSAndVersion", ctx, osConfig.OS, osConfig.Version).Return(nil, errors.New("not found"))
- // Mock saving the download status twice (queued, then downloading)
- mockDownloadRepo.On("Save", ctx, mock.AnythingOfType("*osimage.DownloadStatus")).Return(nil).Twice()
+ // Mock saving the queued download status once
+ mockDownloadRepo.On("Save", ctx, mock.AnythingOfType("*dlstatus.DownloadStatus")).Return(nil).Once()
status, err := service.DownloadOSImage(ctx, osConfig)
@@ -359,7 +361,7 @@ func TestDownloadOSImage_Valid(t *testing.T) {
assert.NotNil(t, status)
assert.Equal(t, osConfig.OS, status.OS)
assert.Equal(t, osConfig.Version, status.Version)
- assert.Equal(t, "downloading", status.Status)
+ assert.Equal(t, dlstatus.StatusQueued, status.Status)
assert.False(t, status.StartedAt.IsZero())
mockRepo.AssertExpectations(t)
@@ -471,7 +473,7 @@ func TestGetDownloadStatus(t *testing.T) {
ID: id,
OS: "ubuntu",
Version: "22.04",
- Status: "downloading",
+ Status: dlstatus.StatusDownloading,
Progress: 50,
StartedAt: time.Now(),
}
@@ -496,13 +498,13 @@ func TestGetActiveDownloads(t *testing.T) {
expectedDownloads := []*DownloadStatus{
{
ID: "download-1",
- Status: "downloading",
+ Status: dlstatus.StatusDownloading,
OS: "ubuntu",
Version: "22.04",
},
{
ID: "download-2",
- Status: "queued",
+ Status: dlstatus.StatusQueued,
OS: "centos",
Version: "8",
},
@@ -530,7 +532,7 @@ func TestCancelDownload(t *testing.T) {
ID: id,
OS: "ubuntu",
Version: "22.04",
- Status: "downloading",
+ Status: dlstatus.StatusDownloading,
Progress: 30,
StartedAt: time.Now(),
}
@@ -538,7 +540,7 @@ func TestCancelDownload(t *testing.T) {
mockDownloadRepo.On("Get", ctx, id).Return(downloadStatus, nil)
mockDownloadRepo.On("Save", ctx, mock.MatchedBy(func(status *DownloadStatus) bool {
return status.ID == id &&
- status.Status == "cancelled" &&
+ status.Status == dlstatus.StatusCancelled &&
status.Progress == 0 &&
status.ErrorMessage == "Download cancelled by user" &&
status.CompletedAt != nil
@@ -561,7 +563,7 @@ func TestCancelDownload_InvalidStatus(t *testing.T) {
id := "download-id"
downloadStatus := &DownloadStatus{
ID: id,
- Status: "completed", // Cannot cancel completed download
+ Status: dlstatus.StatusCompleted, // Cannot cancel completed download
}
mockDownloadRepo.On("Get", ctx, id).Return(downloadStatus, nil)
@@ -623,7 +625,7 @@ func TestDownloadStatus(t *testing.T) {
ID: "download-123",
OS: "ubuntu",
Version: "22.04",
- Status: "completed",
+ Status: dlstatus.StatusCompleted,
Progress: 100,
ErrorMessage: "",
StartedAt: now,
@@ -632,9 +634,103 @@ func TestDownloadStatus(t *testing.T) {
assert.Equal(t, "download-123", status.ID)
assert.Equal(t, "ubuntu", status.OS)
- assert.Equal(t, "completed", status.Status)
+ assert.Equal(t, dlstatus.StatusCompleted, status.Status)
assert.Equal(t, 100, status.Progress)
assert.Empty(t, status.ErrorMessage)
assert.NotNil(t, status.CompletedAt)
assert.True(t, status.CompletedAt.After(status.StartedAt))
}
+
+// Test DownloadOSImage when the worker queue is full
+func TestDownloadOSImage_QueueFull(t *testing.T) {
+ mockRepo := &MockOSImageRepository{}
+ mockDownloadRepo := &MockDownloadStatusRepository{}
+ config := createTestConfig()
+ service := createTestService(mockRepo, mockDownloadRepo, config)
+
+ ctx := context.Background()
+ // Fill the download queue
+ for i := 0; i < 10; i++ {
+ service.downloadChan <- downloadRequest{}
+ }
+
+ osConfig := OSImageConfig{
+ OS: "ubuntu",
+ Version: "22.04",
+ Architecture: "x86_64",
+ }
+
+ mockRepo.On("GetByOSAndVersion", ctx, osConfig.OS, osConfig.Version).Return(nil, errors.New("not found"))
+ // The queued status is persisted before the enqueue attempt...
+ mockDownloadRepo.On("Save", ctx, mock.MatchedBy(func(status *DownloadStatus) bool {
+ return status.Status == dlstatus.StatusQueued
+ })).Return(nil).Once()
+ // ...and then flipped to failed when the queue turns out to be full
+ mockDownloadRepo.On("Save", ctx, mock.MatchedBy(func(status *DownloadStatus) bool {
+ return status.Status == dlstatus.StatusFailed &&
+ status.ErrorMessage == "download queue is full, try again later" &&
+ status.CompletedAt != nil
+ })).Return(nil).Once()
+
+ status, err := service.DownloadOSImage(ctx, osConfig)
+
+ assert.Error(t, err)
+ assert.Nil(t, status)
+ assert.Contains(t, err.Error(), "download queue is full")
+ mockRepo.AssertExpectations(t)
+ mockDownloadRepo.AssertExpectations(t)
+}
+
+// Test that CancelDownload signals the worker via the download's context
+func TestCancelDownload_SignalsWorker(t *testing.T) {
+ mockRepo := &MockOSImageRepository{}
+ mockDownloadRepo := &MockDownloadStatusRepository{}
+ config := createTestConfig()
+ service := createTestService(mockRepo, mockDownloadRepo, config)
+
+ ctx := context.Background()
+ id := "download-id"
+
+ dlCtx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ service.mu.Lock()
+ service.cancels[id] = cancel
+ service.mu.Unlock()
+
+ downloadStatus := &DownloadStatus{
+ ID: id,
+ OS: "ubuntu",
+ Version: "22.04",
+ Status: dlstatus.StatusDownloading,
+ Progress: 30,
+ StartedAt: time.Now(),
+ }
+
+ mockDownloadRepo.On("Get", ctx, id).Return(downloadStatus, nil)
+ mockDownloadRepo.On("Save", ctx, mock.AnythingOfType("*dlstatus.DownloadStatus")).Return(nil)
+
+ err := service.CancelDownload(ctx, id)
+
+ assert.NoError(t, err)
+ assert.Error(t, dlCtx.Err(), "worker context should have been cancelled")
+ assert.Equal(t, dlstatus.StatusCancelled, downloadStatus.Status)
+
+ service.mu.Lock()
+ _, stillTracked := service.cancels[id]
+ service.mu.Unlock()
+ assert.False(t, stillTracked, "cancel func should be removed after CancelDownload")
+
+ mockDownloadRepo.AssertExpectations(t)
+}
+
+// Test numeric version comparison
+func TestCompareVersions(t *testing.T) {
+ assert.Equal(t, -1, compareVersions("9", "10"))
+ assert.Equal(t, 1, compareVersions("10", "9"))
+ assert.Equal(t, 0, compareVersions("22.04", "22.04"))
+ assert.Equal(t, -1, compareVersions("20.04", "22.04"))
+ assert.Equal(t, 1, compareVersions("22.04", "20.04"))
+ assert.Equal(t, -1, compareVersions("6.03", "6.10"))
+ assert.Equal(t, 1, compareVersions("6.10", "6.03"))
+ assert.Equal(t, -1, compareVersions("6.03", "6.03.1"))
+}
diff --git a/osimage/repository.go b/osimage/repository.go
index 13d2313..f89c5e6 100644
--- a/osimage/repository.go
+++ b/osimage/repository.go
@@ -2,6 +2,7 @@ package osimage
import (
"context"
+ "encoding/json"
"fmt"
"ignite/db"
"time"
@@ -12,12 +13,14 @@ import (
// OSImageRepositoryImpl implements OSImageRepository using BoltDB
type OSImageRepositoryImpl struct {
*db.GenericRepository[OSImage]
+ database db.Database
}
// NewOSImageRepository creates a new OS image repository
func NewOSImageRepository(database db.Database) OSImageRepository {
return &OSImageRepositoryImpl{
GenericRepository: db.NewGenericRepository[OSImage](database, "osimages"),
+ database: database,
}
}
@@ -115,7 +118,8 @@ func (r *OSImageRepositoryImpl) Delete(ctx context.Context, id string) error {
return r.GenericRepository.Delete(ctx, id)
}
-// SetDefault sets an OS image as the default for its OS type
+// SetDefault sets an OS image as the default for its OS type.
+// The unset-old/set-new update is applied atomically in a single batch.
func (r *OSImageRepositoryImpl) SetDefault(ctx context.Context, id string) error {
// Get the image to be set as default
image, err := r.Get(ctx, id)
@@ -129,18 +133,53 @@ func (r *OSImageRepositoryImpl) SetDefault(ctx context.Context, id string) error
return err
}
+ now := time.Now()
+ updates := make(map[string]OSImage, len(allImages)+1)
for _, img := range allImages {
if img.Active {
- img.Active = false
- if err := r.Save(ctx, img); err != nil {
- return err
- }
+ updated := *img
+ updated.Active = false
+ updated.UpdatedAt = now
+ updates[updated.ID] = updated
}
}
// Set the target image as default
image.Active = true
- return r.Save(ctx, image)
+ image.UpdatedAt = now
+ updates[image.ID] = *image
+
+ return r.saveManyOSImages(ctx, updates)
+}
+
+// batchPutter is implemented by *db.BoltDB (see db/bolt.go). Repositories
+// assert it via this local interface so they keep depending only on
+// db.Database while still getting atomic batch writes when available.
+type batchPutter interface {
+ BatchPut(ctx context.Context, bucket string, kvs map[string][]byte) error
+}
+
+// saveManyOSImages stores multiple OS image entities in one atomic batch when
+// the underlying database supports it, and falls back to sequential writes
+// otherwise.
+func (r *OSImageRepositoryImpl) saveManyOSImages(ctx context.Context, entities map[string]OSImage) error {
+ kvs := make(map[string][]byte, len(entities))
+ for key, entity := range entities {
+ data, err := json.Marshal(entity)
+ if err != nil {
+ return fmt.Errorf("failed to marshal OS image %q: %w", key, err)
+ }
+ kvs[key] = data
+ }
+ if bp, ok := r.database.(batchPutter); ok {
+ return bp.BatchPut(ctx, "osimages", kvs)
+ }
+ for key, data := range kvs {
+ if err := r.database.PutKV(ctx, "osimages", []byte(key), data); err != nil {
+ return fmt.Errorf("failed to save OS image %q: %w", key, err)
+ }
+ }
+ return nil
}
// DownloadStatusRepositoryImpl implements DownloadStatusRepository using BoltDB
@@ -186,7 +225,7 @@ func (r *DownloadStatusRepositoryImpl) GetActive(ctx context.Context) ([]*Downlo
var active []*DownloadStatus
for _, status := range allStatusMap {
- if status.Status == "downloading" || status.Status == "queued" {
+ if status.Status.Active() {
statusCopy := status
active = append(active, &statusCopy)
}
diff --git a/osimage/service.go b/osimage/service.go
index 06a7710..7082e75 100644
--- a/osimage/service.go
+++ b/osimage/service.go
@@ -3,37 +3,61 @@ package osimage
import (
"context"
"crypto/sha256"
+ "encoding/hex"
+ "errors"
"fmt"
"ignite/config"
+ "ignite/dlstatus"
"io"
"log"
"net/http"
"os"
"path/filepath"
"sort"
+ "strconv"
"strings"
+ "sync"
"time"
"github.com/google/uuid"
)
-// OSImageServiceImpl provides business logic for OS image management
-type OSImageServiceImpl struct {
- repo OSImageRepository
- downloadRepo DownloadStatusRepository
- config *config.Config
- downloadChan chan OSImageConfig
- activeDownloads map[string]*DownloadStatus
+// errDownloadTerminal is returned when a worker tries to persist progress for
+// a download whose stored row has already reached a terminal state.
+var errDownloadTerminal = errors.New("download already in terminal state")
+
+// downloadRequest is a unit of work for the background download worker.
+type downloadRequest struct {
+ ctx context.Context
+ id string
+ osConfig OSImageConfig
+ versionInfo config.OSVersion
+}
+
+// osImageServiceImpl provides business logic for OS image management.
+// The struct is unexported; callers use the OSImageService interface.
+type osImageServiceImpl struct {
+ repo OSImageRepository
+ downloadRepo DownloadStatusRepository
+ config *config.Config
+ httpClient *http.Client
+ downloadChan chan downloadRequest
+
+ mu sync.Mutex
+ cancels map[string]context.CancelFunc // download ID -> cancel func
}
// NewOSImageService creates a new OS image service
func NewOSImageService(repo OSImageRepository, downloadRepo DownloadStatusRepository, cfg *config.Config) OSImageService {
- service := &OSImageServiceImpl{
- repo: repo,
- downloadRepo: downloadRepo,
- config: cfg,
- downloadChan: make(chan OSImageConfig, 10),
- activeDownloads: make(map[string]*DownloadStatus),
+ service := &osImageServiceImpl{
+ repo: repo,
+ downloadRepo: downloadRepo,
+ config: cfg,
+ httpClient: &http.Client{
+ Timeout: 30 * time.Minute,
+ },
+ downloadChan: make(chan downloadRequest, 10),
+ cancels: make(map[string]context.CancelFunc),
}
// Start background download worker
@@ -43,127 +67,149 @@ func NewOSImageService(repo OSImageRepository, downloadRepo DownloadStatusReposi
}
// GetAllOSImages retrieves all OS images
-func (s *OSImageServiceImpl) GetAllOSImages(ctx context.Context) ([]*OSImage, error) {
+func (s *osImageServiceImpl) GetAllOSImages(ctx context.Context) ([]*OSImage, error) {
return s.repo.GetAll(ctx)
}
// GetOSImagesByOS retrieves all images for a specific operating system
-func (s *OSImageServiceImpl) GetOSImagesByOS(ctx context.Context, os string) ([]*OSImage, error) {
- return s.repo.GetByOS(ctx, os)
+func (s *osImageServiceImpl) GetOSImagesByOS(ctx context.Context, osName string) ([]*OSImage, error) {
+ return s.repo.GetByOS(ctx, osName)
}
// GetOSImage retrieves an OS image by ID
-func (s *OSImageServiceImpl) GetOSImage(ctx context.Context, id string) (*OSImage, error) {
+func (s *osImageServiceImpl) GetOSImage(ctx context.Context, id string) (*OSImage, error) {
return s.repo.Get(ctx, id)
}
// GetDefaultVersion retrieves the default version for an OS
-func (s *OSImageServiceImpl) GetDefaultVersion(ctx context.Context, os string) (*OSImage, error) {
- return s.repo.GetDefaultVersion(ctx, os)
+func (s *osImageServiceImpl) GetDefaultVersion(ctx context.Context, osName string) (*OSImage, error) {
+ return s.repo.GetDefaultVersion(ctx, osName)
}
-// SetDefaultVersion sets an OS image as the default for its OS type
-func (s *OSImageServiceImpl) SetDefaultVersion(ctx context.Context, id string) error {
+// SetDefaultVersion sets the default version for an OS
+func (s *osImageServiceImpl) SetDefaultVersion(ctx context.Context, id string) error {
return s.repo.SetDefault(ctx, id)
}
-// DeleteOSImage removes an OS image and its files
-func (s *OSImageServiceImpl) DeleteOSImage(ctx context.Context, id string) error {
- // Get the image to find file paths
- image, err := s.repo.Get(ctx, id)
- if err != nil {
- return err
- }
-
- // Remove files from filesystem
- kernelPath := filepath.Join(s.config.TFTP.Dir, image.KernelPath)
- initrdPath := filepath.Join(s.config.TFTP.Dir, image.InitrdPath)
+// DeleteOSImage deletes an OS image
+func (s *osImageServiceImpl) DeleteOSImage(ctx context.Context, id string) error {
+ return s.repo.Delete(ctx, id)
+}
- if err := os.Remove(kernelPath); err != nil && !os.IsNotExist(err) {
- return fmt.Errorf("failed to remove kernel file: %w", err)
+// GetDownloadStatus retrieves the status of a download.
+// The returned struct is a copy; the worker never shares its live struct.
+func (s *osImageServiceImpl) GetDownloadStatus(ctx context.Context, id string) (*DownloadStatus, error) {
+ status, err := s.downloadRepo.Get(ctx, id)
+ if err != nil {
+ return nil, err
}
+ statusCopy := *status
+ return &statusCopy, nil
+}
- if err := os.Remove(initrdPath); err != nil && !os.IsNotExist(err) {
- return fmt.Errorf("failed to remove initrd file: %w", err)
- }
+// GetActiveDownloads retrieves all active downloads
+func (s *osImageServiceImpl) GetActiveDownloads(ctx context.Context) ([]*DownloadStatus, error) {
+ return s.downloadRepo.GetActive(ctx)
+}
- // Remove from database
- return s.repo.Delete(ctx, id)
+// removeCancel drops the cancel func for a download ID. It is called when a
+// download finishes, fails, is cancelled, or is dequeued by the worker.
+func (s *osImageServiceImpl) removeCancel(id string) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ delete(s.cancels, id)
}
-// DownloadOSImage starts downloading an OS image
-func (s *OSImageServiceImpl) DownloadOSImage(ctx context.Context, osConfig OSImageConfig) (*DownloadStatus, error) {
- // Validate OS and version
+// DownloadOSImage initiates a download of an OS image.
+// It returns a copy of the queued status; the worker mutates only its own
+// re-read copy afterwards.
+func (s *osImageServiceImpl) DownloadOSImage(ctx context.Context, osConfig OSImageConfig) (*DownloadStatus, error) {
+ // Validate OS/version combination
if !s.isValidOSVersion(osConfig.OS, osConfig.Version) {
return nil, fmt.Errorf("unsupported OS/version combination: %s %s", osConfig.OS, osConfig.Version)
}
- // Check if already exists
+ // Check if image already exists
existing, err := s.repo.GetByOSAndVersion(ctx, osConfig.OS, osConfig.Version)
if err == nil && existing != nil {
return nil, fmt.Errorf("OS image already exists: %s %s", osConfig.OS, osConfig.Version)
}
- // Create download status
+ versionInfo := s.config.OSImages.Sources[osConfig.OS].Versions[osConfig.Version]
+
status := &DownloadStatus{
ID: uuid.New().String(),
OS: osConfig.OS,
Version: osConfig.Version,
- Status: "queued",
+ Status: dlstatus.StatusQueued,
Progress: 0,
StartedAt: time.Now(),
}
- if err := s.downloadRepo.Save(ctx, status); err != nil {
- return nil, err
+ // Each download gets its own cancellable context, tracked by download ID.
+ dlCtx, cancel := context.WithCancel(context.Background())
+ s.mu.Lock()
+ s.cancels[status.ID] = cancel
+ s.mu.Unlock()
+
+ req := downloadRequest{
+ ctx: dlCtx,
+ id: status.ID,
+ osConfig: osConfig,
+ versionInfo: versionInfo,
}
- // Update status to downloading BEFORE queuing to prevent race condition
- status.Status = "downloading"
+ // Persist the queued status BEFORE enqueueing so the worker can never
+ // observe a request without its row.
if err := s.downloadRepo.Save(ctx, status); err != nil {
- return nil, err
+ cancel()
+ s.removeCancel(status.ID)
+ return nil, fmt.Errorf("failed to save download status: %w", err)
}
- // Queue for download
select {
- case s.downloadChan <- osConfig:
- // Successfully queued
+ case s.downloadChan <- req:
default:
- status.Status = "failed"
- status.ErrorMessage = "download queue is full"
- s.downloadRepo.Save(ctx, status)
- return status, fmt.Errorf("download queue is full")
+ cancel()
+ s.removeCancel(status.ID)
+ status.Status = dlstatus.StatusFailed
+ status.ErrorMessage = "download queue is full, try again later"
+ now := time.Now()
+ status.CompletedAt = &now
+ if err := s.downloadRepo.Save(ctx, status); err != nil {
+ return nil, fmt.Errorf("download queue is full (failed to record status: %w)", err)
+ }
+ return nil, fmt.Errorf("download queue is full, try again later")
}
- return status, nil
+ // Return a copy so HTTP callers never observe worker-mutated state.
+ statusCopy := *status
+ return &statusCopy, nil
}
-// GetDownloadStatus retrieves the status of a download
-func (s *OSImageServiceImpl) GetDownloadStatus(ctx context.Context, id string) (*DownloadStatus, error) {
- return s.downloadRepo.Get(ctx, id)
-}
-
-// GetActiveDownloads retrieves all active downloads
-func (s *OSImageServiceImpl) GetActiveDownloads(ctx context.Context) ([]*DownloadStatus, error) {
- return s.downloadRepo.GetActive(ctx)
-}
+// CancelDownload cancels an active download. It signals the worker via the
+// download's context and marks the row cancelled in the same critical
+// section used by worker saves, so the two can never overwrite each other.
+func (s *osImageServiceImpl) CancelDownload(ctx context.Context, id string) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
-// CancelDownload cancels an active download and cleans up partial files
-func (s *OSImageServiceImpl) CancelDownload(ctx context.Context, id string) error {
- // Get the download status
status, err := s.downloadRepo.Get(ctx, id)
if err != nil {
- return fmt.Errorf("failed to get download status: %w", err)
+ return fmt.Errorf("download not found: %s", id)
+ }
+
+ if !status.Status.Active() {
+ return fmt.Errorf("cannot cancel download with status %s", status.Status)
}
- // Only allow canceling downloads that are still in progress
- if status.Status != "downloading" && status.Status != "queued" {
- return fmt.Errorf("cannot cancel download with status: %s", status.Status)
+ if cancel, ok := s.cancels[id]; ok {
+ delete(s.cancels, id)
+ cancel()
}
- // Mark as cancelled
now := time.Now()
- status.Status = "cancelled"
+ status.Status = dlstatus.StatusCancelled
status.Progress = 0
status.ErrorMessage = "Download cancelled by user"
status.CompletedAt = &now
@@ -171,206 +217,242 @@ func (s *OSImageServiceImpl) CancelDownload(ctx context.Context, id string) erro
if err := s.downloadRepo.Save(ctx, status); err != nil {
return fmt.Errorf("failed to save cancelled status: %w", err)
}
-
- // Clean up any partially downloaded files
- osDir := filepath.Join(s.config.TFTP.Dir, status.OS, status.Version)
- kernelPath := filepath.Join(osDir, "vmlinuz")
- initrdPath := filepath.Join(osDir, "initrd.img")
-
- // Remove partial files (ignore errors as files might not exist)
- os.Remove(kernelPath)
- os.Remove(initrdPath)
-
- // Try to remove the directory if it's empty
- os.Remove(osDir)
-
return nil
}
-// isValidOSVersion checks if the OS and version combination is supported
-func (s *OSImageServiceImpl) isValidOSVersion(os, version string) bool {
- osDef, exists := s.config.OSImages.Sources[os]
- if !exists {
- return false
+// downloadWorker processes download requests sequentially
+func (s *osImageServiceImpl) downloadWorker() {
+ for req := range s.downloadChan {
+ s.processDownload(req)
}
-
- _, versionExists := osDef.Versions[version]
- return versionExists
}
-// downloadWorker processes download requests in the background
-func (s *OSImageServiceImpl) downloadWorker() {
- for osConfig := range s.downloadChan {
- s.processDownload(osConfig)
+// saveStatusChecked persists status only if the stored row has not reached a
+// terminal state. It re-reads the row under the service mutex (the same
+// mutex CancelDownload uses), so a concurrent cancellation is never
+// overwritten with stale worker data.
+func (s *osImageServiceImpl) saveStatusChecked(ctx context.Context, status *DownloadStatus) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ current, err := s.downloadRepo.Get(ctx, status.ID)
+ if err != nil {
+ return err
}
+ if current.Status.Terminal() {
+ return errDownloadTerminal
+ }
+ return s.downloadRepo.Save(ctx, status)
}
-// processDownload handles the actual download process
-func (s *OSImageServiceImpl) processDownload(osConfig OSImageConfig) {
- ctx := context.Background()
-
- // Find download status
- var status *DownloadStatus
- downloads, err := s.downloadRepo.GetActive(ctx)
- if err != nil {
+// failDownload records a terminal failure for the download unless the row was
+// already cancelled or failed concurrently.
+func (s *osImageServiceImpl) failDownload(ctx context.Context, status *DownloadStatus, err error) {
+ status.Status = dlstatus.StatusFailed
+ status.ErrorMessage = err.Error()
+ now := time.Now()
+ status.CompletedAt = &now
+ if saveErr := s.saveStatusChecked(ctx, status); saveErr != nil {
+ log.Printf("Download %s failed (%v); could not persist failure: %v", status.ID, err, saveErr)
return
}
+ log.Printf("Download %s failed: %v", status.ID, err)
+}
- for _, d := range downloads {
- if d.OS == osConfig.OS && d.Version == osConfig.Version {
- status = d
- break
- }
- }
+// processDownload handles the actual download of OS image files
+func (s *osImageServiceImpl) processDownload(req downloadRequest) {
+ defer s.removeCancel(req.id)
- if status == nil {
- return // Status not found
- }
+ ctx := req.ctx
- // Get OS definition from config
- osDef, exists := s.config.OSImages.Sources[osConfig.OS]
- if !exists {
- s.markDownloadFailed(ctx, status, "unsupported OS")
+ // Re-read the row: the download may have been cancelled while queued.
+ status, err := s.downloadRepo.Get(ctx, req.id)
+ if err != nil {
+ log.Printf("Download %s: status not found, aborting: %v", req.id, err)
return
}
-
- // Get version info from config
- versionInfo, versionExists := osDef.Versions[osConfig.Version]
- if !versionExists {
- s.markDownloadFailed(ctx, status, "unsupported version for this OS")
+ if status.Status.Terminal() {
return
}
- baseURL := versionInfo.BaseURL
- if baseURL == "" {
- s.markDownloadFailed(ctx, status, "no download URL found for this version")
+ status.Status = dlstatus.StatusDownloading
+ status.Progress = 5
+ if err := s.saveStatusChecked(ctx, status); err != nil {
return
}
- // Create directory structure
- osDir := filepath.Join(s.config.TFTP.Dir, osConfig.OS, osConfig.Version)
- if err := os.MkdirAll(osDir, 0755); err != nil {
- s.markDownloadFailed(ctx, status, fmt.Sprintf("failed to create directory: %v", err))
- return
+ osConfig := req.osConfig
+ versionInfo := req.versionInfo
+
+ arch := osConfig.Architecture
+ if arch == "" {
+ if len(versionInfo.Architectures) > 0 {
+ arch = versionInfo.Architectures[0]
+ } else {
+ arch = "x86_64"
+ }
}
- // Get filenames from config
- kernelFile := osDef.KernelFile
- initrdFile := osDef.InitrdFile
+ baseURL := strings.TrimSuffix(versionInfo.BaseURL, "/")
+ kernelURL := fmt.Sprintf("%s/%s/vmlinuz", baseURL, arch)
+ initrdURL := fmt.Sprintf("%s/%s/initrd.img", baseURL, arch)
- kernelURL := strings.TrimSuffix(baseURL, "/") + "/" + kernelFile
- initrdURL := strings.TrimSuffix(baseURL, "/") + "/" + initrdFile
+ tftpDir := s.config.TFTP.Dir
+ downloadDir := filepath.Join(tftpDir, "os-images", osConfig.OS, osConfig.Version)
+ if err := os.MkdirAll(downloadDir, 0755); err != nil {
+ s.failDownload(ctx, status, fmt.Errorf("failed to create download directory: %w", err))
+ return
+ }
- kernelPath := filepath.Join(osDir, "vmlinuz")
- initrdPath := filepath.Join(osDir, "initrd.img")
+ kernelPath := filepath.Join(downloadDir, "vmlinuz")
+ initrdPath := filepath.Join(downloadDir, "initrd.img")
+
+ if err := ctx.Err(); err != nil {
+ return // cancelled; CancelDownload already marked the row
+ }
// Download kernel
- status.Progress = 10
- s.downloadRepo.Save(ctx, status)
+ kernelSize, kernelChecksum, err := s.downloadFile(ctx, kernelURL, kernelPath, versionInfo.ExpectedChecksum)
+ if err != nil {
+ s.failDownload(ctx, status, fmt.Errorf("failed to download kernel: %w", err))
+ return
+ }
- // Check if cancelled before starting kernel download
- if status, err := s.downloadRepo.Get(ctx, status.ID); err != nil || status.Status == "cancelled" {
+ status.Progress = 50
+ if err := s.saveStatusChecked(ctx, status); err != nil {
+ _ = os.Remove(kernelPath)
return
}
- kernelSize, kernelChecksum, err := s.downloadFile(kernelURL, kernelPath)
- if err != nil {
- s.markDownloadFailed(ctx, status, fmt.Sprintf("failed to download kernel: %v", err))
+ if err := ctx.Err(); err != nil {
+ _ = os.Remove(kernelPath)
return
}
// Download initrd
- status.Progress = 60
- s.downloadRepo.Save(ctx, status)
-
- // Check if cancelled before starting initrd download
- if status, err := s.downloadRepo.Get(ctx, status.ID); err != nil || status.Status == "cancelled" {
+ initrdSize, initrdChecksum, err := s.downloadFile(ctx, initrdURL, initrdPath, versionInfo.ExpectedChecksum)
+ if err != nil {
+ _ = os.Remove(kernelPath)
+ s.failDownload(ctx, status, fmt.Errorf("failed to download initrd: %w", err))
return
}
- initrdSize, initrdChecksum, err := s.downloadFile(initrdURL, initrdPath)
- if err != nil {
- s.markDownloadFailed(ctx, status, fmt.Sprintf("failed to download initrd: %v", err))
+ if err := ctx.Err(); err != nil {
+ _ = os.Remove(kernelPath)
+ _ = os.Remove(initrdPath)
return
}
// Create OS image record
- status.Progress = 90
- s.downloadRepo.Save(ctx, status)
-
- osImage := &OSImage{
+ now := time.Now()
+ image := &OSImage{
+ ID: uuid.New().String(),
OS: osConfig.OS,
Version: osConfig.Version,
- Architecture: "x86_64", // Default to x86_64
- KernelPath: fmt.Sprintf("%s/%s/vmlinuz", osConfig.OS, osConfig.Version),
- InitrdPath: fmt.Sprintf("%s/%s/initrd.img", osConfig.OS, osConfig.Version),
+ Architecture: arch,
+ KernelPath: kernelPath,
+ InitrdPath: initrdPath,
KernelSize: kernelSize,
InitrdSize: initrdSize,
Checksum: kernelChecksum + ":" + initrdChecksum,
- Active: false, // Don't automatically set as default
- DownloadURL: baseURL, // Store the base URL where files were downloaded from
+ Active: false,
+ DownloadURL: versionInfo.BaseURL,
+ CreatedAt: now,
+ UpdatedAt: now,
}
- if err := s.repo.Save(ctx, osImage); err != nil {
- s.markDownloadFailed(ctx, status, fmt.Sprintf("failed to save OS image: %v", err))
+ if err := s.repo.Save(ctx, image); err != nil {
+ _ = os.Remove(kernelPath)
+ _ = os.Remove(initrdPath)
+ s.failDownload(ctx, status, fmt.Errorf("failed to save OS image record: %w", err))
return
}
// Mark download as completed
- now := time.Now()
- status.Status = "completed"
+ status.Status = dlstatus.StatusCompleted
status.Progress = 100
- status.CompletedAt = &now
- s.downloadRepo.Save(ctx, status)
+ completed := time.Now()
+ status.CompletedAt = &completed
+ if err := s.saveStatusChecked(ctx, status); err != nil {
+ // The row went terminal concurrently (e.g. cancelled): roll back the
+ // image record and files rather than leaving an orphan.
+ _ = s.repo.Delete(ctx, image.ID)
+ _ = os.Remove(kernelPath)
+ _ = os.Remove(initrdPath)
+ return
+ }
+
+ log.Printf("Successfully downloaded OS image: %s %s", osConfig.OS, osConfig.Version)
}
-// downloadFile downloads a file and returns its size and checksum
-func (s *OSImageServiceImpl) downloadFile(url, filepath string) (int64, string, error) {
- resp, err := http.Get(url)
+// downloadFile downloads a file from URL to destPath. The data is streamed to
+// a temp file in the destination directory and renamed into place only on
+// success, so a failed or cancelled download never leaves a partial file at
+// destPath. It returns the size and SHA256 hex digest of the downloaded file.
+// When expectedSHA256 is non-empty, the digest must match or an error is
+// returned.
+func (s *osImageServiceImpl) downloadFile(ctx context.Context, url, destPath, expectedSHA256 string) (int64, string, error) {
+ if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil {
+ return 0, "", fmt.Errorf("failed to create destination directory: %w", err)
+ }
+
+ tmp, err := os.CreateTemp(filepath.Dir(destPath), ".download-*")
if err != nil {
- log.Printf("HTTP GET failed for %s: %v", url, err)
- return 0, "", err
+ return 0, "", fmt.Errorf("failed to create temp file: %w", err)
}
- defer resp.Body.Close()
+ tmpName := tmp.Name()
+ succeeded := false
+ defer func() {
+ if !succeeded {
+ _ = os.Remove(tmpName)
+ }
+ }()
- if resp.StatusCode != http.StatusOK {
- log.Printf("Download failed with status %s for %s", resp.Status, url)
- return 0, "", fmt.Errorf("download failed: %s", resp.Status)
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
+ if err != nil {
+ _ = tmp.Close()
+ return 0, "", fmt.Errorf("failed to create request: %w", err)
}
- file, err := os.Create(filepath)
+ resp, err := s.httpClient.Do(req)
if err != nil {
- return 0, "", err
+ _ = tmp.Close()
+ return 0, "", fmt.Errorf("failed to download %s: %w", url, err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ _ = tmp.Close()
+ return 0, "", fmt.Errorf("failed to download %s: HTTP %s", url, resp.Status)
}
- defer file.Close()
- // Create hash writer
hash := sha256.New()
-
- // Copy with hash calculation
- size, err := io.Copy(io.MultiWriter(file, hash), resp.Body)
+ size, err := io.Copy(io.MultiWriter(tmp, hash), resp.Body)
+ closeErr := tmp.Close()
if err != nil {
- return 0, "", err
+ return 0, "", fmt.Errorf("failed to write download %s: %w", url, err)
+ }
+ if closeErr != nil {
+ return 0, "", fmt.Errorf("failed to close temp file for %s: %w", url, closeErr)
}
- checksum := fmt.Sprintf("%x", hash.Sum(nil))
- return size, checksum, nil
-}
+ checksum := hex.EncodeToString(hash.Sum(nil))
+ if expectedSHA256 != "" && !strings.EqualFold(checksum, expectedSHA256) {
+ return 0, "", fmt.Errorf("checksum mismatch for %s: expected %s, got %s", url, expectedSHA256, checksum)
+ }
-// markDownloadFailed marks a download as failed with an error message
-func (s *OSImageServiceImpl) markDownloadFailed(ctx context.Context, status *DownloadStatus, errorMsg string) {
- status.Status = "failed"
- status.ErrorMessage = errorMsg
- now := time.Now()
- status.CompletedAt = &now
- s.downloadRepo.Save(ctx, status)
+ if err := os.Rename(tmpName, destPath); err != nil {
+ return 0, "", fmt.Errorf("failed to finalize %s: %w", destPath, err)
+ }
+ succeeded = true
+ return size, checksum, nil
}
-// GetAvailableVersions returns available versions for a given OS
-func (s *OSImageServiceImpl) GetAvailableVersions(ctx context.Context, os string) ([]string, error) {
- osDef, exists := s.config.OSImages.Sources[os]
+// GetAvailableVersions returns available versions for an OS
+func (s *osImageServiceImpl) GetAvailableVersions(ctx context.Context, osName string) ([]string, error) {
+ osDef, exists := s.config.OSImages.Sources[osName]
if !exists {
- return nil, fmt.Errorf("unsupported OS: %s", os)
+ return nil, fmt.Errorf("unsupported OS: %s", osName)
}
var versions []string
@@ -378,6 +460,65 @@ func (s *OSImageServiceImpl) GetAvailableVersions(ctx context.Context, os string
versions = append(versions, version)
}
- sort.Strings(versions)
+ sort.Slice(versions, func(i, j int) bool {
+ return compareVersions(versions[i], versions[j]) < 0
+ })
return versions, nil
}
+
+// compareVersions compares dot-separated version strings numerically,
+// segment by segment, falling back to lexical comparison for non-numeric
+// segments ("9" < "10", "6.03" < "6.10").
+func compareVersions(a, b string) int {
+ as := strings.Split(a, ".")
+ bs := strings.Split(b, ".")
+ for i := 0; i < len(as) && i < len(bs); i++ {
+ ai, aerr := strconv.Atoi(as[i])
+ bi, berr := strconv.Atoi(bs[i])
+ switch {
+ case aerr == nil && berr == nil:
+ if ai != bi {
+ if ai < bi {
+ return -1
+ }
+ return 1
+ }
+ default:
+ if as[i] != bs[i] {
+ if as[i] < bs[i] {
+ return -1
+ }
+ return 1
+ }
+ }
+ }
+ switch {
+ case len(as) < len(bs):
+ return -1
+ case len(as) > len(bs):
+ return 1
+ default:
+ return 0
+ }
+}
+
+// GetSupportedOSes returns list of supported operating systems
+func (s *osImageServiceImpl) GetSupportedOSes() []string {
+ var oses []string
+ for osName := range s.config.OSImages.Sources {
+ oses = append(oses, osName)
+ }
+ sort.Strings(oses)
+ return oses
+}
+
+// isValidOSVersion checks if the OS/version combination is valid
+func (s *osImageServiceImpl) isValidOSVersion(osName, version string) bool {
+ osDef, exists := s.config.OSImages.Sources[osName]
+ if !exists {
+ return false
+ }
+
+ _, exists = osDef.Versions[version]
+ return exists
+}
diff --git a/syslinux/interfaces.go b/syslinux/interfaces.go
index d44cdbc..3a103bb 100644
--- a/syslinux/interfaces.go
+++ b/syslinux/interfaces.go
@@ -2,7 +2,6 @@ package syslinux
import (
"context"
- "io"
)
// Repository interface for Syslinux data persistence
@@ -61,41 +60,6 @@ type Service interface {
CheckDiskSpace(ctx context.Context) (*DiskSpaceInfo, error)
}
-// Downloader interface for handling file downloads
-type Downloader interface {
- Download(ctx context.Context, url, destination string, progress chan<- int) error
- GetFileSize(ctx context.Context, url string) (int64, error)
- VerifyChecksum(filePath, expectedChecksum string) error
-}
-
-// Extractor interface for handling archive extraction
-type Extractor interface {
- Extract(ctx context.Context, archivePath, destination string, progress chan<- int) error
- ListContents(archivePath string) ([]string, error)
- ExtractFile(archivePath, fileName, destination string) error
-}
-
-// MirrorScanner interface for scanning the kernel.org mirror
-type MirrorScanner interface {
- ScanVersions(ctx context.Context, baseURL string) ([]*SyslinuxMirror, error)
- GetFileInfo(ctx context.Context, url string) (*FileInfo, error)
-}
-
-// FileManager interface for file system operations
-type FileManager interface {
- CreateDirectory(path string) error
- CopyFile(src, dst string) error
- MoveFile(src, dst string) error
- DeleteFile(path string) error
- FileExists(path string) bool
- GetFileSize(path string) (int64, error)
- GetFileChecksum(path string) (string, error)
- ReadFile(path string) ([]byte, error)
- WriteFile(path string, data []byte) error
-}
-
-// Additional types for interface support
-
// SystemStatus provides overview of the Syslinux system
type SystemStatus struct {
InstalledVersions map[string]bool `json:"installed_versions"` // version -> installed
@@ -125,70 +89,3 @@ type DiskSpaceInfo struct {
UsagePercent float64 `json:"usage_percent"`
Sufficient bool `json:"sufficient"` // Whether space is sufficient for operations
}
-
-// FileInfo contains information about a file from mirror
-type FileInfo struct {
- Name string `json:"name"`
- Size int64 `json:"size"`
- LastModified string `json:"last_modified"`
- URL string `json:"url"`
-}
-
-// ProgressCallback is used for reporting download/extraction progress
-type ProgressCallback func(percent int, message string)
-
-// EventHandler interface for handling Syslinux events
-type EventHandler interface {
- OnDownloadStarted(version string)
- OnDownloadProgress(version string, percent int)
- OnDownloadCompleted(version string)
- OnDownloadFailed(version string, err error)
- OnExtractionStarted(version string)
- OnExtractionCompleted(version string)
- OnInstallationCompleted(version, bootType string)
-}
-
-// Logger interface for structured logging
-type Logger interface {
- Debug(msg string, fields ...interface{})
- Info(msg string, fields ...interface{})
- Warn(msg string, fields ...interface{})
- Error(msg string, fields ...interface{})
- WithField(key string, value interface{}) Logger
- WithFields(fields map[string]interface{}) Logger
-}
-
-// ConfigProvider interface for configuration management
-type ConfigProvider interface {
- GetConfig() SyslinuxConfig
- SaveConfig(config SyslinuxConfig) error
- GetTFTPDir() string
- GetBiosDir() string
- GetEfiDir() string
- GetTempDir() string
-}
-
-// CacheManager interface for caching downloaded files and metadata
-type CacheManager interface {
- Get(key string) (interface{}, bool)
- Set(key string, value interface{}, ttl int) error
- Delete(key string) error
- Clear() error
- GetSize() int64
-}
-
-// HTTPClient interface for making HTTP requests
-type HTTPClient interface {
- Get(url string) (io.ReadCloser, error)
- GetWithContext(ctx context.Context, url string) (io.ReadCloser, error)
- Head(url string) (*HTTPResponse, error)
- HeadWithContext(ctx context.Context, url string) (*HTTPResponse, error)
-}
-
-// HTTPResponse represents an HTTP response
-type HTTPResponse struct {
- StatusCode int
- ContentLength int64
- LastModified string
- Headers map[string]string
-}
diff --git a/syslinux/models.go b/syslinux/models.go
index 215ad2e..2dba0d8 100644
--- a/syslinux/models.go
+++ b/syslinux/models.go
@@ -1,7 +1,11 @@
package syslinux
import (
+ "fmt"
+ "strings"
"time"
+
+ "ignite/dlstatus"
)
// SyslinuxVersion represents a Syslinux version available for download
@@ -47,16 +51,9 @@ type SyslinuxConfig struct {
VerifyChecksum bool `json:"verify_checksum"` // Verify downloads with checksums
}
-// DownloadStatus represents the status of a Syslinux download
-type DownloadStatus struct {
- ID string `json:"id"`
- Version string `json:"version"`
- Status string `json:"status"` // downloading, extracting, completed, failed
- Progress int `json:"progress"` // Percentage 0-100
- ErrorMessage string `json:"error_message,omitempty"`
- StartedAt time.Time `json:"started_at"`
- CompletedAt *time.Time `json:"completed_at,omitempty"`
-}
+// DownloadStatus is the shared download-status type (see ignite/dlstatus).
+// It is aliased so existing references keep compiling.
+type DownloadStatus = dlstatus.DownloadStatus
// SyslinuxMirror represents available versions scraped from kernel.org
type SyslinuxMirror struct {
@@ -160,24 +157,86 @@ func GetBootFileSourcePath(bootType, fileName string) string {
return ""
}
-// GetBootTypeFromVersion determines available boot types for a version
+// GetBootTypeFromVersion determines available boot types for a version.
+// The comparison is numeric so that e.g. "10.00" is not considered older
+// than "4.00" the way a lexicographic string comparison would.
func GetBootTypeFromVersion(version string) []string {
// Most modern versions support both BIOS and EFI
// Very old versions might only support BIOS
- if version < "4.00" {
+ if compareVersionStrings(version, "4.00") < 0 {
return []string{"bios"}
}
return []string{"bios", "efi"}
}
-// ParseVersionFromFilename extracts version from filename like "syslinux-6.03.tar.gz"
-func ParseVersionFromFilename(filename string) string {
- // Remove syslinux- prefix and .tar.gz suffix
- if len(filename) > 9 && filename[:9] == "syslinux-" {
- end := len(filename) - 7 // Remove .tar.gz
- if end > 9 {
- return filename[9:end]
+// compareVersionStrings compares dot-separated version strings numerically,
+// segment by segment, falling back to lexical comparison for non-numeric
+// segments.
+func compareVersionStrings(a, b string) int {
+ as := strings.Split(a, ".")
+ bs := strings.Split(b, ".")
+ for i := 0; i < len(as) && i < len(bs); i++ {
+ ai, aerr := parseVersionSegment(as[i])
+ bi, berr := parseVersionSegment(bs[i])
+ switch {
+ case aerr == nil && berr == nil:
+ if ai != bi {
+ if ai < bi {
+ return -1
+ }
+ return 1
+ }
+ default:
+ if as[i] != bs[i] {
+ if as[i] < bs[i] {
+ return -1
+ }
+ return 1
+ }
}
}
- return ""
+ switch {
+ case len(as) < len(bs):
+ return -1
+ case len(as) > len(bs):
+ return 1
+ default:
+ return 0
+ }
+}
+
+// parseVersionSegment parses the leading numeric portion of a version
+// segment ("03" -> 3, "pre1" -> error).
+func parseVersionSegment(s string) (int, error) {
+ digits := s
+ for i, r := range s {
+ if r < '0' || r > '9' {
+ digits = s[:i]
+ break
+ }
+ }
+ if digits == "" {
+ return 0, fmt.Errorf("no numeric prefix in %q", s)
+ }
+ n := 0
+ for _, r := range digits {
+ n = n*10 + int(r-'0')
+ }
+ return n, nil
+}
+
+// ParseVersionFromFilename extracts version from filename like "syslinux-6.03.tar.gz".
+// It returns "" unless the filename has both the "syslinux-" prefix and the
+// ".tar.gz" suffix.
+func ParseVersionFromFilename(filename string) string {
+ const prefix = "syslinux-"
+ const suffix = ".tar.gz"
+ if !strings.HasPrefix(filename, prefix) || !strings.HasSuffix(filename, suffix) {
+ return ""
+ }
+ version := filename[len(prefix) : len(filename)-len(suffix)]
+ if version == "" {
+ return ""
+ }
+ return version
}
diff --git a/syslinux/repository.go b/syslinux/repository.go
index 3ef3cc7..4df0ca1 100644
--- a/syslinux/repository.go
+++ b/syslinux/repository.go
@@ -4,15 +4,11 @@ import (
"context"
"encoding/json"
"fmt"
+ "ignite/db"
+ "ignite/dlstatus"
"time"
-
- "go.etcd.io/bbolt"
)
-type boltRepository struct {
- db *bbolt.DB
-}
-
// Repository bucket names
const (
VersionsBucket = "syslinux_versions"
@@ -21,411 +17,298 @@ const (
ConfigBucket = "syslinux_config"
)
-// NewBoltRepository creates a new Bolt-based repository
-func NewBoltRepository(db *bbolt.DB) (Repository, error) {
- repo := &boltRepository{db: db}
+type boltRepository struct {
+ db db.Database
+}
- // Initialize buckets
- err := db.Update(func(tx *bbolt.Tx) error {
- buckets := []string{
- VersionsBucket,
- BootFilesBucket,
- DownloadStatusBucket,
- ConfigBucket,
- }
+// NewBoltRepository creates a new Bolt-based repository backed by the shared
+// db.Database abstraction (no direct *bbolt.DB dependency).
+func NewBoltRepository(database db.Database) (Repository, error) {
+ repo := &boltRepository{db: database}
- for _, bucket := range buckets {
- if _, err := tx.CreateBucketIfNotExists([]byte(bucket)); err != nil {
- return fmt.Errorf("failed to create bucket %s: %w", bucket, err)
- }
+ // Initialize buckets
+ for _, bucket := range []string{
+ VersionsBucket,
+ BootFilesBucket,
+ DownloadStatusBucket,
+ ConfigBucket,
+ } {
+ if err := database.GetOrCreateBucket(context.Background(), bucket); err != nil {
+ return nil, fmt.Errorf("failed to create bucket %s: %w", bucket, err)
}
- return nil
- })
+ }
- return repo, err
+ return repo, nil
}
-// Version management
+// putJSON marshals value and stores it under key in bucket.
+func (r *boltRepository) putJSON(ctx context.Context, bucket, key string, value interface{}) error {
+ data, err := json.Marshal(value)
+ if err != nil {
+ return fmt.Errorf("failed to marshal value: %w", err)
+ }
+ return r.db.PutKV(ctx, bucket, []byte(key), data)
+}
-func (r *boltRepository) SaveVersion(ctx context.Context, version *SyslinuxVersion) error {
- return r.db.Update(func(tx *bbolt.Tx) error {
- b := tx.Bucket([]byte(VersionsBucket))
- version.UpdatedAt = time.Now()
+// getJSON fetches key from bucket and unmarshals it into value.
+func (r *boltRepository) getJSON(ctx context.Context, bucket, key string, value interface{}) error {
+ data, err := r.db.GetKV(ctx, bucket, []byte(key))
+ if err != nil {
+ return err
+ }
+ if data == nil {
+ return fmt.Errorf("key %q not found in bucket %q", key, bucket)
+ }
+ return json.Unmarshal(data, value)
+}
- data, err := json.Marshal(version)
- if err != nil {
- return fmt.Errorf("failed to marshal version: %w", err)
+// allJSON returns every value in bucket unmarshalled into a slice.
+func (r *boltRepository) allJSON(ctx context.Context, bucket string, newValue func() interface{}) ([]interface{}, error) {
+ raw, err := r.db.GetAllKV(ctx, bucket)
+ if err != nil {
+ return nil, err
+ }
+ values := make([]interface{}, 0, len(raw))
+ for _, data := range raw {
+ v := newValue()
+ if err := json.Unmarshal(data, v); err != nil {
+ continue
}
-
- return b.Put([]byte(version.ID), data)
- })
+ values = append(values, v)
+ }
+ return values, nil
}
-func (r *boltRepository) GetVersion(ctx context.Context, id string) (*SyslinuxVersion, error) {
- var version *SyslinuxVersion
-
- err := r.db.View(func(tx *bbolt.Tx) error {
- b := tx.Bucket([]byte(VersionsBucket))
- data := b.Get([]byte(id))
-
- if data == nil {
- return fmt.Errorf("version not found")
- }
+// Version management
- version = &SyslinuxVersion{}
- return json.Unmarshal(data, version)
- })
+func (r *boltRepository) SaveVersion(ctx context.Context, version *SyslinuxVersion) error {
+ // Copy before stamping so the caller's struct is never mutated.
+ versionCopy := *version
+ versionCopy.UpdatedAt = time.Now()
+ return r.putJSON(ctx, VersionsBucket, versionCopy.ID, &versionCopy)
+}
- return version, err
+func (r *boltRepository) GetVersion(ctx context.Context, id string) (*SyslinuxVersion, error) {
+ version := &SyslinuxVersion{}
+ if err := r.getJSON(ctx, VersionsBucket, id, version); err != nil {
+ return nil, fmt.Errorf("version not found: %w", err)
+ }
+ return version, nil
}
func (r *boltRepository) GetVersionByNumber(ctx context.Context, versionNumber string) (*SyslinuxVersion, error) {
- var foundVersion *SyslinuxVersion
-
- err := r.db.View(func(tx *bbolt.Tx) error {
- b := tx.Bucket([]byte(VersionsBucket))
- c := b.Cursor()
-
- for k, v := c.First(); k != nil; k, v = c.Next() {
- var version SyslinuxVersion
- if err := json.Unmarshal(v, &version); err != nil {
- continue
- }
-
- if version.Version == versionNumber {
- foundVersion = &version
- break
- }
- }
-
- if foundVersion == nil {
- return fmt.Errorf("version %s not found", versionNumber)
+ versions, err := r.ListVersions(ctx)
+ if err != nil {
+ return nil, err
+ }
+ for _, version := range versions {
+ if version.Version == versionNumber {
+ return version, nil
}
-
- return nil
- })
-
- return foundVersion, err
+ }
+ return nil, fmt.Errorf("version %s not found", versionNumber)
}
func (r *boltRepository) ListVersions(ctx context.Context) ([]*SyslinuxVersion, error) {
- var versions []*SyslinuxVersion
-
- err := r.db.View(func(tx *bbolt.Tx) error {
- b := tx.Bucket([]byte(VersionsBucket))
- c := b.Cursor()
-
- for k, v := c.First(); k != nil; k, v = c.Next() {
- var version SyslinuxVersion
- if err := json.Unmarshal(v, &version); err != nil {
- continue
- }
- versions = append(versions, &version)
- }
-
- return nil
- })
-
- return versions, err
+ raw, err := r.allJSON(ctx, VersionsBucket, func() interface{} { return &SyslinuxVersion{} })
+ if err != nil {
+ return nil, err
+ }
+ versions := make([]*SyslinuxVersion, 0, len(raw))
+ for _, v := range raw {
+ versions = append(versions, v.(*SyslinuxVersion))
+ }
+ return versions, nil
}
func (r *boltRepository) DeleteVersion(ctx context.Context, id string) error {
- return r.db.Update(func(tx *bbolt.Tx) error {
- b := tx.Bucket([]byte(VersionsBucket))
- return b.Delete([]byte(id))
- })
+ return r.db.DeleteKV(ctx, VersionsBucket, []byte(id))
}
func (r *boltRepository) SetActiveVersion(ctx context.Context, version string) error {
- return r.db.Update(func(tx *bbolt.Tx) error {
- b := tx.Bucket([]byte(VersionsBucket))
- c := b.Cursor()
-
- // First, set all versions to inactive
- for k, v := c.First(); k != nil; k, v = c.Next() {
- var ver SyslinuxVersion
- if err := json.Unmarshal(v, &ver); err != nil {
- continue
- }
+ versions, err := r.ListVersions(ctx)
+ if err != nil {
+ return err
+ }
- if ver.Active {
- ver.Active = false
- ver.UpdatedAt = time.Now()
- data, _ := json.Marshal(&ver)
- b.Put(k, data)
+ now := time.Now()
+ updates := make(map[string][]byte)
+ found := false
+ for _, ver := range versions {
+ updated := *ver
+ changed := false
+ if updated.Active {
+ updated.Active = false
+ updated.UpdatedAt = now
+ changed = true
+ }
+ if updated.Version == version {
+ updated.Active = true
+ updated.UpdatedAt = now
+ changed = true
+ found = true
+ }
+ if changed {
+ data, err := json.Marshal(&updated)
+ if err != nil {
+ return fmt.Errorf("failed to marshal version %q: %w", updated.ID, err)
}
+ updates[updated.ID] = data
}
+ }
- // Then set the specified version to active
- for k, v := c.First(); k != nil; k, v = c.Next() {
- var ver SyslinuxVersion
- if err := json.Unmarshal(v, &ver); err != nil {
- continue
- }
+ if !found {
+ return fmt.Errorf("version %s not found", version)
+ }
- if ver.Version == version {
- ver.Active = true
- ver.UpdatedAt = time.Now()
- data, _ := json.Marshal(&ver)
- b.Put(k, data)
- }
+ // Apply the deactivation/activation atomically in a single batch when the
+ // database supports it; otherwise fall back to sequential writes.
+ if bp, ok := r.db.(batchPutter); ok {
+ return bp.BatchPut(ctx, VersionsBucket, updates)
+ }
+ for key, data := range updates {
+ if err := r.db.PutKV(ctx, VersionsBucket, []byte(key), data); err != nil {
+ return fmt.Errorf("failed to save version %q: %w", key, err)
}
+ }
+ return nil
+}
- return nil
- })
+// batchPutter is implemented by *db.BoltDB (see db/bolt.go). The repository
+// asserts it via this local interface so it keeps depending only on
+// db.Database while still getting atomic batch writes when available.
+type batchPutter interface {
+ BatchPut(ctx context.Context, bucket string, kvs map[string][]byte) error
}
func (r *boltRepository) GetActiveVersion(ctx context.Context) (*SyslinuxVersion, error) {
- var activeVersion *SyslinuxVersion
-
- err := r.db.View(func(tx *bbolt.Tx) error {
- b := tx.Bucket([]byte(VersionsBucket))
- c := b.Cursor()
-
- for k, v := c.First(); k != nil; k, v = c.Next() {
- var version SyslinuxVersion
- if err := json.Unmarshal(v, &version); err != nil {
- continue
- }
-
- if version.Active {
- activeVersion = &version
- break
- }
+ versions, err := r.ListVersions(ctx)
+ if err != nil {
+ return nil, err
+ }
+ for _, version := range versions {
+ if version.Active {
+ return version, nil
}
-
- return nil
- })
-
- if activeVersion == nil {
- return nil, fmt.Errorf("no active version found")
}
-
- return activeVersion, err
+ return nil, fmt.Errorf("no active version found")
}
// Boot file management
func (r *boltRepository) SaveBootFile(ctx context.Context, bootFile *SyslinuxBootFile) error {
- return r.db.Update(func(tx *bbolt.Tx) error {
- b := tx.Bucket([]byte(BootFilesBucket))
- bootFile.UpdatedAt = time.Now()
-
- data, err := json.Marshal(bootFile)
- if err != nil {
- return fmt.Errorf("failed to marshal boot file: %w", err)
- }
-
- return b.Put([]byte(bootFile.ID), data)
- })
+ // Copy before stamping so the caller's struct is never mutated.
+ bootFileCopy := *bootFile
+ bootFileCopy.UpdatedAt = time.Now()
+ return r.putJSON(ctx, BootFilesBucket, bootFileCopy.ID, &bootFileCopy)
}
func (r *boltRepository) GetBootFile(ctx context.Context, id string) (*SyslinuxBootFile, error) {
- var bootFile *SyslinuxBootFile
-
- err := r.db.View(func(tx *bbolt.Tx) error {
- b := tx.Bucket([]byte(BootFilesBucket))
- data := b.Get([]byte(id))
-
- if data == nil {
- return fmt.Errorf("boot file not found")
- }
-
- bootFile = &SyslinuxBootFile{}
- return json.Unmarshal(data, bootFile)
- })
-
- return bootFile, err
+ bootFile := &SyslinuxBootFile{}
+ if err := r.getJSON(ctx, BootFilesBucket, id, bootFile); err != nil {
+ return nil, fmt.Errorf("boot file not found: %w", err)
+ }
+ return bootFile, nil
}
func (r *boltRepository) ListBootFiles(ctx context.Context, version, bootType string) ([]*SyslinuxBootFile, error) {
+ raw, err := r.allJSON(ctx, BootFilesBucket, func() interface{} { return &SyslinuxBootFile{} })
+ if err != nil {
+ return nil, err
+ }
var bootFiles []*SyslinuxBootFile
-
- err := r.db.View(func(tx *bbolt.Tx) error {
- b := tx.Bucket([]byte(BootFilesBucket))
- c := b.Cursor()
-
- for k, v := c.First(); k != nil; k, v = c.Next() {
- var bootFile SyslinuxBootFile
- if err := json.Unmarshal(v, &bootFile); err != nil {
- continue
- }
-
- // Filter by version and/or boot type if specified
- if version != "" && bootFile.Version != version {
- continue
- }
- if bootType != "" && bootFile.BootType != bootType {
- continue
- }
-
- bootFiles = append(bootFiles, &bootFile)
+ for _, v := range raw {
+ bootFile := v.(*SyslinuxBootFile)
+ // Filter by version and/or boot type if specified
+ if version != "" && bootFile.Version != version {
+ continue
}
-
- return nil
- })
-
- return bootFiles, err
+ if bootType != "" && bootFile.BootType != bootType {
+ continue
+ }
+ bootFiles = append(bootFiles, bootFile)
+ }
+ return bootFiles, nil
}
func (r *boltRepository) UpdateBootFileStatus(ctx context.Context, id string, installed bool) error {
- return r.db.Update(func(tx *bbolt.Tx) error {
- b := tx.Bucket([]byte(BootFilesBucket))
- data := b.Get([]byte(id))
-
- if data == nil {
- return fmt.Errorf("boot file not found")
- }
-
- var bootFile SyslinuxBootFile
- if err := json.Unmarshal(data, &bootFile); err != nil {
- return err
- }
-
- bootFile.Installed = installed
- bootFile.UpdatedAt = time.Now()
-
- updatedData, err := json.Marshal(&bootFile)
- if err != nil {
- return err
- }
-
- return b.Put([]byte(id), updatedData)
- })
+ bootFile, err := r.GetBootFile(ctx, id)
+ if err != nil {
+ return err
+ }
+ bootFile.Installed = installed
+ return r.SaveBootFile(ctx, bootFile)
}
func (r *boltRepository) DeleteBootFile(ctx context.Context, id string) error {
- return r.db.Update(func(tx *bbolt.Tx) error {
- b := tx.Bucket([]byte(BootFilesBucket))
- return b.Delete([]byte(id))
- })
+ return r.db.DeleteKV(ctx, BootFilesBucket, []byte(id))
}
// Download status tracking
func (r *boltRepository) SaveDownloadStatus(ctx context.Context, status *DownloadStatus) error {
- return r.db.Update(func(tx *bbolt.Tx) error {
- b := tx.Bucket([]byte(DownloadStatusBucket))
-
- data, err := json.Marshal(status)
- if err != nil {
- return fmt.Errorf("failed to marshal download status: %w", err)
- }
-
- return b.Put([]byte(status.ID), data)
- })
+ return r.putJSON(ctx, DownloadStatusBucket, status.ID, status)
}
func (r *boltRepository) GetDownloadStatus(ctx context.Context, id string) (*DownloadStatus, error) {
- var status *DownloadStatus
-
- err := r.db.View(func(tx *bbolt.Tx) error {
- b := tx.Bucket([]byte(DownloadStatusBucket))
- data := b.Get([]byte(id))
-
- if data == nil {
- return fmt.Errorf("download status not found")
- }
-
- status = &DownloadStatus{}
- return json.Unmarshal(data, status)
- })
-
- return status, err
+ status := &DownloadStatus{}
+ if err := r.getJSON(ctx, DownloadStatusBucket, id, status); err != nil {
+ return nil, fmt.Errorf("download status not found: %w", err)
+ }
+ return status, nil
}
func (r *boltRepository) ListDownloadStatuses(ctx context.Context) ([]*DownloadStatus, error) {
- var statuses []*DownloadStatus
-
- err := r.db.View(func(tx *bbolt.Tx) error {
- b := tx.Bucket([]byte(DownloadStatusBucket))
- c := b.Cursor()
-
- for k, v := c.First(); k != nil; k, v = c.Next() {
- var status DownloadStatus
- if err := json.Unmarshal(v, &status); err != nil {
- continue
- }
- statuses = append(statuses, &status)
- }
-
- return nil
- })
-
- return statuses, err
+ raw, err := r.allJSON(ctx, DownloadStatusBucket, func() interface{} { return &DownloadStatus{} })
+ if err != nil {
+ return nil, err
+ }
+ statuses := make([]*DownloadStatus, 0, len(raw))
+ for _, v := range raw {
+ statuses = append(statuses, v.(*DownloadStatus))
+ }
+ return statuses, nil
}
func (r *boltRepository) DeleteDownloadStatus(ctx context.Context, id string) error {
- return r.db.Update(func(tx *bbolt.Tx) error {
- b := tx.Bucket([]byte(DownloadStatusBucket))
- return b.Delete([]byte(id))
- })
+ return r.db.DeleteKV(ctx, DownloadStatusBucket, []byte(id))
}
// Configuration management
func (r *boltRepository) SaveConfig(ctx context.Context, config *SyslinuxConfig) error {
- return r.db.Update(func(tx *bbolt.Tx) error {
- b := tx.Bucket([]byte(ConfigBucket))
-
- data, err := json.Marshal(config)
- if err != nil {
- return fmt.Errorf("failed to marshal config: %w", err)
- }
-
- return b.Put([]byte("current"), data)
- })
+ return r.putJSON(ctx, ConfigBucket, "current", config)
}
func (r *boltRepository) GetConfig(ctx context.Context) (*SyslinuxConfig, error) {
- var config *SyslinuxConfig
-
- err := r.db.View(func(tx *bbolt.Tx) error {
- b := tx.Bucket([]byte(ConfigBucket))
- data := b.Get([]byte("current"))
-
- if data == nil {
- // Return default config if none exists
- defaultConfig := GetDefaultConfig()
- config = &defaultConfig
- return nil
- }
-
- config = &SyslinuxConfig{}
- return json.Unmarshal(data, config)
- })
-
- return config, err
+ config := &SyslinuxConfig{}
+ if err := r.getJSON(ctx, ConfigBucket, "current", config); err != nil {
+ // Return default config if none exists
+ defaultConfig := GetDefaultConfig()
+ return &defaultConfig, nil
+ }
+ return config, nil
}
// Cleanup old download statuses
func (r *boltRepository) CleanupOldDownloadStatuses(ctx context.Context, olderThan time.Duration) error {
cutoff := time.Now().Add(-olderThan)
- return r.db.Update(func(tx *bbolt.Tx) error {
- b := tx.Bucket([]byte(DownloadStatusBucket))
- c := b.Cursor()
-
- var toDelete [][]byte
-
- for k, v := c.First(); k != nil; k, v = c.Next() {
- var status DownloadStatus
- if err := json.Unmarshal(v, &status); err != nil {
- continue
- }
+ statuses, err := r.ListDownloadStatuses(ctx)
+ if err != nil {
+ return err
+ }
- // Delete completed or failed downloads older than cutoff
- if status.CompletedAt != nil && status.CompletedAt.Before(cutoff) {
- toDelete = append(toDelete, append([]byte(nil), k...))
+ for _, status := range statuses {
+ // Delete completed or failed downloads older than cutoff
+ if status.CompletedAt != nil && status.CompletedAt.Before(cutoff) {
+ if err := r.DeleteDownloadStatus(ctx, status.ID); err != nil {
+ return err
}
}
+ }
- for _, key := range toDelete {
- b.Delete(key)
- }
-
- return nil
- })
+ return nil
}
// Helper methods for statistics and maintenance
@@ -433,39 +316,34 @@ func (r *boltRepository) CleanupOldDownloadStatuses(ctx context.Context, olderTh
func (r *boltRepository) GetStatistics(ctx context.Context) (*RepositoryStatistics, error) {
var stats RepositoryStatistics
- err := r.db.View(func(tx *bbolt.Tx) error {
- // Count versions
- versionsBucket := tx.Bucket([]byte(VersionsBucket))
- stats.TotalVersions = versionsBucket.Stats().KeyN
-
- // Count boot files
- bootFilesBucket := tx.Bucket([]byte(BootFilesBucket))
- stats.TotalBootFiles = bootFilesBucket.Stats().KeyN
-
- // Count active downloads
- downloadsBucket := tx.Bucket([]byte(DownloadStatusBucket))
- c := downloadsBucket.Cursor()
+ versions, err := r.ListVersions(ctx)
+ if err != nil {
+ return nil, err
+ }
+ stats.TotalVersions = len(versions)
- for k, v := c.First(); k != nil; k, v = c.Next() {
- var status DownloadStatus
- if err := json.Unmarshal(v, &status); err != nil {
- continue
- }
+ bootFiles, err := r.ListBootFiles(ctx, "", "")
+ if err != nil {
+ return nil, err
+ }
+ stats.TotalBootFiles = len(bootFiles)
- switch status.Status {
- case "downloading", "extracting":
- stats.ActiveDownloads++
- case "completed":
- stats.CompletedDownloads++
- case "failed", "cancelled":
- stats.FailedDownloads++
- }
+ statuses, err := r.ListDownloadStatuses(ctx)
+ if err != nil {
+ return nil, err
+ }
+ for _, status := range statuses {
+ switch {
+ case status.Status.Active():
+ stats.ActiveDownloads++
+ case status.Status == dlstatus.StatusCompleted:
+ stats.CompletedDownloads++
+ case status.Status.Terminal():
+ stats.FailedDownloads++
}
+ }
- return nil
- })
-
- return &stats, err
+ return &stats, nil
}
// RepositoryStatistics provides repository usage statistics
diff --git a/syslinux/service.go b/syslinux/service.go
index 6c9a0f5..8d98da2 100644
--- a/syslinux/service.go
+++ b/syslinux/service.go
@@ -11,10 +11,15 @@ import (
"os"
"path/filepath"
"regexp"
+ "sort"
"strconv"
"strings"
+ "sync"
"time"
+ "ignite/dlstatus"
+
+ "github.com/google/uuid"
"golang.org/x/net/html"
)
@@ -22,20 +27,76 @@ type service struct {
repo Repository
config SyslinuxConfig
client *http.Client
+
+ mu sync.Mutex
+ cancels map[string]context.CancelFunc // download ID -> cancel func
}
// NewService creates a new Syslinux service
func NewService(repo Repository, config SyslinuxConfig) Service {
return &service{
- repo: repo,
- config: config,
- client: &http.Client{Timeout: 30 * time.Second},
+ repo: repo,
+ config: config,
+ client: &http.Client{Timeout: 30 * time.Second},
+ cancels: make(map[string]context.CancelFunc),
+ }
+}
+
+// removeCancel drops the cancel func for a download ID.
+func (s *service) removeCancel(id string) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ delete(s.cancels, id)
+}
+
+// updateStatus re-reads the download row and applies the new state only if it
+// has not reached a terminal state. It runs under the service mutex (the same
+// mutex CancelDownload uses), so a concurrent cancellation is never
+// overwritten with stale worker data. It returns false when the row is
+// missing or already terminal.
+func (s *service) updateStatus(ctx context.Context, id string, status dlstatus.Status, progress int, errMsg string) bool {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ current, err := s.repo.GetDownloadStatus(ctx, id)
+ if err != nil {
+ return false
+ }
+ if current.Status.Terminal() {
+ return false
+ }
+ current.Status = status
+ current.Progress = progress
+ if errMsg != "" {
+ current.ErrorMessage = errMsg
+ }
+ if status.Terminal() {
+ now := time.Now()
+ current.CompletedAt = &now
+ }
+ if err := s.repo.SaveDownloadStatus(ctx, current); err != nil {
+ log.Printf("syslinux: failed to save download status %s: %v", id, err)
+ return false
}
+ return true
+}
+
+// failStatus records a terminal failure unless the row already went terminal.
+func (s *service) failStatus(ctx context.Context, id, errMsg string) {
+ if !s.updateStatus(ctx, id, dlstatus.StatusFailed, 0, errMsg) {
+ log.Printf("syslinux: download %s failed (%s); status already terminal", id, errMsg)
+ return
+ }
+ log.Printf("syslinux: download %s failed: %s", id, errMsg)
}
// ScanMirror scans the kernel.org mirror for available Syslinux versions
func (s *service) ScanMirror(ctx context.Context) ([]*SyslinuxMirror, error) {
- resp, err := s.client.Get(s.config.BaseURL)
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.config.BaseURL, nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create mirror request: %w", err)
+ }
+ resp, err := s.client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to fetch mirror page: %w", err)
}
@@ -224,7 +285,6 @@ func (s *service) RefreshAvailableVersions(ctx context.Context) error {
existing.DownloadURL = mirror.DownloadURL
existing.FileName = mirror.FileName
existing.Size = mirror.Size
- existing.UpdatedAt = time.Now()
if err := s.repo.SaveVersion(ctx, existing); err != nil {
return fmt.Errorf("failed to update version %s: %w", mirror.Version, err)
}
@@ -257,7 +317,9 @@ func (s *service) GetAvailableVersions(ctx context.Context) ([]*SyslinuxVersion,
return s.repo.ListVersions(ctx)
}
-// DownloadVersion downloads a specific version
+// DownloadVersion downloads a specific version in the background.
+// It returns a copy of the queued status; the worker only ever mutates
+// re-read copies afterwards.
func (s *service) DownloadVersion(ctx context.Context, version string) (*DownloadStatus, error) {
// Get version info
sysVersion, err := s.repo.GetVersionByNumber(ctx, version)
@@ -265,11 +327,11 @@ func (s *service) DownloadVersion(ctx context.Context, version string) (*Downloa
return nil, fmt.Errorf("version not found: %w", err)
}
- // Create download status
+ // Create download status with a collision-resistant ID
status := &DownloadStatus{
- ID: fmt.Sprintf("download-%s-%d", version, time.Now().Unix()),
+ ID: uuid.New().String(),
Version: version,
- Status: "downloading",
+ Status: dlstatus.StatusQueued,
Progress: 0,
StartedAt: time.Now(),
}
@@ -278,68 +340,150 @@ func (s *service) DownloadVersion(ctx context.Context, version string) (*Downloa
return nil, fmt.Errorf("failed to save download status: %w", err)
}
+ dlCtx, cancel := context.WithCancel(context.Background())
+ s.mu.Lock()
+ s.cancels[status.ID] = cancel
+ s.mu.Unlock()
+
// Start download in background
- go func() {
- defer func() {
- if r := recover(); r != nil {
- status.Status = "failed"
- status.ErrorMessage = fmt.Sprintf("panic: %v", r)
- now := time.Now()
- status.CompletedAt = &now
- s.repo.SaveDownloadStatus(ctx, status)
- }
- }()
-
- // Step 1: Clean up any existing active version
- if err := s.cleanupActiveVersion(ctx); err != nil {
- status.Status = "failed"
- status.ErrorMessage = "Failed to cleanup previous version: " + err.Error()
- } else if err := s.downloadFile(ctx, sysVersion, status); err != nil {
- status.Status = "failed"
- status.ErrorMessage = err.Error()
+ go s.downloadAndInstall(dlCtx, status.ID, sysVersion)
+
+ // Return a copy so the HTTP caller never sees worker-mutated state.
+ statusCopy := *status
+ return &statusCopy, nil
+}
+
+// downloadAndInstall runs the download pipeline in the background:
+// download -> extract -> verify, and only then clean up the old active
+// version and swap to the new one. A cancelled context aborts promptly and
+// leaves the previously active version untouched.
+func (s *service) downloadAndInstall(ctx context.Context, statusID string, sysVersion *SyslinuxVersion) {
+ defer s.removeCancel(statusID)
+
+ defer func() {
+ if r := recover(); r != nil {
+ s.failStatus(context.Background(), statusID, fmt.Sprintf("panic: %v", r))
+ }
+ }()
+
+ bg := context.Background()
+
+ if !s.updateStatus(ctx, statusID, dlstatus.StatusDownloading, 0, "") {
+ return // cancelled while queued
+ }
+
+ // Step 1: download the archive (temp file + rename on success)
+ if err := s.downloadFile(ctx, statusID, sysVersion); err != nil {
+ if ctx.Err() != nil {
+ s.markCancelled(bg, statusID)
} else {
- // Step 2: Install boot files automatically after extraction
- status.Status = "installing"
- s.repo.SaveDownloadStatus(ctx, status)
-
- // Install both BIOS and EFI boot files
- if err := s.InstallBootFiles(ctx, sysVersion.Version, "bios"); err != nil {
- status.Status = "failed"
- status.ErrorMessage = "Failed to install BIOS boot files: " + err.Error()
- } else if err := s.InstallBootFiles(ctx, sysVersion.Version, "efi"); err != nil {
- status.Status = "failed"
- status.ErrorMessage = "Failed to install EFI boot files: " + err.Error()
- } else {
- // Step 3: Mark as downloaded and active
- status.Status = "completed"
- status.Progress = 100
-
- sysVersion.Downloaded = true
- sysVersion.Active = true
- now := time.Now()
- sysVersion.DownloadedAt = &now
- sysVersion.UpdatedAt = now
- s.repo.SaveVersion(ctx, sysVersion)
- }
+ s.failStatus(bg, statusID, err.Error())
}
+ return
+ }
- now := time.Now()
- status.CompletedAt = &now
- s.repo.SaveDownloadStatus(ctx, status)
- }()
+ if ctx.Err() != nil {
+ s.markCancelled(bg, statusID)
+ return
+ }
+
+ // Step 2: extract
+ if !s.updateStatus(ctx, statusID, dlstatus.StatusExtracting, 90, "") {
+ return
+ }
+ if err := s.ExtractBootFiles(ctx, sysVersion.Version); err != nil {
+ if ctx.Err() != nil {
+ s.markCancelled(bg, statusID)
+ } else {
+ s.failStatus(bg, statusID, fmt.Sprintf("failed to extract boot files: %v", err))
+ }
+ return
+ }
+
+ if ctx.Err() != nil {
+ s.markCancelled(bg, statusID)
+ return
+ }
+
+ // Step 3: verify the extracted files before touching the active version
+ if err := s.verifyExtractedFiles(sysVersion.Version); err != nil {
+ s.failStatus(bg, statusID, err.Error())
+ return
+ }
- return status, nil
+ if ctx.Err() != nil {
+ s.markCancelled(bg, statusID)
+ return
+ }
+
+ // Step 4: only now clean up the old active version and swap to the new one
+ if err := s.cleanupActiveVersion(bg); err != nil {
+ s.failStatus(bg, statusID, "failed to cleanup previous version: "+err.Error())
+ return
+ }
+
+ now := time.Now()
+ sysVersion.Downloaded = true
+ sysVersion.Active = true
+ sysVersion.DownloadedAt = &now
+ if err := s.repo.SaveVersion(bg, sysVersion); err != nil {
+ s.failStatus(bg, statusID, "failed to save version: "+err.Error())
+ return
+ }
+ if err := s.repo.SetActiveVersion(bg, sysVersion.Version); err != nil {
+ s.failStatus(bg, statusID, "failed to activate version: "+err.Error())
+ return
+ }
+
+ s.updateStatus(bg, statusID, dlstatus.StatusCompleted, 100, "")
+ log.Printf("syslinux: successfully downloaded and activated version %s", sysVersion.Version)
+}
+
+// markCancelled records cancellation unless the row already went terminal.
+func (s *service) markCancelled(ctx context.Context, id string) {
+ s.updateStatus(ctx, id, dlstatus.StatusCancelled, 0, "Download cancelled by user")
+}
+
+// throttledProgress forwards progress updates at most every 500ms or when the
+// whole percentage changes, so a download doesn't write to the DB per chunk.
+type throttledProgress struct {
+ update func(pct int)
+ mu sync.Mutex
+ lastPct int
+ lastTime time.Time
+}
+
+func (t *throttledProgress) onProgress(current, total int64) {
+ if total <= 0 {
+ return
+ }
+ pct := int(float64(current) / float64(total) * 100)
+ if pct > 100 {
+ pct = 100
+ }
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ if pct == t.lastPct && time.Since(t.lastTime) < 500*time.Millisecond {
+ return
+ }
+ t.lastPct = pct
+ t.lastTime = time.Now()
+ t.update(pct)
}
-// downloadFile handles the actual file download
-func (s *service) downloadFile(ctx context.Context, version *SyslinuxVersion, status *DownloadStatus) error {
+// downloadFile downloads the version archive to the temp directory via a temp
+// file that is renamed into place only on success.
+func (s *service) downloadFile(ctx context.Context, statusID string, version *SyslinuxVersion) error {
// Ensure temp directory exists
if err := os.MkdirAll(s.config.TempDir, 0755); err != nil {
return fmt.Errorf("failed to create temp directory: %w", err)
}
- // Download file
- resp, err := s.client.Get(version.DownloadURL)
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, version.DownloadURL, nil)
+ if err != nil {
+ return fmt.Errorf("failed to create download request: %w", err)
+ }
+ resp, err := s.client.Do(req)
if err != nil {
return fmt.Errorf("failed to start download: %w", err)
}
@@ -349,47 +493,49 @@ func (s *service) downloadFile(ctx context.Context, version *SyslinuxVersion, st
return fmt.Errorf("download failed with status %d", resp.StatusCode)
}
- // Create destination file
- filePath := filepath.Join(s.config.TempDir, version.FileName)
- file, err := os.Create(filePath)
+ tmp, err := os.CreateTemp(s.config.TempDir, ".download-*")
if err != nil {
- return fmt.Errorf("failed to create file: %w", err)
+ return fmt.Errorf("failed to create temp file: %w", err)
}
- defer file.Close()
+ tmpName := tmp.Name()
+ succeeded := false
+ defer func() {
+ if !succeeded {
+ _ = os.Remove(tmpName)
+ }
+ }()
- // Copy with progress tracking
+ // Copy with throttled progress tracking
contentLength := resp.ContentLength
if contentLength <= 0 {
contentLength = version.Size
}
- reader := &progressReader{
- reader: resp.Body,
- total: contentLength,
- onProgress: func(current, total int64) {
- if total > 0 {
- progress := int(float64(current) / float64(total) * 100)
- status.Progress = progress
- s.repo.SaveDownloadStatus(ctx, status)
- }
+ progress := &throttledProgress{
+ update: func(pct int) {
+ s.updateStatus(ctx, statusID, dlstatus.StatusDownloading, pct, "")
},
}
- _, err = io.Copy(file, reader)
- if err != nil {
- return fmt.Errorf("failed to download file: %w", err)
+ reader := &progressReader{
+ reader: resp.Body,
+ total: contentLength,
+ onProgress: progress.onProgress,
}
- // Auto-extract if configured
- if s.config.AutoExtract {
- status.Status = "extracting"
- s.repo.SaveDownloadStatus(ctx, status)
-
- if err := s.ExtractBootFiles(ctx, version.Version); err != nil {
- return fmt.Errorf("failed to extract boot files: %w", err)
- }
+ if _, err := io.Copy(tmp, reader); err != nil {
+ _ = tmp.Close()
+ return fmt.Errorf("failed to download file: %w", err)
+ }
+ if err := tmp.Close(); err != nil {
+ return fmt.Errorf("failed to close temp file: %w", err)
}
+ filePath := filepath.Join(s.config.TempDir, version.FileName)
+ if err := os.Rename(tmpName, filePath); err != nil {
+ return fmt.Errorf("failed to finalize download: %w", err)
+ }
+ succeeded = true
return nil
}
@@ -414,6 +560,10 @@ func (s *service) ExtractBootFiles(ctx context.Context, version string) error {
// Extract relevant files
for {
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+
header, err := tr.Next()
if err == io.EOF {
break
@@ -428,7 +578,7 @@ func (s *service) ExtractBootFiles(ctx context.Context, version string) error {
// Check if this is a boot file we need
if s.shouldExtractFile(header.Name, version) {
- if err := s.extractSingleFile(tr, header, version); err != nil {
+ if err := s.extractSingleFile(ctx, tr, header, version); err != nil {
return fmt.Errorf("failed to extract %s: %w", header.Name, err)
}
}
@@ -474,8 +624,9 @@ func (s *service) shouldExtractFile(filePath, version string) bool {
return false
}
-// extractSingleFile extracts a single file from the archive
-func (s *service) extractSingleFile(tr *tar.Reader, header *tar.Header, version string) error {
+// extractSingleFile extracts a single file from the archive using the
+// caller's context for repository writes.
+func (s *service) extractSingleFile(ctx context.Context, tr *tar.Reader, header *tar.Header, version string) error {
// Determine boot type and target directory
var bootType, targetDir string
@@ -532,7 +683,7 @@ func (s *service) extractSingleFile(tr *tar.Reader, header *tar.Header, version
UpdatedAt: time.Now(),
}
- return s.repo.SaveBootFile(context.Background(), bootFile)
+ return s.repo.SaveBootFile(ctx, bootFile)
}
// getFileDescription returns a description for a boot file
@@ -561,28 +712,45 @@ func (s *service) isRequiredFile(bootType, fileName string) bool {
return false
}
-// GetDownloadStatus retrieves download status
+// GetDownloadStatus retrieves download status.
+// The returned struct is a copy; the worker never shares its live struct.
func (s *service) GetDownloadStatus(ctx context.Context, id string) (*DownloadStatus, error) {
- return s.repo.GetDownloadStatus(ctx, id)
+ status, err := s.repo.GetDownloadStatus(ctx, id)
+ if err != nil {
+ return nil, err
+ }
+ statusCopy := *status
+ return &statusCopy, nil
}
-// CancelDownload cancels an ongoing download
+// CancelDownload cancels an ongoing download: it signals the worker via the
+// download's context and marks the row cancelled under the same mutex the
+// worker uses for status updates, so the two can never overwrite each other.
func (s *service) CancelDownload(ctx context.Context, id string) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
status, err := s.repo.GetDownloadStatus(ctx, id)
if err != nil {
return err
}
- status.Status = "cancelled"
+ if !status.Status.Active() {
+ return fmt.Errorf("cannot cancel download with status %s", status.Status)
+ }
+
+ if cancel, ok := s.cancels[id]; ok {
+ delete(s.cancels, id)
+ cancel()
+ }
+
now := time.Now()
+ status.Status = dlstatus.StatusCancelled
status.CompletedAt = &now
return s.repo.SaveDownloadStatus(ctx, status)
}
-// Additional service methods would continue here...
-// (InstallBootFiles, ListInstalledBootFiles, RemoveBootFiles, etc.)
-
// progressReader tracks download progress
type progressReader struct {
reader io.Reader
@@ -600,9 +768,97 @@ func (pr *progressReader) Read(p []byte) (int, error) {
return n, err
}
-// Implement remaining Service interface methods...
+// verifyExtractedFiles checks that every required BIOS and EFI boot file for
+// the version exists in the configured TFTP directories.
+func (s *service) verifyExtractedFiles(version string) error {
+ bootTypes := map[string]map[string]string{
+ "bios": GetRequiredBiosFiles(),
+ "efi": GetRequiredEfiFiles(),
+ }
+ for bootType, files := range bootTypes {
+ var dir string
+ switch bootType {
+ case "bios":
+ dir = filepath.Join(s.config.TFTPDir, s.config.BiosDir)
+ case "efi":
+ dir = filepath.Join(s.config.TFTPDir, s.config.EfiDir)
+ }
+ var missing []string
+ for fileName := range files {
+ if _, err := os.Stat(filepath.Join(dir, fileName)); err != nil {
+ missing = append(missing, fileName)
+ }
+ }
+ if len(missing) > 0 {
+ sort.Strings(missing)
+ return fmt.Errorf("missing %s boot files for version %s: %s",
+ bootType, version, strings.Join(missing, ", "))
+ }
+ }
+ return nil
+}
+
+// InstallBootFiles ensures the boot files for version/bootType are installed:
+// every required file must exist in the configured TFTP boot directory, and
+// its boot-file record is created or marked installed. It returns an error
+// listing any missing files instead of silently succeeding.
func (s *service) InstallBootFiles(ctx context.Context, version, bootType string) error {
- // Implementation for installing boot files
+ var required map[string]string
+ var targetDir string
+ switch bootType {
+ case "bios":
+ required = GetRequiredBiosFiles()
+ targetDir = filepath.Join(s.config.TFTPDir, s.config.BiosDir)
+ case "efi":
+ required = GetRequiredEfiFiles()
+ targetDir = filepath.Join(s.config.TFTPDir, s.config.EfiDir)
+ default:
+ return fmt.Errorf("unsupported boot type: %s", bootType)
+ }
+
+ sysVersion, err := s.repo.GetVersionByNumber(ctx, version)
+ if err != nil {
+ return fmt.Errorf("version not found: %w", err)
+ }
+ if !sysVersion.Downloaded {
+ return fmt.Errorf("version %s has not been downloaded", version)
+ }
+
+ var missing []string
+ for fileName, description := range required {
+ targetPath := filepath.Join(targetDir, fileName)
+ fi, err := os.Stat(targetPath)
+ if err != nil {
+ missing = append(missing, fileName)
+ continue
+ }
+
+ id := fmt.Sprintf("%s-%s-%s", version, bootType, fileName)
+ bootFile, err := s.repo.GetBootFile(ctx, id)
+ if err != nil {
+ bootFile = &SyslinuxBootFile{
+ ID: id,
+ Version: version,
+ BootType: bootType,
+ FileName: fileName,
+ Description: description,
+ Required: true,
+ CreatedAt: time.Now(),
+ }
+ }
+ bootFile.FilePath = targetPath
+ bootFile.Size = fi.Size()
+ bootFile.Installed = true
+ if err := s.repo.SaveBootFile(ctx, bootFile); err != nil {
+ return fmt.Errorf("failed to record boot file %s: %w", fileName, err)
+ }
+ }
+
+ if len(missing) > 0 {
+ sort.Strings(missing)
+ return fmt.Errorf("missing %s boot files for version %s: %s",
+ bootType, version, strings.Join(missing, ", "))
+ }
return nil
}
@@ -611,16 +867,16 @@ func (s *service) ListInstalledBootFiles(ctx context.Context, bootType string) (
}
func (s *service) RemoveBootFiles(ctx context.Context, version, bootType string) error {
- // Get the appropriate boot directory
+ // Get the appropriate boot directory from configuration
var bootDir string
var requiredFiles map[string]string
switch bootType {
case "bios":
- bootDir = filepath.Join(s.config.TFTPDir, "boot-bios")
+ bootDir = filepath.Join(s.config.TFTPDir, s.config.BiosDir)
requiredFiles = GetRequiredBiosFiles()
case "efi":
- bootDir = filepath.Join(s.config.TFTPDir, "boot-efi")
+ bootDir = filepath.Join(s.config.TFTPDir, s.config.EfiDir)
requiredFiles = GetRequiredEfiFiles()
default:
return fmt.Errorf("unsupported boot type: %s", bootType)
@@ -668,7 +924,9 @@ func (s *service) CheckDiskSpace(ctx context.Context) (*DiskSpaceInfo, error) {
return &DiskSpaceInfo{}, nil
}
-// cleanupActiveVersion removes any currently active version and cleans up files
+// cleanupActiveVersion removes any currently active version and cleans up files.
+// It is only called after the new version has been fully downloaded,
+// extracted, and verified.
func (s *service) cleanupActiveVersion(ctx context.Context) error {
versions, err := s.repo.ListVersions(ctx)
if err != nil {
diff --git a/syslinux/syslinux_test.go b/syslinux/syslinux_test.go
index c3ab847..bcc2a64 100644
--- a/syslinux/syslinux_test.go
+++ b/syslinux/syslinux_test.go
@@ -5,6 +5,8 @@ import (
"testing"
"time"
+ "ignite/dlstatus"
+
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
@@ -180,7 +182,7 @@ func TestDownloadStatus(t *testing.T) {
status := &DownloadStatus{
ID: "download-123",
Version: "6.03",
- Status: "completed",
+ Status: dlstatus.StatusCompleted,
Progress: 100,
ErrorMessage: "",
StartedAt: now,
@@ -189,7 +191,7 @@ func TestDownloadStatus(t *testing.T) {
assert.Equal(t, "download-123", status.ID)
assert.Equal(t, "6.03", status.Version)
- assert.Equal(t, "completed", status.Status)
+ assert.Equal(t, dlstatus.StatusCompleted, status.Status)
assert.Equal(t, 100, status.Progress)
assert.Empty(t, status.ErrorMessage)
assert.NotNil(t, status.CompletedAt)
@@ -319,7 +321,7 @@ func TestParseVersionFromFilename(t *testing.T) {
// Invalid cases
{"invalid-file.tar.gz", ""},
{"syslinux-.tar.gz", ""},
- {"syslinux-6.03.zip", "6"}, // Wrong extension, but still parses (implementation quirk)
+ {"syslinux-6.03.zip", ""}, // Wrong extension is rejected
{"notasyslinux-6.03.tar.gz", ""}, // Wrong prefix
{"syslinux", ""}, // No extension
{"", ""}, // Empty string
@@ -520,7 +522,7 @@ func TestMockRepository_DownloadStatusOperations(t *testing.T) {
status := &DownloadStatus{
ID: "download-123",
Version: "6.03",
- Status: "downloading",
+ Status: dlstatus.StatusDownloading,
}
// Test SaveDownloadStatus
@@ -559,7 +561,7 @@ func TestGetBootTypeFromVersion_Boundary(t *testing.T) {
assert.Equal(t, []string{"bios"}, GetBootTypeFromVersion("3.99"))
// Test string comparison behavior
- assert.Equal(t, []string{"bios"}, GetBootTypeFromVersion("3.999")) // String comparison
+ assert.Equal(t, []string{"bios"}, GetBootTypeFromVersion("3.999")) // Numeric comparison
}
// Test configuration validation
@@ -586,3 +588,70 @@ func TestSyslinuxConfig_Validation(t *testing.T) {
assert.Contains(t, config.BaseURL, "https://")
assert.Contains(t, config.BaseURL, "syslinux")
}
+
+// Test that CancelDownload signals the worker context and marks cancelled
+func TestCancelDownload(t *testing.T) {
+ repo := &MockRepository{}
+ ctx := context.Background()
+ svc := NewService(repo, GetDefaultConfig()).(*service)
+
+ id := "download-123"
+ dlCtx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ svc.mu.Lock()
+ svc.cancels[id] = cancel
+ svc.mu.Unlock()
+
+ status := &DownloadStatus{
+ ID: id,
+ Version: "6.03",
+ Status: dlstatus.StatusDownloading,
+ StartedAt: time.Now(),
+ }
+
+ repo.On("GetDownloadStatus", ctx, id).Return(status, nil)
+ repo.On("SaveDownloadStatus", ctx, mock.MatchedBy(func(s *DownloadStatus) bool {
+ return s.Status == dlstatus.StatusCancelled && s.CompletedAt != nil
+ })).Return(nil)
+
+ err := svc.CancelDownload(ctx, id)
+ assert.NoError(t, err)
+ assert.Error(t, dlCtx.Err(), "worker context should have been cancelled")
+ repo.AssertExpectations(t)
+}
+
+// Test that CancelDownload refuses downloads in a terminal state
+func TestCancelDownload_Terminal(t *testing.T) {
+ repo := &MockRepository{}
+ ctx := context.Background()
+ svc := NewService(repo, GetDefaultConfig()).(*service)
+
+ status := &DownloadStatus{ID: "download-123", Status: dlstatus.StatusCompleted}
+ repo.On("GetDownloadStatus", ctx, "download-123").Return(status, nil)
+
+ err := svc.CancelDownload(ctx, "download-123")
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "cannot cancel")
+ repo.AssertExpectations(t)
+}
+
+// Test that worker status updates never overwrite a terminal row
+func TestUpdateStatus_TerminalGuard(t *testing.T) {
+ repo := &MockRepository{}
+ ctx := context.Background()
+ svc := NewService(repo, GetDefaultConfig()).(*service)
+
+ stored := &DownloadStatus{ID: "d1", Status: dlstatus.StatusCancelled}
+ repo.On("GetDownloadStatus", ctx, "d1").Return(stored, nil)
+ // SaveDownloadStatus must NOT be called for a terminal row
+
+ ok := svc.updateStatus(ctx, "d1", dlstatus.StatusDownloading, 50, "")
+ assert.False(t, ok)
+ repo.AssertExpectations(t)
+}
+
+// Test numeric boot-type comparison: 10.x is newer than 4.x
+func TestGetBootTypeFromVersion_Numeric(t *testing.T) {
+ assert.Equal(t, []string{"bios", "efi"}, GetBootTypeFromVersion("10.00"))
+ assert.Equal(t, []string{"bios"}, GetBootTypeFromVersion("3.86"))
+}
diff --git a/tftp/tftp.go b/tftp/tftp.go
old mode 100755
new mode 100644
index c75ade2..04f00b5
--- a/tftp/tftp.go
+++ b/tftp/tftp.go
@@ -1,67 +1,130 @@
package tftp
import (
+ "fmt"
"io"
"log"
"net"
"os"
- "time"
+ "path/filepath"
+ "strings"
+ "sync"
v3 "github.com/pin/tftp/v3"
)
// Server manages the TFTP server, handling file read and write operations.
type Server struct {
- serveDir string
+ serveDir string
+ absServeDir string
+
+ mu sync.Mutex
listener *net.UDPConn
tftpServer *v3.Server
+ wg sync.WaitGroup
+ running bool
}
// NewServer creates and returns a new TFTP server instance with the specified directory for serving files.
func NewServer(serveDir string) *Server {
+ abs, err := filepath.Abs(serveDir)
+ if err != nil {
+ abs = serveDir
+ }
return &Server{
- serveDir: serveDir,
+ serveDir: serveDir,
+ absServeDir: abs,
+ }
+}
+
+// resolvePath maps a client-supplied TFTP filename to a path inside the
+// serve directory. Absolute paths and any path that escapes the serve
+// directory (via ".." segments or otherwise) are rejected.
+func (s *Server) resolvePath(filename string) (string, error) {
+ if filename == "" {
+ return "", fmt.Errorf("tftp: empty filename")
+ }
+ if filepath.IsAbs(filename) {
+ return "", fmt.Errorf("tftp: absolute path %q not allowed", filename)
}
+ cleaned := filepath.Clean(filename)
+ if cleaned == "." || cleaned == ".." || strings.HasPrefix(cleaned, ".."+string(os.PathSeparator)) {
+ return "", fmt.Errorf("tftp: path %q escapes serve directory", filename)
+ }
+ full := filepath.Join(s.absServeDir, cleaned)
+ rel, err := filepath.Rel(s.absServeDir, full)
+ if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
+ return "", fmt.Errorf("tftp: path %q escapes serve directory", filename)
+ }
+ return full, nil
}
// Start initiates the TFTP server, listening for incoming connections on port 69.
+// The UDP socket is bound before Start reports success. It returns an error if
+// the server is already running.
func (s *Server) Start() error {
- var err error
- s.listener, err = net.ListenUDP("udp4", &net.UDPAddr{Port: 69})
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ if s.running {
+ return fmt.Errorf("tftp: server already running")
+ }
+
+ listener, err := net.ListenUDP("udp4", &net.UDPAddr{Port: 69})
if err != nil {
- return err
+ return fmt.Errorf("tftp: failed to bind UDP port 69: %w", err)
}
- s.tftpServer = v3.NewServer(s.readHandler, s.writeHandler)
+ tftpServer := v3.NewServer(s.readHandler, s.writeHandler)
- errChan := make(chan error, 1)
+ s.listener = listener
+ s.tftpServer = tftpServer
+ s.running = true
+ s.wg.Add(1)
go func() {
- err := s.tftpServer.Serve(s.listener)
- if err != nil {
- log.Printf("Server stopped with error: %v", err)
+ defer s.wg.Done()
+ if err := tftpServer.Serve(listener); err != nil {
+ log.Printf("TFTP server stopped with error: %v", err)
}
- errChan <- err
- s.tftpServer.Shutdown()
}()
-
- select {
- case err := <-errChan:
- return err
- case <-time.After(100 * time.Millisecond):
- return nil
- }
+ return nil
}
-// Stop closes the server's listener, effectively stopping the server from accepting new connections.
+// Stop gracefully shuts down the TFTP server: it stops accepting new
+// requests, waits for outstanding transfers to finish, then waits for the
+// serve goroutine to exit. It is safe to call on a server that was never
+// started.
func (s *Server) Stop() {
- if s.listener != nil {
- s.listener.Close()
+ s.mu.Lock()
+ if !s.running {
+ s.mu.Unlock()
+ return
+ }
+ s.running = false
+ tftpServer := s.tftpServer
+ listener := s.listener
+ s.tftpServer = nil
+ s.listener = nil
+ s.mu.Unlock()
+
+ if tftpServer != nil {
+ // Shutdown blocks until outstanding transfers complete.
+ tftpServer.Shutdown()
}
+ if listener != nil {
+ _ = listener.Close()
+ }
+ s.wg.Wait()
}
// readHandler serves file read requests by opening and reading from the specified file in the server's directory.
func (s *Server) readHandler(filename string, rf io.ReaderFrom) error {
- file, err := os.Open(s.serveDir + "/" + filename)
+ filePath, err := s.resolvePath(filename)
+ if err != nil {
+ return err
+ }
+
+ file, err := os.Open(filePath)
if err != nil {
return err
}
@@ -74,7 +137,12 @@ func (s *Server) readHandler(filename string, rf io.ReaderFrom) error {
func (s *Server) writeHandler(filename string, wt io.WriterTo) error {
log.Printf("Write request for %s", filename)
- file, err := os.Create(s.serveDir + "/" + filename)
+ filePath, err := s.resolvePath(filename)
+ if err != nil {
+ return err
+ }
+
+ file, err := os.Create(filePath)
if err != nil {
return err
}
diff --git a/tftp/tftp_test.go b/tftp/tftp_test.go
old mode 100755
new mode 100644
index d455bf3..fb175bb
--- a/tftp/tftp_test.go
+++ b/tftp/tftp_test.go
@@ -98,3 +98,51 @@ func TestTFTPServerCreation(t *testing.T) {
}
// Removed mock types as we're now testing public interface only
+
+func TestResolvePathTraversal(t *testing.T) {
+ serveDir := "./testdata-resolve"
+ if err := os.MkdirAll(serveDir, 0755); err != nil {
+ t.Fatalf("Failed to create test directory: %v", err)
+ }
+ defer os.RemoveAll(serveDir)
+
+ server := NewServer(serveDir)
+
+ // Legitimate relative paths, including subdirectories, must resolve.
+ for _, name := range []string{"pxelinux.0", "boot/pxelinux.0", "a/../b.cfg"} {
+ if _, err := server.resolvePath(name); err != nil {
+ t.Errorf("resolvePath(%q) unexpectedly rejected: %v", name, err)
+ }
+ }
+
+ // Traversal and absolute paths must be rejected.
+ for _, name := range []string{
+ "/etc/passwd",
+ "../escape.txt",
+ "../../escape.txt",
+ "boot/../../../escape.txt",
+ "..",
+ "",
+ } {
+ if _, err := server.resolvePath(name); err == nil {
+ t.Errorf("resolvePath(%q) should have been rejected", name)
+ }
+ }
+}
+
+func TestServerDoubleStart(t *testing.T) {
+ // Skip this test if not running as root (TFTP requires port 69)
+ if os.Getuid() != 0 {
+ t.Skip("Skipping TFTP server test - requires root privileges for port 69")
+ }
+
+ server := NewServer("./testdata-double")
+ if err := server.Start(); err != nil {
+ t.Fatalf("Failed to start server: %v", err)
+ }
+ defer server.Stop()
+
+ if err := server.Start(); err == nil {
+ t.Error("Expected error on double Start, got nil")
+ }
+}