Jarl works out which views are reachable from the internet (match-clients
ending in a catch-all, not private-looking addresses: an internal view of a
global IPv6 prefix is still yours; a server without views: open allow-query
and a non-private listen address), with a per-view override (schema 9), and
checks public ones: rate limiting, open resolver, minimal-any, cache
snooping, open NOTIFY and transfers, version and host name, management
interfaces. Fixes are staged like any change, including rate limiting from
ISC's example in log-only mode with the secondaries exempt, then enforcing.
named logs what rate limiting limits (or would, in log-only mode, where its
counters don't move) as query-errors; Jarl routes those lines to its log
and counts them (rrl breakdown, ratelimited top lists, Prometheus
jarl_ratelimit_logged_total), shown on the new Exposure page.
A change that newly opens a public view up must be confirmed (accept_risk,
--accept-risk, a checkbox on the Changes page; 409 confirm_risk
otherwise). Findings can be acknowledged with a reason and stay visible as
info. GET /exposure, POST /exposure/fix|ack|unack, jarl exposure,
docs/exposure.md.
Also fixes query statistics skipping TSIG-signed queries ("IP#port/key
NAME" in named's log).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
314 lines
12 KiB
Go
314 lines
12 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"
|
|
)
|
|
|
|
// 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"`
|
|
|
|
// 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"`
|
|
}
|
|
|
|
// 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"`
|
|
}
|
|
|
|
// 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
|