Files
housecarl/internal/server/routes.go
T
abrown38andClaude Opus 5.5 9d766b9823 Boot fallback, flowtable devices that come and go, healthz, renames
The boot unit fails closed: if ruleset.nft doesn't load, it loads
ruleset-fallback.nft (render.Fallback), saved beside it from the same
configuration: nothing new in from external zones, established, the
ICMPv6 IPv6 needs and DHCPv6 replies let in, the inside left alone and
masqueraded, and the unit marked failed with a critical message.
housecarld replaces it when it applies; status, the dashboard and
/healthz say while it is in force. no_external_zone now also says the
fallback closes everything without an external zone.

The boot ruleset has no flowtable: nftables on Debian 13's kernel
refuses one naming a device that doesn't exist yet (ppp0 before PPPoE),
and with it the whole file. internal/linkwatch listens to rtnetlink link
messages; housecarld applies again when a flowtable device appears or
comes back as a new interface (by ifindex), under the engine's lock.
docs/flowtable.md: what it does, when to use it and when not, how.

GET /healthz reports firewall (applied, unconfigured, fallback,
missing), changes on probation and the machine's warnings, with 503
unless in force, read at most every five seconds.

Renaming a zone, set or service changes what names it in the same
change (one history entry, one undo); dynamic sets keep their names.
The importer keeps a chain's head in order: once a would-be built-in
stays a rule, the ones after it do too, so the maintainer's ICMPv6
accepts stay behind the invalid drop as written. hc settings set
--flowtable none empties the list.

make test (nft 1.1.3) and make e2e (242 checks, with VLAN
sub-interfaces, the fallback and the flowtable) pass.

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

143 lines
6.2 KiB
Go

