diff --git a/README.md b/README.md index 3d64de1..fe1320b 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,7 @@ $ curl -v -X POST -d "@README.md" localhost:3535/api/v1/file-upload/testfile All API endpoints can be protected with HTTP Basic Auth. The API endpoints are initially unauthenticated, until a secret is configured -either via file or via API. If the secret is configured via API, the SHA256 +either via file or via API. If the secret is configured via API, the salted SHA256 hash is be stored in a file (specified in the config file) to enable basic auth protection across restarts. diff --git a/cmd/system-api/main.go b/cmd/system-api/main.go index 1d513f2..e849644 100644 --- a/cmd/system-api/main.go +++ b/cmd/system-api/main.go @@ -91,11 +91,7 @@ func runCli(cCtx *cli.Context) (err error) { ) // Setup and start the server (in the background) - cfg := &systemapi.HTTPServerConfig{ - Log: log, - Config: config, - } - server, err := systemapi.NewServer(cfg) + server, err := systemapi.NewServer(log, config) if err != nil { log.Error("Error creating server", "err", err) return err diff --git a/systemapi-config.toml b/systemapi-config.toml index 612dcaa..1b8b291 100644 --- a/systemapi-config.toml +++ b/systemapi-config.toml @@ -5,14 +5,19 @@ pprof = true log_json = false log_debug = true -# Enable HTTP Basic auth by setting a file for the hashed secret -basic_auth_secret_path = "basic-auth-secret.txt" +# HTTP Basic Auth +basic_auth_secret_path = "basic-auth-secret.txt" # basic auth is supported if a path is provided +basic_auth_secret_salt = "D;%yL9TS:5PalS/d" # use a random string for the salt + +# HTTP server timeouts +# http_read_timeout_ms = 2500 +# http_write_timeout_ms = 2500 [actions] +echo_test = "echo test" # reboot = "reboot" # rbuilder_restart = "/etc/init.d/rbuilder restart" # rbuilder_stop = "/etc/init.d/rbuilder stop" -echo_test = "echo test" [file_uploads] testfile = "/tmp/testfile.txt" diff --git a/systemapi/config.go b/systemapi/config.go index ff02b41..cd00cfa 100644 --- a/systemapi/config.go +++ b/systemapi/config.go @@ -14,6 +14,10 @@ type systemAPIConfigGeneral struct { EnablePprof bool `toml:"pprof"` // Enables pprof endpoints BasicAuthSecretPath string `toml:"basic_auth_secret_path"` + BasicAuthSecretSalt string `toml:"basic_auth_secret_salt"` + + HTTPReadTimeoutMillis int `toml:"http_read_timeout_ms"` + HTTPWriteTimeoutMillis int `toml:"http_write_timeout_ms"` } type SystemAPIConfig struct { diff --git a/systemapi/middleware.go b/systemapi/middleware.go index 0fc7310..c7ee1d7 100644 --- a/systemapi/middleware.go +++ b/systemapi/middleware.go @@ -9,7 +9,7 @@ import ( ) // BasicAuth implements a simple middleware handler for adding basic http auth to a route. -func BasicAuth(realm string, getHashedCredentials func() map[string]string) func(next http.Handler) http.Handler { +func BasicAuth(realm, salt string, getHashedCredentials func() map[string]string) func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Loading credentials dynamically because they can be updated at runtime @@ -31,6 +31,7 @@ func BasicAuth(realm string, getHashedCredentials func() map[string]string) func // Hash the password and see if credentials are allowed h := sha256.New() h.Write([]byte(pass)) + h.Write([]byte(salt)) userPassHash := hex.EncodeToString(h.Sum(nil)) // Compare to allowed credentials diff --git a/systemapi/server.go b/systemapi/server.go index efe3a79..214decd 100644 --- a/systemapi/server.go +++ b/systemapi/server.go @@ -23,25 +23,14 @@ import ( "github.com/go-chi/httplog/v2" ) -type HTTPServerConfig struct { - Config *SystemAPIConfig - Log *httplog.Logger - - DrainDuration time.Duration - GracefulShutdownDuration time.Duration - ReadTimeout time.Duration - WriteTimeout time.Duration -} - type Event struct { ReceivedAt time.Time `json:"received_at"` Message string `json:"message"` } type Server struct { - cfg *HTTPServerConfig + cfg *SystemAPIConfig log *httplog.Logger - srv *http.Server events []Event @@ -50,68 +39,68 @@ type Server struct { basicAuthHash string } -func NewServer(cfg *HTTPServerConfig) (srv *Server, err error) { - srv = &Server{ +func NewServer(log *httplog.Logger, cfg *SystemAPIConfig) (server *Server, err error) { + server = &Server{ cfg: cfg, - log: cfg.Log, + log: log, srv: nil, events: make([]Event, 0), } // Load (or create) file with basic auth secret hash - err = srv.loadBasicAuthSecretFromFile() + err = server.loadBasicAuthSecretFromFile() if err != nil { return nil, err } // Setup the pipe file - if cfg.Config.General.PipeFile != "" { - os.Remove(cfg.Config.General.PipeFile) - err := syscall.Mknod(cfg.Config.General.PipeFile, syscall.S_IFIFO|0o666, 0) + if cfg.General.PipeFile != "" { + os.Remove(cfg.General.PipeFile) + err := syscall.Mknod(cfg.General.PipeFile, syscall.S_IFIFO|0o666, 0) if err != nil { return nil, err } - go srv.readPipeInBackground() + go server.readPipeInBackground() } // Create the HTTP server - srv.srv = &http.Server{ - Addr: cfg.Config.General.ListenAddr, - Handler: srv.getRouter(), - ReadTimeout: cfg.ReadTimeout, - WriteTimeout: cfg.WriteTimeout, + server.srv = &http.Server{ + Addr: cfg.General.ListenAddr, + Handler: server.getRouter(), + ReadTimeout: time.Duration(cfg.General.HTTPReadTimeoutMillis) * time.Millisecond, + WriteTimeout: time.Duration(cfg.General.HTTPWriteTimeoutMillis) * time.Millisecond, } - return srv, nil + return server, nil } func (s *Server) loadBasicAuthSecretFromFile() error { - if s.cfg.Config.General.BasicAuthSecretPath == "" { + if s.cfg.General.BasicAuthSecretPath == "" { return nil } // Create if the file does not exist - if _, err := os.Stat(s.cfg.Config.General.BasicAuthSecretPath); os.IsNotExist(err) { - err = os.WriteFile(s.cfg.Config.General.BasicAuthSecretPath, []byte{}, 0o600) + if _, err := os.Stat(s.cfg.General.BasicAuthSecretPath); os.IsNotExist(err) { + err = os.WriteFile(s.cfg.General.BasicAuthSecretPath, []byte{}, 0o600) if err != nil { return fmt.Errorf("failed to create basic auth secret file: %w", err) } - s.cfg.Log.Info("Basic auth file created, auth disabled until secret is configured", "file", s.cfg.Config.General.BasicAuthSecretPath) + s.log.Info("Basic auth file created, auth disabled until secret is configured", "file", s.cfg.General.BasicAuthSecretPath) s.basicAuthHash = "" return nil } // Read the secret from the file - secret, err := os.ReadFile(s.cfg.Config.General.BasicAuthSecretPath) + secret, err := os.ReadFile(s.cfg.General.BasicAuthSecretPath) if err != nil { return fmt.Errorf("failed to read basic auth secret file: %w", err) } if len(secret) == 0 { - s.cfg.Log.Info("Basic auth file without secret loaded, auth disabled until secret is configured", "file", s.cfg.Config.General.BasicAuthSecretPath) + s.log.Info("Basic auth file without secret loaded, auth disabled until secret is configured", "file", s.cfg.General.BasicAuthSecretPath) } else { - s.cfg.Log.Info("Basic auth enabled", "file", s.cfg.Config.General.BasicAuthSecretPath) + s.log.Info("Basic auth enabled", "file", s.cfg.General.BasicAuthSecretPath) } s.basicAuthHash = string(secret) return nil @@ -124,7 +113,7 @@ func (s *Server) getRouter() http.Handler { mux.Use(middleware.Recoverer) // Enable a custom HTTP Basic Auth middleware - mux.Use(BasicAuth("system-api", s.getBasicAuthHashedCredentials)) + mux.Use(BasicAuth("system-api", s.cfg.General.BasicAuthSecretSalt, s.getBasicAuthHashedCredentials)) // Common APIs mux.Get("/", s.handleLivenessCheck) @@ -145,7 +134,7 @@ func (s *Server) getRouter() http.Handler { mux.Post("/api/v1/file-upload/{file}", s.handleFileUpload) // Optionally, pprof - if s.cfg.Config.General.EnablePprof { + if s.cfg.General.EnablePprof { mux.Mount("/debug", middleware.Profiler()) s.log.Info("pprof API enabled: /debug/pprof/") } @@ -154,7 +143,7 @@ func (s *Server) getRouter() http.Handler { } func (s *Server) readPipeInBackground() { - file, err := os.OpenFile(s.cfg.Config.General.PipeFile, os.O_CREATE, os.ModeNamedPipe) + file, err := os.OpenFile(s.cfg.General.PipeFile, os.O_CREATE, os.ModeNamedPipe) if err != nil { s.log.Error("Open named pipe file error:", "error", err) return @@ -176,7 +165,7 @@ func (s *Server) readPipeInBackground() { } func (s *Server) Start() { - s.log.Info("Starting HTTP server", "listenAddress", s.cfg.Config.General.ListenAddr) + s.log.Info("Starting HTTP server", "listenAddress", s.cfg.General.ListenAddr) if err := s.srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { s.log.Error("HTTP server failed", "err", err) } @@ -189,8 +178,8 @@ func (s *Server) Shutdown(ctx context.Context) error { s.log.Error("HTTP server shutdown failed", "err", err) } - if s.cfg.Config.General.PipeFile != "" { - os.Remove(s.cfg.Config.General.PipeFile) + if s.cfg.General.PipeFile != "" { + os.Remove(s.cfg.General.PipeFile) } s.log.Info("HTTP server shutdown") @@ -273,12 +262,12 @@ func (s *Server) handleAction(w http.ResponseWriter, r *http.Request) { action := chi.URLParam(r, "action") s.log.Info("Received action", "action", action) - if s.cfg.Config == nil { + if s.cfg == nil { w.WriteHeader(http.StatusNotImplemented) return } - cmd, ok := s.cfg.Config.Actions[action] + cmd, ok := s.cfg.Actions[action] if !ok { w.WriteHeader(http.StatusBadRequest) return @@ -305,12 +294,12 @@ func (s *Server) handleFileUpload(w http.ResponseWriter, r *http.Request) { log := s.log.With("file", fileArg) log.Info("Receiving file upload") - if s.cfg.Config == nil { + if s.cfg == nil { w.WriteHeader(http.StatusNotImplemented) return } - filename, ok := s.cfg.Config.FileUploads[fileArg] + filename, ok := s.cfg.FileUploads[fileArg] if !ok { w.WriteHeader(http.StatusBadRequest) return @@ -354,7 +343,7 @@ func (s *Server) getBasicAuthHashedCredentials() map[string]string { } func (s *Server) handleSetBasicAuthCreds(w http.ResponseWriter, r *http.Request) { - if s.cfg.Config.General.BasicAuthSecretPath == "" { + if s.cfg.General.BasicAuthSecretPath == "" { s.log.Warn("Basic auth secret path not set") w.WriteHeader(http.StatusNotImplemented) return @@ -371,10 +360,11 @@ func (s *Server) handleSetBasicAuthCreds(w http.ResponseWriter, r *http.Request) // Create hash of the secret h := sha256.New() h.Write(secret) + h.Write([]byte(s.cfg.General.BasicAuthSecretSalt)) secretHash := hex.EncodeToString(h.Sum(nil)) // write secret to file - err = os.WriteFile(s.cfg.Config.General.BasicAuthSecretPath, []byte(secretHash), 0o600) + err = os.WriteFile(s.cfg.General.BasicAuthSecretPath, []byte(secretHash), 0o600) if err != nil { s.log.Error("Failed to write secret to file", "err", err) w.WriteHeader(http.StatusInternalServerError) diff --git a/systemapi/server_test.go b/systemapi/server_test.go index 1dd37dd..0c4f782 100644 --- a/systemapi/server_test.go +++ b/systemapi/server_test.go @@ -22,11 +22,11 @@ func getTestLogger() *httplog.Logger { }) } -func getTestConfig() *HTTPServerConfig { - return &HTTPServerConfig{ - Log: getTestLogger(), - Config: NewSystemAPIConfig(), - } +func newTestServer(t *testing.T) *Server { + t.Helper() + srv, err := NewServer(getTestLogger(), NewSystemAPIConfig()) + require.NoError(t, err) + return srv } // Helper to execute an API request with optional basic auth @@ -61,8 +61,7 @@ func createRequestRunner(t *testing.T, router http.Handler, method, url string) func TestGeneralHandlers(t *testing.T) { // Instantiate the server - srv, err := NewServer(getTestConfig()) - require.NoError(t, err) + srv := newTestServer(t) router := srv.getRouter() // Test /livez @@ -94,31 +93,28 @@ func TestGeneralHandlers(t *testing.T) { func TestBasicAuth(t *testing.T) { tempDir := t.TempDir() basicAuthSecret := []byte("secret") + basicAuthSalt := "salt" // Create a hash of the basic auth secret h := sha256.New() h.Write(basicAuthSecret) + h.Write([]byte(basicAuthSalt)) basicAuthSecretHash := hex.EncodeToString(h.Sum(nil)) // Create the config - cfg := getTestConfig() - cfg.Config.General.BasicAuthSecretPath = tempDir + "/basic_auth_secret" + cfg := NewSystemAPIConfig() + cfg.General.BasicAuthSecretPath = tempDir + "/basic_auth_secret" + cfg.General.BasicAuthSecretSalt = basicAuthSalt // Create the server instance - _, err := NewServer(cfg) + srv, err := NewServer(getTestLogger(), cfg) require.NoError(t, err) // Ensure the basic auth secret file was created - _, err = os.Stat(cfg.Config.General.BasicAuthSecretPath) + _, err = os.Stat(cfg.General.BasicAuthSecretPath) require.NoError(t, err) - // Create the temporary file to store the basic auth secret - err = os.WriteFile(cfg.Config.General.BasicAuthSecretPath, []byte{}, 0o600) - require.NoError(t, err) - - // Server will work now - srv, err := NewServer(cfg) - require.NoError(t, err) + // Get the router router := srv.getRouter() // Prepare request helpers @@ -138,7 +134,7 @@ func TestBasicAuth(t *testing.T) { require.Equal(t, http.StatusOK, code) // Ensure hash was written to file and is reproducible - secretFromFile, err := os.ReadFile(cfg.Config.General.BasicAuthSecretPath) + secretFromFile, err := os.ReadFile(cfg.General.BasicAuthSecretPath) require.NoError(t, err) require.Equal(t, basicAuthSecretHash, string(secretFromFile))