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