diff --git a/bootserver/bootoption/bootoption.go b/bootserver/bootoption/bootoption.go new file mode 100644 index 0000000..f8fae79 --- /dev/null +++ b/bootserver/bootoption/bootoption.go @@ -0,0 +1,14 @@ +package bootoption + +type EFIApp struct { + Name string `json:"name"` + Description string `json:"description"` + Path string `json:"path"` +} + +type Client struct { + IP string `json:"ip"` + Name string `json:"name"` + Options []EFIApp `json:"options"` + SelectedOption string `json:"selected_option"` +} diff --git a/bootserver/controllers/client/client.go b/bootserver/controllers/client/client.go new file mode 100644 index 0000000..26a0780 --- /dev/null +++ b/bootserver/controllers/client/client.go @@ -0,0 +1,85 @@ +package client + +import ( + "encoding/json" + "fmt" + "io" + "net" + "net/http" + + "git.faercol.me/faercol/http-boot-server/bootserver/helpers" + "git.faercol.me/faercol/http-boot-server/bootserver/services" + "github.com/sirupsen/logrus" +) + +const BootRoute = "/boot" + +type BootController struct { + clientService *services.ClientHandlerService + l *logrus.Logger +} + +func NewBootController(logger *logrus.Logger) *BootController { + return &BootController{ + clientService: services.NewClientHandlerService(), + l: logger, + } +} + +func (bc *BootController) getBootOption(clientIP string, w http.ResponseWriter, r *http.Request) (int, []byte, error) { + bootOption, err := bc.clientService.GetClientSelectedBootOption(clientIP) + if err != nil { + return http.StatusInternalServerError, nil, fmt.Errorf("failed to get boot option: %w", err) + } + + dat, err := json.Marshal(bootOption) + if err != nil { + return http.StatusInternalServerError, nil, fmt.Errorf("failed to serialize body") + } + + w.Header().Add("Content-Type", "application/json") + return http.StatusOK, dat, nil +} + +func (bc *BootController) setBootOption(clientIP string, w http.ResponseWriter, r *http.Request) (int, error) { + dat, err := io.ReadAll(r.Body) + if err != nil { + return http.StatusInternalServerError, fmt.Errorf("failed to read body: %w", err) + } + var option string + if err := json.Unmarshal(dat, &option); err != nil { + return http.StatusInternalServerError, fmt.Errorf("failed to parse body: %w", err) + } + + if err := bc.clientService.SetClientBootOption(clientIP, option); err != nil { + return http.StatusInternalServerError, fmt.Errorf("failed to set boot option for client: %w", err) + } + + return http.StatusAccepted, nil +} + +func (bc *BootController) ServeHTTP(w http.ResponseWriter, r *http.Request) { + clientIP, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + bc.l.Errorf("Failed to read remote IP: %s", err.Error()) + helpers.HandleResponse(w, r, http.StatusInternalServerError, nil, bc.l) + return + } + + var returncode int + var content []byte + + switch r.Method { + case http.MethodGet: + returncode, content, err = bc.getBootOption(clientIP, w, r) + case http.MethodPut: + returncode, err = bc.setBootOption(clientIP, w, r) + default: + returncode = http.StatusMethodNotAllowed + } + + if err != nil { + bc.l.Errorf("An error occured while handling boot request: %q", err.Error()) + } + helpers.HandleResponse(w, r, returncode, content, bc.l) +} diff --git a/bootserver/controllers/client/enroll.go b/bootserver/controllers/client/enroll.go new file mode 100644 index 0000000..a68ab1f --- /dev/null +++ b/bootserver/controllers/client/enroll.go @@ -0,0 +1,61 @@ +package client + +import ( + "encoding/json" + "fmt" + "io" + "net" + "net/http" + + "git.faercol.me/faercol/http-boot-server/bootserver/bootoption" + "git.faercol.me/faercol/http-boot-server/bootserver/helpers" + "git.faercol.me/faercol/http-boot-server/bootserver/services" + "github.com/sirupsen/logrus" +) + +const EnrollRoute = "/enroll" + +type EnrollController struct { + clientService *services.ClientHandlerService + l *logrus.Logger +} + +func NewEnrollController(l *logrus.Logger) *EnrollController { + return &EnrollController{ + clientService: services.NewClientHandlerService(), + l: l, + } +} + +func (ec *EnrollController) enrollMachine(w http.ResponseWriter, r *http.Request) (int, error) { + 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 { + return http.StatusInternalServerError, fmt.Errorf("failed to read body: %w", err) + } + + var client bootoption.Client + 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) + return http.StatusAccepted, nil +} + +func (ec *EnrollController) ServeHTTP(w http.ResponseWriter, r *http.Request) { + returncode, err := ec.enrollMachine(w, r) + if err != nil { + ec.l.Errorf("Error handling client enrollement: %s", err.Error()) + } + helpers.HandleResponse(w, r, returncode, nil, ec.l) +} diff --git a/bootserver/helpers/helpers.go b/bootserver/helpers/helpers.go new file mode 100644 index 0000000..5a6e659 --- /dev/null +++ b/bootserver/helpers/helpers.go @@ -0,0 +1,31 @@ +package helpers + +import ( + "context" + "net/http" + + "github.com/sirupsen/logrus" +) + +type ContextKey string + +const ResponseInfoKey ContextKey = "response_info" + +type ResponseInfo struct { + ReturnCode int + ContentLength int +} + +func HandleResponse(w http.ResponseWriter, r *http.Request, returncode int, content []byte, l *logrus.Logger) { + w.WriteHeader(returncode) + n, err := w.Write(content) + if err != nil { + l.Errorf("Failed to write content to response: %q", err.Error()) + } + if n != len(content) { + l.Errorf("Failed to write the entire response (%d/%d)", n, len(content)) + } + + contextedReq := r.WithContext(context.WithValue(r.Context(), ResponseInfoKey, ResponseInfo{ReturnCode: returncode, ContentLength: len(content)})) + *r = *contextedReq +} diff --git a/bootserver/logger/logger.go b/bootserver/logger/logger.go index 31b8a0e..25a2c04 100644 --- a/bootserver/logger/logger.go +++ b/bootserver/logger/logger.go @@ -7,4 +7,7 @@ var L *logrus.Logger func Init(level logrus.Level) { L = logrus.New() L.SetLevel(level) + L.SetFormatter(&logrus.TextFormatter{ + ForceColors: true, + }) } diff --git a/bootserver/middlewares/logger.go b/bootserver/middlewares/logger.go new file mode 100644 index 0000000..df74f18 --- /dev/null +++ b/bootserver/middlewares/logger.go @@ -0,0 +1,39 @@ +package middlewares + +import ( + "net" + "net/http" + "time" + + "git.faercol.me/faercol/http-boot-server/bootserver/helpers" + "github.com/sirupsen/logrus" +) + +var defaultResponseInfo = helpers.ResponseInfo{ + ReturnCode: -1, + ContentLength: -1, +} + +type LoggerMiddleware struct { + l *logrus.Logger +} + +func (lm *LoggerMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) { + responseInfo, ok := r.Context().Value(helpers.ResponseInfoKey).(helpers.ResponseInfo) + if !ok { + lm.l.Errorf("Failed to read response info from context, got %v", r.Context().Value("response_info")) + responseInfo = defaultResponseInfo + } + method := r.Method + route := r.RequestURI + currentTime := time.Now().UTC() + httpVersion := r.Proto + + clientIP, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + lm.l.Errorf("Failed to read remote IP: %s", err.Error()) + clientIP = "unknown" + } + + lm.l.Infof(`%s - [%v] "%s %s %s" %d %d`, clientIP, currentTime, method, route, httpVersion, responseInfo.ReturnCode, responseInfo.ContentLength) +} diff --git a/bootserver/middlewares/middlewarechain.go b/bootserver/middlewares/middlewarechain.go new file mode 100644 index 0000000..ac5070d --- /dev/null +++ b/bootserver/middlewares/middlewarechain.go @@ -0,0 +1,23 @@ +package middlewares + +import ( + "net/http" + + "github.com/sirupsen/logrus" +) + +type MiddlewareChains struct { + handlers []http.Handler +} + +func (mc *MiddlewareChains) ServeHTTP(rw http.ResponseWriter, r *http.Request) { + for _, h := range mc.handlers { + h.ServeHTTP(rw, r) + } +} + +func WithLogger(handler http.Handler, l *logrus.Logger) *MiddlewareChains { + return &MiddlewareChains{ + handlers: []http.Handler{handler, &LoggerMiddleware{l}}, + } +} diff --git a/bootserver/server/server.go b/bootserver/server/server.go index 143f930..852ef68 100644 --- a/bootserver/server/server.go +++ b/bootserver/server/server.go @@ -8,19 +8,24 @@ import ( "net/http" "os" + "git.faercol.me/faercol/http-boot-server/bootserver/bootoption" "git.faercol.me/faercol/http-boot-server/bootserver/config" + "git.faercol.me/faercol/http-boot-server/bootserver/controllers/client" + "git.faercol.me/faercol/http-boot-server/bootserver/middlewares" "github.com/sirupsen/logrus" ) type Server struct { - ctx context.Context - cancel context.CancelFunc - httpSrv *http.Server - listener net.Listener - serverMode config.ListeningMode - address string - handler *http.ServeMux - l *logrus.Logger + ctx context.Context + cancel context.CancelFunc + httpSrv *http.Server + listener net.Listener + serverMode config.ListeningMode + address string + handler *http.ServeMux + l *logrus.Logger + clients map[string]bootoption.Client + controllers map[string]http.Handler } func newUnixListener(sockPath string) (net.Listener, error) { @@ -59,6 +64,11 @@ func New(appConf *config.AppConfig, logger *logrus.Logger) (*Server, error) { panic(fmt.Errorf("unexpected listening mode %v", appConf.ServerMode)) } + controllers := map[string]http.Handler{ + client.BootRoute: middlewares.WithLogger(client.NewBootController(logger), logger), + client.EnrollRoute: middlewares.WithLogger(client.NewEnrollController(logger), logger), + } + m := http.NewServeMux() return &Server{ @@ -66,15 +76,19 @@ func New(appConf *config.AppConfig, logger *logrus.Logger) (*Server, error) { httpSrv: &http.Server{ Handler: m, }, - listener: listener, - l: logger, - serverMode: appConf.ServerMode, - address: addr, + listener: listener, + l: logger, + serverMode: appConf.ServerMode, + address: addr, + clients: make(map[string]bootoption.Client), + controllers: controllers, }, nil } func (s *Server) initMux() { - s.handler.HandleFunc("/", s.statusHandler) + for r, c := range s.controllers { + s.handler.Handle(r, c) + } } func (s *Server) Run(ctx context.Context) { @@ -96,12 +110,3 @@ func (s *Server) Run(ctx context.Context) { func (s *Server) Done() <-chan struct{} { return s.ctx.Done() } - -func (s *Server) statusHandler(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - w.WriteHeader(http.StatusMethodNotAllowed) - return - } - - w.Write([]byte("Hello world!")) -} diff --git a/bootserver/services/services.go b/bootserver/services/services.go new file mode 100644 index 0000000..a09d35a --- /dev/null +++ b/bootserver/services/services.go @@ -0,0 +1,58 @@ +package services + +import ( + "errors" + + "git.faercol.me/faercol/http-boot-server/bootserver/bootoption" +) + +var ErrUnknownClient = errors.New("unknown client") +var ErrUnselectedBootOption = errors.New("unselected boot option") +var ErrUnknownBootOption = errors.New("unknown boot option") + +type ClientHandlerService struct { + clients map[string]*bootoption.Client +} + +func NewClientHandlerService() *ClientHandlerService { + return &ClientHandlerService{ + clients: make(map[string]*bootoption.Client), + } +} + +func (chs *ClientHandlerService) AddClient(client *bootoption.Client) { + chs.clients[client.IP] = client +} + +func (chs *ClientHandlerService) GetClientSelectedBootOption(client string) (*bootoption.EFIApp, error) { + clientDetails, ok := chs.clients[client] + if !ok { + return nil, ErrUnknownClient + } + + if clientDetails.SelectedOption == "" { + return nil, ErrUnselectedBootOption + } + + for _, o := range clientDetails.Options { + if o.Name == clientDetails.SelectedOption { + return &o, nil + } + } + return nil, ErrUnknownBootOption +} + +func (chs *ClientHandlerService) SetClientBootOption(client, option string) error { + clientDetails, ok := chs.clients[client] + if !ok { + return ErrUnknownClient + } + + for _, o := range clientDetails.Options { + if o.Name == option { + clientDetails.SelectedOption = option + return nil + } + } + return ErrUnknownBootOption +}