Files
housecarl/internal/server/h_import.go
T
abrown38andClaude Opus 5.5 47bae07d42 Import: keep what rulesets mean, name zones as asked
Fixes from an audit of the importer, each of which could change what an
imported ruleset does: two base chains on one hook imported their rules
twice (and hoisted the second's built-ins), now in priority order with
the stricter policy; raw rules from a jumped-to chain lost the jump's
condition, now put in front; a jump and a rule matching one field are
combined only when one's addresses lie within the other's; a second
match on a field replaced the first, and != on hop limit and TCP flags
read as ==, now kept raw; a forward accept without ct status dnat no
longer folds into the port forward, nor an interface-limited IPv6
accept into an open pinhole; raw rules' set references are renamed
(NAME_v4/_v6) or written out, so nftables takes them; DNATs with
matches a forward can't hold are left out with a note.

Sets with flags timeout become dynamic sets; a DNAT of several ports one
forward per port. Zone names: --zone IFACE=NAME (and on the Import
page), VLAN defines, more suffixes, and a note on where each name came
from. testdata/audit.nft covers the cases, checked with nft 1.1.3.

make test and make e2e (246 checks) pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 08:43:42 +01:00

166 lines
4.6 KiB
Go

package server
import (
"fmt"
"net/http"
"strings"
"housecarl/internal/api"
"housecarl/internal/auth"
"housecarl/internal/engine"
"housecarl/internal/importer"
"housecarl/internal/model"
)
// importNft makes an nftables ruleset Housecarl's configuration
// (internal/importer); ?dry_run=1 shows the result without applying it.
func (s *Server) importNft(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.ResConfig, true); err != nil {
return err
}
if err := notStaged(r, "an import"); err != nil {
return err
}
var in api.ImportInput
if err := decodeJSON(r, &in); err != nil {
return err
}
res, err := importer.Import(importer.Input{JSON: in.JSON, Text: in.Text, Defines: in.Defines, Table: in.Table, Zones: in.Zones})
if err != nil {
return badRequest("%v", err)
}
cur := s.eng.State()
if !in.Replace && hasConfig(cur) {
return conflict("Housecarl already has a configuration; import with replace (--replace) to replace it")
}
// The importer numbers objects from 1; give them IDs of their own.
if err := s.renumber(r, res.State); err != nil {
return err
}
opts := engine.Options{DryRun: queryBool(r, "dry_run"), Via: via(r, p)}
er, err := s.eng.Replace(r.Context(), p, "import from nftables ("+tableName(in.Table)+")", opts, res.State)
out := api.ImportResult{State: res.State, Notes: res.Notes, Raw: res.Raw}
if out.Notes == nil {
out.Notes = []string{}
}
if out.Raw == nil {
out.Raw = []string{}
}
if err != nil {
s.writeErr(w, r, err, out)
return nil
}
s.afterChange(r.Context(), p, er)
if er.Applied {
// Until it is taken over, the old table still applies too: the
// status warns (takeover_pending).
s.store.SetMeta(r.Context(), importedTableKey, res.Table)
}
out.MutationResponse = api.MutationResponse{Applied: er.Applied, DryRun: er.DryRun, HistoryID: er.HistoryID, Changes: er.Changes, Warnings: er.Warnings}
if out.Changes == nil {
out.Changes = []model.Change{}
}
if pr := s.probationStatus(); er.Applied && len(pr.Pending) > 0 {
out.ConfirmBy = &pr.Deadline
}
out.ConfirmToken = s.issueCheck(w, r, p, er)
writeJSON(w, http.StatusOK, out)
return nil
}
func tableName(t string) string {
if t == "" {
return "the ruleset"
}
return t
}
func hasConfig(st *model.State) bool {
for _, k := range model.Kinds {
if len(st.Objects(k)) > 0 {
return true
}
}
return false
}
// renumber gives every object of an imported state a fresh ID.
func (s *Server) renumber(r *http.Request, st *model.State) error {
n := 0
for _, k := range model.Kinds {
n += len(st.Objects(k))
}
if n == 0 {
return nil
}
ids, err := s.store.AllocateIDs(r.Context(), n)
if err != nil {
return err
}
i := 0
for _, k := range model.Kinds {
for _, o := range st.Objects(k) {
setID(o, ids[i])
i++
}
}
return nil
}
// takeover deletes the tables Housecarl replaces (the ruleset imported).
// Over the network it is on probation: unless confirmed, the tables come
// back.
func (s *Server) takeover(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := requireAdmin(p); err != nil {
return err
}
if err := notStaged(r, "a takeover"); err != nil {
return err
}
var in api.TakeoverInput
if err := decodeJSON(r, &in); err != nil {
return err
}
if len(in.Tables) == 0 {
return badRequest("name the tables to delete (\"inet main\")")
}
var done []string
for _, t := range in.Tables {
fam, name, ok := strings.Cut(strings.TrimSpace(t), " ")
if !ok || name == "" {
return badRequest("%q: write a table as FAMILY NAME (\"inet main\")", t)
}
if fam == "inet" && name == "housecarl" {
return badRequest("that is Housecarl's own table")
}
script, err := s.fw.TableText(r.Context(), fam, name)
if err != nil {
return notFound("there is no table %s %s", fam, name)
}
if queryBool(r, "dry_run") {
done = append(done, t)
continue
}
if err := s.fw.DeleteTable(r.Context(), fam, name); err != nil {
return fmt.Errorf("deleting table %s: %w", t, err)
}
s.startProbation(r.Context(), p, 0, script)
s.eng.Audit(r.Context(), p, "takeover", t, "success", "")
done = append(done, t)
}
msg := "deleted " + strings.Join(done, ", ")
if queryBool(r, "dry_run") {
msg = "would delete " + strings.Join(done, ", ")
} else if pr := s.probationStatus(); pr.Takeovers > 0 {
msg += fmt.Sprintf("; confirm with 'hc confirm' within %ds, or they come back", pr.SecondsLeft)
}
answer := map[string]any{"deleted": done, "message": msg}
if !queryBool(r, "dry_run") && len(done) > 0 {
if tok := s.issueCheck(w, r, p, nil); tok != "" {
answer["confirm_token"] = tok
}
}
writeJSON(w, http.StatusOK, answer)
return nil
}