Files
zot/pkg/api/htpasswd.go
T
Vladimir Ermakov 3893eec714 feat(htpasswd): add autoreload for htpasswd (#2933)
* feat(htpasswd): move htpasswd processing to a helper struct and add reload

Signed-off-by: Vladimir Ermakov <vooon341@gmail.com>

* feat(htpasswd): use dedicated fsnotify reloader for htpasswd file

- rewrite htpasswd watcher not to store context
- improve logging

Signed-off-by: Vladimir Ermakov <vooon341@gmail.com>

* feat(htpasswd): add htpasswd reload test

Signed-off-by: Vladimir Ermakov <vooon341@gmail.com>

---------

Signed-off-by: Vladimir Ermakov <vooon341@gmail.com>
2025-02-27 12:42:57 +02:00

199 lines
4.1 KiB
Go

package api
import (
"bufio"
"context"
"errors"
"os"
"os/signal"
"strings"
"sync"
"syscall"
"github.com/fsnotify/fsnotify"
"golang.org/x/crypto/bcrypt"
"zotregistry.dev/zot/pkg/log"
)
// HTPasswd user auth store
//
// Currently supports only bcrypt hashes.
type HTPasswd struct {
mu sync.RWMutex
credMap map[string]string
log log.Logger
}
func NewHTPasswd(log log.Logger) *HTPasswd {
return &HTPasswd{
credMap: make(map[string]string),
log: log,
}
}
func (s *HTPasswd) Reload(filePath string) error {
credMap := make(map[string]string)
credsFile, err := os.Open(filePath)
if err != nil {
s.log.Error().Err(err).Str("htpasswd-file", filePath).Msg("failed to reload htpasswd")
return err
}
defer credsFile.Close()
scanner := bufio.NewScanner(credsFile)
for scanner.Scan() {
user, hash, ok := strings.Cut(scanner.Text(), ":")
if ok {
credMap[user] = hash
}
}
if len(credMap) == 0 {
s.log.Warn().Str("htpasswd-file", filePath).Msg("loaded htpasswd file appears to have zero users")
} else {
s.log.Info().Str("htpasswd-file", filePath).Int("users", len(credMap)).Msg("loaded htpasswd file")
}
s.mu.Lock()
defer s.mu.Unlock()
s.credMap = credMap
return nil
}
func (s *HTPasswd) Get(username string) (passphraseHash string, present bool) { //nolint: nonamedreturns
s.mu.RLock()
defer s.mu.RUnlock()
passphraseHash, present = s.credMap[username]
return
}
func (s *HTPasswd) Clear() {
s.mu.Lock()
defer s.mu.Unlock()
s.credMap = make(map[string]string)
}
func (s *HTPasswd) Authenticate(username, passphrase string) (ok, present bool) { //nolint: nonamedreturns
passphraseHash, present := s.Get(username)
if !present {
return false, false
}
err := bcrypt.CompareHashAndPassword([]byte(passphraseHash), []byte(passphrase))
ok = err == nil
if err != nil && !errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) {
// Log that user's hash has unsupported format. Better than silently return 401.
s.log.Warn().Err(err).Str("username", username).Msg("htpasswd bcrypt compare failed")
}
return
}
// HTPasswdWatcher helper which triggers htpasswd reload on file change event.
//
// Cannot be restarted.
type HTPasswdWatcher struct {
htp *HTPasswd
filePath string
watcher *fsnotify.Watcher
cancel context.CancelFunc
log log.Logger
}
// NewHTPasswdWatcher create and start watcher.
func NewHTPasswdWatcher(htp *HTPasswd, filePath string) (*HTPasswdWatcher, error) {
watcher, err := fsnotify.NewWatcher()
if err != nil {
return nil, err
}
if filePath != "" {
err = watcher.Add(filePath)
if err != nil {
return nil, errors.Join(err, watcher.Close())
}
}
// background event processor job context
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
ret := &HTPasswdWatcher{
htp: htp,
filePath: filePath,
watcher: watcher,
cancel: cancel,
log: htp.log,
}
go func() {
defer ret.watcher.Close() //nolint: errcheck
for {
select {
case ev := <-ret.watcher.Events:
if ev.Op != fsnotify.Write {
continue
}
ret.log.Info().Str("htpasswd-file", ret.filePath).Msg("htpasswd file changed, trying to reload config")
err := ret.htp.Reload(ret.filePath)
if err != nil {
ret.log.Warn().Err(err).Str("htpasswd-file", ret.filePath).Msg("failed to reload file")
}
case err := <-ret.watcher.Errors:
ret.log.Error().Err(err).Str("htpasswd-file", ret.filePath).Msg("failed to fsnotfy, got error while watching file")
case <-ctx.Done():
ret.log.Debug().Msg("htpasswd watcher terminating...")
return
}
}
}()
return ret, nil
}
// ChangeFile changes monitored file. Empty string clears store.
func (s *HTPasswdWatcher) ChangeFile(filePath string) error {
if s.filePath != "" {
err := s.watcher.Remove(s.filePath)
if err != nil {
return err
}
}
if filePath == "" {
s.filePath = filePath
s.htp.Clear()
return nil
}
err := s.watcher.Add(filePath)
if err != nil {
return err
}
s.filePath = filePath
return s.htp.Reload(filePath)
}
func (s *HTPasswdWatcher) Close() error {
s.cancel()
return nil
}