A set can take its addresses from Jarl: every A and AAAA record of its
names (NAME@VIEW), CNAMEs followed within Jarl's zones in the same view.
housecarld reads them every jarl.refresh and after each change, keeps
only the addresses within the zones and networks the set allows, and
keeps what it last read (saved in the database and the boot ruleset)
while Jarl can't be reached. A change of the addresses applies the
ruleset again without a history entry and is audited (set.jarl).
hc set add --jarl --within, hc set jarl; the kind "from Jarl" and
Addresses… on the Sets page; GET /sets/{name}/jarl and /jarl/refresh;
validation codes set_jarl_*, status warnings jarl_*; docs/jarl.md.
e2e phase with scripts/fakejarl.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
873 lines
23 KiB
Go
873 lines
23 KiB
Go
package server
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/netip"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
|
|
udiff "github.com/aymanbagabas/go-udiff"
|
|
|
|
"housecarl/internal/api"
|
|
"housecarl/internal/auth"
|
|
"housecarl/internal/engine"
|
|
"housecarl/internal/matrix"
|
|
"housecarl/internal/model"
|
|
"housecarl/internal/nft"
|
|
"housecarl/internal/store"
|
|
)
|
|
|
|
// change makes an operation: at once through the engine, or, with a
|
|
// changeset named (?changeset=ID or X-Housecarl-Changeset), staged in it.
|
|
// A network change starts (or joins) the probation.
|
|
func (s *Server) change(w http.ResponseWriter, r *http.Request, p *auth.Principal, op model.Op) error {
|
|
if err := require(p, auth.ResConfig, true); err != nil {
|
|
return err
|
|
}
|
|
if op.Op == model.OpCreate && op.ID == 0 {
|
|
ids, err := s.store.AllocateIDs(r.Context(), 1)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
op.ID = ids[0]
|
|
}
|
|
csID, err := changesetParam(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if csID != 0 {
|
|
return s.stage(w, r, p, csID, op)
|
|
}
|
|
var id int64
|
|
res, err := s.eng.Apply(r.Context(), p, "", engine.Options{DryRun: queryBool(r, "dry_run"), Via: via(r, p)}, func(st *model.State, _ func() int64) error {
|
|
var err error
|
|
id, err = applyOp(st, &op)
|
|
return err
|
|
})
|
|
if err != nil {
|
|
s.writeErr(w, r, err, res)
|
|
return nil
|
|
}
|
|
s.afterChange(r.Context(), p, res)
|
|
out := s.mutation(res, objectOf(&op, id))
|
|
out.ConfirmToken = s.issueCheck(w, r, p, res)
|
|
writeJSON(w, s.mutationStatus(res, op.Op == model.OpCreate), out)
|
|
return nil
|
|
}
|
|
|
|
// mutation is the answer to a change: what changed, warnings, the object
|
|
// (as it is after the change), the probation, and for a dry run the
|
|
// ruleset's diff.
|
|
func (s *Server) mutation(res *engine.Result, object func(st *model.State) any) api.MutationResponse {
|
|
out := api.MutationResponse{Applied: res.Applied, DryRun: res.DryRun, HistoryID: res.HistoryID, Changes: res.Changes, Warnings: res.Warnings}
|
|
if out.Changes == nil {
|
|
out.Changes = []model.Change{}
|
|
}
|
|
if pr := s.probationStatus(); res.Applied && (len(pr.Pending) > 0 || pr.Takeovers > 0) {
|
|
out.ConfirmBy = &pr.Deadline
|
|
}
|
|
if object != nil && res.After != nil && (res.Applied || res.DryRun) {
|
|
if v := object(res.After); v != nil {
|
|
out.Object, _ = json.Marshal(v)
|
|
}
|
|
}
|
|
if res.DryRun && len(res.Changes) > 0 {
|
|
out.Diff = s.rulesetDiff(res.Before, res.After)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// mutationStatus is 201 for an applied create, else 200.
|
|
func (s *Server) mutationStatus(res *engine.Result, creates bool) int {
|
|
if res.Applied && creates {
|
|
return http.StatusCreated
|
|
}
|
|
return http.StatusOK
|
|
}
|
|
|
|
// respondChange answers a change made without an operation (undo).
|
|
func (s *Server) respondChange(w http.ResponseWriter, r *http.Request, p *auth.Principal, res *engine.Result, object func(st *model.State) any, creates bool) {
|
|
out := s.mutation(res, object)
|
|
out.ConfirmToken = s.issueCheck(w, r, p, res)
|
|
writeJSON(w, s.mutationStatus(res, creates), out)
|
|
}
|
|
|
|
// rulesetDiff is the unified diff of the rulesets two configurations
|
|
// make: the file housecarl-ruleset.service loads, now and after.
|
|
func (s *Server) rulesetDiff(before, after *model.State) string {
|
|
return s.labeledDiff(before, after, "now", "after")
|
|
}
|
|
|
|
// labeledDiff is rulesetDiff with its own names for the two sides.
|
|
func (s *Server) labeledDiff(before, after *model.State, from, to string) string {
|
|
if before == nil || after == nil {
|
|
return ""
|
|
}
|
|
now, then := s.fw.Render(before).Text, s.fw.Render(after).Text
|
|
diff := hunkStarts(udiff.Unified("ruleset.nft ("+from+")", "ruleset.nft ("+to+")", now, then))
|
|
return blockHeaders(diff, now, then)
|
|
}
|
|
|
|
// hunkStarts numbers each hunk's new side again, from its old side and
|
|
// the lines the hunks before it added and removed. go-udiff (v0.4.1)
|
|
// leaves out of its count the unchanged lines between two edits of one
|
|
// hunk, so after a hunk like a moved line every later one started too
|
|
// early ("@@ -76,9 +74,9 @@" for "+76,9"), and blockHeaders named the
|
|
// chain before it.
|
|
func hunkStarts(diff string) string {
|
|
lines := strings.SplitAfter(diff, "\n")
|
|
delta := 0
|
|
for i := 0; i < len(lines); i++ {
|
|
f := strings.Fields(lines[i])
|
|
if !strings.HasPrefix(lines[i], "@@ ") || len(f) < 4 || f[3] != "@@" {
|
|
continue
|
|
}
|
|
start, _, _ := strings.Cut(f[1][1:], ",")
|
|
from, err := strconv.Atoi(start)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
oldN, newN := 0, 0
|
|
for _, l := range lines[i+1:] {
|
|
if strings.HasPrefix(l, "@@ ") {
|
|
break
|
|
}
|
|
switch {
|
|
case strings.HasPrefix(l, "-"):
|
|
oldN++
|
|
case strings.HasPrefix(l, "+"):
|
|
newN++
|
|
case strings.HasPrefix(l, " "):
|
|
oldN++
|
|
newN++
|
|
}
|
|
}
|
|
// An empty side is numbered by the line before it.
|
|
first := from
|
|
if oldN == 0 {
|
|
first++
|
|
}
|
|
to := first + delta
|
|
if newN == 0 {
|
|
to--
|
|
}
|
|
side := "+" + strconv.Itoa(to)
|
|
if newN > 1 {
|
|
side += "," + strconv.Itoa(newN)
|
|
}
|
|
f[2] = side
|
|
lines[i] = strings.Join(f, " ") + "\n"
|
|
delta += newN - oldN
|
|
}
|
|
return strings.Join(lines, "")
|
|
}
|
|
|
|
// blockHeaders names, after each hunk's @@ line, the chain (or other
|
|
// block of the table) the hunk starts in, as git names the function:
|
|
// "@@ -40,6 +40,8 @@ chain forward". Without it a change deep in a long
|
|
// chain doesn't say which chain it is in.
|
|
func blockHeaders(diff, now, then string) string {
|
|
if diff == "" {
|
|
return diff
|
|
}
|
|
oldBlocks, newBlocks := enclosingBlocks(now), enclosingBlocks(then)
|
|
lines := strings.SplitAfter(diff, "\n")
|
|
for i, l := range lines {
|
|
if !strings.HasPrefix(l, "@@ ") {
|
|
continue
|
|
}
|
|
// "@@ -a,b +c,d @@": the new side's start, or the old side's
|
|
// when nothing of the hunk is left.
|
|
f := strings.Fields(l)
|
|
if len(f) < 4 || f[3] != "@@" {
|
|
continue
|
|
}
|
|
block := blockAt(newBlocks, f[2])
|
|
if block == "" {
|
|
block = blockAt(oldBlocks, f[1])
|
|
}
|
|
if block != "" {
|
|
lines[i] = strings.TrimRight(l, "\n") + " " + block + "\n"
|
|
}
|
|
}
|
|
return strings.Join(lines, "")
|
|
}
|
|
|
|
// enclosingBlocks gives each line of a rendered ruleset the block of the
|
|
// table it is in ("chain input"), "" outside one. A block opens with a
|
|
// line of one tab ending in "{" and closes with "\t}".
|
|
func enclosingBlocks(text string) []string {
|
|
var out []string
|
|
cur := ""
|
|
for _, l := range strings.Split(text, "\n") {
|
|
if strings.HasPrefix(l, "\t") && !strings.HasPrefix(l, "\t\t") && strings.HasSuffix(l, "{") {
|
|
cur = strings.TrimSpace(strings.TrimSuffix(l, "{"))
|
|
}
|
|
out = append(out, cur)
|
|
if l == "\t}" {
|
|
cur = ""
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// blockAt is the block at a hunk side's start ("+40,8" or "-40"); lines
|
|
// count from 1, and an empty side (",0") sits after its line.
|
|
func blockAt(blocks []string, side string) string {
|
|
start, _, _ := strings.Cut(side[1:], ",")
|
|
n, err := strconv.Atoi(start)
|
|
if err != nil || n < 1 || n > len(blocks) {
|
|
return ""
|
|
}
|
|
return blocks[n-1]
|
|
}
|
|
|
|
// kindOf maps the API's plural path names to kinds.
|
|
var kindOf = map[string]string{
|
|
"zones": model.KindZone, "sets": model.KindSet, "services": model.KindService, "rules": model.KindRule,
|
|
"forwards": model.KindForward, "snats": model.KindSNAT, "redirects": model.KindRedirect,
|
|
"banlists": model.KindBanList,
|
|
}
|
|
|
|
func pathKind(r *http.Request) (string, error) {
|
|
k, ok := kindOf[r.PathValue("kind")]
|
|
if !ok {
|
|
return "", &httpError{status: http.StatusNotFound, code: "not_found", msg: "no such API endpoint: " + r.URL.Path}
|
|
}
|
|
return k, nil
|
|
}
|
|
|
|
func findObject(st *model.State, kind, ref string) (model.Object, error) {
|
|
if o := st.Find(kind, ref); o != nil {
|
|
return o, nil
|
|
}
|
|
return nil, notFound("no %s %s", kind, ref)
|
|
}
|
|
|
|
// ---- objects
|
|
|
|
func (s *Server) listObjects(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
kind, err := pathKind(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if kind == model.KindBanList && !p.Can(auth.ResConfig, false) {
|
|
return s.listOwnBanLists(w, p)
|
|
}
|
|
if err := require(p, auth.ResConfig, false); err != nil {
|
|
return err
|
|
}
|
|
cur, st, draft, err := s.viewState(w, r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
out := []any{}
|
|
c := r.URL.Query().Get("chain")
|
|
if draft {
|
|
// Marked new, changed or deleted.
|
|
for _, o := range withPending(cur, st, kind) {
|
|
if c != "" && kind == model.KindRule && o.(map[string]any)["chain"] != c {
|
|
continue
|
|
}
|
|
out = append(out, o)
|
|
}
|
|
} else {
|
|
for _, o := range st.Objects(kind) {
|
|
if c != "" {
|
|
if rule, ok := o.(*model.Rule); ok && rule.Chain != c {
|
|
continue
|
|
}
|
|
}
|
|
out = append(out, o)
|
|
}
|
|
}
|
|
// Services: the built-in ones too, unless overridden.
|
|
if kind == model.KindService && !queryBool(r, "own") {
|
|
for _, b := range model.BuiltinServices() {
|
|
if mine := st.Find(kind, b.Name); mine == nil {
|
|
out = append(out, b)
|
|
}
|
|
}
|
|
name := func(x any) string {
|
|
if m, ok := x.(map[string]any); ok {
|
|
n, _ := m["name"].(string)
|
|
return n
|
|
}
|
|
return x.(*model.Service).Name
|
|
}
|
|
sort.SliceStable(out, func(i, j int) bool { return name(out[i]) < name(out[j]) })
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) getObject(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
kind, err := pathKind(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// A program that bans into a list may read it (its grouping, times).
|
|
if kind == model.KindBanList && !p.Can(auth.ResConfig, false) {
|
|
b, err := s.banList(r, p, false)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
writeJSON(w, http.StatusOK, b)
|
|
return nil
|
|
}
|
|
if err := require(p, auth.ResConfig, false); err != nil {
|
|
return err
|
|
}
|
|
_, st, _, err := s.viewState(w, r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
o, err := findObject(st, kind, r.PathValue("ref"))
|
|
if err != nil && kind == model.KindService {
|
|
if b := model.BuiltinService(r.PathValue("ref")); b != nil {
|
|
writeJSON(w, http.StatusOK, b)
|
|
return nil
|
|
}
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
writeJSON(w, http.StatusOK, o)
|
|
return nil
|
|
}
|
|
|
|
// decodeObject reads a kind's object from JSON (unknown fields refused).
|
|
func decodeObject(kind string, data []byte) (model.Object, error) {
|
|
var o model.Object
|
|
switch kind {
|
|
case model.KindZone:
|
|
o = &model.Zone{}
|
|
case model.KindSet:
|
|
o = &model.AddressSet{}
|
|
case model.KindService:
|
|
o = &model.Service{}
|
|
case model.KindRule:
|
|
o = &model.Rule{}
|
|
case model.KindForward:
|
|
o = &model.PortForward{}
|
|
case model.KindSNAT:
|
|
o = &model.SourceNAT{}
|
|
case model.KindRedirect:
|
|
o = &model.Redirect{}
|
|
case model.KindBanList:
|
|
o = &model.BanList{}
|
|
}
|
|
dec := json.NewDecoder(strings.NewReader(string(data)))
|
|
dec.DisallowUnknownFields()
|
|
if err := dec.Decode(o); err != nil {
|
|
return nil, badRequest("%s: %v", kind, err)
|
|
}
|
|
return o, nil
|
|
}
|
|
|
|
// lastPosition is after the last rule of the chain (or NAT rule of the
|
|
// kind).
|
|
func lastPosition(st *model.State, kind, chain string) float64 {
|
|
max := 0.0
|
|
for _, o := range st.Objects(kind) {
|
|
var pos float64
|
|
switch x := o.(type) {
|
|
case *model.Rule:
|
|
if x.Chain != chain {
|
|
continue
|
|
}
|
|
pos = x.Position
|
|
case *model.PortForward:
|
|
pos = x.Position
|
|
case *model.SourceNAT:
|
|
pos = x.Position
|
|
case *model.Redirect:
|
|
pos = x.Position
|
|
}
|
|
if pos > max {
|
|
max = pos
|
|
}
|
|
}
|
|
return max + 1
|
|
}
|
|
|
|
func setID(o model.Object, id int64) {
|
|
switch x := o.(type) {
|
|
case *model.Zone:
|
|
x.ID = id
|
|
case *model.AddressSet:
|
|
x.ID = id
|
|
case *model.Service:
|
|
x.ID = id
|
|
case *model.Rule:
|
|
x.ID = id
|
|
case *model.PortForward:
|
|
x.ID = id
|
|
case *model.SourceNAT:
|
|
x.ID = id
|
|
case *model.Redirect:
|
|
x.ID = id
|
|
case *model.BanList:
|
|
x.ID = id
|
|
}
|
|
}
|
|
|
|
func position(o model.Object) *float64 {
|
|
switch x := o.(type) {
|
|
case *model.Rule:
|
|
return &x.Position
|
|
case *model.PortForward:
|
|
return &x.Position
|
|
case *model.SourceNAT:
|
|
return &x.Position
|
|
case *model.Redirect:
|
|
return &x.Position
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func chainOf(o model.Object) string {
|
|
if r, ok := o.(*model.Rule); ok {
|
|
return r.Chain
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (s *Server) createObject(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
kind, err := pathKind(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var raw json.RawMessage
|
|
if err := decodeJSON(r, &raw); err != nil {
|
|
return err
|
|
}
|
|
// Refuse a bad object now, not when it is applied.
|
|
if _, err := decodeObject(kind, raw); err != nil {
|
|
return err
|
|
}
|
|
op := model.Op{Op: model.OpCreate, Kind: kind, Data: raw}
|
|
// ?before=ID, ?after=ID or ?first=1: where it goes in its order.
|
|
q := r.URL.Query()
|
|
var place api.MoveInput
|
|
place.Before, _ = strconv.ParseInt(q.Get("before"), 10, 64)
|
|
place.After, _ = strconv.ParseInt(q.Get("after"), 10, 64)
|
|
if queryBool(r, "first") {
|
|
place.To = "start"
|
|
}
|
|
if place.Before != 0 || place.After != 0 || place.To != "" {
|
|
op.Place, _ = json.Marshal(place)
|
|
}
|
|
return s.change(w, r, p, op)
|
|
}
|
|
|
|
func (s *Server) updateObject(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
kind, err := pathKind(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var patch map[string]json.RawMessage
|
|
if err := decodeJSON(r, &patch); err != nil {
|
|
return err
|
|
}
|
|
data, _ := json.Marshal(patch)
|
|
return s.change(w, r, p, model.Op{Op: model.OpUpdate, Kind: kind, Ref: r.PathValue("ref"), Data: data})
|
|
}
|
|
|
|
func (s *Server) deleteObject(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
kind, err := pathKind(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.change(w, r, p, model.Op{Op: model.OpDelete, Kind: kind, Ref: r.PathValue("ref")})
|
|
}
|
|
|
|
// usedBy names an object that refers to o (zones, sets and services are
|
|
// referred to by name), or "".
|
|
func usedBy(st *model.State, kind string, o model.Object) string {
|
|
name := o.Label()
|
|
has := func(list []string) bool {
|
|
for _, x := range list {
|
|
if strings.EqualFold(x, name) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
uses := func(m model.Match) bool {
|
|
switch kind {
|
|
case model.KindZone:
|
|
return has(m.InZones) || has(m.OutZones)
|
|
case model.KindSet:
|
|
return has(m.Sources) || has(m.Destinations)
|
|
case model.KindService:
|
|
return has(m.Services)
|
|
}
|
|
return false
|
|
}
|
|
for _, x := range st.Rules {
|
|
if uses(x.Match) {
|
|
return x.Label()
|
|
}
|
|
}
|
|
for _, x := range st.SNATs {
|
|
if uses(x.Match) {
|
|
return x.Label()
|
|
}
|
|
}
|
|
for _, x := range st.Redirects {
|
|
if uses(x.Match) {
|
|
return x.Label()
|
|
}
|
|
}
|
|
if kind == model.KindSet {
|
|
for _, f := range st.Forwards {
|
|
if has(f.Sources) || has(f.PublicAddresses) || has(f.HairpinSources) {
|
|
return f.Label()
|
|
}
|
|
}
|
|
for _, b := range st.BanLists {
|
|
if has(b.Hosts) || has(b.Never) {
|
|
return "ban list " + b.Name
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// renameRefs makes every object that refers to a zone, set or service by
|
|
// its old name use the new one: renaming it changes them in the same
|
|
// change, so the history shows (and undoes) them together.
|
|
func renameRefs(st *model.State, kind, old, now string) {
|
|
fix := func(list []string) {
|
|
for i, x := range list {
|
|
if strings.EqualFold(x, old) {
|
|
list[i] = now
|
|
}
|
|
}
|
|
}
|
|
match := func(m *model.Match) {
|
|
switch kind {
|
|
case model.KindZone:
|
|
fix(m.InZones)
|
|
fix(m.OutZones)
|
|
case model.KindSet:
|
|
fix(m.Sources)
|
|
fix(m.Destinations)
|
|
case model.KindService:
|
|
fix(m.Services)
|
|
}
|
|
}
|
|
for _, x := range st.Rules {
|
|
match(&x.Match)
|
|
}
|
|
for _, x := range st.SNATs {
|
|
match(&x.Match)
|
|
}
|
|
for _, x := range st.Redirects {
|
|
match(&x.Match)
|
|
}
|
|
if kind == model.KindSet {
|
|
for _, f := range st.Forwards {
|
|
fix(f.Sources)
|
|
fix(f.PublicAddresses)
|
|
fix(f.HairpinSources)
|
|
}
|
|
for _, b := range st.BanLists {
|
|
fix(b.Hosts)
|
|
fix(b.Never)
|
|
}
|
|
}
|
|
}
|
|
|
|
// moveObject reorders a rule or NAT rule (moveOp).
|
|
func (s *Server) moveObject(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
kind, err := pathKind(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var in api.MoveInput
|
|
if err := decodeJSON(r, &in); err != nil {
|
|
return err
|
|
}
|
|
data, _ := json.Marshal(in)
|
|
return s.change(w, r, p, model.Op{Op: model.OpMove, Kind: kind, Ref: r.PathValue("ref"), Data: data})
|
|
}
|
|
|
|
// ---- settings
|
|
|
|
func (s *Server) getSettings(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
if err := require(p, auth.ResConfig, false); err != nil {
|
|
return err
|
|
}
|
|
_, st, _, err := s.viewState(w, 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 {
|
|
var patch map[string]json.RawMessage
|
|
if err := decodeJSON(r, &patch); err != nil {
|
|
return err
|
|
}
|
|
data, _ := json.Marshal(patch)
|
|
return s.change(w, r, p, model.Op{Op: model.OpSettings, Data: data})
|
|
}
|
|
|
|
// ---- the zone matrix
|
|
|
|
// zoneMatrix answers what the rules do to new connections between zones
|
|
// (internal/matrix); with a changeset named, as it will make them.
|
|
func (s *Server) zoneMatrix(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
if err := require(p, auth.ResConfig, false); err != nil {
|
|
return err
|
|
}
|
|
_, st, _, err := s.viewState(w, r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
m := matrix.Build(st, matrix.Options{Connected: s.connected(st)})
|
|
if t, err := s.fw.Table(r.Context()); err == nil {
|
|
matrixHits(&m, t.Counters)
|
|
}
|
|
writeJSON(w, http.StatusOK, m)
|
|
return nil
|
|
}
|
|
|
|
// matrixHits fills in the hits of the matrix's rules, built-ins and
|
|
// policies from the counters.
|
|
func matrixHits(m *api.Matrix, counters map[string]nft.Counter) {
|
|
hits := func(marks []string) *uint64 {
|
|
var n uint64
|
|
found := false
|
|
for _, mk := range marks {
|
|
if c, ok := counters[mk]; ok {
|
|
n, found = n+c.Packets, true
|
|
}
|
|
}
|
|
if !found {
|
|
return nil
|
|
}
|
|
return &n
|
|
}
|
|
for i := range m.Cells {
|
|
c := &m.Cells[i]
|
|
c.Hits = hits(c.Marks)
|
|
for j := range c.Exceptions {
|
|
c.Exceptions[j].Hits = hits(c.Exceptions[j].Marks)
|
|
}
|
|
}
|
|
}
|
|
|
|
// connected maps the networks on the firewall's interfaces (link-local
|
|
// and loopback aside) to their zones.
|
|
// Connected are the networks connected to the firewall's interfaces,
|
|
// with their zones (link-local and loopback left out).
|
|
func (s *Server) Connected(st *model.State) map[netip.Prefix]string { return s.connected(st) }
|
|
|
|
func (s *Server) connected(st *model.State) map[netip.Prefix]string {
|
|
out := map[netip.Prefix]string{}
|
|
for _, i := range s.hostInterfaces(st) {
|
|
if i.Zone == "" {
|
|
continue
|
|
}
|
|
for _, a := range i.Addresses {
|
|
p, err := netip.ParsePrefix(a)
|
|
if err != nil || p.Addr().IsLinkLocalUnicast() || p.Addr().IsLoopback() {
|
|
continue
|
|
}
|
|
out[p.Masked()] = i.Zone
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ---- the ruleset
|
|
|
|
func (s *Server) ruleset(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
if err := require(p, auth.ResConfig, false); err != nil {
|
|
return err
|
|
}
|
|
_, st, _, err := s.viewState(w, r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rs := s.fw.Render(st)
|
|
// In shadow mode: what would be loaded once it is off.
|
|
if queryBool(r, "enforcing") {
|
|
rs = s.fw.RenderEnforcing(st)
|
|
}
|
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
|
w.Write([]byte(rs.Text))
|
|
return nil
|
|
}
|
|
|
|
// ---- history
|
|
|
|
func (s *Server) listHistory(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
if err := require(p, auth.ResConfig, false); err != nil {
|
|
return err
|
|
}
|
|
hs, err := s.store.ListHistory(r.Context(), queryInt(r, "limit", 100), int64(queryInt(r, "before", 0)))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
writeJSON(w, http.StatusOK, hs)
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) getHistory(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
if err := require(p, auth.ResConfig, false); err != nil {
|
|
return err
|
|
}
|
|
id, err := pathID(r, "id")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
h, err := s.store.GetHistory(r.Context(), id)
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
return notFound("history entry %d not found", id)
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
writeJSON(w, http.StatusOK, h)
|
|
return nil
|
|
}
|
|
|
|
// historyDiff is the ruleset's diff for one history entry: the
|
|
// configurations before and after it, got back by stepping back from
|
|
// the current one through every later entry, rendered by this version.
|
|
func (s *Server) historyDiff(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
if err := require(p, auth.ResConfig, false); err != nil {
|
|
return err
|
|
}
|
|
id, err := pathID(r, "id")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
hs, err := s.store.HistorySince(r.Context(), id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(hs) == 0 || hs[len(hs)-1].ID != id {
|
|
return notFound("history entry %d not found", id)
|
|
}
|
|
st := s.eng.State()
|
|
var after *model.State
|
|
for _, h := range hs {
|
|
if h.ID == id {
|
|
after = st.Clone()
|
|
}
|
|
if err := engine.Rewind(st, h); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
before, done := fmt.Sprintf("before %d", id), fmt.Sprintf("after %d", id)
|
|
writeJSON(w, http.StatusOK, map[string]string{"diff": s.labeledDiff(st, after, before, done)})
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) revertHistory(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
if err := require(p, auth.ResConfig, true); err != nil {
|
|
return err
|
|
}
|
|
id, err := pathID(r, "id")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// Staged, an undo is an operation of its own, reviewed like any.
|
|
if csID, err := changesetParam(r); err != nil {
|
|
return err
|
|
} else if csID != 0 {
|
|
h, err := s.store.GetHistory(r.Context(), id)
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
return notFound("history entry %d not found", id)
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if h.RevertedBy != 0 {
|
|
return conflict("history entry %d was already undone by entry %d", id, h.RevertedBy)
|
|
}
|
|
data, _ := json.Marshal(h)
|
|
return s.stage(w, r, p, csID, model.Op{Op: model.OpRevert, ID: id, Data: data})
|
|
}
|
|
res, err := s.eng.Revert(r.Context(), p, id, engine.Options{DryRun: queryBool(r, "dry_run"), Via: via(r, p)})
|
|
if err != nil {
|
|
s.writeErr(w, r, err, res)
|
|
return nil
|
|
}
|
|
s.afterChange(r.Context(), p, res)
|
|
s.respondChange(w, r, p, res, nil, false)
|
|
return nil
|
|
}
|
|
|
|
// ---- probation
|
|
|
|
func (s *Server) getProbation(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
if err := require(p, auth.ResConfig, false); err != nil {
|
|
return err
|
|
}
|
|
writeJSON(w, http.StatusOK, s.probationStatus())
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) confirmChanges(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
if err := require(p, auth.ResConfig, true); err != nil {
|
|
return err
|
|
}
|
|
var in api.ConfirmInput
|
|
if r.ContentLength != 0 {
|
|
if err := decodeJSON(r, &in); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
confirm := func() (int, error) { return s.confirm(r.Context(), p) }
|
|
if in.Token != "" {
|
|
confirm = func() (int, error) { return s.confirmToken(r, p, in.Token) }
|
|
}
|
|
n, err := confirm()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]int{"confirmed": n})
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) rollbackChanges(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
if err := require(p, auth.ResConfig, true); err != nil {
|
|
return err
|
|
}
|
|
ps := s.probationStatus()
|
|
n := len(ps.Pending) + ps.Takeovers
|
|
if n > 0 {
|
|
s.rollback("rolled back by " + p.Name())
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]int{"rolled_back": n})
|
|
return nil
|
|
}
|
|
|
|
// listOwnBanLists lists the ban lists a principal without config:read
|
|
// may use.
|
|
func (s *Server) listOwnBanLists(w http.ResponseWriter, p *auth.Principal) error {
|
|
out := []*model.BanList{}
|
|
for _, b := range s.eng.State().BanLists {
|
|
if p.CanBan(b.Name, false) {
|
|
out = append(out, b)
|
|
}
|
|
}
|
|
if len(out) == 0 && !p.Can(auth.ResBans, false) {
|
|
return require(p, auth.ResConfig, false)
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
return nil
|
|
}
|