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") + } +}