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:
abrown38andClaude Opus 5.5 committed 2026-10-07 15:03:13 +00:00
commit bd6b8778e4
122 files changed
+29217

No files matched your search

+262
View File
@@ -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
}
+447
View File
@@ -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)
}
+344
View File
@@ -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
}
+549
View File
@@ -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
}
+541
View File
@@ -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}
})
}
+144
View File
@@ -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
}
+183
View File
@@ -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
}
+221
View File
@@ -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
}
+379
View File
@@ -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
}
+328
View File
@@ -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
}