Files
housecarl/internal/api/types.go
T
abrown38andClaude Opus 5.5 a45c893830 Connections, "every packet" rules, and a packet tracer
- Connections: the kernel's tracked connections, read and closed over
  ctnetlink (internal/conntrack, raw netlink like the drop log), with
  their NAT and TCP state; closing one makes the rules check its next
  packet again (GET /connections, POST /connections/close; hc
  connections; Traffic → Connections).
- Rules can check every packet (every_packet, --every-packet): before
  established connections are accepted, so a drop or reject also stops
  connections already open. They can't accept.
- A packet tracer (internal/trace): a table of its own, inet
  housecarl-trace, marks the packets a filter picks, `nft monitor trace`
  reports every rule and policy they meet in every table, and the tracer
  names what accepted or dropped them, Housecarl's rules by name (POST,
  GET, DELETE /trace; hc trace; Traffic → Trace). One trace at a time,
  at most 200 packets and 20 a second; the table goes when it ends.

e2e: 157 checks, among them an upload a normal block doesn't stop and an
every-packet one does, and a trace naming the blocking rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 20:22:34 +01:00

425 lines
15 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"`
}
// 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"`
}