624 lines
19 KiB
Go
624 lines
19 KiB
Go
// Package api serves the admin REST API, the Prometheus metrics endpoint and
|
|
// the embedded web console.
|
|
//
|
|
// The API is the only component that mutates state, so every handler funnels
|
|
// through one of three paths: a read from a live component, a config mutation
|
|
// (validate -> write file -> swap -> broadcast), or a targeted runtime action
|
|
// (flush cache, refresh lists, reload config).
|
|
package api
|
|
|
|
import (
|
|
"context"
|
|
"crypto/subtle"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net"
|
|
"net/http"
|
|
"net/netip"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
|
|
)
|
|
|
|
// Principal is the authenticated identity behind a request.
|
|
type Principal struct {
|
|
// Mode is "apikey", "basic" or "anonymous".
|
|
Mode string
|
|
// Name is a human label for the key or user.
|
|
Name string
|
|
// ReadOnly is true for a read-only API key.
|
|
ReadOnly bool
|
|
// Loopback is true when the request came from the local machine and
|
|
// trust_loopback is on.
|
|
Loopback bool
|
|
}
|
|
|
|
// CanWrite reports whether the principal may perform mutations.
|
|
func (p Principal) CanWrite() bool { return !p.ReadOnly }
|
|
|
|
// Hooks are the live components the API reads and drives. They are interfaces
|
|
// so the api package does not depend on the engine internals.
|
|
type Hooks struct {
|
|
// Store is the config store (required).
|
|
Store *config.Store
|
|
// Logger is the structured logger (required).
|
|
Logger *slog.Logger
|
|
// Version is the running build's version.
|
|
Version string
|
|
// StartedAt is the process start time.
|
|
StartedAt time.Time
|
|
|
|
// Dashboard returns the aggregated dashboard payload.
|
|
Dashboard func(ctx context.Context, rangeKey string) (any, error)
|
|
// BlocklistState returns the compiled blocklist detail.
|
|
BlocklistState func() any
|
|
// BlocklistStats returns the compiled rule counts.
|
|
BlocklistStats func() any
|
|
// RefreshBlocklists recompiles the blocklist; empty names means all.
|
|
RefreshBlocklists func(ctx context.Context, names []string) error
|
|
// ToggleBlocklist enables or disables one source at runtime.
|
|
ToggleBlocklist func(name string, enabled bool) error
|
|
// DomainCheck explains how a name would be treated.
|
|
DomainCheck func(name string) (any, error)
|
|
// ListDomains pages the compiled rules.
|
|
ListDomains func(q DomainQuery) (any, error)
|
|
// AddDomain adds an inline rule and persists it.
|
|
AddDomain func(ctx context.Context, in DomainInput) (any, error)
|
|
// DeleteDomain removes a rule and persists the change.
|
|
DeleteDomain func(ctx context.Context, name, action, list string) error
|
|
// GetACL returns the ACL groups.
|
|
GetACL func() any
|
|
// PutACL validates and persists new ACL groups.
|
|
PutACL func(ctx context.Context, groups []config.Group, def string) (int64, error)
|
|
// GetUpstream returns the upstream pool state.
|
|
GetUpstream func() any
|
|
// PutUpstream validates and persists a new upstream config.
|
|
PutUpstream func(ctx context.Context, up config.Upstream) (int64, error)
|
|
// SearchLog pages the query log.
|
|
SearchLog func(ctx context.Context, q LogQuery) (any, error)
|
|
// LogStats is the live stats event pushed over SSE.
|
|
LogStats func() any
|
|
// SubscribeLog streams new log entries; the cancel func must be called.
|
|
SubscribeLog func() (<-chan LogEntry, func())
|
|
// ClearLog empties the query log.
|
|
ClearLog func() error
|
|
// CacheStats returns the cache snapshot.
|
|
CacheStats func() any
|
|
// CacheTop returns the hottest cached names.
|
|
CacheTop func(limit int) any
|
|
// FlushCache empties the cache.
|
|
FlushCache func() int64
|
|
// Health returns the health payload for /healthz.
|
|
Health func() any
|
|
// Status returns the per-component runtime status.
|
|
Status func() any
|
|
// Flush persists buffered state.
|
|
Flush func()
|
|
}
|
|
|
|
// DomainQuery pages the compiled rules.
|
|
type DomainQuery struct {
|
|
Text string
|
|
Action string
|
|
List string
|
|
Page int
|
|
PerPage int
|
|
}
|
|
|
|
// DomainInput adds a rule.
|
|
type DomainInput struct {
|
|
Name string `json:"name"`
|
|
Action string `json:"action"`
|
|
List string `json:"list"`
|
|
Source string `json:"source"`
|
|
Content string `json:"content,omitempty"`
|
|
}
|
|
|
|
// LogQuery pages the query log.
|
|
type LogQuery struct {
|
|
Limit int
|
|
Offset int
|
|
Text string
|
|
Action string
|
|
Client string
|
|
RCode int
|
|
BlockedOnly bool
|
|
Since time.Time
|
|
}
|
|
|
|
// LogEntry is the JSON shape of a query-log row, matching what the DNS
|
|
// handlers record.
|
|
type LogEntry struct {
|
|
ID uint64 `json:"id"`
|
|
TS time.Time `json:"ts"`
|
|
Client string `json:"client"`
|
|
Group string `json:"client_group,omitempty"`
|
|
Name string `json:"name"`
|
|
QType uint16 `json:"qtype"`
|
|
QTypeStr string `json:"qtype_name"`
|
|
RCode int `json:"rcode"`
|
|
RCodeStr string `json:"rcode_name"`
|
|
Action string `json:"action"`
|
|
Response string `json:"response,omitempty"`
|
|
Duration int64 `json:"duration_ms"`
|
|
Upstream string `json:"upstream,omitempty"`
|
|
List string `json:"list,omitempty"`
|
|
Protocol string `json:"protocol,omitempty"`
|
|
Blocked bool `json:"blocked"`
|
|
}
|
|
|
|
// Server is the admin HTTP server.
|
|
type Server struct {
|
|
hooks Hooks
|
|
log *slog.Logger
|
|
base string
|
|
mux *http.ServeMux
|
|
auth *authenticator
|
|
trusted []netip.Prefix
|
|
|
|
// sessions tracks issued session tokens for the basic-auth flow.
|
|
sessMu sync.Mutex
|
|
sessions map[string]session
|
|
|
|
// limits tracks per-IP rate limits and lockouts.
|
|
limMu sync.Mutex
|
|
limits map[string]*limitState
|
|
now func() time.Time
|
|
}
|
|
|
|
type session struct {
|
|
principal Principal
|
|
expires time.Time
|
|
}
|
|
|
|
type limitState struct {
|
|
window time.Time
|
|
count int
|
|
failures int
|
|
lockedTill time.Time
|
|
}
|
|
|
|
// New builds the API server.
|
|
func New(hooks Hooks) (*Server, error) {
|
|
if hooks.Store == nil {
|
|
return nil, errors.New("api: Store is required")
|
|
}
|
|
log := hooks.Logger
|
|
if log == nil {
|
|
log = slog.Default()
|
|
}
|
|
cfg := hooks.Store.Get()
|
|
s := &Server{
|
|
hooks: hooks,
|
|
log: log.With("component", "api"),
|
|
base: cfg.API.BasePath,
|
|
now: time.Now,
|
|
sessions: make(map[string]session),
|
|
limits: make(map[string]*limitState),
|
|
auth: newAuthenticator(cfg.API.Auth),
|
|
}
|
|
for _, c := range cfg.API.TrustedProxies {
|
|
if p, err := netip.ParsePrefix(c); err == nil {
|
|
s.trusted = append(s.trusted, p)
|
|
}
|
|
}
|
|
s.mux = s.routes(cfg)
|
|
return s, nil
|
|
}
|
|
|
|
// Handler returns the HTTP handler, wrapped with logging and panic recovery.
|
|
func (s *Server) Handler() http.Handler {
|
|
return s.recoverer(s.cors(s.mux))
|
|
}
|
|
|
|
// routes builds the route table. The stdlib mux handles the path prefix, so
|
|
// handlers see paths relative to the base.
|
|
func (s *Server) routes(cfg *config.Config) *http.ServeMux {
|
|
mux := http.NewServeMux()
|
|
// base is the prefix every route is built from, so it must end in a
|
|
// slash: "GET " + base + "healthz" has to produce "/api/healthz", not
|
|
// "/apihealthz".
|
|
base := strings.TrimSuffix(s.base, "/") + "/"
|
|
|
|
// Unauthenticated: health and metrics are for probes, not operators.
|
|
mux.Handle("GET "+base+"healthz", s.public(s.handleHealth))
|
|
if cfg.Metrics.Enabled {
|
|
// Bound late so the metrics package's init has run by the time the
|
|
// handler is invoked; a nil handler means "not wired up" (a test, or
|
|
// an embedder that does not import metrics), which must not panic the
|
|
// route table.
|
|
mux.Handle("GET "+base+"metrics", s.protectMetrics(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if h := metricsHandler(); h != nil {
|
|
h.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
http.Error(w, "metrics not available", http.StatusNotImplemented)
|
|
})))
|
|
}
|
|
mux.Handle("POST "+base+"auth/login", s.public(s.handleLogin))
|
|
mux.Handle("POST "+base+"auth/logout", s.authed(s.handleLogout, false))
|
|
|
|
// Everything below requires a principal.
|
|
mux.Handle("GET "+base+"dashboard", s.authed(s.handleDashboard, false))
|
|
mux.Handle("GET "+base+"status", s.authed(s.handleStatus, false))
|
|
mux.Handle("GET "+base+"blocklists", s.authed(s.handleBlocklists, false))
|
|
mux.Handle("POST "+base+"blocklists/refresh", s.authed(s.handleBlocklistRefresh, true))
|
|
mux.Handle("POST "+base+"blocklists/toggle", s.authed(s.handleBlocklistToggle, true))
|
|
mux.Handle("GET "+base+"domains", s.authed(s.handleDomains, false))
|
|
mux.Handle("POST "+base+"domains", s.authed(s.handleDomainAdd, true))
|
|
mux.Handle("DELETE "+base+"domains/{name...}", s.authed(s.handleDomainDelete, true))
|
|
mux.Handle("GET "+base+"domains/check", s.authed(s.handleDomainCheck, false))
|
|
mux.Handle("GET "+base+"clients", s.authed(s.handleClients, false))
|
|
mux.Handle("PUT "+base+"clients", s.authed(s.handleClientsPut, true))
|
|
mux.Handle("GET "+base+"upstreams", s.authed(s.handleUpstreams, false))
|
|
mux.Handle("PUT "+base+"upstreams", s.authed(s.handleUpstreamsPut, true))
|
|
mux.Handle("GET "+base+"logs", s.authed(s.handleLogs, false))
|
|
mux.Handle("GET "+base+"logs/stream", s.authed(s.handleLogStream, false))
|
|
mux.Handle("POST "+base+"logs/clear", s.authed(s.handleLogsClear, true))
|
|
mux.Handle("GET "+base+"cache", s.authed(s.handleCache, false))
|
|
mux.Handle("GET "+base+"cache/top", s.authed(s.handleCacheTop, false))
|
|
mux.Handle("POST "+base+"cache/flush", s.authed(s.handleCacheFlush, true))
|
|
mux.Handle("GET "+base+"settings", s.authed(s.handleSettingsGet, false))
|
|
mux.Handle("PUT "+base+"settings", s.authed(s.handleSettingsPut, true))
|
|
mux.Handle("POST "+base+"settings/validate", s.authed(s.handleSettingsValidate, false))
|
|
mux.Handle("POST "+base+"settings/reload", s.authed(s.handleSettingsReload, true))
|
|
mux.Handle("GET "+base+"whoami", s.authed(s.handleWhoami, false))
|
|
return mux
|
|
}
|
|
|
|
// recoverer turns a panic in a handler into a 500 instead of a dropped
|
|
// connection, and logs it.
|
|
func (s *Server) recoverer(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
defer func() {
|
|
if rec := recover(); rec != nil {
|
|
s.log.Error("panic in api handler", "path", r.URL.Path, "panic", rec)
|
|
writeError(w, http.StatusInternalServerError, "internal error")
|
|
}
|
|
}()
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// cors adds the configured origins. A wildcard is allowed but echoed per
|
|
// request rather than as "*", so credentials keep working.
|
|
func (s *Server) cors(next http.Handler) http.Handler {
|
|
allowed := map[string]bool{}
|
|
wildcard := false
|
|
for _, o := range s.hooks.Store.Get().API.CORSOrigins {
|
|
if o == "*" {
|
|
wildcard = true
|
|
}
|
|
allowed[o] = true
|
|
}
|
|
if len(allowed) == 0 && !wildcard {
|
|
return next
|
|
}
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
origin := r.Header.Get("Origin")
|
|
if origin != "" && (wildcard || allowed[origin]) {
|
|
w.Header().Set("Access-Control-Allow-Origin", origin)
|
|
w.Header().Add("Vary", "Origin")
|
|
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
|
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, X-API-Key, Authorization")
|
|
}
|
|
if r.Method == http.MethodOptions {
|
|
w.WriteHeader(http.StatusNoContent)
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// public marks a handler as reachable without a principal.
|
|
func (s *Server) public(h http.HandlerFunc) http.Handler { return h }
|
|
|
|
// cfg is a small accessor so handlers read the current config once.
|
|
|
|
// authed wraps a handler with authentication and, for mutations, a write
|
|
// permission check.
|
|
func (s *Server) authed(h http.HandlerFunc, write bool) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
p, err := s.principal(r)
|
|
if err != nil {
|
|
if s.hooks.Store.Get().API.Auth.Mode != "none" && !s.cfgTrustLoopback(r) {
|
|
writeError(w, http.StatusUnauthorized, "authentication required")
|
|
return
|
|
}
|
|
// auth mode none, or a trusted loopback client: run as a local
|
|
// principal.
|
|
p = Principal{Mode: "loopback", Name: "loopback", Loopback: true}
|
|
}
|
|
if write && !p.CanWrite() {
|
|
writeError(w, http.StatusForbidden, "this key is read-only")
|
|
return
|
|
}
|
|
if !s.allowRequest(w, r) {
|
|
return
|
|
}
|
|
ctx := context.WithValue(r.Context(), principalKey, p)
|
|
h(w, r.WithContext(ctx))
|
|
})
|
|
}
|
|
|
|
type ctxKey string
|
|
|
|
const principalKey ctxKey = "fancydns.principal"
|
|
|
|
// PrincipalFrom returns the authenticated principal, if any.
|
|
func PrincipalFrom(ctx context.Context) (Principal, bool) {
|
|
p, ok := ctx.Value(principalKey).(Principal)
|
|
return p, ok
|
|
}
|
|
|
|
// principal authenticates a request.
|
|
func (s *Server) principal(r *http.Request) (Principal, error) {
|
|
// API key in the header wins: it is the machine path.
|
|
if key := r.Header.Get("X-API-Key"); key != "" {
|
|
return s.auth.apiKey(key)
|
|
}
|
|
if h := r.Header.Get("Authorization"); h != "" {
|
|
scheme, value, ok := strings.Cut(h, " ")
|
|
if !ok {
|
|
return Principal{}, errors.New("malformed Authorization header")
|
|
}
|
|
switch strings.ToLower(scheme) {
|
|
case "bearer":
|
|
return s.auth.session(value)
|
|
case "basic":
|
|
user, pass, ok := r.BasicAuth()
|
|
if !ok {
|
|
return Principal{}, errors.New("malformed basic credentials")
|
|
}
|
|
return s.auth.basic(user, pass)
|
|
}
|
|
return Principal{}, fmt.Errorf("unsupported auth scheme %q", scheme)
|
|
}
|
|
if s.cfgTrustLoopback(r) {
|
|
return Principal{Mode: "loopback", Name: "loopback", Loopback: true}, nil
|
|
}
|
|
return Principal{}, errors.New("no credentials")
|
|
}
|
|
|
|
func (s *Server) cfgTrustLoopback(r *http.Request) bool {
|
|
cfg := s.hooks.Store.Get()
|
|
if !cfg.API.Auth.TrustLoopback {
|
|
return false
|
|
}
|
|
ip := s.clientIP(r)
|
|
a, err := netip.ParseAddr(ip)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return a.IsLoopback()
|
|
}
|
|
|
|
// clientIP resolves the client address, honouring X-Forwarded-For only from a
|
|
// trusted proxy. Trusting it from anyone would let a client spoof its identity
|
|
// past the ACL and the rate limiter.
|
|
func (s *Server) clientIP(r *http.Request) string {
|
|
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
|
if err != nil {
|
|
host = r.RemoteAddr
|
|
}
|
|
addr, err := netip.ParseAddr(host)
|
|
if err != nil {
|
|
return host
|
|
}
|
|
if !s.isTrusted(addr) {
|
|
return addr.String()
|
|
}
|
|
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
|
// Left-most entry is the original client.
|
|
first := strings.TrimSpace(strings.Split(xff, ",")[0])
|
|
if a, err := netip.ParseAddr(first); err == nil {
|
|
return a.String()
|
|
}
|
|
}
|
|
if xr := r.Header.Get("X-Real-IP"); xr != "" {
|
|
if a, err := netip.ParseAddr(strings.TrimSpace(xr)); err == nil {
|
|
return a.String()
|
|
}
|
|
}
|
|
return addr.String()
|
|
}
|
|
|
|
func (s *Server) isTrusted(a netip.Addr) bool {
|
|
for _, p := range s.trusted {
|
|
if p.Contains(a) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// allowRequest applies the API rate limit per client IP.
|
|
func (s *Server) allowRequest(w http.ResponseWriter, r *http.Request) bool {
|
|
cfg := s.hooks.Store.Get()
|
|
if cfg.API.RateLimit.PerMinute <= 0 {
|
|
return true
|
|
}
|
|
ip := s.clientIP(r)
|
|
now := s.now()
|
|
s.limMu.Lock()
|
|
defer s.limMu.Unlock()
|
|
st, ok := s.limits[ip]
|
|
if !ok {
|
|
st = &limitState{window: now}
|
|
s.limits[ip] = st
|
|
}
|
|
if now.Before(st.lockedTill) {
|
|
w.Header().Set("Retry-After", strconv.Itoa(int(st.lockedTill.Sub(now).Seconds())+1))
|
|
writeError(w, http.StatusTooManyRequests, "rate limit exceeded")
|
|
return false
|
|
}
|
|
if now.Sub(st.window) >= time.Minute {
|
|
st.window = now
|
|
st.count = 0
|
|
}
|
|
st.count++
|
|
if st.count > cfg.API.RateLimit.PerMinute {
|
|
w.Header().Set("Retry-After", "60")
|
|
writeError(w, http.StatusTooManyRequests, "rate limit exceeded")
|
|
return false
|
|
}
|
|
// Opportunistic cleanup keeps the map bounded without a sweeper.
|
|
if len(s.limits) > 4096 {
|
|
for k, v := range s.limits {
|
|
if now.Sub(v.window) > 10*time.Minute {
|
|
delete(s.limits, k)
|
|
}
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// recordLoginFailure locks an IP out after repeated bad logins.
|
|
func (s *Server) recordLoginFailure(ip string) {
|
|
cfg := s.hooks.Store.Get()
|
|
if cfg.API.RateLimit.Failures <= 0 {
|
|
return
|
|
}
|
|
s.limMu.Lock()
|
|
defer s.limMu.Unlock()
|
|
st, ok := s.limits[ip]
|
|
if !ok {
|
|
st = &limitState{window: s.now()}
|
|
s.limits[ip] = st
|
|
}
|
|
st.failures++
|
|
if st.failures >= cfg.API.RateLimit.Failures {
|
|
st.lockedTill = s.now().Add(cfg.API.RateLimit.Lockout.D())
|
|
st.failures = 0
|
|
}
|
|
s.log.Warn("failed admin login", "client", ip)
|
|
}
|
|
|
|
func (s *Server) clearLoginFailures(ip string) {
|
|
s.limMu.Lock()
|
|
delete(s.limits, ip)
|
|
s.limMu.Unlock()
|
|
}
|
|
|
|
// protectMetrics gates /metrics behind an API key when configured.
|
|
func (s *Server) protectMetrics(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
cfg := s.hooks.Store.Get()
|
|
if !cfg.Metrics.Protect {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
if _, err := s.principal(r); err != nil {
|
|
writeError(w, http.StatusUnauthorized, "metrics are protected")
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// ---- response helpers -------------------------------------------------------
|
|
|
|
func writeJSON(w http.ResponseWriter, code int, v any) {
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
w.WriteHeader(code)
|
|
enc := json.NewEncoder(w)
|
|
enc.SetEscapeHTML(false)
|
|
if err := enc.Encode(v); err != nil {
|
|
// The status line is already sent, so the only useful thing left is a
|
|
// truncated-object fallback the client can detect.
|
|
_, _ = w.Write([]byte(`{"error":"response encoding failed"}`))
|
|
}
|
|
}
|
|
|
|
type apiError struct {
|
|
Error string `json:"error"`
|
|
Fields []string `json:"fields,omitempty"`
|
|
}
|
|
|
|
func writeError(w http.ResponseWriter, code int, msg string) {
|
|
writeJSON(w, code, apiError{Error: msg})
|
|
}
|
|
|
|
func writeErrorFields(w http.ResponseWriter, code int, msg string, fields []string) {
|
|
writeJSON(w, code, apiError{Error: msg, Fields: fields})
|
|
}
|
|
|
|
// decodeJSON reads a JSON body with a size cap and strict unknown-field
|
|
// rejection, so a typo in a UI payload is an error rather than a silent no-op.
|
|
func decodeJSON(r *http.Request, v any) error {
|
|
defer func() { _ = r.Body.Close() }()
|
|
dec := json.NewDecoder(http.MaxBytesReader(nil, r.Body, 4<<20))
|
|
dec.DisallowUnknownFields()
|
|
if err := dec.Decode(v); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func queryInt(r *http.Request, key string, def int) int {
|
|
v := r.URL.Query().Get(key)
|
|
if v == "" {
|
|
return def
|
|
}
|
|
n, err := strconv.Atoi(v)
|
|
if err != nil {
|
|
return def
|
|
}
|
|
return n
|
|
}
|
|
|
|
func queryBool(r *http.Request, key string) bool {
|
|
v := strings.ToLower(r.URL.Query().Get(key))
|
|
return v == "1" || v == "true" || v == "yes"
|
|
}
|
|
|
|
func queryTime(r *http.Request, key string) time.Time {
|
|
v := r.URL.Query().Get(key)
|
|
if v == "" {
|
|
return time.Time{}
|
|
}
|
|
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
|
|
if n > 1e12 {
|
|
return time.UnixMilli(n)
|
|
}
|
|
return time.Unix(n, 0)
|
|
}
|
|
if t, err := time.Parse(time.RFC3339, v); err == nil {
|
|
return t
|
|
}
|
|
return time.Time{}
|
|
}
|
|
|
|
func constantTimeEqual(a, b string) bool {
|
|
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
|
|
}
|
|
|
|
func isFileWritable(path string) bool {
|
|
if path == "" {
|
|
return false
|
|
}
|
|
f, err := os.OpenFile(path, os.O_WRONLY, 0)
|
|
if err != nil {
|
|
// A missing file in a writable directory is still writable.
|
|
dir := path
|
|
if i := strings.LastIndexByte(dir, '/'); i > 0 {
|
|
dir = dir[:i]
|
|
}
|
|
d, derr := os.Stat(dir)
|
|
if derr != nil {
|
|
return false
|
|
}
|
|
return d.IsDir()
|
|
}
|
|
_ = f.Close()
|
|
return true
|
|
}
|