Files
jarl/internal/engine/inputs.go
T
adamandClaude Opus 5.5 057433c198 Linked zones: shared records kept in step across views
Zones of the same name in different views can be linked. Shared records
are kept in step: a change in one view reaches the others in the same
changeset, as explicit operations, so the diff, permission checks,
history and undo cover every view. Records local to a view override the
shared records of that name and type there; removing the override brings
the shared ones back, and sharing a view's version makes it every view's.
Each zone keeps its own options, serial and file.

Linking existing zones changes no answer: what is the same everywhere
becomes shared, the rest stays local. Unlinking keeps every record, all
local. ACME challenges are written once per link, and --view all changes
a link once. jarl zone link/unlink, zone create --link-with, record
--local/--shared, LINK column, web UI controls, POST /zones/{zone}/link
and /unlink, validation checks linked_*, store migration 11, docs and
e2e.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 15:53:58 +01:00

349 lines
14 KiB
Go

package engine
import (
"encoding/json"
"time"
"jarl/internal/model"
)
// Operation kinds.
const (
OpZoneCreate = "zone.create"
OpZoneUpdate = "zone.update"
OpZoneDelete = "zone.delete"
OpRecordCreate = "record.create"
OpRecordUpdate = "record.update"
OpRecordDelete = "record.delete"
OpRRSetReplace = "rrset.replace"
OpViewCreate = "view.create"
OpViewUpdate = "view.update"
OpViewDelete = "view.delete"
OpViewReorder = "view.reorder"
OpACLCreate = "acl.create"
OpACLUpdate = "acl.update"
OpACLDelete = "acl.delete"
OpTSIGCreate = "tsig.create"
OpTSIGUpdate = "tsig.update"
OpTSIGDelete = "tsig.delete"
OpOptionCreate = "option.create"
OpOptionUpdate = "option.update"
OpOptionDelete = "option.delete"
OpSettingsUpdate = "settings.update"
OpBlocklistCreate = "blocklist.create"
OpBlocklistUpdate = "blocklist.update"
OpBlocklistDelete = "blocklist.delete"
OpBlockRuleCreate = "blockrule.create"
OpBlockRuleUpdate = "blockrule.update"
OpBlockRuleDelete = "blockrule.delete"
OpBlockingPause = "blocking.pause"
OpBlockStepCreate = "blockstep.create"
OpBlockStepUpdate = "blockstep.update"
OpBlockStepDelete = "blockstep.delete"
OpClientGroupCreate = "clientgroup.create"
OpClientGroupUpdate = "clientgroup.update"
OpClientGroupDelete = "clientgroup.delete"
// Requests only: disabling and enabling records, by ID or by match
// (zone, name, type and optionally data), become record.update
// operations.
OpRecordDisable = "record.disable"
OpRecordEnable = "record.enable"
// Acknowledging an exposure finding (docs/exposure.md), and taking it
// back, become view.update or settings.update operations.
OpExposureAck = "exposure.ack"
OpExposureUnack = "exposure.unack"
// Linked zones (docs/views.md#linked-zones): requests that expand
// into zone and record updates.
OpZoneLink = "zone.link"
OpZoneUnlink = "zone.unlink"
)
// AckInput acknowledges (or un-acknowledges) an exposure finding of a view
// (by ID or name), or of the server (neither given).
type AckInput struct {
ViewID int64 `json:"view_id,omitempty"`
View string `json:"view,omitempty"`
Code string `json:"code"`
Reason string `json:"reason,omitempty"`
}
// OpKinds lists all operation kinds.
var OpKinds = []string{OpZoneCreate, OpZoneUpdate, OpZoneDelete, OpRecordCreate, OpRecordUpdate, OpRecordDelete, OpRRSetReplace,
OpViewCreate, OpViewUpdate, OpViewDelete, OpViewReorder, OpACLCreate, OpACLUpdate, OpACLDelete,
OpTSIGCreate, OpTSIGUpdate, OpTSIGDelete, OpOptionCreate, OpOptionUpdate, OpOptionDelete, OpSettingsUpdate,
OpBlocklistCreate, OpBlocklistUpdate, OpBlocklistDelete, OpBlockRuleCreate, OpBlockRuleUpdate, OpBlockRuleDelete, OpBlockingPause,
OpBlockStepCreate, OpBlockStepUpdate, OpBlockStepDelete, OpClientGroupCreate, OpClientGroupUpdate, OpClientGroupDelete}
// Request is an operation as submitted by a client. Requests are expanded
// into one or more stored operations (for example an A record with "ptr"
// also creates the matching PTR record).
type Request struct {
Op string `json:"op"`
// ID is the object to update or delete.
ID int64 `json:"id,omitempty"`
// BaseVersion, if set, makes the request fail when the object's
// version differs (optimistic locking). Otherwise the version at
// staging time is recorded.
BaseVersion int `json:"base_version,omitempty"`
Data json.RawMessage `json:"data,omitempty"`
}
// NewRequest builds a request with data marshaled from an input struct.
func NewRequest(op string, id int64, data any) Request {
r := Request{Op: op, ID: id}
if data != nil {
b, err := json.Marshal(data)
if err != nil {
panic(err)
}
r.Data = b
}
return r
}
// SOAInput sets SOA fields of a primary zone.
type SOAInput struct {
MName *string `json:"mname,omitempty"`
RName *string `json:"rname,omitempty"` // mailbox name or email address
Serial *uint32 `json:"serial,omitempty"`
Refresh *uint32 `json:"refresh,omitempty"`
Retry *uint32 `json:"retry,omitempty"`
Expire *uint32 `json:"expire,omitempty"`
Minimum *uint32 `json:"minimum,omitempty"`
}
// ZoneInput creates or updates a zone.
type ZoneInput struct {
// Name is the zone name. For create, a CIDR prefix such as
// "192.168.1.0/24" creates the matching reverse zone.
Name *string `json:"name,omitempty"`
ViewID *int64 `json:"view_id,omitempty"`
View *string `json:"view,omitempty"` // view name, alternative to view_id
Type *string `json:"type,omitempty"`
InView *string `json:"in_view,omitempty"`
File *string `json:"file,omitempty"`
SOA *SOAInput `json:"soa,omitempty"`
DefaultTTL *uint32 `json:"default_ttl,omitempty"`
SerialPolicy *string `json:"serial_policy,omitempty"`
// DNSSECPolicy turns signing on ("default" or a custom policy's
// name), starts turning it off ("insecure") or, once that has
// finished, off ("").
DNSSECPolicy *string `json:"dnssec_policy,omitempty"`
Comment *string `json:"comment,omitempty"`
// Disabled keeps the zone in Jarl but out of named's configuration.
Disabled *bool `json:"disabled,omitempty"`
// LinkGroup is the zone's link group (0: not linked). Set by zone.link
// and zone.unlink; requests use those.
LinkGroup *int64 `json:"link_group,omitempty"`
// LinkWith (create only) links the new zone with the zone of the same
// name in this view, copying its shared records (or all of its
// records, when it isn't linked yet).
LinkWith string `json:"link_with,omitempty"`
// Create only: name servers for the apex NS records (default: the
// configured default name servers), additional records, and a zone to
// copy records from.
Nameservers []string `json:"nameservers,omitempty"`
Records []RecordInput `json:"records,omitempty"`
CloneFrom int64 `json:"clone_from,omitempty"`
// Options (create only) are zone options to set, e.g. primaries for a
// secondary zone or forwarders for a forward zone.
Options []ZoneOption `json:"options,omitempty"`
}
// ZoneOption is an option given when creating a zone.
type ZoneOption struct {
Name string `json:"name"`
Value string `json:"value"`
Comment string `json:"comment,omitempty"`
}
// RecordInput creates, updates or (by match) deletes a record.
type RecordInput struct {
ZoneID int64 `json:"zone_id,omitempty"`
Zone string `json:"zone,omitempty"` // zone name, alternative to zone_id
View string `json:"view,omitempty"` // view of Zone
Name *string `json:"name,omitempty"`
Type *string `json:"type,omitempty"`
TTL model.Opt[uint32] `json:"ttl,omitzero"` // null: use the zone default
Data *string `json:"data,omitempty"`
Comment *string `json:"comment,omitempty"`
// Disabled keeps the record in Jarl but out of the zone.
Disabled *bool `json:"disabled,omitempty"`
// PTR also creates (or deletes, disables or enables) the matching PTR
// record in the reverse zone, for A and AAAA records.
PTR bool `json:"ptr,omitempty"`
// MissingOK makes a delete-by-match succeed when nothing matches.
MissingOK bool `json:"missing_ok,omitempty"`
// Local (requests, linked zones): true keeps the record in this view
// only (an override: the whole name and type becomes local here);
// false shares it with the linked zones. Unset: like the records of
// the same name and type, shared when there are none.
Local *bool `json:"local,omitempty"`
// SharedID (stored operations) ties the copies of a shared record
// together; 0 makes it local.
SharedID *int64 `json:"shared_id,omitempty"`
}
// LinkInput links zones of the same name in several views, or (unlink)
// takes one out of its link.
type LinkInput struct {
ZoneID int64 `json:"zone_id,omitempty"`
Zone string `json:"zone,omitempty"`
View string `json:"view,omitempty"`
// With are the views whose zone of the same name to link with (link
// only; default: every view that has one).
With []string `json:"with,omitempty"`
}
// RRSetInput replaces all records of one name and type.
type RRSetInput struct {
ZoneID int64 `json:"zone_id,omitempty"`
Zone string `json:"zone,omitempty"`
View string `json:"view,omitempty"`
Name string `json:"name"`
Type string `json:"type"`
TTL model.Opt[uint32] `json:"ttl,omitzero"`
Data []string `json:"data"`
Comment *string `json:"comment,omitempty"`
// NewIDs are allocated for records that need to be created.
NewIDs []int64 `json:"new_ids,omitempty"`
// Local (requests): like RecordInput.Local, for the whole set.
Local *bool `json:"local,omitempty"`
// Shared (stored operations, linked zones): the records are shared
// (true) or local (false).
Shared *bool `json:"shared,omitempty"`
}
// ViewInput creates or updates a view.
type ViewInput struct {
Name *string `json:"name,omitempty"`
MatchClients *[]string `json:"match_clients,omitempty"`
MatchDestinations *[]string `json:"match_destinations,omitempty"`
MatchRecursiveOnly model.Opt[bool] `json:"match_recursive_only,omitzero"`
Comment *string `json:"comment,omitempty"`
Position *int `json:"position,omitempty"`
// AdoptZones (create) moves all zones that are not in a view into the
// new view; named requires every zone to be in a view once views
// are used.
AdoptZones bool `json:"adopt_zones,omitempty"`
// Cascade (delete) also deletes the view's zones.
Cascade bool `json:"cascade,omitempty"`
// Exposure is "auto" (or ""), "public" or "private" (docs/exposure.md).
Exposure *string `json:"exposure,omitempty"`
Acknowledged *[]model.Acknowledgement `json:"acknowledged,omitempty"`
}
// ReorderInput sets the order of views.
type ReorderInput struct {
IDs []int64 `json:"ids"`
}
// ACLInput creates or updates an ACL.
type ACLInput struct {
Name *string `json:"name,omitempty"`
Elements *[]string `json:"elements,omitempty"`
Comment *string `json:"comment,omitempty"`
}
// TSIGInput creates or updates a TSIG key. A missing secret on create is
// generated.
type TSIGInput struct {
Name *string `json:"name,omitempty"`
Algorithm *string `json:"algorithm,omitempty"`
Secret *string `json:"secret,omitempty"`
Comment *string `json:"comment,omitempty"`
}
// OptionInput creates or updates an option.
type OptionInput struct {
Scope *string `json:"scope,omitempty"` // options, view, zone, toplevel
ScopeID *int64 `json:"scope_id,omitempty"` // view or zone ID
Name *string `json:"name,omitempty"`
Value *string `json:"value,omitempty"`
Comment *string `json:"comment,omitempty"`
Position *int `json:"position,omitempty"`
}
// SettingsInput updates Jarl's settings.
type SettingsInput struct {
DefaultTTL *uint32 `json:"default_ttl,omitempty"`
Nameservers *[]string `json:"nameservers,omitempty"`
RName *string `json:"rname,omitempty"`
Refresh *uint32 `json:"refresh,omitempty"`
Retry *uint32 `json:"retry,omitempty"`
Expire *uint32 `json:"expire,omitempty"`
NegativeTTL *uint32 `json:"negative_ttl,omitempty"`
SerialPolicy *string `json:"serial_policy,omitempty"`
// Exposure and Acknowledged are the server's (docs/exposure.md).
Exposure *string `json:"exposure,omitempty"`
Acknowledged *[]model.Acknowledgement `json:"acknowledged,omitempty"`
}
// DeleteInput carries options for delete operations.
type DeleteInput struct {
Cascade bool `json:"cascade,omitempty"`
}
// BlocklistInput creates or updates a blocklist. Views are given by name
// (Views) or ID (ViewIDs); an empty list means every view that offers
// recursion.
type BlocklistInput struct {
Name *string `json:"name,omitempty"`
URL *string `json:"url,omitempty"`
Views *[]string `json:"views,omitempty"`
ViewIDs *[]int64 `json:"view_ids,omitempty"`
Disabled *bool `json:"disabled,omitempty"`
Comment *string `json:"comment,omitempty"`
// Step is the step the list starts at, by name ("normal" or a step
// above it) or ID (StepID; 0 is normal).
Step *string `json:"step,omitempty"`
StepID *int64 `json:"step_id,omitempty"`
}
// BlockStepInput creates or updates a blocking step above Normal.
// Position orders the steps (new steps go on top).
type BlockStepInput struct {
Name *string `json:"name,omitempty"`
Position *int `json:"position,omitempty"`
Comment *string `json:"comment,omitempty"`
}
// ClientGroupInput creates or updates a client group. Members are
// addresses, prefixes or ACL names; the step is given by name ("off",
// "normal" or a step) or ID; views as for BlocklistInput.
type ClientGroupInput struct {
Name *string `json:"name,omitempty"`
Members *[]string `json:"members,omitempty"`
Step *string `json:"step,omitempty"`
StepID *int64 `json:"step_id,omitempty"`
Views *[]string `json:"views,omitempty"`
ViewIDs *[]int64 `json:"view_ids,omitempty"`
Comment *string `json:"comment,omitempty"`
}
// BlockRuleInput creates or updates a block or allow rule. Views as for
// BlocklistInput.
type BlockRuleInput struct {
Name *string `json:"name,omitempty"`
Action *string `json:"action,omitempty"` // block (default) or allow
Exact *bool `json:"exact,omitempty"` // only the name, not the names below it
Views *[]string `json:"views,omitempty"`
ViewIDs *[]int64 `json:"view_ids,omitempty"`
Comment *string `json:"comment,omitempty"`
}
// BlockingPauseInput pauses blocking until a time, or resumes it (null).
type BlockingPauseInput struct {
Until *time.Time `json:"until"`
// Indefinitely pauses until someone resumes blocking (Until is nil).
Indefinitely bool `json:"indefinitely,omitempty"`
}
// MaxBlockingPause is the longest pause: blocking shouldn't stay off by
// mistake (disable lists to stop using them).
const MaxBlockingPause = 24 * time.Hour