Files
housecarl/internal/api/types.go
T
abrown38andClaude Opus 5.5 e4efdf9c78 Confirm changes automatically from the client that made them
A change made over the network answers with a one-time confirm_token
and closes its connection (HTTP/2: a GOAWAY). The web UI and hc send the
token straight back to /confirm, which accepts it only for the newest
change, from the same user and API token, the same address, and over a
new connection: proof that the client can still reach the API through
the new ruleset (an established connection would get through anyway). A
change that cuts its client off can't be confirmed so, and rolls back by
itself. The setting manual_confirm (hc settings set --auto-confirm off)
and hc --no-confirm leave confirming to a person. Changes through the
local socket still need no confirmation. e2e: 136 checks, among them a
lockout change rolled back without anyone confirming.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 13:23:28 +01:00

333 lines
12 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"`
}