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>
463 lines
17 KiB
Go
463 lines
17 KiB
Go
// Package api holds the JSON shapes of Housecarl's REST API, shared by the
|
||
// server and the Go client (and so the CLI).
|
||
package api
|
||
|
||
import (
|
||
"encoding/json"
|
||
"time"
|
||
|
||
"housecarl/internal/model"
|
||
"housecarl/internal/validate"
|
||
"housecarl/internal/version"
|
||
)
|
||
|
||
// Prefix starts every API path.
|
||
const Prefix = "/api/v1"
|
||
|
||
// ErrorBody is every error response.
|
||
type ErrorBody struct {
|
||
Error ErrorInfo `json:"error"`
|
||
}
|
||
|
||
// ErrorInfo describes an error.
|
||
type ErrorInfo struct {
|
||
Code string `json:"code"`
|
||
Message string `json:"message"`
|
||
Problems []validate.Problem `json:"problems,omitempty"`
|
||
// Result is the preview of a refused change.
|
||
Result json.RawMessage `json:"result,omitempty"`
|
||
// Op is the changeset operation (its seq) that couldn't be applied.
|
||
Op int `json:"op,omitempty"`
|
||
}
|
||
|
||
// Whoami describes the caller.
|
||
type Whoami struct {
|
||
User *model.User `json:"user"`
|
||
Admin bool `json:"admin"`
|
||
Method string `json:"method"`
|
||
Permissions []string `json:"permissions"`
|
||
// TokenID is the token the caller authenticated with (0: none).
|
||
TokenID int64 `json:"token_id,omitempty"`
|
||
CSRFToken string `json:"csrf_token,omitempty"`
|
||
Build version.Build `json:"build"`
|
||
}
|
||
|
||
// Login is the login request.
|
||
type Login struct {
|
||
Username string `json:"username"`
|
||
Password string `json:"password"`
|
||
}
|
||
|
||
// PasswordChange changes the caller's password.
|
||
type PasswordChange struct {
|
||
Current string `json:"current"`
|
||
Password string `json:"password"`
|
||
}
|
||
|
||
// MutationResponse answers a configuration change.
|
||
type MutationResponse struct {
|
||
Applied bool `json:"applied"`
|
||
DryRun bool `json:"dry_run,omitempty"`
|
||
HistoryID int64 `json:"history_id,omitempty"`
|
||
Changes []model.Change `json:"changes"`
|
||
Warnings []validate.Problem `json:"warnings,omitempty"`
|
||
// Object is the created or changed object.
|
||
Object json.RawMessage `json:"object,omitempty"`
|
||
// ConfirmBy is set when the change is on probation: unless confirmed
|
||
// (POST /confirm) by then, it is rolled back.
|
||
ConfirmBy *time.Time `json:"confirm_by,omitempty"`
|
||
// ConfirmToken lets the client that made the change confirm it by
|
||
// itself (POST /confirm {"token"}): from the same address, over a new
|
||
// connection, which proves it can still reach the API. The answer
|
||
// closes its connection, so the next request opens a new one.
|
||
ConfirmToken string `json:"confirm_token,omitempty"`
|
||
// Diff is the ruleset's unified diff, for a dry run.
|
||
Diff string `json:"diff,omitempty"`
|
||
// Staged: the change was added to a changeset (Changeset), not
|
||
// applied.
|
||
Staged bool `json:"staged,omitempty"`
|
||
Changeset *model.Changeset `json:"changeset,omitempty"`
|
||
}
|
||
|
||
// ChangesetInput creates a changeset, or changes its description.
|
||
type ChangesetInput struct {
|
||
Description string `json:"description"`
|
||
}
|
||
|
||
// ChangesetPreview is what applying a changeset would do: its changes,
|
||
// the problems, nftables' check and the ruleset's diff.
|
||
type ChangesetPreview struct {
|
||
// Valid: it can be applied as it is.
|
||
Valid bool `json:"valid"`
|
||
Changes []model.Change `json:"changes"`
|
||
Warnings []validate.Problem `json:"warnings,omitempty"`
|
||
// Error says why it can't be applied (Op: which operation, if one).
|
||
Error *ErrorInfo `json:"error,omitempty"`
|
||
Diff string `json:"diff,omitempty"`
|
||
}
|
||
|
||
// HostInterface is one of the server's network interfaces, with the zone
|
||
// it is in.
|
||
type HostInterface struct {
|
||
Name string `json:"name"`
|
||
Addresses []string `json:"addresses"`
|
||
Up bool `json:"up"`
|
||
Zone string `json:"zone,omitempty"`
|
||
}
|
||
|
||
// Counter is packets and bytes since the table was created.
|
||
type Counter struct {
|
||
Packets uint64 `json:"packets"`
|
||
Bytes uint64 `json:"bytes"`
|
||
}
|
||
|
||
// Counters are the hits of every object, by mark: "r12" (rule 12), "f3"
|
||
// (port forward 3; "f3-6" its IPv6 pinhole, "f3-hairpin"), "s4", "d5",
|
||
// "b-established" (a built-in), "p-input" (what reached a policy).
|
||
type Counters struct {
|
||
Since time.Time `json:"since"`
|
||
Counters map[string]Counter `json:"counters"`
|
||
}
|
||
|
||
// Element is a dynamic set's entry.
|
||
type Element struct {
|
||
Address string `json:"address"`
|
||
// Timeout in seconds: on input, how long it stays (0: the set's
|
||
// default); on output, how long it has left.
|
||
Timeout int `json:"timeout,omitempty"`
|
||
}
|
||
|
||
// Probation is the changes waiting to be confirmed.
|
||
type Probation struct {
|
||
// Pending history entries, oldest first (empty: nothing pending).
|
||
Pending []int64 `json:"pending"`
|
||
// Takeovers is how many takeovers (deleted tables) are pending.
|
||
Takeovers int `json:"takeovers,omitempty"`
|
||
Deadline time.Time `json:"deadline,omitempty"`
|
||
// Seconds left before they are rolled back.
|
||
SecondsLeft int `json:"seconds_left,omitempty"`
|
||
}
|
||
|
||
// LogEntry is a packet a rule or a policy logged.
|
||
type LogEntry struct {
|
||
Time time.Time `json:"time"`
|
||
Prefix string `json:"prefix"`
|
||
In string `json:"in,omitempty"`
|
||
Out string `json:"out,omitempty"`
|
||
Family string `json:"family"`
|
||
Protocol string `json:"protocol"`
|
||
Source string `json:"source"`
|
||
Dest string `json:"destination"`
|
||
SrcPort int `json:"source_port,omitempty"`
|
||
DestPort int `json:"destination_port,omitempty"`
|
||
ICMPType string `json:"icmp_type,omitempty"`
|
||
Length int `json:"length"`
|
||
TCPFlags string `json:"tcp_flags,omitempty"`
|
||
RuleMark string `json:"mark,omitempty"`
|
||
Verdict string `json:"verdict,omitempty"`
|
||
}
|
||
|
||
// Status is the dashboard's overview.
|
||
type Status struct {
|
||
Build version.Build `json:"build"`
|
||
Started time.Time `json:"started"`
|
||
Database string `json:"database,omitempty"`
|
||
Applied bool `json:"applied"`
|
||
ApplyError string `json:"apply_error,omitempty"`
|
||
// Unconfigured: the configuration is empty, and no ruleset was
|
||
// applied yet.
|
||
Unconfigured bool `json:"unconfigured,omitempty"`
|
||
Problems []validate.Problem `json:"problems"`
|
||
Probation Probation `json:"probation"`
|
||
Interfaces []HostInterface `json:"interfaces"`
|
||
Counts map[string]int `json:"counts"`
|
||
// Other tables in the ruleset (not Housecarl's), "inet main".
|
||
OtherTables []string `json:"other_tables"`
|
||
}
|
||
|
||
// MoveInput moves a rule (or NAT rule) before or after another, or to the
|
||
// start or end of its chain; Chain moves a rule to another chain.
|
||
type MoveInput struct {
|
||
Before int64 `json:"before,omitempty"`
|
||
After int64 `json:"after,omitempty"`
|
||
To string `json:"to,omitempty"` // start, end
|
||
Chain string `json:"chain,omitempty"`
|
||
}
|
||
|
||
// ImportInput is an nftables ruleset to import: nft -j's JSON, and
|
||
// optionally the ruleset file's text, whose defines name the sets.
|
||
type ImportInput struct {
|
||
JSON json.RawMessage `json:"json"`
|
||
// Text is `nft -a list ruleset`: rules Housecarl can't express are
|
||
// kept from it as raw rules.
|
||
Text string `json:"text,omitempty"`
|
||
Defines string `json:"defines,omitempty"`
|
||
// Table limits the import to one table ("inet main"; default: every
|
||
// table but Housecarl's).
|
||
Table string `json:"table,omitempty"`
|
||
// Replace replaces Housecarl's configuration; without it, the import
|
||
// is refused when there is one.
|
||
Replace bool `json:"replace,omitempty"`
|
||
// Zones names the zones by interface ({"enp2s0": "lan"}); interfaces
|
||
// given the same name share a zone.
|
||
Zones map[string]string `json:"zones,omitempty"`
|
||
}
|
||
|
||
// ImportResult reports an import.
|
||
type ImportResult struct {
|
||
MutationResponse
|
||
// State is the imported configuration (also on a dry run).
|
||
State *model.State `json:"state"`
|
||
// Notes say how things were mapped; Raw lists what became raw rules.
|
||
Notes []string `json:"notes"`
|
||
Raw []string `json:"raw"`
|
||
}
|
||
|
||
// TakeoverInput deletes the tables Housecarl replaces ("inet main").
|
||
type TakeoverInput struct {
|
||
Tables []string `json:"tables"`
|
||
}
|
||
|
||
// TokenInput creates a token.
|
||
type TokenInput struct {
|
||
Name string `json:"name"`
|
||
// Grants ("log:read", "sets:write:guest") or a role restrict the token; none: all of
|
||
// the user's permissions.
|
||
Grants []string `json:"grants,omitempty"`
|
||
Role string `json:"role,omitempty"`
|
||
ExpiresIn string `json:"expires_in,omitempty"` // 90d, 720h
|
||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||
}
|
||
|
||
// TokenRotation gives a token a new secret. Grace keeps the old secret
|
||
// working for a while (up to 7d). The token gets its lifetime again,
|
||
// counted from now, unless ExpiresIn or NoExpiry says otherwise.
|
||
type TokenRotation struct {
|
||
Grace string `json:"grace,omitempty"`
|
||
ExpiresIn string `json:"expires_in,omitempty"`
|
||
NoExpiry bool `json:"no_expiry,omitempty"`
|
||
}
|
||
|
||
// Token is a token; Secret is only set when it is created.
|
||
type Token struct {
|
||
*model.Token
|
||
GrantStrings []string `json:"grant_strings"`
|
||
Secret string `json:"secret,omitempty"`
|
||
}
|
||
|
||
// UserInput creates or changes a user.
|
||
type UserInput struct {
|
||
Username *string `json:"username,omitempty"`
|
||
Password *string `json:"password,omitempty"`
|
||
Admin *bool `json:"admin,omitempty"`
|
||
Grants *[]string `json:"grants,omitempty"`
|
||
Role *string `json:"role,omitempty"`
|
||
Disabled *bool `json:"disabled,omitempty"`
|
||
Comment *string `json:"comment,omitempty"`
|
||
}
|
||
|
||
// User is a user with grants written out.
|
||
type User struct {
|
||
*model.User
|
||
GrantStrings []string `json:"grant_strings"`
|
||
}
|
||
|
||
// CertStep is one step of the preflight check.
|
||
type CertStep struct {
|
||
Name string `json:"name"`
|
||
Status string `json:"status"` // ok, fail, warn, skip
|
||
Detail string `json:"detail"`
|
||
// Queries are the DNS questions asked, as dig commands.
|
||
Queries []string `json:"queries,omitempty"`
|
||
}
|
||
|
||
// CertCheck is the result of a preflight check: can Housecarl publish its
|
||
// challenge through Jarl, and does the internet see it?
|
||
type CertCheck struct {
|
||
Time time.Time `json:"time"`
|
||
Domain string `json:"domain"`
|
||
OK bool `json:"ok"`
|
||
Steps []CertStep `json:"steps"`
|
||
}
|
||
|
||
// CertStatus describes the certificate, for the API and the dashboard.
|
||
type CertStatus struct {
|
||
Domain string `json:"domain"`
|
||
Directory string `json:"directory"`
|
||
Staging bool `json:"staging"`
|
||
State string `json:"state"` // none, ok, working, error
|
||
NotBefore *time.Time `json:"not_before,omitempty"`
|
||
NotAfter *time.Time `json:"not_after,omitempty"`
|
||
Issuer string `json:"issuer,omitempty"`
|
||
// IssuedBy is the directory the current certificate came from (it may
|
||
// differ from Directory until the next renewal).
|
||
IssuedBy string `json:"issued_by,omitempty"`
|
||
RenewAt *time.Time `json:"renew_at,omitempty"`
|
||
LastError string `json:"last_error,omitempty"`
|
||
LastAttempt *time.Time `json:"last_attempt,omitempty"`
|
||
NextAttempt *time.Time `json:"next_attempt,omitempty"`
|
||
Check *CertCheck `json:"check,omitempty"`
|
||
}
|
||
|
||
// CertificateResponse is GET /certificate.
|
||
type CertificateResponse struct {
|
||
// Configured is false when tls.acme is off.
|
||
Configured bool `json:"configured"`
|
||
// Source is acme, file or none.
|
||
Source string `json:"source"`
|
||
Status *CertStatus `json:"status,omitempty"`
|
||
}
|
||
|
||
// QuotaInfo is a rule's quota and what it used.
|
||
type QuotaInfo struct {
|
||
RuleID int64 `json:"rule_id"`
|
||
Rule string `json:"rule"`
|
||
Quota model.Quota `json:"quota"`
|
||
// Used is a total quota's bytes; Addresses a per-address quota's,
|
||
// each ("192.168.1.20", or "2001:db8:1::/64").
|
||
Used int64 `json:"used"`
|
||
Addresses []QuotaAddress `json:"addresses,omitempty"`
|
||
LastReset *time.Time `json:"last_reset,omitempty"`
|
||
NextReset *time.Time `json:"next_reset,omitempty"`
|
||
}
|
||
|
||
// QuotaAddress is one address's (or prefix's) use of a per-address quota.
|
||
type QuotaAddress struct {
|
||
Address string `json:"address"`
|
||
Used int64 `json:"used"`
|
||
}
|
||
|
||
// ConfirmInput confirms the changes on probation. With Token (a change's
|
||
// confirm_token), it is the client that made the change confirming it by
|
||
// itself; without, a person's confirmation.
|
||
type ConfirmInput struct {
|
||
Token string `json:"token,omitempty"`
|
||
}
|
||
|
||
// Connection is a connection the kernel tracks (conntrack), as it was
|
||
// first seen: Source:SourcePort → Destination:DestinationPort. NAT shows
|
||
// as DNATTo (where it was really sent: a port forward) and SNATTo (the
|
||
// source it went out with: masquerading).
|
||
type Connection struct {
|
||
ID uint32 `json:"id,omitempty"`
|
||
Family string `json:"family"`
|
||
Protocol string `json:"protocol"`
|
||
Source string `json:"source"`
|
||
SourcePort int `json:"source_port,omitempty"`
|
||
Destination string `json:"destination"`
|
||
DestinationPort int `json:"destination_port,omitempty"`
|
||
ICMPID int `json:"icmp_id,omitempty"`
|
||
ICMPType int `json:"icmp_type,omitempty"`
|
||
ICMPCode int `json:"icmp_code,omitempty"`
|
||
DNATTo string `json:"dnat_to,omitempty"`
|
||
DNATPort int `json:"dnat_port,omitempty"`
|
||
SNATTo string `json:"snat_to,omitempty"`
|
||
// State is TCP's (established, time-wait, …); Assured: traffic went
|
||
// both ways.
|
||
State string `json:"state,omitempty"`
|
||
Assured bool `json:"assured,omitempty"`
|
||
// Timeout is the seconds left until it is forgotten unless more
|
||
// traffic comes.
|
||
Timeout int `json:"timeout"`
|
||
Mark uint32 `json:"mark,omitempty"`
|
||
// Packets and Bytes, both ways, when the kernel counts them
|
||
// (net.netfilter.nf_conntrack_acct).
|
||
Packets uint64 `json:"packets,omitempty"`
|
||
Bytes uint64 `json:"bytes,omitempty"`
|
||
}
|
||
|
||
// CloseConnections closes connections: these, or every one Search finds
|
||
// (as GET /connections?q= does).
|
||
type CloseConnections struct {
|
||
Connections []Connection `json:"connections,omitempty"`
|
||
Search string `json:"search,omitempty"`
|
||
}
|
||
|
||
// TraceInput says what to trace: packets from or to Address (an address
|
||
// or prefix), on Port (either way), of Protocol, for Seconds (0: 60).
|
||
type TraceInput struct {
|
||
Address string `json:"address,omitempty"`
|
||
Port int `json:"port,omitempty"`
|
||
Protocol string `json:"protocol,omitempty"`
|
||
Seconds int `json:"seconds,omitempty"`
|
||
}
|
||
|
||
// TraceStep is one rule or policy a traced packet met, in any table.
|
||
type TraceStep struct {
|
||
Table string `json:"table"` // "inet housecarl", "ip nat"…
|
||
Chain string `json:"chain"`
|
||
Kind string `json:"kind"` // rule or policy
|
||
// Rule is the rule as nftables writes it; Mark its Housecarl mark
|
||
// ("r12", "b-established"), if it is one of Housecarl's.
|
||
Rule string `json:"rule,omitempty"`
|
||
Mark string `json:"mark,omitempty"`
|
||
Verdict string `json:"verdict"` // accept, drop, continue, jump X…
|
||
// Label names Housecarl's object ("rule input #12 (ssh)").
|
||
Label string `json:"label,omitempty"`
|
||
}
|
||
|
||
// TracePacket is one traced packet: its headers as it arrived, every
|
||
// step, and its fate.
|
||
type TracePacket struct {
|
||
ID string `json:"id"`
|
||
Time time.Time `json:"time"`
|
||
In string `json:"in,omitempty"`
|
||
Out string `json:"out,omitempty"`
|
||
Protocol string `json:"protocol,omitempty"`
|
||
Source string `json:"source,omitempty"`
|
||
SourcePort int `json:"source_port,omitempty"`
|
||
Destination string `json:"destination,omitempty"`
|
||
DestinationPort int `json:"destination_port,omitempty"`
|
||
TCPFlags string `json:"tcp_flags,omitempty"`
|
||
ICMPType string `json:"icmp_type,omitempty"`
|
||
Steps []TraceStep `json:"steps"`
|
||
// Verdict is accepted or dropped; DecidedBy the step that decided.
|
||
Verdict string `json:"verdict"`
|
||
DecidedBy TraceStep `json:"decided_by"`
|
||
}
|
||
|
||
// TraceStatus is the trace running (or the last one).
|
||
type TraceStatus struct {
|
||
Running bool `json:"running"`
|
||
Input TraceInput `json:"input"`
|
||
Started time.Time `json:"started"`
|
||
Ends time.Time `json:"ends"`
|
||
Packets []TracePacket `json:"packets"`
|
||
Error string `json:"error,omitempty"`
|
||
}
|
||
|
||
// Matrix is what the rules do to new connections between zones:
|
||
// zone → zone (the forward chain), zone → the firewall (input) and the
|
||
// firewall → zone (output). "firewall" stands for the machine itself.
|
||
type Matrix struct {
|
||
Zones []string `json:"zones"`
|
||
Cells []MatrixCell `json:"cells"`
|
||
}
|
||
|
||
// MatrixCell is one pair: what happens to new connections from From to
|
||
// To that no exception covers (Verdict, decided by DecidedBy), and the
|
||
// exceptions: rules and built-ins that decide for some of them (some
|
||
// addresses, services, times…), in the order they are checked.
|
||
type MatrixCell struct {
|
||
From string `json:"from"`
|
||
To string `json:"to"`
|
||
Chain string `json:"chain"`
|
||
Verdict string `json:"verdict"` // allowed or blocked
|
||
DecidedBy string `json:"decided_by"`
|
||
RuleID int64 `json:"rule_id,omitempty"` // the deciding rule
|
||
Exceptions []MatrixException `json:"exceptions"`
|
||
}
|
||
|
||
// MatrixException is a rule (or built-in) that decides for part of a
|
||
// pair's traffic.
|
||
type MatrixException struct {
|
||
RuleID int64 `json:"rule_id,omitempty"` // 0: a built-in, or port forwards
|
||
Chain string `json:"chain"`
|
||
Label string `json:"label"`
|
||
// Verdict is allowed, blocked, logged (and goes on), or unknown (a raw
|
||
// rule Housecarl can't read).
|
||
Verdict string `json:"verdict"`
|
||
// What it covers: "from trusted · ssh", "Monday 08:00–17:00".
|
||
What string `json:"what"`
|
||
}
|