package server
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"io/fs"
"net/http"
"strings"
"time"
"housecarl/internal/api"
)
func (s *Server) routes() {
m := s.mux
p := api.Prefix
m.HandleFunc("GET /healthz", s.healthz)
// Authentication, users and tokens
m.HandleFunc("POST "+p+"/auth/login", s.public(s.login))
m.HandleFunc("POST "+p+"/auth/logout", s.authed(s.logout))
m.HandleFunc("GET "+p+"/auth/whoami", s.authed(s.whoami))
m.HandleFunc("POST "+p+"/me/password", s.authed(s.changeOwnPassword))
m.HandleFunc("GET "+p+"/me/tokens", s.authed(s.listOwnTokens))
m.HandleFunc("POST "+p+"/me/tokens", s.authed(s.createOwnToken))
m.HandleFunc("GET "+p+"/tokens", s.authed(s.listAllTokens))
m.HandleFunc("DELETE "+p+"/tokens/{id}", s.authed(s.revokeToken))
m.HandleFunc("POST "+p+"/tokens/{id}/rotate", s.authed(s.rotateToken))
m.HandleFunc("GET "+p+"/users", s.authed(s.listUsers))
m.HandleFunc("POST "+p+"/users", s.authed(s.createUser))
m.HandleFunc("PATCH "+p+"/users/{id}", s.authed(s.updateUser))
m.HandleFunc("DELETE "+p+"/users/{id}", s.authed(s.deleteUser))
m.HandleFunc("GET "+p+"/users/{id}/tokens", s.authed(s.listUserTokens))
m.HandleFunc("POST "+p+"/users/{id}/tokens", s.authed(s.createUserToken))
// Configuration
m.HandleFunc("GET "+p+"/settings", s.authed(s.getSettings))
m.HandleFunc("PATCH "+p+"/settings", s.authed(s.updateSettings))
m.HandleFunc("GET "+p+"/sets/{ref}/elements", s.authed(s.listElements))
m.HandleFunc("POST "+p+"/sets/{ref}/elements", s.authed(s.addElement))
m.HandleFunc("DELETE "+p+"/sets/{ref}/elements/{addr}", s.authed(s.deleteElement))
m.HandleFunc("GET "+p+"/quotas", s.authed(s.listQuotas))
m.HandleFunc("GET "+p+"/connections", s.authed(s.listConnections))
m.HandleFunc("POST "+p+"/connections/close", s.authed(s.closeConnections))
m.HandleFunc("GET "+p+"/trace", s.authed(s.getTrace))
m.HandleFunc("POST "+p+"/trace", s.authed(s.startTrace))
m.HandleFunc("DELETE "+p+"/trace", s.authed(s.stopTrace))
m.HandleFunc("POST "+p+"/rules/{ref}/reset-quota", s.authed(s.resetQuota))
m.HandleFunc("GET "+p+"/changesets", s.authed(s.listChangesets))
m.HandleFunc("POST "+p+"/changesets", s.authed(s.createChangeset))
m.HandleFunc("GET "+p+"/changesets/{id}", s.authed(s.getChangeset))
m.HandleFunc("PATCH "+p+"/changesets/{id}", s.authed(s.updateChangeset))
m.HandleFunc("GET "+p+"/changesets/{id}/preview", s.authed(s.previewChangeset))
m.HandleFunc("POST "+p+"/changesets/{id}/apply", s.authed(s.applyChangeset))
m.HandleFunc("POST "+p+"/changesets/{id}/discard", s.authed(s.discardChangeset))
m.HandleFunc("DELETE "+p+"/changesets/{id}/ops/{seq}", s.authed(s.removeOp))
m.HandleFunc("GET "+p+"/{kind}", s.authed(s.listObjects))
m.HandleFunc("POST "+p+"/{kind}", s.authed(s.createObject))
m.HandleFunc("GET "+p+"/{kind}/{ref}", s.authed(s.getObject))
m.HandleFunc("PATCH "+p+"/{kind}/{ref}", s.authed(s.updateObject))
m.HandleFunc("DELETE "+p+"/{kind}/{ref}", s.authed(s.deleteObject))
m.HandleFunc("POST "+p+"/{kind}/{ref}/move", s.authed(s.moveObject))
m.HandleFunc("GET "+p+"/ruleset", s.authed(s.ruleset))
m.HandleFunc("GET "+p+"/matrix", s.authed(s.zoneMatrix))
m.HandleFunc("GET "+p+"/stats/status", s.authed(s.statsStatus))
m.HandleFunc("GET "+p+"/stats/summary", s.authed(s.statsSummary))
m.HandleFunc("GET "+p+"/stats/timeline", s.authed(s.statsTimeline))
m.HandleFunc("GET "+p+"/stats/top", s.authed(s.statsTop))
m.HandleFunc("GET "+p+"/stats/hits", s.authed(s.statsHits))
m.HandleFunc("GET "+p+"/stats/heatmap", s.authed(s.statsHeatmap))
m.HandleFunc("GET "+p+"/history", s.authed(s.listHistory))
m.HandleFunc("GET "+p+"/history/{id}", s.authed(s.getHistory))
m.HandleFunc("POST "+p+"/history/{id}/revert", s.authed(s.revertHistory))
m.HandleFunc("GET "+p+"/probation", s.authed(s.getProbation))
m.HandleFunc("POST "+p+"/confirm", s.authed(s.confirmChanges))
m.HandleFunc("POST "+p+"/rollback", s.authed(s.rollbackChanges))
m.HandleFunc("POST "+p+"/import/nft", s.authed(s.importNft))
m.HandleFunc("POST "+p+"/takeover", s.authed(s.takeover))
m.HandleFunc("GET "+p+"/catalog", s.authed(s.catalog))
m.HandleFunc("GET "+p+"/interfaces", s.authed(s.interfaces))
// Status, counters, the log and audit
m.HandleFunc("GET "+p+"/status", s.authed(s.status))
m.HandleFunc("GET "+p+"/counters", s.authed(s.counters))
m.HandleFunc("GET "+p+"/log", s.authed(s.listLog))
m.HandleFunc("GET "+p+"/audit", s.authed(s.listAudit))
m.HandleFunc("GET "+p+"/certificate", s.authed(s.certificate))
m.HandleFunc("POST "+p+"/certificate/check", s.authed(s.certificateCheck))
m.HandleFunc("POST "+p+"/certificate/renew", s.authed(s.certificateRenew))
m.HandleFunc(p+"/", func(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusNotFound, "not_found", "no such API endpoint: "+r.Method+" "+r.URL.Path)
})
// Web UI
if s.web != nil {
files := http.FileServer(http.FS(s.web))
etags := contentETags(s.web)
m.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
w.Header().Set("Allow", "GET, HEAD")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if r.URL.Path == "/" || r.URL.Path == "/index.html" {
b, err := fs.ReadFile(s.web, "index.html")
if err != nil {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("ETag", etags["index.html"])
http.ServeContent(w, r, "index.html", time.Time{}, bytes.NewReader(b))
return
}
if strings.HasPrefix(r.URL.Path, "/assets/") {
w.Header().Set("Cache-Control", "no-cache")
if tag, ok := etags[strings.TrimPrefix(r.URL.Path, "/")]; ok {
w.Header().Set("ETag", tag)
}
files.ServeHTTP(w, r)
return
}
http.NotFound(w, r)
})
}
}
// contentETags gives every embedded file an ETag from its content.
func contentETags(fsys fs.FS) map[string]string {
tags := map[string]string{}
fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return nil
}
b, err := fs.ReadFile(fsys, path)
if err != nil {
return nil
}
sum := sha256.Sum256(b)
tags[path] = `"` + hex.EncodeToString(sum[:12]) + `"`
return nil
})
return tags
}