Skip to content

Commit

Permalink
salt
Browse files Browse the repository at this point in the history
  • Loading branch information
metachris committed Nov 14, 2024
1 parent 262a86b commit df27728
Show file tree
Hide file tree
Showing 7 changed files with 66 additions and 74 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
6 changes: 1 addition & 5 deletions cmd/system-api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 8 additions & 3 deletions systemapi-config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
4 changes: 4 additions & 0 deletions systemapi/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
3 changes: 2 additions & 1 deletion systemapi/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
80 changes: 35 additions & 45 deletions systemapi/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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/")
}
Expand All @@ -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
Expand All @@ -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)
}
Expand All @@ -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")
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down
34 changes: 15 additions & 19 deletions systemapi/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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))

Expand Down

0 comments on commit df27728

Please sign in to comment.