diff --git a/bootserver/bootoption/bootoption.go b/bootserver/bootoption/bootoption.go index d4ef016..18a16e4 100644 --- a/bootserver/bootoption/bootoption.go +++ b/bootserver/bootoption/bootoption.go @@ -3,15 +3,13 @@ package bootoption import "github.com/google/uuid" type EFIApp struct { - Name string `json:"name"` - Description string `json:"description"` - Path string `json:"path"` + Name string `json:"name"` + Path string `json:"path"` } type Client struct { ID uuid.UUID - IP string `json:"ip"` - Name string `json:"name"` - Options []EFIApp `json:"options"` - SelectedOption string `json:"selected_option"` + Name string `json:"name"` + Options map[string]EFIApp `json:"options"` + SelectedOption string `json:"selected_option"` } diff --git a/bootserver/config/config.go b/bootserver/config/config.go index c0a9f7f..9aabc58 100644 --- a/bootserver/config/config.go +++ b/bootserver/config/config.go @@ -42,6 +42,9 @@ type jsonConf struct { Log struct { Level string `json:"level"` } `json:"log"` + Storage struct { + Path string `json:"path"` + } `json:"storage"` Server struct { Host string `json:"host"` Port int `json:"port"` @@ -58,6 +61,7 @@ type jsonConf struct { type AppConfig struct { LogLevel logrus.Level ServerMode ListeningMode + DataFilepath string Host string Port int SockPath string @@ -93,12 +97,14 @@ func (ac *AppConfig) UnmarshalJSON(data []byte) error { ac.UPDMcastGroup = jsonConf.BootProvider.McastGroup ac.UDPIface = jsonConf.BootProvider.Iface ac.UDPPort = jsonConf.BootProvider.Port + ac.DataFilepath = jsonConf.Storage.Path return nil } var defaultConfig AppConfig = AppConfig{ LogLevel: logrus.InfoLevel, ServerMode: ModeNet, + DataFilepath: "boot_options.json", Host: "0.0.0.0", Port: 5000, UPDMcastGroup: "ff02::abcd:1234", diff --git a/bootserver/controllers/client/enroll.go b/bootserver/controllers/client/enroll.go index aad8939..1c8f9ae 100644 --- a/bootserver/controllers/client/enroll.go +++ b/bootserver/controllers/client/enroll.go @@ -4,7 +4,6 @@ import ( "encoding/json" "fmt" "io" - "net" "net/http" "git.faercol.me/faercol/http-boot-server/bootserver/bootoption" @@ -31,10 +30,6 @@ func (ec *EnrollController) enrollMachine(w http.ResponseWriter, r *http.Request if r.Method != http.MethodPost { return http.StatusMethodNotAllowed, nil } - clientIP, _, err := net.SplitHostPort(r.RemoteAddr) - if err != nil { - return http.StatusInternalServerError, fmt.Errorf("failed to read remote IP: %w", err) - } dat, err := io.ReadAll(r.Body) if err != nil { @@ -45,10 +40,9 @@ func (ec *EnrollController) enrollMachine(w http.ResponseWriter, r *http.Request if err := json.Unmarshal(dat, &client); err != nil { return http.StatusInternalServerError, fmt.Errorf("failed to parse body: %w", err) } - client.IP = clientIP ec.clientService.AddClient(&client) - ec.l.Infof("Added client %s", clientIP) + ec.l.Infof("Added client") return http.StatusAccepted, nil } diff --git a/bootserver/filelock/filelock.go b/bootserver/filelock/filelock.go new file mode 100644 index 0000000..b0488b5 --- /dev/null +++ b/bootserver/filelock/filelock.go @@ -0,0 +1,54 @@ +package filelock + +import ( + "errors" + "fmt" + "os" + "path" + "time" +) + +var ErrLocked = errors.New("failed to get a lock in a timely manner") + +type FileLock struct { + lockPath string +} + +func New(filepath string) *FileLock { + dir, filename := path.Split(filepath) + lockPath := path.Join(dir, fmt.Sprintf(".%s.lock", filename)) + return &FileLock{ + lockPath: lockPath, + } +} + +func (fl *FileLock) lockFileExists() bool { + if _, err := os.Stat(fl.lockPath); errors.Is(err, os.ErrNotExist) { + return false + } + return true +} + +func (fl *FileLock) Lock(timeout time.Duration) error { + start := time.Now().UTC() + end := start.Add(timeout) + + for { + if !fl.lockFileExists() { + if _, err := os.Create(fl.lockPath); err != nil { + return fmt.Errorf("failed to create lock: %w", err) + } + } + if time.Now().After(end) { + return ErrLocked + } + time.Sleep(100 * time.Millisecond) + } +} + +func (fl *FileLock) Unlock() error { + if err := os.Remove(fl.lockPath); err != nil { + return fmt.Errorf("failed to remove lock: %w", err) + } + return nil +} diff --git a/bootserver/server/server.go b/bootserver/server/server.go index 720f8a4..a0b83f7 100644 --- a/bootserver/server/server.go +++ b/bootserver/server/server.go @@ -64,9 +64,8 @@ func New(appConf *config.AppConfig, logger *logrus.Logger) (*Server, error) { default: panic(fmt.Errorf("unexpected listening mode %v", appConf.ServerMode)) } - service := services.NewClientHandlerService() + service := services.NewClientHandlerService(appConf.DataFilepath, logger) controllers := map[string]http.Handler{ - // client.BootRoute: middlewares.WithLogger(client.NewBootController(logger, service), logger), client.EnrollRoute: middlewares.WithLogger(client.NewEnrollController(logger, service), logger), } diff --git a/bootserver/services/services.go b/bootserver/services/services.go index ac74d5b..9c845f7 100644 --- a/bootserver/services/services.go +++ b/bootserver/services/services.go @@ -1,32 +1,109 @@ package services import ( + "encoding/json" "errors" + "fmt" + "os" + "time" "git.faercol.me/faercol/http-boot-server/bootserver/bootoption" + "git.faercol.me/faercol/http-boot-server/bootserver/filelock" "github.com/google/uuid" + "github.com/sirupsen/logrus" ) var ErrUnknownClient = errors.New("unknown client") var ErrUnselectedBootOption = errors.New("unselected boot option") var ErrUnknownBootOption = errors.New("unknown boot option") +const defaultLockTimeout = 1 * time.Second + type ClientHandlerService struct { - clients map[uuid.UUID]*bootoption.Client + filepath string + fileLock *filelock.FileLock + lockTimeout time.Duration + logger *logrus.Logger } -func NewClientHandlerService() *ClientHandlerService { +func NewClientHandlerService(filepath string, logger *logrus.Logger) *ClientHandlerService { return &ClientHandlerService{ - clients: make(map[uuid.UUID]*bootoption.Client), + filepath: filepath, + fileLock: filelock.New(filepath), + lockTimeout: defaultLockTimeout, + logger: logger, } } -func (chs *ClientHandlerService) AddClient(client *bootoption.Client) { - chs.clients[client.ID] = client +func (chs *ClientHandlerService) init() { + if _, err := os.Open(chs.filepath); errors.Is(err, os.ErrNotExist) { + if err := os.WriteFile(chs.filepath, nil, 0o644); err != nil { + panic(fmt.Errorf("failed to init data file: %w", err)) + } + } +} + +func (chs *ClientHandlerService) unload(conf map[uuid.UUID]*bootoption.Client) error { + dat, err := json.Marshal(conf) + if err != nil { + return fmt.Errorf("failed to marshal data to JSON: %w", err) + } + + if err := os.WriteFile(chs.filepath, dat, 0o644); err != nil { + return fmt.Errorf("failed to commit to the data file: %w", err) + } + if err := chs.fileLock.Unlock(); err != nil { + return fmt.Errorf("failed to release the lock to data file: %w", err) + } + return nil +} + +func (chs *ClientHandlerService) load() (map[uuid.UUID]*bootoption.Client, error) { + conf := make(map[uuid.UUID]*bootoption.Client) + + if err := chs.fileLock.Lock(chs.lockTimeout); err != nil { + return nil, fmt.Errorf("failed to obtain a lock to the data file: %w", err) + } + + dat, err := os.ReadFile(chs.filepath) + if err != nil { + return nil, fmt.Errorf("failed to read data file: %w", err) + } + if err := json.Unmarshal(dat, &conf); err != nil { + return nil, fmt.Errorf("failed to parse data file: %w", err) + } + return conf, nil +} + +func (chs *ClientHandlerService) unloadNoCommmit(conf map[uuid.UUID]*bootoption.Client) { + if err := chs.unload(conf); err != nil { + chs.logger.Errorf("Failed to unload config: %q", err.Error()) + } +} + +func (chs *ClientHandlerService) AddClient(client *bootoption.Client) (uuid.UUID, error) { + clients, err := chs.load() + if err != nil { + return uuid.Nil, fmt.Errorf("failed to load current config: %w", err) + } + + client.ID = uuid.New() + clients[client.ID] = client + + if err := chs.unload(clients); err != nil { + return uuid.Nil, fmt.Errorf("failed to save current config: %w", err) + } + return client.ID, nil } func (chs *ClientHandlerService) GetClientSelectedBootOption(client uuid.UUID) (*bootoption.EFIApp, error) { - clientDetails, ok := chs.clients[client] + clients, err := chs.load() + if err != nil { + return nil, fmt.Errorf("failed to load current config: %w", err) + } + defer chs.unloadNoCommmit(clients) + + clientDetails, ok := clients[client] if !ok { return nil, ErrUnknownClient } @@ -35,25 +112,39 @@ func (chs *ClientHandlerService) GetClientSelectedBootOption(client uuid.UUID) ( return nil, ErrUnselectedBootOption } - for _, o := range clientDetails.Options { - if o.Name == clientDetails.SelectedOption { - return &o, nil - } + if option, ok := clientDetails.Options[clientDetails.SelectedOption]; !ok { + return nil, ErrUnknownBootOption + } else { + return &option, nil } - return nil, ErrUnknownBootOption } func (chs *ClientHandlerService) SetClientBootOption(client uuid.UUID, option string) error { - clientDetails, ok := chs.clients[client] - if !ok { - return ErrUnknownClient + var err error + + clients, err := chs.load() + if err != nil { + return fmt.Errorf("failed to load current config: %w", err) } - for _, o := range clientDetails.Options { - if o.Name == option { + clientDetails, ok := clients[client] + if !ok { + err = ErrUnknownClient + } else { + if _, ok := clientDetails.Options[option]; !ok { + err = ErrUnknownBootOption + } else { clientDetails.SelectedOption = option - return nil } } - return ErrUnknownBootOption + + if err != nil { + defer chs.unloadNoCommmit(clients) + return err + } + + if err := chs.unload(clients); err != nil { + return fmt.Errorf("failed to save current config: %w", err) + } + return nil }