Jarl 0.1.0: manage BIND through a web UI, REST API and CLI
Jarl keeps a BIND 9.20 server's whole configuration (views, ACLs, TSIG keys, zones of every type, records, named.conf options) in SQLite and renders named.conf, keys.conf and zone files from it. Every change goes through one changeset pipeline: whole-configuration validation, named-checkconf -z and named-checkzone in a staging directory, atomic install, rndc reconfig/reload, serial verification with rndc zonestatus, and rollback if named does not load the change. Changes apply one at a time by default or are bundled and applied together. Applied changesets can be reverted. - jarld: the daemon with the REST API and an embedded web UI, plus import, adopt, check, resync, backup and local user recovery commands - jarl: the CLI, including ACME DNS-01 hooks for certbot and acme.sh - users with argon2id passwords and CSRF-protected sessions; hashed API tokens with granular grants and roles; an audit log - systemd unit, sysusers.d/tmpfiles.d files, Makefile - unit tests, integration tests against a real named, scripts/e2e.sh - docs/ guides, README and CHANGELOG Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
bd6b8778e4
122 files changed
+29217
No files matched your search
@@ -0,0 +1,262 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"jarl/internal/auth"
|
||||
"jarl/internal/model"
|
||||
"jarl/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
sessionCookie = "jarl_session"
|
||||
csrfHeader = "X-CSRF-Token"
|
||||
)
|
||||
|
||||
// rateLimiter limits failed authentication attempts per key (IP or
|
||||
// username) with a sliding window.
|
||||
type rateLimiter struct {
|
||||
mu sync.Mutex
|
||||
hits map[string][]time.Time
|
||||
}
|
||||
|
||||
func newRateLimiter() *rateLimiter { return &rateLimiter{hits: map[string][]time.Time{}} }
|
||||
|
||||
const (
|
||||
failWindow = 15 * time.Minute
|
||||
failsPerIP = 20
|
||||
failsPerKey = 8
|
||||
)
|
||||
|
||||
func (l *rateLimiter) recent(key string, now time.Time) []time.Time {
|
||||
var keep []time.Time
|
||||
for _, t := range l.hits[key] {
|
||||
if now.Sub(t) < failWindow {
|
||||
keep = append(keep, t)
|
||||
}
|
||||
}
|
||||
l.hits[key] = keep
|
||||
return keep
|
||||
}
|
||||
|
||||
// blocked reports whether key has failed too often.
|
||||
func (l *rateLimiter) blocked(key string, max int) bool {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
return len(l.recent(key, time.Now())) >= max
|
||||
}
|
||||
|
||||
func (l *rateLimiter) fail(keys ...string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
now := time.Now()
|
||||
for _, k := range keys {
|
||||
l.hits[k] = append(l.recent(k, now), now)
|
||||
}
|
||||
}
|
||||
|
||||
func (l *rateLimiter) reset(key string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
delete(l.hits, key)
|
||||
}
|
||||
|
||||
func (l *rateLimiter) cleanup() {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
now := time.Now()
|
||||
for k := range l.hits {
|
||||
if len(l.recent(k, now)) == 0 {
|
||||
delete(l.hits, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var errUnauthenticated = &httpError{http.StatusUnauthorized, "unauthenticated", "authentication required: log in, or send an API token as 'Authorization: Bearer jarl_...'"}
|
||||
|
||||
// authenticate identifies the caller from a bearer token or a session
|
||||
// cookie. For sessions, state-changing requests must carry the CSRF token.
|
||||
func (s *Server) authenticate(r *http.Request) (*auth.Principal, *model.Session, error) {
|
||||
ip := s.clientIP(r)
|
||||
if h := r.Header.Get("Authorization"); h != "" {
|
||||
scheme, tok, _ := strings.Cut(h, " ")
|
||||
if !strings.EqualFold(scheme, "Bearer") {
|
||||
return nil, nil, &httpError{http.StatusUnauthorized, "unauthenticated", "unsupported authorization scheme (use Bearer with an API token)"}
|
||||
}
|
||||
if s.limiter.blocked("ip:"+ip, failsPerIP) {
|
||||
return nil, nil, &httpError{http.StatusTooManyRequests, "rate_limited", "too many failed attempts; try again later"}
|
||||
}
|
||||
p, err := s.tokenPrincipal(r.Context(), strings.TrimSpace(tok), ip, r.UserAgent())
|
||||
if err != nil {
|
||||
s.limiter.fail("ip:" + ip)
|
||||
s.eng.Audit(r.Context(), &auth.Principal{IP: ip, Agent: r.UserAgent(), Method: "token"}, "auth.token_failed", "", 0, "", "failure", err.Error())
|
||||
return nil, nil, &httpError{http.StatusUnauthorized, "unauthenticated", "invalid API token: " + err.Error()}
|
||||
}
|
||||
return p, nil, nil
|
||||
}
|
||||
c, err := r.Cookie(sessionCookie)
|
||||
if err != nil || c.Value == "" {
|
||||
return nil, nil, errUnauthenticated
|
||||
}
|
||||
hash := auth.HashSecret(c.Value)
|
||||
se, err := s.store.GetSession(r.Context(), hash)
|
||||
if err != nil {
|
||||
return nil, nil, &httpError{http.StatusUnauthorized, "session_expired", "your session has expired; please log in again"}
|
||||
}
|
||||
now := time.Now()
|
||||
if now.After(se.ExpiresAt) || now.Sub(se.LastSeenAt) > s.cfg.SessionIdle {
|
||||
s.store.DeleteSession(r.Context(), hash)
|
||||
return nil, nil, &httpError{http.StatusUnauthorized, "session_expired", "your session has expired; please log in again"}
|
||||
}
|
||||
u, err := s.store.GetUser(r.Context(), se.UserID)
|
||||
if err != nil || u.Disabled {
|
||||
s.store.DeleteSession(r.Context(), hash)
|
||||
return nil, nil, &httpError{http.StatusUnauthorized, "session_expired", "your account is not available; please log in again"}
|
||||
}
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead && r.Method != http.MethodOptions {
|
||||
got := r.Header.Get(csrfHeader)
|
||||
if got == "" || subtle.ConstantTimeCompare([]byte(got), []byte(se.CSRFToken)) != 1 {
|
||||
return nil, nil, &httpError{http.StatusForbidden, "csrf", "missing or invalid " + csrfHeader + " header"}
|
||||
}
|
||||
}
|
||||
if now.Sub(se.LastSeenAt) > time.Minute {
|
||||
s.store.TouchSession(r.Context(), hash, now)
|
||||
}
|
||||
return &auth.Principal{User: u, Method: "session", IP: ip, Agent: r.UserAgent()}, se, nil
|
||||
}
|
||||
|
||||
func (s *Server) tokenPrincipal(ctx context.Context, tok, ip, agent string) (*auth.Principal, error) {
|
||||
pid, secret, err := auth.ParseToken(tok)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t, err := s.store.GetTokenByPublicID(ctx, pid)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return nil, errors.New("unknown token")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !auth.CheckSecret(secret, t.SecretHash) {
|
||||
return nil, errors.New("wrong token secret")
|
||||
}
|
||||
now := time.Now()
|
||||
if t.RevokedAt != nil {
|
||||
return nil, errors.New("token was revoked")
|
||||
}
|
||||
if t.ExpiresAt != nil && now.After(*t.ExpiresAt) {
|
||||
return nil, errors.New("token has expired")
|
||||
}
|
||||
u, err := s.store.GetUser(ctx, t.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if u.Disabled {
|
||||
return nil, errors.New("the token's user is disabled")
|
||||
}
|
||||
if t.LastUsedAt == nil || now.Sub(*t.LastUsedAt) > time.Minute || t.LastUsedIP != ip {
|
||||
s.store.TouchToken(ctx, t.ID, ip)
|
||||
}
|
||||
return &auth.Principal{User: u, Token: t, Method: "token", IP: ip, Agent: agent}, nil
|
||||
}
|
||||
|
||||
// authed wraps a handler with authentication and error handling.
|
||||
func (s *Server) authed(h handler) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, s.cfg.MaxBodyBytes)
|
||||
p, se, err := s.authenticate(r)
|
||||
if err != nil {
|
||||
s.writeErr(w, r, err, nil)
|
||||
return
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), keyPrincipal, p)
|
||||
if se != nil {
|
||||
ctx = context.WithValue(ctx, keySession, se)
|
||||
}
|
||||
r = r.WithContext(ctx)
|
||||
if err := h(w, r, p); err != nil {
|
||||
s.writeErr(w, r, err, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// public wraps an unauthenticated handler.
|
||||
func (s *Server) public(h func(w http.ResponseWriter, r *http.Request) error) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
|
||||
if err := h(w, r); err != nil {
|
||||
s.writeErr(w, r, err, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) cookieSecure(r *http.Request) bool {
|
||||
switch s.cfg.CookieSecure {
|
||||
case "always":
|
||||
return true
|
||||
case "never":
|
||||
return false
|
||||
}
|
||||
return s.isHTTPS(r)
|
||||
}
|
||||
|
||||
func (s *Server) login(w http.ResponseWriter, r *http.Request) error {
|
||||
var in struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
return err
|
||||
}
|
||||
ip := s.clientIP(r)
|
||||
name := strings.TrimSpace(in.Username)
|
||||
userKey := "user:" + strings.ToLower(name)
|
||||
if s.limiter.blocked("ip:"+ip, failsPerIP) || s.limiter.blocked(userKey, failsPerKey) {
|
||||
s.eng.Audit(r.Context(), &auth.Principal{User: &model.User{Username: name}, IP: ip, Agent: r.UserAgent(), Method: "session"}, "auth.login_blocked", "user", 0, name, "denied", "too many failed attempts")
|
||||
return &httpError{http.StatusTooManyRequests, "rate_limited", "too many failed login attempts; wait a few minutes and try again"}
|
||||
}
|
||||
u, err := s.store.GetUserByName(r.Context(), name)
|
||||
hash := ""
|
||||
if err == nil {
|
||||
hash = u.PasswordHash
|
||||
}
|
||||
ok := auth.CheckPassword(hash, in.Password) // constant time even for unknown users
|
||||
if err != nil || !ok || u.Disabled || u.PasswordHash == "" {
|
||||
s.limiter.fail("ip:"+ip, userKey)
|
||||
reason := "wrong username or password"
|
||||
if err == nil && u.Disabled && ok {
|
||||
reason = "account disabled"
|
||||
}
|
||||
s.eng.Audit(r.Context(), &auth.Principal{User: &model.User{Username: name}, IP: ip, Agent: r.UserAgent(), Method: "session"}, "auth.login_failed", "user", 0, name, "failure", reason)
|
||||
return &httpError{http.StatusUnauthorized, "login_failed", "wrong username or password"}
|
||||
}
|
||||
s.limiter.reset(userKey)
|
||||
id, csrf := auth.NewSessionID()
|
||||
now := time.Now()
|
||||
se := &model.Session{IDHash: auth.HashSecret(id), UserID: u.ID, CSRFToken: csrf, CreatedAt: now, ExpiresAt: now.Add(s.cfg.SessionLifetime), LastSeenAt: now, IP: ip, UserAgent: r.UserAgent()}
|
||||
if err := s.store.CreateSession(r.Context(), se); err != nil {
|
||||
return err
|
||||
}
|
||||
s.store.TouchLogin(r.Context(), u.ID)
|
||||
http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: id, Path: "/", HttpOnly: true, Secure: s.cookieSecure(r), SameSite: http.SameSiteStrictMode, Expires: se.ExpiresAt})
|
||||
p := &auth.Principal{User: u, Method: "session", IP: ip, Agent: r.UserAgent()}
|
||||
s.eng.Audit(r.Context(), p, "auth.login", "user", u.ID, u.Username, "success", "")
|
||||
writeJSON(w, http.StatusOK, s.whoamiOf(p, se))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) logout(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if se, ok := r.Context().Value(keySession).(*model.Session); ok {
|
||||
s.store.DeleteSession(r.Context(), se.IDHash)
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: "", Path: "/", HttpOnly: true, Secure: s.cookieSecure(r), SameSite: http.SameSiteStrictMode, MaxAge: -1})
|
||||
s.eng.Audit(r.Context(), p, "auth.logout", "user", p.User.ID, p.Name(), "success", "")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"jarl/internal/api"
|
||||
"jarl/internal/auth"
|
||||
"jarl/internal/model"
|
||||
"jarl/internal/store"
|
||||
)
|
||||
|
||||
func (s *Server) whoami(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
se, _ := r.Context().Value(keySession).(*model.Session)
|
||||
writeJSON(w, http.StatusOK, s.whoamiOf(p, se))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) changeOwnPassword(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if p.Token != nil {
|
||||
return forbidden("passwords can't be changed with an API token; log in to the web UI")
|
||||
}
|
||||
var in api.PasswordChange
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
return err
|
||||
}
|
||||
u, err := s.store.GetUser(r.Context(), p.User.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if u.PasswordHash != "" && !auth.CheckPassword(u.PasswordHash, in.Current) {
|
||||
s.eng.Audit(r.Context(), p, "user.password", "user", u.ID, u.Username, "failure", "wrong current password")
|
||||
return &httpError{http.StatusForbidden, "wrong_password", "the current password is wrong"}
|
||||
}
|
||||
hash, err := auth.HashPassword(in.Password)
|
||||
if err != nil {
|
||||
return badRequest("%v", err)
|
||||
}
|
||||
u.PasswordHash = hash
|
||||
if err := s.store.UpdateUser(r.Context(), u); err != nil {
|
||||
return err
|
||||
}
|
||||
// Log out other sessions.
|
||||
keep := ""
|
||||
if se, ok := r.Context().Value(keySession).(*model.Session); ok {
|
||||
keep = se.IDHash
|
||||
}
|
||||
s.store.DeleteUserSessions(r.Context(), u.ID, keep)
|
||||
s.eng.Audit(r.Context(), p, "user.password", "user", u.ID, u.Username, "success", "")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return nil
|
||||
}
|
||||
|
||||
// grantsFrom builds the grant list from structured grants, compact
|
||||
// strings or a role template.
|
||||
func grantsFrom(grants *[]model.Grant, strs *[]string, role string) ([]model.Grant, bool, error) {
|
||||
var out []model.Grant
|
||||
set := false
|
||||
if role != "" {
|
||||
t, ok := auth.RoleTemplates[role]
|
||||
if !ok {
|
||||
var names []string
|
||||
for n := range auth.RoleTemplates {
|
||||
names = append(names, n)
|
||||
}
|
||||
return nil, false, badRequest("unknown role %q (known: %s)", role, strings.Join(names, ", "))
|
||||
}
|
||||
out = append(out, t...)
|
||||
set = true
|
||||
}
|
||||
if grants != nil {
|
||||
for _, g := range *grants {
|
||||
if err := auth.ValidateGrant(&g); err != nil {
|
||||
return nil, false, badRequest("%v", err)
|
||||
}
|
||||
out = append(out, g)
|
||||
}
|
||||
set = true
|
||||
}
|
||||
if strs != nil {
|
||||
for _, gs := range *strs {
|
||||
g, err := auth.ParseGrant(gs)
|
||||
if err != nil {
|
||||
return nil, false, badRequest("grant %q: %v", gs, err)
|
||||
}
|
||||
out = append(out, g)
|
||||
}
|
||||
set = true
|
||||
}
|
||||
if set && out == nil {
|
||||
out = []model.Grant{}
|
||||
}
|
||||
return out, set, nil
|
||||
}
|
||||
|
||||
func parseExpiry(in api.TokenInput) (*time.Time, error) {
|
||||
if in.ExpiresAt != nil {
|
||||
if in.ExpiresAt.Before(time.Now()) {
|
||||
return nil, badRequest("expires_at is in the past")
|
||||
}
|
||||
return in.ExpiresAt, nil
|
||||
}
|
||||
if in.ExpiresIn == "" {
|
||||
return nil, nil
|
||||
}
|
||||
v := strings.TrimSpace(in.ExpiresIn)
|
||||
var d time.Duration
|
||||
if n, ok := strings.CutSuffix(v, "d"); ok {
|
||||
days, err := strconv.Atoi(n)
|
||||
if err != nil || days <= 0 {
|
||||
return nil, badRequest("invalid expires_in %q", v)
|
||||
}
|
||||
d = time.Duration(days) * 24 * time.Hour
|
||||
} else {
|
||||
var err error
|
||||
d, err = time.ParseDuration(v)
|
||||
if err != nil || d <= 0 {
|
||||
return nil, badRequest("invalid expires_in %q (use e.g. 90d or 720h)", v)
|
||||
}
|
||||
}
|
||||
t := time.Now().Add(d)
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
func (s *Server) createTokenFor(w http.ResponseWriter, r *http.Request, p *auth.Principal, u *model.User) error {
|
||||
var in api.TokenInput
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(in.Name) == "" {
|
||||
return badRequest("a token needs a name")
|
||||
}
|
||||
grants, set, err := grantsFrom(in.Grants, in.GrantStrings, in.Role)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
exp, err := parseExpiry(in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// A restricted token can only create tokens that are no wider than
|
||||
// itself.
|
||||
if p.Token != nil && p.Token.Grants != nil {
|
||||
if !set {
|
||||
grants = p.Token.Grants
|
||||
} else if !narrower(grants, p.Token.Grants) {
|
||||
return forbidden("a restricted token can only create tokens with a subset of its own grants")
|
||||
}
|
||||
}
|
||||
secret, pid, hash := auth.NewToken()
|
||||
t := &model.Token{UserID: u.ID, Name: strings.TrimSpace(in.Name), PublicID: pid, SecretHash: hash, ExpiresAt: exp}
|
||||
if set {
|
||||
t.Grants = grants
|
||||
}
|
||||
if err := s.store.CreateToken(r.Context(), t); err != nil {
|
||||
return err
|
||||
}
|
||||
t.Username = u.Username
|
||||
s.eng.Audit(r.Context(), p, "token.create", "token", t.ID, u.Username+"/"+t.Name, "success", "")
|
||||
out := presentToken(t)
|
||||
out.Secret = secret
|
||||
writeJSON(w, http.StatusCreated, out)
|
||||
return nil
|
||||
}
|
||||
|
||||
// narrower reports whether every grant in a is covered by a grant in b
|
||||
// (a conservative check: identical or b is unfiltered for the resource).
|
||||
func narrower(a, b []model.Grant) bool {
|
||||
for _, g := range a {
|
||||
ok := false
|
||||
for _, h := range b {
|
||||
if (h.Resource == auth.ResAll || h.Resource == g.Resource) && (h.Access == auth.Write || g.Access == auth.Read) &&
|
||||
len(h.Zones) == 0 && len(h.Views) == 0 && len(h.Names) == 0 && len(h.Types) == 0 {
|
||||
ok = true
|
||||
break
|
||||
}
|
||||
if auth.FormatGrant(g) == auth.FormatGrant(h) {
|
||||
ok = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Server) createOwnToken(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
return s.createTokenFor(w, r, p, p.User)
|
||||
}
|
||||
|
||||
func (s *Server) listTokens(w http.ResponseWriter, r *http.Request, userID int64) error {
|
||||
ts, err := s.store.ListTokens(r.Context(), userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out := []api.Token{}
|
||||
for _, t := range ts {
|
||||
if t.RevokedAt != nil && !queryBool(r, "all") {
|
||||
continue
|
||||
}
|
||||
out = append(out, presentToken(t))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) listOwnTokens(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
return s.listTokens(w, r, p.User.ID)
|
||||
}
|
||||
|
||||
func (s *Server) listAllTokens(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := requireAdmin(p); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.listTokens(w, r, 0)
|
||||
}
|
||||
|
||||
func (s *Server) revokeToken(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
t, err := s.store.GetToken(r.Context(), id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if t.UserID != p.User.ID && !p.IsAdmin() {
|
||||
return notFound("token %d not found", id)
|
||||
}
|
||||
if err := s.store.RevokeToken(r.Context(), id); err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return conflict("token %d is already revoked", id)
|
||||
}
|
||||
return err
|
||||
}
|
||||
s.eng.Audit(r.Context(), p, "token.revoke", "token", t.ID, t.Username+"/"+t.Name, "success", "")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) listUsers(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := requireAdmin(p); err != nil {
|
||||
return err
|
||||
}
|
||||
us, err := s.store.ListUsers(r.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out := []api.User{}
|
||||
for _, u := range us {
|
||||
out = append(out, presentUser(u))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
return nil
|
||||
}
|
||||
|
||||
func validUsername(n string) error {
|
||||
if len(n) < 1 || len(n) > 64 {
|
||||
return badRequest("usernames must be 1 to 64 characters")
|
||||
}
|
||||
for _, c := range n {
|
||||
if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || strings.ContainsRune("._-@", c)) {
|
||||
return badRequest("usernames may contain letters, digits, '.', '_', '-' and '@'")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) createUser(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := requireAdmin(p); err != nil {
|
||||
return err
|
||||
}
|
||||
var in api.UserInput
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
return err
|
||||
}
|
||||
if in.Username == nil {
|
||||
return badRequest("a user needs a username")
|
||||
}
|
||||
u := &model.User{Username: strings.TrimSpace(*in.Username)}
|
||||
if err := validUsername(u.Username); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := applyUserInput(u, &in); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.store.CreateUser(r.Context(), u); err != nil {
|
||||
if errors.Is(err, store.ErrConflict) {
|
||||
return conflict("user %s already exists", u.Username)
|
||||
}
|
||||
return err
|
||||
}
|
||||
s.eng.Audit(r.Context(), p, "user.create", "user", u.ID, u.Username, "success", strings.Join(presentUser(u).GrantStrings, "; "))
|
||||
writeJSON(w, http.StatusCreated, presentUser(u))
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyUserInput(u *model.User, in *api.UserInput) error {
|
||||
role := ""
|
||||
if in.Role != nil {
|
||||
role = *in.Role
|
||||
}
|
||||
grants, set, err := grantsFrom(in.Grants, in.GrantStrings, role)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if set {
|
||||
u.Grants = grants
|
||||
}
|
||||
if in.Admin != nil {
|
||||
u.Admin = *in.Admin
|
||||
}
|
||||
if in.Disabled != nil {
|
||||
u.Disabled = *in.Disabled
|
||||
}
|
||||
if in.Comment != nil {
|
||||
u.Comment = *in.Comment
|
||||
}
|
||||
if in.Password != nil {
|
||||
if *in.Password == "" {
|
||||
u.PasswordHash = "" // service account: tokens only
|
||||
} else {
|
||||
h, err := auth.HashPassword(*in.Password)
|
||||
if err != nil {
|
||||
return badRequest("%v", err)
|
||||
}
|
||||
u.PasswordHash = h
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) userByPath(r *http.Request) (*model.User, error) {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u, err := s.store.GetUser(r.Context(), id)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return nil, notFound("user %d not found", id)
|
||||
}
|
||||
return u, err
|
||||
}
|
||||
|
||||
func (s *Server) getUser(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
u, err := s.userByPath(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if u.ID != p.User.ID && !p.IsAdmin() {
|
||||
return forbidden("only administrators can see other users")
|
||||
}
|
||||
writeJSON(w, http.StatusOK, presentUser(u))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) updateUser(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := requireAdmin(p); err != nil {
|
||||
return err
|
||||
}
|
||||
u, err := s.userByPath(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var in api.UserInput
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
return err
|
||||
}
|
||||
wasAdmin := u.Admin && !u.Disabled
|
||||
if in.Username != nil {
|
||||
u.Username = strings.TrimSpace(*in.Username)
|
||||
if err := validUsername(u.Username); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := applyUserInput(u, &in); err != nil {
|
||||
return err
|
||||
}
|
||||
if wasAdmin && (!u.Admin || u.Disabled) {
|
||||
if n, err := s.store.CountAdmins(r.Context()); err == nil && n <= 1 {
|
||||
return conflict("%s is the last administrator; make someone else an administrator first", u.Username)
|
||||
}
|
||||
}
|
||||
if err := s.store.UpdateUser(r.Context(), u); err != nil {
|
||||
if errors.Is(err, store.ErrConflict) {
|
||||
return conflict("user %s already exists", u.Username)
|
||||
}
|
||||
return err
|
||||
}
|
||||
if u.Disabled || in.Password != nil {
|
||||
s.store.DeleteUserSessions(r.Context(), u.ID, "")
|
||||
}
|
||||
s.eng.Audit(r.Context(), p, "user.update", "user", u.ID, u.Username, "success", strings.Join(presentUser(u).GrantStrings, "; "))
|
||||
writeJSON(w, http.StatusOK, presentUser(u))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) deleteUser(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := requireAdmin(p); err != nil {
|
||||
return err
|
||||
}
|
||||
u, err := s.userByPath(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if u.ID == p.User.ID {
|
||||
return conflict("you can't delete yourself")
|
||||
}
|
||||
if u.Admin && !u.Disabled {
|
||||
if n, err := s.store.CountAdmins(r.Context()); err == nil && n <= 1 {
|
||||
return conflict("%s is the last administrator", u.Username)
|
||||
}
|
||||
}
|
||||
if err := s.store.DeleteUser(r.Context(), u.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
s.eng.Audit(r.Context(), p, "user.delete", "user", u.ID, u.Username, "success", "")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) listUserTokens(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
u, err := s.userByPath(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if u.ID != p.User.ID && !p.IsAdmin() {
|
||||
return forbidden("only administrators can see other users' tokens")
|
||||
}
|
||||
return s.listTokens(w, r, u.ID)
|
||||
}
|
||||
|
||||
func (s *Server) createUserToken(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
u, err := s.userByPath(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if u.ID != p.User.ID && !p.IsAdmin() {
|
||||
return forbidden("only administrators can create tokens for other users")
|
||||
}
|
||||
return s.createTokenFor(w, r, p, u)
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"jarl/internal/api"
|
||||
"jarl/internal/auth"
|
||||
"jarl/internal/engine"
|
||||
"jarl/internal/model"
|
||||
"jarl/internal/store"
|
||||
)
|
||||
|
||||
func toRequests(in []api.ChangeRequest) []engine.Request {
|
||||
out := make([]engine.Request, len(in))
|
||||
for i, c := range in {
|
||||
out[i] = engine.Request{Op: c.Op, ID: c.ID, BaseVersion: c.BaseVersion, Data: c.Data}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func presentChangeset(cs *model.Changeset) api.Changeset {
|
||||
c := *cs
|
||||
c.Ops = engine.RedactOps(cs.Ops)
|
||||
undoable := false
|
||||
if cs.Status == model.ChangesetApplied && len(cs.Result) > 0 {
|
||||
var r struct {
|
||||
Undo []json.RawMessage `json:"undo"`
|
||||
}
|
||||
if json.Unmarshal(cs.Result, &r) == nil {
|
||||
undoable = len(r.Undo) > 0
|
||||
}
|
||||
// The undo operations hold old TSIG secrets; don't send them.
|
||||
var m map[string]json.RawMessage
|
||||
if json.Unmarshal(cs.Result, &m) == nil {
|
||||
delete(m, "undo")
|
||||
c.Result, _ = json.Marshal(m)
|
||||
}
|
||||
}
|
||||
return api.Changeset{Changeset: c, Undoable: undoable}
|
||||
}
|
||||
|
||||
// applyBatch: POST /apply applies several requests atomically.
|
||||
func (s *Server) applyBatch(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
var in api.BatchRequest
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(in.Ops) == 0 {
|
||||
return badRequest("no operations given")
|
||||
}
|
||||
q := r.URL.Query()
|
||||
if in.DryRun {
|
||||
q.Set("dry_run", "1")
|
||||
}
|
||||
if in.Force {
|
||||
q.Set("force", "1")
|
||||
}
|
||||
r.URL.RawQuery = q.Encode()
|
||||
return s.mutate(w, r, p, in.Description, toRequests(in.Ops), nil)
|
||||
}
|
||||
|
||||
func (s *Server) listChangesets(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
f := store.ChangesetFilter{Status: r.URL.Query().Get("status"), Limit: queryInt(r, "limit", 100), Before: int64(queryInt(r, "before", 0))}
|
||||
all := p.IsAdmin() || p.Can(auth.Action{Resource: auth.ResAudit})
|
||||
if !all || queryBool(r, "mine") {
|
||||
f.UserID = p.User.ID
|
||||
}
|
||||
list, err := s.store.ListChangesets(r.Context(), f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out := []api.Changeset{}
|
||||
for _, cs := range list {
|
||||
out = append(out, presentChangeset(cs))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) createChangeset(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
var in api.BatchRequest
|
||||
body, err := readBody(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(body) > 0 {
|
||||
if err := json.Unmarshal(body, &in); err != nil {
|
||||
return badRequest("invalid JSON: %v", err)
|
||||
}
|
||||
}
|
||||
cs, err := s.eng.CreateChangeset(r.Context(), p, via(r, p), in.Description)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(in.Ops) > 0 {
|
||||
res, err := s.eng.Stage(r.Context(), cs.ID, toRequests(in.Ops), p)
|
||||
if err != nil {
|
||||
s.eng.Discard(r.Context(), cs.ID, p)
|
||||
return err
|
||||
}
|
||||
cs = res.Changeset
|
||||
if in.Apply {
|
||||
out, err := s.eng.Apply(r.Context(), cs.ID, p, engine.ApplyOptions{DryRun: in.DryRun, Force: in.Force})
|
||||
if err != nil {
|
||||
s.writeErr(w, r, err, out)
|
||||
return nil
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, api.MutationResponse{Applied: out.Applied, DryRun: out.DryRun, ChangesetID: cs.ID, Result: raw(out), Warnings: out.Warnings})
|
||||
return nil
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, api.MutationResponse{Staged: true, ChangesetID: cs.ID, Changeset: raw(presentChangeset(cs)), Added: engine.RedactOps(res.Added), Warnings: res.Warnings, Problems: raw(res.Problems)})
|
||||
return nil
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, presentChangeset(cs))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) changesetByPath(r *http.Request, p *auth.Principal) (*model.Changeset, error) {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cs, err := s.store.GetChangeset(r.Context(), id)
|
||||
if errors.Is(err, store.ErrNotFound) || err == nil && !engine.CanAccessChangeset(p, cs, false) {
|
||||
return nil, notFound("changeset %d not found", id)
|
||||
}
|
||||
return cs, err
|
||||
}
|
||||
|
||||
func (s *Server) getChangeset(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
cs, err := s.changesetByPath(r, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writeJSON(w, http.StatusOK, presentChangeset(cs))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) updateChangeset(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var in struct {
|
||||
Description string `json:"description"`
|
||||
}
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
return err
|
||||
}
|
||||
cs, err := s.eng.UpdateDescription(r.Context(), id, in.Description, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writeJSON(w, http.StatusOK, presentChangeset(cs))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) stageOps(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var in api.BatchRequest
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := s.eng.Stage(r.Context(), id, toRequests(in.Ops), p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, api.MutationResponse{Staged: true, ChangesetID: id, Changeset: raw(presentChangeset(res.Changeset)), Added: engine.RedactOps(res.Added), Warnings: res.Warnings, Problems: raw(res.Problems)})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) unstageOp(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
seq, err := strconv.Atoi(r.PathValue("seq"))
|
||||
if err != nil {
|
||||
return badRequest("invalid operation number")
|
||||
}
|
||||
cs, err := s.eng.RemoveOps(r.Context(), id, []int{seq}, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writeJSON(w, http.StatusOK, presentChangeset(cs))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) previewChangeset(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := s.eng.Preview(r.Context(), id, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writeJSON(w, http.StatusOK, res)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) applyChangeset(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var in struct {
|
||||
DryRun bool `json:"dry_run"`
|
||||
Force bool `json:"force"`
|
||||
}
|
||||
if body, err := readBody(r); err != nil {
|
||||
return err
|
||||
} else if len(body) > 0 {
|
||||
if err := json.Unmarshal(body, &in); err != nil {
|
||||
return badRequest("invalid JSON: %v", err)
|
||||
}
|
||||
}
|
||||
res, err := s.eng.Apply(r.Context(), id, p, engine.ApplyOptions{DryRun: in.DryRun || queryBool(r, "dry_run"), Force: in.Force || queryBool(r, "force")})
|
||||
if err != nil {
|
||||
s.writeErr(w, r, err, res)
|
||||
return nil
|
||||
}
|
||||
writeJSON(w, http.StatusOK, api.MutationResponse{Applied: res.Applied, DryRun: res.DryRun, ChangesetID: res.ChangesetID, Result: raw(res), Warnings: res.Warnings})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) discardChangeset(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.eng.Discard(r.Context(), id, p); err != nil {
|
||||
return err
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) revertChangeset(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cs, err := s.eng.Revert(r.Context(), id, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, presentChangeset(cs))
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- status, system, audit
|
||||
|
||||
func (s *Server) status(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
st, err := s.eng.Status(r.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !p.IsAdmin() && !p.Can(auth.Action{Resource: auth.ResOptions}) {
|
||||
// Keep it to the essentials for restricted principals.
|
||||
st.Problems = nil
|
||||
st.Drift = nil
|
||||
if st.Named != nil {
|
||||
st.Named.Raw = ""
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, api.StatusResponse{Version: s.cfg.Version, Engine: raw(st), Time: time.Now()})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) systemCheck(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := require(p, auth.Action{Resource: auth.ResOptions}); err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := s.eng.CheckCurrent(r.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writeJSON(w, http.StatusOK, res)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) systemResync(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := requireAdmin(p); err != nil {
|
||||
return err
|
||||
}
|
||||
var in struct {
|
||||
DryRun bool `json:"dry_run"`
|
||||
Force bool `json:"force"`
|
||||
}
|
||||
if body, err := readBody(r); err != nil {
|
||||
return err
|
||||
} else if len(body) > 0 {
|
||||
if err := json.Unmarshal(body, &in); err != nil {
|
||||
return badRequest("invalid JSON: %v", err)
|
||||
}
|
||||
}
|
||||
res, err := s.eng.Resync(r.Context(), p, engine.ApplyOptions{DryRun: in.DryRun, Force: in.Force})
|
||||
if err != nil {
|
||||
s.writeErr(w, r, err, res)
|
||||
return nil
|
||||
}
|
||||
writeJSON(w, http.StatusOK, res)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) listAudit(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
q := r.URL.Query()
|
||||
f := store.AuditFilter{Username: q.Get("user"), Action: q.Get("action"), Outcome: q.Get("outcome"), ObjectType: q.Get("object_type"),
|
||||
ChangesetID: int64(queryInt(r, "changeset", 0)), Search: q.Get("q"), Before: int64(queryInt(r, "before", 0)), Limit: queryInt(r, "limit", 200)}
|
||||
for name, dst := range map[string]*time.Time{"since": &f.Since, "until": &f.Until} {
|
||||
if v := q.Get(name); v != "" {
|
||||
t, err := time.Parse(time.RFC3339, v)
|
||||
if err != nil {
|
||||
if d, derr := time.Parse("2006-01-02", v); derr == nil {
|
||||
t = d
|
||||
} else {
|
||||
return badRequest("invalid %s %q (use RFC 3339 or YYYY-MM-DD)", name, v)
|
||||
}
|
||||
}
|
||||
*dst = t
|
||||
}
|
||||
}
|
||||
if !p.IsAdmin() && !p.Can(auth.Action{Resource: auth.ResAudit}) {
|
||||
// Everyone may see their own activity.
|
||||
f.Username = p.User.Username
|
||||
}
|
||||
evs, err := s.store.ListAudit(r.Context(), f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if evs == nil {
|
||||
evs = []*model.AuditEvent{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, evs)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,549 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"jarl/internal/api"
|
||||
"jarl/internal/auth"
|
||||
"jarl/internal/catalog"
|
||||
"jarl/internal/dnsname"
|
||||
"jarl/internal/engine"
|
||||
"jarl/internal/model"
|
||||
"jarl/internal/rr"
|
||||
)
|
||||
|
||||
// state returns the configuration a request works on: the current one,
|
||||
// or, when the request names an open changeset (?changeset= or the
|
||||
// X-Jarl-Changeset header), the configuration as it will be after that
|
||||
// changeset, so staged objects can be referred to.
|
||||
func (s *Server) state(r *http.Request) (*model.State, error) {
|
||||
st, _, err := s.states(r)
|
||||
return st, err
|
||||
}
|
||||
|
||||
// states is like state but also returns the current configuration when a
|
||||
// changeset is involved (base is nil otherwise).
|
||||
func (s *Server) states(r *http.Request) (work, base *model.State, err error) {
|
||||
if csID, _ := changesetTarget(r); csID > 0 {
|
||||
if p, ok := r.Context().Value(keyPrincipal).(*auth.Principal); ok {
|
||||
if w, b, err := s.eng.WorkingState(r.Context(), csID, p); err == nil {
|
||||
return w, b, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
st, err := s.store.LoadState(r.Context())
|
||||
return st, nil, err
|
||||
}
|
||||
|
||||
// ---- views
|
||||
|
||||
func (s *Server) listViews(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
st, err := s.state(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out := []api.View{}
|
||||
for _, v := range st.OrderedViews() {
|
||||
if p.Can(auth.Action{Resource: auth.ResViews, View: v.Name}) {
|
||||
out = append(out, presentView(st, v))
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) viewByPath(r *http.Request, st *model.State, p *auth.Principal) (*model.View, error) {
|
||||
ref := r.PathValue("id")
|
||||
var v *model.View
|
||||
if id, err := strconv.ParseInt(ref, 10, 64); err == nil {
|
||||
v = st.Views[id]
|
||||
} else {
|
||||
v = st.ViewByName(ref)
|
||||
}
|
||||
if v == nil || !p.Can(auth.Action{Resource: auth.ResViews, View: v.Name}) {
|
||||
return nil, notFound("view %s not found", ref)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func (s *Server) getView(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
st, err := s.state(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
v, err := s.viewByPath(r, st, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writeJSON(w, http.StatusOK, presentView(st, v))
|
||||
return nil
|
||||
}
|
||||
|
||||
func viewObject(st *model.State, id int64) any {
|
||||
if v := st.Views[id]; v != nil {
|
||||
return presentView(st, v)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) createView(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
body, err := readBody(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.mutate(w, r, p, "", []engine.Request{request(engine.OpViewCreate, 0, body)}, viewObject)
|
||||
}
|
||||
|
||||
func (s *Server) updateView(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
st, err := s.state(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
v, err := s.viewByPath(r, st, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body, err := readBody(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id := v.ID
|
||||
return s.mutate(w, r, p, "", []engine.Request{request(engine.OpViewUpdate, id, body)}, func(st *model.State, _ int64) any { return viewObject(st, id) })
|
||||
}
|
||||
|
||||
func (s *Server) deleteView(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
st, err := s.state(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
v, err := s.viewByPath(r, st, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, _ := json.Marshal(engine.DeleteInput{Cascade: queryBool(r, "cascade")})
|
||||
return s.mutate(w, r, p, "", []engine.Request{request(engine.OpViewDelete, v.ID, data)}, nil)
|
||||
}
|
||||
|
||||
func (s *Server) reorderViews(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
body, err := readBody(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.mutate(w, r, p, "", []engine.Request{request(engine.OpViewReorder, 0, body)}, nil)
|
||||
}
|
||||
|
||||
// ---- ACLs
|
||||
|
||||
func (s *Server) listACLs(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := require(p, auth.Action{Resource: auth.ResACLs}); err != nil {
|
||||
return err
|
||||
}
|
||||
st, err := s.state(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out := []api.ACL{}
|
||||
for _, a := range st.SortedACLs() {
|
||||
out = append(out, presentACL(st, a))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) aclByPath(r *http.Request, st *model.State) (*model.ACL, error) {
|
||||
ref := r.PathValue("id")
|
||||
var a *model.ACL
|
||||
if id, err := strconv.ParseInt(ref, 10, 64); err == nil {
|
||||
a = st.ACLs[id]
|
||||
} else {
|
||||
a = st.ACLByName(ref)
|
||||
}
|
||||
if a == nil {
|
||||
return nil, notFound("ACL %s not found", ref)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (s *Server) getACL(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := require(p, auth.Action{Resource: auth.ResACLs}); err != nil {
|
||||
return err
|
||||
}
|
||||
st, err := s.state(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
a, err := s.aclByPath(r, st)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writeJSON(w, http.StatusOK, presentACL(st, a))
|
||||
return nil
|
||||
}
|
||||
|
||||
func aclObject(st *model.State, id int64) any {
|
||||
if a := st.ACLs[id]; a != nil {
|
||||
return presentACL(st, a)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) createACL(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
body, err := readBody(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.mutate(w, r, p, "", []engine.Request{request(engine.OpACLCreate, 0, body)}, aclObject)
|
||||
}
|
||||
|
||||
func (s *Server) updateACL(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
st, err := s.state(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
a, err := s.aclByPath(r, st)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body, err := readBody(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id := a.ID
|
||||
return s.mutate(w, r, p, "", []engine.Request{request(engine.OpACLUpdate, id, body)}, func(st *model.State, _ int64) any { return aclObject(st, id) })
|
||||
}
|
||||
|
||||
func (s *Server) deleteACL(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
st, err := s.state(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
a, err := s.aclByPath(r, st)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.mutate(w, r, p, "", []engine.Request{request(engine.OpACLDelete, a.ID, nil)}, nil)
|
||||
}
|
||||
|
||||
// ---- TSIG keys
|
||||
|
||||
func (s *Server) listKeys(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := require(p, auth.Action{Resource: auth.ResTSIG}); err != nil {
|
||||
return err
|
||||
}
|
||||
st, err := s.state(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out := []api.TSIGKey{}
|
||||
for _, k := range st.SortedKeys() {
|
||||
out = append(out, presentKey(st, k, false))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) keyByPath(r *http.Request, st *model.State) (*model.TSIGKey, error) {
|
||||
ref := r.PathValue("id")
|
||||
var k *model.TSIGKey
|
||||
if id, err := strconv.ParseInt(ref, 10, 64); err == nil {
|
||||
k = st.Keys[id]
|
||||
} else {
|
||||
k = st.KeyByName(ref)
|
||||
}
|
||||
if k == nil {
|
||||
return nil, notFound("TSIG key %s not found", ref)
|
||||
}
|
||||
return k, nil
|
||||
}
|
||||
|
||||
func (s *Server) getKey(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := require(p, auth.Action{Resource: auth.ResTSIG}); err != nil {
|
||||
return err
|
||||
}
|
||||
st, err := s.state(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
k, err := s.keyByPath(r, st)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
withSecret := queryBool(r, "secret")
|
||||
if withSecret {
|
||||
if err := require(p, auth.Action{Resource: auth.ResTSIG, Write: true}); err != nil {
|
||||
return forbidden("showing a TSIG secret needs tsig:write")
|
||||
}
|
||||
s.eng.Audit(r.Context(), p, "tsig.reveal", "tsig", k.ID, k.Name, "success", "secret shown")
|
||||
}
|
||||
writeJSON(w, http.StatusOK, presentKey(st, k, withSecret))
|
||||
return nil
|
||||
}
|
||||
|
||||
func keyObject(withSecret bool) func(st *model.State, id int64) any {
|
||||
return func(st *model.State, id int64) any {
|
||||
if k := st.Keys[id]; k != nil {
|
||||
return presentKey(st, k, withSecret)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) createKey(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
body, err := readBody(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// The creator gets to see a generated secret once.
|
||||
return s.mutate(w, r, p, "", []engine.Request{request(engine.OpTSIGCreate, 0, body)}, keyObject(true))
|
||||
}
|
||||
|
||||
func (s *Server) updateKey(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
st, err := s.state(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
k, err := s.keyByPath(r, st)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body, err := readBody(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id := k.ID
|
||||
return s.mutate(w, r, p, "", []engine.Request{request(engine.OpTSIGUpdate, id, body)}, func(st *model.State, _ int64) any { return keyObject(false)(st, id) })
|
||||
}
|
||||
|
||||
func (s *Server) deleteKey(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
st, err := s.state(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
k, err := s.keyByPath(r, st)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.mutate(w, r, p, "", []engine.Request{request(engine.OpTSIGDelete, k.ID, nil)}, nil)
|
||||
}
|
||||
|
||||
// ---- options
|
||||
|
||||
func (s *Server) listOptions(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
st, err := s.state(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
scope := r.URL.Query().Get("scope")
|
||||
scopeID := int64(queryInt(r, "scope_id", 0))
|
||||
out := []api.Option{}
|
||||
var list []*model.Option
|
||||
if scope != "" {
|
||||
list = st.ScopedOptions(scope, scopeID)
|
||||
} else {
|
||||
for _, sc := range []string{model.ScopeTopLevel, model.ScopeOptions} {
|
||||
list = append(list, st.ScopedOptions(sc, 0)...)
|
||||
}
|
||||
for _, v := range st.OrderedViews() {
|
||||
list = append(list, st.ScopedOptions(model.ScopeView, v.ID)...)
|
||||
}
|
||||
for _, z := range st.SortedZones() {
|
||||
list = append(list, st.ScopedOptions(model.ScopeZone, z.ID)...)
|
||||
}
|
||||
}
|
||||
for _, o := range list {
|
||||
if canReadOption(p, st, o) {
|
||||
out = append(out, presentOption(st, o))
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) optionByPath(r *http.Request, st *model.State, p *auth.Principal) (*model.Option, error) {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
o := st.Options[id]
|
||||
if o == nil || !canReadOption(p, st, o) {
|
||||
return nil, notFound("option %d not found", id)
|
||||
}
|
||||
return o, nil
|
||||
}
|
||||
|
||||
func (s *Server) getOption(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
st, err := s.state(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
o, err := s.optionByPath(r, st, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writeJSON(w, http.StatusOK, presentOption(st, o))
|
||||
return nil
|
||||
}
|
||||
|
||||
func optionObject(st *model.State, id int64) any {
|
||||
if o := st.Options[id]; o != nil {
|
||||
return presentOption(st, o)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) createOption(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
body, err := readBody(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.mutate(w, r, p, "", []engine.Request{request(engine.OpOptionCreate, 0, body)}, optionObject)
|
||||
}
|
||||
|
||||
func (s *Server) updateOption(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
st, err := s.state(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
o, err := s.optionByPath(r, st, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body, err := readBody(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id := o.ID
|
||||
return s.mutate(w, r, p, "", []engine.Request{request(engine.OpOptionUpdate, id, body)}, func(st *model.State, _ int64) any { return optionObject(st, id) })
|
||||
}
|
||||
|
||||
func (s *Server) deleteOption(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
st, err := s.state(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
o, err := s.optionByPath(r, st, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.mutate(w, r, p, "", []engine.Request{request(engine.OpOptionDelete, o.ID, nil)}, nil)
|
||||
}
|
||||
|
||||
// ---- settings
|
||||
|
||||
func (s *Server) getSettings(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
st, err := s.state(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writeJSON(w, http.StatusOK, st.Settings)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) updateSettings(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
body, err := readBody(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.mutate(w, r, p, "update settings", []engine.Request{request(engine.OpSettingsUpdate, 0, body)}, func(st *model.State, _ int64) any { return st.Settings })
|
||||
}
|
||||
|
||||
// ---- catalogs
|
||||
|
||||
func (s *Server) catalogOptions(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
cat := catalog.Default()
|
||||
scope := r.URL.Query().Get("scope")
|
||||
if scope == "" {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"scopes": cat.Scopes(), "zone_types": cat.ZoneTypes()})
|
||||
return nil
|
||||
}
|
||||
list := cat.List(scope)
|
||||
if list == nil {
|
||||
return notFound("unknown scope %q (use one of %s)", scope, strings.Join(cat.Scopes(), ", "))
|
||||
}
|
||||
out := []*catalog.Statement{}
|
||||
for _, st := range list {
|
||||
if !st.Managed || queryBool(r, "all") {
|
||||
out = append(out, st)
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) catalogRRTypes(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
writeJSON(w, http.StatusOK, rr.Types())
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) catalogGrants(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
roles := map[string][]string{}
|
||||
for name, gs := range auth.RoleTemplates {
|
||||
for _, g := range gs {
|
||||
roles[name] = append(roles[name], auth.FormatGrant(g))
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"resources": auth.Resources, "access": []string{auth.Read, auth.Write}, "roles": roles,
|
||||
"syntax": "resource:access [zones=a,b] [views=x] [names=pattern] [types=TXT,CNAME]"})
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- search
|
||||
|
||||
func (s *Server) search(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
q := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("q")))
|
||||
if len(q) < 2 {
|
||||
return badRequest("search for at least 2 characters")
|
||||
}
|
||||
st, err := s.state(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
limit := queryInt(r, "limit", 200)
|
||||
out := []api.SearchResult{}
|
||||
add := func(res api.SearchResult) bool {
|
||||
out = append(out, res)
|
||||
return len(out) >= limit
|
||||
}
|
||||
for _, z := range st.SortedZones() {
|
||||
if strings.Contains(z.Name, q) && p.CanSeeZone(z.Name, st.ViewName(z)) {
|
||||
if add(api.SearchResult{Kind: "zone", ID: z.ID, ZoneID: z.ID, Label: engine.ZoneLabel(st, z), Detail: z.Type}) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
var recs []*model.Record
|
||||
for _, r := range st.Records {
|
||||
if strings.Contains(r.Name, q) || strings.Contains(strings.ToLower(r.Data), q) || strings.Contains(strings.ToLower(r.Comment), q) {
|
||||
recs = append(recs, r)
|
||||
}
|
||||
}
|
||||
sort.Slice(recs, func(i, j int) bool {
|
||||
if recs[i].Name != recs[j].Name {
|
||||
return model.CanonicalLess(recs[i].Name, recs[j].Name)
|
||||
}
|
||||
return recs[i].ID < recs[j].ID
|
||||
})
|
||||
for _, rec := range recs {
|
||||
if len(out) >= limit {
|
||||
break
|
||||
}
|
||||
if !canReadRecord(p, st, rec) {
|
||||
continue
|
||||
}
|
||||
z := st.Zones[rec.ZoneID]
|
||||
add(api.SearchResult{Kind: "record", ID: rec.ID, ZoneID: rec.ZoneID, Label: dnsname.Display(rec.Name) + " " + rec.Type + " " + rec.Data, Detail: engine.ZoneLabel(st, z)})
|
||||
}
|
||||
if p.Can(auth.Action{Resource: auth.ResACLs}) {
|
||||
for _, a := range st.SortedACLs() {
|
||||
if strings.Contains(strings.ToLower(a.Name), q) || strings.Contains(strings.ToLower(strings.Join(a.Elements, " ")), q) {
|
||||
add(api.SearchResult{Kind: "acl", ID: a.ID, Label: "ACL " + a.Name, Detail: strings.Join(a.Elements, "; ")})
|
||||
}
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,541 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"jarl/internal/api"
|
||||
"jarl/internal/auth"
|
||||
"jarl/internal/dnsname"
|
||||
"jarl/internal/engine"
|
||||
"jarl/internal/importer"
|
||||
"jarl/internal/model"
|
||||
"jarl/internal/render"
|
||||
)
|
||||
|
||||
// zoneByPath resolves {zone}: an ID, a zone name (with ?view= when the
|
||||
// name exists in several views), or "root" for ".".
|
||||
func (s *Server) zoneByPath(r *http.Request, st *model.State, p *auth.Principal) (*model.Zone, error) {
|
||||
ref := r.PathValue("zone")
|
||||
var z *model.Zone
|
||||
if id, err := strconv.ParseInt(ref, 10, 64); err == nil {
|
||||
z = st.Zones[id]
|
||||
if z == nil {
|
||||
return nil, notFound("zone %d not found", id)
|
||||
}
|
||||
} else {
|
||||
name := ref
|
||||
if name == "root" {
|
||||
name = "."
|
||||
}
|
||||
var err error
|
||||
z, err = engine.FindZoneRef(st, name, r.URL.Query().Get("view"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if !p.CanSeeZone(z.Name, st.ViewName(z)) {
|
||||
return nil, notFound("zone %s not found", ref)
|
||||
}
|
||||
return z, nil
|
||||
}
|
||||
|
||||
func recordCounts(st *model.State) map[int64]int {
|
||||
counts := map[int64]int{}
|
||||
for _, r := range st.Records {
|
||||
counts[r.ZoneID]++
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
func (s *Server) listZones(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
st, base, err := s.states(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
q := r.URL.Query()
|
||||
view, typ, search := q.Get("view"), q.Get("type"), strings.ToLower(q.Get("q"))
|
||||
counts := recordCounts(st)
|
||||
out := []api.Zone{}
|
||||
for _, z := range st.SortedZones() {
|
||||
vn := st.ViewName(z)
|
||||
if !p.CanSeeZone(z.Name, vn) {
|
||||
continue
|
||||
}
|
||||
if view != "" && !strings.EqualFold(view, vn) || typ != "" && typ != z.Type || search != "" && !strings.Contains(z.Name, search) {
|
||||
continue
|
||||
}
|
||||
zo := s.presentZone(st, z, counts)
|
||||
if base != nil {
|
||||
if old := base.Zones[z.ID]; old == nil {
|
||||
zo.Pending = "add"
|
||||
} else if old.Version != z.Version {
|
||||
zo.Pending = "change"
|
||||
}
|
||||
}
|
||||
out = append(out, zo)
|
||||
}
|
||||
if base != nil {
|
||||
for _, z := range base.SortedZones() {
|
||||
if st.Zones[z.ID] == nil && p.CanSeeZone(z.Name, base.ViewName(z)) {
|
||||
zo := s.presentZone(base, z, recordCounts(base))
|
||||
zo.Pending = "delete"
|
||||
out = append(out, zo)
|
||||
}
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) getZone(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
st, err := s.state(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
z, err := s.zoneByPath(r, st, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writeJSON(w, http.StatusOK, s.presentZone(st, z, recordCounts(st)))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) zoneObject(st *model.State, id int64) any {
|
||||
if z := st.Zones[id]; z != nil {
|
||||
return s.presentZone(st, z, recordCounts(st))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) createZone(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
body, err := readBody(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.mutate(w, r, p, "", []engine.Request{request(engine.OpZoneCreate, 0, body)}, s.zoneObject)
|
||||
}
|
||||
|
||||
func (s *Server) updateZone(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
st, err := s.state(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
z, err := s.zoneByPath(r, st, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body, err := readBody(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id := z.ID
|
||||
return s.mutate(w, r, p, "", []engine.Request{request(engine.OpZoneUpdate, id, body)}, func(st *model.State, _ int64) any { return s.zoneObject(st, id) })
|
||||
}
|
||||
|
||||
func (s *Server) deleteZone(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
st, err := s.state(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
z, err := s.zoneByPath(r, st, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.mutate(w, r, p, "", []engine.Request{request(engine.OpZoneDelete, z.ID, nil)}, nil)
|
||||
}
|
||||
|
||||
func (s *Server) zoneFile(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
st, err := s.state(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
z, err := s.zoneByPath(r, st, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if z.Type != model.ZonePrimary {
|
||||
return badRequest("only primary zones have a zone file generated by Jarl")
|
||||
}
|
||||
if err := require(p, auth.Action{Resource: auth.ResRecords, Zone: z.Name, View: st.ViewName(z)}); err != nil {
|
||||
return err
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`inline; filename="%s.zone"`, dnsname.FileSafe(z.Name)))
|
||||
w.Write([]byte(render.ZoneFile(st, z)))
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- records
|
||||
|
||||
func (s *Server) listRecords(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
st, base, err := s.states(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
z, err := s.zoneByPath(r, st, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
q := r.URL.Query()
|
||||
name := q.Get("name")
|
||||
if name != "" {
|
||||
if n, err := dnsname.Owner(name, z.Name); err == nil {
|
||||
name = n
|
||||
}
|
||||
}
|
||||
typ := strings.ToUpper(q.Get("type"))
|
||||
search := strings.ToLower(q.Get("q"))
|
||||
match := func(rec *model.Record) bool {
|
||||
if name != "" && rec.Name != name || typ != "" && rec.Type != typ {
|
||||
return false
|
||||
}
|
||||
return search == "" || strings.Contains(rec.Name, search) || strings.Contains(strings.ToLower(rec.Data), search) || strings.Contains(strings.ToLower(rec.Comment), search)
|
||||
}
|
||||
recs := st.ZoneRecords(z.ID)
|
||||
var deleted []*model.Record
|
||||
if base != nil {
|
||||
// Show records the changeset deletes too, marked as such.
|
||||
for _, rec := range base.ZoneRecords(z.ID) {
|
||||
if st.Records[rec.ID] == nil {
|
||||
deleted = append(deleted, rec)
|
||||
}
|
||||
}
|
||||
recs = append(recs, deleted...)
|
||||
model.SortRecords(recs, z)
|
||||
}
|
||||
out := []api.Record{}
|
||||
for _, rec := range recs {
|
||||
if !match(rec) {
|
||||
continue
|
||||
}
|
||||
src := st
|
||||
if st.Records[rec.ID] == nil {
|
||||
src = base
|
||||
}
|
||||
if !canReadRecord(p, src, rec) {
|
||||
continue
|
||||
}
|
||||
ro := presentRecord(src, rec)
|
||||
if base != nil {
|
||||
switch old := base.Records[rec.ID]; {
|
||||
case src == base:
|
||||
ro.Pending = "delete"
|
||||
case old == nil:
|
||||
ro.Pending = "add"
|
||||
case old.Version != rec.Version:
|
||||
ro.Pending = "change"
|
||||
}
|
||||
}
|
||||
out = append(out, ro)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
return nil
|
||||
}
|
||||
|
||||
func recordObject(st *model.State, id int64) any {
|
||||
if r := st.Records[id]; r != nil {
|
||||
return presentRecord(st, r)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// withZone puts the zone ID into a record request body.
|
||||
func withZone(body json.RawMessage, zoneID int64) (json.RawMessage, error) {
|
||||
m := map[string]any{}
|
||||
if len(body) > 0 {
|
||||
if err := json.Unmarshal(body, &m); err != nil {
|
||||
return nil, badRequest("invalid JSON: %v", err)
|
||||
}
|
||||
}
|
||||
delete(m, "zone")
|
||||
delete(m, "view")
|
||||
m["zone_id"] = zoneID
|
||||
return json.Marshal(m)
|
||||
}
|
||||
|
||||
func (s *Server) createRecord(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
st, err := s.state(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
z, err := s.zoneByPath(r, st, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body, err := readBody(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Several records may be posted as a JSON array.
|
||||
var reqs []engine.Request
|
||||
if len(body) > 0 && strings.HasPrefix(strings.TrimSpace(string(body)), "[") {
|
||||
var items []json.RawMessage
|
||||
if err := json.Unmarshal(body, &items); err != nil {
|
||||
return badRequest("invalid JSON: %v", err)
|
||||
}
|
||||
for _, it := range items {
|
||||
b, err := withZone(it, z.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reqs = append(reqs, request(engine.OpRecordCreate, 0, b))
|
||||
}
|
||||
} else {
|
||||
b, err := withZone(body, z.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reqs = []engine.Request{request(engine.OpRecordCreate, 0, b)}
|
||||
}
|
||||
return s.mutate(w, r, p, "", reqs, recordObject)
|
||||
}
|
||||
|
||||
func (s *Server) recordByPath(r *http.Request, st *model.State, p *auth.Principal) (*model.Record, error) {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rec := st.Records[id]
|
||||
if rec == nil || !canReadRecord(p, st, rec) {
|
||||
return nil, notFound("record %d not found", id)
|
||||
}
|
||||
return rec, nil
|
||||
}
|
||||
|
||||
func (s *Server) getRecord(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
st, err := s.state(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rec, err := s.recordByPath(r, st, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writeJSON(w, http.StatusOK, presentRecord(st, rec))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) updateRecord(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
st, err := s.state(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rec, err := s.recordByPath(r, st, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body, err := readBody(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req := request(engine.OpRecordUpdate, rec.ID, body)
|
||||
if v := r.Header.Get("If-Match"); v != "" {
|
||||
if n, err := strconv.Atoi(strings.Trim(v, `"W/`)); err == nil {
|
||||
req.BaseVersion = n
|
||||
}
|
||||
}
|
||||
id := rec.ID
|
||||
return s.mutate(w, r, p, "", []engine.Request{req}, func(st *model.State, _ int64) any { return recordObject(st, id) })
|
||||
}
|
||||
|
||||
func (s *Server) deleteRecord(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
st, err := s.state(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rec, err := s.recordByPath(r, st, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var data json.RawMessage
|
||||
if queryBool(r, "ptr") {
|
||||
data, _ = json.Marshal(map[string]any{"ptr": true})
|
||||
}
|
||||
return s.mutate(w, r, p, "", []engine.Request{request(engine.OpRecordDelete, rec.ID, data)}, nil)
|
||||
}
|
||||
|
||||
// deleteRecordsByMatch: DELETE /zones/{zone}/records?name=&type=[&data=][&missing_ok=1][&ptr=1]
|
||||
func (s *Server) deleteRecordsByMatch(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
st, err := s.state(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
z, err := s.zoneByPath(r, st, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
q := r.URL.Query()
|
||||
if q.Get("name") == "" || q.Get("type") == "" {
|
||||
return badRequest("give name and type (and optionally data) of the records to delete")
|
||||
}
|
||||
in := engine.RecordInput{ZoneID: z.ID, Name: model.Ptr(q.Get("name")), Type: model.Ptr(q.Get("type")), MissingOK: queryBool(r, "missing_ok"), PTR: queryBool(r, "ptr")}
|
||||
if d := q.Get("data"); d != "" {
|
||||
in.Data = &d
|
||||
}
|
||||
return s.mutate(w, r, p, "", []engine.Request{engine.NewRequest(engine.OpRecordDelete, 0, in)}, nil)
|
||||
}
|
||||
|
||||
func (s *Server) putRRSet(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
st, err := s.state(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
z, err := s.zoneByPath(r, st, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var in struct {
|
||||
TTL model.Opt[uint32] `json:"ttl,omitzero"`
|
||||
Data []string `json:"data"`
|
||||
Comment *string `json:"comment,omitempty"`
|
||||
}
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
return err
|
||||
}
|
||||
req := engine.NewRequest(engine.OpRRSetReplace, 0, engine.RRSetInput{ZoneID: z.ID, Name: r.PathValue("name"), Type: r.PathValue("type"), TTL: in.TTL, Data: in.Data, Comment: in.Comment})
|
||||
zid, name, typ := z.ID, r.PathValue("name"), strings.ToUpper(r.PathValue("type"))
|
||||
return s.mutate(w, r, p, "", []engine.Request{req}, func(st *model.State, _ int64) any {
|
||||
z := st.Zones[zid]
|
||||
owner, _ := dnsname.Owner(name, z.Name)
|
||||
out := []api.Record{}
|
||||
for _, rec := range st.ZoneRecords(zid) {
|
||||
if rec.Name == owner && rec.Type == typ {
|
||||
out = append(out, presentRecord(st, rec))
|
||||
}
|
||||
}
|
||||
return out
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) deleteRRSet(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
st, err := s.state(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
z, err := s.zoneByPath(r, st, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req := engine.NewRequest(engine.OpRRSetReplace, 0, engine.RRSetInput{ZoneID: z.ID, Name: r.PathValue("name"), Type: r.PathValue("type"), Data: []string{}})
|
||||
return s.mutate(w, r, p, "", []engine.Request{req}, nil)
|
||||
}
|
||||
|
||||
// importZoneFile turns zone file text into record operations.
|
||||
func (s *Server) importZoneFile(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
st, err := s.state(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
z, err := s.zoneByPath(r, st, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if z.Type != model.ZonePrimary {
|
||||
return badRequest("records can only be imported into primary zones")
|
||||
}
|
||||
var in api.ZoneImport
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
return err
|
||||
}
|
||||
if in.Mode == "" {
|
||||
in.Mode = "merge"
|
||||
}
|
||||
if in.Mode != "merge" && in.Mode != "replace" {
|
||||
return badRequest(`mode must be "merge" or "replace"`)
|
||||
}
|
||||
pz, err := importer.ParseZoneText(z.Name, in.Text)
|
||||
if err != nil {
|
||||
return badRequest("could not parse the zone file: %v", err)
|
||||
}
|
||||
defTTL := z.DefaultTTL
|
||||
var reqs []engine.Request
|
||||
if in.UseSOA && pz.SOA != nil {
|
||||
soa := pz.SOA
|
||||
zi := engine.ZoneInput{SOA: &engine.SOAInput{Refresh: &soa.Refresh, Retry: &soa.Retry, Expire: &soa.Expire, Minimum: &soa.Minttl}}
|
||||
if pz.HasTTL {
|
||||
zi.DefaultTTL = &pz.DefaultTTL
|
||||
defTTL = pz.DefaultTTL
|
||||
}
|
||||
reqs = append(reqs, engine.NewRequest(engine.OpZoneUpdate, z.ID, zi))
|
||||
}
|
||||
ttlOf := func(t uint32) model.Opt[uint32] {
|
||||
if t == defTTL {
|
||||
return model.Opt[uint32]{Set: true, Null: true}
|
||||
}
|
||||
return model.Some(t)
|
||||
}
|
||||
existing := map[string]bool{}
|
||||
sets := map[model.RRSetKey]bool{}
|
||||
for _, rec := range st.ZoneRecords(z.ID) {
|
||||
existing[rec.Name+"|"+rec.Type+"|"+strings.ToLower(rec.Data)] = true
|
||||
sets[model.RRSetKey{Name: rec.Name, Type: rec.Type}] = true
|
||||
}
|
||||
switch in.Mode {
|
||||
case "merge":
|
||||
for _, pr := range pz.Records {
|
||||
if existing[pr.Name+"|"+pr.Type+"|"+strings.ToLower(pr.Data)] {
|
||||
continue
|
||||
}
|
||||
ri := engine.RecordInput{ZoneID: z.ID, Name: model.Ptr(pr.Name), Type: model.Ptr(pr.Type), Data: model.Ptr(pr.Data)}
|
||||
if pr.TTL != defTTL {
|
||||
ri.TTL = model.Some(pr.TTL)
|
||||
}
|
||||
if pr.Comment != "" {
|
||||
ri.Comment = model.Ptr(pr.Comment)
|
||||
}
|
||||
reqs = append(reqs, engine.NewRequest(engine.OpRecordCreate, 0, ri))
|
||||
}
|
||||
case "replace":
|
||||
type set struct {
|
||||
ttl uint32
|
||||
data []string
|
||||
}
|
||||
want := map[model.RRSetKey]*set{}
|
||||
var order []model.RRSetKey
|
||||
for _, pr := range pz.Records {
|
||||
k := model.RRSetKey{Name: pr.Name, Type: pr.Type}
|
||||
if want[k] == nil {
|
||||
want[k] = &set{ttl: pr.TTL}
|
||||
order = append(order, k)
|
||||
}
|
||||
want[k].data = append(want[k].data, pr.Data)
|
||||
}
|
||||
// Delete RRsets that are not in the file first, so CNAMEs can
|
||||
// replace other data.
|
||||
var gone []model.RRSetKey
|
||||
for k := range sets {
|
||||
if want[k] == nil {
|
||||
gone = append(gone, k)
|
||||
}
|
||||
}
|
||||
sort.Slice(gone, func(i, j int) bool { return gone[i].Name+gone[i].Type < gone[j].Name+gone[j].Type })
|
||||
for _, k := range gone {
|
||||
reqs = append(reqs, engine.NewRequest(engine.OpRRSetReplace, 0, engine.RRSetInput{ZoneID: z.ID, Name: k.Name, Type: k.Type, Data: []string{}}))
|
||||
}
|
||||
for _, k := range order {
|
||||
reqs = append(reqs, engine.NewRequest(engine.OpRRSetReplace, 0, engine.RRSetInput{ZoneID: z.ID, Name: k.Name, Type: k.Type, TTL: ttlOf(want[k].ttl), Data: want[k].data}))
|
||||
}
|
||||
}
|
||||
if len(reqs) == 0 {
|
||||
writeJSON(w, http.StatusOK, api.MutationResponse{Warnings: append([]string{"nothing to import: all records already exist"}, pz.Skipped...)})
|
||||
return nil
|
||||
}
|
||||
if in.DryRun {
|
||||
q := r.URL.Query()
|
||||
q.Set("dry_run", "1")
|
||||
r.URL.RawQuery = q.Encode()
|
||||
}
|
||||
desc := fmt.Sprintf("import %d records into %s (%s)", len(pz.Records), engine.ZoneLabel(st, z), in.Mode)
|
||||
// Report skipped records as warnings by wrapping the writer.
|
||||
if len(pz.Skipped) > 0 {
|
||||
w.Header().Set("X-Jarl-Skipped", strconv.Itoa(len(pz.Skipped)))
|
||||
}
|
||||
return s.mutate(w, r, p, desc, reqs, func(st *model.State, _ int64) any {
|
||||
return map[string]any{"records": len(pz.Records), "skipped": pz.Skipped}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"jarl/internal/api"
|
||||
"jarl/internal/auth"
|
||||
"jarl/internal/catalog"
|
||||
"jarl/internal/dnsname"
|
||||
"jarl/internal/engine"
|
||||
"jarl/internal/model"
|
||||
"jarl/internal/render"
|
||||
"jarl/internal/rr"
|
||||
)
|
||||
|
||||
func (s *Server) presentZone(st *model.State, z *model.Zone, counts map[int64]int) api.Zone {
|
||||
out := api.Zone{Zone: *z, Display: dnsname.Display(z.Name), View: st.ViewName(z), Records: counts[z.ID]}
|
||||
out.ZoneFile = render.ZoneFilePath(st, z, s.eng.Config().Paths)
|
||||
if z.Type == model.ZonePrimary {
|
||||
out.RName = dnsname.EmailFromMailbox(z.SOA.RName)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func presentRecord(st *model.State, r *model.Record) api.Record {
|
||||
z := st.Zones[r.ZoneID]
|
||||
out := api.Record{Record: *r}
|
||||
if z != nil {
|
||||
out.Zone, out.View, out.RelativeName, out.EffectiveTTL = z.Name, st.ViewName(z), dnsname.Relative(r.Name, z.Name), r.EffectiveTTL(z)
|
||||
if rr.IsTextType(r.Type) {
|
||||
if p, err := rr.Parse(z.Name, r.Name, r.Type, r.Data); err == nil {
|
||||
if t, ok := rr.TextValue(p.RR); ok {
|
||||
out.Text = &t
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func canReadRecord(p *auth.Principal, st *model.State, r *model.Record) bool {
|
||||
z := st.Zones[r.ZoneID]
|
||||
return z != nil && p.Can(auth.Action{Resource: auth.ResRecords, Zone: z.Name, View: st.ViewName(z), Name: r.Name, Type: r.Type})
|
||||
}
|
||||
|
||||
func presentView(st *model.State, v *model.View) api.View {
|
||||
return api.View{View: *v, Zones: len(st.ZonesInView(v.ID))}
|
||||
}
|
||||
|
||||
func presentACL(st *model.State, a *model.ACL) api.ACL {
|
||||
used := engine.FindACLRefs(st, a.Name)
|
||||
if used == nil {
|
||||
used = []string{}
|
||||
}
|
||||
return api.ACL{ACL: *a, UsedBy: used}
|
||||
}
|
||||
|
||||
func presentKey(st *model.State, k *model.TSIGKey, withSecret bool) api.TSIGKey {
|
||||
out := api.TSIGKey{TSIGKey: *k, UsedBy: engine.FindKeyRefs(st, k.Name)}
|
||||
if out.UsedBy == nil {
|
||||
out.UsedBy = []string{}
|
||||
}
|
||||
if !withSecret {
|
||||
out.Secret = ""
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func optionWhere(st *model.State, o *model.Option) string {
|
||||
switch o.Scope {
|
||||
case model.ScopeView:
|
||||
if v := st.Views[o.ScopeID]; v != nil {
|
||||
return "view " + v.Name
|
||||
}
|
||||
case model.ScopeZone:
|
||||
if z := st.Zones[o.ScopeID]; z != nil {
|
||||
return "zone " + engine.ZoneLabel(st, z)
|
||||
}
|
||||
case model.ScopeTopLevel:
|
||||
return "top level"
|
||||
}
|
||||
return "server options"
|
||||
}
|
||||
|
||||
func presentOption(st *model.State, o *model.Option) api.Option {
|
||||
out := api.Option{Option: *o, Where: optionWhere(st, o)}
|
||||
scope := catalog.ScopeOptions
|
||||
switch o.Scope {
|
||||
case model.ScopeView:
|
||||
scope = catalog.ScopeView
|
||||
case model.ScopeTopLevel:
|
||||
scope = catalog.ScopeTop
|
||||
case model.ScopeZone:
|
||||
if z := st.Zones[o.ScopeID]; z != nil {
|
||||
scope = catalog.ZoneScope(z.Type)
|
||||
}
|
||||
}
|
||||
if c, ok := catalog.Default().Lookup(scope, o.Name); ok {
|
||||
out.Description, out.Status = c.Description, c.Status
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// canReadOption reports whether p may see an option.
|
||||
func canReadOption(p *auth.Principal, st *model.State, o *model.Option) bool {
|
||||
switch o.Scope {
|
||||
case model.ScopeView:
|
||||
v := st.Views[o.ScopeID]
|
||||
return v != nil && p.Can(auth.Action{Resource: auth.ResViews, View: v.Name})
|
||||
case model.ScopeZone:
|
||||
z := st.Zones[o.ScopeID]
|
||||
return z != nil && p.Can(auth.Action{Resource: auth.ResZones, Zone: z.Name, View: st.ViewName(z)})
|
||||
}
|
||||
return p.Can(auth.Action{Resource: auth.ResOptions})
|
||||
}
|
||||
|
||||
func presentUser(u *model.User) api.User {
|
||||
out := api.User{User: *u}
|
||||
for _, g := range u.Grants {
|
||||
out.GrantStrings = append(out.GrantStrings, auth.FormatGrant(g))
|
||||
}
|
||||
if out.GrantStrings == nil {
|
||||
out.GrantStrings = []string{}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func presentToken(t *model.Token) api.Token {
|
||||
out := api.Token{Token: *t}
|
||||
for _, g := range t.Grants {
|
||||
out.GrantStrings = append(out.GrantStrings, auth.FormatGrant(g))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Server) whoamiOf(p *auth.Principal, se *model.Session) api.Whoami {
|
||||
out := api.Whoami{User: presentUser(p.User), Method: p.Method, Permissions: p.Summary(), Admin: p.IsAdmin(), Version: s.cfg.Version}
|
||||
if p.Token != nil {
|
||||
t := presentToken(p.Token)
|
||||
out.Token = &t
|
||||
}
|
||||
if se != nil {
|
||||
out.CSRFToken = se.CSRFToken
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"jarl/internal/api"
|
||||
"jarl/internal/auth"
|
||||
"jarl/internal/engine"
|
||||
"jarl/internal/store"
|
||||
)
|
||||
|
||||
// httpError is an error with an HTTP status.
|
||||
type httpError struct {
|
||||
status int
|
||||
code string
|
||||
msg string
|
||||
}
|
||||
|
||||
func (e *httpError) Error() string { return e.msg }
|
||||
|
||||
func badRequest(format string, args ...any) error {
|
||||
return &httpError{http.StatusBadRequest, "bad_request", fmt.Sprintf(format, args...)}
|
||||
}
|
||||
|
||||
func notFound(format string, args ...any) error {
|
||||
return &httpError{http.StatusNotFound, "not_found", fmt.Sprintf(format, args...)}
|
||||
}
|
||||
|
||||
func forbidden(format string, args ...any) error {
|
||||
return &httpError{http.StatusForbidden, "forbidden", fmt.Sprintf(format, args...)}
|
||||
}
|
||||
|
||||
func conflict(format string, args ...any) error {
|
||||
return &httpError{http.StatusConflict, "conflict", fmt.Sprintf(format, args...)}
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(status)
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetEscapeHTML(false)
|
||||
enc.Encode(v)
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, code, msg string) {
|
||||
writeJSON(w, status, api.ErrorBody{Error: api.ErrorInfo{Code: code, Message: msg}})
|
||||
}
|
||||
|
||||
func engineStatus(k engine.ErrorKind) int {
|
||||
switch k {
|
||||
case engine.KindBadRequest:
|
||||
return http.StatusBadRequest
|
||||
case engine.KindForbidden:
|
||||
return http.StatusForbidden
|
||||
case engine.KindNotFound:
|
||||
return http.StatusNotFound
|
||||
case engine.KindConflict, engine.KindDrift, engine.KindNotAdopted:
|
||||
return http.StatusConflict
|
||||
case engine.KindValidation:
|
||||
return http.StatusUnprocessableEntity
|
||||
case engine.KindBIND:
|
||||
return http.StatusBadGateway
|
||||
}
|
||||
return http.StatusInternalServerError
|
||||
}
|
||||
|
||||
func raw(v any) json.RawMessage {
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if string(b) == "null" {
|
||||
return nil
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// writeErr writes any error as a JSON error response. result, if given, is
|
||||
// included (e.g. the preview of a change that failed validation).
|
||||
func (s *Server) writeErr(w http.ResponseWriter, r *http.Request, err error, result any) {
|
||||
var he *httpError
|
||||
if errors.As(err, &he) {
|
||||
writeError(w, he.status, he.code, he.msg)
|
||||
return
|
||||
}
|
||||
if ee, ok := engine.AsError(err); ok {
|
||||
body := api.ErrorBody{Error: api.ErrorInfo{Code: string(ee.Kind), Message: ee.Error(), Op: ee.OpSeq, Problems: raw(ee.Problems), Check: raw(ee.Check)}}
|
||||
if result != nil {
|
||||
body.Error.Result = raw(result)
|
||||
}
|
||||
writeJSON(w, engineStatus(ee.Kind), body)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "not found")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, store.ErrConflict) {
|
||||
writeError(w, http.StatusConflict, "conflict", "already exists")
|
||||
return
|
||||
}
|
||||
s.log.Error("request failed", "method", r.Method, "path", r.URL.Path, "err", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal", "internal server error: "+err.Error())
|
||||
}
|
||||
|
||||
// handler is an authenticated API handler.
|
||||
type handler func(w http.ResponseWriter, r *http.Request, p *auth.Principal) error
|
||||
|
||||
// decodeJSON reads a JSON request body into v, rejecting unknown fields.
|
||||
func decodeJSON(r *http.Request, v any) error {
|
||||
ct := r.Header.Get("Content-Type")
|
||||
if ct != "" && !strings.HasPrefix(ct, "application/json") {
|
||||
return badRequest("expected a JSON request body (Content-Type: application/json)")
|
||||
}
|
||||
dec := json.NewDecoder(r.Body)
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(v); err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
return badRequest("request body is empty")
|
||||
}
|
||||
var mbe *http.MaxBytesError
|
||||
if errors.As(err, &mbe) {
|
||||
return &httpError{http.StatusRequestEntityTooLarge, "too_large", "request body is too large"}
|
||||
}
|
||||
return badRequest("invalid JSON: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// readBody returns the raw JSON body (may be empty).
|
||||
func readBody(r *http.Request) (json.RawMessage, error) {
|
||||
b, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
var mbe *http.MaxBytesError
|
||||
if errors.As(err, &mbe) {
|
||||
return nil, &httpError{http.StatusRequestEntityTooLarge, "too_large", "request body is too large"}
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if len(strings.TrimSpace(string(b))) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if !json.Valid(b) {
|
||||
return nil, badRequest("invalid JSON body")
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func pathID(r *http.Request, name string) (int64, error) {
|
||||
v := r.PathValue(name)
|
||||
id, err := strconv.ParseInt(v, 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
return 0, badRequest("invalid %s %q", name, v)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func queryBool(r *http.Request, name string) bool {
|
||||
switch strings.ToLower(r.URL.Query().Get(name)) {
|
||||
case "1", "true", "yes", "on":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func queryInt(r *http.Request, name string, def int) int {
|
||||
if v := r.URL.Query().Get(name); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"jarl/internal/api"
|
||||
"jarl/internal/auth"
|
||||
"jarl/internal/engine"
|
||||
"jarl/internal/model"
|
||||
)
|
||||
|
||||
func (s *Server) routes() {
|
||||
m := s.mux
|
||||
p := api.Prefix
|
||||
m.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "version": s.cfg.Version})
|
||||
})
|
||||
// Authentication
|
||||
m.HandleFunc("POST "+p+"/auth/login", s.public(s.login))
|
||||
m.HandleFunc("POST "+p+"/auth/logout", s.authed(s.logout))
|
||||
m.HandleFunc("GET "+p+"/auth/whoami", s.authed(s.whoami))
|
||||
m.HandleFunc("POST "+p+"/me/password", s.authed(s.changeOwnPassword))
|
||||
m.HandleFunc("GET "+p+"/me/tokens", s.authed(s.listOwnTokens))
|
||||
m.HandleFunc("POST "+p+"/me/tokens", s.authed(s.createOwnToken))
|
||||
m.HandleFunc("DELETE "+p+"/tokens/{id}", s.authed(s.revokeToken))
|
||||
m.HandleFunc("GET "+p+"/tokens", s.authed(s.listAllTokens))
|
||||
// Users (admin)
|
||||
m.HandleFunc("GET "+p+"/users", s.authed(s.listUsers))
|
||||
m.HandleFunc("POST "+p+"/users", s.authed(s.createUser))
|
||||
m.HandleFunc("GET "+p+"/users/{id}", s.authed(s.getUser))
|
||||
m.HandleFunc("PATCH "+p+"/users/{id}", s.authed(s.updateUser))
|
||||
m.HandleFunc("DELETE "+p+"/users/{id}", s.authed(s.deleteUser))
|
||||
m.HandleFunc("GET "+p+"/users/{id}/tokens", s.authed(s.listUserTokens))
|
||||
m.HandleFunc("POST "+p+"/users/{id}/tokens", s.authed(s.createUserToken))
|
||||
// Views
|
||||
m.HandleFunc("GET "+p+"/views", s.authed(s.listViews))
|
||||
m.HandleFunc("POST "+p+"/views", s.authed(s.createView))
|
||||
m.HandleFunc("POST "+p+"/views/order", s.authed(s.reorderViews))
|
||||
m.HandleFunc("GET "+p+"/views/{id}", s.authed(s.getView))
|
||||
m.HandleFunc("PATCH "+p+"/views/{id}", s.authed(s.updateView))
|
||||
m.HandleFunc("DELETE "+p+"/views/{id}", s.authed(s.deleteView))
|
||||
// ACLs
|
||||
m.HandleFunc("GET "+p+"/acls", s.authed(s.listACLs))
|
||||
m.HandleFunc("POST "+p+"/acls", s.authed(s.createACL))
|
||||
m.HandleFunc("GET "+p+"/acls/{id}", s.authed(s.getACL))
|
||||
m.HandleFunc("PATCH "+p+"/acls/{id}", s.authed(s.updateACL))
|
||||
m.HandleFunc("DELETE "+p+"/acls/{id}", s.authed(s.deleteACL))
|
||||
// TSIG keys
|
||||
m.HandleFunc("GET "+p+"/tsig-keys", s.authed(s.listKeys))
|
||||
m.HandleFunc("POST "+p+"/tsig-keys", s.authed(s.createKey))
|
||||
m.HandleFunc("GET "+p+"/tsig-keys/{id}", s.authed(s.getKey))
|
||||
m.HandleFunc("PATCH "+p+"/tsig-keys/{id}", s.authed(s.updateKey))
|
||||
m.HandleFunc("DELETE "+p+"/tsig-keys/{id}", s.authed(s.deleteKey))
|
||||
// Zones and records
|
||||
m.HandleFunc("GET "+p+"/zones", s.authed(s.listZones))
|
||||
m.HandleFunc("POST "+p+"/zones", s.authed(s.createZone))
|
||||
m.HandleFunc("GET "+p+"/zones/{zone}", s.authed(s.getZone))
|
||||
m.HandleFunc("PATCH "+p+"/zones/{zone}", s.authed(s.updateZone))
|
||||
m.HandleFunc("DELETE "+p+"/zones/{zone}", s.authed(s.deleteZone))
|
||||
m.HandleFunc("GET "+p+"/zones/{zone}/file", s.authed(s.zoneFile))
|
||||
m.HandleFunc("POST "+p+"/zones/{zone}/import", s.authed(s.importZoneFile))
|
||||
m.HandleFunc("GET "+p+"/zones/{zone}/records", s.authed(s.listRecords))
|
||||
m.HandleFunc("POST "+p+"/zones/{zone}/records", s.authed(s.createRecord))
|
||||
m.HandleFunc("DELETE "+p+"/zones/{zone}/records", s.authed(s.deleteRecordsByMatch))
|
||||
m.HandleFunc("PUT "+p+"/zones/{zone}/rrsets/{name}/{type}", s.authed(s.putRRSet))
|
||||
m.HandleFunc("DELETE "+p+"/zones/{zone}/rrsets/{name}/{type}", s.authed(s.deleteRRSet))
|
||||
m.HandleFunc("GET "+p+"/records/{id}", s.authed(s.getRecord))
|
||||
m.HandleFunc("PATCH "+p+"/records/{id}", s.authed(s.updateRecord))
|
||||
m.HandleFunc("DELETE "+p+"/records/{id}", s.authed(s.deleteRecord))
|
||||
// Options and settings
|
||||
m.HandleFunc("GET "+p+"/options", s.authed(s.listOptions))
|
||||
m.HandleFunc("POST "+p+"/options", s.authed(s.createOption))
|
||||
m.HandleFunc("GET "+p+"/options/{id}", s.authed(s.getOption))
|
||||
m.HandleFunc("PATCH "+p+"/options/{id}", s.authed(s.updateOption))
|
||||
m.HandleFunc("DELETE "+p+"/options/{id}", s.authed(s.deleteOption))
|
||||
m.HandleFunc("GET "+p+"/settings", s.authed(s.getSettings))
|
||||
m.HandleFunc("PATCH "+p+"/settings", s.authed(s.updateSettings))
|
||||
// Catalogs
|
||||
m.HandleFunc("GET "+p+"/catalog/options", s.authed(s.catalogOptions))
|
||||
m.HandleFunc("GET "+p+"/catalog/rrtypes", s.authed(s.catalogRRTypes))
|
||||
m.HandleFunc("GET "+p+"/catalog/grants", s.authed(s.catalogGrants))
|
||||
m.HandleFunc("GET "+p+"/search", s.authed(s.search))
|
||||
// Changesets
|
||||
m.HandleFunc("POST "+p+"/apply", s.authed(s.applyBatch))
|
||||
m.HandleFunc("GET "+p+"/changesets", s.authed(s.listChangesets))
|
||||
m.HandleFunc("POST "+p+"/changesets", s.authed(s.createChangeset))
|
||||
m.HandleFunc("GET "+p+"/changesets/{id}", s.authed(s.getChangeset))
|
||||
m.HandleFunc("PATCH "+p+"/changesets/{id}", s.authed(s.updateChangeset))
|
||||
m.HandleFunc("POST "+p+"/changesets/{id}/ops", s.authed(s.stageOps))
|
||||
m.HandleFunc("DELETE "+p+"/changesets/{id}/ops/{seq}", s.authed(s.unstageOp))
|
||||
m.HandleFunc("GET "+p+"/changesets/{id}/preview", s.authed(s.previewChangeset))
|
||||
m.HandleFunc("POST "+p+"/changesets/{id}/apply", s.authed(s.applyChangeset))
|
||||
m.HandleFunc("POST "+p+"/changesets/{id}/discard", s.authed(s.discardChangeset))
|
||||
m.HandleFunc("POST "+p+"/changesets/{id}/revert", s.authed(s.revertChangeset))
|
||||
// Status, system and audit
|
||||
m.HandleFunc("GET "+p+"/status", s.authed(s.status))
|
||||
m.HandleFunc("POST "+p+"/system/check", s.authed(s.systemCheck))
|
||||
m.HandleFunc("POST "+p+"/system/resync", s.authed(s.systemResync))
|
||||
m.HandleFunc("GET "+p+"/audit", s.authed(s.listAudit))
|
||||
m.HandleFunc(p+"/", func(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "no such API endpoint: "+r.Method+" "+r.URL.Path)
|
||||
})
|
||||
// Web UI
|
||||
if s.web != nil {
|
||||
files := http.FileServer(http.FS(s.web))
|
||||
m.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
w.Header().Set("Allow", "GET, HEAD")
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/" || r.URL.Path == "/index.html" {
|
||||
b, err := fs.ReadFile(s.web, "index.html")
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Write(b)
|
||||
return
|
||||
}
|
||||
if strings.HasPrefix(r.URL.Path, "/assets/") {
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
files.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// via names the interface a request came through.
|
||||
func via(r *http.Request, p *auth.Principal) string {
|
||||
if strings.Contains(r.UserAgent(), "jarl-cli") {
|
||||
return "cli"
|
||||
}
|
||||
if p.Method == "session" {
|
||||
return "web"
|
||||
}
|
||||
return "api"
|
||||
}
|
||||
|
||||
// changesetTarget returns the changeset to stage into, if any.
|
||||
func changesetTarget(r *http.Request) (int64, error) {
|
||||
v := r.URL.Query().Get("changeset")
|
||||
if v == "" {
|
||||
v = r.Header.Get("X-Jarl-Changeset")
|
||||
}
|
||||
if v == "" {
|
||||
return 0, nil
|
||||
}
|
||||
id, err := strconv.ParseInt(v, 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
return 0, badRequest("invalid changeset %q", v)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// mutate applies (or stages, or previews) requests and writes the
|
||||
// response. object, if set, returns the resulting object to include.
|
||||
func (s *Server) mutate(w http.ResponseWriter, r *http.Request, p *auth.Principal, description string, reqs []engine.Request, object func(st *model.State, created int64) any) error {
|
||||
csID, err := changesetTarget(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if csID != 0 {
|
||||
res, err := s.eng.Stage(r.Context(), csID, reqs, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res.Changeset.Ops = engine.RedactOps(res.Changeset.Ops)
|
||||
writeJSON(w, http.StatusAccepted, api.MutationResponse{Staged: true, ChangesetID: csID, Changeset: raw(res.Changeset), Added: engine.RedactOps(res.Added), Warnings: res.Warnings, Problems: raw(res.Problems)})
|
||||
return nil
|
||||
}
|
||||
opts := engine.ApplyOptions{DryRun: queryBool(r, "dry_run"), Force: queryBool(r, "force")}
|
||||
res, err := s.eng.Submit(r.Context(), reqs, p, via(r, p), description, opts)
|
||||
if err != nil {
|
||||
s.writeErr(w, r, err, res)
|
||||
return nil
|
||||
}
|
||||
out := api.MutationResponse{Applied: res.Applied, DryRun: res.DryRun, ChangesetID: res.ChangesetID, Result: raw(res), Warnings: res.Warnings}
|
||||
if object != nil && res.Applied {
|
||||
st, err := s.store.LoadState(r.Context())
|
||||
if err == nil {
|
||||
var created int64
|
||||
if len(res.Ops) > 0 {
|
||||
created = res.Ops[0].ID
|
||||
}
|
||||
out.Object = raw(object(st, created))
|
||||
}
|
||||
}
|
||||
status := http.StatusOK
|
||||
if res.Applied && r.Method == http.MethodPost {
|
||||
status = http.StatusCreated
|
||||
}
|
||||
writeJSON(w, status, out)
|
||||
return nil
|
||||
}
|
||||
|
||||
// request builds an engine request from a raw JSON body.
|
||||
func request(op string, id int64, body []byte) engine.Request {
|
||||
return engine.Request{Op: op, ID: id, Data: body}
|
||||
}
|
||||
|
||||
func requireAdmin(p *auth.Principal) error {
|
||||
if !p.IsAdmin() {
|
||||
return forbidden("this needs an administrator (and not a restricted token)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func require(p *auth.Principal, a auth.Action) error {
|
||||
if !p.Can(a) {
|
||||
return forbidden("permission denied: this needs %s", a)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
// Package server implements Jarl's HTTP API and serves the web UI.
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"jarl/internal/auth"
|
||||
"jarl/internal/engine"
|
||||
"jarl/internal/store"
|
||||
)
|
||||
|
||||
// Config configures the HTTP server.
|
||||
type Config struct {
|
||||
Listen []string
|
||||
TLSCertFile string
|
||||
TLSKeyFile string
|
||||
TrustedProxies []netip.Prefix
|
||||
SessionLifetime time.Duration
|
||||
SessionIdle time.Duration
|
||||
// CookieSecure is "auto" (secure when TLS is used or the request came
|
||||
// through a trusted HTTPS proxy), "always" or "never".
|
||||
CookieSecure string
|
||||
MaxBodyBytes int64
|
||||
Version string
|
||||
}
|
||||
|
||||
// Server is the HTTP server.
|
||||
type Server struct {
|
||||
cfg Config
|
||||
eng *engine.Engine
|
||||
store *store.Store
|
||||
log *slog.Logger
|
||||
mux *http.ServeMux
|
||||
limiter *rateLimiter
|
||||
web fs.FS
|
||||
certs *certReloader
|
||||
|
||||
mu sync.Mutex
|
||||
srvs []*http.Server
|
||||
}
|
||||
|
||||
// New creates a server. web holds the static web UI (may be nil).
|
||||
func New(cfg Config, eng *engine.Engine, log *slog.Logger, web fs.FS) *Server {
|
||||
if cfg.SessionLifetime == 0 {
|
||||
cfg.SessionLifetime = 12 * time.Hour
|
||||
}
|
||||
if cfg.SessionIdle == 0 {
|
||||
cfg.SessionIdle = 2 * time.Hour
|
||||
}
|
||||
if cfg.MaxBodyBytes == 0 {
|
||||
cfg.MaxBodyBytes = 16 << 20
|
||||
}
|
||||
if cfg.CookieSecure == "" {
|
||||
cfg.CookieSecure = "auto"
|
||||
}
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
s := &Server{cfg: cfg, eng: eng, store: eng.Store(), log: log, mux: http.NewServeMux(), limiter: newRateLimiter(), web: web}
|
||||
s.routes()
|
||||
return s
|
||||
}
|
||||
|
||||
// Handler returns the server's HTTP handler with all middleware.
|
||||
func (s *Server) Handler() http.Handler {
|
||||
return s.recoverer(s.securityHeaders(s.logRequests(s.mux)))
|
||||
}
|
||||
|
||||
// Serve listens on all configured addresses until ctx is canceled.
|
||||
func (s *Server) Serve(ctx context.Context, ready func()) error {
|
||||
if len(s.cfg.Listen) == 0 {
|
||||
return errors.New("no listen addresses configured")
|
||||
}
|
||||
var tlsCfg *tls.Config
|
||||
if s.cfg.TLSCertFile != "" {
|
||||
cr, err := newCertReloader(s.cfg.TLSCertFile, s.cfg.TLSKeyFile, s.log)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.certs = cr
|
||||
tlsCfg = &tls.Config{MinVersion: tls.VersionTLS12, GetCertificate: cr.get}
|
||||
}
|
||||
errc := make(chan error, len(s.cfg.Listen))
|
||||
var listeners []net.Listener
|
||||
for _, addr := range s.cfg.Listen {
|
||||
ln, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
for _, l := range listeners {
|
||||
l.Close()
|
||||
}
|
||||
return fmt.Errorf("listening on %s: %w", addr, err)
|
||||
}
|
||||
listeners = append(listeners, ln)
|
||||
}
|
||||
for i, ln := range listeners {
|
||||
srv := &http.Server{
|
||||
Handler: s.Handler(),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
ReadTimeout: 2 * time.Minute,
|
||||
WriteTimeout: 5 * time.Minute,
|
||||
IdleTimeout: 2 * time.Minute,
|
||||
TLSConfig: tlsCfg,
|
||||
ErrorLog: slog.NewLogLogger(s.log.Handler(), slog.LevelWarn),
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.srvs = append(s.srvs, srv)
|
||||
s.mu.Unlock()
|
||||
scheme := "http"
|
||||
if tlsCfg != nil {
|
||||
scheme = "https"
|
||||
}
|
||||
s.log.Info("listening", "addr", s.cfg.Listen[i], "url", scheme+"://"+ln.Addr().String())
|
||||
go func(srv *http.Server, ln net.Listener) {
|
||||
var err error
|
||||
if tlsCfg != nil {
|
||||
err = srv.ServeTLS(ln, "", "")
|
||||
} else {
|
||||
err = srv.Serve(ln)
|
||||
}
|
||||
if !errors.Is(err, http.ErrServerClosed) {
|
||||
errc <- err
|
||||
}
|
||||
}(srv, ln)
|
||||
}
|
||||
if tlsCfg == nil {
|
||||
for _, a := range s.cfg.Listen {
|
||||
if host, _, err := net.SplitHostPort(a); err == nil {
|
||||
if ip, err := netip.ParseAddr(host); host == "" || err == nil && !ip.IsLoopback() {
|
||||
s.log.Warn("serving plain HTTP on a non-loopback address: passwords and API tokens travel unencrypted; configure tls or a TLS reverse proxy", "addr", a)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if ready != nil {
|
||||
ready()
|
||||
}
|
||||
go s.housekeeping(ctx)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case err := <-errc:
|
||||
s.shutdown()
|
||||
return err
|
||||
}
|
||||
s.shutdown()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) shutdown() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for _, srv := range s.srvs {
|
||||
srv.Shutdown(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// ReloadTLS rereads the TLS certificate (on SIGHUP).
|
||||
func (s *Server) ReloadTLS() {
|
||||
if s.certs != nil {
|
||||
s.certs.reload()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) housekeeping(ctx context.Context) {
|
||||
t := time.NewTicker(10 * time.Minute)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
if err := s.store.PurgeSessions(ctx, time.Now()); err != nil {
|
||||
s.log.Warn("purging sessions failed", "err", err)
|
||||
}
|
||||
s.limiter.cleanup()
|
||||
if s.certs != nil {
|
||||
s.certs.reloadIfChanged()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// certReloader serves a certificate that is reread when the files change,
|
||||
// so renewed certificates (e.g. from Let's Encrypt) are picked up.
|
||||
type certReloader struct {
|
||||
certFile, keyFile string
|
||||
log *slog.Logger
|
||||
mu sync.RWMutex
|
||||
cert *tls.Certificate
|
||||
modTime time.Time
|
||||
}
|
||||
|
||||
func newCertReloader(certFile, keyFile string, log *slog.Logger) (*certReloader, error) {
|
||||
cr := &certReloader{certFile: certFile, keyFile: keyFile, log: log}
|
||||
if err := cr.load(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cr, nil
|
||||
}
|
||||
|
||||
func (cr *certReloader) load() error {
|
||||
cert, err := tls.LoadX509KeyPair(cr.certFile, cr.keyFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("loading TLS certificate: %w", err)
|
||||
}
|
||||
fi, _ := os.Stat(cr.certFile)
|
||||
cr.mu.Lock()
|
||||
cr.cert = &cert
|
||||
if fi != nil {
|
||||
cr.modTime = fi.ModTime()
|
||||
}
|
||||
cr.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cr *certReloader) reload() {
|
||||
if err := cr.load(); err != nil {
|
||||
cr.log.Error("reloading TLS certificate failed", "err", err)
|
||||
return
|
||||
}
|
||||
cr.log.Info("TLS certificate reloaded", "file", cr.certFile)
|
||||
}
|
||||
|
||||
func (cr *certReloader) reloadIfChanged() {
|
||||
fi, err := os.Stat(cr.certFile)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
cr.mu.RLock()
|
||||
changed := fi.ModTime().After(cr.modTime)
|
||||
cr.mu.RUnlock()
|
||||
if changed {
|
||||
cr.reload()
|
||||
}
|
||||
}
|
||||
|
||||
func (cr *certReloader) get(*tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||
cr.mu.RLock()
|
||||
defer cr.mu.RUnlock()
|
||||
return cr.cert, nil
|
||||
}
|
||||
|
||||
// ---- middleware
|
||||
|
||||
type ctxKey int
|
||||
|
||||
const (
|
||||
keyPrincipal ctxKey = iota
|
||||
keySession
|
||||
)
|
||||
|
||||
func (s *Server) recoverer(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
if v := recover(); v != nil {
|
||||
if v == http.ErrAbortHandler {
|
||||
panic(v)
|
||||
}
|
||||
s.log.Error("panic serving request", "path", r.URL.Path, "panic", v)
|
||||
writeError(w, http.StatusInternalServerError, "internal", "internal server error")
|
||||
}
|
||||
}()
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) securityHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
h := w.Header()
|
||||
h.Set("X-Content-Type-Options", "nosniff")
|
||||
h.Set("X-Frame-Options", "DENY")
|
||||
h.Set("Referrer-Policy", "no-referrer")
|
||||
h.Set("Content-Security-Policy", "default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'")
|
||||
h.Set("Cross-Origin-Opener-Policy", "same-origin")
|
||||
if s.isHTTPS(r) {
|
||||
h.Set("Strict-Transport-Security", "max-age=31536000")
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
type statusWriter struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
bytes int
|
||||
}
|
||||
|
||||
func (w *statusWriter) WriteHeader(code int) {
|
||||
w.status = code
|
||||
w.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func (w *statusWriter) Write(b []byte) (int, error) {
|
||||
if w.status == 0 {
|
||||
w.status = http.StatusOK
|
||||
}
|
||||
n, err := w.ResponseWriter.Write(b)
|
||||
w.bytes += n
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (s *Server) logRequests(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
sw := &statusWriter{ResponseWriter: w}
|
||||
next.ServeHTTP(sw, r)
|
||||
if r.URL.Path == "/healthz" || strings.HasPrefix(r.URL.Path, "/assets/") {
|
||||
return
|
||||
}
|
||||
user := ""
|
||||
if p, ok := r.Context().Value(keyPrincipal).(*auth.Principal); ok {
|
||||
user = p.Name()
|
||||
}
|
||||
s.log.Debug("http", "method", r.Method, "path", r.URL.Path, "status", sw.status, "bytes", sw.bytes, "duration", time.Since(start).Round(time.Millisecond), "ip", s.clientIP(r), "user", user)
|
||||
})
|
||||
}
|
||||
|
||||
// clientIP returns the client's address, honoring X-Forwarded-For from
|
||||
// trusted proxies.
|
||||
func (s *Server) clientIP(r *http.Request) string {
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
host = r.RemoteAddr
|
||||
}
|
||||
peer, err := netip.ParseAddr(host)
|
||||
if err != nil || !s.trusted(peer) {
|
||||
return host
|
||||
}
|
||||
xff := r.Header.Get("X-Forwarded-For")
|
||||
if xff == "" {
|
||||
return host
|
||||
}
|
||||
parts := strings.Split(xff, ",")
|
||||
// Walk from the right, skipping trusted proxies.
|
||||
for i := len(parts) - 1; i >= 0; i-- {
|
||||
a, err := netip.ParseAddr(strings.TrimSpace(parts[i]))
|
||||
if err != nil {
|
||||
return host
|
||||
}
|
||||
if !s.trusted(a) {
|
||||
return a.String()
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(parts[0])
|
||||
}
|
||||
|
||||
func (s *Server) trusted(a netip.Addr) bool {
|
||||
a = a.Unmap()
|
||||
for _, p := range s.cfg.TrustedProxies {
|
||||
if p.Contains(a) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *Server) isHTTPS(r *http.Request) bool {
|
||||
if r.TLS != nil {
|
||||
return true
|
||||
}
|
||||
host, _, _ := net.SplitHostPort(r.RemoteAddr)
|
||||
if a, err := netip.ParseAddr(host); err == nil && s.trusted(a) {
|
||||
return strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"jarl/internal/api"
|
||||
"jarl/internal/auth"
|
||||
"jarl/internal/engine"
|
||||
"jarl/internal/model"
|
||||
"jarl/internal/render"
|
||||
"jarl/internal/store"
|
||||
)
|
||||
|
||||
type apiEnv struct {
|
||||
t *testing.T
|
||||
ts *httptest.Server
|
||||
st *store.Store
|
||||
token string // admin token
|
||||
}
|
||||
|
||||
func newAPIEnv(t *testing.T) *apiEnv {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
st, err := store.Open(filepath.Join(dir, "jarl.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
quiet := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
eng := engine.New(st, nil, engine.Config{
|
||||
Paths: render.Paths{ConfigDir: filepath.Join(dir, "etc"), ZoneDir: filepath.Join(dir, "zones"), CacheDir: filepath.Join(dir, "cache")},
|
||||
StagingDir: filepath.Join(dir, "staging"), NoReload: true, SkipCheck: true,
|
||||
}, quiet)
|
||||
srv := New(Config{Version: "test"}, eng, quiet, nil)
|
||||
ts := httptest.NewServer(srv.Handler())
|
||||
t.Cleanup(ts.Close)
|
||||
hash, _ := auth.HashPassword("correct horse battery")
|
||||
admin := &model.User{Username: "admin", PasswordHash: hash, Admin: true}
|
||||
st.CreateUser(context.Background(), admin)
|
||||
tok, pid, sh := auth.NewToken()
|
||||
st.CreateToken(context.Background(), &model.Token{UserID: admin.ID, Name: "test", PublicID: pid, SecretHash: sh})
|
||||
env := &apiEnv{t: t, ts: ts, st: st, token: tok}
|
||||
env.do("PATCH", "/settings", env.token, map[string]any{"nameservers": []string{"ns1.example.com."}}, 200)
|
||||
return env
|
||||
}
|
||||
|
||||
// do sends a request with a bearer token and checks the status.
|
||||
func (e *apiEnv) do(method, path, token string, body any, want int) map[string]any {
|
||||
e.t.Helper()
|
||||
out, status := e.req(method, path, token, body)
|
||||
if status != want {
|
||||
e.t.Fatalf("%s %s = %d, want %d: %s", method, path, status, want, out)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
var m map[string]any
|
||||
if json.Unmarshal(out, &m) != nil {
|
||||
return map[string]any{"_raw": string(out)}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (e *apiEnv) req(method, path, token string, body any) ([]byte, int) {
|
||||
var rd io.Reader
|
||||
if body != nil {
|
||||
b, _ := json.Marshal(body)
|
||||
rd = bytes.NewReader(b)
|
||||
}
|
||||
r, _ := http.NewRequest(method, e.ts.URL+api.Prefix+path, rd)
|
||||
if token != "" {
|
||||
r.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
resp, err := http.DefaultClient.Do(r)
|
||||
if err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return b, resp.StatusCode
|
||||
}
|
||||
|
||||
func (e *apiEnv) list(path, token string) []map[string]any {
|
||||
e.t.Helper()
|
||||
b, status := e.req("GET", path, token, nil)
|
||||
if status != 200 {
|
||||
e.t.Fatalf("GET %s = %d: %s", path, status, b)
|
||||
}
|
||||
var out []map[string]any
|
||||
json.Unmarshal(b, &out)
|
||||
return out
|
||||
}
|
||||
|
||||
func TestRecordsAPI(t *testing.T) {
|
||||
e := newAPIEnv(t)
|
||||
e.do("POST", "/zones", e.token, map[string]any{"name": "example.com", "records": []map[string]any{{"name": "ns1", "type": "A", "data": "192.0.2.1"}}}, 201)
|
||||
res := e.do("POST", "/zones/example.com/records", e.token, map[string]any{"name": "www", "type": "A", "data": "192.0.2.10", "ttl": 300}, 201)
|
||||
obj := res["object"].(map[string]any)
|
||||
if obj["name"] != "www.example.com." || obj["relative_name"] != "www" || obj["effective_ttl"].(float64) != 300 {
|
||||
t.Errorf("object = %v", obj)
|
||||
}
|
||||
id := int64(obj["id"].(float64))
|
||||
// The CNAME rule, over HTTP.
|
||||
b, status := e.req("POST", "/zones/example.com/records", e.token, map[string]any{"name": "www", "type": "CNAME", "data": "ns1"})
|
||||
if status != 422 || !strings.Contains(string(b), "cname_conflict") {
|
||||
t.Errorf("CNAME next to A: %d %s", status, b)
|
||||
}
|
||||
// Bad data is a 400 with a clear message.
|
||||
b, status = e.req("POST", "/zones/example.com/records", e.token, map[string]any{"name": "x", "type": "A", "data": "1.2.3"})
|
||||
if status != 400 || !strings.Contains(string(b), "invalid A data") {
|
||||
t.Errorf("bad data: %d %s", status, b)
|
||||
}
|
||||
// TXT as plain text.
|
||||
e.do("POST", "/zones/example.com/records", e.token, map[string]any{"name": "@", "type": "TXT", "data": "v=spf1 mx -all"}, 201)
|
||||
recs := e.list("/zones/example.com/records?type=TXT", e.token)
|
||||
if len(recs) != 1 || recs[0]["data"] != `"v=spf1 mx -all"` || recs[0]["text"] != "v=spf1 mx -all" {
|
||||
t.Errorf("TXT = %v", recs)
|
||||
}
|
||||
// Update, dry run first.
|
||||
res = e.do("PATCH", "/records/"+itoa(id)+"?dry_run=1", e.token, map[string]any{"data": "192.0.2.11"}, 200)
|
||||
if res["applied"] == true || !strings.Contains(res["result"].(map[string]any)["diff"].(string), "192.0.2.11") {
|
||||
t.Errorf("dry run = %v", res)
|
||||
}
|
||||
e.do("PATCH", "/records/"+itoa(id), e.token, map[string]any{"data": "192.0.2.11"}, 200)
|
||||
got := e.do("GET", "/records/"+itoa(id), e.token, nil, 200)
|
||||
if got["data"] != "192.0.2.11" {
|
||||
t.Errorf("after update = %v", got)
|
||||
}
|
||||
// RRset replace and delete by match.
|
||||
e.do("PUT", "/zones/example.com/rrsets/api/A", e.token, map[string]any{"ttl": 60, "data": []string{"192.0.2.30", "192.0.2.31"}}, 200)
|
||||
if n := len(e.list("/zones/example.com/records?name=api", e.token)); n != 2 {
|
||||
t.Errorf("rrset has %d records", n)
|
||||
}
|
||||
e.do("DELETE", "/zones/example.com/records?name=api&type=A&data=192.0.2.30", e.token, nil, 200)
|
||||
e.do("DELETE", "/zones/example.com/rrsets/api/A", e.token, nil, 200)
|
||||
if n := len(e.list("/zones/example.com/records?name=api", e.token)); n != 0 {
|
||||
t.Errorf("rrset not deleted: %d", n)
|
||||
}
|
||||
// Zone file.
|
||||
b, status = e.req("GET", "/zones/example.com/file", e.token, nil)
|
||||
if status != 200 || !strings.Contains(string(b), "www\t\tIN\tA\t192.0.2.11") && !strings.Contains(string(b), "www\t300\tIN\tA\t192.0.2.11") {
|
||||
t.Errorf("zone file %d:\n%s", status, b)
|
||||
}
|
||||
// History and revert.
|
||||
cs := e.list("/changesets?status=applied", e.token)
|
||||
if len(cs) < 5 {
|
||||
t.Errorf("changesets = %d", len(cs))
|
||||
}
|
||||
if strings.Contains(string(mustJSON(cs)), `"undo"`) {
|
||||
t.Error("undo ops leaked in changeset list")
|
||||
}
|
||||
}
|
||||
|
||||
func itoa(n int64) string { return strings.TrimSpace(strings.TrimSuffix(string(mustJSON(n)), "\n")) }
|
||||
|
||||
func mustJSON(v any) []byte {
|
||||
b, _ := json.Marshal(v)
|
||||
return b
|
||||
}
|
||||
|
||||
func TestStagingAPI(t *testing.T) {
|
||||
e := newAPIEnv(t)
|
||||
e.do("POST", "/zones", e.token, map[string]any{"name": "example.com", "records": []map[string]any{{"name": "ns1", "type": "A", "data": "192.0.2.1"}}}, 201)
|
||||
cs := e.do("POST", "/changesets", e.token, map[string]any{"description": "web refresh"}, 201)
|
||||
id := itoa(int64(cs["id"].(float64)))
|
||||
res := e.do("POST", "/zones/example.com/records?changeset="+id, e.token, map[string]any{"name": "www", "type": "A", "data": "192.0.2.10"}, 202)
|
||||
if res["staged"] != true {
|
||||
t.Errorf("not staged: %v", res)
|
||||
}
|
||||
if n := len(e.list("/zones/example.com/records?name=www", e.token)); n != 0 {
|
||||
t.Error("staged record visible before apply")
|
||||
}
|
||||
prev := e.do("GET", "/changesets/"+id+"/preview", e.token, nil, 200)
|
||||
if prev["valid"] != true || !strings.Contains(prev["diff"].(string), "192.0.2.10") {
|
||||
t.Errorf("preview = %v", prev)
|
||||
}
|
||||
e.do("POST", "/changesets/"+id+"/apply", e.token, nil, 200)
|
||||
if n := len(e.list("/zones/example.com/records?name=www", e.token)); n != 1 {
|
||||
t.Error("record missing after apply")
|
||||
}
|
||||
b, status := e.req("POST", "/zones/example.com/records?changeset="+id, e.token, map[string]any{"name": "x", "type": "A", "data": "192.0.2.2"})
|
||||
if status != 409 {
|
||||
t.Errorf("staging into applied changeset: %d %s", status, b)
|
||||
}
|
||||
// Batch apply is atomic.
|
||||
b, status = e.req("POST", "/apply", e.token, map[string]any{"ops": []map[string]any{
|
||||
{"op": "record.create", "data": map[string]any{"zone": "example.com", "name": "a", "type": "A", "data": "192.0.2.3"}},
|
||||
{"op": "record.create", "data": map[string]any{"zone": "example.com", "name": "a", "type": "CNAME", "data": "www"}},
|
||||
}})
|
||||
if status != 422 {
|
||||
t.Errorf("bad batch: %d %s", status, b)
|
||||
}
|
||||
if n := len(e.list("/zones/example.com/records?name=a", e.token)); n != 0 {
|
||||
t.Error("part of a failed batch was applied")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionsAndPermissions(t *testing.T) {
|
||||
e := newAPIEnv(t)
|
||||
e.do("POST", "/zones", e.token, map[string]any{"name": "example.com", "records": []map[string]any{{"name": "ns1", "type": "A", "data": "192.0.2.1"}}}, 201)
|
||||
e.do("POST", "/zones", e.token, map[string]any{"name": "secret.org", "nameservers": []string{"ns1.example.com."}}, 201)
|
||||
// Unauthenticated.
|
||||
if _, status := e.req("GET", "/zones", "", nil); status != 401 {
|
||||
t.Errorf("unauthenticated = %d", status)
|
||||
}
|
||||
if _, status := e.req("GET", "/zones", "jarl_aaaaaaaaaaaaaaaa_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", nil); status != 401 {
|
||||
t.Errorf("bad token = %d", status)
|
||||
}
|
||||
// An ACME user with a restricted token.
|
||||
e.do("POST", "/users", e.token, map[string]any{"username": "acme", "password": "acme-password-123", "grant_strings": []string{"records:write zones=example.com names=_acme-challenge.* types=TXT"}}, 201)
|
||||
users := e.list("/users", e.token)
|
||||
var acmeID int64
|
||||
for _, u := range users {
|
||||
if u["username"] == "acme" {
|
||||
acmeID = int64(u["id"].(float64))
|
||||
}
|
||||
}
|
||||
tok := e.do("POST", "/users/"+itoa(acmeID)+"/tokens", e.token, map[string]any{"name": "certbot", "expires_in": "90d"}, 201)
|
||||
acme := tok["token"].(string)
|
||||
e.do("POST", "/zones/example.com/records", acme, map[string]any{"name": "_acme-challenge.www", "type": "TXT", "data": "token"}, 201)
|
||||
if _, status := e.req("POST", "/zones/example.com/records", acme, map[string]any{"name": "www", "type": "A", "data": "192.0.2.66"}); status != 403 {
|
||||
t.Errorf("acme wrote an A record: %d", status)
|
||||
}
|
||||
zones := e.list("/zones", acme)
|
||||
if len(zones) != 1 || zones[0]["name"] != "example.com." {
|
||||
t.Errorf("acme sees zones %v", zones)
|
||||
}
|
||||
if recs := e.list("/zones/example.com/records", acme); len(recs) != 1 {
|
||||
t.Errorf("acme sees %d records", len(recs))
|
||||
}
|
||||
if _, status := e.req("GET", "/zones/secret.org", acme, nil); status != 404 {
|
||||
t.Errorf("acme can see secret.org: %d", status)
|
||||
}
|
||||
if _, status := e.req("GET", "/users", acme, nil); status != 403 {
|
||||
t.Errorf("acme lists users: %d", status)
|
||||
}
|
||||
// Session login with CSRF protection.
|
||||
jar, _ := cookiejar.New(nil)
|
||||
c := &http.Client{Jar: jar}
|
||||
post := func(path, csrf string, body any) *http.Response {
|
||||
b, _ := json.Marshal(body)
|
||||
r, _ := http.NewRequest("POST", e.ts.URL+api.Prefix+path, bytes.NewReader(b))
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
if csrf != "" {
|
||||
r.Header.Set(csrfHeader, csrf)
|
||||
}
|
||||
resp, err := c.Do(r)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
resp := post("/auth/login", "", map[string]string{"username": "admin", "password": "wrong"})
|
||||
if resp.StatusCode != 401 {
|
||||
t.Errorf("wrong password = %d", resp.StatusCode)
|
||||
}
|
||||
resp = post("/auth/login", "", map[string]string{"username": "ADMIN", "password": "correct horse battery"})
|
||||
var who api.Whoami
|
||||
json.NewDecoder(resp.Body).Decode(&who)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != 200 || who.CSRFToken == "" || !who.Admin {
|
||||
t.Fatalf("login = %d %+v", resp.StatusCode, who)
|
||||
}
|
||||
if r := post("/zones/example.com/records", "", map[string]any{"name": "web", "type": "A", "data": "192.0.2.80"}); r.StatusCode != 403 {
|
||||
t.Errorf("missing CSRF = %d", r.StatusCode)
|
||||
}
|
||||
if r := post("/zones/example.com/records", who.CSRFToken, map[string]any{"name": "web", "type": "A", "data": "192.0.2.80"}); r.StatusCode != 201 {
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
t.Errorf("with CSRF = %d %s", r.StatusCode, b)
|
||||
}
|
||||
cs := e.list("/changesets?status=applied", e.token)
|
||||
if cs[0]["via"] != "web" || cs[0]["username"] != "admin" {
|
||||
t.Errorf("last changeset = %v", cs[0])
|
||||
}
|
||||
if r := post("/auth/logout", who.CSRFToken, nil); r.StatusCode != 204 {
|
||||
t.Errorf("logout = %d", r.StatusCode)
|
||||
}
|
||||
resp2, _ := c.Get(e.ts.URL + api.Prefix + "/auth/whoami")
|
||||
if resp2.StatusCode != 401 {
|
||||
t.Errorf("after logout = %d", resp2.StatusCode)
|
||||
}
|
||||
// Audit trail.
|
||||
evs := e.list("/audit?action=auth.", e.token)
|
||||
if len(evs) < 3 {
|
||||
t.Errorf("auth audit events = %d", len(evs))
|
||||
}
|
||||
// Login rate limiting.
|
||||
for i := 0; i < 10; i++ {
|
||||
post("/auth/login", "", map[string]string{"username": "acme", "password": "nope"})
|
||||
}
|
||||
if r := post("/auth/login", "", map[string]string{"username": "acme", "password": "acme-password-123"}); r.StatusCode != 429 {
|
||||
t.Errorf("rate limit = %d", r.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTSIGSecrets(t *testing.T) {
|
||||
e := newAPIEnv(t)
|
||||
res := e.do("POST", "/tsig-keys", e.token, map[string]any{"name": "xfer"}, 201)
|
||||
obj := res["object"].(map[string]any)
|
||||
if s, _ := obj["secret"].(string); len(s) != 44 {
|
||||
t.Errorf("generated secret not returned once: %v", obj)
|
||||
}
|
||||
list := e.list("/tsig-keys", e.token)
|
||||
if _, has := list[0]["secret"]; has {
|
||||
t.Error("secret in list")
|
||||
}
|
||||
k := e.do("GET", "/tsig-keys/xfer?secret=1", e.token, nil, 200)
|
||||
if len(k["secret"].(string)) != 44 {
|
||||
t.Error("secret not revealed")
|
||||
}
|
||||
ops := e.list("/changesets?status=applied", e.token)[0]["ops"].([]any)
|
||||
if !strings.Contains(string(mustJSON(ops)), "(hidden)") {
|
||||
t.Errorf("secret not redacted in changeset: %s", mustJSON(ops))
|
||||
}
|
||||
_ = time.Now
|
||||
}
|
||||
Reference in new issue
Block a user