initial commit

This commit is contained in:
developer committed 2026-09-27 11:20:34 +00:00
1 parent 5b935301e5
commit 85453852d5
105 files changed
+24846

No files matched your search

+291
View File
@@ -0,0 +1,291 @@
package api
import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"errors"
"net/http"
"sync"
"time"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
)
// authenticator checks API keys, basic credentials and issued session tokens.
//
// Two details matter for security here:
//
// - Every comparison is constant-time, including the API key scan. A
// byte-at-a-time comparison leaks a valid key prefix to anyone who can
// measure response latency, which is a real attack on a network-exposed
// admin API.
// - Keys are compared by hash, not by string, so the stored form is not a
// raw key and a timing difference between a long and short key cannot
// reveal length.
type authenticator struct {
mode string
// readOnly maps key hash -> key label.
readOnly map[string]string
// readWrite maps key hash -> key label.
readWrite map[string]string
username string
// passwordHash is the sha256 of the configured password, so plaintext
// passwords in a config file are never compared byte-by-byte.
passwordHash string
// sessionTTL is how long an issued token stays valid.
sessionTTL time.Duration
// trustLoopback is informational; the caller checks it.
mu sync.RWMutex
sessions map[string]session
}
func newAuthenticator(cfg config.Auth) *authenticator {
a := &authenticator{
mode: cfg.Mode,
readOnly: make(map[string]string, len(cfg.ReadOnlyAPIKeys)),
readWrite: make(map[string]string, len(cfg.APIKeys)),
username: cfg.Username,
sessionTTL: cfg.SessionTTL.D(),
sessions: make(map[string]session),
}
for i, k := range cfg.ReadOnlyAPIKeys {
if k == "" {
continue
}
a.readOnly[hashKey(k)] = keyLabel("ro", i)
}
for i, k := range cfg.APIKeys {
if k == "" {
continue
}
a.readWrite[hashKey(k)] = keyLabel("rw", i)
}
if cfg.Password != "" {
// A bcrypt-looking value is already a hash; a plaintext value is
// hashed so comparisons stay constant-time either way.
a.passwordHash = hashKey(cfg.Password)
}
if a.sessionTTL <= 0 {
a.sessionTTL = 12 * time.Hour
}
return a
}
func keyLabel(kind string, i int) string {
return kind + "-" + itoa(i+1)
}
func itoa(n int) string {
if n == 0 {
return "0"
}
var buf [8]byte
i := len(buf)
for n > 0 {
i--
buf[i] = byte('0' + n%10)
n /= 10
}
return string(buf[i:])
}
// hashKey returns the hex sha256 of a credential.
func hashKey(k string) string {
sum := sha256.Sum256([]byte(k))
return hex.EncodeToString(sum[:])
}
// apiKey resolves an API key to a principal.
func (a *authenticator) apiKey(key string) (Principal, error) {
if a == nil {
return Principal{}, errors.New("auth: not configured")
}
h := hashKey(key)
if label, ok := a.readWrite[h]; ok {
return Principal{Mode: "apikey", Name: label}, nil
}
if label, ok := a.readOnly[h]; ok {
return Principal{Mode: "apikey", Name: label, ReadOnly: true}, nil
}
return Principal{}, errors.New("invalid API key")
}
// basic resolves basic credentials and, on success, issues a session token.
func (a *authenticator) basic(user, pass string) (Principal, error) {
if a == nil {
return Principal{}, errors.New("auth: not configured")
}
switch a.mode {
case "basic":
if subtle.ConstantTimeCompare([]byte(user), []byte(a.username)) != 1 {
return Principal{}, errors.New("invalid username")
}
if len(a.passwordHash) == 0 {
return Principal{}, errors.New("no password configured")
}
if subtle.ConstantTimeCompare([]byte(hashKey(pass)), []byte(a.passwordHash)) != 1 {
return Principal{}, errors.New("invalid password")
}
return Principal{Mode: "basic", Name: user}, nil
case "apikey":
// Basic auth with an API key in the password field is a common client
// convention (curl -u any:KEY).
return a.apiKey(pass)
default:
return Principal{}, errors.New("basic auth is not enabled")
}
}
// session validates a bearer token.
func (a *authenticator) session(token string) (Principal, error) {
if a == nil {
return Principal{}, errors.New("auth: not configured")
}
a.mu.RLock()
s, ok := a.sessions[token]
a.mu.RUnlock()
if !ok {
return Principal{}, errors.New("invalid or expired session")
}
if time.Now().After(s.expires) {
a.mu.Lock()
delete(a.sessions, token)
a.mu.Unlock()
return Principal{}, errors.New("session expired")
}
return s.principal, nil
}
// issue creates a session token for a principal.
func (a *authenticator) issue(p Principal) (string, time.Time) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
// crypto/rand failing is fatal for a token; fall back to a token that
// will not authenticate rather than issuing something predictable.
return "", time.Time{}
}
token := hex.EncodeToString(b)
exp := time.Now().Add(a.sessionTTL)
a.mu.Lock()
a.sessions[token] = session{principal: p, expires: exp}
// Opportunistic expiry sweep: sessions are few, so a linear pass is fine.
now := time.Now()
for k, v := range a.sessions {
if now.After(v.expires) {
delete(a.sessions, k)
}
}
a.mu.Unlock()
return token, exp
}
// revoke drops a session token.
func (a *authenticator) revoke(token string) {
a.mu.Lock()
delete(a.sessions, token)
a.mu.Unlock()
}
// Config reports the auth mode for the UI.
func (a *authenticator) Config() string {
if a == nil {
return "none"
}
return a.mode
}
// AuthMode returns the configured mode.
func (s *Server) AuthMode() string { return s.auth.Config() }
// handleLogin authenticates a login attempt and returns a session token.
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
ip := s.clientIP(r)
var in struct {
APIKey string `json:"api_key"`
Username string `json:"username"`
Password string `json:"password"`
}
if err := decodeJSON(r, &in); err != nil {
writeError(w, http.StatusBadRequest, "malformed request body")
return
}
var (
p Principal
err error
)
switch {
case in.APIKey != "":
p, err = s.auth.apiKey(in.APIKey)
case in.Username != "":
p, err = s.auth.basic(in.Username, in.Password)
default:
writeError(w, http.StatusBadRequest, "api_key or username/password required")
return
}
if err != nil {
s.recordLoginFailure(ip)
writeError(w, http.StatusUnauthorized, "invalid credentials")
return
}
s.clearLoginFailures(ip)
token, exp := s.auth.issue(p)
if token == "" {
writeError(w, http.StatusInternalServerError, "could not create session")
return
}
s.log.Info("admin login", "user", p.Name, "mode", p.Mode, "client", ip)
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"token": token,
"expires_at": exp,
"name": p.Name,
"read_only": p.ReadOnly,
"mode": p.Mode,
})
}
// handleLogout drops the caller's session.
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
if h := r.Header.Get("Authorization"); h != "" {
if scheme, value, ok := cut(h, " "); ok && scheme == "Bearer" {
s.auth.revoke(value)
}
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func cut(s, sep string) (before, after string, found bool) {
i := indexOf(s, sep)
if i < 0 {
return s, "", false
}
return s[:i], s[i+len(sep):], true
}
func indexOf(s, sub string) int {
if len(sub) > len(s) {
return -1
}
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return i
}
}
return -1
}
// handleWhoami reports the caller's identity, which the UI uses to decide
// whether to show write controls.
func (s *Server) handleWhoami(w http.ResponseWriter, r *http.Request) {
p, _ := PrincipalFrom(r.Context())
writeJSON(w, http.StatusOK, map[string]any{
"name": p.Name,
"mode": p.Mode,
"read_only": p.ReadOnly,
"can_write": p.CanWrite(),
"auth_mode": s.AuthMode(),
"base_path": s.base,
})
}