Compare commits
8
Commits
main
...
v0.1.0-rc.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d25e12995e | ||
|
|
e4efdf9c78 | ||
|
|
fc390eb57c | ||
|
|
09504e5555 | ||
|
|
2880f7ddc9 | ||
|
|
ebec2cfcca | ||
|
|
eaadbe6a3d | ||
|
|
34292eeebb |
No files matched your search
@@ -0,0 +1,69 @@
|
||||
# Builds and publishes a release when a version tag (v0.2.0, v1.0.0-rc.1)
|
||||
# is pushed:
|
||||
# * a Gitea release with the CHANGELOG notes, the linux tarballs, the CLI
|
||||
# for Linux, macOS and Windows, the .debs and SHA256SUMS;
|
||||
# * the .debs (amd64, arm64) in the owner's Debian package registry:
|
||||
# distribution "stable", or "testing" for a pre-release (a version
|
||||
# with a hyphen), which Gitea also marks as a pre-release.
|
||||
#
|
||||
# Needs a repository secret RELEASE_TOKEN: an access token of a user who
|
||||
# can write to the repository, with the write:repository and write:package
|
||||
# scopes. docs/development.md ("Releasing") has the steps.
|
||||
name: release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Check the tag against VERSION and CHANGELOG.md
|
||||
run: |
|
||||
version=$(cat VERSION)
|
||||
if [ "$GITHUB_REF_NAME" != "v$version" ]; then
|
||||
echo "::error::tag $GITHUB_REF_NAME does not match VERSION ($version)"
|
||||
exit 1
|
||||
fi
|
||||
# The release notes (a pre-release may use its final version's
|
||||
# section or Unreleased; see scripts/gitea-release.sh).
|
||||
if ! scripts/gitea-release.sh notes > /dev/null; then
|
||||
echo "::error::no release notes for $version in CHANGELOG.md"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Tools
|
||||
run: |
|
||||
missing=
|
||||
for t in jq dpkg-deb curl; do command -v $t >/dev/null || missing="$missing $t"; done
|
||||
if [ -n "$missing" ]; then
|
||||
sudo=; [ "$(id -u)" = 0 ] || sudo=sudo
|
||||
$sudo apt-get update -qq && $sudo apt-get install -y -qq jq dpkg curl
|
||||
fi
|
||||
|
||||
- name: Test
|
||||
run: make test
|
||||
|
||||
- name: Build
|
||||
env:
|
||||
# Checked against VERSION above; don't depend on the checkout
|
||||
# having the tag.
|
||||
RELEASE_TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
export DEB_MAINTAINER="$(git log -1 --format='%an <%ae>')"
|
||||
make dist
|
||||
make cli-dist
|
||||
make deb DEB_ARCH=amd64
|
||||
make deb DEB_ARCH=arm64
|
||||
|
||||
- name: Publish
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
run: scripts/gitea-release.sh
|
||||
@@ -1,8 +1,9 @@
|
||||
# Working on Housecarl
|
||||
|
||||
Notes for coding agents (and people) changing this repository. Read this
|
||||
first, then [docs/design.md](docs/design.md), the plan and its open
|
||||
questions.
|
||||
first, then [docs/design.md](docs/design.md) (how it fits together and
|
||||
what 0.1 leaves out) and [docs/development.md](docs/development.md)
|
||||
(layout, tests, releasing).
|
||||
|
||||
Housecarl is a firewall manager for Linux: `housecarld` keeps the
|
||||
machine's nftables ruleset and serves a REST API and a web UI; `hc` is the
|
||||
@@ -12,9 +13,6 @@ their ways: the same auth, socket and web UI helpers, the same release
|
||||
process, the same look. When in doubt, do it the way Jarl and Steward do.
|
||||
Go module `housecarl`, `CGO_ENABLED=0`, Linux only for housecarld.
|
||||
|
||||
Housecarl is at its start: only the version commands, the build and the
|
||||
brand exist. Most rules below are for what is coming.
|
||||
|
||||
## Ground rules
|
||||
|
||||
* **The database is the source of truth.** The ruleset is rendered from
|
||||
@@ -28,11 +26,17 @@ brand exist. Most rules below are for what is coming.
|
||||
* **Housecarl's own table only.** Write `inet housecarl` and nothing
|
||||
else; never flush the whole ruleset (Docker, libvirt and others keep
|
||||
theirs).
|
||||
* **Don't lock the user out.** Changes to the rules for the machine itself
|
||||
are applied on probation and rolled back unless confirmed
|
||||
(docs/design.md).
|
||||
* **Don't lock the user out.** Changes made over the network are applied
|
||||
on probation and rolled back unless confirmed (docs/design.md). An
|
||||
empty configuration applies nothing. But never force a rule that keeps
|
||||
SSH or the web UI open: the maintainer doesn't want one.
|
||||
* **Stay within nftables 1.1.3** (Debian 13). The dev machine's newer nft
|
||||
accepts things 1.1.3 refuses; the tests with `HOUSECARL_NFT` and the
|
||||
e2e test use 1.1.3.
|
||||
* **Every user-visible change updates `CHANGELOG.md`** under
|
||||
`## [Unreleased]`, and the matching `docs/` page in the same change.
|
||||
New validation codes get a case in `internal/validate/validate_test.go`
|
||||
and a row in `docs/validation.md`.
|
||||
* **Errors** say what is wrong and how to fix it, naming the object.
|
||||
* **Schema changes** append a migration; never edit a shipped one.
|
||||
* `make test` runs `go vet` and a `gofmt -l` check. `goimports` isn't
|
||||
@@ -56,8 +60,25 @@ brand exist. Most rules below are for what is coming.
|
||||
|
||||
```sh
|
||||
make test # vet + gofmt check + unit tests
|
||||
make e2e # real traffic through a router container (needs Docker)
|
||||
```
|
||||
|
||||
* **Docker** on this machine needs `DOCKER_HOST=unix:///var/run/docker.sock`.
|
||||
`docker build` can't reach the network here: images are made with
|
||||
`docker run` + `docker commit` (scripts/e2e.sh makes `housecarl-nft`).
|
||||
The e2e test prints `RESULT: N passed, 0 failed`; run it after any
|
||||
change to `render`, `nft`, `engine`, `server`, `importer` or the CLI.
|
||||
* **Real nft in unit tests:**
|
||||
`HOUSECARL_NFT="docker run --rm -i --cap-add NET_ADMIN housecarl-nft nft"`
|
||||
and `HOUSECARL_NFT_IMAGE=housecarl-nft`; without them those tests skip.
|
||||
Unprivileged namespaces (`unshare -rn`) are blocked here by AppArmor.
|
||||
* **The systemd sandbox is only tested by a package test**: `make deb`,
|
||||
installed in a `debian:trixie` container running systemd
|
||||
(`--privileged --cgroupns=host /sbin/init`).
|
||||
* **The web UI** has no JS tests. Bundle `assets/app.js` with esbuild to
|
||||
catch syntax and import errors, and look at it in headless Chrome
|
||||
against the e2e lab (`KEEP=1`, web UI on `10.88.10.1:8070`).
|
||||
|
||||
## Web UI rules
|
||||
|
||||
As in Jarl and Steward:
|
||||
|
||||
+77
-5
@@ -6,14 +6,86 @@ All notable changes to Housecarl are documented here. The format follows
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
The start of Housecarl, a firewall manager for nftables, built the way
|
||||
Jarl and Steward are.
|
||||
The first version of Housecarl, a firewall manager for nftables, built
|
||||
the way Jarl and Steward are.
|
||||
|
||||
### Added
|
||||
|
||||
- **Zones, address sets and services:** interfaces grouped by what they
|
||||
connect to (wildcards like `incusbr*`; external zones face the
|
||||
internet); sets of IPv4 and IPv6 addresses, prefixes and ranges, mixed;
|
||||
ports and ICMP types by name, with twenty built in (docs/rules.md).
|
||||
- **Rules** in four chains (input, forward, output, and early, before
|
||||
connection tracking), in order, matching by zone, address, service,
|
||||
family, connection state and status, days and hours, a rate (also per
|
||||
source address), and a connection count; accept, drop, reject (ICMP or
|
||||
TCP reset) or log. Raw nftables rules for the rest.
|
||||
- **IPv4 and IPv6 together:** one rule becomes the nftables rules each
|
||||
family needs.
|
||||
- **NAT:** port forwards as one object (DNAT, the forward accept, hairpin
|
||||
NAT, and optionally an IPv6 pinhole to a host's own port); source NAT
|
||||
(masquerade or an address; NAT66 on request); redirects to the firewall
|
||||
(docs/nat.md).
|
||||
- **Built-ins**, each switchable: loopback, established connections,
|
||||
invalid packets, ICMPv6 (RFC 4890), ping from internal zones, DHCPv6
|
||||
client replies, antispoofing with your own prefixes, MSS clamping, and
|
||||
a flowtable fast path.
|
||||
- **Changesets, as in Jarl:** changes staged to be reviewed and applied
|
||||
together, as one history entry. Each is checked as it is staged; the
|
||||
review shows the problems, nftables' check and the diff of the ruleset.
|
||||
The web UI collects edits as pending changes by default (pending rows,
|
||||
a bar with Review and Apply now, a Changes page), with a switch for live
|
||||
edits; hc stages with `-C ID` or `HOUSECARL_CHANGESET`, and has `hc
|
||||
changeset create/list/show/diff/apply/discard/remove`. An applied
|
||||
changeset is on probation like any change. `--dry-run` shows the
|
||||
ruleset's diff too (docs/design.md, docs/cli.md).
|
||||
- **Bandwidth limits and quotas:** rate limits in bytes (`--limit
|
||||
20mbytes/second,over,per-source`), per source or per destination;
|
||||
quotas, an allowance of traffic per rule or per address, reset daily,
|
||||
weekly, monthly on a chosen day, or by hand, kept across changes,
|
||||
restarts and reboots (`hc quotas`, `hc rule reset-quota`, usage on the
|
||||
rules page). Rules that count traffic are checked before established
|
||||
connections are accepted, so they see every packet. Per-address limits
|
||||
group IPv6 by /64 by default (`--group-v4`, `--group-v6`). Meters,
|
||||
byte rates and quotas in an imported ruleset carry over
|
||||
(docs/rules.md).
|
||||
- **Changes checked twice and applied atomically:** validation of the
|
||||
whole configuration with stable problem codes (docs/validation.md),
|
||||
then `nft -c`, then one `nft -f` transaction that replaces Housecarl's
|
||||
own table, `inet housecarl`, and nothing else. Dynamic sets' entries
|
||||
and the counters carry across.
|
||||
- **Probation:** a change made over the network rolls itself back unless
|
||||
confirmed within two minutes. The web UI and `hc` confirm their own
|
||||
changes once they reach Housecarl again over a new connection, from the
|
||||
same address (a change that cuts its client off can't, and rolls back);
|
||||
`--no-confirm` and the "a person confirms each change" setting leave it
|
||||
to `hc confirm` or the banner;
|
||||
changes through the local socket are kept at once; a restart rolls
|
||||
back what was pending. No rule is forced to keep SSH or the web UI
|
||||
open.
|
||||
- **At boot:** the confirmed ruleset is loaded by
|
||||
`housecarl-ruleset.service` before the network comes up, without
|
||||
housecarld. An empty configuration applies nothing.
|
||||
- **Hits and the log:** packet and byte counters for every rule, NAT rule,
|
||||
built-in and policy; packets logged by rules and policies, read from
|
||||
netlink log group 4171 (`hc log -f`, the Log page).
|
||||
- **Dynamic sets** whose entries expire by themselves, changed through
|
||||
their own API with tokens that may be limited to one set
|
||||
(`sets:write:guests`).
|
||||
- **Import** of an existing nftables ruleset (`hc import`): rules keep
|
||||
their order, defines name the sets, DNAT becomes port forwards, what
|
||||
can't be translated stays as raw rules, with notes; `hc takeover`
|
||||
deletes the old table, on probation (docs/import.md). DNAT maps and
|
||||
verdict maps are expanded into one port forward or rule per element.
|
||||
- **The `hc` CLI, the REST API and the web UI** (dashboard, rules per
|
||||
chain with hits and reordering, NAT, zones, sets, services, the log,
|
||||
settings, import, history with undo; the rule dialog keeps the less
|
||||
used parts, such as rate limits, quotas and time windows, behind "+"
|
||||
buttons), with users, roles, scoped API
|
||||
tokens, sessions, the audit log, and the web UI's certificate from
|
||||
Let's Encrypt through Jarl, as in Steward.
|
||||
- **Packaging:** Debian packages `housecarl` and `housecarl-cli`, a
|
||||
systemd unit whose only privilege is `CAP_NET_ADMIN`, daily backups.
|
||||
- **The Housecarl logo:** a carved Nordic doorway in Jarl's blue, with a
|
||||
simplified version for small sizes, lighter on dark backgrounds; and a
|
||||
HOUSECARL wordmark in Jarl's and Steward's angular letters.
|
||||
- **The skeleton:** the `housecarld` daemon and the `hc` CLI (both only
|
||||
print their version), the build, and the design sketch
|
||||
(docs/design.md).
|
||||
@@ -3,6 +3,12 @@
|
||||
# make build bin/housecarld and bin/hc (and their bash completion)
|
||||
# make test vet, gofmt check and unit tests
|
||||
# make logo rebuild the web UI's logo and the README banner from artwork/
|
||||
# make e2e end-to-end test with real traffic through a firewall in Docker (scripts/e2e.sh)
|
||||
# sudo make install install binaries, bash completion, systemd units,
|
||||
# sysusers/tmpfiles files and an example /etc/housecarl/housecarl.yaml
|
||||
# make deb Debian packages in dist/ (DEB_ARCH=arm64 for another
|
||||
# architecture): housecarl (the server) and housecarl-cli (hc,
|
||||
# which housecarl depends on)
|
||||
# make dist static binaries for linux/amd64 and linux/arm64 in dist/
|
||||
# make cli-dist the CLI alone for Linux, macOS and Windows in dist/
|
||||
# (housecarl-cli-<version>-<os>-<arch>[.exe])
|
||||
@@ -23,7 +29,32 @@ LDFLAGS := -s -w -X housecarl/internal/version.Version=$(VERSION) -X housecarl/i
|
||||
GOFLAGS := -trimpath
|
||||
export CGO_ENABLED := 0
|
||||
|
||||
.PHONY: all build completion test vet logo dist cli-dist clean
|
||||
PREFIX ?= /usr/local
|
||||
BINDIR ?= $(PREFIX)/bin
|
||||
DOCDIR ?= $(PREFIX)/share/doc/housecarl
|
||||
# bash-completion also looks in /usr/local/share (XDG_DATA_DIRS).
|
||||
BASHCOMPDIR ?= $(PREFIX)/share/bash-completion/completions
|
||||
UNITDIR ?= /etc/systemd/system
|
||||
SYSUSERSDIR ?= /usr/lib/sysusers.d
|
||||
TMPFILESDIR ?= /usr/lib/tmpfiles.d
|
||||
DESTDIR ?=
|
||||
|
||||
# The binaries install-files copies (make deb points these at other
|
||||
# architectures' builds).
|
||||
HOUSECARLD_BIN ?= bin/housecarld
|
||||
HC_BIN ?= bin/hc
|
||||
|
||||
# make deb: the package's architecture (Debian name) and version. A
|
||||
# release is VERSION with ~ for - (0.1.0-rc.1 -> 0.1.0~rc.1, which apt
|
||||
# sorts before 0.1.0). A development build is versioned after the last
|
||||
# tag (0.1.0~rc.1+git3.gca0f148); uncommitted changes add the build time.
|
||||
DEB_ARCH ?= $(shell dpkg --print-architecture 2>/dev/null || go env GOARCH)
|
||||
DEB_DEV_VERSION := $(shell [ -n '$(DESCRIBE)' ] || exit 0; \
|
||||
m=; git diff --quiet HEAD 2>/dev/null || m=.m$$(date -u +%Y%m%d%H%M%S); \
|
||||
echo '$(DESCRIBE)' | sed -E 's/^v(.*)-([0-9]+)-g([0-9a-f]{7})[0-9a-f]*$$/\1+git\2.g\3/' | tr - '~' | sed "s/$$/$$m/")
|
||||
DEB_VERSION ?= $(if $(RELEASE_TAG),$(subst -,~,$(VERSION)),$(or $(DEB_DEV_VERSION),$(subst -,~,$(VERSION))))
|
||||
|
||||
.PHONY: all build completion test vet logo e2e install install-files uninstall deb dist cli-dist clean
|
||||
|
||||
all: build
|
||||
|
||||
@@ -48,6 +79,55 @@ test: vet
|
||||
logo:
|
||||
python3 scripts/logo.py
|
||||
|
||||
e2e:
|
||||
scripts/e2e.sh
|
||||
|
||||
install: build install-files
|
||||
@if [ ! -e $(DESTDIR)/etc/housecarl/housecarl.yaml ]; then \
|
||||
install -d -m 0750 $(DESTDIR)/etc/housecarl; \
|
||||
bin/housecarld config --example > $(DESTDIR)/etc/housecarl/housecarl.yaml; \
|
||||
chmod 0640 $(DESTDIR)/etc/housecarl/housecarl.yaml; \
|
||||
echo "wrote $(DESTDIR)/etc/housecarl/housecarl.yaml"; \
|
||||
fi
|
||||
@echo
|
||||
@echo "Next (as root): systemd-sysusers housecarl.conf && systemd-tmpfiles --create housecarl.conf"
|
||||
@echo " chgrp housecarl /etc/housecarl /etc/housecarl/housecarl.yaml && systemctl daemon-reload"
|
||||
@echo " systemctl enable housecarl-ruleset.service"
|
||||
@echo "Then follow docs/install.md (init, import your ruleset, start)."
|
||||
|
||||
# Everything but /etc/housecarl/housecarl.yaml (shared by install and deb).
|
||||
install-files:
|
||||
install -D -m 0755 $(HOUSECARLD_BIN) $(DESTDIR)$(BINDIR)/housecarld
|
||||
install -D -m 0755 $(HC_BIN) $(DESTDIR)$(BINDIR)/hc
|
||||
install -D -m 0644 bin/completion/hc $(DESTDIR)$(BASHCOMPDIR)/hc
|
||||
install -D -m 0644 bin/completion/housecarld $(DESTDIR)$(BASHCOMPDIR)/housecarld
|
||||
for u in housecarl.service housecarl-ruleset.service housecarl-backup.service housecarl-backup.timer; do \
|
||||
install -D -m 0644 deploy/systemd/$$u $(DESTDIR)$(UNITDIR)/$$u && \
|
||||
sed -i 's#/usr/local/bin/housecarld#$(BINDIR)/housecarld#; s#/usr/local/share/doc/housecarl#$(DOCDIR)#' $(DESTDIR)$(UNITDIR)/$$u || exit 1; \
|
||||
done
|
||||
install -D -m 0644 deploy/sysusers.d/housecarl.conf $(DESTDIR)$(SYSUSERSDIR)/housecarl.conf
|
||||
install -D -m 0644 deploy/tmpfiles.d/housecarl.conf $(DESTDIR)$(TMPFILESDIR)/housecarl.conf
|
||||
install -D -m 0644 README.md $(DESTDIR)$(DOCDIR)/README.md
|
||||
install -D -m 0644 LICENSE $(DESTDIR)$(DOCDIR)/LICENSE
|
||||
install -D -m 0644 CHANGELOG.md $(DESTDIR)$(DOCDIR)/CHANGELOG.md
|
||||
install -d $(DESTDIR)$(DOCDIR)/docs
|
||||
install -m 0644 docs/*.md $(DESTDIR)$(DOCDIR)/docs/
|
||||
|
||||
uninstall:
|
||||
rm -f $(DESTDIR)$(BINDIR)/housecarld $(DESTDIR)$(BINDIR)/hc $(DESTDIR)$(UNITDIR)/housecarl.service \
|
||||
$(DESTDIR)$(UNITDIR)/housecarl-ruleset.service \
|
||||
$(DESTDIR)$(UNITDIR)/housecarl-backup.service $(DESTDIR)$(UNITDIR)/housecarl-backup.timer \
|
||||
$(DESTDIR)$(BASHCOMPDIR)/hc $(DESTDIR)$(BASHCOMPDIR)/housecarld \
|
||||
$(DESTDIR)$(SYSUSERSDIR)/housecarl.conf $(DESTDIR)$(TMPFILESDIR)/housecarl.conf
|
||||
rm -rf $(DESTDIR)$(DOCDIR)
|
||||
@echo "Kept /etc/housecarl, /var/lib/housecarl and /var/backups/housecarl. The ruleset in force stays until the next boot."
|
||||
|
||||
# Debian packages, built with dpkg-deb (no root needed); scripts/mkdeb.sh
|
||||
# explains the layout.
|
||||
deb: build
|
||||
DEB_ARCH='$(DEB_ARCH)' DEB_VERSION='$(DEB_VERSION)' VERSION='$(VERSION)' \
|
||||
LDFLAGS='$(LDFLAGS)' GOFLAGS='$(GOFLAGS)' scripts/mkdeb.sh
|
||||
|
||||
dist: build
|
||||
for arch in amd64 arm64; do \
|
||||
GOOS=linux GOARCH=$$arch go build $(GOFLAGS) -ldflags '$(LDFLAGS)' -o dist/housecarl-$(VERSION)-linux-$$arch/housecarld ./cmd/housecarld && \
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
*Every Jarl has a housecarl at his side; this one keeps the door.*
|
||||
|
||||
Housecarl will be a firewall for Linux that you manage through a web UI, a
|
||||
Housecarl is a firewall for Linux that you manage through a web UI, a
|
||||
REST API and a command line client (`hc`), all three backed by the same
|
||||
API. It is the third of a set with [Jarl](https://gitea.adbraun.lol/adam/jarl),
|
||||
the BIND manager, and [Steward](https://gitea.adbraun.lol/adam/steward), the
|
||||
@@ -17,24 +17,107 @@ door and decides who comes in. Housecarl does that for your network. It
|
||||
keeps the machine's nftables ruleset, built from its own database, in one
|
||||
table of its own.
|
||||
|
||||
## What it will do
|
||||
## What it does
|
||||
|
||||
* **Zones** for your interfaces and VLANs (`lan`, `iot`, `guest`, `wan`),
|
||||
with a default policy between each pair, and rules between them.
|
||||
* **Address sets** that rules refer to, whose entries can expire by
|
||||
themselves; another program can be allowed to change just one set. That
|
||||
is how Steward's captive portal will let a guest in.
|
||||
* **NAT**: masquerading and port forwards.
|
||||
* **Changes you can trust**: validated as a whole, checked by nftables
|
||||
before they are applied, applied in one transaction, and undoable. A
|
||||
change that could lock you out is rolled back unless you confirm it.
|
||||
* **Zones, sets and services, then rules.** Name your interfaces (`wan`,
|
||||
`lan`, `wg`), your addresses (`trusted`, IPv4 and IPv6 in one set) and
|
||||
your ports (`web`, or the built-in `ssh`, `dns`, `https`…), and
|
||||
write ordered rules with them: in, out or through the firewall, by
|
||||
zone, address, service, connection state, time of day, rate per
|
||||
source, or connection count. Anything else can be written as an
|
||||
nftables rule by hand.
|
||||
* **Bandwidth and quotas.** Byte-rate limits per device, and traffic
|
||||
allowances that reset daily, weekly or monthly (or by hand), counted
|
||||
across changes and reboots.
|
||||
* **IPv4 and IPv6 together.** One rule covers both; Housecarl writes the
|
||||
nftables rules each family needs.
|
||||
* **NAT in one object.** A port forward is the DNAT, the forward accept
|
||||
and the hairpin NAT together, and can open the same port to an IPv6
|
||||
host. Masquerading, SNAT and redirects too.
|
||||
* **The rules every firewall needs**, built in and each one switchable:
|
||||
loopback, established connections, invalid packets, the ICMPv6 that
|
||||
IPv6 needs, antispoofing for your prefixes, MSS clamping for PPPoE, the
|
||||
DHCPv6 client, and a flowtable fast path.
|
||||
* **Changes you can trust.** Each change is validated as a whole, checked
|
||||
by nftables itself, and applied in one transaction. As in Jarl, edits
|
||||
can be collected in a changeset, reviewed with the diff of the ruleset,
|
||||
and applied (or discarded) together. A change made over
|
||||
the network is on probation: unless you confirm it within two minutes,
|
||||
it rolls itself back, so a rule that cuts you off undoes itself.
|
||||
* **Your existing ruleset, imported.** `hc import` turns an nftables
|
||||
ruleset into zones, sets, services, rules and port forwards, keeping the
|
||||
rules' order, and keeps what it can't translate as raw rules
|
||||
([docs/import.md](docs/import.md)).
|
||||
* **Easy to watch.** Hits per rule, a live log of what was dropped and
|
||||
why, and the history of every change, field by field, with undo.
|
||||
* **Its own table only.** Docker, Incus and libvirt keep theirs. The
|
||||
confirmed ruleset is loaded at boot, before the network comes up, even
|
||||
if housecarld doesn't start.
|
||||
* **Sets other programs can fill.** A dynamic set's entries expire by
|
||||
themselves, and a token can be limited to one set: that is how
|
||||
Steward's captive portal will let a guest in.
|
||||
* **Users, tokens and HTTPS** as in Jarl and Steward, with the web UI's
|
||||
certificate from Let's Encrypt through Jarl
|
||||
([docs/certificates.md](docs/certificates.md)).
|
||||
|
||||
See [docs/design.md](docs/design.md) for the plan and the open questions.
|
||||
Not yet: rules by MAC address or DNS name, and iptables import
|
||||
([docs/design.md](docs/design.md)).
|
||||
|
||||
## Quick start
|
||||
|
||||
On Debian 13, from the package registry (see
|
||||
[docs/install.md](docs/install.md) for other ways):
|
||||
|
||||
```sh
|
||||
sudo apt install housecarl
|
||||
sudo -u housecarl housecarld init # asks for the admin password
|
||||
sudo systemctl enable --now housecarl # applies nothing until configured
|
||||
|
||||
# Import the ruleset you have...
|
||||
sudo hc import --defines /etc/nftables.conf -n # look first
|
||||
sudo hc import --defines /etc/nftables.conf
|
||||
sudo hc takeover "inet main"
|
||||
sudo systemctl disable nftables.service
|
||||
|
||||
# ...or start from scratch: let yourself in first
|
||||
sudo hc set add admins 192.168.1.0/24
|
||||
sudo hc rule add input --from admins --service ssh
|
||||
sudo hc zone add wan --interface ppp0 --external
|
||||
sudo hc zone add lan --interface eth1
|
||||
sudo hc rule add forward --in lan --out wan
|
||||
sudo hc snat add --out wan
|
||||
sudo hc status
|
||||
```
|
||||
|
||||
The web UI is on `http://127.0.0.1:8070/` (see `listen` in
|
||||
`/etc/housecarl/housecarl.yaml`); write a rule that lets you reach it.
|
||||
|
||||
## Documentation
|
||||
|
||||
* [install.md](docs/install.md): packages, building from source, first steps
|
||||
* [rules.md](docs/rules.md): zones, address sets, services, rules and the built-ins
|
||||
* [nat.md](docs/nat.md): port forwards, hairpin NAT, IPv6 pinholes, source NAT, redirects
|
||||
* [import.md](docs/import.md): moving from an existing nftables ruleset
|
||||
* [operations.md](docs/operations.md): probation, the boot, hits and the log, backups, troubleshooting
|
||||
* [certificates.md](docs/certificates.md): HTTPS with a certificate from Let's Encrypt, through Jarl
|
||||
* [jarl.md](docs/jarl.md): connecting Housecarl to Jarl, and what its token may do
|
||||
* [cli.md](docs/cli.md): the `hc` command line client
|
||||
* [api.md](docs/api.md): the REST API
|
||||
* [configuration.md](docs/configuration.md): `/etc/housecarl/housecarl.yaml`
|
||||
* [permissions.md](docs/permissions.md): users, roles, grants and API tokens
|
||||
* [validation.md](docs/validation.md): every check and what it means
|
||||
* [design.md](docs/design.md): how Housecarl is built, and what 0.1 leaves out
|
||||
* [development.md](docs/development.md): building, testing, releasing
|
||||
|
||||
## Status
|
||||
|
||||
Housecarl is at its start: it has its name, its logo and the skeleton of
|
||||
the daemon (`housecarld`) and CLI (`hc`). There is nothing to run yet.
|
||||
Housecarl 0.1.0 is in testing (0.1.0-rc.3): zones, address sets and
|
||||
services with ordered rules, IPv4 and IPv6, port forwards with hairpin NAT
|
||||
and IPv6 pinholes, source NAT and redirects, built-ins, probation for
|
||||
network changes (confirmed by the client that made them), the ruleset
|
||||
at boot, hits and the drop log, nftables
|
||||
import, changesets reviewed with the ruleset's diff (as in Jarl),
|
||||
bandwidth limits and quotas, a web UI, REST API and CLI.
|
||||
|
||||
## Licence
|
||||
|
||||
|
||||
+630
@@ -0,0 +1,630 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/term"
|
||||
|
||||
"housecarl/internal/api"
|
||||
"housecarl/internal/model"
|
||||
)
|
||||
|
||||
func historyCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{Use: "history", Short: "The history of configuration changes, and undo"}
|
||||
var limit int
|
||||
list := &cobra.Command{
|
||||
Use: "list", Short: "List recent changes", Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var hs []model.HistoryEntry
|
||||
if err := c.Get(ctx(), "/history", q("limit", strconv.Itoa(limit)), &hs); err != nil {
|
||||
return err
|
||||
}
|
||||
return output(hs, func(w io.Writer) {
|
||||
fmt.Fprintln(w, "ID\tWHEN\tBY\tVIA\tCHANGES\tDESCRIPTION")
|
||||
for _, h := range hs {
|
||||
desc := h.Description
|
||||
if h.RevertedBy != 0 {
|
||||
desc += fmt.Sprintf(" (undone by %d)", h.RevertedBy)
|
||||
}
|
||||
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%d\t%s\n", h.ID, h.Time.Local().Format("2006-01-02 15:04"), h.Username, h.Via, len(h.Changes), desc)
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
list.Flags().IntVar(&limit, "limit", 30, "how many")
|
||||
cmd.AddCommand(list)
|
||||
cmd.AddCommand(&cobra.Command{
|
||||
Use: "show ID", Short: "Show a change: each object before and after", Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var h model.HistoryEntry
|
||||
if err := c.Get(ctx(), "/history/"+args[0], nil, &h); err != nil {
|
||||
return err
|
||||
}
|
||||
return output(h, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "%d\t%s by %s (%s): %s\n", h.ID, h.Time.Local().Format("2006-01-02 15:04:05"), h.Username, h.Via, h.Description)
|
||||
for _, ch := range h.Changes {
|
||||
fmt.Fprintf(w, "\n%s %s %s\n", ch.Action(), ch.Kind, ch.Label)
|
||||
for _, line := range diffJSON(ch.Before, ch.After) {
|
||||
fmt.Fprintln(w, " "+line)
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
cmd.AddCommand(&cobra.Command{
|
||||
Use: "undo ID",
|
||||
Short: "Undo a change",
|
||||
Long: `Undoes a change by applying it backwards. It is refused when an object the
|
||||
change touched has changed since; undo the later changes first.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var resp api.MutationResponse
|
||||
if err := c.Do(ctx(), "POST", "/history/"+args[0]+"/revert", nil, nil, &resp); err != nil {
|
||||
return err
|
||||
}
|
||||
return report(&resp)
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
// diffJSON lists the fields that differ between two versions of an
|
||||
// object: "name: office -> lab".
|
||||
func diffJSON(before, after json.RawMessage) []string {
|
||||
var a, b map[string]any
|
||||
json.Unmarshal(before, &a)
|
||||
json.Unmarshal(after, &b)
|
||||
keys := map[string]bool{}
|
||||
for k := range a {
|
||||
keys[k] = true
|
||||
}
|
||||
for k := range b {
|
||||
keys[k] = true
|
||||
}
|
||||
var names []string
|
||||
for k := range keys {
|
||||
names = append(names, k)
|
||||
}
|
||||
sort.Strings(names)
|
||||
var out []string
|
||||
show := func(v any, ok bool) string {
|
||||
if !ok {
|
||||
return "-"
|
||||
}
|
||||
x, _ := json.Marshal(v)
|
||||
return string(x)
|
||||
}
|
||||
for _, k := range names {
|
||||
va, oka := a[k]
|
||||
vb, okb := b[k]
|
||||
sa, sb := show(va, oka), show(vb, okb)
|
||||
if sa == sb {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case before == nil:
|
||||
out = append(out, k+": "+sb)
|
||||
case after == nil:
|
||||
out = append(out, k+": "+sa)
|
||||
default:
|
||||
out = append(out, k+": "+sa+" -> "+sb)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func whoamiCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "whoami", Short: "Show who you are to the server", Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var me api.Whoami
|
||||
if err := c.Get(ctx(), "/auth/whoami", nil, &me); err != nil {
|
||||
return err
|
||||
}
|
||||
return output(me, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "User:\t%s (%s)\n", me.User.Username, me.Method)
|
||||
fmt.Fprintf(w, "Administrator:\t%v\n", me.Admin)
|
||||
fmt.Fprintf(w, "May:\t%s\n", strings.Join(me.Permissions, ", "))
|
||||
fmt.Fprintf(w, "Server:\tHousecarl %s\n", me.Build.String())
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func readSecret(prompt string) (string, error) {
|
||||
fmt.Fprint(os.Stderr, prompt)
|
||||
if term.IsTerminal(int(os.Stdin.Fd())) {
|
||||
b, err := term.ReadPassword(int(os.Stdin.Fd()))
|
||||
fmt.Fprintln(os.Stderr)
|
||||
return strings.TrimSpace(string(b)), err
|
||||
}
|
||||
var s string
|
||||
_, err := fmt.Fscanln(os.Stdin, &s)
|
||||
return strings.TrimSpace(s), err
|
||||
}
|
||||
|
||||
func userID(name string) (int64, error) {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var us []api.User
|
||||
if err := c.Get(ctx(), "/users", nil, &us); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for _, u := range us {
|
||||
if strings.EqualFold(u.Username, name) || strconv.FormatInt(u.ID, 10) == name {
|
||||
return u.ID, nil
|
||||
}
|
||||
}
|
||||
return 0, fmt.Errorf("no user %s", name)
|
||||
}
|
||||
|
||||
func userCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{Use: "user", Aliases: []string{"users"}, Short: "Users (administrators only)"}
|
||||
cmd.AddCommand(&cobra.Command{
|
||||
Use: "list", Short: "List users", Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var us []api.User
|
||||
if err := c.Get(ctx(), "/users", nil, &us); err != nil {
|
||||
return err
|
||||
}
|
||||
return output(us, func(w io.Writer) {
|
||||
fmt.Fprintln(w, "ID\tUSER\tADMIN\tGRANTS\tPASSWORD\tDISABLED\tLAST LOGIN")
|
||||
for _, u := range us {
|
||||
login := "-"
|
||||
if u.LastLoginAt != nil {
|
||||
login = fmtWhen(*u.LastLoginAt)
|
||||
}
|
||||
fmt.Fprintf(w, "%d\t%s\t%v\t%s\t%v\t%v\t%s\n", u.ID, u.Username, u.Admin, orDash(strings.Join(u.GrantStrings, " ")), u.HasPassword, u.Disabled, login)
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
var admin, noPassword bool
|
||||
var role, comment string
|
||||
var grants []string
|
||||
add := &cobra.Command{
|
||||
Use: "add USER",
|
||||
Short: "Add a user (asks for the password)",
|
||||
Long: `Adds a user. Without --admin, give what they may do: a role (viewer,
|
||||
operator, log) or grants such as --grant config:read --grant sets:write:guests.
|
||||
--no-password makes a service account that only uses API tokens.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
in := api.UserInput{Username: &args[0], Admin: &admin}
|
||||
if !noPassword {
|
||||
pw, err := readSecret("Password for " + args[0] + ": ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
in.Password = &pw
|
||||
}
|
||||
if role != "" {
|
||||
in.Role = &role
|
||||
}
|
||||
if grants != nil {
|
||||
in.Grants = &grants
|
||||
}
|
||||
if comment != "" {
|
||||
in.Comment = &comment
|
||||
}
|
||||
var u api.User
|
||||
if err := c.Do(ctx(), "POST", "/users", nil, in, &u); err != nil {
|
||||
return err
|
||||
}
|
||||
if !flagQuiet {
|
||||
fmt.Printf("added user %s (%d)\n", u.Username, u.ID)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
f := add.Flags()
|
||||
f.BoolVar(&admin, "admin", false, "an administrator (may do everything)")
|
||||
f.StringVar(&role, "role", "", "a role: viewer, operator or log")
|
||||
f.StringArrayVar(&grants, "grant", nil, "a grant, resource:access (config, sets, log, audit; read or write; sets:write:SET,SET)")
|
||||
f.BoolVar(&noPassword, "no-password", false, "no password: API tokens only")
|
||||
f.StringVar(&comment, "comment", "", "a comment")
|
||||
cmd.AddCommand(add)
|
||||
|
||||
var setPassword, disable, enable bool
|
||||
update := &cobra.Command{
|
||||
Use: "update USER", Short: "Change a user", Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
id, err := userID(args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c, _ := newClient()
|
||||
in := api.UserInput{}
|
||||
fl := cmd.Flags()
|
||||
if fl.Changed("admin") {
|
||||
in.Admin = &admin
|
||||
}
|
||||
if fl.Changed("role") {
|
||||
in.Role = &role
|
||||
}
|
||||
if fl.Changed("grant") {
|
||||
in.Grants = &grants
|
||||
}
|
||||
if fl.Changed("comment") {
|
||||
in.Comment = &comment
|
||||
}
|
||||
if disable || enable {
|
||||
in.Disabled = &disable
|
||||
}
|
||||
if setPassword {
|
||||
pw, err := readSecret("New password for " + args[0] + ": ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
in.Password = &pw
|
||||
}
|
||||
var u api.User
|
||||
if err := c.Do(ctx(), "PATCH", "/users/"+strconv.FormatInt(id, 10), nil, in, &u); err != nil {
|
||||
return err
|
||||
}
|
||||
if !flagQuiet {
|
||||
fmt.Printf("updated user %s\n", u.Username)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
f = update.Flags()
|
||||
f.BoolVar(&admin, "admin", false, "make (or with --admin=false, unmake) an administrator")
|
||||
f.StringVar(&role, "role", "", "replace the grants with a role's")
|
||||
f.StringArrayVar(&grants, "grant", nil, "replace the grants (repeat for several)")
|
||||
f.BoolVar(&setPassword, "password", false, "set a new password (asks for it)")
|
||||
f.BoolVar(&disable, "disable", false, "disable the user (logs them out)")
|
||||
f.BoolVar(&enable, "enable", false, "enable the user again")
|
||||
f.StringVar(&comment, "comment", "", "a comment")
|
||||
cmd.AddCommand(update)
|
||||
cmd.AddCommand(&cobra.Command{
|
||||
Use: "delete USER", Short: "Delete a user and their tokens", Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
id, err := userID(args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c, _ := newClient()
|
||||
if err := c.Do(ctx(), "DELETE", "/users/"+strconv.FormatInt(id, 10), nil, nil, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
if !flagQuiet {
|
||||
fmt.Println("deleted user", args[0])
|
||||
}
|
||||
return nil
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
func tokenCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{Use: "token", Aliases: []string{"tokens"}, Short: "API tokens"}
|
||||
var all bool
|
||||
var user string
|
||||
list := &cobra.Command{
|
||||
Use: "list", Short: "List your tokens (--all: everyone's, for administrators)", Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path := "/me/tokens"
|
||||
if all {
|
||||
path = "/tokens"
|
||||
}
|
||||
var ts []api.Token
|
||||
if err := c.Get(ctx(), path, nil, &ts); err != nil {
|
||||
return err
|
||||
}
|
||||
return output(ts, func(w io.Writer) {
|
||||
fmt.Fprintln(w, "ID\tUSER\tNAME\tGRANTS\tEXPIRES\tLAST USED")
|
||||
for _, t := range ts {
|
||||
exp, used := "never", "never"
|
||||
if t.ExpiresAt != nil {
|
||||
exp = fmtWhen(*t.ExpiresAt)
|
||||
}
|
||||
if t.LastUsedAt != nil {
|
||||
used = fmtWhen(*t.LastUsedAt) + " from " + t.LastUsedIP
|
||||
}
|
||||
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\t%s\n", t.ID, t.Username, t.Name, strings.Join(t.GrantStrings, " "), exp, used)
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
list.Flags().BoolVar(&all, "all", false, "everyone's tokens")
|
||||
cmd.AddCommand(list)
|
||||
var role, expires string
|
||||
var grants []string
|
||||
var save bool
|
||||
create := &cobra.Command{
|
||||
Use: "create NAME",
|
||||
Short: "Make an API token (the secret is shown once)",
|
||||
Long: `Makes an API token. It has all of its user's permissions unless --role or
|
||||
--grant narrows it. On the server as root, --user names whose token it is.
|
||||
--save puts it in the CLI's configuration.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path := "/me/tokens"
|
||||
if user != "" {
|
||||
id, err := userID(user)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path = "/users/" + strconv.FormatInt(id, 10) + "/tokens"
|
||||
}
|
||||
in := api.TokenInput{Name: args[0], Role: role, Grants: grants, ExpiresIn: expires}
|
||||
var t api.Token
|
||||
if err := c.Do(ctx(), "POST", path, nil, in, &t); err != nil {
|
||||
return err
|
||||
}
|
||||
if save {
|
||||
cfg, err := loadCLIConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.Token = t.Secret
|
||||
if err := saveCLIConfig(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, "saved in", flagConfig)
|
||||
}
|
||||
if flagOutput == "json" || flagOutput == "yaml" {
|
||||
return output(t, nil)
|
||||
}
|
||||
fmt.Println(t.Secret)
|
||||
if !flagQuiet {
|
||||
fmt.Fprintln(os.Stderr, "this is the only time the token is shown; keep it safe")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
f := create.Flags()
|
||||
f.StringVar(&role, "role", "", "narrow it to a role: viewer, operator or log")
|
||||
f.StringArrayVar(&grants, "grant", nil, "narrow it to grants (resource:access)")
|
||||
f.StringVar(&expires, "expires", "", "expire after this long (90d, 720h)")
|
||||
f.StringVar(&user, "user", "", "the user it belongs to (administrators)")
|
||||
f.BoolVar(&save, "save", false, "save it as this CLI's token")
|
||||
cmd.AddCommand(create)
|
||||
var grace, rexp string
|
||||
var current, rsave bool
|
||||
rotate := &cobra.Command{
|
||||
Use: "rotate [ID]",
|
||||
Short: "Give a token a new secret (shown once)",
|
||||
Long: `Gives a token a new secret. It keeps its ID, name and permissions, so only
|
||||
the programs that use it need the new secret. The old secret stops at once,
|
||||
or after --grace (up to 7d) so you can update them without an outage. A
|
||||
token that expires gets its lifetime again, from now, unless --expires says
|
||||
otherwise (90d, or never).
|
||||
|
||||
hc token rotate --current --save # this CLI's own token`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id := ""
|
||||
switch {
|
||||
case current && len(args) == 0:
|
||||
var me api.Whoami
|
||||
if err := c.Get(ctx(), "/auth/whoami", nil, &me); err != nil {
|
||||
return err
|
||||
}
|
||||
if me.TokenID == 0 {
|
||||
return fmt.Errorf("this CLI isn't using a token (%s); give the token's ID", me.Method)
|
||||
}
|
||||
id = strconv.FormatInt(me.TokenID, 10)
|
||||
case len(args) == 1 && !current:
|
||||
id = args[0]
|
||||
default:
|
||||
return fmt.Errorf("give the token's ID, or --current")
|
||||
}
|
||||
body := api.TokenRotation{Grace: grace}
|
||||
switch rexp {
|
||||
case "":
|
||||
case "never":
|
||||
body.NoExpiry = true
|
||||
default:
|
||||
body.ExpiresIn = rexp
|
||||
}
|
||||
var t api.Token
|
||||
if err := c.Do(ctx(), "POST", "/tokens/"+id+"/rotate", nil, body, &t); err != nil {
|
||||
return err
|
||||
}
|
||||
if rsave {
|
||||
cfg, err := loadCLIConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.Token = t.Secret
|
||||
if err := saveCLIConfig(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, "saved in", flagConfig)
|
||||
}
|
||||
if flagOutput == "json" || flagOutput == "yaml" {
|
||||
return output(t, nil)
|
||||
}
|
||||
fmt.Println(t.Secret)
|
||||
if !flagQuiet {
|
||||
if t.PrevExpiresAt != nil {
|
||||
fmt.Fprintf(os.Stderr, "the old secret works until %s\n", t.PrevExpiresAt.Local().Format("2006-01-02 15:04"))
|
||||
} else {
|
||||
fmt.Fprintln(os.Stderr, "the old secret no longer works")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
f = rotate.Flags()
|
||||
f.StringVar(&grace, "grace", "", "keep the old secret working this long (30m, 24h, up to 7d)")
|
||||
f.StringVar(&rexp, "expires", "", "a new lifetime from now (90d), or never")
|
||||
f.BoolVar(¤t, "current", false, "rotate the token this CLI uses")
|
||||
f.BoolVar(&rsave, "save", false, "save the new secret as this CLI's token")
|
||||
cmd.AddCommand(rotate)
|
||||
cmd.AddCommand(&cobra.Command{
|
||||
Use: "revoke ID", Short: "Revoke a token", Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.Do(ctx(), "DELETE", "/tokens/"+args[0], nil, nil, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
if !flagQuiet {
|
||||
fmt.Println("revoked token", args[0])
|
||||
}
|
||||
return nil
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
func passwordCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "password", Short: "Change your own password", Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cur, err := readSecret("Current password: ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pw, err := readSecret("New password: ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.Do(ctx(), "POST", "/me/password", nil, api.PasswordChange{Current: cur, Password: pw}, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println("password changed")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func auditCmd() *cobra.Command {
|
||||
var user, action, search string
|
||||
var limit int
|
||||
cmd := &cobra.Command{
|
||||
Use: "audit", Short: "Show the audit log: logins, users, tokens, changes, confirmations, set entries", Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var evs []model.AuditEvent
|
||||
if err := c.Get(ctx(), "/audit", q("user", user, "action", action, "q", search, "limit", strconv.Itoa(limit)), &evs); err != nil {
|
||||
return err
|
||||
}
|
||||
return output(evs, func(w io.Writer) {
|
||||
fmt.Fprintln(w, "WHEN\tUSER\tFROM\tACTION\tOBJECT\tRESULT\tDETAIL")
|
||||
for _, e := range evs {
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", e.Time.Local().Format("2006-01-02 15:04:05"), orDash(e.Username), orDash(e.IP), e.Action, orDash(e.Object), e.Result, e.Detail)
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
f := cmd.Flags()
|
||||
f.StringVar(&user, "user", "", "only this user's")
|
||||
f.StringVar(&action, "action", "", "only actions starting with this (auth., config., set.)")
|
||||
f.StringVar(&search, "search", "", "only events whose object or detail contains this")
|
||||
f.IntVar(&limit, "limit", 100, "how many")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func configCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{Use: "config", Short: "This CLI's settings: the server and token"}
|
||||
cmd.AddCommand(&cobra.Command{
|
||||
Use: "show", Short: "Show the CLI's configuration", Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cfg, err := loadCLIConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tok := "-"
|
||||
if cfg.Token != "" {
|
||||
tok = cfg.Token[:min(len(cfg.Token), 24)] + "…"
|
||||
}
|
||||
fmt.Printf("file: %s\nserver: %s\ntoken: %s\ninsecure: %v\nca_cert: %s\n", flagConfig, orDash(cfg.Server)+" (empty: the local socket)", tok, cfg.Insecure, orDash(cfg.CACert))
|
||||
return nil
|
||||
},
|
||||
})
|
||||
cmd.AddCommand(&cobra.Command{
|
||||
Use: "set KEY [VALUE]",
|
||||
Short: "Set server, token (asks when no value), insecure or ca_cert",
|
||||
Args: cobra.RangeArgs(1, 2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cfg, err := loadCLIConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
val := ""
|
||||
if len(args) == 2 {
|
||||
val = args[1]
|
||||
} else if args[0] == "token" {
|
||||
if val, err = readSecret("API token: "); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
switch args[0] {
|
||||
case "server":
|
||||
cfg.Server = val
|
||||
case "token":
|
||||
cfg.Token = val
|
||||
case "insecure":
|
||||
cfg.Insecure = val == "true" || val == "yes" || val == "on"
|
||||
case "ca_cert", "ca-cert":
|
||||
cfg.CACert = val
|
||||
default:
|
||||
return fmt.Errorf("unknown setting %q (use server, token, insecure or ca_cert)", args[0])
|
||||
}
|
||||
if err := saveCLIConfig(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, "saved in", flagConfig)
|
||||
return nil
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"housecarl/internal/api"
|
||||
)
|
||||
|
||||
func certCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "cert",
|
||||
Aliases: []string{"certificate"},
|
||||
Short: "The web UI's certificate, obtained through Jarl (administrators)",
|
||||
Long: `Shows the certificate housecarld obtains itself for its web UI (tls.acme in
|
||||
housecarl.yaml, with Jarl publishing the DNS-01 challenge; docs/certificates.md).
|
||||
'check' tests the whole path without asking the CA; 'renew' gets a new
|
||||
certificate now.`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error { return certStatus() },
|
||||
}
|
||||
cmd.AddCommand(&cobra.Command{Use: "status", Short: "Show the certificate", Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error { return certStatus() }})
|
||||
cmd.AddCommand(&cobra.Command{
|
||||
Use: "check",
|
||||
Short: "Check that the challenge can be published and seen, without the CA",
|
||||
Long: `Runs the check housecarld runs before every certificate order: Jarl answers
|
||||
and knows the token, a zone holds _acme-challenge.DOMAIN, the token may
|
||||
write it, a test record is written, the zone's name servers and the public
|
||||
resolvers serve it, and it is removed again. It can take a minute or two.`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.HTTP.Timeout = 5 * time.Minute
|
||||
if !flagQuiet && flagOutput == "table" {
|
||||
fmt.Fprintln(os.Stderr, "checking (this can take a minute or two)…")
|
||||
}
|
||||
var ch api.CertCheck
|
||||
if err := c.Do(ctx(), "POST", "/certificate/check", nil, nil, &ch); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := output(ch, func(w io.Writer) { printCheck(w, &ch) }); err != nil {
|
||||
return err
|
||||
}
|
||||
if !ch.OK {
|
||||
return fmt.Errorf("the check failed")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
})
|
||||
var wait bool
|
||||
renew := &cobra.Command{
|
||||
Use: "renew",
|
||||
Short: "Get a new certificate now (the check runs first)",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var r api.CertificateResponse
|
||||
if err := c.Do(ctx(), "POST", "/certificate/renew", nil, nil, &r); err != nil {
|
||||
return err
|
||||
}
|
||||
if !wait {
|
||||
fmt.Println("renewing in the background; follow it with 'hc cert'")
|
||||
return nil
|
||||
}
|
||||
start := time.Now()
|
||||
for {
|
||||
time.Sleep(3 * time.Second)
|
||||
if err := c.Get(ctx(), "/certificate", nil, &r); err != nil {
|
||||
return err
|
||||
}
|
||||
st := r.Status
|
||||
if st.State != "working" && st.LastAttempt != nil && !st.LastAttempt.Before(start.Add(-time.Second)) {
|
||||
break
|
||||
}
|
||||
}
|
||||
if err := certStatus(); err != nil {
|
||||
return err
|
||||
}
|
||||
if r.Status.LastError != "" {
|
||||
return fmt.Errorf("renewing failed")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
renew.Flags().BoolVarP(&wait, "wait", "w", false, "wait until it is done")
|
||||
cmd.AddCommand(renew)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func certStatus() error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var r api.CertificateResponse
|
||||
if err := c.Get(ctx(), "/certificate", nil, &r); err != nil {
|
||||
return err
|
||||
}
|
||||
return output(r, func(w io.Writer) {
|
||||
switch r.Source {
|
||||
case "file":
|
||||
fmt.Fprintln(w, "Source:\ttls.cert_file (renewed by you)")
|
||||
return
|
||||
case "none":
|
||||
fmt.Fprintln(w, "Source:\tnone: the web UI is plain HTTP (set tls.acme or tls.cert_file in housecarl.yaml; docs/certificates.md)")
|
||||
return
|
||||
}
|
||||
st := r.Status
|
||||
fmt.Fprintf(w, "Domain:\t%s\n", st.Domain)
|
||||
ca := st.Directory
|
||||
if st.Staging {
|
||||
ca += " (staging: browsers don't trust it)"
|
||||
}
|
||||
fmt.Fprintf(w, "CA:\t%s\n", ca)
|
||||
fmt.Fprintf(w, "State:\t%s\n", st.State)
|
||||
if st.NotAfter != nil {
|
||||
fmt.Fprintf(w, "Valid:\t%s to %s, from %s\n", st.NotBefore.Local().Format("2006-01-02"), st.NotAfter.Local().Format("2006-01-02 15:04"), st.Issuer)
|
||||
fmt.Fprintf(w, "Renews:\t%s\n", st.RenewAt.Local().Format("2006-01-02"))
|
||||
}
|
||||
if st.LastAttempt != nil {
|
||||
fmt.Fprintf(w, "Last attempt:\t%s\n", st.LastAttempt.Local().Format("2006-01-02 15:04"))
|
||||
}
|
||||
if st.NextAttempt != nil && st.State != "working" {
|
||||
fmt.Fprintf(w, "Next check:\t%s\n", st.NextAttempt.Local().Format("2006-01-02 15:04"))
|
||||
}
|
||||
if st.LastError != "" {
|
||||
fmt.Fprintf(w, "Error:\t%s\n", st.LastError)
|
||||
}
|
||||
if st.Check != nil {
|
||||
fmt.Fprintf(w, "\nLast check, %s:\n", st.Check.Time.Local().Format("2006-01-02 15:04"))
|
||||
printCheck(w, st.Check)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func printCheck(w io.Writer, c *api.CertCheck) {
|
||||
for _, s := range c.Steps {
|
||||
mark := map[string]string{"ok": "ok ", "fail": "FAIL", "warn": "warn", "skip": "skip"}[s.Status]
|
||||
fmt.Fprintf(w, " %s\t%s\t%s\n", mark, s.Name, s.Detail)
|
||||
if s.Status == "fail" && len(s.Queries) > 0 {
|
||||
for _, q := range s.Queries {
|
||||
fmt.Fprintf(w, " \t\t%s\n", strings.TrimSpace(q))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"housecarl/internal/api"
|
||||
"housecarl/internal/client"
|
||||
"housecarl/internal/model"
|
||||
)
|
||||
|
||||
// Changesets: configuration changes staged to be reviewed and applied
|
||||
// together. -C/--changeset stages into a changeset as given (housecarld
|
||||
// still refuses one that isn't open, or another user's unless you are an
|
||||
// administrator). One from HOUSECARL_CHANGESET is checked first: an
|
||||
// exported ID outlives its changeset (it gets applied), and IDs keep
|
||||
// growing, so a stale or mistyped one could name someone else's later. It
|
||||
// is used only when it is open and yours, and hc says on stderr which
|
||||
// changeset that is.
|
||||
|
||||
var flagChangeset int64
|
||||
|
||||
// envChangeset returns the changeset in HOUSECARL_CHANGESET (0 if unset).
|
||||
func envChangeset() (int64, error) {
|
||||
v := os.Getenv("HOUSECARL_CHANGESET")
|
||||
if v == "" {
|
||||
return 0, nil
|
||||
}
|
||||
n, err := strconv.ParseInt(v, 10, 64)
|
||||
if err != nil || n <= 0 {
|
||||
return 0, fmt.Errorf("invalid HOUSECARL_CHANGESET %q", v)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
const startHint = "export HOUSECARL_CHANGESET=$(hc changeset create -q)"
|
||||
|
||||
// checkEnvChangeset checks the changeset from HOUSECARL_CHANGESET before
|
||||
// it is used. With mustOwn, it must also be open and the caller's own
|
||||
// (for staging into it, applying or discarding it).
|
||||
func checkEnvChangeset(c *client.Client, id int64, mustOwn bool) (*model.Changeset, error) {
|
||||
var cs model.Changeset
|
||||
if err := c.Do(ctx(), "GET", fmt.Sprintf("/changesets/%d", id), nil, nil, &cs); err != nil {
|
||||
var ce *client.Error
|
||||
if errors.As(err, &ce) && ce.Status == 404 {
|
||||
return nil, fmt.Errorf("HOUSECARL_CHANGESET is %d, but there is no changeset %d: start one with '%s', or 'unset HOUSECARL_CHANGESET' to make changes at once", id, id, startHint)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if mustOwn {
|
||||
if cs.Status != model.ChangesetOpen {
|
||||
return nil, fmt.Errorf("HOUSECARL_CHANGESET is %d, but changeset %d is %s: 'unset HOUSECARL_CHANGESET' to make changes at once, or start a new one with '%s'", id, id, cs.Status, startHint)
|
||||
}
|
||||
var me api.Whoami
|
||||
if err := c.Do(ctx(), "GET", "/auth/whoami", nil, nil, &me); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mine := me.User != nil && cs.UserID == me.User.ID && (me.User.ID != 0 || cs.Username == me.User.Username)
|
||||
if !mine {
|
||||
return nil, fmt.Errorf("HOUSECARL_CHANGESET is %d, but changeset %d is %s's (made via %s), not yours, so it isn't used from HOUSECARL_CHANGESET; give it as -C %d to use it anyway, or 'unset HOUSECARL_CHANGESET'", id, id, cs.Username, cs.Via, id)
|
||||
}
|
||||
}
|
||||
if !flagQuiet {
|
||||
desc := cs.Description
|
||||
if desc == "" {
|
||||
desc = "no description"
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "changeset %d (%s, %s) from HOUSECARL_CHANGESET\n", id, desc, plural(len(cs.Ops), "operation"))
|
||||
}
|
||||
return &cs, nil
|
||||
}
|
||||
|
||||
// setupChangeset makes the client stage configuration changes: in
|
||||
// --changeset's changeset, else HOUSECARL_CHANGESET's (checked before
|
||||
// the first change).
|
||||
func setupChangeset(c *client.Client) error {
|
||||
if flagChangeset > 0 {
|
||||
c.Changeset = flagChangeset
|
||||
return nil
|
||||
}
|
||||
n, err := envChangeset()
|
||||
if err != nil || n == 0 {
|
||||
return err
|
||||
}
|
||||
c.Changeset = n
|
||||
c.BeforeStage = func() error {
|
||||
_, err := checkEnvChangeset(c, n, true)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// changesetArg returns the changeset a changeset subcommand works on: the
|
||||
// ID argument, else --changeset, else HOUSECARL_CHANGESET (checked).
|
||||
// fromEnv says it came from HOUSECARL_CHANGESET.
|
||||
func changesetArg(c *client.Client, args []string, mustOwn bool) (id string, fromEnv bool, err error) {
|
||||
if len(args) > 0 {
|
||||
return args[0], false, nil
|
||||
}
|
||||
if flagChangeset > 0 {
|
||||
return strconv.FormatInt(flagChangeset, 10), false, nil
|
||||
}
|
||||
n, err := envChangeset()
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
if n == 0 {
|
||||
return "", false, errors.New("which changeset? Give its ID, or set HOUSECARL_CHANGESET (" + startHint + ")")
|
||||
}
|
||||
if _, err := checkEnvChangeset(c, n, mustOwn); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
return strconv.FormatInt(n, 10), true, nil
|
||||
}
|
||||
|
||||
// envChangesetDone reminds that HOUSECARL_CHANGESET still names a
|
||||
// changeset that was just applied or discarded.
|
||||
func envChangesetDone(id, what string) {
|
||||
if !flagQuiet {
|
||||
fmt.Fprintf(os.Stderr, "HOUSECARL_CHANGESET still names changeset %s, which is now %s: run 'unset HOUSECARL_CHANGESET' (or export a new one) before making more changes\n", id, what)
|
||||
}
|
||||
}
|
||||
|
||||
func plural(n int, word string) string {
|
||||
if n == 1 {
|
||||
return "1 " + word
|
||||
}
|
||||
return strconv.Itoa(n) + " " + word + "s"
|
||||
}
|
||||
|
||||
// printDiff prints a unified diff, colored on a terminal.
|
||||
func printDiff(w io.Writer, diff string) {
|
||||
color := isTerminal(os.Stdout)
|
||||
for _, line := range strings.Split(strings.TrimSuffix(diff, "\n"), "\n") {
|
||||
switch {
|
||||
case !color:
|
||||
case strings.HasPrefix(line, "+++"), strings.HasPrefix(line, "---"):
|
||||
line = "\x1b[1m" + line + "\x1b[0m"
|
||||
case strings.HasPrefix(line, "+"):
|
||||
line = "\x1b[32m" + line + "\x1b[0m"
|
||||
case strings.HasPrefix(line, "-"):
|
||||
line = "\x1b[31m" + line + "\x1b[0m"
|
||||
case strings.HasPrefix(line, "@@"):
|
||||
line = "\x1b[36m" + line + "\x1b[0m"
|
||||
}
|
||||
fmt.Fprintln(w, line)
|
||||
}
|
||||
}
|
||||
|
||||
func isTerminal(f *os.File) bool {
|
||||
fi, err := f.Stat()
|
||||
return err == nil && fi.Mode()&os.ModeCharDevice != 0
|
||||
}
|
||||
|
||||
func changesetCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "changeset",
|
||||
Aliases: []string{"changesets", "cs"},
|
||||
Short: "Stage changes to review and apply together",
|
||||
Long: `A changeset collects configuration changes to apply together, as one
|
||||
change in the history. Each change is checked as it is staged; 'hc
|
||||
changeset diff' shows what applying them would do: the problems, nftables'
|
||||
check and the ruleset's diff. Over the network an applied changeset is on
|
||||
probation like any change.
|
||||
|
||||
The web UI stages its edits in a changeset by default; hc and the API
|
||||
make changes at once unless one is named with -C ID or HOUSECARL_CHANGESET.
|
||||
Changesets staged in the web UI show up here too, and the other way round.`,
|
||||
Example: ` export HOUSECARL_CHANGESET=$(hc changeset create "guest network" -q)
|
||||
hc zone add guest -i eth1.30
|
||||
hc rule add forward --in guest --out wan
|
||||
hc changeset diff # HOUSECARL_CHANGESET's
|
||||
hc changeset apply
|
||||
unset HOUSECARL_CHANGESET`,
|
||||
}
|
||||
cmd.AddCommand(&cobra.Command{
|
||||
Use: "create [DESCRIPTION]", Short: "Start a changeset (-q prints only its ID)", Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
in := api.ChangesetInput{}
|
||||
if len(args) > 0 {
|
||||
in.Description = args[0]
|
||||
}
|
||||
var cs model.Changeset
|
||||
if err := c.Do(ctx(), "POST", "/changesets", nil, in, &cs); err != nil {
|
||||
return err
|
||||
}
|
||||
if flagQuiet || flagOutput == "id" {
|
||||
fmt.Println(cs.ID)
|
||||
return nil
|
||||
}
|
||||
fmt.Printf("created changeset %d\nstage changes with -C %d (or export HOUSECARL_CHANGESET=%d), then 'hc changeset apply'\n", cs.ID, cs.ID, cs.ID)
|
||||
return nil
|
||||
},
|
||||
})
|
||||
var status string
|
||||
var mine bool
|
||||
list := &cobra.Command{
|
||||
Use: "list", Short: "List changesets (open ones by default)", Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var css []*model.Changeset
|
||||
query := q("status", status)
|
||||
if mine {
|
||||
query.Set("mine", "1")
|
||||
}
|
||||
if err := c.Do(ctx(), "GET", "/changesets", query, nil, &css); err != nil {
|
||||
return err
|
||||
}
|
||||
return output(css, func(w io.Writer) {
|
||||
fmt.Fprintln(w, "ID\tSTATUS\tBY\tVIA\tOPERATIONS\tUPDATED\tDESCRIPTION")
|
||||
for _, cs := range css {
|
||||
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%d\t%s\t%s\n", cs.ID, cs.Status, cs.Username, cs.Via, len(cs.Ops), cs.UpdatedAt.Local().Format("2006-01-02 15:04"), cs.Description)
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
list.Flags().StringVar(&status, "status", "open", "open, applied, discarded, or \"\" for all")
|
||||
list.Flags().BoolVar(&mine, "mine", false, "only your own")
|
||||
cmd.AddCommand(list)
|
||||
cmd.AddCommand(&cobra.Command{
|
||||
Use: "show [ID]", Short: "Show a changeset's operations (default: -C or HOUSECARL_CHANGESET)", Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id, _, err := changesetArg(c, args, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var cs model.Changeset
|
||||
if err := c.Do(ctx(), "GET", "/changesets/"+id, nil, nil, &cs); err != nil {
|
||||
return err
|
||||
}
|
||||
return output(&cs, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Changeset:\t%d (%s)\n", cs.ID, cs.Status)
|
||||
fmt.Fprintf(w, "By:\t%s via %s, %s\n", cs.Username, cs.Via, cs.CreatedAt.Local().Format("2006-01-02 15:04"))
|
||||
if cs.Description != "" {
|
||||
fmt.Fprintf(w, "Description:\t%s\n", cs.Description)
|
||||
}
|
||||
if cs.HistoryID != 0 {
|
||||
fmt.Fprintf(w, "History:\t%d\n", cs.HistoryID)
|
||||
}
|
||||
fmt.Fprintln(w, "\nOP\tCHANGE")
|
||||
for _, o := range cs.Ops {
|
||||
fmt.Fprintf(w, "%d\t%s\n", o.Seq, o.Summary)
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
cmd.AddCommand(&cobra.Command{
|
||||
Use: "diff [ID]", Short: "Check an open changeset and show what applying it would change (default: -C or HOUSECARL_CHANGESET)", Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id, _, err := changesetArg(c, args, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var pv api.ChangesetPreview
|
||||
if err := c.Do(ctx(), "GET", "/changesets/"+id+"/preview", nil, nil, &pv); err != nil {
|
||||
return err
|
||||
}
|
||||
if flagOutput == "json" || flagOutput == "yaml" {
|
||||
return output(&pv, nil)
|
||||
}
|
||||
for _, ch := range pv.Changes {
|
||||
if ch.Kind == model.KindSettings {
|
||||
fmt.Printf("would %s settings\n", ch.Action())
|
||||
} else {
|
||||
fmt.Printf("would %s %s %s\n", ch.Action(), ch.Kind, ch.Label)
|
||||
}
|
||||
}
|
||||
printProblems(os.Stderr, pv.Warnings)
|
||||
if pv.Error != nil {
|
||||
printProblems(os.Stderr, pv.Error.Problems)
|
||||
return fmt.Errorf("changeset %s can't be applied: %s", id, pv.Error.Message)
|
||||
}
|
||||
if !pv.Valid {
|
||||
fmt.Println("nothing to change")
|
||||
return nil
|
||||
}
|
||||
fmt.Println()
|
||||
printDiff(os.Stdout, pv.Diff)
|
||||
fmt.Printf("\nvalid: nftables accepts the new ruleset; apply with 'hc changeset apply %s'\n", id)
|
||||
return nil
|
||||
},
|
||||
})
|
||||
var desc string
|
||||
apply := &cobra.Command{
|
||||
Use: "apply [ID]", Short: "Apply an open changeset (default: -C or HOUSECARL_CHANGESET)", Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id, fromEnv, err := changesetArg(c, args, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var resp api.MutationResponse
|
||||
if err := c.Do(ctx(), "POST", "/changesets/"+id+"/apply", nil, api.ChangesetInput{Description: desc}, &resp); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := report(&resp); err != nil {
|
||||
return err
|
||||
}
|
||||
if fromEnv {
|
||||
envChangesetDone(id, "applied")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
apply.Flags().StringVar(&desc, "description", "", "the history entry's description (default: the changeset's)")
|
||||
cmd.AddCommand(apply)
|
||||
cmd.AddCommand(&cobra.Command{
|
||||
Use: "discard [ID]", Short: "Throw away an open changeset (default: -C or HOUSECARL_CHANGESET)", Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id, fromEnv, err := changesetArg(c, args, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.Do(ctx(), "POST", "/changesets/"+id+"/discard", nil, nil, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
if !flagQuiet {
|
||||
fmt.Printf("discarded changeset %s\n", id)
|
||||
}
|
||||
if fromEnv {
|
||||
envChangesetDone(id, "discarded")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
})
|
||||
cmd.AddCommand(&cobra.Command{
|
||||
Use: "remove [ID] OP", Short: "Remove one operation from an open changeset (default: -C or HOUSECARL_CHANGESET)", Args: cobra.RangeArgs(1, 2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
op := args[len(args)-1]
|
||||
id, _, err := changesetArg(c, args[:len(args)-1], true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var cs model.Changeset
|
||||
if err := c.Do(ctx(), "DELETE", "/changesets/"+id+"/ops/"+op, nil, nil, &cs); err != nil {
|
||||
return err
|
||||
}
|
||||
if !flagQuiet {
|
||||
fmt.Printf("removed operation %s; changeset %s has %s\n", op, id, plural(len(cs.Ops), "operation"))
|
||||
}
|
||||
return nil
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,538 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"housecarl/internal/api"
|
||||
"housecarl/internal/model"
|
||||
)
|
||||
|
||||
func jsonUnmarshal(b []byte, v any) error { return json.Unmarshal(b, v) }
|
||||
|
||||
// ---- status
|
||||
|
||||
func statusCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "status", Short: "Show the firewall's state: ruleset, probation, problems, interfaces", Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
st, err := get[api.Status]("/status", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return output(st, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Housecarl:\t%s, up since %s\n", st.Build.Label, st.Started.Local().Format("2006-01-02 15:04"))
|
||||
if st.Applied {
|
||||
fmt.Fprintln(w, "Ruleset:\tin force (table inet housecarl)")
|
||||
} else if st.Unconfigured {
|
||||
fmt.Fprintln(w, "Ruleset:\tnone yet: the configuration is empty (add rules, or 'hc import')")
|
||||
} else {
|
||||
fmt.Fprintf(w, "Ruleset:\tNOT in force: %s\n", st.ApplyError)
|
||||
}
|
||||
if len(st.Probation.Pending) > 0 {
|
||||
n := len(st.Probation.Pending)
|
||||
what := map[bool]string{true: "1 change rolls", false: fmt.Sprintf("%d changes roll", n)}[n == 1]
|
||||
fmt.Fprintf(w, "Probation:\t%s back in %ds unless confirmed ('hc confirm')\n", what, st.Probation.SecondsLeft)
|
||||
}
|
||||
if len(st.OtherTables) > 0 {
|
||||
fmt.Fprintf(w, "Other tables:\t%s (not Housecarl's; 'hc takeover' deletes them once replaced)\n", strings.Join(st.OtherTables, ", "))
|
||||
}
|
||||
var counts []string
|
||||
for _, k := range model.Kinds {
|
||||
counts = append(counts, fmt.Sprintf("%d %ss", st.Counts[k], k))
|
||||
}
|
||||
fmt.Fprintf(w, "Configuration:\t%s\n", strings.Join(counts, ", "))
|
||||
fmt.Fprintln(w, "Interfaces:")
|
||||
for _, i := range st.Interfaces {
|
||||
state := "down"
|
||||
if i.Up {
|
||||
state = "up"
|
||||
}
|
||||
fmt.Fprintf(w, " %s\t%s\tzone %s\t%s\n", i.Name, state, orDash(i.Zone), strings.Join(i.Addresses, ", "))
|
||||
}
|
||||
if len(st.Problems) > 0 {
|
||||
fmt.Fprintln(w, "Problems:")
|
||||
for _, p := range st.Problems {
|
||||
fmt.Fprintf(w, " %s:\t%s: %s [%s]\n", p.Severity, p.Object, p.Message, p.Code)
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func interfaceCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "interfaces", Aliases: []string{"interface"}, Short: "List the machine's interfaces and their zones", Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ifs, err := get[[]api.HostInterface]("/interfaces", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return output(ifs, func(w io.Writer) {
|
||||
fmt.Fprintln(w, "INTERFACE\tSTATE\tZONE\tADDRESSES")
|
||||
for _, i := range ifs {
|
||||
state := "down"
|
||||
if i.Up {
|
||||
state = "up"
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", i.Name, state, orDash(i.Zone), orDash(strings.Join(i.Addresses, ", ")))
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ---- settings
|
||||
|
||||
func settingsCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{Use: "settings", Short: "Policies, built-in rules and other firewall-wide settings"}
|
||||
cmd.AddCommand(&cobra.Command{
|
||||
Use: "show", Short: "Show the settings", Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
s, err := get[model.Settings]("/settings", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return output(s, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Policies:\tinput %s, forward %s, output %s\n", s.InputPolicy, s.ForwardPolicy, s.OutputPolicy)
|
||||
fmt.Fprintf(w, "Log policy drops:\t%s\n", yesNo(s.LogPolicyDrops))
|
||||
b, _ := json.Marshal(s.Builtins)
|
||||
var m map[string]bool
|
||||
json.Unmarshal(b, &m)
|
||||
var names []string
|
||||
for n := range m {
|
||||
names = append(names, n)
|
||||
}
|
||||
sort.Strings(names)
|
||||
for i, n := range names {
|
||||
label := ""
|
||||
if i == 0 {
|
||||
label = "Built-ins:"
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s %s\n", label, n, map[bool]string{true: "on", false: "off"}[m[n]])
|
||||
}
|
||||
fmt.Fprintf(w, "Own prefixes:\t%s\n", list(s.OwnPrefixes))
|
||||
fmt.Fprintf(w, "Antispoof IPv4:\t%s\n", list(s.AntispoofV4))
|
||||
fmt.Fprintf(w, "Antispoof IPv6:\t%s\n", list(s.AntispoofV6))
|
||||
fmt.Fprintf(w, "Flowtable:\t%s\n", list(s.FlowtableDevices))
|
||||
fmt.Fprintf(w, "Probation:\t%s\n", fmtSeconds(s.Probation()))
|
||||
if s.ManualConfirm {
|
||||
fmt.Fprintln(w, "Confirmation:\tby a person ('hc confirm', the web UI's banner)")
|
||||
} else {
|
||||
fmt.Fprintln(w, "Confirmation:\tautomatic, by the client that made the change, once it reaches Housecarl again over a new connection")
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
var input, forward, output, logDrops, probation, autoConf string
|
||||
var builtins, own, flowtable, spoof4, spoof6 []string
|
||||
set := &cobra.Command{
|
||||
Use: "set [flags]", Short: "Change settings (only the flags given)", Args: cobra.NoArgs,
|
||||
Long: `Changes the settings. Built-ins are rules Housecarl adds itself; each can be
|
||||
turned off: loopback, established, drop_invalid, icmpv6, ping_internal,
|
||||
dhcpv6_client, antispoof, mss_clamp.
|
||||
|
||||
hc settings set --builtin mss_clamp=on --own-prefix 2001:db8::/48
|
||||
hc settings set --forward-policy drop --log-drops on
|
||||
hc settings set --flowtable wan --flowtable eth1`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
fl := cmd.Flags()
|
||||
p := patch{}
|
||||
for flag, field := range map[string]string{"input-policy": "input_policy", "forward-policy": "forward_policy", "output-policy": "output_policy"} {
|
||||
if fl.Changed(flag) {
|
||||
v, _ := fl.GetString(flag)
|
||||
p[field] = v
|
||||
}
|
||||
}
|
||||
if fl.Changed("log-drops") {
|
||||
on, err := onOff("log-drops", logDrops)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p["log_policy_drops"] = on
|
||||
}
|
||||
if len(builtins) > 0 {
|
||||
b := map[string]bool{}
|
||||
for _, x := range builtins {
|
||||
k, v, ok := strings.Cut(x, "=")
|
||||
on, err := onOff("builtin "+k, v)
|
||||
if !ok || err != nil {
|
||||
return fmt.Errorf("--builtin %q: write it as NAME=on or NAME=off", x)
|
||||
}
|
||||
b[strings.ReplaceAll(k, "-", "_")] = on
|
||||
}
|
||||
p["builtins"] = b
|
||||
}
|
||||
for flag, v := range map[string][]string{"own-prefix": own, "flowtable": flowtable, "antispoof-v4": spoof4, "antispoof-v6": spoof6} {
|
||||
if fl.Changed(flag) {
|
||||
p[map[string]string{"own-prefix": "own_prefixes", "flowtable": "flowtable_devices", "antispoof-v4": "antispoof_v4", "antispoof-v6": "antispoof_v6"}[flag]] = v
|
||||
}
|
||||
}
|
||||
if fl.Changed("auto-confirm") {
|
||||
on, err := onOff("auto-confirm", autoConf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p["manual_confirm"] = !on
|
||||
}
|
||||
if fl.Changed("probation") {
|
||||
n, err := parseSeconds(probation)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p["probation_seconds"] = n
|
||||
}
|
||||
return change("PATCH", "/settings", p)
|
||||
},
|
||||
}
|
||||
f := set.Flags()
|
||||
f.StringVar(&input, "input-policy", "", "accept or drop what no rule decided, to the firewall")
|
||||
f.StringVar(&forward, "forward-policy", "", "… through it")
|
||||
f.StringVar(&output, "output-policy", "", "… from it")
|
||||
f.StringVar(&logDrops, "log-drops", "", "log what the input and forward policies drop: on or off")
|
||||
f.StringArrayVar(&builtins, "builtin", nil, "turn a built-in on or off: NAME=on|off (repeat)")
|
||||
f.StringArrayVar(&own, "own-prefix", nil, "your public prefixes, for antispoofing (repeat; replaces)")
|
||||
f.StringArrayVar(&flowtable, "flowtable", nil, "fast-path devices (repeat; replaces; none: off)")
|
||||
f.StringArrayVar(&spoof4, "antispoof-v4", nil, "IPv4 sources dropped from the internet (replaces)")
|
||||
f.StringArrayVar(&spoof6, "antispoof-v6", nil, "IPv6 sources dropped from the internet (replaces)")
|
||||
f.StringVar(&probation, "probation", "", "how long a network change has to be confirmed (2m)")
|
||||
f.StringVar(&autoConf, "auto-confirm", "", "on: the client that made a change confirms it once it reaches Housecarl again over a new connection; off: a person confirms")
|
||||
cmd.AddCommand(set)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func onOff(name, v string) (bool, error) {
|
||||
switch strings.ToLower(v) {
|
||||
case "on", "yes", "true":
|
||||
return true, nil
|
||||
case "off", "no", "false":
|
||||
return false, nil
|
||||
}
|
||||
return false, fmt.Errorf("--%s: use on or off", name)
|
||||
}
|
||||
|
||||
// ---- the ruleset, counters, the log
|
||||
|
||||
func rulesetCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "ruleset", Short: "Print the nftables ruleset Housecarl builds", Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var text string
|
||||
if err := c.Do(ctx(), "GET", "/ruleset", nil, nil, &text); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Print(text)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func countersCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "counters", Short: "Hits of every rule, NAT rule and built-in", Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cs, err := get[api.Counters]("/counters", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return output(cs, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Since %s\n", cs.Since.Local().Format("2006-01-02 15:04"))
|
||||
fmt.Fprintln(w, "MARK\tPACKETS\tBYTES")
|
||||
var marks []string
|
||||
for m := range cs.Counters {
|
||||
marks = append(marks, m)
|
||||
}
|
||||
sort.Strings(marks)
|
||||
for _, m := range marks {
|
||||
c := cs.Counters[m]
|
||||
fmt.Fprintf(w, "%s\t%d\t%d\n", m, c.Packets, c.Bytes)
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func quotasCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "quotas", Aliases: []string{"quota"}, Short: "What the rules' quotas used, and when they reset", Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
qs, err := get[[]api.QuotaInfo]("/quotas", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return output(qs, func(w io.Writer) {
|
||||
fmt.Fprintln(w, "RULE\tUSED\tOF\tRESETS\tNEXT RESET")
|
||||
for _, q := range qs {
|
||||
resets := q.Quota.Reset
|
||||
if resets == "" {
|
||||
resets = "by hand"
|
||||
} else if resets == "monthly" && q.Quota.Day > 1 {
|
||||
resets = fmt.Sprintf("monthly (%d)", q.Quota.Day)
|
||||
}
|
||||
next := "-"
|
||||
if q.NextReset != nil {
|
||||
next = q.NextReset.Local().Format("2006-01-02 15:04")
|
||||
}
|
||||
of := humanBytes(q.Quota.Bytes)
|
||||
if q.Quota.PerSource || q.Quota.PerDestination {
|
||||
of += " each"
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", q.Rule, humanBytes(q.Used), of, resets, next)
|
||||
for _, a := range q.Addresses {
|
||||
fmt.Fprintf(w, " %s\t%s\t\t\t\n", a.Address, humanBytes(a.Used))
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func logCmd() *cobra.Command {
|
||||
var follow bool
|
||||
var limit int
|
||||
var search string
|
||||
cmd := &cobra.Command{
|
||||
Use: "log", Short: "Packets the ruleset logged (dropped by a rule or a policy)", Args: cobra.NoArgs,
|
||||
Long: `Shows the packets rules with --log and the policies (settings
|
||||
--log-drops) logged, newest last. -f keeps watching.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var last time.Time
|
||||
print := func(es []api.LogEntry) {
|
||||
sort.Slice(es, func(i, j int) bool { return es[i].Time.Before(es[j].Time) })
|
||||
for _, e := range es {
|
||||
last = e.Time
|
||||
src, dst := e.Source, e.Dest
|
||||
if e.SrcPort != 0 {
|
||||
src = joinPort(src, e.SrcPort)
|
||||
}
|
||||
if e.DestPort != 0 {
|
||||
dst = joinPort(dst, e.DestPort)
|
||||
}
|
||||
what := e.Protocol
|
||||
if e.ICMPType != "" {
|
||||
what += " " + e.ICMPType
|
||||
}
|
||||
if e.TCPFlags != "" {
|
||||
what += " " + e.TCPFlags
|
||||
}
|
||||
fmt.Printf("%s %-28s %-6s %-6s %s -> %s %s\n", e.Time.Local().Format("15:04:05"), strings.TrimSpace(e.Prefix), orDash(e.In), orDash(e.Out), src, dst, what)
|
||||
}
|
||||
}
|
||||
for {
|
||||
var es []api.LogEntry
|
||||
query := q("limit", fmt.Sprint(limit), "q", search)
|
||||
if !last.IsZero() {
|
||||
query.Set("after", last.Format(time.RFC3339Nano))
|
||||
}
|
||||
if err := c.Get(ctx(), "/log", query, &es); err != nil {
|
||||
return err
|
||||
}
|
||||
if flagOutput == "json" || flagOutput == "yaml" {
|
||||
return output(es, nil)
|
||||
}
|
||||
print(es)
|
||||
if !follow {
|
||||
if len(es) == 0 {
|
||||
fmt.Fprintln(os.Stderr, "nothing logged yet (log with 'hc rule update ID --log' or 'hc settings set --log-drops on')")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVarP(&follow, "follow", "f", false, "keep watching")
|
||||
cmd.Flags().IntVar(&limit, "limit", 50, "at most this many")
|
||||
cmd.Flags().StringVar(&search, "search", "", "only entries containing this (address, interface, prefix)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func joinPort(addr string, port int) string {
|
||||
if strings.Contains(addr, ":") {
|
||||
return fmt.Sprintf("[%s]:%d", addr, port)
|
||||
}
|
||||
return fmt.Sprintf("%s:%d", addr, port)
|
||||
}
|
||||
|
||||
// ---- probation
|
||||
|
||||
func confirmCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "confirm", Short: "Keep the changes on probation (otherwise they roll back)", Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var out map[string]int
|
||||
if err := c.Do(ctx(), "POST", "/confirm", nil, nil, &out); err != nil {
|
||||
return err
|
||||
}
|
||||
if out["confirmed"] == 0 {
|
||||
fmt.Println("nothing was waiting to be confirmed")
|
||||
} else if out["confirmed"] == 1 {
|
||||
fmt.Println("confirmed 1 change; it is kept and loaded at boot")
|
||||
} else {
|
||||
fmt.Printf("confirmed %d changes; they are kept and loaded at boot\n", out["confirmed"])
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func rollbackCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "rollback", Short: "Undo the changes on probation now", Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var out map[string]int
|
||||
if err := c.Do(ctx(), "POST", "/rollback", nil, nil, &out); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("rolled back %d changes\n", out["rolled_back"])
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ---- import and takeover
|
||||
|
||||
func importCmd() *cobra.Command {
|
||||
var replace bool
|
||||
var table, defines, textFile string
|
||||
cmd := &cobra.Command{
|
||||
Use: "import [FILE|-]",
|
||||
Short: "Import an nftables ruleset (nft -j JSON) as Housecarl's configuration",
|
||||
Long: `Turns an existing nftables ruleset into zones, sets, services, rules, port
|
||||
forwards and NAT. The input is nft's JSON; on the firewall:
|
||||
|
||||
sudo hc import --defines /etc/nftables.conf -n
|
||||
|
||||
Without a FILE, hc runs 'nft -j list ruleset' and 'nft -a list ruleset'
|
||||
itself (root on the firewall); from elsewhere, give the JSON as FILE and
|
||||
the text with --text. --defines reads the ruleset file's define lines, to
|
||||
name the sets as you did (admins, lan_net, …). -n shows the result without
|
||||
saving; what can't be expressed becomes raw rules, listed with notes.
|
||||
Once it looks right, import without -n, check it with 'hc ruleset', and
|
||||
'hc takeover inet main' deletes the old table.`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
var data, text []byte
|
||||
var err error
|
||||
switch {
|
||||
case len(args) == 0:
|
||||
data, err = exec.Command("nft", "-j", "list", "ruleset").Output()
|
||||
if err != nil {
|
||||
return fmt.Errorf("running 'nft -j list ruleset': %v (give a FILE, or run as root on the firewall)", err)
|
||||
}
|
||||
text, _ = exec.Command("nft", "-a", "list", "ruleset").Output()
|
||||
case args[0] == "-":
|
||||
data, err = io.ReadAll(os.Stdin)
|
||||
default:
|
||||
data, err = os.ReadFile(args[0])
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if textFile != "" {
|
||||
if text, err = os.ReadFile(textFile); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
in := api.ImportInput{JSON: data, Text: string(text), Table: table, Replace: replace}
|
||||
if defines != "" {
|
||||
b, err := os.ReadFile(defines)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
in.Defines = string(b)
|
||||
}
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var res api.ImportResult
|
||||
if err := c.Do(ctx(), "POST", "/import/nft", nil, in, &res); err != nil {
|
||||
return err
|
||||
}
|
||||
if flagOutput == "json" || flagOutput == "yaml" {
|
||||
return output(res, nil)
|
||||
}
|
||||
st := res.State
|
||||
fmt.Printf("%d zones, %d sets, %d services, %d rules, %d port forwards, %d source NAT rules, %d redirects\n",
|
||||
len(st.Zones), len(st.Sets), len(st.Services), len(st.Rules), len(st.Forwards), len(st.SNATs), len(st.Redirects))
|
||||
for _, n := range res.Notes {
|
||||
fmt.Println("note:", n)
|
||||
}
|
||||
for _, r := range res.Raw {
|
||||
fmt.Println("raw rule:", r)
|
||||
}
|
||||
return report(&res.MutationResponse)
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&replace, "replace", false, "replace Housecarl's configuration (needed when it has one)")
|
||||
cmd.Flags().StringVar(&table, "table", "", "import only this table (\"inet main\"; default: all but Housecarl's)")
|
||||
cmd.Flags().StringVar(&defines, "defines", "", "the ruleset file, whose defines name the sets")
|
||||
cmd.Flags().StringVar(&textFile, "text", "", "'nft -a list ruleset' output, to keep what can't be expressed as raw rules")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func takeoverCmd() *cobra.Command {
|
||||
var yes bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "takeover TABLE...",
|
||||
Short: "Delete the tables Housecarl replaces (\"inet main\"), on probation",
|
||||
Long: `Deletes other nftables tables once Housecarl's ruleset replaces them (after
|
||||
'hc import'). Until then both apply: a packet must get past both. Over the
|
||||
network this is on probation like a change: unless confirmed, the tables
|
||||
come back. Don't forget the boot: disable nftables.service, or empty
|
||||
/etc/nftables.conf, or it brings the old ruleset back.
|
||||
|
||||
hc takeover "inet main"`,
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if !confirm("Delete "+strings.Join(args, ", ")+"?", yes) {
|
||||
return fmt.Errorf("nothing deleted")
|
||||
}
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var out map[string]any
|
||||
if err := c.Do(ctx(), "POST", "/takeover", nil, api.TakeoverInput{Tables: args}, &out); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println(out["message"])
|
||||
if tok, _ := out["confirm_token"].(string); tok != "" && !flagNoConfirm {
|
||||
if err := autoConfirm(tok); err != nil {
|
||||
fmt.Printf("couldn't confirm it (%v): confirm with 'hc confirm', or the tables come back\n", err)
|
||||
} else {
|
||||
fmt.Println("confirmed: this client reached Housecarl again over a new connection")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVarP(&yes, "yes", "y", false, "don't ask")
|
||||
return cmd
|
||||
}
|
||||
+467
-14
@@ -1,42 +1,495 @@
|
||||
// Command hc is Housecarl's command line client: it will manage the
|
||||
// firewall through housecarld's API, as jarl and steward do for theirs.
|
||||
// For now it only knows its version.
|
||||
// Command hc is the command line client for Housecarl. It talks to
|
||||
// housecarld's REST API: on the server itself through the local socket, or
|
||||
// anywhere with the server's URL and an API token.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"go.yaml.in/yaml/v3"
|
||||
|
||||
"housecarl/internal/api"
|
||||
"housecarl/internal/client"
|
||||
"housecarl/internal/model"
|
||||
"housecarl/internal/validate"
|
||||
"housecarl/internal/version"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := rootCmd().Execute(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error:", err)
|
||||
os.Exit(1)
|
||||
printError(err)
|
||||
os.Exit(exitCode(err))
|
||||
}
|
||||
}
|
||||
|
||||
// Global flags.
|
||||
var (
|
||||
flagServer string
|
||||
flagToken string
|
||||
flagConfig string
|
||||
flagDryRun bool
|
||||
flagOutput string
|
||||
flagInsecure bool
|
||||
flagCACert string
|
||||
flagQuiet bool
|
||||
flagNoConfirm bool
|
||||
// lastClient is the client the command made (to confirm a change by
|
||||
// itself after it).
|
||||
lastClient *client.Client
|
||||
)
|
||||
|
||||
// DefaultSocket is housecarld's local socket.
|
||||
const DefaultSocket = "/run/housecarl/housecarl.sock"
|
||||
|
||||
// cliConfig is the CLI's configuration file.
|
||||
type cliConfig struct {
|
||||
Server string `yaml:"server,omitempty"`
|
||||
Token string `yaml:"token,omitempty"`
|
||||
Insecure bool `yaml:"insecure,omitempty"`
|
||||
CACert string `yaml:"ca_cert,omitempty"`
|
||||
}
|
||||
|
||||
func defaultConfigPath() string {
|
||||
if p := os.Getenv("HOUSECARL_CLI_CONFIG"); p != "" {
|
||||
return p
|
||||
}
|
||||
dir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
dir = filepath.Join(os.Getenv("HOME"), ".config")
|
||||
}
|
||||
return filepath.Join(dir, "housecarl", "config.yaml")
|
||||
}
|
||||
|
||||
func loadCLIConfig() (*cliConfig, error) {
|
||||
c := &cliConfig{}
|
||||
b, err := os.ReadFile(flagConfig)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return c, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c, yaml.Unmarshal(b, c)
|
||||
}
|
||||
|
||||
func saveCLIConfig(c *cliConfig) error {
|
||||
if err := os.MkdirAll(filepath.Dir(flagConfig), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
b, err := yaml.Marshal(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(flagConfig, b, 0o600)
|
||||
}
|
||||
|
||||
func firstNonEmpty(vals ...string) string {
|
||||
for _, v := range vals {
|
||||
if v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// newClient connects to the server: --server, HOUSECARL_SERVER, the config
|
||||
// file, or else the local socket.
|
||||
func newClient() (*client.Client, error) {
|
||||
cfg, err := loadCLIConfig()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading %s: %w", flagConfig, err)
|
||||
}
|
||||
server := firstNonEmpty(flagServer, os.Getenv("HOUSECARL_SERVER"), cfg.Server)
|
||||
token := firstNonEmpty(flagToken, os.Getenv("HOUSECARL_TOKEN"), cfg.Token)
|
||||
if server == "" || server == "local" {
|
||||
sock := firstNonEmpty(os.Getenv("HOUSECARL_SOCKET"), DefaultSocket)
|
||||
if _, err := os.Stat(sock); err != nil {
|
||||
return nil, fmt.Errorf("no server configured, and there is no local socket %s: use --server URL, HOUSECARL_SERVER or 'hc config set server https://...'; on the server itself, check that housecarld runs", sock)
|
||||
}
|
||||
server = "unix://" + sock
|
||||
}
|
||||
if strings.HasPrefix(token, "@") {
|
||||
b, err := os.ReadFile(token[1:])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading the token file: %w", err)
|
||||
}
|
||||
token = strings.TrimSpace(string(b))
|
||||
}
|
||||
if _, isSocket := client.SocketPath(server); !isSocket && token == "" {
|
||||
return nil, errors.New("no API token: use --token, HOUSECARL_TOKEN or 'hc config set token'")
|
||||
}
|
||||
c, err := client.New(client.Options{Server: server, Token: token, Insecure: flagInsecure || cfg.Insecure, CACert: firstNonEmpty(flagCACert, cfg.CACert)})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.DryRun = flagDryRun
|
||||
if err := setupChangeset(c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lastClient = c
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func rootCmd() *cobra.Command {
|
||||
root := &cobra.Command{
|
||||
Use: "hc",
|
||||
Short: "Manage a Housecarl firewall",
|
||||
Long: `hc will manage a Housecarl firewall through housecarld's API: its zones,
|
||||
address sets, rules and port forwards. It doesn't yet: Housecarl is at its
|
||||
start (docs/design.md).`,
|
||||
Short: "Command line client for Housecarl, the firewall",
|
||||
Long: `hc manages a Housecarl firewall through its API.
|
||||
|
||||
Every change is checked as a whole, then by nftables, before it is put in
|
||||
force, and is recorded in the history, where 'hc history undo ID' takes it
|
||||
back. --dry-run shows what a change would do, with the ruleset's diff.
|
||||
Changes are made at once; to review several and apply them together,
|
||||
stage them in a changeset ('hc changeset --help', -C ID or
|
||||
HOUSECARL_CHANGESET).
|
||||
|
||||
A change made over the network is on probation: hc confirms it by itself
|
||||
once it reaches Housecarl again over a new connection, and a change that
|
||||
cuts you off can't be confirmed, so it is rolled back by itself (within 2
|
||||
minutes by default). --no-confirm leaves it to 'hc confirm'. On the firewall itself, root uses
|
||||
housecarld's local socket: no token, no probation. From elsewhere, set the
|
||||
server and an API token once:
|
||||
|
||||
hc config set server https://firewall.example.com:8070
|
||||
hc config set token (paste the token at the prompt)
|
||||
|
||||
Exit codes: 0 ok, 1 error, 3 not authenticated or not allowed, 4 not found,
|
||||
5 conflict, 6 invalid or rejected by validation, 7 server error.`,
|
||||
SilenceUsage: true,
|
||||
SilenceErrors: true,
|
||||
Version: version.String(),
|
||||
}
|
||||
root.AddCommand(versionCmd())
|
||||
pf := root.PersistentFlags()
|
||||
pf.StringVarP(&flagServer, "server", "s", "", "server URL, or \"local\" for the local socket (env HOUSECARL_SERVER)")
|
||||
pf.StringVar(&flagToken, "token", "", "API token, or @FILE to read it from a file (env HOUSECARL_TOKEN)")
|
||||
pf.StringVar(&flagConfig, "config", defaultConfigPath(), "CLI configuration file")
|
||||
pf.BoolVarP(&flagDryRun, "dry-run", "n", false, "check and show a change without making it")
|
||||
pf.StringVarP(&flagOutput, "output", "o", "table", "output format: table, json, yaml or id")
|
||||
pf.BoolVar(&flagInsecure, "insecure", false, "don't verify the server's TLS certificate")
|
||||
pf.StringVar(&flagCACert, "ca-cert", "", "PEM file with a CA to trust for the server's certificate")
|
||||
pf.BoolVarP(&flagQuiet, "quiet", "q", false, "print only errors (and IDs of new objects)")
|
||||
pf.BoolVar(&flagNoConfirm, "no-confirm", false, "leave a change on probation instead of confirming it once this client reaches Housecarl again")
|
||||
pf.Int64VarP(&flagChangeset, "changeset", "C", 0, "stage configuration changes in this changeset instead of applying them (env HOUSECARL_CHANGESET)")
|
||||
root.AddGroup(&cobra.Group{ID: "fw", Title: "Firewall:"}, &cobra.Group{ID: "changes", Title: "Changes:"}, &cobra.Group{ID: "admin", Title: "Administration:"})
|
||||
add := func(group string, cmds ...*cobra.Command) {
|
||||
for _, c := range cmds {
|
||||
c.GroupID = group
|
||||
root.AddCommand(c)
|
||||
}
|
||||
}
|
||||
add("fw", statusCmd(), zoneCmd(), setCmd(), serviceCmd(), ruleCmd(), forwardCmd(), snatCmd(), redirectCmd(), settingsCmd(),
|
||||
rulesetCmd(), countersCmd(), quotasCmd(), logCmd(), interfaceCmd())
|
||||
add("changes", changesetCmd(), confirmCmd(), rollbackCmd(), historyCmd(), importCmd(), takeoverCmd())
|
||||
add("admin", whoamiCmd(), userCmd(), tokenCmd(), passwordCmd(), certCmd(), auditCmd(), configCmd())
|
||||
return root
|
||||
}
|
||||
|
||||
func versionCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "version", Short: "Print the version", Args: cobra.NoArgs,
|
||||
Run: func(cmd *cobra.Command, args []string) { fmt.Println("hc", version.String()) },
|
||||
func ctx() context.Context { return context.Background() }
|
||||
|
||||
// ---- output
|
||||
|
||||
func output(v any, table func(w io.Writer)) error {
|
||||
switch flagOutput {
|
||||
case "json":
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(v)
|
||||
case "yaml":
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var x any
|
||||
if err := json.Unmarshal(b, &x); err != nil {
|
||||
return err
|
||||
}
|
||||
return yaml.NewEncoder(os.Stdout).Encode(x)
|
||||
case "id":
|
||||
return outputIDs(v)
|
||||
case "table", "":
|
||||
if table == nil {
|
||||
flagOutput = "yaml"
|
||||
return output(v, nil)
|
||||
}
|
||||
tw := tabwriter.NewWriter(os.Stdout, 2, 4, 2, ' ', 0)
|
||||
table(tw)
|
||||
return tw.Flush()
|
||||
}
|
||||
return fmt.Errorf("unknown output format %q (use table, json, yaml or id)", flagOutput)
|
||||
}
|
||||
|
||||
// outputIDs prints the id of each object.
|
||||
func outputIDs(v any) error {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dec := json.NewDecoder(bytes.NewReader(b))
|
||||
dec.UseNumber()
|
||||
var x any
|
||||
if err := dec.Decode(&x); err != nil {
|
||||
return err
|
||||
}
|
||||
list, ok := x.([]any)
|
||||
if !ok {
|
||||
list = []any{x}
|
||||
}
|
||||
for _, it := range list {
|
||||
if m, ok := it.(map[string]any); ok {
|
||||
if id, ok := m["id"]; ok {
|
||||
fmt.Println(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func printProblems(w io.Writer, ps []validate.Problem) {
|
||||
for _, p := range ps {
|
||||
obj := ""
|
||||
if p.Object != "" {
|
||||
obj = p.Object + ": "
|
||||
}
|
||||
fmt.Fprintf(w, "%s: %s%s [%s]\n", p.Severity, obj, p.Message, p.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func printError(err error) {
|
||||
var ce *client.Error
|
||||
if errors.As(err, &ce) {
|
||||
ps := ce.Info.Problems
|
||||
if len(ps) == 1 {
|
||||
fmt.Fprintf(os.Stderr, "error: %s [%s]\n", ce.Info.Message, ps[0].Code)
|
||||
return
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, "error:", ce.Info.Message)
|
||||
if len(ps) > 1 {
|
||||
printProblems(os.Stderr, ps)
|
||||
}
|
||||
return
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, "error:", err)
|
||||
}
|
||||
|
||||
// exitCode maps errors to exit codes: 1 other errors, 3 not authenticated
|
||||
// or not allowed, 4 not found, 5 conflict, 6 invalid request or rejected
|
||||
// by validation, 7 server error.
|
||||
func exitCode(err error) int {
|
||||
var ce *client.Error
|
||||
if errors.As(err, &ce) {
|
||||
switch {
|
||||
case ce.Status == 401 || ce.Status == 403:
|
||||
return 3
|
||||
case ce.Status == 404:
|
||||
return 4
|
||||
case ce.Status == 409:
|
||||
return 5
|
||||
case ce.Status == 400 || ce.Status == 422:
|
||||
return 6
|
||||
case ce.Status >= 500:
|
||||
return 7
|
||||
}
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
// autoConfirm confirms a change with its token: the request goes over a
|
||||
// new connection (the server closed the change's), so it gets through
|
||||
// only if the new ruleset still lets this client reach Housecarl.
|
||||
func autoConfirm(token string) error {
|
||||
if lastClient == nil {
|
||||
return errors.New("no client")
|
||||
}
|
||||
c, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
var out map[string]int
|
||||
return lastClient.Do(c, "POST", "/confirm", nil, api.ConfirmInput{Token: token}, &out)
|
||||
}
|
||||
|
||||
// report prints the outcome of a change.
|
||||
func report(resp *api.MutationResponse) error {
|
||||
switch flagOutput {
|
||||
case "json", "yaml":
|
||||
return output(resp, nil)
|
||||
case "id":
|
||||
if len(resp.Object) > 0 {
|
||||
return outputIDs(resp.Object)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
w := os.Stdout
|
||||
verb := func(c model.Change) string {
|
||||
if resp.DryRun {
|
||||
return "would " + c.Action()
|
||||
}
|
||||
if resp.Staged {
|
||||
return "staged: " + c.Action()
|
||||
}
|
||||
return c.Action() + "d"
|
||||
}
|
||||
if !flagQuiet || resp.DryRun {
|
||||
for _, c := range resp.Changes {
|
||||
if c.Kind == model.KindSettings {
|
||||
fmt.Fprintf(w, "%s settings\n", verb(c))
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(w, "%s %s %s\n", verb(c), c.Kind, c.Label)
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case resp.Staged && resp.Changeset != nil:
|
||||
if !flagQuiet {
|
||||
cs := resp.Changeset
|
||||
fmt.Fprintf(w, "staged in changeset %d as operation %d (%s now); review with 'hc changeset diff %d', apply with 'hc changeset apply %d'\n",
|
||||
cs.ID, cs.Ops[len(cs.Ops)-1].Seq, plural(len(cs.Ops), "operation"), cs.ID, cs.ID)
|
||||
}
|
||||
case resp.DryRun:
|
||||
if resp.Diff != "" && !flagQuiet {
|
||||
fmt.Fprintln(w)
|
||||
printDiff(w, resp.Diff)
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
fmt.Fprintln(w, "dry run: nothing was changed")
|
||||
case resp.Applied && resp.ConfirmBy != nil && resp.ConfirmToken != "" && !flagNoConfirm:
|
||||
if err := autoConfirm(resp.ConfirmToken); err != nil {
|
||||
fmt.Fprintf(w, "applied on probation (history %d), but couldn't confirm it: %v\nIt is rolled back within %s unless confirmed ('hc confirm')\n",
|
||||
resp.HistoryID, err, time.Until(*resp.ConfirmBy).Round(time.Second))
|
||||
} else if !flagQuiet {
|
||||
fmt.Fprintf(w, "applied and confirmed (history %d): this client reached Housecarl again over a new connection\n", resp.HistoryID)
|
||||
}
|
||||
case resp.Applied && resp.ConfirmBy != nil:
|
||||
fmt.Fprintf(w, "applied on probation (history %d): confirm with 'hc confirm' within %s, or it is rolled back\n",
|
||||
resp.HistoryID, time.Until(*resp.ConfirmBy).Round(time.Second))
|
||||
case resp.Applied && !flagQuiet:
|
||||
fmt.Fprintf(w, "applied and saved (history %d; undo with 'hc history undo %d')\n", resp.HistoryID, resp.HistoryID)
|
||||
case !resp.Applied && len(resp.Changes) == 0 && !flagQuiet:
|
||||
fmt.Fprintln(w, "nothing to change")
|
||||
}
|
||||
if flagQuiet && len(resp.Object) > 0 && (resp.Applied || resp.Staged) {
|
||||
outputIDs(resp.Object)
|
||||
}
|
||||
printProblems(os.Stderr, resp.Warnings)
|
||||
return nil
|
||||
}
|
||||
|
||||
func q(kv ...string) url.Values {
|
||||
v := url.Values{}
|
||||
for i := 0; i+1 < len(kv); i += 2 {
|
||||
if kv[i+1] != "" {
|
||||
v.Set(kv[i], kv[i+1])
|
||||
}
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func confirm(prompt string, yes bool) bool {
|
||||
if yes || flagDryRun {
|
||||
return true
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "%s [y/N] ", prompt)
|
||||
var answer string
|
||||
fmt.Scanln(&answer)
|
||||
return strings.EqualFold(answer, "y") || strings.EqualFold(answer, "yes")
|
||||
}
|
||||
|
||||
// ---- durations
|
||||
|
||||
// parseSeconds reads a duration: "12h", "30m", "7d", "1d12h" or plain
|
||||
// seconds.
|
||||
func parseSeconds(s string) (int, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if n, err := strconv.Atoi(s); err == nil {
|
||||
return n, nil
|
||||
}
|
||||
days := 0
|
||||
if i := strings.Index(s, "d"); i > 0 {
|
||||
n, err := strconv.Atoi(s[:i])
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid duration %q (use e.g. 12h, 30m or 7d)", s)
|
||||
}
|
||||
days, s = n, s[i+1:]
|
||||
}
|
||||
var d time.Duration
|
||||
if s != "" {
|
||||
var err error
|
||||
if d, err = time.ParseDuration(s); err != nil {
|
||||
return 0, fmt.Errorf("invalid duration %q (use e.g. 12h, 30m or 7d)", s)
|
||||
}
|
||||
}
|
||||
return days*86400 + int(d/time.Second), nil
|
||||
}
|
||||
|
||||
// fmtSeconds writes seconds the short way: 43200 is "12h", 90 is "1m30s".
|
||||
func fmtSeconds(n int) string {
|
||||
if n <= 0 {
|
||||
return "-"
|
||||
}
|
||||
var parts []string
|
||||
for _, u := range []struct {
|
||||
s int
|
||||
name string
|
||||
}{{86400, "d"}, {3600, "h"}, {60, "m"}, {1, "s"}} {
|
||||
if n >= u.s {
|
||||
parts = append(parts, strconv.Itoa(n/u.s)+u.name)
|
||||
n %= u.s
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, "")
|
||||
}
|
||||
|
||||
// fmtWhen writes a time relative to now: "in 3h", "2d ago".
|
||||
func fmtWhen(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return "-"
|
||||
}
|
||||
d := time.Until(t)
|
||||
ago := d < 0
|
||||
if ago {
|
||||
d = -d
|
||||
}
|
||||
var s string
|
||||
switch {
|
||||
case d < time.Minute:
|
||||
return "now"
|
||||
case d < time.Hour:
|
||||
s = fmt.Sprintf("%dm", int(d.Minutes()))
|
||||
case d < 48*time.Hour:
|
||||
s = fmt.Sprintf("%dh%02dm", int(d.Hours()), int(d.Minutes())%60)
|
||||
default:
|
||||
s = fmt.Sprintf("%dd", int(d.Hours()/24))
|
||||
}
|
||||
if ago {
|
||||
return s + " ago"
|
||||
}
|
||||
return "in " + s
|
||||
}
|
||||
|
||||
// fmtSeen is a last-seen time, or "-" for a client never seen.
|
||||
func fmtSeen(t *time.Time) string {
|
||||
if t == nil {
|
||||
return "-"
|
||||
}
|
||||
return fmtWhen(*t)
|
||||
}
|
||||
|
||||
func orDash(s string) string {
|
||||
if s == "" {
|
||||
return "-"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// optionsArg turns --option NAME=VALUE flags into option inputs.
|
||||
@@ -0,0 +1,42 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"housecarl/internal/model"
|
||||
)
|
||||
|
||||
func TestDurations(t *testing.T) {
|
||||
for in, want := range map[string]int{"3600": 3600, "12h": 43200, "30m": 1800, "1d": 86400, "1d12h": 129600, "1h30m": 5400} {
|
||||
if got, err := parseSeconds(in); err != nil || got != want {
|
||||
t.Errorf("parseSeconds(%q) = %d, %v; want %d", in, got, err, want)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{"12x", "d", "1d2x"} {
|
||||
if _, err := parseSeconds(bad); err == nil {
|
||||
t.Errorf("parseSeconds(%q) accepted", bad)
|
||||
}
|
||||
}
|
||||
for n, want := range map[int]string{43200: "12h", 5400: "1h30m", 90061: "1d1h1m1s", 0: "-"} {
|
||||
if got := fmtSeconds(n); got != want {
|
||||
t.Errorf("fmtSeconds(%d) = %q, want %q", n, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLimitAndDescribe(t *testing.T) {
|
||||
l, err := parseLimit("3/minutes,burst=5,over,per-source")
|
||||
if err != nil || l.Rate != 3 || l.Per != "minute" || l.Burst != 5 || !l.Over || !l.PerSource {
|
||||
t.Errorf("parseLimit: %+v %v", l, err)
|
||||
}
|
||||
if _, err := parseLimit("fast"); err == nil {
|
||||
t.Error("a bad limit accepted")
|
||||
}
|
||||
m := model.Match{InZones: []string{"wan"}, NotSources: true, Sources: []string{"trusted"}, Services: []string{"ssh", "tcp/8080"}}
|
||||
if got := describe(m); got != "in wan from not trusted ssh,tcp/8080" {
|
||||
t.Errorf("describe: %q", got)
|
||||
}
|
||||
if got := editList([]string{"a", "b", "c"}, []string{"d"}, []string{"b"}); len(got) != 3 || got[1] != "c" || got[2] != "d" {
|
||||
t.Errorf("editList: %v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,461 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"housecarl/internal/api"
|
||||
"housecarl/internal/model"
|
||||
)
|
||||
|
||||
// ---- helpers shared by the object commands
|
||||
|
||||
// change sends a change and reports it.
|
||||
func change(method, path string, body any) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var resp api.MutationResponse
|
||||
if err := c.Do(ctx(), method, path, nil, body, &resp); err != nil {
|
||||
return err
|
||||
}
|
||||
return report(&resp)
|
||||
}
|
||||
|
||||
func objPath(kind, ref string) string { return "/" + kind + "/" + url.PathEscape(ref) }
|
||||
|
||||
func get[T any](path string, query url.Values) (T, error) {
|
||||
var out T
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
err = c.Get(ctx(), path, query, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// counters returns the hit counters, or none when they can't be read
|
||||
// (no log:read permission).
|
||||
func counters() map[string]api.Counter {
|
||||
cs, err := get[api.Counters]("/counters", nil)
|
||||
if err != nil {
|
||||
return map[string]api.Counter{}
|
||||
}
|
||||
return cs.Counters
|
||||
}
|
||||
|
||||
func hits(c map[string]api.Counter, marks ...string) string {
|
||||
var p uint64
|
||||
found := false
|
||||
for _, m := range marks {
|
||||
if x, ok := c[m]; ok {
|
||||
p += x.Packets
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return "-"
|
||||
}
|
||||
return humanCount(p)
|
||||
}
|
||||
|
||||
func humanCount(n uint64) string {
|
||||
switch {
|
||||
case n >= 10_000_000_000:
|
||||
return fmt.Sprintf("%dG", n/1_000_000_000)
|
||||
case n >= 10_000_000:
|
||||
return fmt.Sprintf("%dM", n/1_000_000)
|
||||
case n >= 10_000:
|
||||
return fmt.Sprintf("%dk", n/1_000)
|
||||
}
|
||||
return fmt.Sprint(n)
|
||||
}
|
||||
|
||||
func list(vals []string) string { return orDash(strings.Join(vals, ", ")) }
|
||||
|
||||
// patch collects the flags given on an update command.
|
||||
type patch map[string]any
|
||||
|
||||
func deleteCmd(kind, what string) *cobra.Command {
|
||||
var yes bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "delete " + strings.ToUpper(what), Short: "Delete a " + what, Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if !confirm(fmt.Sprintf("Delete %s %s?", what, args[0]), yes) {
|
||||
return fmt.Errorf("not deleted")
|
||||
}
|
||||
return change("DELETE", objPath(kind, args[0]), nil)
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVarP(&yes, "yes", "y", false, "don't ask")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func showCmd[T any](kind, what string, print func(w io.Writer, o T)) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "show " + strings.ToUpper(what), Short: "Show a " + what, Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
o, err := get[T](objPath(kind, args[0]), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return output(o, func(w io.Writer) { print(w, o) })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ---- zones
|
||||
|
||||
func zoneCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{Use: "zone", Aliases: []string{"zones"}, Short: "Zones: groups of interfaces rules refer to"}
|
||||
cmd.AddCommand(&cobra.Command{
|
||||
Use: "list", Short: "List the zones", Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
zs, err := get[[]model.Zone]("/zones", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return output(zs, func(w io.Writer) {
|
||||
fmt.Fprintln(w, "NAME\tINTERFACES\tEXTERNAL\tCOMMENT")
|
||||
for _, z := range zs {
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", z.Name, list(z.Interfaces), yesNo(z.External), z.Comment)
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
cmd.AddCommand(showCmd("zones", "zone", func(w io.Writer, z model.Zone) {
|
||||
fmt.Fprintf(w, "Name:\t%s\nInterfaces:\t%s\nExternal:\t%s\n", z.Name, list(z.Interfaces), yesNo(z.External))
|
||||
if z.Comment != "" {
|
||||
fmt.Fprintf(w, "Comment:\t%s\n", z.Comment)
|
||||
}
|
||||
}))
|
||||
var ifaces []string
|
||||
var external bool
|
||||
var comment string
|
||||
add := &cobra.Command{
|
||||
Use: "add NAME --interface IFACE...", Short: "Add a zone", Args: cobra.ExactArgs(1),
|
||||
Long: `Adds a zone: interfaces rules can then name together. Wildcards match
|
||||
many: --interface 'incusbr*'. --external marks a zone facing the internet:
|
||||
antispoofing, MSS clamping and port forwards apply there.
|
||||
|
||||
hc zone add wan --interface wan --external
|
||||
hc zone add lan --interface eth1`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return change("POST", "/zones", model.Zone{Name: args[0], Interfaces: ifaces, External: external, Comment: comment})
|
||||
},
|
||||
}
|
||||
add.Flags().StringArrayVarP(&ifaces, "interface", "i", nil, "an interface (repeat; a trailing * matches many)")
|
||||
add.Flags().BoolVar(&external, "external", false, "the zone faces the internet")
|
||||
add.Flags().StringVar(&comment, "comment", "", "a comment")
|
||||
cmd.AddCommand(add)
|
||||
var addIf, removeIf []string
|
||||
var name string
|
||||
update := &cobra.Command{
|
||||
Use: "update ZONE", Short: "Change a zone", Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
p := patch{}
|
||||
fl := cmd.Flags()
|
||||
if fl.Changed("interface") || len(addIf) > 0 || len(removeIf) > 0 {
|
||||
cur := ifaces
|
||||
if !fl.Changed("interface") {
|
||||
z, err := get[model.Zone](objPath("zones", args[0]), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cur = z.Interfaces
|
||||
}
|
||||
p["interfaces"] = editList(cur, addIf, removeIf)
|
||||
}
|
||||
if fl.Changed("external") {
|
||||
p["external"] = external
|
||||
}
|
||||
if fl.Changed("comment") {
|
||||
p["comment"] = comment
|
||||
}
|
||||
if fl.Changed("name") {
|
||||
p["name"] = name
|
||||
}
|
||||
return change("PATCH", objPath("zones", args[0]), p)
|
||||
},
|
||||
}
|
||||
f := update.Flags()
|
||||
f.StringArrayVarP(&ifaces, "interface", "i", nil, "replace the interfaces (repeat)")
|
||||
f.StringArrayVar(&addIf, "add", nil, "add an interface")
|
||||
f.StringArrayVar(&removeIf, "remove", nil, "remove an interface")
|
||||
f.BoolVar(&external, "external", false, "the zone faces the internet (--external=false: it doesn't)")
|
||||
f.StringVar(&comment, "comment", "", "a comment")
|
||||
f.StringVar(&name, "name", "", "a new name")
|
||||
cmd.AddCommand(update)
|
||||
cmd.AddCommand(deleteCmd("zones", "zone"))
|
||||
return cmd
|
||||
}
|
||||
|
||||
func editList(cur, add, remove []string) []string {
|
||||
out := []string{}
|
||||
drop := map[string]bool{}
|
||||
for _, r := range remove {
|
||||
drop[r] = true
|
||||
}
|
||||
for _, x := range cur {
|
||||
if !drop[x] {
|
||||
out = append(out, x)
|
||||
}
|
||||
}
|
||||
return append(out, add...)
|
||||
}
|
||||
|
||||
func yesNo(b bool) string {
|
||||
if b {
|
||||
return "yes"
|
||||
}
|
||||
return "no"
|
||||
}
|
||||
|
||||
// ---- sets
|
||||
|
||||
func setCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{Use: "set", Aliases: []string{"sets"}, Short: "Address sets: named lists of IPv4 and IPv6 addresses"}
|
||||
cmd.AddCommand(&cobra.Command{
|
||||
Use: "list", Short: "List the sets", Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ss, err := get[[]model.AddressSet]("/sets", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return output(ss, func(w io.Writer) {
|
||||
fmt.Fprintln(w, "NAME\tENTRIES\tCOMMENT")
|
||||
for _, s := range ss {
|
||||
entries := list(s.Entries)
|
||||
if s.Dynamic {
|
||||
entries = "dynamic"
|
||||
if s.Timeout > 0 {
|
||||
entries += ", timeout " + fmtSeconds(s.Timeout)
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\n", s.Name, entries, s.Comment)
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
cmd.AddCommand(showCmd("sets", "set", func(w io.Writer, s model.AddressSet) {
|
||||
fmt.Fprintf(w, "Name:\t%s\n", s.Name)
|
||||
if s.Dynamic {
|
||||
fmt.Fprintf(w, "Dynamic:\tyes (entries: hc set entries %s)\n", s.Name)
|
||||
if s.Timeout > 0 {
|
||||
fmt.Fprintf(w, "Timeout:\t%s\n", fmtSeconds(s.Timeout))
|
||||
}
|
||||
}
|
||||
for i, e := range s.Entries {
|
||||
label := ""
|
||||
if i == 0 {
|
||||
label = "Entries:"
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s\n", label, e)
|
||||
}
|
||||
if s.Comment != "" {
|
||||
fmt.Fprintf(w, "Comment:\t%s\n", s.Comment)
|
||||
}
|
||||
}))
|
||||
var dynamic bool
|
||||
var timeout, comment string
|
||||
add := &cobra.Command{
|
||||
Use: "add NAME [ENTRY...]", Short: "Add a set", Args: cobra.MinimumNArgs(1),
|
||||
Long: `Adds a set of addresses, prefixes and ranges, IPv4 and IPv6 mixed:
|
||||
|
||||
hc set add trusted 192.168.1.0/24 192.168.2.0/24 2001:db8:1::/64
|
||||
hc set add guests --dynamic --timeout 24h
|
||||
|
||||
A dynamic set takes entries at run time, each with its own timeout ('hc
|
||||
set entries', or the API with a token for that set alone: a captive
|
||||
portal's signed-in clients).`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
s := model.AddressSet{Name: args[0], Entries: args[1:], Dynamic: dynamic, Comment: comment}
|
||||
if timeout != "" {
|
||||
n, err := parseSeconds(timeout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.Timeout = n
|
||||
}
|
||||
return change("POST", "/sets", s)
|
||||
},
|
||||
}
|
||||
add.Flags().BoolVar(&dynamic, "dynamic", false, "entries are added at run time")
|
||||
add.Flags().StringVar(&timeout, "timeout", "", "a dynamic set's default entry lifetime (1h, 7d)")
|
||||
add.Flags().StringVar(&comment, "comment", "", "a comment")
|
||||
cmd.AddCommand(add)
|
||||
var addE, removeE, entries []string
|
||||
var name string
|
||||
update := &cobra.Command{
|
||||
Use: "update SET", Short: "Change a set", Args: cobra.ExactArgs(1),
|
||||
Long: `Changes a set: --add and --remove change entries, --entry replaces them all.
|
||||
|
||||
hc set update trusted --add 10.65.65.0/24 --remove 10.1.1.0/24`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
p := patch{}
|
||||
fl := cmd.Flags()
|
||||
if fl.Changed("entry") || len(addE) > 0 || len(removeE) > 0 {
|
||||
cur := entries
|
||||
if !fl.Changed("entry") {
|
||||
s, err := get[model.AddressSet](objPath("sets", args[0]), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cur = s.Entries
|
||||
}
|
||||
p["entries"] = editList(cur, addE, removeE)
|
||||
}
|
||||
if fl.Changed("timeout") {
|
||||
n, err := parseSeconds(timeout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p["timeout"] = n
|
||||
}
|
||||
if fl.Changed("comment") {
|
||||
p["comment"] = comment
|
||||
}
|
||||
if fl.Changed("name") {
|
||||
p["name"] = name
|
||||
}
|
||||
return change("PATCH", objPath("sets", args[0]), p)
|
||||
},
|
||||
}
|
||||
f := update.Flags()
|
||||
f.StringArrayVar(&entries, "entry", nil, "replace the entries (repeat)")
|
||||
f.StringArrayVar(&addE, "add", nil, "add an entry")
|
||||
f.StringArrayVar(&removeE, "remove", nil, "remove an entry")
|
||||
f.StringVar(&timeout, "timeout", "", "a dynamic set's default entry lifetime")
|
||||
f.StringVar(&comment, "comment", "", "a comment")
|
||||
f.StringVar(&name, "name", "", "a new name")
|
||||
cmd.AddCommand(update)
|
||||
cmd.AddCommand(deleteCmd("sets", "set"))
|
||||
cmd.AddCommand(entriesCmd())
|
||||
return cmd
|
||||
}
|
||||
|
||||
// entriesCmd manages a dynamic set's run-time entries.
|
||||
func entriesCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{Use: "entries SET", Short: "A dynamic set's entries: list, add, remove", Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
es, err := get[[]api.Element](objPath("sets", args[0])+"/elements", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return output(es, func(w io.Writer) {
|
||||
fmt.Fprintln(w, "ADDRESS\tEXPIRES IN")
|
||||
for _, e := range es {
|
||||
exp := "-"
|
||||
if e.Timeout > 0 {
|
||||
exp = fmtSeconds(e.Timeout)
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s\n", e.Address, exp)
|
||||
}
|
||||
})
|
||||
}}
|
||||
var timeout string
|
||||
add := &cobra.Command{Use: "add SET ADDRESS", Short: "Add an entry to a dynamic set", Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
e := api.Element{Address: args[1]}
|
||||
if timeout != "" {
|
||||
n, err := parseSeconds(timeout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
e.Timeout = n
|
||||
}
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.Do(ctx(), "POST", objPath("sets", args[0])+"/elements", nil, e, nil)
|
||||
}}
|
||||
add.Flags().StringVar(&timeout, "timeout", "", "how long it stays (1h; default: the set's)")
|
||||
cmd.AddCommand(add)
|
||||
cmd.AddCommand(&cobra.Command{Use: "remove SET ADDRESS", Short: "Remove an entry from a dynamic set", Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.Do(ctx(), "DELETE", objPath("sets", args[0])+"/elements/"+url.PathEscape(args[1]), nil, nil, nil)
|
||||
}})
|
||||
return cmd
|
||||
}
|
||||
|
||||
// ---- services
|
||||
|
||||
func serviceCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{Use: "service", Aliases: []string{"services"}, Short: "Services: named ports and ICMP types (many are built in)"}
|
||||
var own bool
|
||||
ls := &cobra.Command{
|
||||
Use: "list", Short: "List the services, built-in ones too", Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ss, err := get[[]model.Service]("/services", q("own", map[bool]string{true: "1"}[own]))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return output(ss, func(w io.Writer) {
|
||||
fmt.Fprintln(w, "NAME\tENTRIES\tSOURCE\tCOMMENT")
|
||||
for _, s := range ss {
|
||||
src := "yours"
|
||||
if s.Builtin {
|
||||
src = "built in"
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", s.Name, list(s.Entries), src, s.Comment)
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
ls.Flags().BoolVar(&own, "own", false, "only yours")
|
||||
cmd.AddCommand(ls)
|
||||
cmd.AddCommand(showCmd("services", "service", func(w io.Writer, s model.Service) {
|
||||
fmt.Fprintf(w, "Name:\t%s\nEntries:\t%s\n", s.Name, list(s.Entries))
|
||||
if s.Builtin {
|
||||
fmt.Fprintln(w, "Source:\tbuilt in (add one of the same name to change it)")
|
||||
}
|
||||
}))
|
||||
var comment string
|
||||
add := &cobra.Command{
|
||||
Use: "add NAME ENTRY...", Short: "Add a service", Args: cobra.MinimumNArgs(2),
|
||||
Long: `Adds a service: ports of a protocol, and ICMP types.
|
||||
|
||||
hc service add web tcp/8080 tcp/8443
|
||||
hc service add games udp/27015-27030 tcp/27015
|
||||
hc service add pmtu icmp/destination-unreachable icmpv6/packet-too-big`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return change("POST", "/services", model.Service{Name: args[0], Entries: args[1:], Comment: comment})
|
||||
},
|
||||
}
|
||||
add.Flags().StringVar(&comment, "comment", "", "a comment")
|
||||
cmd.AddCommand(add)
|
||||
var addE, removeE []string
|
||||
update := &cobra.Command{
|
||||
Use: "update SERVICE", Short: "Change a service", Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
p := patch{}
|
||||
if len(addE) > 0 || len(removeE) > 0 {
|
||||
s, err := get[model.Service](objPath("services", args[0]), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p["entries"] = editList(s.Entries, addE, removeE)
|
||||
}
|
||||
if cmd.Flags().Changed("comment") {
|
||||
p["comment"] = comment
|
||||
}
|
||||
return change("PATCH", objPath("services", args[0]), p)
|
||||
},
|
||||
}
|
||||
update.Flags().StringArrayVar(&addE, "add", nil, "add an entry")
|
||||
update.Flags().StringArrayVar(&removeE, "remove", nil, "remove an entry")
|
||||
update.Flags().StringVar(&comment, "comment", "", "a comment")
|
||||
cmd.AddCommand(update)
|
||||
cmd.AddCommand(deleteCmd("services", "service"))
|
||||
return cmd
|
||||
}
|
||||
+764
@@ -0,0 +1,764 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
|
||||
"housecarl/internal/api"
|
||||
"housecarl/internal/model"
|
||||
)
|
||||
|
||||
// matchFlags are the flags of what a rule, source NAT or redirect
|
||||
// matches.
|
||||
type matchFlags struct {
|
||||
in, out, from, to, services []string
|
||||
notIn, notOut, notFrom, notTo bool
|
||||
family string
|
||||
}
|
||||
|
||||
func (m *matchFlags) register(f *pflag.FlagSet, in, out bool) {
|
||||
if in {
|
||||
f.StringArrayVar(&m.in, "in", nil, "coming in on a zone (repeat)")
|
||||
f.BoolVar(&m.notIn, "not-in", false, "on any zone but the --in ones")
|
||||
}
|
||||
if out {
|
||||
f.StringArrayVar(&m.out, "out", nil, "going out on a zone (repeat)")
|
||||
f.BoolVar(&m.notOut, "not-out", false, "on any zone but the --out ones")
|
||||
}
|
||||
f.StringArrayVar(&m.from, "from", nil, "source: a set, address, prefix or range (repeat)")
|
||||
f.BoolVar(&m.notFrom, "not-from", false, "any source but the --from ones")
|
||||
f.StringArrayVar(&m.to, "to", nil, "destination: a set, address, prefix or range (repeat)")
|
||||
f.BoolVar(&m.notTo, "not-to", false, "any destination but the --to ones")
|
||||
f.StringArrayVar(&m.services, "service", nil, "a service (ssh, https, yours) or entry (tcp/8080, udp/1000-2000, icmp/echo-request); repeat")
|
||||
f.StringVar(&m.family, "family", "", "ipv4 or ipv6 only")
|
||||
}
|
||||
|
||||
func (m *matchFlags) match() model.Match {
|
||||
return model.Match{InZones: m.in, NotIn: m.notIn, OutZones: m.out, NotOut: m.notOut, Sources: m.from, NotSources: m.notFrom,
|
||||
Destinations: m.to, NotDests: m.notTo, Services: m.services, Family: m.family}
|
||||
}
|
||||
|
||||
// patch adds the given match flags to an update.
|
||||
func (m *matchFlags) patch(fl *pflag.FlagSet, p patch) {
|
||||
set := func(flag, field string, v any) {
|
||||
if fl.Lookup(flag) != nil && fl.Changed(flag) {
|
||||
p[field] = v
|
||||
}
|
||||
}
|
||||
set("in", "in_zones", m.in)
|
||||
set("not-in", "not_in", m.notIn)
|
||||
set("out", "out_zones", m.out)
|
||||
set("not-out", "not_out", m.notOut)
|
||||
set("from", "sources", m.from)
|
||||
set("not-from", "not_sources", m.notFrom)
|
||||
set("to", "destinations", m.to)
|
||||
set("not-to", "not_destinations", m.notTo)
|
||||
set("service", "services", m.services)
|
||||
set("family", "family", m.family)
|
||||
}
|
||||
|
||||
// describe sums up a match: "in lan from trusted ssh, dns".
|
||||
func describe(m model.Match) string {
|
||||
var parts []string
|
||||
side := func(word string, not bool, list []string) {
|
||||
if len(list) == 0 {
|
||||
return
|
||||
}
|
||||
if not {
|
||||
word += " not"
|
||||
}
|
||||
parts = append(parts, word+" "+strings.Join(list, ","))
|
||||
}
|
||||
side("in", m.NotIn, m.InZones)
|
||||
side("out", m.NotOut, m.OutZones)
|
||||
side("from", m.NotSources, m.Sources)
|
||||
side("to", m.NotDests, m.Destinations)
|
||||
if len(m.Services) > 0 {
|
||||
parts = append(parts, strings.Join(m.Services, ","))
|
||||
}
|
||||
if m.Family != "" {
|
||||
parts = append(parts, m.Family+" only")
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return "everything"
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
// ---- rules
|
||||
|
||||
type ruleFlags struct {
|
||||
matchFlags
|
||||
action, rejectWith, comment, logPrefix, raw string
|
||||
states, statuses, days []string
|
||||
hours, limit, quota string
|
||||
connLimit, groupV4, groupV6 int
|
||||
log, disabled bool
|
||||
before, after int64
|
||||
first bool
|
||||
}
|
||||
|
||||
func (r *ruleFlags) register(f *pflag.FlagSet) {
|
||||
r.matchFlags.register(f, true, true)
|
||||
f.StringVarP(&r.action, "action", "a", "accept", "accept, drop, reject, or log (log and go on)")
|
||||
f.StringVar(&r.rejectWith, "reject-with", "", "tcp-reset (default: an ICMP error)")
|
||||
f.StringSliceVar(&r.states, "state", nil, "connection states: new, established, related, invalid, untracked")
|
||||
f.StringSliceVar(&r.statuses, "status", nil, "connection status: dnat, snat")
|
||||
f.StringSliceVar(&r.days, "days", nil, "days it applies: Monday,Tuesday,…")
|
||||
f.StringVar(&r.hours, "hours", "", "hours it applies: 08:00-17:30 (22:00-06:00 passes midnight)")
|
||||
f.StringVar(&r.limit, "limit", "", "a rate: 10/minute, or bytes: 1mbytes/second, 500kbytes/minute; with ,burst=N ,over (beyond the rate) ,per-source or ,per-destination")
|
||||
f.IntVar(&r.connLimit, "conn-limit", 0, "match a source with more than this many connections")
|
||||
f.StringVar(&r.quota, "quota", "", "an allowance: 5000mbytes or 5gbytes, with ,over (match once used up) ,per-source or ,per-destination, and ,daily ,weekly or ,monthly=DAY to reset")
|
||||
f.IntVar(&r.groupV4, "group-v4", 0, "per-source/destination limits and quotas count IPv4 addresses by this prefix (default 32: each address)")
|
||||
f.IntVar(&r.groupV6, "group-v6", 0, "…and IPv6 addresses by this prefix (default 64: one device or network)")
|
||||
f.BoolVar(&r.log, "log", false, "log matching packets ('hc log')")
|
||||
f.StringVar(&r.logPrefix, "log-prefix", "", "the log line's prefix")
|
||||
f.StringVar(&r.raw, "raw", "", "an nftables rule written by hand instead of the match flags")
|
||||
f.StringVar(&r.comment, "comment", "", "a comment")
|
||||
f.BoolVar(&r.disabled, "disabled", false, "keep the rule but don't use it")
|
||||
}
|
||||
|
||||
var amountRe = regexp.MustCompile(`^(\d+)\s*([a-zA-Z]*)$`)
|
||||
|
||||
// byteUnit reads a unit: "" (packets, for rates), bytes, kbytes, mbytes,
|
||||
// gbytes, or b, kb, mb, gb in any case.
|
||||
func byteUnit(u string) (string, bool) {
|
||||
switch strings.ToLower(u) {
|
||||
case "", "packets", "packet", "p":
|
||||
return "", true
|
||||
case "bytes", "byte", "b":
|
||||
return "bytes", true
|
||||
case "kbytes", "kb", "k":
|
||||
return "kbytes", true
|
||||
case "mbytes", "mb", "m":
|
||||
return "mbytes", true
|
||||
case "gbytes", "gb", "g":
|
||||
return "gbytes", true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func parseLimit(s string) (*model.Limit, error) {
|
||||
if s == "" {
|
||||
return nil, nil
|
||||
}
|
||||
parts := strings.Split(s, ",")
|
||||
rate, per, ok := strings.Cut(strings.TrimSpace(parts[0]), "/")
|
||||
m := amountRe.FindStringSubmatch(strings.TrimSpace(rate))
|
||||
unit, unitOK := "", false
|
||||
if m != nil {
|
||||
unit, unitOK = byteUnit(m[2])
|
||||
}
|
||||
if !ok || m == nil || !unitOK || unit == "gbytes" {
|
||||
return nil, fmt.Errorf("--limit %q: write it as 10/minute, or as bytes 1mbytes/second (bytes, kbytes, mbytes), with ,burst=N ,over ,per-source", s)
|
||||
}
|
||||
n, _ := strconv.Atoi(m[1])
|
||||
l := &model.Limit{Rate: n, Unit: unit, Per: strings.TrimSuffix(per, "s")}
|
||||
for _, p := range parts[1:] {
|
||||
k, v, _ := strings.Cut(strings.TrimSpace(p), "=")
|
||||
switch k {
|
||||
case "burst":
|
||||
b := amountRe.FindStringSubmatch(v)
|
||||
bu, buOK := "", false
|
||||
if b != nil {
|
||||
bu, buOK = byteUnit(b[2])
|
||||
}
|
||||
if b == nil || !buOK || (b[2] != "" && bu != unit) {
|
||||
return nil, fmt.Errorf("--limit: burst=%q: a number, in the rate's unit", v)
|
||||
}
|
||||
l.Burst, _ = strconv.Atoi(b[1])
|
||||
case "over":
|
||||
l.Over = true
|
||||
case "per-source":
|
||||
l.PerSource = true
|
||||
case "per-destination":
|
||||
l.PerDestination = true
|
||||
default:
|
||||
return nil, fmt.Errorf("--limit: unknown %q (burst=N, over, per-source, per-destination)", k)
|
||||
}
|
||||
}
|
||||
return l, nil
|
||||
}
|
||||
|
||||
// parseQuota reads --quota: 5000mbytes,over,per-source,monthly=15.
|
||||
func parseQuota(s string) (*model.Quota, error) {
|
||||
if s == "" {
|
||||
return nil, nil
|
||||
}
|
||||
parts := strings.Split(s, ",")
|
||||
m := amountRe.FindStringSubmatch(strings.TrimSpace(parts[0]))
|
||||
unit, ok := "", false
|
||||
if m != nil {
|
||||
unit, ok = byteUnit(m[2])
|
||||
}
|
||||
if m == nil || !ok || unit == "" {
|
||||
return nil, fmt.Errorf("--quota %q: write it as 5000mbytes or 5gbytes, with ,over ,per-source ,daily ,weekly or ,monthly=DAY", s)
|
||||
}
|
||||
n, _ := strconv.ParseInt(m[1], 10, 64)
|
||||
q := &model.Quota{Bytes: n * model.ByteUnits[unit]}
|
||||
for _, p := range parts[1:] {
|
||||
k, v, _ := strings.Cut(strings.TrimSpace(p), "=")
|
||||
switch k {
|
||||
case "over":
|
||||
q.Over = true
|
||||
case "per-source":
|
||||
q.PerSource = true
|
||||
case "per-destination":
|
||||
q.PerDestination = true
|
||||
case "daily", "weekly":
|
||||
q.Reset = k
|
||||
case "monthly":
|
||||
q.Reset = k
|
||||
if v != "" {
|
||||
d, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("--quota: monthly=%q: the day of the month, 1 to 31", v)
|
||||
}
|
||||
q.Day = d
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("--quota: unknown %q (over, per-source, per-destination, daily, weekly, monthly=DAY)", k)
|
||||
}
|
||||
}
|
||||
return q, nil
|
||||
}
|
||||
|
||||
// humanBytes writes bytes for people: "1.8 GB".
|
||||
func humanBytes(n int64) string {
|
||||
switch {
|
||||
case n >= 1<<30:
|
||||
return strconv.FormatFloat(float64(n)/(1<<30), 'f', 1, 64) + " GB"
|
||||
case n >= 1<<20:
|
||||
return strconv.FormatFloat(float64(n)/(1<<20), 'f', 1, 64) + " MB"
|
||||
case n >= 1<<10:
|
||||
return strconv.FormatFloat(float64(n)/(1<<10), 'f', 1, 64) + " KB"
|
||||
}
|
||||
return strconv.FormatInt(n, 10) + " bytes"
|
||||
}
|
||||
|
||||
func (r *ruleFlags) rule(chain string) (model.Rule, error) {
|
||||
rule := model.Rule{Chain: chain, Match: r.match(), Action: r.action, RejectWith: r.rejectWith, CtState: r.states, CtStatus: r.statuses,
|
||||
Days: r.days, ConnLimit: r.connLimit, Log: r.log, LogPrefix: r.logPrefix, Raw: r.raw, Comment: r.comment, Disabled: r.disabled}
|
||||
if r.hours != "" {
|
||||
from, to, ok := strings.Cut(r.hours, "-")
|
||||
if !ok {
|
||||
return rule, fmt.Errorf("--hours: write it as 08:00-17:30")
|
||||
}
|
||||
rule.HourFrom, rule.HourTo = from, to
|
||||
}
|
||||
l, err := parseLimit(r.limit)
|
||||
if err != nil {
|
||||
return rule, err
|
||||
}
|
||||
rule.Limit = l
|
||||
rule.Quota, err = parseQuota(r.quota)
|
||||
rule.GroupV4, rule.GroupV6 = r.groupV4, r.groupV6
|
||||
return rule, err
|
||||
}
|
||||
|
||||
func ruleCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{Use: "rule", Aliases: []string{"rules"}, Short: "Rules: what to accept, drop or reject, in order"}
|
||||
ls := &cobra.Command{
|
||||
Use: "list [CHAIN]", Short: "List the rules, in order, with their hits", Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
query := q()
|
||||
if len(args) == 1 {
|
||||
query = q("chain", args[0])
|
||||
}
|
||||
rs, err := get[[]model.Rule]("/rules", query)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cs := counters()
|
||||
return output(rs, func(w io.Writer) {
|
||||
fmt.Fprintln(w, "CHAIN\tID\tMATCH\tACTION\tHITS\tCOMMENT")
|
||||
for _, r := range rs {
|
||||
what := describe(r.Match)
|
||||
if r.Raw != "" {
|
||||
what = "raw: " + r.Raw
|
||||
}
|
||||
extra := ruleExtras(r)
|
||||
action := r.Action
|
||||
if r.Log {
|
||||
action += "+log"
|
||||
}
|
||||
if r.Disabled {
|
||||
action += " (disabled)"
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%d\t%s%s\t%s\t%s\t%s\n", r.Chain, r.ID, what, extra, action, hits(cs, fmt.Sprintf("r%d", r.ID)), r.Comment)
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
cmd.AddCommand(ls)
|
||||
cmd.AddCommand(showCmd("rules", "rule", func(w io.Writer, r model.Rule) {
|
||||
fmt.Fprintf(w, "ID:\t%d\nChain:\t%s\nMatch:\t%s%s\nAction:\t%s\n", r.ID, r.Chain, describe(r.Match), ruleExtras(r), r.Action)
|
||||
if r.Raw != "" {
|
||||
fmt.Fprintf(w, "Raw:\t%s\n", r.Raw)
|
||||
}
|
||||
if r.Log {
|
||||
fmt.Fprintf(w, "Log:\tyes %s\n", r.LogPrefix)
|
||||
}
|
||||
if r.Comment != "" {
|
||||
fmt.Fprintf(w, "Comment:\t%s\n", r.Comment)
|
||||
}
|
||||
cs := counters()
|
||||
fmt.Fprintf(w, "Hits:\t%s\n", hits(cs, fmt.Sprintf("r%d", r.ID)))
|
||||
}))
|
||||
var rf ruleFlags
|
||||
add := &cobra.Command{
|
||||
Use: "add CHAIN [flags]", Short: "Add a rule (at the end of its chain unless --first, --before or --after)", Args: cobra.ExactArgs(1),
|
||||
Long: `Adds a rule to a chain: input (to the firewall), forward (through it),
|
||||
output (from it) or early (before connection tracking: cheap drops).
|
||||
|
||||
hc rule add input --from trusted --service ssh
|
||||
hc rule add input --in wg --service dns
|
||||
hc rule add forward --in lan --out wan --from 2001:db8:1::/64
|
||||
hc rule add input --in wan --service ssh --limit 3/minute,burst=5,over,per-source -a drop --log
|
||||
hc rule add forward --in wan -a drop --comment "nothing else gets in"
|
||||
hc rule add forward --raw 'iifname != "wan" ct state invalid ip daddr @trusted_v4 accept'`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
r, err := rf.rule(args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var resp api.MutationResponse
|
||||
if err := c.Do(ctx(), "POST", "/rules", nil, r, &resp); err != nil {
|
||||
return err
|
||||
}
|
||||
// Then place it.
|
||||
if resp.Applied && (rf.first || rf.before != 0 || rf.after != 0) {
|
||||
var created model.Rule
|
||||
if err := jsonUnmarshal(resp.Object, &created); err == nil {
|
||||
mv := api.MoveInput{Before: rf.before, After: rf.after}
|
||||
if rf.first {
|
||||
mv.To = "start"
|
||||
}
|
||||
var moved api.MutationResponse
|
||||
if err := c.Do(ctx(), "POST", objPath("rules", fmt.Sprint(created.ID))+"/move", nil, mv, &moved); err != nil {
|
||||
return err
|
||||
}
|
||||
resp.Changes = append(resp.Changes, moved.Changes...)
|
||||
resp.ConfirmBy = moved.ConfirmBy
|
||||
}
|
||||
}
|
||||
return report(&resp)
|
||||
},
|
||||
}
|
||||
rf.register(add.Flags())
|
||||
add.Flags().BoolVar(&rf.first, "first", false, "put it first in its chain")
|
||||
add.Flags().Int64Var(&rf.before, "before", 0, "put it before rule ID")
|
||||
add.Flags().Int64Var(&rf.after, "after", 0, "put it after rule ID")
|
||||
cmd.AddCommand(add)
|
||||
var uf ruleFlags
|
||||
update := &cobra.Command{
|
||||
Use: "update ID [flags]", Short: "Change a rule (only the flags given)", Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
fl := cmd.Flags()
|
||||
p := patch{}
|
||||
uf.matchFlags.patch(fl, p)
|
||||
r, err := uf.rule("")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for flag, field := range map[string][2]any{
|
||||
"action": {"action", r.Action}, "reject-with": {"reject_with", r.RejectWith}, "state": {"ct_state", r.CtState},
|
||||
"status": {"ct_status", r.CtStatus}, "days": {"days", r.Days}, "conn-limit": {"conn_limit", r.ConnLimit},
|
||||
"log": {"log", r.Log}, "log-prefix": {"log_prefix", r.LogPrefix}, "raw": {"raw", r.Raw}, "comment": {"comment", r.Comment},
|
||||
"disabled": {"disabled", r.Disabled}, "group-v4": {"group_v4", r.GroupV4}, "group-v6": {"group_v6", r.GroupV6},
|
||||
} {
|
||||
if fl.Changed(flag) {
|
||||
p[field[0].(string)] = field[1]
|
||||
}
|
||||
}
|
||||
if fl.Changed("hours") {
|
||||
p["hour_from"], p["hour_to"] = r.HourFrom, r.HourTo
|
||||
}
|
||||
if fl.Changed("limit") {
|
||||
p["limit"] = r.Limit
|
||||
}
|
||||
if fl.Changed("quota") {
|
||||
p["quota"] = r.Quota
|
||||
}
|
||||
return change("PATCH", objPath("rules", args[0]), p)
|
||||
},
|
||||
}
|
||||
uf.register(update.Flags())
|
||||
cmd.AddCommand(update)
|
||||
var before, after int64
|
||||
var first, last bool
|
||||
var chain string
|
||||
move := &cobra.Command{
|
||||
Use: "move ID (--before ID | --after ID | --first | --last | --chain CHAIN)", Short: "Move a rule", Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
mv := api.MoveInput{Before: before, After: after, Chain: chain}
|
||||
switch {
|
||||
case first:
|
||||
mv.To = "start"
|
||||
case last:
|
||||
mv.To = "end"
|
||||
}
|
||||
return change("POST", objPath("rules", args[0])+"/move", mv)
|
||||
},
|
||||
}
|
||||
move.Flags().Int64Var(&before, "before", 0, "before rule ID")
|
||||
move.Flags().Int64Var(&after, "after", 0, "after rule ID")
|
||||
move.Flags().BoolVar(&first, "first", false, "first in its chain")
|
||||
move.Flags().BoolVar(&last, "last", false, "last in its chain")
|
||||
move.Flags().StringVar(&chain, "chain", "", "to the end of another chain")
|
||||
cmd.AddCommand(move)
|
||||
cmd.AddCommand(&cobra.Command{
|
||||
Use: "reset-quota ID", Short: "Start a rule's quota again now (every address's, for a per-address quota)", Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.Do(ctx(), "POST", objPath("rules", args[0])+"/reset-quota", nil, nil, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
if !flagQuiet {
|
||||
fmt.Printf("reset the quota of rule %s\n", args[0])
|
||||
}
|
||||
return nil
|
||||
},
|
||||
})
|
||||
for _, on := range []bool{true, false} {
|
||||
verb, short := "disable", "Keep a rule but don't use it"
|
||||
if on {
|
||||
verb, short = "enable", "Use a disabled rule again"
|
||||
}
|
||||
cmd.AddCommand(&cobra.Command{Use: verb + " ID", Short: short, Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return change("PATCH", objPath("rules", args[0]), patch{"disabled": !on})
|
||||
}})
|
||||
}
|
||||
cmd.AddCommand(deleteCmd("rules", "rule"))
|
||||
return cmd
|
||||
}
|
||||
|
||||
func ruleExtras(r model.Rule) string {
|
||||
var x []string
|
||||
if len(r.CtState) > 0 {
|
||||
x = append(x, "state "+strings.Join(r.CtState, ","))
|
||||
}
|
||||
if len(r.CtStatus) > 0 {
|
||||
x = append(x, "status "+strings.Join(r.CtStatus, ","))
|
||||
}
|
||||
if len(r.Days) > 0 {
|
||||
x = append(x, strings.Join(r.Days, ","))
|
||||
}
|
||||
if r.HourFrom != "" {
|
||||
x = append(x, r.HourFrom+"-"+r.HourTo)
|
||||
}
|
||||
if l := r.Limit; l != nil {
|
||||
unit := ""
|
||||
if l.Unit != "" && l.Unit != "packets" {
|
||||
unit = " " + l.Unit
|
||||
}
|
||||
s := fmt.Sprintf("limit %d%s/%s", l.Rate, unit, l.Per)
|
||||
if l.Over {
|
||||
s = "over " + s
|
||||
}
|
||||
if l.PerSource {
|
||||
s += " per source"
|
||||
}
|
||||
if l.PerDestination {
|
||||
s += " per destination"
|
||||
}
|
||||
x = append(x, s)
|
||||
}
|
||||
if q := r.Quota; q != nil {
|
||||
s := "quota " + humanBytes(q.Bytes)
|
||||
if q.Over {
|
||||
s = "over " + s
|
||||
}
|
||||
if q.PerSource {
|
||||
s += " per source"
|
||||
}
|
||||
if q.PerDestination {
|
||||
s += " per destination"
|
||||
}
|
||||
switch {
|
||||
case q.Reset == "monthly" && q.Day > 1:
|
||||
s += fmt.Sprintf(" monthly (%d)", q.Day)
|
||||
case q.Reset != "":
|
||||
s += " " + q.Reset
|
||||
}
|
||||
x = append(x, s)
|
||||
}
|
||||
if r.ConnLimit > 0 {
|
||||
x = append(x, fmt.Sprintf("over %d connections", r.ConnLimit))
|
||||
}
|
||||
if len(x) == 0 {
|
||||
return ""
|
||||
}
|
||||
return " " + strings.Join(x, " ")
|
||||
}
|
||||
|
||||
// ---- port forwards
|
||||
|
||||
func forwardCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{Use: "forward", Aliases: []string{"forwards", "port-forward"}, Short: "Port forwards: publish an inside host's service"}
|
||||
cmd.AddCommand(&cobra.Command{
|
||||
Use: "list", Short: "List the port forwards, with their hits", Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
fs, err := get[[]model.PortForward]("/forwards", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cs := counters()
|
||||
return output(fs, func(w io.Writer) {
|
||||
fmt.Fprintln(w, "ID\tNAME\tPUBLIC\tTO\tTO6\tFROM\tHAIRPIN\tHITS")
|
||||
for _, f := range fs {
|
||||
pub := list(f.PublicAddresses)
|
||||
if len(f.PublicAddresses) == 0 {
|
||||
pub = "(external zones)"
|
||||
}
|
||||
to := orDash(f.To)
|
||||
if f.ToPort != "" {
|
||||
to += ":" + f.ToPort
|
||||
}
|
||||
name := orDash(f.Name)
|
||||
if f.Disabled {
|
||||
name += " (disabled)"
|
||||
}
|
||||
fmt.Fprintf(w, "%d\t%s\t%s %s/%s\t%s\t%s\t%s\t%s\t%s\n", f.ID, name, pub, strings.Join(f.Protocols, ","), f.Ports, to,
|
||||
orDash(f.To6), list(f.Sources), yesNo(f.Hairpin), hits(cs, fmt.Sprintf("f%d", f.ID), fmt.Sprintf("f%d-6", f.ID)))
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
cmd.AddCommand(showCmd("forwards", "forward", func(w io.Writer, f model.PortForward) {
|
||||
fmt.Fprintf(w, "ID:\t%d\nName:\t%s\nProtocols:\t%s\nPorts:\t%s\nPublic:\t%s\nTo:\t%s\n", f.ID, orDash(f.Name), list(f.Protocols), f.Ports, list(f.PublicAddresses), orDash(f.To))
|
||||
if f.ToPort != "" {
|
||||
fmt.Fprintf(w, "To port:\t%s\n", f.ToPort)
|
||||
}
|
||||
fmt.Fprintf(w, "To (IPv6):\t%s\nFrom:\t%s\nHairpin:\t%s %s\n", orDash(f.To6), list(f.Sources), yesNo(f.Hairpin), list(f.HairpinSources))
|
||||
}))
|
||||
var pf model.PortForward
|
||||
var to string
|
||||
add := &cobra.Command{
|
||||
Use: "add PORTS --to HOST[:PORT] [flags]", Short: "Add a port forward", Args: cobra.ExactArgs(1),
|
||||
Long: `Forwards a public port (or range) to an inside host, lets the connections
|
||||
through, and with --hairpin makes the public address work from inside too.
|
||||
--to6 also opens the port to an IPv6 host (no translation there: its own
|
||||
port).
|
||||
|
||||
hc forward add 8443 --to 192.168.1.10:443 --to6 2001:db8:1::10 --public 203.0.113.10 --hairpin --name web
|
||||
hc forward add 27015-27030 --proto tcp,udp --to 192.168.1.20 --public 203.0.113.10 --hairpin --name game
|
||||
hc forward add 25 --to 192.168.1.25 --from trusted`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
pf.Ports = args[0]
|
||||
if to != "" {
|
||||
host, port, ok := strings.Cut(to, ":")
|
||||
if ok && strings.Count(to, ":") == 1 {
|
||||
pf.To, pf.ToPort = host, port
|
||||
} else {
|
||||
pf.To = to
|
||||
}
|
||||
}
|
||||
return change("POST", "/forwards", pf)
|
||||
},
|
||||
}
|
||||
f := add.Flags()
|
||||
f.StringVar(&pf.Name, "name", "", "a name")
|
||||
f.StringSliceVar(&pf.Protocols, "proto", []string{"tcp"}, "tcp, udp or both")
|
||||
f.StringArrayVar(&pf.PublicAddresses, "public", nil, "a public IPv4 address or set to match (repeat; default: any address on the external zones)")
|
||||
f.StringArrayVar(&pf.Sources, "from", nil, "only from these sources (repeat)")
|
||||
f.StringVar(&to, "to", "", "the inside IPv4 host, with :PORT if it differs")
|
||||
f.StringVar(&pf.To6, "to6", "", "an inside IPv6 host to open the port to")
|
||||
f.BoolVar(&pf.Hairpin, "hairpin", false, "make the public address work from inside")
|
||||
f.StringArrayVar(&pf.HairpinSources, "hairpin-from", nil, "only for these inside sources (repeat; default: all inside)")
|
||||
f.StringVar(&pf.Comment, "comment", "", "a comment")
|
||||
cmd.AddCommand(add)
|
||||
cmd.AddCommand(objectUpdate("forwards", "forward", map[string]string{"name": "name", "proto": "protocols", "public": "public_addresses",
|
||||
"from": "sources", "to6": "to6", "hairpin": "hairpin", "hairpin-from": "hairpin_sources", "comment": "comment", "disabled": "disabled",
|
||||
"ports": "ports", "to": "to", "to-port": "to_port"}, func(f *pflag.FlagSet) {
|
||||
f.String("name", "", "a name")
|
||||
f.StringSlice("proto", nil, "tcp, udp or both")
|
||||
f.StringArray("public", nil, "public addresses (replace)")
|
||||
f.StringArray("from", nil, "sources (replace)")
|
||||
f.String("ports", "", "the public port or range")
|
||||
f.String("to", "", "the inside IPv4 host")
|
||||
f.String("to-port", "", "the inside port (\"\" keeps the public one)")
|
||||
f.String("to6", "", "the inside IPv6 host (\"\": none)")
|
||||
f.Bool("hairpin", false, "make the public address work from inside")
|
||||
f.StringArray("hairpin-from", nil, "hairpin sources (replace)")
|
||||
f.String("comment", "", "a comment")
|
||||
f.Bool("disabled", false, "keep it but don't use it")
|
||||
}))
|
||||
cmd.AddCommand(deleteCmd("forwards", "forward"))
|
||||
return cmd
|
||||
}
|
||||
|
||||
// objectUpdate makes an update command whose flags map one to one onto
|
||||
// fields.
|
||||
func objectUpdate(kind, what string, fields map[string]string, register func(*pflag.FlagSet)) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "update " + strings.ToUpper(what) + " [flags]", Short: "Change a " + what + " (only the flags given)", Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
p := patch{}
|
||||
var err error
|
||||
cmd.Flags().Visit(func(fl *pflag.Flag) {
|
||||
field, ok := fields[fl.Name]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
switch fl.Value.Type() {
|
||||
case "bool":
|
||||
p[field] = fl.Value.String() == "true"
|
||||
case "stringSlice":
|
||||
v, _ := cmd.Flags().GetStringSlice(fl.Name)
|
||||
p[field] = v
|
||||
case "stringArray":
|
||||
v, _ := cmd.Flags().GetStringArray(fl.Name)
|
||||
p[field] = v
|
||||
default:
|
||||
p[field] = fl.Value.String()
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return change("PATCH", objPath(kind, args[0]), p)
|
||||
},
|
||||
}
|
||||
register(cmd.Flags())
|
||||
return cmd
|
||||
}
|
||||
|
||||
// ---- source NAT
|
||||
|
||||
func snatCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{Use: "snat", Aliases: []string{"masquerade"}, Short: "Source NAT: masquerade or rewrite outgoing sources"}
|
||||
cmd.AddCommand(&cobra.Command{
|
||||
Use: "list", Short: "List the source NAT rules, in order", Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
xs, err := get[[]model.SourceNAT]("/snats", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cs := counters()
|
||||
return output(xs, func(w io.Writer) {
|
||||
fmt.Fprintln(w, "ID\tMATCH\tTO\tHITS\tCOMMENT")
|
||||
for _, x := range xs {
|
||||
to := x.To
|
||||
if to == "" {
|
||||
to = "masquerade"
|
||||
}
|
||||
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\n", x.ID, describe(x.Match), to, hits(cs, fmt.Sprintf("s%d", x.ID)), x.Comment)
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
var mf matchFlags
|
||||
var to, comment string
|
||||
add := &cobra.Command{
|
||||
Use: "add [flags]", Short: "Add a source NAT rule", Args: cobra.NoArgs,
|
||||
Long: `Masquerades (or with --snat-to, rewrites to an address) the source of
|
||||
traffic leaving the firewall. IPv4 only, unless --family ipv6 (NAT66).
|
||||
|
||||
hc snat add --out wan --from trusted
|
||||
hc snat add --out wg --not-out --from wg_net
|
||||
hc snat add --out wan --from fd00:1::/48 --family ipv6 --snat-to 2001:db8::1`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return change("POST", "/snats", model.SourceNAT{Match: mf.match(), To: to, Comment: comment})
|
||||
},
|
||||
}
|
||||
mf.register(add.Flags(), true, true)
|
||||
add.Flags().StringVar(&to, "snat-to", "", "rewrite to this address instead of masquerading")
|
||||
add.Flags().StringVar(&comment, "comment", "", "a comment")
|
||||
cmd.AddCommand(add)
|
||||
cmd.AddCommand(natUpdate("snats", "snat", true))
|
||||
cmd.AddCommand(deleteCmd("snats", "snat"))
|
||||
return cmd
|
||||
}
|
||||
|
||||
// natUpdate changes a source NAT rule or redirect.
|
||||
func natUpdate(kind, what string, out bool) *cobra.Command {
|
||||
var mf matchFlags
|
||||
var to, comment string
|
||||
var disabled bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "update ID [flags]", Short: "Change a " + what + " (only the flags given)", Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
fl := cmd.Flags()
|
||||
p := patch{}
|
||||
mf.patch(fl, p)
|
||||
if fl.Changed("snat-to") {
|
||||
p["to"] = to
|
||||
}
|
||||
if fl.Changed("to-port") {
|
||||
p["to_port"] = to
|
||||
}
|
||||
if fl.Changed("comment") {
|
||||
p["comment"] = comment
|
||||
}
|
||||
if fl.Changed("disabled") {
|
||||
p["disabled"] = disabled
|
||||
}
|
||||
return change("PATCH", objPath(kind, args[0]), p)
|
||||
},
|
||||
}
|
||||
mf.register(cmd.Flags(), true, out)
|
||||
if out {
|
||||
cmd.Flags().StringVar(&to, "snat-to", "", "rewrite to this address (\"\": masquerade)")
|
||||
} else {
|
||||
cmd.Flags().StringVar(&to, "to-port", "", "the local port")
|
||||
}
|
||||
cmd.Flags().StringVar(&comment, "comment", "", "a comment")
|
||||
cmd.Flags().BoolVar(&disabled, "disabled", false, "keep it but don't use it")
|
||||
return cmd
|
||||
}
|
||||
|
||||
// ---- redirects
|
||||
|
||||
func redirectCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{Use: "redirect", Aliases: []string{"redirects"}, Short: "Redirects: send traffic to a port on the firewall itself"}
|
||||
cmd.AddCommand(&cobra.Command{
|
||||
Use: "list", Short: "List the redirects", Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
xs, err := get[[]model.Redirect]("/redirects", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cs := counters()
|
||||
return output(xs, func(w io.Writer) {
|
||||
fmt.Fprintln(w, "ID\tMATCH\tTO PORT\tHITS\tCOMMENT")
|
||||
for _, x := range xs {
|
||||
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\n", x.ID, describe(x.Match), orDash(x.ToPort), hits(cs, fmt.Sprintf("d%d", x.ID)), x.Comment)
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
var mf matchFlags
|
||||
var toPort, comment string
|
||||
add := &cobra.Command{
|
||||
Use: "add [flags]", Short: "Add a redirect", Args: cobra.NoArgs,
|
||||
Long: `Sends matching traffic to a port on the firewall itself, so every client
|
||||
uses its DNS server, say:
|
||||
|
||||
hc redirect add --in lan --service dns --not-to 192.168.1.5 --comment "force DNS"`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return change("POST", "/redirects", model.Redirect{Match: mf.match(), ToPort: toPort, Comment: comment})
|
||||
},
|
||||
}
|
||||
mf.register(add.Flags(), true, false)
|
||||
add.Flags().StringVar(&toPort, "to-port", "", "the local port (default: the service's)")
|
||||
add.Flags().StringVar(&comment, "comment", "", "a comment")
|
||||
cmd.AddCommand(add)
|
||||
cmd.AddCommand(natUpdate("redirects", "redirect", false))
|
||||
cmd.AddCommand(deleteCmd("redirects", "redirect"))
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// errInUse means another process holds the database lock.
|
||||
var errInUse = errors.New("the database is in use by a running housecarld or by 'housecarld restore'")
|
||||
|
||||
// lockDatabase takes the exclusive lock (on DATABASE.lock) that marks the
|
||||
// database as in use by a running housecarld or by 'housecarld restore', so a
|
||||
// restore can never replace the database under a running housecarld, and two
|
||||
// housecarlds can't serve the same database. Other commands (backup, user, ...)
|
||||
// don't take it: they work alongside a running housecarld.
|
||||
func lockDatabase(db string) (release func(), err error) {
|
||||
if err := os.MkdirAll(filepath.Dir(db), 0o750); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
path := db + ".lock"
|
||||
// Read-only is enough for flock, and works whoever created the file.
|
||||
f, err := os.OpenFile(path, os.O_RDONLY|os.O_CREATE, 0o640)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
|
||||
f.Close()
|
||||
if errors.Is(err, syscall.EWOULDBLOCK) {
|
||||
return nil, errInUse
|
||||
}
|
||||
return nil, fmt.Errorf("locking %s: %w", path, err)
|
||||
}
|
||||
return func() {
|
||||
syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
|
||||
f.Close()
|
||||
}, nil
|
||||
}
|
||||
+644
-7
@@ -1,17 +1,47 @@
|
||||
// Command housecarld is Housecarl's daemon: it will keep the firewall's
|
||||
// nftables ruleset, and serve its REST API and web UI (docs/design.md).
|
||||
// For now it only knows its version.
|
||||
// Command housecarld is Housecarl's daemon: it keeps the firewall's
|
||||
// ruleset and serves the REST API and the web UI. It also has the commands
|
||||
// that work on the database directly (init, user, backup, restore, check).
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/term"
|
||||
|
||||
"housecarl/internal/auth"
|
||||
"housecarl/internal/certs"
|
||||
"housecarl/internal/config"
|
||||
"housecarl/internal/engine"
|
||||
"housecarl/internal/hostif"
|
||||
"housecarl/internal/jarl"
|
||||
"housecarl/internal/model"
|
||||
"housecarl/internal/nflog"
|
||||
"housecarl/internal/nft"
|
||||
"housecarl/internal/render"
|
||||
"housecarl/internal/server"
|
||||
"housecarl/internal/store"
|
||||
"housecarl/internal/validate"
|
||||
"housecarl/internal/version"
|
||||
"housecarl/internal/web"
|
||||
)
|
||||
|
||||
var configPath string
|
||||
|
||||
func main() {
|
||||
if err := rootCmd().Execute(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error:", err)
|
||||
@@ -23,17 +53,624 @@ func rootCmd() *cobra.Command {
|
||||
root := &cobra.Command{
|
||||
Use: "housecarld",
|
||||
Short: "Housecarl's firewall daemon, API and web UI",
|
||||
Long: `housecarld will keep a Linux firewall's nftables ruleset, built from its
|
||||
database, and serve the REST API and web UI that change it. It doesn't yet:
|
||||
Housecarl is at its start (docs/design.md).`,
|
||||
SilenceUsage: true,
|
||||
SilenceErrors: true,
|
||||
Version: version.String(),
|
||||
}
|
||||
root.AddCommand(versionCmd())
|
||||
root.PersistentFlags().StringVarP(&configPath, "config", "c", config.DefaultPath, "configuration file")
|
||||
root.AddCommand(serveCmd(), initCmd(), userCmd(), checkCmd(), backupCmd(), restoreCmd(), configCmd(), versionCmd())
|
||||
return root
|
||||
}
|
||||
|
||||
func newLogger(cfg *config.Config, w io.Writer) *slog.Logger {
|
||||
var level slog.Level
|
||||
switch cfg.Log.Level {
|
||||
case "debug":
|
||||
level = slog.LevelDebug
|
||||
case "warn":
|
||||
level = slog.LevelWarn
|
||||
case "error":
|
||||
level = slog.LevelError
|
||||
}
|
||||
opts := &slog.HandlerOptions{Level: level}
|
||||
if cfg.Log.Format == "json" {
|
||||
return slog.New(slog.NewJSONHandler(w, opts))
|
||||
}
|
||||
// The journal adds its own timestamps.
|
||||
if os.Getenv("JOURNAL_STREAM") != "" {
|
||||
opts.ReplaceAttr = func(groups []string, a slog.Attr) slog.Attr {
|
||||
if a.Key == slog.TimeKey && len(groups) == 0 {
|
||||
return slog.Attr{}
|
||||
}
|
||||
return a
|
||||
}
|
||||
}
|
||||
return slog.New(slog.NewTextHandler(w, opts))
|
||||
}
|
||||
|
||||
type app struct {
|
||||
cfg *config.Config
|
||||
log *slog.Logger
|
||||
store *store.Store
|
||||
}
|
||||
|
||||
func openApp(allowMissing bool) (*app, error) {
|
||||
cfg, err := config.Load(configPath, allowMissing)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("loading the configuration: %w", err)
|
||||
}
|
||||
log := newLogger(cfg, os.Stderr)
|
||||
if err := os.MkdirAll(filepath.Dir(cfg.Database), 0o750); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
st, err := store.Open(cfg.Database)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("opening the database %s: %w", cfg.Database, err)
|
||||
}
|
||||
return &app{cfg: cfg, log: log, store: st}, nil
|
||||
}
|
||||
|
||||
// hostEnv describes this machine's interfaces for validation.
|
||||
func hostEnv(h *hostif.Host) func() *validate.Env {
|
||||
return func() *validate.Env {
|
||||
all, err := h.All()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return &validate.Env{Interfaces: all}
|
||||
}
|
||||
}
|
||||
|
||||
func serveCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "serve",
|
||||
Short: "Keep the firewall's ruleset, and serve the API and the web UI",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
a, err := openApp(true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer a.store.Close()
|
||||
release, err := lockDatabase(a.cfg.Database)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer release()
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
a.log.Info("starting", "version", version.String(), "database", a.cfg.Database)
|
||||
if users, err := a.store.ListUsers(ctx); err == nil && len(users) == 0 {
|
||||
a.log.Warn("there are no users yet; create an administrator with 'housecarld init' (root can use 'hc' on the local socket meanwhile)")
|
||||
}
|
||||
eng, err := engine.New(ctx, a.store, a.log)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
host := &hostif.Host{}
|
||||
eng.Env = hostEnv(host)
|
||||
fw := &nft.Firewall{Runner: &nft.Runner{Command: nft.Wrapper()}, Interfaces: host.Exists, BootFile: a.cfg.Ruleset}
|
||||
eng.Firewall = fw
|
||||
for _, p := range eng.Problems() {
|
||||
a.log.Warn("configuration problem: "+p.Message, "object", p.Object, "code", p.Code)
|
||||
}
|
||||
var acme *certs.Manager
|
||||
if a.cfg.TLS.ACME.Domain != "" {
|
||||
tok, err := a.cfg.Jarl.ReadToken()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
jc, err := jarl.New(a.cfg.Jarl.Server, tok, a.cfg.Jarl.CACert, a.cfg.Jarl.Insecure)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
acme = certs.New(ctx, certs.Config{ACME: a.cfg.TLS.ACME, Version: version.Short()}, jc, a.store, a.log)
|
||||
go acme.Run(ctx)
|
||||
}
|
||||
srv := server.New(server.Config{
|
||||
Listen: a.cfg.Listen, TLSCertFile: a.cfg.TLS.CertFile, TLSKeyFile: a.cfg.TLS.KeyFile, TrustedProxies: a.cfg.Proxies(),
|
||||
SessionLifetime: time.Duration(a.cfg.Session.Lifetime), SessionIdle: time.Duration(a.cfg.Session.IdleTimeout),
|
||||
CookieSecure: a.cfg.Session.CookieSecure, Socket: a.cfg.Socket, ACME: acme,
|
||||
}, eng, fw, host, a.log, web.FS())
|
||||
// Put the ruleset in force: what was on probation when
|
||||
// housecarld stopped is rolled back first. An empty
|
||||
// configuration applies nothing: its default policies would
|
||||
// close the machine before any rule lets anyone in.
|
||||
// What the quotas used before housecarld (or the machine)
|
||||
// stopped comes back with the next apply.
|
||||
if v, err := a.store.GetMeta(ctx, server.QuotaUsageKey); err == nil && v != "" {
|
||||
var u nft.QuotaUsage
|
||||
if json.Unmarshal([]byte(v), &u) == nil {
|
||||
fw.RestoreQuotas(&u)
|
||||
}
|
||||
}
|
||||
if !srv.ResumeProbation(ctx) {
|
||||
if eng.State().Empty() {
|
||||
a.log.Info("the configuration is empty: no ruleset is applied until the first change (or 'hc import')")
|
||||
} else if err := eng.ApplyCurrent(ctx); err != nil {
|
||||
a.log.Error("applying the ruleset failed; the one in force stays (see 'hc status')", "err", err)
|
||||
} else if err := fw.SaveBoot(eng.State()); err != nil {
|
||||
a.log.Error("saving the ruleset for the next boot failed", "err", err)
|
||||
}
|
||||
}
|
||||
go nflog.Listen(ctx, render.LogGroup, a.log, srv.LogPacket)
|
||||
go srv.RunQuotas(ctx)
|
||||
hup := make(chan os.Signal, 1)
|
||||
signal.Notify(hup, syscall.SIGHUP)
|
||||
go func() {
|
||||
for range hup {
|
||||
sdNotify("RELOADING=1")
|
||||
srv.ReloadTLS()
|
||||
sdNotify("READY=1")
|
||||
}
|
||||
}()
|
||||
err = srv.Serve(ctx, func() { sdNotify("READY=1") })
|
||||
sdNotify("STOPPING=1")
|
||||
srv.SaveQuotaUsage(context.Background())
|
||||
a.log.Info("stopped")
|
||||
return err
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func sdNotify(state string) {
|
||||
sock := os.Getenv("NOTIFY_SOCKET")
|
||||
if sock == "" {
|
||||
return
|
||||
}
|
||||
addr := &net.UnixAddr{Name: sock, Net: "unixgram"}
|
||||
if strings.HasPrefix(sock, "@") {
|
||||
addr.Name = "\x00" + sock[1:]
|
||||
}
|
||||
c, err := net.DialUnix("unixgram", nil, addr)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer c.Close()
|
||||
c.Write([]byte(state))
|
||||
}
|
||||
|
||||
// ---- init and users
|
||||
|
||||
func readPassword(prompt string, fromStdin bool) (string, error) {
|
||||
if fromStdin || !term.IsTerminal(int(os.Stdin.Fd())) {
|
||||
line, err := bufio.NewReader(os.Stdin).ReadString('\n')
|
||||
if err != nil && !errors.Is(err, io.EOF) {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimRight(line, "\r\n"), nil
|
||||
}
|
||||
fmt.Fprint(os.Stderr, prompt)
|
||||
b, err := term.ReadPassword(int(os.Stdin.Fd()))
|
||||
fmt.Fprintln(os.Stderr)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
fmt.Fprint(os.Stderr, "Repeat: ")
|
||||
b2, err := term.ReadPassword(int(os.Stdin.Fd()))
|
||||
fmt.Fprintln(os.Stderr)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if string(b) != string(b2) {
|
||||
return "", errors.New("the passwords don't match")
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
// readLine reads one line a byte at a time, so nothing after it is
|
||||
// buffered away from the password prompt that follows.
|
||||
func readLine(r io.Reader) string {
|
||||
var b []byte
|
||||
one := make([]byte, 1)
|
||||
for {
|
||||
n, err := r.Read(one)
|
||||
if n == 1 {
|
||||
if one[0] == '\n' {
|
||||
break
|
||||
}
|
||||
b = append(b, one[0])
|
||||
}
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
return strings.TrimRight(string(b), "\r")
|
||||
}
|
||||
|
||||
func localPrincipal(u *model.User) *auth.Principal {
|
||||
return &auth.Principal{User: u, Method: "local", IP: "local"}
|
||||
}
|
||||
|
||||
func audit(ctx context.Context, st *store.Store, p *auth.Principal, action, object string) {
|
||||
ev := &model.AuditEvent{Action: action, Object: object, Result: "success", Auth: p.Method, Username: p.Name()}
|
||||
if p.User != nil {
|
||||
ev.UserID = p.User.ID
|
||||
}
|
||||
st.AddAudit(ctx, ev)
|
||||
}
|
||||
|
||||
func createToken(ctx context.Context, st *store.Store, u *model.User, name string) (string, error) {
|
||||
secret, pid, hash := auth.NewToken()
|
||||
if err := st.CreateToken(ctx, &model.Token{UserID: u.ID, Name: name, PublicID: pid, SecretHash: hash}); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return secret, nil
|
||||
}
|
||||
|
||||
func initCmd() *cobra.Command {
|
||||
var username string
|
||||
var stdin, withToken bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "init",
|
||||
Short: "Create the database and the first administrator",
|
||||
Long: `Creates Housecarl's database with a first administrator.
|
||||
|
||||
Housecarl starts with an empty configuration, and applies no ruleset until
|
||||
it has one: import your nftables ruleset with 'hc import', or write rules
|
||||
with 'hc rule add' or in the web UI (docs/install.md).`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
a, err := openApp(true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer a.store.Close()
|
||||
ctx := context.Background()
|
||||
users, err := a.store.ListUsers(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(users) > 0 {
|
||||
return fmt.Errorf("Housecarl is already set up (%d users); add users with 'housecarld user add' or 'hc user add'", len(users))
|
||||
}
|
||||
// Asked for when not given, on a terminal.
|
||||
if !cmd.Flags().Changed("user") && !stdin && term.IsTerminal(int(os.Stdin.Fd())) {
|
||||
fmt.Fprint(os.Stderr, "The first administrator's user name [admin]: ")
|
||||
if name := strings.TrimSpace(readLine(os.Stdin)); name != "" {
|
||||
username = name
|
||||
}
|
||||
}
|
||||
if err := server.ValidUsername(username); err != nil {
|
||||
return err
|
||||
}
|
||||
pw, err := readPassword("Password for "+username+": ", stdin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hash, err := auth.HashPassword(pw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
u := &model.User{Username: username, PasswordHash: hash, Admin: true, Comment: "created by housecarld init"}
|
||||
if err := a.store.CreateUser(ctx, u); err != nil {
|
||||
return err
|
||||
}
|
||||
audit(ctx, a.store, localPrincipal(u), "system.init", "user "+u.Username)
|
||||
fmt.Printf("Created the administrator %q in %s.\n", u.Username, a.cfg.Database)
|
||||
if withToken {
|
||||
tok, err := createToken(ctx, a.store, u, "cli")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("\nAPI token for the CLI (shown only once):\n\n %s\n", tok)
|
||||
}
|
||||
fmt.Println("\nNext steps:")
|
||||
fmt.Println(" 1. Start the daemon: sudo systemctl enable --now housecarl")
|
||||
fmt.Println(" 2. Import your ruleset: sudo hc import --defines /etc/nftables.conf -n (look first; docs/import.md)")
|
||||
fmt.Println(" or let yourself in first: sudo hc rule add input --from 10.0.10.0/24 --service ssh (then zones, rules, NAT)")
|
||||
fmt.Printf(" 3. Open the web UI: http://%s/ (see listen in %s)\n", a.cfg.Listen[0], configPath)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVarP(&username, "user", "u", "admin", "the administrator's name (best your own: each person should have their own account); asked for on a terminal")
|
||||
cmd.Flags().BoolVar(&stdin, "password-stdin", false, "read the password from standard input")
|
||||
cmd.Flags().BoolVar(&withToken, "token", false, "also make an API token for the CLI")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func userCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{Use: "user", Short: "Manage users directly in the database (when the API can't be used)"}
|
||||
var admin, stdin bool
|
||||
add := &cobra.Command{
|
||||
Use: "add NAME", Short: "Add a user", Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
a, err := openApp(true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer a.store.Close()
|
||||
if err := server.ValidUsername(args[0]); err != nil {
|
||||
return err
|
||||
}
|
||||
pw, err := readPassword("Password for "+args[0]+": ", stdin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hash, err := auth.HashPassword(pw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
u := &model.User{Username: args[0], PasswordHash: hash, Admin: admin}
|
||||
if err := a.store.CreateUser(context.Background(), u); err != nil {
|
||||
if errors.Is(err, store.ErrConflict) {
|
||||
return fmt.Errorf("user %s already exists", args[0])
|
||||
}
|
||||
return err
|
||||
}
|
||||
audit(context.Background(), a.store, localPrincipal(&model.User{Username: "root"}), "user.create", "user "+u.Username)
|
||||
fmt.Printf("added user %s\n", u.Username)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
add.Flags().BoolVar(&admin, "admin", false, "an administrator")
|
||||
add.Flags().BoolVar(&stdin, "password-stdin", false, "read the password from standard input")
|
||||
cmd.AddCommand(add)
|
||||
pw := &cobra.Command{
|
||||
Use: "password NAME", Short: "Set a user's password (and log them out)", Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
a, err := openApp(true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer a.store.Close()
|
||||
ctx := context.Background()
|
||||
u, err := a.store.GetUserByName(ctx, args[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("no user %s", args[0])
|
||||
}
|
||||
p, err := readPassword("New password for "+u.Username+": ", stdin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if u.PasswordHash, err = auth.HashPassword(p); err != nil {
|
||||
return err
|
||||
}
|
||||
u.Disabled = false
|
||||
if err := a.store.UpdateUser(ctx, u); err != nil {
|
||||
return err
|
||||
}
|
||||
a.store.DeleteUserSessions(ctx, u.ID, "")
|
||||
audit(ctx, a.store, localPrincipal(&model.User{Username: "root"}), "user.password", "user "+u.Username)
|
||||
fmt.Printf("set the password of %s\n", u.Username)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
pw.Flags().BoolVar(&stdin, "password-stdin", false, "read the password from standard input")
|
||||
cmd.AddCommand(pw)
|
||||
cmd.AddCommand(&cobra.Command{
|
||||
Use: "token NAME TOKEN-NAME", Short: "Make an API token for a user (printed once)", Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
a, err := openApp(true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer a.store.Close()
|
||||
ctx := context.Background()
|
||||
u, err := a.store.GetUserByName(ctx, args[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("no user %s", args[0])
|
||||
}
|
||||
tok, err := createToken(ctx, a.store, u, args[1])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
audit(ctx, a.store, localPrincipal(&model.User{Username: "root"}), "token.create", "token "+u.Username+"/"+args[1])
|
||||
fmt.Println(tok)
|
||||
return nil
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
func checkCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "check",
|
||||
Short: "Check the configuration in the database against this machine",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
a, err := openApp(false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer a.store.Close()
|
||||
eng, err := engine.New(context.Background(), a.store, a.log)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
eng.Env = hostEnv(&hostif.Host{})
|
||||
ps := eng.Problems()
|
||||
for _, p := range ps {
|
||||
fmt.Printf("%s: %s: %s [%s]\n", p.Severity, p.Object, p.Message, p.Code)
|
||||
}
|
||||
if validate.HasErrors(ps) {
|
||||
return errors.New("the configuration has errors")
|
||||
}
|
||||
if len(ps) == 0 {
|
||||
fmt.Println("ok: no problems")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ---- backups
|
||||
|
||||
// backupName is a backup's file name; the time in it orders and prunes
|
||||
// backups.
|
||||
const backupPrefix, backupSuffix = "housecarl-", ".db"
|
||||
|
||||
func backupCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "backup [FILE]",
|
||||
Short: "Write a backup of the database (and delete old ones)",
|
||||
Long: `Writes a consistent copy of the database, also while housecarld runs. Without
|
||||
FILE it goes to backup.dir as housecarl-<time>.db, and backups older than
|
||||
backup.retention are deleted (keeping at least backup.keep_at_least).`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cfg, err := config.Load(configPath, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := os.Stat(cfg.Database); err != nil {
|
||||
return fmt.Errorf("no database at %s yet (run 'housecarld init')", cfg.Database)
|
||||
}
|
||||
st, err := store.OpenExisting(cfg.Database)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer st.Close()
|
||||
dest := ""
|
||||
if len(args) == 1 {
|
||||
dest = args[0]
|
||||
} else {
|
||||
if err := os.MkdirAll(cfg.Backup.Dir, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
dest = filepath.Join(cfg.Backup.Dir, backupPrefix+time.Now().UTC().Format("20060102T150405Z")+backupSuffix)
|
||||
}
|
||||
tmp := dest + ".tmp"
|
||||
os.Remove(tmp)
|
||||
if err := st.Backup(context.Background(), tmp); err != nil {
|
||||
return fmt.Errorf("writing the backup: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmp, dest); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println(dest)
|
||||
if len(args) == 0 {
|
||||
return prune(cfg)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func prune(cfg *config.Config) error {
|
||||
entries, err := os.ReadDir(cfg.Backup.Dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
type bk struct {
|
||||
name string
|
||||
t time.Time
|
||||
}
|
||||
var list []bk
|
||||
for _, e := range entries {
|
||||
n := e.Name()
|
||||
if !strings.HasPrefix(n, backupPrefix) || !strings.HasSuffix(n, backupSuffix) {
|
||||
continue
|
||||
}
|
||||
t, err := time.Parse("20060102T150405Z", strings.TrimSuffix(strings.TrimPrefix(n, backupPrefix), backupSuffix))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
list = append(list, bk{n, t})
|
||||
}
|
||||
sort.Slice(list, func(i, j int) bool { return list[i].t.After(list[j].t) })
|
||||
cut := time.Now().Add(-time.Duration(cfg.Backup.Retention))
|
||||
for i, b := range list {
|
||||
if i < cfg.Backup.KeepAtLeast || b.t.After(cut) {
|
||||
continue
|
||||
}
|
||||
os.Remove(filepath.Join(cfg.Backup.Dir, b.name))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func restoreCmd() *cobra.Command {
|
||||
var yes bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "restore FILE",
|
||||
Short: "Replace the database with a backup (housecarld must be stopped)",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cfg, err := config.Load(configPath, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
release, err := lockDatabase(cfg.Database)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w; stop it first: systemctl stop housecarl", err)
|
||||
}
|
||||
defer release()
|
||||
src, err := store.OpenExisting(args[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s isn't a database: %w", args[0], err)
|
||||
}
|
||||
v, err := src.Version(context.Background())
|
||||
if err != nil {
|
||||
src.Close()
|
||||
return fmt.Errorf("%s isn't a Housecarl database: %w", args[0], err)
|
||||
}
|
||||
var ok string
|
||||
if err := src.DB().QueryRow(`PRAGMA integrity_check`).Scan(&ok); err != nil || ok != "ok" {
|
||||
src.Close()
|
||||
return fmt.Errorf("%s fails SQLite's integrity check: %s %v", args[0], ok, err)
|
||||
}
|
||||
src.Close()
|
||||
if v > store.SchemaVersion() {
|
||||
return fmt.Errorf("the backup is from a newer Housecarl (schema %d, this one has %d)", v, store.SchemaVersion())
|
||||
}
|
||||
if !yes {
|
||||
fmt.Fprintf(os.Stderr, "Replace %s with %s? [y/N] ", cfg.Database, args[0])
|
||||
var a string
|
||||
fmt.Scanln(&a)
|
||||
if a != "y" && a != "yes" {
|
||||
return errors.New("not restored")
|
||||
}
|
||||
}
|
||||
stamp := time.Now().UTC().Format("20060102T150405Z")
|
||||
for _, ext := range []string{"", "-wal", "-shm"} {
|
||||
if _, err := os.Stat(cfg.Database + ext); err == nil {
|
||||
if err := os.Rename(cfg.Database+ext, cfg.Database+".before-restore-"+stamp+ext); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
b, err := os.ReadFile(args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(cfg.Database, b, 0o640); err != nil {
|
||||
return err
|
||||
}
|
||||
st, err := store.Open(cfg.Database)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer st.Close()
|
||||
audit(context.Background(), st, localPrincipal(&model.User{Username: "root"}), "system.restore", args[0])
|
||||
fmt.Printf("restored %s; the old database is %s.before-restore-%s\n", args[0], cfg.Database, stamp)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVarP(&yes, "yes", "y", false, "don't ask")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func configCmd() *cobra.Command {
|
||||
var example bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "config", Short: "Show the configuration in use (--example: a commented example)", Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if example {
|
||||
fmt.Print(config.Example)
|
||||
return nil
|
||||
}
|
||||
cfg, err := config.Load(configPath, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("listen: %s\nsocket: %s\ndatabase: %s\nbackups: %s\n", strings.Join(cfg.Listen, ", "), cfg.Socket, cfg.Database, cfg.Backup.Dir)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&example, "example", false, "print a commented example configuration")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func versionCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "version", Short: "Print the version", Args: cobra.NoArgs,
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
Package: housecarl-cli
|
||||
Version: @VERSION@
|
||||
Architecture: @ARCH@
|
||||
Maintainer: @MAINTAINER@
|
||||
Installed-Size: @SIZE@
|
||||
Section: net
|
||||
Priority: optional
|
||||
Description: command line client for Housecarl, the firewall manager
|
||||
hc is the command line client for Housecarl servers (housecarld): zones,
|
||||
address sets, services, rules, port forwards, source NAT and redirects,
|
||||
counters and the drop log, the history of changes with undo and the
|
||||
confirmation of changes on probation, users and tokens, and importing an
|
||||
nftables ruleset. It talks to housecarld's REST API with an API token,
|
||||
or, on the firewall itself, to housecarld's local socket.
|
||||
.
|
||||
Install this package alone to manage Housecarl from another machine; the
|
||||
housecarl package (the server) depends on it.
|
||||
@@ -0,0 +1,22 @@
|
||||
Package: housecarl
|
||||
Version: @VERSION@
|
||||
Architecture: @ARCH@
|
||||
Maintainer: @MAINTAINER@
|
||||
Installed-Size: @SIZE@
|
||||
Depends: systemd, nftables, housecarl-cli (= @VERSION@)
|
||||
Section: net
|
||||
Priority: optional
|
||||
Description: nftables firewall manager with a web UI, a REST API and a CLI
|
||||
Housecarl keeps a Linux firewall's nftables ruleset: zones, address sets,
|
||||
services, ordered rules, port forwards with hairpin NAT and IPv6
|
||||
pinholes, source NAT and redirects, IPv4 and IPv6 together, in its own
|
||||
table (inet housecarl). It is configured through a web UI, a REST API and
|
||||
the hc command line client. Every change is validated as a whole and
|
||||
checked by nftables before it is applied in one transaction, recorded in
|
||||
a history where it can be undone, and audited. A change made over the
|
||||
network is rolled back unless confirmed. It can import an existing
|
||||
nftables ruleset.
|
||||
.
|
||||
This package is the server: housecarld (as the systemd service housecarl),
|
||||
the boot unit housecarl-ruleset and the daily backup timer. The hc CLI
|
||||
comes from housecarl-cli.
|
||||
Executable
+63
@@ -0,0 +1,63 @@
|
||||
#!/bin/sh
|
||||
# Housecarl: runs after the package's files are in place.
|
||||
set -e
|
||||
|
||||
if [ "$1" = configure ]; then
|
||||
# The housecarl system user and Housecarl's directories: /etc/housecarl,
|
||||
# /var/lib/housecarl and /var/backups/housecarl.
|
||||
systemd-sysusers housecarl.conf
|
||||
systemd-tmpfiles --create housecarl.conf || true
|
||||
|
||||
# An example configuration on the first install; an existing one is
|
||||
# never touched.
|
||||
if [ ! -e /etc/housecarl/housecarl.yaml ]; then
|
||||
/usr/bin/housecarld config --example > /etc/housecarl/housecarl.yaml.dpkg-new
|
||||
chown root:housecarl /etc/housecarl/housecarl.yaml.dpkg-new
|
||||
chmod 0640 /etc/housecarl/housecarl.yaml.dpkg-new
|
||||
mv /etc/housecarl/housecarl.yaml.dpkg-new /etc/housecarl/housecarl.yaml
|
||||
fi
|
||||
|
||||
# The daily database backup (housecarl-backup.timer) is on by default. An
|
||||
# administrator's 'systemctl disable housecarl-backup.timer' is remembered
|
||||
# by deb-systemd-helper and kept across upgrades. Before 'housecarld init'
|
||||
# the backup skips itself (there is no database yet).
|
||||
if command -v deb-systemd-helper >/dev/null 2>&1; then
|
||||
deb-systemd-helper unmask housecarl-backup.timer >/dev/null || true
|
||||
if deb-systemd-helper --quiet was-enabled housecarl-backup.timer; then
|
||||
deb-systemd-helper enable housecarl-backup.timer >/dev/null || true
|
||||
else
|
||||
deb-systemd-helper update-state housecarl-backup.timer >/dev/null || true
|
||||
fi
|
||||
elif [ -z "$2" ] && [ -d /run/systemd/system ]; then
|
||||
systemctl enable housecarl-backup.timer >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
# The boot unit loads the last confirmed ruleset before the network comes
|
||||
# up; it does nothing until housecarld has saved one.
|
||||
if [ -z "$2" ] && [ -d /run/systemd/system ]; then
|
||||
systemctl enable housecarl-ruleset.service >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
if [ -d /run/systemd/system ]; then
|
||||
systemctl daemon-reload || true
|
||||
if systemctl is-enabled --quiet housecarl-backup.timer 2>/dev/null; then
|
||||
systemctl start housecarl-backup.timer || true
|
||||
fi
|
||||
# An upgrade restarts a running housecarld (its database is upgraded
|
||||
# when it starts). A first install starts nothing: Housecarl needs
|
||||
# 'housecarld init' first.
|
||||
if [ -n "$2" ] && systemctl is-active --quiet housecarl.service; then
|
||||
systemctl restart housecarl.service || echo "housecarl: restarting housecarld failed; see 'journalctl -u housecarl'" >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "$2" ]; then
|
||||
echo "Housecarl is installed. Next: create the database and start it, as described"
|
||||
echo "in /usr/share/doc/housecarl/docs/install.md:"
|
||||
echo " sudo -u housecarl housecarld init && sudo systemctl enable --now housecarl"
|
||||
echo "To bring your current nftables ruleset over: sudo hc import --defines /etc/nftables.conf -n"
|
||||
echo "(docs/import.md). Housecarl only writes its own table, inet housecarl."
|
||||
fi
|
||||
fi
|
||||
|
||||
exit 0
|
||||
Executable
+35
@@ -0,0 +1,35 @@
|
||||
#!/bin/sh
|
||||
# Housecarl: runs after the package's files are removed.
|
||||
set -e
|
||||
|
||||
case "$1" in
|
||||
remove|purge)
|
||||
if [ -d /run/systemd/system ]; then
|
||||
systemctl daemon-reload || true
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
# The backup timer's enabled state (see postinst): masked while the
|
||||
# package is removed, forgotten on purge.
|
||||
if command -v deb-systemd-helper >/dev/null 2>&1; then
|
||||
case "$1" in
|
||||
remove)
|
||||
deb-systemd-helper mask housecarl-backup.timer >/dev/null || true
|
||||
;;
|
||||
purge)
|
||||
deb-systemd-helper purge housecarl-backup.timer >/dev/null || true
|
||||
deb-systemd-helper unmask housecarl-backup.timer >/dev/null || true
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# Even a purge keeps Housecarl's data: the database in /var/lib/housecarl holds
|
||||
# the configuration and its history. Remove it by hand when you no longer
|
||||
# need it.
|
||||
if [ "$1" = purge ]; then
|
||||
echo "housecarl: kept /etc/housecarl, /var/lib/housecarl and /var/backups/housecarl;"
|
||||
echo "remove them by hand if unused."
|
||||
fi
|
||||
|
||||
exit 0
|
||||
Executable
+16
@@ -0,0 +1,16 @@
|
||||
#!/bin/sh
|
||||
# Housecarl: runs before the package's files are removed (also on upgrades).
|
||||
set -e
|
||||
|
||||
# On removal (not on upgrade) stop housecarld and the backup timer and remove
|
||||
# the boot-time links (postrm deals with the timer's). The ruleset in force
|
||||
# stays until the next boot; then the machine has no Housecarl table.
|
||||
if [ "$1" = remove ] && [ -d /run/systemd/system ]; then
|
||||
systemctl stop housecarl.service housecarl-backup.timer housecarl-backup.service 2>/dev/null || true
|
||||
systemctl disable housecarl.service housecarl-ruleset.service 2>/dev/null || true
|
||||
if ! command -v deb-systemd-helper >/dev/null 2>&1; then
|
||||
systemctl disable housecarl-backup.timer 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
|
||||
exit 0
|
||||
@@ -0,0 +1,44 @@
|
||||
# Housecarl: one database backup, run daily by housecarl-backup.timer.
|
||||
# Installed as /etc/systemd/system/housecarl-backup.service (see
|
||||
# docs/operations.md#backups). What to keep, and where, is in the backup:
|
||||
# section of /etc/housecarl/housecarl.yaml.
|
||||
[Unit]
|
||||
Description=Housecarl database backup
|
||||
Documentation=file:///usr/local/share/doc/housecarl/docs/operations.md
|
||||
# Nothing to back up before 'housecarld init'.
|
||||
ConditionPathExists=/var/lib/housecarl/housecarl.db
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/bin/housecarld --config /etc/housecarl/housecarl.yaml backup
|
||||
User=housecarl
|
||||
Group=housecarl
|
||||
UMask=0077
|
||||
Nice=10
|
||||
IOSchedulingClass=idle
|
||||
|
||||
# If you change backup.dir in housecarl.yaml, allow the new one too:
|
||||
# 'systemctl edit housecarl-backup.service' and add ReadWritePaths=/new/dir.
|
||||
ProtectSystem=strict
|
||||
ReadWritePaths=/var/lib/housecarl -/var/backups/housecarl
|
||||
ProtectHome=yes
|
||||
PrivateTmp=yes
|
||||
PrivateDevices=yes
|
||||
PrivateNetwork=yes
|
||||
NoNewPrivileges=yes
|
||||
CapabilityBoundingSet=
|
||||
ProtectKernelTunables=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectKernelLogs=yes
|
||||
ProtectControlGroups=yes
|
||||
ProtectClock=yes
|
||||
ProtectHostname=yes
|
||||
RestrictNamespaces=yes
|
||||
RestrictRealtime=yes
|
||||
RestrictSUIDSGID=yes
|
||||
LockPersonality=yes
|
||||
MemoryDenyWriteExecute=yes
|
||||
RestrictAddressFamilies=AF_UNIX
|
||||
SystemCallArchitectures=native
|
||||
SystemCallFilter=@system-service
|
||||
SystemCallErrorNumber=EPERM
|
||||
@@ -0,0 +1,20 @@
|
||||
# Housecarl: runs housecarl-backup.service (a database backup) every day.
|
||||
# Installed as /etc/systemd/system/housecarl-backup.timer. Change the schedule
|
||||
# with 'systemctl edit housecarl-backup.timer', e.g.
|
||||
# [Timer]
|
||||
# OnCalendar=
|
||||
# OnCalendar=*-*-* 02:30
|
||||
# and keep backup.retention in housecarl.yaml above the new interval.
|
||||
[Unit]
|
||||
Description=Daily Housecarl database backup
|
||||
Documentation=file:///usr/local/share/doc/housecarl/docs/operations.md
|
||||
|
||||
[Timer]
|
||||
OnCalendar=daily
|
||||
# Spread the start over an hour, and catch up on a run missed while the
|
||||
# machine was off.
|
||||
RandomizedDelaySec=1h
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -0,0 +1,21 @@
|
||||
# Housecarl: loads the last confirmed ruleset at boot, before the network
|
||||
# comes up and whether or not housecarld starts.
|
||||
# Installed as /etc/systemd/system/housecarl-ruleset.service.
|
||||
[Unit]
|
||||
Description=Housecarl firewall ruleset
|
||||
Documentation=file:///usr/local/share/doc/housecarl/README.md
|
||||
DefaultDependencies=no
|
||||
Before=network-pre.target shutdown.target
|
||||
Wants=network-pre.target
|
||||
# Debian's nftables.service flushes the whole ruleset: Housecarl's loads after it.
|
||||
After=nftables.service local-fs.target
|
||||
Conflicts=shutdown.target
|
||||
ConditionPathExists=/var/lib/housecarl/ruleset.nft
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
RemainAfterExit=yes
|
||||
ExecStart=/usr/sbin/nft -f /var/lib/housecarl/ruleset.nft
|
||||
|
||||
[Install]
|
||||
WantedBy=sysinit.target
|
||||
@@ -0,0 +1,63 @@
|
||||
# Housecarl: keeps the firewall's nftables ruleset; its REST API and web UI.
|
||||
# Installed as /etc/systemd/system/housecarl.service (see docs/install.md).
|
||||
[Unit]
|
||||
Description=Housecarl firewall manager (with web UI and API)
|
||||
Documentation=file:///usr/local/share/doc/housecarl/README.md
|
||||
# The confirmed ruleset is already in force from housecarl-ruleset.service.
|
||||
After=housecarl-ruleset.service network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=notify
|
||||
NotifyAccess=main
|
||||
ExecStart=/usr/local/bin/housecarld --config /etc/housecarl/housecarl.yaml serve
|
||||
# SIGHUP rereads the TLS certificate.
|
||||
ExecReload=/bin/kill -HUP $MAINPID
|
||||
Restart=on-failure
|
||||
RestartSec=2s
|
||||
|
||||
User=housecarl
|
||||
Group=housecarl
|
||||
UMask=0027
|
||||
|
||||
# /var/lib/housecarl: the database and the confirmed ruleset (ruleset.nft).
|
||||
StateDirectory=housecarl
|
||||
StateDirectoryMode=0750
|
||||
ConfigurationDirectory=housecarl
|
||||
ConfigurationDirectoryMode=0750
|
||||
# /run/housecarl: the local API socket (housecarl.sock), for root and the
|
||||
# housecarl user.
|
||||
RuntimeDirectory=housecarl
|
||||
RuntimeDirectoryMode=0750
|
||||
|
||||
# Sandboxing: housecarld writes only its own state.
|
||||
ProtectSystem=strict
|
||||
ProtectHome=yes
|
||||
PrivateTmp=yes
|
||||
PrivateDevices=yes
|
||||
NoNewPrivileges=yes
|
||||
# The only privilege housecarld gets: changing nftables (it runs nft) and
|
||||
# reading logged packets (nfnetlink_log), both CAP_NET_ADMIN.
|
||||
CapabilityBoundingSet=CAP_NET_ADMIN
|
||||
AmbientCapabilities=CAP_NET_ADMIN
|
||||
ProtectKernelTunables=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectKernelLogs=yes
|
||||
ProtectControlGroups=yes
|
||||
ProtectClock=yes
|
||||
ProtectHostname=yes
|
||||
RestrictNamespaces=yes
|
||||
RestrictRealtime=yes
|
||||
RestrictSUIDSGID=yes
|
||||
LockPersonality=yes
|
||||
MemoryDenyWriteExecute=yes
|
||||
# AF_NETLINK: nftables, logged packets, the interfaces and their addresses.
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK
|
||||
SystemCallArchitectures=native
|
||||
SystemCallFilter=@system-service
|
||||
SystemCallErrorNumber=EPERM
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
# 'systemctl restart housecarld' works too (the daemon's binary is housecarld).
|
||||
Alias=housecarld.service
|
||||
@@ -0,0 +1,2 @@
|
||||
# Housecarl's system user (installed as /usr/lib/sysusers.d/housecarl.conf).
|
||||
u housecarl - "Housecarl firewall manager" /var/lib/housecarl
|
||||
@@ -0,0 +1,8 @@
|
||||
# Directories Housecarl needs (installed as /usr/lib/tmpfiles.d/housecarl.conf).
|
||||
# Apply with: systemd-tmpfiles --create housecarl.conf
|
||||
#
|
||||
# Type Path Mode User Group
|
||||
d /etc/housecarl 0750 root housecarl
|
||||
d /var/lib/housecarl 0750 housecarl housecarl
|
||||
# Database backups (housecarl-backup.timer; backup.dir in housecarl.yaml).
|
||||
d /var/backups/housecarl 0700 housecarl housecarl
|
||||
+221
@@ -0,0 +1,221 @@
|
||||
# The REST API
|
||||
|
||||
Everything the web UI and the CLI do goes through this API, under
|
||||
`/api/v1`. Requests and answers are JSON.
|
||||
|
||||
## Authentication
|
||||
|
||||
* **API tokens**: `Authorization: Bearer housecarl_...`. Make one in the
|
||||
web UI (your account), with `hc token create NAME`, or with `housecarld
|
||||
user token USER NAME` on the server.
|
||||
* **Sessions**: `POST /auth/login` with `{"username", "password"}` sets a
|
||||
cookie and returns `csrf_token`. Requests other than GET must send it as
|
||||
`X-CSRF-Token`.
|
||||
* **The local socket** (`/run/housecarl/housecarl.sock`): root and
|
||||
housecarld's own user need nothing; others send a token. Changes made
|
||||
through it aren't on probation.
|
||||
|
||||
Failed logins and token attempts are rate limited per address and per
|
||||
user name. [permissions.md](permissions.md) has what each call needs.
|
||||
|
||||
## Errors
|
||||
|
||||
```json
|
||||
{"error": {"code": "validation", "message": "rule input #31: there is no set trusted",
|
||||
"problems": [{"severity": "error", "code": "unknown_set", "message": "from: there is no set trusted", "object": "rule input #31"}]}}
|
||||
```
|
||||
|
||||
| Status | Code | |
|
||||
|---|---|---|
|
||||
| 400 | `bad_request` | the request can't be read |
|
||||
| 401 | `unauthenticated`, `session_expired`, `login_failed` | |
|
||||
| 403 | `forbidden`, `csrf` | |
|
||||
| 404 | `not_found` | |
|
||||
| 409 | `conflict` | e.g. undoing a change after the objects changed again |
|
||||
| 422 | `validation` | the change would make the configuration invalid; `problems` lists why ([validation.md](validation.md)) |
|
||||
| 422 | `firewall` | nftables refused the ruleset (`nft -c`), or applying it failed; the message has nftables' words. Nothing was changed |
|
||||
| 429 | `rate_limited` | |
|
||||
|
||||
## Changes
|
||||
|
||||
Calls that change the configuration answer:
|
||||
|
||||
```json
|
||||
{"applied": true, "history_id": 7,
|
||||
"changes": [{"kind": "rule", "id": 31, "label": "input #31 (SSH from home)", "after": {...}}],
|
||||
"warnings": [{"severity": "warning", "code": "set_empty", "message": "...", "object": "..."}],
|
||||
"object": {...},
|
||||
"confirm_by": "2026-10-19T10:02:00Z"}
|
||||
```
|
||||
|
||||
* `confirm_by`: the change is on probation, and is rolled back at that
|
||||
time unless `POST /confirm` comes first. Missing for changes made
|
||||
through the socket.
|
||||
* `confirm_token`: the client that made the change can confirm it by
|
||||
itself with `POST /confirm {"token"}`, from the same address, as the
|
||||
same user and token, over a new connection (the answer closes this
|
||||
one). Missing when a person must confirm (`manual_confirm`).
|
||||
* `?dry_run=1` checks the change, with nftables too, and returns what it
|
||||
would do, with `"applied": false, "dry_run": true`, and `diff`: the
|
||||
ruleset's unified diff.
|
||||
* `?changeset=ID` (or the header `X-Housecarl-Changeset: ID`) stages the
|
||||
change in that changeset instead ([Changesets](#changesets)): the answer
|
||||
has `"staged": true`, `changes` (this operation's) and `changeset`.
|
||||
* `before` is missing for a create and `after` for a delete.
|
||||
* `warnings` are the problems the change brings; older ones are in
|
||||
`/status`.
|
||||
* `object` is the created or changed object.
|
||||
|
||||
Creating answers 201, other changes 200.
|
||||
|
||||
## Endpoints
|
||||
|
||||
### Status
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `GET /healthz` | `{"ok": true, "version"}`, without authentication |
|
||||
| `GET /status` | `build`, `started`, `applied` (the ruleset is in force), `apply_error`, `problems`, `probation`, `interfaces`, `counts` (objects by kind), `other_tables` (other nftables tables, `"inet main"`) |
|
||||
| `GET /interfaces` | the machine's interfaces: `name`, `up`, `addresses`, `zone` |
|
||||
| `GET /ruleset` | the nftables ruleset Housecarl builds, as text |
|
||||
| `GET /counters` | `{"since", "counters": {"r12": {"packets", "bytes"}, ...}}`, by mark ([design.md](design.md#its-own-table-only)); totals since housecarld started |
|
||||
| `GET /quotas` | each rule's quota: `rule_id`, `rule`, `quota`, `used` (bytes), `addresses` (`[{"address", "used"}]` for a per-address quota), `last_reset`, `next_reset` |
|
||||
| `GET /log[?limit=&after=&q=]` | logged packets, newest first: `time`, `prefix`, `in`, `out`, `family`, `protocol`, `source`, `source_port`, `destination`, `destination_port`, `icmp_type`, `tcp_flags`, `length`, `mark` (the rule's). `after` (a time) for those since; `q` searches |
|
||||
| `GET /catalog` | built-in services, chains, actions, roles, the log group |
|
||||
|
||||
### Settings
|
||||
|
||||
`GET /settings`, `PATCH /settings` with any of:
|
||||
|
||||
```json
|
||||
{"input_policy": "drop", "forward_policy": "drop", "output_policy": "accept",
|
||||
"log_policy_drops": false,
|
||||
"builtins": {"loopback": true, "established": true, "drop_invalid": true, "icmpv6": true,
|
||||
"ping_internal": true, "dhcpv6_client": true, "antispoof": true, "mss_clamp": false},
|
||||
"own_prefixes": ["2001:db8::/48"], "antispoof_v4": ["10.0.0.0/8", "..."], "antispoof_v6": ["fc00::/7", "..."],
|
||||
"flowtable_devices": ["wan", "eth1"], "probation_seconds": 120, "manual_confirm": false}
|
||||
```
|
||||
|
||||
Lists replace the old ones. `probation_seconds` 0 is the default (120).
|
||||
|
||||
### Objects
|
||||
|
||||
Zones, sets, services, rules, port forwards, source NAT and redirects
|
||||
share one pattern, `/{kind}` with kind `zones`, `sets`, `services`,
|
||||
`rules`, `forwards`, `snats` or `redirects`:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `GET /{kind}` | all of them, in order; `GET /rules?chain=input` for one chain; `GET /services` includes the built-in ones |
|
||||
| `POST /{kind}` | create; rules, forwards and NAT rules go last unless given a `position` |
|
||||
| `GET /{kind}/{ref}` | by ID, or name for zones, sets, services and forwards |
|
||||
| `PATCH /{kind}/{ref}` | change the fields given (a JSON merge: `null` clears) |
|
||||
| `DELETE /{kind}/{ref}` | refused while something uses it |
|
||||
| `POST /rules/{ref}/reset-quota` | start a rule's quota again now (`config:write`; audited, not staged) |
|
||||
| `POST /{kind}/{ref}/move` | `{"before": ID}`, `{"after": ID}` or `{"to": "start"\|"end"}`; a rule can take `"chain"` too |
|
||||
|
||||
The objects:
|
||||
|
||||
```json
|
||||
{"name": "lan", "interfaces": ["eth1", "eth1.*"], "external": false, "comment": ""}
|
||||
{"name": "trusted", "entries": ["192.168.1.0/24", "2001:db8:1::/64"], "dynamic": false, "timeout": 0, "comment": ""}
|
||||
{"name": "web", "entries": ["tcp/8080", "tcp/8443"], "comment": ""}
|
||||
{"chain": "input",
|
||||
"in_zones": ["wan"], "not_in": false, "out_zones": [], "not_out": false,
|
||||
"sources": ["trusted"], "not_sources": false, "destinations": [], "not_destinations": false,
|
||||
"services": ["ssh"], "family": "",
|
||||
"ct_state": ["new"], "ct_status": [], "days": [], "hour_from": "", "hour_to": "",
|
||||
"limit": {"rate": 3, "unit": "", "per": "minute", "burst": 5, "over": true, "per_source": true, "per_destination": false}, "conn_limit": 0,
|
||||
"quota": {"bytes": 53687091200, "over": true, "per_source": true, "per_destination": false, "reset": "monthly", "day": 15},
|
||||
"group_v4": 0, "group_v6": 0,
|
||||
"action": "drop", "reject_with": "", "log": true, "log_prefix": "",
|
||||
"raw": "", "comment": "", "disabled": false}
|
||||
{"name": "web", "protocols": ["tcp"], "ports": "8443", "public_addresses": ["203.0.113.10"], "sources": [],
|
||||
"to": "192.168.1.10", "to_port": "443", "to6": "2001:db8:1::10", "hairpin": true, "hairpin_sources": [],
|
||||
"comment": "", "disabled": false}
|
||||
```
|
||||
|
||||
A source NAT rule is a match (as a rule's, without the rule's other
|
||||
fields) with `"to"` (empty: masquerade), a redirect a match with
|
||||
`"to_port"`. Times are seconds (`timeout`).
|
||||
|
||||
### Changesets
|
||||
|
||||
Changes staged to be reviewed and applied together ([design.md](design.md#reviewing-changes-changesets)).
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `GET /changesets[?status=open&mine=1&limit=]` | newest first |
|
||||
| `POST /changesets` | `{"description"}`; answers the changeset (201) |
|
||||
| `GET /changesets/{id}` | `{"id", "username", "via", "description", "status": "open"\|"applied"\|"discarded", "ops": [{"seq", "op", "kind", "ref", "id", "data", "summary", "added_at"}], "created_at", "updated_at", "history_id"}` |
|
||||
| `PATCH /changesets/{id}` | `{"description"}` |
|
||||
| `GET /changesets/{id}/preview` | `{"valid", "changes", "warnings", "error", "diff"}`: what applying it would do; `error.op` names an operation that no longer applies |
|
||||
| `POST /changesets/{id}/apply` | optional `{"description"}`; answers like a change, with `confirm_by` over the network and `changeset` |
|
||||
| `POST /changesets/{id}/discard` | |
|
||||
| `DELETE /changesets/{id}/ops/{seq}` | take one operation out |
|
||||
|
||||
Staging (`?changeset=ID` on any change to objects or settings) checks
|
||||
the changeset with the new operation, and nftables checks its ruleset;
|
||||
an error refuses the operation. Only open changesets take operations, and
|
||||
only their owner's (or an administrator's). Undo, `/import/nft` and
|
||||
`/takeover` can't be staged: with a changeset named they answer 400.
|
||||
|
||||
Reads of objects, settings and `/ruleset` with `?changeset=ID` show the
|
||||
configuration as the changeset will make it; objects are marked
|
||||
`"pending": "new"`, `"changed"` or `"deleted"` (deleted ones stay in the
|
||||
list). A changeset that no longer applies shows the current
|
||||
configuration, with the reason in the `X-Housecarl-Changeset-Error`
|
||||
header.
|
||||
|
||||
### Dynamic sets' entries
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `GET /sets/{name}/elements` | `[{"address", "timeout"}]`, `timeout` the seconds left |
|
||||
| `POST /sets/{name}/elements` | `{"address": "10.0.30.17", "timeout": 3600}`; `timeout` 0 is the set's |
|
||||
| `DELETE /sets/{name}/elements/{address}` | |
|
||||
|
||||
These need `sets:read` or `sets:write`, for that set
|
||||
([permissions.md](permissions.md)); they aren't on probation or in the
|
||||
history, but are audited.
|
||||
|
||||
### Probation
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `GET /probation` | `{"pending": [history IDs], "takeovers": N, "deadline", "seconds_left"}` |
|
||||
| `POST /confirm` | keep them: `{"confirmed": N}`; the ruleset is saved for the boot. With `{"token"}`, the client confirming its own change (see `confirm_token`): 409 if a newer change is pending, or over the change's own connection; 403 from another user or address |
|
||||
| `POST /rollback` | undo them now |
|
||||
|
||||
### History, import, takeover
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `GET /history[?limit=&before=]` | changes, newest first |
|
||||
| `GET /history/{id}` | one, with each object before and after |
|
||||
| `POST /history/{id}/revert` | undo it; 409 if an object it touched has changed since, or it was undone already |
|
||||
| `POST /import/nft` | `{"json": NFT_JSON, "text": "nft -a list ruleset output", "defines": "ruleset file", "table": "inet main", "replace": false}` ([import.md](import.md)); answers with `state` (the imported configuration), `notes` and `raw` too |
|
||||
| `POST /takeover` | `{"tables": ["inet main"]}`: delete these tables (administrators); on probation over the network |
|
||||
|
||||
### Certificate
|
||||
|
||||
Administrators only ([certificates.md](certificates.md)).
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `GET /certificate` | `{"configured", "source": "acme"\|"file"\|"none", "status": {"domain", "directory", "staging", "state": "none"\|"ok"\|"working"\|"error", "not_before", "not_after", "issuer", "renew_at", "last_error", "last_attempt", "next_attempt", "check"}}` |
|
||||
| `POST /certificate/check` | run the check now (up to a few minutes); answers `{"time", "domain", "ok", "steps": [{"name", "status", "detail", "queries"}]}` |
|
||||
| `POST /certificate/renew` | get a new certificate now, in the background (202); follow it with `GET /certificate` |
|
||||
|
||||
### Users, tokens, audit
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `POST /auth/login`, `POST /auth/logout`, `GET /auth/whoami` | `whoami` has `token_id` when the caller uses a token |
|
||||
| `POST /me/password` | `{"current", "password"}` |
|
||||
| `GET /me/tokens`, `POST /me/tokens` | `{"name", "role", "grants", "expires_in"}`; the answer's `secret` is shown once |
|
||||
| `GET /tokens`, `DELETE /tokens/{id}` | all tokens (administrators), revoke |
|
||||
| `POST /tokens/{id}/rotate` | a new secret: `{"grace": "1h", "expires_in": "90d", "no_expiry": false}`, all optional; answers with `secret` and `previous_secret_valid_until` |
|
||||
| `GET`, `POST /users`; `PATCH`, `DELETE /users/{id}` | administrators; `{"username", "password", "admin", "role", "grants", "disabled", "comment"}` |
|
||||
| `GET`, `POST /users/{id}/tokens` | |
|
||||
| `GET /audit[?user=&action=&result=&q=&before=&limit=]` | |
|
||||
@@ -0,0 +1,120 @@
|
||||
# The web UI's certificate
|
||||
|
||||
Housecarl can serve its web UI and API over HTTPS in two ways:
|
||||
|
||||
* **A certificate you manage**: `tls.cert_file` and `tls.key_file` in
|
||||
`housecarl.yaml`. housecarld rereads them when they change
|
||||
([configuration.md](configuration.md#https)).
|
||||
* **A certificate it gets itself** from Let's Encrypt (or another ACME CA),
|
||||
with Jarl publishing the DNS-01 challenge. That's this page.
|
||||
|
||||
With DNS-01 the web UI doesn't need to be reachable from the internet.
|
||||
`fw.adbraun.lol` can point at a private address and the certificate
|
||||
still works on your network. Only the challenge record has to be visible
|
||||
on the internet, and Jarl puts it in every view that has your zone.
|
||||
|
||||
## Setting it up
|
||||
|
||||
1. **Connect Housecarl to Jarl** with a user and a token that may only write
|
||||
Housecarl's own challenge record: [jarl.md](jarl.md).
|
||||
2. **Give your web UI a name in DNS**, such as `fw.adbraun.lol` with the
|
||||
Housecarl server's address, in Jarl. It is the name you will type in the
|
||||
browser.
|
||||
3. **Configure the certificate** in `/etc/housecarl/housecarl.yaml`:
|
||||
|
||||
```yaml
|
||||
listen: ["192.168.1.2:8070"] # an address your browser can reach
|
||||
tls:
|
||||
acme:
|
||||
domain: fw.adbraun.lol
|
||||
email: you@adbraun.lol # Let's Encrypt writes here if something is wrong
|
||||
directory: letsencrypt-staging # first; see below
|
||||
agree_tos: true # you accept Let's Encrypt's terms of service
|
||||
jarl:
|
||||
server: https://dns.adbraun.lol:8053
|
||||
token_file: /etc/housecarl/jarl.token
|
||||
```
|
||||
|
||||
4. **Restart housecarld** (`sudo systemctl restart housecarl`), and check:
|
||||
|
||||
```sh
|
||||
sudo hc cert check
|
||||
```
|
||||
|
||||
5. **Watch it get the certificate:** `sudo hc cert`, or Server →
|
||||
Certificate in the web UI. Then open `https://fw.adbraun.lol:8070/`.
|
||||
6. **Switch to the real CA.** The staging certificate works, but browsers
|
||||
warn about it, because Let's Encrypt's staging service isn't trusted.
|
||||
It's there to prove everything works without touching Let's Encrypt's
|
||||
rate limits. Once it does, set `directory: letsencrypt`, restart, and
|
||||
Housecarl gets a trusted certificate right away.
|
||||
|
||||
Until the first certificate arrives, the web UI is served over plain
|
||||
HTTP. housecarld switches to HTTPS on the same port without a restart. From
|
||||
then on, plain HTTP is redirected to HTTPS, except from the server itself
|
||||
(`127.0.0.1`), so tools there keep working.
|
||||
|
||||
## Checking
|
||||
|
||||
Before every certificate order, and whenever you ask (`hc cert
|
||||
check`, or Test in the web UI), Housecarl checks the whole path without
|
||||
asking the CA. Let's Encrypt allows only 5 failed validations per name
|
||||
per hour, so a broken setup shouldn't find out the hard way.
|
||||
|
||||
| Step | What it checks |
|
||||
|---|---|
|
||||
| Jarl | Jarl answers at `jarl.server`, and the token is valid (it says whose it is) |
|
||||
| Zone | a primary zone the token can see holds `_acme-challenge.<domain>`, and in which views |
|
||||
| Permission | the token may write the TXT record there (Jarl checks it as a dry run) |
|
||||
| Write | a test record with a random value is written through Jarl, which loads it into named |
|
||||
| Name servers | each of the zone's public name servers serves it, asked directly as the CA's resolvers will. A name server passes if any of its addresses does (`tls.acme.check_propagation`) |
|
||||
| Public resolvers | the resolvers in `tls.acme.resolvers` (1.1.1.1, 9.9.9.9, 8.8.8.8) see it. If the name servers do but a resolver doesn't, it is only a warning: it may be holding an earlier answer |
|
||||
| Clean up | the test record is removed |
|
||||
|
||||
Each step says what went wrong and what to do. For example:
|
||||
|
||||
```
|
||||
ok Jarl the token is housecarl's, on Jarl v0.6.1
|
||||
ok Zone adbraun.lol in views internal, external
|
||||
FAIL Permission the token may not write the challenge record (...); give it the grant:
|
||||
records:write zones=adbraun.lol names=_acme-challenge.fw.adbraun.lol types=TXT
|
||||
```
|
||||
|
||||
The checks wait up to 90 seconds for the record to appear. A failing step
|
||||
lists the DNS questions it asked, as `dig` commands, so you can ask them
|
||||
yourself.
|
||||
|
||||
If the challenge name is a CNAME into another zone, the check follows it,
|
||||
as Let's Encrypt does.
|
||||
|
||||
## Renewal
|
||||
|
||||
A certificate is renewed when a third of its lifetime is left: 30 days
|
||||
before the end for Let's Encrypt's 90-day certificates, 2 days for 6-day
|
||||
ones. housecarld looks twice a day. The check runs first every time, and
|
||||
the new certificate is used at once, without a restart.
|
||||
|
||||
If renewing fails, the old certificate keeps being served, and housecarld
|
||||
tries again after 1 hour, then 3, 6 and every 12. The dashboard shows a
|
||||
problem (`certificate_failed`), and `certificate_expiring` once less than
|
||||
14 days are left. `hc cert renew` (Renew now in the web UI) tries
|
||||
again at once.
|
||||
|
||||
The ACME account and the certificate, with its key, are kept in Housecarl's
|
||||
database, so backups include them.
|
||||
|
||||
## Settings
|
||||
|
||||
All under `tls.acme` in `housecarl.yaml` ([configuration.md](configuration.md)):
|
||||
|
||||
| Setting | Default | |
|
||||
|---|---|---|
|
||||
| `domain` | none (off) | the name on the certificate |
|
||||
| `email` | none | the account's contact address |
|
||||
| `directory` | `letsencrypt-staging` | `letsencrypt`, `letsencrypt-staging`, or an ACME directory URL (a private CA such as step-ca) |
|
||||
| `agree_tos` | `false` | accept the CA's terms of service; no account is made without it |
|
||||
| `resolvers` | `1.1.1.1:53`, `9.9.9.9:53`, `8.8.8.8:53` | public resolvers, to see the challenge as the CA does |
|
||||
| `check_propagation` | `true` | also ask each of the zone's name servers directly |
|
||||
| `ca_bundle` | none | extra CAs to trust for the ACME server itself |
|
||||
|
||||
`tls.cert_file` and `tls.acme` can't both be set.
|
||||
+260
@@ -0,0 +1,260 @@
|
||||
# The hc CLI
|
||||
|
||||
`hc` talks to housecarld's REST API. `hc help COMMAND` describes each
|
||||
command and its flags.
|
||||
|
||||
## Connecting
|
||||
|
||||
* **On the firewall**, with nothing configured, the CLI uses
|
||||
housecarld's local socket, `/run/housecarl/housecarl.sock`. Root and
|
||||
the `housecarl` user are administrators there without a token, and
|
||||
their changes need no confirmation: `sudo hc status`.
|
||||
* **From elsewhere**, give the server's URL and an API token:
|
||||
|
||||
```sh
|
||||
hc config set server https://fw.example.com:8070
|
||||
hc config set token # asks for it
|
||||
```
|
||||
|
||||
The settings are in `~/.config/housecarl/config.yaml` (on macOS
|
||||
`~/Library/Application Support/housecarl/config.yaml`, on Windows
|
||||
`%AppData%\housecarl\config.yaml`).
|
||||
|
||||
| Flag | Environment | |
|
||||
|---|---|---|
|
||||
| `-s, --server URL` | `HOUSECARL_SERVER` | the server; `local` for the local socket |
|
||||
| `--token TOKEN` or `@FILE` | `HOUSECARL_TOKEN` | the API token |
|
||||
| `--config FILE` | `HOUSECARL_CLI_CONFIG` | the CLI's configuration file |
|
||||
| | `HOUSECARL_SOCKET` | the local socket's path |
|
||||
| `--insecure`, `--ca-cert FILE` | | TLS: skip the check, or trust another CA |
|
||||
| `-n, --dry-run` | | check and show a change without making it, with the ruleset's diff |
|
||||
| `--no-confirm` | | leave a change on probation instead of confirming it once `hc` reaches Housecarl again |
|
||||
| `-C, --changeset ID` | `HOUSECARL_CHANGESET` (checked, see [Changesets](#changesets)) | stage configuration changes in this changeset instead of making them |
|
||||
| `-o, --output FORMAT` | | `table` (default), `json`, `yaml` or `id` |
|
||||
| `-q, --quiet` | | print only errors (and the IDs of new objects) |
|
||||
|
||||
Exit codes: 0 ok, 1 error, 3 not authenticated or not allowed, 4 not
|
||||
found, 5 conflict, 6 invalid or refused by validation or nftables, 7
|
||||
server error.
|
||||
|
||||
## Changes
|
||||
|
||||
Every change is checked as a whole, then by nftables, before it is put
|
||||
in force. A refused change says why, with the problem's code
|
||||
([validation.md](validation.md)). An applied one says its history entry
|
||||
and any new warnings. Over the network, it is on probation, and `hc`
|
||||
confirms it by reaching Housecarl again over a new connection
|
||||
([operations.md](operations.md#changes-on-probation)):
|
||||
|
||||
```
|
||||
$ hc rule add input --from trusted --service ssh --comment "SSH from home"
|
||||
created rule input #31 (from trusted · ssh)
|
||||
applied and confirmed (history 12): this client reached Housecarl again over a new connection
|
||||
```
|
||||
|
||||
Through the socket the change is kept at once:
|
||||
|
||||
```
|
||||
applied and saved (history 12; undo with 'hc history undo 12')
|
||||
```
|
||||
|
||||
`-n` shows what would change without changing it, with the diff of the
|
||||
ruleset; nftables checks it too.
|
||||
|
||||
## Changesets
|
||||
|
||||
To review several changes and apply them together, as one entry in the
|
||||
history, stage them in a changeset:
|
||||
|
||||
```sh
|
||||
export HOUSECARL_CHANGESET=$(hc changeset create "guest network" -q)
|
||||
hc zone add guest -i eth1.30
|
||||
hc rule add forward --in guest --out wan
|
||||
hc changeset diff # the operations, problems, nftables' check, the ruleset's diff
|
||||
hc changeset remove 2 # take operation 2 out
|
||||
hc changeset apply # one change; over the network on probation
|
||||
unset HOUSECARL_CHANGESET
|
||||
```
|
||||
|
||||
```
|
||||
$ hc rule add forward --in guest --out wan
|
||||
changeset 7 (guest network, 1 operation) from HOUSECARL_CHANGESET
|
||||
staged: create rule forward #52
|
||||
staged in changeset 7 as operation 2 (2 operations now); review with 'hc changeset diff 7', apply with 'hc changeset apply 7'
|
||||
```
|
||||
|
||||
Each change is checked as it is staged: the changeset with it must stay
|
||||
valid, and nftables must accept the ruleset, so make a set before the
|
||||
rule that names it. Applying checks the whole changeset again, on the
|
||||
configuration as it is then; an operation whose object changed since is
|
||||
named (`operation 3 (update rule input #12) can't be applied: …`), and
|
||||
`hc changeset remove` takes it out.
|
||||
|
||||
| Command | |
|
||||
|---|---|
|
||||
| `changeset create [DESCRIPTION]` | start one; `-q` prints only its ID |
|
||||
| `changeset list [--status open\|applied\|discarded\|""] [--mine]` | open ones by default |
|
||||
| `changeset show [ID]` | its operations |
|
||||
| `changeset diff [ID]` | what applying it would do |
|
||||
| `changeset apply [ID] [--description D]` | apply it |
|
||||
| `changeset discard [ID]` | throw it away |
|
||||
| `changeset remove [ID] OP` | take one operation out |
|
||||
|
||||
`show`, `diff`, `apply`, `discard` and `remove` work on the changeset you
|
||||
name, else the one in `-C`, else the one in `HOUSECARL_CHANGESET`.
|
||||
|
||||
A changeset from `HOUSECARL_CHANGESET` is checked before anything uses
|
||||
it, because an exported ID easily outlives its changeset:
|
||||
|
||||
- Staging into it, and `apply`, `discard` and `remove` on it, need it to
|
||||
be open and yours. Otherwise the command stops and says why (`there is
|
||||
no changeset 99`, `changeset 42 is applied`, `changeset 57 is sam's`).
|
||||
Commands that only read, and `hc changeset create`, don't care.
|
||||
- Each command says on stderr which changeset it uses: `changeset 42
|
||||
(guest network, 2 operations) from HOUSECARL_CHANGESET` (not with `-q`).
|
||||
- After `apply` or `discard`, a reminder to `unset HOUSECARL_CHANGESET`.
|
||||
|
||||
`-C ID` and an ID argument are used as given (housecarld still refuses a
|
||||
changeset that isn't open, or another user's unless you are an
|
||||
administrator). Only configuration changes are staged: dynamic set
|
||||
entries, `confirm` and `rollback` are made at once, and `history undo`,
|
||||
`import` and `takeover` can't be staged, so they are refused while a
|
||||
changeset is named. Changesets staged in the web UI show up here too,
|
||||
and the other way round.
|
||||
|
||||
## Commands
|
||||
|
||||
Objects are named by ID or, for zones, sets, services and port forwards,
|
||||
by name.
|
||||
|
||||
### status and the ruleset
|
||||
|
||||
| Command | |
|
||||
|---|---|
|
||||
| `status` | the ruleset (in force or not), changes on probation, problems, interfaces, other tables |
|
||||
| `interfaces` | the machine's interfaces, their addresses and zones |
|
||||
| `ruleset` | the nftables ruleset Housecarl builds |
|
||||
| `counters` | the hits of every rule, NAT rule, built-in and policy, by mark (`r12`, `f3`, `b-established`, `p-input`) |
|
||||
| `quotas` | what each rule's quota used (each address's, for per-address quotas), and when it resets |
|
||||
| `log [-f] [--search TEXT] [--limit N]` | the packets logged by rules and policies ([operations.md](operations.md#the-log)) |
|
||||
|
||||
### zone, set, service
|
||||
|
||||
| Command | |
|
||||
|---|---|
|
||||
| `zone list`, `zone show ZONE` | |
|
||||
| `zone add NAME -i IFACE... [--external] [--comment C]` | |
|
||||
| `zone update ZONE [-i IFACE]... [--add IFACE] [--remove IFACE] [--external[=false]] [--name N] [--comment C]` | `-i` replaces the interfaces |
|
||||
| `zone delete ZONE` | refused while rules use it |
|
||||
| `set list`, `set show SET` | |
|
||||
| `set add NAME [ENTRY...] [--dynamic] [--timeout 24h] [--comment C]` | |
|
||||
| `set update SET [--entry E]... [--add E] [--remove E] [--timeout] [--name] [--comment]` | `--entry` replaces the entries |
|
||||
| `set entries SET`, `set entries add SET ADDRESS [--timeout 1h]`, `set entries remove SET ADDRESS` | a dynamic set's entries |
|
||||
| `set delete SET` | |
|
||||
| `service list`, `service show SERVICE` | with the built-in ones |
|
||||
| `service add NAME ENTRY... [--comment C]` | entries `tcp/22`, `udp/1000-2000`, `icmp/echo-request`, `icmpv6/packet-too-big` |
|
||||
| `service update SERVICE`, `service delete SERVICE` | |
|
||||
|
||||
See [rules.md](rules.md).
|
||||
|
||||
### rule
|
||||
|
||||
| Command | |
|
||||
|---|---|
|
||||
| `rule list [CHAIN]` | in order, with their hits |
|
||||
| `rule show ID` | |
|
||||
| `rule add CHAIN [MATCH] [-a ACTION] [--first\|--before ID\|--after ID]` | at the end of its chain unless placed |
|
||||
| `rule update ID [flags]` | only the flags given |
|
||||
| `rule move ID --before ID\|--after ID\|--first\|--last\|--chain CHAIN` | |
|
||||
| `rule enable\|disable ID` | |
|
||||
| `rule reset-quota ID` | start a rule's quota again now |
|
||||
| `rule delete ID` | |
|
||||
|
||||
The match flags (`rule add`, `snat add`, `redirect add`):
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `--in ZONE`, `--out ZONE` (`--not-in`, `--not-out`) | zones |
|
||||
| `--from`, `--to` (`--not-from`, `--not-to`) | sets, addresses, prefixes, ranges |
|
||||
| `--service` | services or entries |
|
||||
| `--family ipv4\|ipv6` | |
|
||||
|
||||
And for rules: `--state`, `--status`, `--days`, `--hours`, `--limit
|
||||
10/minute|1mbytes/second[,burst=N][,over][,per-source|,per-destination]`,
|
||||
`--quota 50gbytes[,over][,per-source|,per-destination][,daily|,weekly|,monthly=DAY]`,
|
||||
`--group-v4 N`, `--group-v6 N`, `--conn-limit N`, `-a
|
||||
accept|drop|reject|log`, `--reject-with tcp-reset`, `--log`,
|
||||
`--log-prefix`, `--raw RULE`, `--comment`, `--disabled`
|
||||
([rules.md](rules.md#rules)).
|
||||
|
||||
### forward, snat, redirect
|
||||
|
||||
| Command | |
|
||||
|---|---|
|
||||
| `forward list`, `forward show FORWARD` | port forwards, with their hits |
|
||||
| `forward add PORTS --to HOST[:PORT] [--proto tcp,udp] [--public ADDR]... [--from SRC]... [--hairpin] [--hairpin-from SRC]... [--to6 HOST] [--name N] [--comment C]` | |
|
||||
| `forward update FORWARD [flags]`, `forward delete FORWARD` | |
|
||||
| `snat list`, `snat add [MATCH] [--snat-to ADDR]`, `snat update ID`, `snat delete ID` | source NAT, in order; masquerade without `--snat-to` |
|
||||
| `redirect list`, `redirect add [MATCH] [--to-port PORT]`, `redirect update ID`, `redirect delete ID` | |
|
||||
|
||||
See [nat.md](nat.md).
|
||||
|
||||
### settings
|
||||
|
||||
| Command | |
|
||||
|---|---|
|
||||
| `settings show` | |
|
||||
| `settings set [--input-policy\|--forward-policy\|--output-policy accept\|drop] [--log-drops on\|off] [--builtin NAME=on\|off]... [--own-prefix P]... [--antispoof-v4 P]... [--antispoof-v6 P]... [--flowtable DEV]... [--probation 2m] [--auto-confirm on\|off]` | lists given replace the old ones |
|
||||
|
||||
### probation, history, import
|
||||
|
||||
| Command | |
|
||||
|---|---|
|
||||
| `confirm` | keep the changes on probation; the ruleset is saved for the boot |
|
||||
| `rollback` | undo the changes on probation now |
|
||||
| `history list [--limit N]` | changes, newest first |
|
||||
| `history show ID` | each object before and after |
|
||||
| `history undo ID` | apply the change backwards (refused if something it touched has changed since) |
|
||||
| `import [FILE\|-] [--text FILE] [--defines FILE] [--table T] [--replace]` | [import.md](import.md) |
|
||||
| `takeover TABLE... [-y]` | delete tables Housecarl's ruleset replaces (administrators) |
|
||||
|
||||
### cert
|
||||
|
||||
Administrators only ([certificates.md](certificates.md)).
|
||||
|
||||
| Command | |
|
||||
|---|---|
|
||||
| `cert` | the certificate: name, CA, validity, renewal, the last check |
|
||||
| `cert check` | check that the challenge can be published through Jarl and seen, without the CA (exit code 1 if it can't) |
|
||||
| `cert renew [-w]` | get a new certificate now; `-w` waits and shows the result |
|
||||
|
||||
### users, tokens, audit
|
||||
|
||||
| Command | |
|
||||
|---|---|
|
||||
| `whoami` | who you are to the server, and what you may do |
|
||||
| `user list\|add\|update\|delete` | users (administrators); `user add NAME --role operator` |
|
||||
| `token list [--all]`, `token create NAME [--role R] [--grant G]... [--expires 90d] [--user U] [--save]`, `token revoke ID` | API tokens |
|
||||
| `token rotate ID\|--current [--grace 1h] [--expires 90d\|never] [--save]` | a new secret for a token, the old one working for the grace period |
|
||||
| `password` | change your password |
|
||||
| `audit [--user U] [--action PREFIX] [--search TEXT]` | the audit log |
|
||||
| `config show`, `config set server\|token\|insecure\|ca_cert [VALUE]` | this CLI's settings |
|
||||
|
||||
[permissions.md](permissions.md) explains roles and grants.
|
||||
|
||||
## housecarld
|
||||
|
||||
The daemon's own commands work on the database directly, as root or the
|
||||
`housecarl` user (`sudo -u housecarl housecarld ...`):
|
||||
|
||||
| Command | |
|
||||
|---|---|
|
||||
| `housecarld serve` | run (the systemd unit does) |
|
||||
| `housecarld init [-u NAME] [--token]` | create the database and the first administrator (asks for the name on a terminal) |
|
||||
| `housecarld user add NAME [--admin]`, `user password NAME`, `user token NAME TOKEN-NAME` | when the API can't be used |
|
||||
| `housecarld check` | check the configuration against this machine's interfaces |
|
||||
| `housecarld backup [FILE]`, `housecarld restore FILE` | [operations.md](operations.md) |
|
||||
| `housecarld config [--example]` | the settings in use, or a commented example |
|
||||
| `housecarld version` | |
|
||||
|
||||
All take `-c FILE` for another configuration file.
|
||||
@@ -0,0 +1,52 @@
|
||||
# Configuration file
|
||||
|
||||
`/etc/housecarl/housecarl.yaml` says how housecarld runs. The firewall's
|
||||
configuration (zones, sets, rules, NAT, settings) isn't here; it is in
|
||||
the database, changed with the CLI, the API or the web UI.
|
||||
|
||||
`housecarld config --example` prints a commented file with the defaults.
|
||||
Every setting is optional; unknown settings are an error. housecarld
|
||||
reads the file when it starts.
|
||||
|
||||
| Setting | Default | |
|
||||
|---|---|---|
|
||||
| `listen` | `["127.0.0.1:8070"]` | addresses of the web UI and API (`host:port`). Keep them on localhost or a management network, and write an input rule that lets you reach them |
|
||||
| `tls.cert_file`, `tls.key_file` | none | serve HTTPS with this certificate; housecarld rereads it when the file changes, or on `systemctl reload housecarl` |
|
||||
| `tls.acme.*` | off | get the certificate from Let's Encrypt, with Jarl publishing the challenge ([certificates.md](certificates.md#settings)) |
|
||||
| `jarl.server`, `jarl.token_file` (or `jarl.token`), `jarl.ca_cert`, `jarl.insecure` | none | the Jarl server for the certificate's challenge, and its token ([jarl.md](jarl.md)) |
|
||||
| `ruleset` | `/var/lib/housecarl/ruleset.nft` | the confirmed ruleset, which `housecarl-ruleset.service` loads at boot |
|
||||
| `trusted_proxies` | `[]` | addresses or prefixes of reverse proxies whose `X-Forwarded-For` and `X-Forwarded-Proto` are believed |
|
||||
| `socket` | `/run/housecarl/housecarl.sock` | the local API socket; `""` turns it off |
|
||||
| `database` | `/var/lib/housecarl/housecarl.db` | the SQLite database: configuration, history, users, audit log |
|
||||
| `log.level` | `info` | `debug` (every HTTP request), `info`, `warn` or `error` |
|
||||
| `log.format` | `text` | `text` or `json` |
|
||||
| `session.lifetime` | `12h` | how long a web UI login lasts |
|
||||
| `session.idle_timeout` | `2h` | logged out after this long without use |
|
||||
| `session.cookie_secure` | `auto` | `auto` (secure over HTTPS), `always` or `never` |
|
||||
| `backup.dir` | `/var/backups/housecarl` | where `housecarld backup` writes |
|
||||
| `backup.retention` | `30d` | backups older than this are deleted… |
|
||||
| `backup.keep_at_least` | `7` | …keeping at least this many |
|
||||
|
||||
Durations are written `15s`, `12h` or `30d`.
|
||||
|
||||
The probation's length is a firewall setting, not here: `hc settings set
|
||||
--probation 5m`.
|
||||
|
||||
## HTTPS
|
||||
|
||||
Over plain HTTP, passwords and tokens cross the network in the clear;
|
||||
housecarld warns when it listens on a non-loopback address without TLS.
|
||||
Let it get its own certificate through Jarl ([certificates.md](certificates.md)),
|
||||
or give it one:
|
||||
|
||||
```yaml
|
||||
listen: ["192.168.1.1:8070"]
|
||||
tls:
|
||||
cert_file: /etc/housecarl/tls/fullchain.pem
|
||||
key_file: /etc/housecarl/tls/privkey.pem
|
||||
```
|
||||
|
||||
Or keep it on `127.0.0.1` behind a reverse proxy that terminates TLS, and
|
||||
list the proxy in `trusted_proxies`. With a certificate, the same port
|
||||
answers HTTPS and redirects plain HTTP to it, except from the firewall
|
||||
itself.
|
||||
+139
-53
@@ -8,79 +8,165 @@ talks to that API; SQLite for everything. As Jarl does for BIND, it keeps
|
||||
a Linux machine's **nftables** ruleset: the database is the source of
|
||||
truth, and the ruleset is built from it.
|
||||
|
||||
This page is the starting sketch. Nothing here is built yet; it records
|
||||
what was decided and what is still open.
|
||||
## The model
|
||||
|
||||
Named objects, and ordered rules that refer to them
|
||||
([rules.md](rules.md), [nat.md](nat.md)):
|
||||
|
||||
* **Zones**: interfaces grouped by what they connect to (`wan`, `lan`,
|
||||
`wg`). External zones face the internet.
|
||||
* **Address sets**: IPv4 and IPv6 addresses, prefixes and ranges, mixed.
|
||||
A **dynamic** set takes entries at run time, each with a timeout that
|
||||
nftables enforces by itself.
|
||||
* **Services**: ports of a protocol and ICMP types, by name. Many are
|
||||
built in (`ssh`, `dns`, `https`, `wireguard`).
|
||||
* **Rules**, in order, in four chains: `input` (to the firewall),
|
||||
`forward` (through it), `output` (from it) and `early` (before
|
||||
connection tracking).
|
||||
* **NAT**: port forwards (with hairpin NAT and an optional IPv6 pinhole),
|
||||
source NAT (masquerade or an address), redirects to the firewall.
|
||||
* **Settings**: the chains' policies, and the **built-ins**, the rules
|
||||
every firewall needs (loopback, established connections, ICMPv6,
|
||||
antispoofing, MSS clamping), each of which can be turned off.
|
||||
|
||||
IPv4 and IPv6 are handled together: a set holds both, and a rule that
|
||||
names one becomes one or two nftables rules as needed.
|
||||
|
||||
## How a change is made
|
||||
|
||||
As in Jarl, every change goes through one engine:
|
||||
Every change goes through one engine (`internal/engine`):
|
||||
|
||||
1. the request becomes a change to a copy of the configuration;
|
||||
2. the whole result is validated, with errors that say what is wrong and
|
||||
how to fix it;
|
||||
3. the ruleset is rendered, and checked with `nft -c -f` (a dry run by
|
||||
nftables itself);
|
||||
2. the whole result is validated ([validation.md](validation.md)), with
|
||||
errors that say what is wrong and how to fix it;
|
||||
3. the ruleset is rendered (`internal/render`) and checked with
|
||||
`nft -c -f` (a dry run by nftables itself);
|
||||
4. it is applied with `nft -f`, which replaces Housecarl's table in one
|
||||
transaction: the old rules stay in force until the new ones are all in;
|
||||
5. the change, its history entry and the audit record are saved together.
|
||||
transaction: the old rules stay in force until the new ones are all
|
||||
in;
|
||||
5. the objects, the history entry and the audit record are saved
|
||||
together. If saving fails, the previous ruleset is applied again.
|
||||
|
||||
Dry runs, history with undo, users, sessions and scoped API tokens come
|
||||
from the same machinery, as in Jarl and Steward.
|
||||
from the same machinery as in Jarl and Steward.
|
||||
|
||||
### Not locking yourself out
|
||||
## Reviewing changes: changesets
|
||||
|
||||
A firewall managed over the network can cut off its own manager. Housecarl
|
||||
should guard against that from the start:
|
||||
As in Jarl, a change can be made at once or staged in a **changeset**,
|
||||
to be reviewed and applied together with others:
|
||||
|
||||
* **Confirm or roll back.** A risky change (one touching the rules for the
|
||||
machine itself) is applied on probation: unless it is confirmed within a
|
||||
minute or two, from the web UI or `hc confirm`, the previous ruleset comes
|
||||
back by itself. Network equipment calls this "commit confirmed".
|
||||
* **A management rule that can't be dropped by accident**: the API and SSH
|
||||
stay reachable from the management network unless that rule is removed
|
||||
on purpose.
|
||||
* Each change is an **operation** (create, update, delete or move an
|
||||
object, or change the settings), kept as the API received it.
|
||||
* Staged in a changeset, an operation is checked as it is added: the
|
||||
changeset with it must still be valid, and nftables must accept the
|
||||
ruleset it makes. Nothing is applied.
|
||||
* The **review** shows the operations, the problems, and the **diff of
|
||||
the ruleset**: the file that is loaded at boot, now and after.
|
||||
* **Applying** replays the operations on the configuration as it is then,
|
||||
as one change: one check, one transaction, one history entry, undone as
|
||||
a whole. An operation that no longer applies (its object was changed or
|
||||
deleted since) is named, and can be taken out.
|
||||
* Then the applied changeset is **on probation** like any change made over
|
||||
the network: unless it is confirmed, it is rolled back.
|
||||
|
||||
| Interface | Default | Why |
|
||||
|---|---|---|
|
||||
| Web UI | Review edits: they are collected in a draft changeset, pages show pending rows (green new, yellow changed, struck out deleted), and a bar leads to the Changes page, with the diff and Apply. The switch in the header turns on Live edits, applied at once. | Several related edits, seen together before they are in force. |
|
||||
| CLI | Immediate: each command is a change of its own. Stage with `-C ID` or `HOUSECARL_CHANGESET`. | Scripts want a clear result per step. |
|
||||
| API | Immediate unless a changeset is named (`?changeset=ID`, `X-Housecarl-Changeset`). | As the CLI. |
|
||||
|
||||
Undo, imports and takeovers can't be staged; with a changeset named, they
|
||||
are refused rather than applied.
|
||||
|
||||
## Its own table only
|
||||
|
||||
Housecarl writes one nftables table of its own (`inet housecarl`) and
|
||||
leaves every other table alone, such as Docker's or libvirt's. It never
|
||||
flushes the whole ruleset.
|
||||
Housecarl writes one table, `inet housecarl`, and leaves every other
|
||||
table alone (Docker's, Incus's, libvirt's). It never flushes the whole
|
||||
ruleset. The table is replaced in one script:
|
||||
|
||||
## Concepts (draft)
|
||||
```
|
||||
table inet housecarl {}
|
||||
delete table inet housecarl
|
||||
table inet housecarl { ... }
|
||||
```
|
||||
|
||||
* **Zones**: interfaces or VLANs grouped by trust (`lan`, `iot`, `guest`,
|
||||
`wan`), and the default policy between each pair.
|
||||
* **Address sets**: named sets of addresses or prefixes, which rules refer
|
||||
to. An entry may have a **timeout**, which nftables enforces by itself, so
|
||||
an entry expires even if housecarld is down. Other programs can be given
|
||||
a token that may only change one set.
|
||||
* **Rules** between zones: allow or drop, by set, port and protocol, with
|
||||
logging and counters.
|
||||
* **NAT**: masquerading out of the WAN zone, and port forwards.
|
||||
* **Services**: names for ports (`ssh`, `https`, `dns`).
|
||||
Two things survive the replacement: the **entries of dynamic sets**
|
||||
(read before, written back with their remaining timeouts, in the same
|
||||
script), and the **counters** (housecarld adds the old totals to the
|
||||
new, so hits don't reset with every change).
|
||||
|
||||
Every rule carries a comment `hc:<mark>`, such as `hc:r12` for rule 12,
|
||||
`hc:f3` for port forward 3, `hc:b-established` for a built-in. Counters
|
||||
and log lines are traced back to their object through it.
|
||||
|
||||
## Not locking yourself out
|
||||
|
||||
A change made over the network is applied **on probation** (a changeset
|
||||
when it is applied, a live edit at once): unless it is
|
||||
confirmed within two minutes (Settings; `hc confirm`, the banner in the
|
||||
web UI, `POST /confirm`), it is rolled back by itself, and the rollback is
|
||||
recorded in the history. A change that cuts you off undoes itself.
|
||||
|
||||
* **The client that made a change confirms it by itself** once it
|
||||
reaches the API again: the change's answer carries a one-time token and
|
||||
closes its connection (HTTP/2: a GOAWAY), and the web UI and `hc` send
|
||||
the token straight back. That proves something only on a new
|
||||
connection, since the built-in that accepts established connections
|
||||
would let the old one through whatever the change did. So the token is
|
||||
accepted only from the same user (and API token), the same address, a
|
||||
different connection, and for the newest change. A change that cuts its
|
||||
client off can't be confirmed by it, and rolls back. This proves the
|
||||
API is still reachable, not that SSH or the LAN still work; with the
|
||||
API, you can fix those. Settings → "a person confirms each change"
|
||||
(`hc settings set --auto-confirm off`) goes back to confirming by hand;
|
||||
`hc --no-confirm` leaves one change waiting.
|
||||
* Changes made through the local socket (`sudo hc …` on the firewall)
|
||||
need no confirmation: whoever makes them is already on the machine. One
|
||||
made while others are pending joins them.
|
||||
* A new change restarts the clock for everything pending.
|
||||
* If housecarld stops with changes pending, it rolls them back when it
|
||||
starts.
|
||||
* Housecarl doesn't force a rule that keeps SSH or the web UI open. You
|
||||
write the rules that let you in; probation is the safety net.
|
||||
|
||||
## At boot
|
||||
|
||||
The ruleset is saved to `/var/lib/housecarl/ruleset.nft` whenever it is
|
||||
confirmed (or changed through the socket). `housecarl-ruleset.service`
|
||||
loads it before the network comes up, without housecarld, so the
|
||||
firewall is up even if housecarld can't start. An unconfirmed ruleset is
|
||||
never saved there: a reboot during probation brings back the last
|
||||
confirmed one.
|
||||
|
||||
## The drop log
|
||||
|
||||
Rules with logging on, and the policies (Settings), send packets to
|
||||
netlink log group 4171. housecarld listens there and keeps the last 2000
|
||||
in memory (`hc log`, the Log page). Nothing is written to the kernel log.
|
||||
|
||||
## Importing a ruleset
|
||||
|
||||
`hc import` turns an existing nftables ruleset into zones, sets,
|
||||
services, rules and NAT, and `hc takeover` deletes the old table once
|
||||
Housecarl's replaces it ([import.md](import.md)).
|
||||
|
||||
## Working with Steward and Jarl
|
||||
|
||||
* **Steward's captive portal** (parked until Housecarl exists): a guest
|
||||
network's zone lets clients that haven't signed in reach only DNS and
|
||||
the portal, and sends their web traffic to it. When a guest signs in,
|
||||
Steward adds its address to a set (`guest-signed-in`) for the session's
|
||||
length, with a token that may only change that set. Addresses, not MAC
|
||||
* **Steward's captive portal** (parked until now): a guest network's zone
|
||||
lets clients that haven't signed in reach only DNS and the portal, and
|
||||
sends their web traffic to it. When a guest signs in, Steward adds its
|
||||
address to a dynamic set for the session's length, with a token that
|
||||
may only change that set (`sets:write:guests`). Addresses, not MAC
|
||||
addresses: matching by MAC only works on the guests' own network
|
||||
segment.
|
||||
* **Names instead of addresses**: Steward knows every reservation's and
|
||||
lease's address, so rules could name a device ("the printer") or a
|
||||
subnet; Jarl knows the names in DNS.
|
||||
* **Steward's deny lists** could also be enforced here, so a denied device
|
||||
with an address set by hand is still blocked.
|
||||
* **Names instead of addresses**: Steward knows every reservation's
|
||||
address and Jarl every name, so rules could name a device or a subnet.
|
||||
* **The web UI's certificate** comes through Jarl, as Steward's does
|
||||
([certificates.md](certificates.md)).
|
||||
|
||||
## Open questions
|
||||
## Not in 0.1
|
||||
|
||||
* IPv6 from the start, or IPv4 first as Steward did?
|
||||
* Import an existing nftables (or iptables) ruleset, as Jarl imports a BIND
|
||||
configuration?
|
||||
* Connection tracking defaults: established and related traffic allowed
|
||||
everywhere unless a rule says otherwise?
|
||||
* Does Housecarl also run the router's interfaces and routes, or only the
|
||||
firewall? (Only the firewall, for now.)
|
||||
* Rate limits and flow offloading: later.
|
||||
* Interfaces, addresses and routes: Housecarl only runs the firewall.
|
||||
* iptables rulesets (only nftables can be imported).
|
||||
* Rules by MAC address, by user, or by DNS name.
|
||||
* Flow offloading to hardware (the software flowtable is there).
|
||||
@@ -0,0 +1,137 @@
|
||||
# Developing Housecarl
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
cmd/housecarld/ the daemon: serve, init, user, check, backup, restore, config
|
||||
cmd/hc/ the CLI
|
||||
internal/
|
||||
model/ the configuration (State: settings, zones, sets, services, rules, NAT), users, history
|
||||
validate/ whole-configuration checks with stable codes (docs/validation.md)
|
||||
render/ State → the nftables ruleset text, with each line's object
|
||||
nft/ runs nft: check, apply (keeping dynamic set entries and counters), list, the boot file
|
||||
nflog/ reads logged packets from netlink log group 4171
|
||||
importer/ nft -j list ruleset → State, with notes and raw rules (docs/import.md)
|
||||
hostif/ the machine's interfaces
|
||||
store/ SQLite (modernc.org/sqlite, no cgo): configuration, history, users, tokens, sessions, audit
|
||||
engine/ configuration changes: edit a copy, diff, validate, check and apply, save with history, undo
|
||||
server/ the HTTP API and the web UI, authentication, the local socket, probation, the log ring
|
||||
api/, client/ JSON types and the Go client shared by server and CLI
|
||||
auth/ passwords (argon2id), tokens, sessions, grants and roles
|
||||
certs/, jarl/ the web UI's certificate through Jarl (docs/certificates.md)
|
||||
config/ housecarld's YAML configuration
|
||||
version/ what a build reports
|
||||
web/static/ the web UI: plain ES modules, no build step, embedded with go:embed
|
||||
deploy/ systemd units, sysusers.d and tmpfiles.d files, Debian package scripts
|
||||
scripts/e2e.sh end-to-end test with real traffic through a router container
|
||||
scripts/mkdeb.sh the Debian packages (make deb)
|
||||
scripts/logo.py the web UI's logo from artwork/logo.svg
|
||||
scripts/banner.py the README's banner from the web UI's logo and wordmark
|
||||
```
|
||||
|
||||
[design.md](design.md) describes how the pieces fit.
|
||||
|
||||
## Building and testing
|
||||
|
||||
```sh
|
||||
make # bin/housecarld, bin/hc, bash completion
|
||||
make test # go vet, gofmt check, go test ./...
|
||||
make e2e # scripts/e2e.sh: needs Docker
|
||||
make deb # dist/housecarl_<version>_<arch>.deb and housecarl-cli_...
|
||||
```
|
||||
|
||||
**Never apply a test ruleset to your own machine's nftables.** Tests that
|
||||
need the real `nft` run it in a container.
|
||||
|
||||
**Unit tests** need no nftables: the API tests (`internal/server`) use a
|
||||
fake firewall. The render tests compare the ruleset
|
||||
with what is expected, line by line, and the importer tests import the
|
||||
maintainer's router ruleset (`internal/importer/testdata`) and check the
|
||||
result.
|
||||
|
||||
**Real nftables**: with `HOUSECARL_NFT` set to a command that runs nft
|
||||
somewhere harmless, the render, nft and importer tests also have nft
|
||||
check (and apply) their rulesets. A `debian:trixie` image with nftables
|
||||
1.1.3 (Debian 13's) is the reference:
|
||||
|
||||
```sh
|
||||
make e2e # makes the housecarl-nft image, if missing
|
||||
export HOUSECARL_NFT="docker run --rm -i --cap-add NET_ADMIN housecarl-nft nft"
|
||||
export HOUSECARL_NFT_IMAGE=housecarl-nft
|
||||
make test
|
||||
```
|
||||
|
||||
Newer nftables accepts things 1.1.3 doesn't (a meter with a timeout for
|
||||
`ct count`, hour ranges past midnight): keep to what 1.1.3 takes.
|
||||
|
||||
**The e2e test** builds a small network in Docker from the same image:
|
||||
|
||||
* a router container (housecarld, the `hc` CLI) on three networks named
|
||||
`wan`, `enp2s0` and `wg0` inside it, IPv4 and IPv6;
|
||||
* "the internet" on the WAN side, a LAN client, and a "jellyfin" host.
|
||||
|
||||
It imports a copy of the maintainer's ruleset (lab addresses), takes
|
||||
over the old table, and checks with real traffic: port forwards with
|
||||
hairpin NAT and the IPv6 pinhole, masquerading, antispoofing, the drop
|
||||
log, a lockout change that rolls back by itself, a restart, the boot
|
||||
ruleset after a flush, and that other tables are left alone. It makes
|
||||
the `housecarl-nft` image itself if it is missing.
|
||||
|
||||
`KEEP=1` leaves the lab running (the web UI on `10.88.10.1:8070`);
|
||||
`DOCKER_HOST` points at another Docker daemon. Root isn't needed on the
|
||||
host.
|
||||
|
||||
The systemd sandbox (capabilities, the boot unit) is only tested by
|
||||
installing the package in a container that runs systemd
|
||||
(`--privileged --cgroupns=host /sbin/init`).
|
||||
|
||||
## Certificates
|
||||
|
||||
As in Steward: `internal/certs` has unit tests with a fake Jarl and an
|
||||
in-process DNS server; a real order is tested against Jarl's e2e
|
||||
environment and Pebble (Steward's docs/development.md has the steps).
|
||||
|
||||
## The web UI
|
||||
|
||||
Plain JavaScript modules under `internal/web/static/`, built with the
|
||||
same helpers as Jarl's and Steward's:
|
||||
|
||||
* All DOM goes through `h()` in `ui.js`, never `innerHTML`.
|
||||
* The Content-Security-Policy allows only the app's own scripts and
|
||||
styles, so no inline styles. Use a class in `style.css`, or set
|
||||
`el.style.prop`.
|
||||
* The files are embedded, so rebuild housecarld after changing them.
|
||||
|
||||
There are no JavaScript tests: bundle `assets/app.js` with esbuild to
|
||||
catch syntax and import errors, and look at the pages in a browser
|
||||
against the e2e lab.
|
||||
|
||||
## Conventions
|
||||
|
||||
* Every user-visible change goes into `CHANGELOG.md` under "Unreleased",
|
||||
with the docs updated in the same change.
|
||||
* Errors say what is wrong and how to fix it, naming the object.
|
||||
* New validation rules get a stable code, a test, and a row in
|
||||
[validation.md](validation.md).
|
||||
* Configuration changes go through the engine, never straight to the
|
||||
store, so they are validated, checked by nftables, recorded and
|
||||
undoable.
|
||||
* Schema changes append a migration to `internal/store/store.go`; never
|
||||
edit a shipped one.
|
||||
* `gofmt` and `go vet` clean (`make test`).
|
||||
|
||||
## Releasing
|
||||
|
||||
As for Jarl and Steward:
|
||||
|
||||
1. Move "Unreleased" in `CHANGELOG.md` under `## [<version>] - <date>`.
|
||||
A release candidate may leave it there.
|
||||
2. Put the version in `VERSION`, and update the README's status line.
|
||||
3. Run `make test && make e2e`.
|
||||
4. Commit as "Release X", tag it `git tag -a vX -m "Housecarl X"`, and push.
|
||||
|
||||
The tag starts `.gitea/workflows/release.yml`, which checks the tag
|
||||
against `VERSION` and the CHANGELOG, runs the tests, builds the tarballs,
|
||||
the CLI for each OS and the `.deb`s, and publishes them
|
||||
(`scripts/gitea-release.sh`). It needs the repository secret
|
||||
`RELEASE_TOKEN` (`write:repository`, `write:package`).
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
# Importing an nftables ruleset
|
||||
|
||||
`hc import` turns the ruleset you have into Housecarl's configuration,
|
||||
and `hc takeover` deletes the old table once Housecarl's replaces it.
|
||||
Until then both apply.
|
||||
|
||||
## 1. Look first
|
||||
|
||||
On the firewall, as root:
|
||||
|
||||
```sh
|
||||
sudo hc import --defines /etc/nftables.conf -n
|
||||
```
|
||||
|
||||
`hc` runs `nft -j list ruleset` (and `nft -a list ruleset`, for the text
|
||||
of rules it can't translate) and sends both to housecarld. `-n` (dry run)
|
||||
shows what it would make, without saving anything: zones, sets,
|
||||
services, rules, port forwards and NAT, then **notes** on every guess it
|
||||
made, and the rules it **kept raw**.
|
||||
|
||||
`--defines` reads your ruleset file's `define` lines, so sets get your
|
||||
names (`define admins = { ... }` becomes the set `admins`).
|
||||
Without it, sets are named after what they hold. Interfaces named by a
|
||||
define name their zone too (`define wan_interface = "ppp0"` makes the
|
||||
zone `wan`); otherwise a zone is named after its interface.
|
||||
|
||||
From another machine, give the JSON as a file, and the text with
|
||||
`--text`:
|
||||
|
||||
```sh
|
||||
ssh fw 'sudo nft -j list ruleset' > ruleset.json
|
||||
ssh fw 'sudo nft -a list ruleset' > ruleset.txt
|
||||
hc import ruleset.json --text ruleset.txt --defines nftables.conf -n
|
||||
```
|
||||
|
||||
With several tables, `--table "inet main"` picks one; by default every
|
||||
table but Housecarl's is read. The web UI has the same under Server →
|
||||
Import a ruleset.
|
||||
|
||||
## What becomes what
|
||||
|
||||
* **Interfaces** become zones. Zones are marked external when the rules
|
||||
show it: MSS clamping on the way out, DHCPv6 client replies, ping
|
||||
accepted from everywhere else.
|
||||
* **Sets and defines** of addresses become address sets; port lists
|
||||
become services.
|
||||
* **Jumps and gotos** to your own chains are flattened: the jump's
|
||||
condition is added to each rule of the chain, in place.
|
||||
* **Rules** keep their order. A rule that is a built-in (loopback,
|
||||
established, invalid, the ICMPv6 rules, MSS clamping, the DHCPv6 client
|
||||
rule, antispoofing) becomes that built-in only when it sits where the
|
||||
built-in goes, at the head of every chain it covers. Otherwise it stays
|
||||
a rule where it was, so the ruleset does what it did. Built-ins your
|
||||
ruleset doesn't have are turned off.
|
||||
* **DNAT** rules become port forwards, TCP and UDP of the same port
|
||||
merged into one. The forward-chain accepts that let them through
|
||||
become part of the port forward, and a masquerade for inside sources
|
||||
to the same host becomes its hairpin. An IPv6 accept to the same port
|
||||
becomes the forward's IPv6 pinhole when there is only one candidate.
|
||||
* **Maps are expanded**, one element at a time. A DNAT map
|
||||
(`dnat ip to tcp dport map @forwards`, named or written in the rule)
|
||||
becomes one port forward per element; forwards from a named map are
|
||||
called after it (`forwards-443`). A verdict map
|
||||
(`tcp dport vmap { 22 : accept, 23 : drop }`) becomes one rule per
|
||||
element, in place, with `jump`s inlined as above. A lookup matches at
|
||||
most one element, so the rules do the same. A map keyed by several
|
||||
fields (`ip saddr . tcp dport`) can't be expanded: a rule using a named
|
||||
one is left out with a note, to be added by hand.
|
||||
* **Masquerade and SNAT** become source NAT; **redirect** becomes a
|
||||
redirect.
|
||||
* **Meters** (`update @m { ip saddr limit rate over 3/minute }`,
|
||||
`add @m { ip saddr ct count over 20 }`, quotas in sets) become the
|
||||
rule's per-source (or per-destination) limit, connection limit or quota;
|
||||
a mask in the key (`ip saddr and 255.255.255.0`) becomes its grouping.
|
||||
Byte rates and quotas are imported too; a quota imported resets only by
|
||||
hand until you give it a period. A rule with a quota or a byte rate that
|
||||
accepts stays a raw rule ([rules.md](rules.md#bandwidth-and-quotas)).
|
||||
* **Policies** become the chains' policies.
|
||||
* **Anything else** becomes a raw rule with its original text, in place.
|
||||
A raw rule naming a set of the old table needs the new set name:
|
||||
Housecarl's are `NAME_v4` and `NAME_v6`, and a note says so.
|
||||
|
||||
Chains on hooks Housecarl doesn't manage (ingress, a bridge table) are
|
||||
left out, with a note.
|
||||
|
||||
## 2. Import
|
||||
|
||||
```sh
|
||||
sudo hc import --defines /etc/nftables.conf
|
||||
sudo hc ruleset # what Housecarl now applies
|
||||
sudo hc status # problems, and the other tables
|
||||
```
|
||||
|
||||
Housecarl's table is now in force **alongside** the old one: a packet
|
||||
must be accepted by both. Nothing should change for your traffic. If
|
||||
Housecarl already has a configuration, `--replace` replaces it.
|
||||
|
||||
Imported over the network (not through the local socket), the import is
|
||||
on probation like any change: confirm it.
|
||||
|
||||
## 3. Take over
|
||||
|
||||
Once the rules look right (and their hits in the web UI agree), delete
|
||||
the old table:
|
||||
|
||||
```sh
|
||||
sudo hc takeover "inet main"
|
||||
```
|
||||
|
||||
From now on only Housecarl's rules apply. Over the network, this is on
|
||||
probation too: unless confirmed, the old table comes back.
|
||||
|
||||
## 4. The boot
|
||||
|
||||
Housecarl's ruleset is loaded at boot by `housecarl-ruleset.service`.
|
||||
Debian's `nftables.service` loads `/etc/nftables.conf`, which usually
|
||||
starts with `flush ruleset`: that would delete Housecarl's table, or
|
||||
bring the old one back. Retire it:
|
||||
|
||||
```sh
|
||||
sudo systemctl disable nftables.service
|
||||
sudo mv /etc/nftables.conf /etc/nftables.conf.before-housecarl
|
||||
```
|
||||
|
||||
`housecarl-ruleset.service` runs after `nftables.service` in any case,
|
||||
so a leftover `flush ruleset` can't undo it. Docker, Incus and libvirt
|
||||
keep adding their own tables as before.
|
||||
|
||||
## Checking the result
|
||||
|
||||
* `hc rule list input` (and `forward`) side by side with
|
||||
`nft -a list table inet main`: the same rules, in the same order, with
|
||||
built-ins above.
|
||||
* The notes: each says what to check.
|
||||
* `hc counters` after a while: a rule with no hits that had hits before
|
||||
is worth a look.
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
# Installing Housecarl
|
||||
|
||||
Housecarl runs on Linux with nftables 1.0 or newer. It is made for and
|
||||
tested on Debian 13 (nftables 1.1.3). The CLI also runs on macOS and
|
||||
Windows.
|
||||
|
||||
## From the package registry
|
||||
|
||||
The release workflow publishes Debian packages to the Gitea registry,
|
||||
`stable` for releases and `testing` for release candidates. Jarl's and
|
||||
Steward's packages come from the same registry. If apt already has it,
|
||||
`sudo apt install housecarl` is all it takes. Otherwise add it:
|
||||
|
||||
```sh
|
||||
sudo install -d /etc/apt/keyrings
|
||||
sudo curl -fsSL https://gitea.adbraun.lol/api/packages/adam/debian/repository.key -o /etc/apt/keyrings/gitea-adam.asc
|
||||
echo "deb [signed-by=/etc/apt/keyrings/gitea-adam.asc] https://gitea.adbraun.lol/api/packages/adam/debian stable main" \
|
||||
| sudo tee /etc/apt/sources.list.d/gitea-adam.list
|
||||
sudo apt update
|
||||
sudo apt install housecarl
|
||||
```
|
||||
|
||||
For release candidates too, add the `testing` distribution: the same
|
||||
`deb` line with `testing` instead of `stable`.
|
||||
|
||||
There are two packages:
|
||||
|
||||
* **`housecarl`** is the server: `/usr/bin/housecarld`, the systemd units
|
||||
`housecarl.service`, `housecarl-ruleset.service` (the ruleset at boot),
|
||||
`housecarl-backup.service` and `housecarl-backup.timer`, and the
|
||||
`housecarl` user and directories. It creates
|
||||
`/etc/housecarl/housecarl.yaml` on the first install and never touches
|
||||
it again. It depends on `housecarl-cli` and `nftables`.
|
||||
* **`housecarl-cli`** is the CLI, `/usr/bin/hc`. It can be installed
|
||||
alone on any machine to manage Housecarl remotely.
|
||||
|
||||
Installing changes nothing in the firewall: housecarld applies a ruleset
|
||||
only once it has a configuration.
|
||||
|
||||
The release page also has `.deb` files, tarballs and the CLI for Linux,
|
||||
macOS and Windows.
|
||||
|
||||
## From source
|
||||
|
||||
```sh
|
||||
make # bin/housecarld, bin/hc (Go, as in go.mod)
|
||||
sudo make install # /usr/local/bin, units in /etc/systemd/system
|
||||
sudo systemd-sysusers housecarl.conf && sudo systemd-tmpfiles --create housecarl.conf
|
||||
sudo chgrp housecarl /etc/housecarl /etc/housecarl/housecarl.yaml && sudo systemctl daemon-reload
|
||||
```
|
||||
|
||||
`make deb` builds the packages (`DEB_ARCH=arm64` for another architecture).
|
||||
|
||||
## First steps
|
||||
|
||||
Do this from a console, or over SSH with a way back in if it goes wrong:
|
||||
you are changing the firewall you are connected through.
|
||||
|
||||
1. **Settings** are in `/etc/housecarl/housecarl.yaml`
|
||||
([configuration.md](configuration.md)). By default the web UI and API
|
||||
listen on `127.0.0.1:8070`. To reach them from your network, add an
|
||||
address there, ideally with TLS or behind a TLS reverse proxy.
|
||||
2. **Create the database and the first administrator:**
|
||||
|
||||
```sh
|
||||
sudo -u housecarl housecarld init
|
||||
```
|
||||
|
||||
It asks for the administrator's name (`admin` if you just press Enter;
|
||||
your own name is better) and password.
|
||||
3. **Start Housecarl:**
|
||||
|
||||
```sh
|
||||
sudo systemctl enable --now housecarl
|
||||
```
|
||||
|
||||
Until it has a configuration, housecarld applies nothing: the machine
|
||||
stays as open (or as closed) as it was.
|
||||
4. **Import your ruleset**, if you have one ([import.md](import.md)):
|
||||
|
||||
```sh
|
||||
sudo hc import --defines /etc/nftables.conf -n # look
|
||||
sudo hc import --defines /etc/nftables.conf # import
|
||||
sudo hc takeover "inet main"
|
||||
sudo systemctl disable nftables.service
|
||||
```
|
||||
|
||||
**Or start from scratch**, through the local socket (no probation).
|
||||
The first change puts the ruleset in force, and the input and forward
|
||||
policies drop what no rule accepts, so let yourself in first:
|
||||
|
||||
```sh
|
||||
sudo hc set add admins 192.168.1.0/24
|
||||
sudo hc rule add input --from admins --service ssh
|
||||
sudo hc rule add input --from admins --service tcp/8070 --comment "Housecarl"
|
||||
sudo hc zone add wan --interface ppp0 --external
|
||||
sudo hc zone add lan --interface eth1
|
||||
sudo hc rule add input --in lan --service dns --service dhcp
|
||||
sudo hc rule add forward --in lan --out wan
|
||||
sudo hc snat add --out wan
|
||||
sudo hc settings set --builtin mss_clamp=on --own-prefix 2001:db8::/48
|
||||
sudo hc status
|
||||
```
|
||||
|
||||
Housecarl doesn't add a rule that keeps SSH or the web UI open: you
|
||||
write it. Connections already open (your SSH session) carry on, since
|
||||
established connections are accepted.
|
||||
5. **Open the web UI** on an address in `listen`, and log in.
|
||||
|
||||
## From another machine
|
||||
|
||||
Install `housecarl-cli`, make a token, and set the server:
|
||||
|
||||
```sh
|
||||
sudo hc token create laptop # on the firewall; prints the token once
|
||||
hc config set server https://fw.example.com:8070
|
||||
hc config set token # paste it
|
||||
hc status
|
||||
```
|
||||
|
||||
Changes from there are on probation: `hc confirm` keeps them.
|
||||
|
||||
## Upgrading
|
||||
|
||||
`apt upgrade` restarts a running housecarld, which applies the ruleset
|
||||
again (counters keep counting) and upgrades its database when it starts.
|
||||
The firewall stays up during the upgrade: the ruleset belongs to the
|
||||
kernel, not to housecarld. Take a backup first if you like
|
||||
(`sudo -u housecarl housecarld backup`); the daily timer keeps 30 days of
|
||||
them.
|
||||
|
||||
## Removing
|
||||
|
||||
`apt remove housecarl` stops housecarld and disables the boot unit. The
|
||||
ruleset stays in force until the next boot, or until you delete it:
|
||||
`sudo nft delete table inet housecarl`. A purge keeps the database and
|
||||
the backups; remove `/var/lib/housecarl` and `/var/backups/housecarl` by
|
||||
hand if you no longer need them.
|
||||
@@ -0,0 +1,85 @@
|
||||
# Connecting Housecarl to Jarl
|
||||
|
||||
Housecarl uses [Jarl](https://gitea.adbraun.lol/adam/jarl), the BIND
|
||||
manager, for its DNS records. In this version that means one thing: the
|
||||
DNS-01 challenge for the web UI's certificate ([certificates.md](certificates.md)).
|
||||
Naming hosts in rules by their DNS names may use the same connection later.
|
||||
|
||||
Housecarl talks to Jarl's REST API with an API token. Give it a **user of its
|
||||
own**, with **only the permission it needs**.
|
||||
|
||||
## 1. A user for Housecarl, in Jarl
|
||||
|
||||
On the Jarl server, make a user without a password. It can't log in to
|
||||
Jarl's web UI, only use its tokens. Grant it the challenge record of
|
||||
Housecarl's own name, and nothing else:
|
||||
|
||||
```sh
|
||||
jarl user create housecarl --no-password --comment "Housecarl on fw01" \
|
||||
--grant "records:write zones=adbraun.lol names=_acme-challenge.fw.adbraun.lol types=TXT"
|
||||
```
|
||||
|
||||
Use your zone for `adbraun.lol`, and the name Housecarl's web UI is reached
|
||||
by for `fw.adbraun.lol` (`tls.acme.domain`). The grant lets the token add
|
||||
and remove TXT records at exactly `_acme-challenge.fw.adbraun.lol`:
|
||||
|
||||
* nothing else in the zone (`names=`);
|
||||
* no other record type (`types=TXT`);
|
||||
* no other zone (`zones=`).
|
||||
|
||||
It can see that the zone exists, which it needs to find it, but it can't
|
||||
read the zone's other records.
|
||||
|
||||
Why not something wider:
|
||||
|
||||
* **Jarl's `acme` role** (`records:write names=_acme-challenge.* types=TXT`)
|
||||
would let the token publish challenges for *any* name in *any* zone, and
|
||||
so obtain certificates for all of them. Housecarl needs only its own name.
|
||||
* **Your own account's token** would carry all your permissions, and
|
||||
Housecarl's certificate would stop renewing the day your account changes.
|
||||
A user per program keeps the two apart, and the audit log shows
|
||||
`housecarl/...` for what Housecarl did.
|
||||
|
||||
If the challenge name is a CNAME into a zone kept for challenges, such as
|
||||
`_acme-challenge.fw.adbraun.lol CNAME fw.acme.adbraun.lol.`, grant the
|
||||
target instead: `zones=acme.adbraun.lol names=fw.acme.adbraun.lol`.
|
||||
|
||||
## 2. The token
|
||||
|
||||
Make the token, and copy it to the Housecarl server into a file only the
|
||||
`housecarl` user can read:
|
||||
|
||||
```sh
|
||||
jarl token create fw01 --user housecarl -q > housecarl.token # on the Jarl server, or with the jarl CLI anywhere
|
||||
scp housecarl.token fw01:
|
||||
ssh fw01 'sudo install -m 0600 -o housecarl -g housecarl housecarl.token /etc/housecarl/jarl.token && rm housecarl.token'
|
||||
rm housecarl.token
|
||||
```
|
||||
|
||||
A token file is better than putting the token in `housecarl.yaml`: the
|
||||
configuration file is readable by the `housecarl` group, and gets copied
|
||||
around more readily.
|
||||
|
||||
You can give the token an expiry (`--expires 365d`). Rotate it in Jarl with
|
||||
`jarl token rotate ID --grace 1d`, then put the new secret in the file and
|
||||
restart housecarld within the grace period. Since the certificate is renewed
|
||||
every couple of months, nothing breaks while you do.
|
||||
|
||||
## 3. Housecarl's settings
|
||||
|
||||
In `/etc/housecarl/housecarl.yaml`:
|
||||
|
||||
```yaml
|
||||
jarl:
|
||||
server: https://dns.adbraun.lol:8053
|
||||
token_file: /etc/housecarl/jarl.token
|
||||
# ca_cert: /etc/housecarl/jarl-ca.pem # if Jarl's certificate isn't from a public CA
|
||||
```
|
||||
|
||||
Then restart housecarld (`systemctl restart housecarl`). Housecarl needs to
|
||||
reach Jarl's API over the network: Jarl's `listen` must include an
|
||||
address Housecarl can reach, ideally over HTTPS.
|
||||
|
||||
`hc cert check` ([certificates.md](certificates.md#checking)) tests
|
||||
the connection, the token's permission, and that the record reaches the
|
||||
internet.
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
# NAT
|
||||
|
||||
## Port forwards
|
||||
|
||||
A port forward publishes an inside host's service: connections to a
|
||||
public port are sent to the host, and let through the forward chain.
|
||||
One object does what takes three rules by hand (the DNAT, the forward
|
||||
accept, and the hairpin masquerade).
|
||||
|
||||
```sh
|
||||
hc forward add 8443 --to 192.168.1.10:443 --public 203.0.113.10 --hairpin --name web
|
||||
hc forward add 27015-27030 --proto tcp,udp --to 192.168.1.20 --public 203.0.113.10 --hairpin --name game
|
||||
hc forward add 25 --to 192.168.1.25 --from mail-relays --name smtp
|
||||
hc forward list
|
||||
```
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `PORTS` | the public port or range |
|
||||
| `--proto` | `tcp`, `udp` or both (`tcp,udp`); `tcp` by default |
|
||||
| `--to HOST[:PORT]` | the inside IPv4 host, and its port if it differs. A range keeps its ports |
|
||||
| `--public` | the public IPv4 addresses (or a set) to match. Without it, any address arriving on an external zone |
|
||||
| `--from` | only these sources may connect |
|
||||
| `--hairpin` | the public address also works from inside (below); `--hairpin-from` limits that to some inside sources |
|
||||
| `--to6` | an inside IPv6 host to open the port to (below) |
|
||||
|
||||
In the ruleset, a forward is a `dnat ip to` rule in the `dstnat` chain,
|
||||
an accept in `forward` for connections it translated (`ct status dnat`),
|
||||
placed after the built-ins and before your forward rules, so a later
|
||||
"drop everything from wan" rule doesn't stop it.
|
||||
|
||||
### Hairpin NAT
|
||||
|
||||
From inside, a client that connects to the public address (because DNS
|
||||
gives it) has its connection sent to the inside host, which would answer
|
||||
it directly, and the client would drop the answer. With `--hairpin`,
|
||||
Housecarl also masquerades those connections, so the answers come back
|
||||
through the firewall. It needs `--public`: without the public address,
|
||||
there is nothing to recognise those connections by.
|
||||
|
||||
### IPv6
|
||||
|
||||
IPv6 needs no translation: inside hosts have public addresses. `--to6`
|
||||
opens the same port (or the `--to` port, if given) to an IPv6 host:
|
||||
|
||||
```sh
|
||||
hc forward add 8443 --to 192.168.1.10:443 --to6 2001:db8:1::10 --public 203.0.113.10 --name web
|
||||
```
|
||||
|
||||
IPv6 clients connect to `[2001:db8:1::10]:443` directly. A forward
|
||||
can have only `--to6`, to open an IPv6 port alone.
|
||||
|
||||
## Source NAT
|
||||
|
||||
Source NAT rewrites the source of traffic leaving the firewall:
|
||||
masquerade (the outgoing interface's address) or a fixed address with
|
||||
`--snat-to`. Like rules, they are in order and match by zone, address and
|
||||
service; the first that matches decides.
|
||||
|
||||
```sh
|
||||
hc snat add --out wan --from trusted --comment "the LAN to the internet"
|
||||
hc snat add --out wg0 --not-out --from wg-clients
|
||||
hc snat add --out wan --from fd00:1::/48 --family ipv6 --snat-to 2001:db8::1
|
||||
```
|
||||
|
||||
Source NAT is IPv4 only unless `--family ipv6` says otherwise: with a
|
||||
delegated prefix, IPv6 needs no NAT, and NAT66 is for unusual cases
|
||||
(a ULA network behind one public address).
|
||||
|
||||
## Redirects
|
||||
|
||||
A redirect sends matching traffic to a port on the firewall itself, for
|
||||
example to make every client use the firewall's DNS server:
|
||||
|
||||
```sh
|
||||
hc redirect add --in lan --service dns --not-to 192.168.1.5 --comment "force DNS"
|
||||
hc redirect add --in lan --service http --to-port 3128
|
||||
```
|
||||
|
||||
Without `--to-port`, the service's own port. The input chain must
|
||||
accept the redirected traffic: redirected DNS needs a rule accepting DNS
|
||||
from `lan`.
|
||||
@@ -0,0 +1,174 @@
|
||||
# Running Housecarl
|
||||
|
||||
## The services
|
||||
|
||||
```sh
|
||||
systemctl status housecarl # housecarld: the API, the web UI, the log
|
||||
journalctl -u housecarl -f
|
||||
systemctl reload housecarl # reread the TLS certificate
|
||||
systemctl status housecarl-ruleset # the ruleset loaded at boot
|
||||
```
|
||||
|
||||
The firewall itself is the kernel's: it stays in force while housecarld
|
||||
is stopped, restarted or upgraded. Without housecarld there is no API,
|
||||
no web UI, no log and no probation timer.
|
||||
|
||||
housecarld logs each change it applies, rollbacks, and what it couldn't
|
||||
apply. When it starts, it applies the configuration again (rolling back
|
||||
first what was on probation when it stopped).
|
||||
|
||||
## Reviewing before applying
|
||||
|
||||
In the web UI, edits are collected as pending changes (the bar under the
|
||||
header), and Changes shows them with the diff of the ruleset; Apply puts
|
||||
them in force together. The switch in the header (Review edits / Live
|
||||
edits) applies each edit at once instead. With hc, see
|
||||
[cli.md](cli.md#changesets).
|
||||
|
||||
## Changes on probation
|
||||
|
||||
Changes made over the network roll back unless confirmed
|
||||
([design.md](design.md#not-locking-yourself-out)). The web UI and `hc`
|
||||
confirm their own changes once they reach Housecarl again over a new
|
||||
connection, so normally nothing waits:
|
||||
|
||||
```
|
||||
$ hc rule add input --from admins --service ssh
|
||||
created rule input #31
|
||||
applied and confirmed (history 12): this client reached Housecarl again over a new connection
|
||||
```
|
||||
|
||||
A change that cuts the client off can't be confirmed that way:
|
||||
|
||||
```
|
||||
applied on probation (history 13), but couldn't confirm it: … context deadline exceeded
|
||||
It is rolled back within 1m50s unless confirmed ('hc confirm')
|
||||
```
|
||||
|
||||
By hand (with `--no-confirm`, or with Settings → "a person confirms each
|
||||
change"):
|
||||
|
||||
```sh
|
||||
hc status # Probation: 2 changes roll back in 97s unless confirmed
|
||||
hc confirm
|
||||
hc rollback # or undo them now
|
||||
```
|
||||
|
||||
The web UI shows a banner with a countdown and both buttons. A rollback
|
||||
is in the history, as an undo by `housecarl` via `rollback`, so it can be
|
||||
undone like any change.
|
||||
|
||||
If you are cut off by a change: wait. When the time is up, the previous
|
||||
ruleset comes back by itself. If you are cut off by a confirmed change,
|
||||
use the console, or SSH from a source the rules still let in:
|
||||
|
||||
```sh
|
||||
sudo hc history list
|
||||
sudo hc history undo 42
|
||||
```
|
||||
|
||||
## The ruleset at boot
|
||||
|
||||
A confirmed ruleset (or one changed through the socket) is saved to
|
||||
`/var/lib/housecarl/ruleset.nft`, which `housecarl-ruleset.service`
|
||||
loads before the network comes up. To see what would load:
|
||||
|
||||
```sh
|
||||
sudo cat /var/lib/housecarl/ruleset.nft
|
||||
sudo nft -c -f /var/lib/housecarl/ruleset.nft # check it
|
||||
```
|
||||
|
||||
Dynamic sets start empty after a reboot.
|
||||
|
||||
Keep Debian's `nftables.service` disabled, or `/etc/nftables.conf`
|
||||
without `flush ruleset`: otherwise it deletes Housecarl's table at
|
||||
boot. `housecarl-ruleset.service` runs after it, which covers the
|
||||
ordinary case.
|
||||
|
||||
## Hits
|
||||
|
||||
Every rule, NAT rule, built-in and policy has a counter. `hc rule list`,
|
||||
`hc forward list` and the web UI show packets; `hc counters` shows them
|
||||
all, with bytes. They count from housecarld's start, and carry across
|
||||
changes to the ruleset.
|
||||
|
||||
## Quotas
|
||||
|
||||
`hc quotas` (and the rules page) show what each quota used and when it
|
||||
resets; `hc rule reset-quota ID` starts one again. housecarld resets
|
||||
quotas at midnight when their period ends (late, if it was stopped then),
|
||||
and saves their usage every five minutes and when it stops; after a
|
||||
reboot it puts it back. A reboot without a clean stop loses at most the
|
||||
last five minutes' count.
|
||||
|
||||
## The log
|
||||
|
||||
Rules with `--log` (Log in the web UI) and, with `hc settings set
|
||||
--log-drops on`, what the input and forward policies drop (at most 10
|
||||
packets a second) are logged:
|
||||
|
||||
```sh
|
||||
hc log -f
|
||||
hc log --search 198.51.100.7
|
||||
```
|
||||
|
||||
```
|
||||
10:02:13 hc input #95 drop: wan - 198.51.100.7:53868 -> 203.0.113.10:22 tcp syn
|
||||
10:02:15 hc forward policy drop: wan eth1 198.51.100.9:443 -> 192.168.1.20:51432 tcp
|
||||
```
|
||||
|
||||
The packets go to netlink log group 4171, where housecarld reads them;
|
||||
the last 2000 are kept in memory. They aren't written to the kernel log
|
||||
or the journal. A rule's log isn't rate-limited: on a busy rule, give it
|
||||
a limit, or log only new connections (`--state new`).
|
||||
|
||||
## When something doesn't get through
|
||||
|
||||
1. `hc log -f` with `--log-drops on`: is it dropped by a policy (no rule
|
||||
accepted it), or by a rule (its number is in the line)?
|
||||
2. `hc rule list forward` (or `input`): hits on the rule you expect?
|
||||
3. `hc ruleset`: the rule as nftables sees it, with the zones' interfaces
|
||||
and the sets' families.
|
||||
4. `hc status`: other tables. A Docker or Incus table, or an old one,
|
||||
can drop what Housecarl accepts.
|
||||
5. Port forwards: `hc forward list` hits, and the inside host's own
|
||||
firewall and default route (answers must go back through Housecarl).
|
||||
|
||||
## Backups
|
||||
|
||||
`housecarl-backup.timer` runs `housecarld backup` daily. It writes a
|
||||
consistent copy of the database to `backup.dir`
|
||||
(`/var/backups/housecarl/housecarl-<time>.db`), and deletes those older
|
||||
than `backup.retention` (30 days), keeping at least
|
||||
`backup.keep_at_least` (7). A backup holds everything: configuration,
|
||||
history, users, tokens (hashed) and the audit log.
|
||||
|
||||
```sh
|
||||
sudo -u housecarl housecarld backup # now
|
||||
sudo -u housecarl housecarld backup /tmp/h.db # to a file of your choice
|
||||
sudo systemctl disable --now housecarl-backup.timer # no daily backups
|
||||
```
|
||||
|
||||
To **restore**, stop housecarld first; restore refuses to replace the
|
||||
database under it:
|
||||
|
||||
```sh
|
||||
sudo systemctl stop housecarl
|
||||
sudo -u housecarl housecarld restore /var/backups/housecarl/housecarl-20261014T020000Z.db
|
||||
sudo systemctl start housecarl
|
||||
```
|
||||
|
||||
housecarld applies the restored configuration when it starts. The old
|
||||
database is kept next to it as `housecarl.db.before-restore-<time>`.
|
||||
|
||||
## Upgrades
|
||||
|
||||
`apt upgrade` restarts a running housecarld. A new version upgrades the
|
||||
database when it starts, and an older one refuses a database a newer one
|
||||
has upgraded: restore a backup to go back.
|
||||
|
||||
## Checks
|
||||
|
||||
`housecarld check` validates the configuration in the database against
|
||||
this machine's interfaces, the same checks as `hc status` shows
|
||||
([validation.md](validation.md)).
|
||||
@@ -0,0 +1,87 @@
|
||||
# Users, permissions and tokens
|
||||
|
||||
## Users
|
||||
|
||||
**Administrators** may do everything, including managing users. Everyone
|
||||
else gets **grants**, `resource:access`:
|
||||
|
||||
| Resource | read | write |
|
||||
|---|---|---|
|
||||
| `config` | settings, zones, sets, services, rules, NAT, the ruleset, history, interfaces, probation, changesets (the web UI shows the forms locked) | change them, stage and apply changesets (your own, or anyone's for administrators), undo, import, confirm and roll back |
|
||||
| `sets` | dynamic sets' entries | add and remove entries |
|
||||
| `log` | hit counters and the log | – |
|
||||
| `audit` | the audit log | – |
|
||||
| `*` | all of the above | |
|
||||
|
||||
A `sets` grant can name the sets it covers: `sets:write:guests` may
|
||||
change the entries of the set `guests` and no other;
|
||||
`sets:read:guests,blocked` may read two. That is the grant for another
|
||||
program, such as Steward's captive portal, which should be able to let a
|
||||
client in and nothing more. A dynamic set's entries need a `sets` grant
|
||||
even for those with `config:write`.
|
||||
|
||||
Only administrators may delete other tables (`hc takeover`), and manage
|
||||
users and the certificate (`hc cert`, Server → Certificate).
|
||||
|
||||
**Roles** are ready-made sets of grants:
|
||||
|
||||
| Role | Grants |
|
||||
|---|---|
|
||||
| `viewer` | `config:read sets:read log:read` |
|
||||
| `operator` | `config:write sets:write log:read audit:read` |
|
||||
| `log` | `log:read`, e.g. for a monitoring script |
|
||||
|
||||
```sh
|
||||
hc user add sam --role operator
|
||||
hc user add portal --no-password --grant sets:write:guests # tokens only
|
||||
hc user update sam --grant config:read --grant log:read
|
||||
```
|
||||
|
||||
The last administrator can't be removed, demoted or disabled.
|
||||
|
||||
## API tokens
|
||||
|
||||
A token has its user's permissions, or fewer. A token made with `--role`
|
||||
or `--grant` can do only that, even if its user can do more:
|
||||
|
||||
```sh
|
||||
hc token create portal --user portal --grant sets:write:guests --expires 365d
|
||||
```
|
||||
|
||||
The secret is shown once; only its hash is stored. A restricted token can
|
||||
only create tokens with a subset of its own grants. The web UI only offers
|
||||
roles within what your account may do; a token with more would get no
|
||||
more than its user anyway.
|
||||
|
||||
**Rotating** gives a token a new secret, keeping its ID, name and
|
||||
permissions, so only the programs that use it need updating:
|
||||
|
||||
```sh
|
||||
hc token rotate 7 --grace 1h # the old secret works for another hour
|
||||
hc token rotate --current --save # this CLI's own token
|
||||
```
|
||||
|
||||
A token that expires gets its lifetime again, from now (`--expires 90d`
|
||||
or `never` to change it). Without `--grace` the old secret stops at once:
|
||||
use that when it may have leaked. Using an old secret during its grace
|
||||
period is logged, naming the token and the address, so you can find the
|
||||
programs still to update. A restricted token may rotate itself and tokens
|
||||
no wider than itself. `hc token list` shows when each token was last
|
||||
used, and from where. `hc token revoke ID` stops one at once.
|
||||
|
||||
## The local socket
|
||||
|
||||
On the firewall, `/run/housecarl/housecarl.sock` lets root and the
|
||||
`housecarl` user act as administrator without a token, and their changes
|
||||
aren't on probation. The audit log shows them by their system user name.
|
||||
Both can already change the machine's firewall, so this adds convenience,
|
||||
not power. Anyone else on the socket needs a token, and their changes are
|
||||
on probation like any other.
|
||||
|
||||
## The audit log
|
||||
|
||||
Logins (and failed ones), users, tokens, every configuration change (with
|
||||
its history entry), changesets created, applied and discarded,
|
||||
confirmations and rollbacks, imports, takeovers and
|
||||
dynamic set entries are recorded with who, from where and how: `session`,
|
||||
`token` or `socket`.
|
||||
+254
@@ -0,0 +1,254 @@
|
||||
# Zones, sets, services and rules
|
||||
|
||||
## Zones
|
||||
|
||||
A zone names one or more interfaces, so rules can say "from the LAN"
|
||||
rather than `eth1`. An interface belongs to one zone at most. A trailing
|
||||
`*` matches many: `incusbr*`.
|
||||
|
||||
```sh
|
||||
hc zone add wan --interface wan --external
|
||||
hc zone add lan --interface eth1 --interface 'eth1.*'
|
||||
hc zone add wg --interface wg0
|
||||
```
|
||||
|
||||
**External** zones face the internet. Antispoofing, MSS clamping, the
|
||||
DHCPv6 client rule and port forwards (without public addresses) apply on
|
||||
them. Everything else is internal: ping to the firewall from internal
|
||||
zones is accepted by a built-in.
|
||||
|
||||
A zone may name an interface that doesn't exist yet (a WireGuard
|
||||
interface that comes up later): its rules match once it appears, and
|
||||
`hc status` warns until then.
|
||||
|
||||
## Address sets
|
||||
|
||||
A set is a named list of IPv4 and IPv6 addresses, prefixes and ranges,
|
||||
mixed:
|
||||
|
||||
```sh
|
||||
hc set add trusted 192.168.1.0/24 192.168.2.0/24 2001:db8:1::/64
|
||||
hc set add dns-servers 192.168.1.5 2001:db8:1::5 192.168.1.250-192.168.1.254
|
||||
```
|
||||
|
||||
In the ruleset each becomes two nftables sets, `trusted_v4` and
|
||||
`trusted_v6` (one if the set has only one family). A rule that names it
|
||||
becomes one nftables rule per family. Rules can also give addresses
|
||||
directly (`--from 192.168.1.3`); a set is better when several rules share
|
||||
them.
|
||||
|
||||
A **dynamic** set's entries are added at run time, each with a timeout,
|
||||
and nftables removes them when it runs out, even with housecarld stopped:
|
||||
|
||||
```sh
|
||||
hc set add guests --dynamic --timeout 24h
|
||||
hc set entries add guests 10.0.30.17 # for 24 hours
|
||||
hc set entries add guests 2001:db8:3::17 --timeout 2h
|
||||
hc set entries guests # with the time left
|
||||
hc set entries remove guests 10.0.30.17
|
||||
```
|
||||
|
||||
Entries aren't configuration: they aren't in the history and aren't on
|
||||
probation, but each change is in the audit log. They survive every
|
||||
change to the ruleset, and a restart of housecarld; a reboot empties
|
||||
them. A token can be limited to one set ([permissions.md](permissions.md)):
|
||||
that is how another program (Steward's captive portal) lets a client in.
|
||||
|
||||
## Services
|
||||
|
||||
A service names ports of a protocol (`tcp`, `udp`, `sctp`) and ICMP
|
||||
types:
|
||||
|
||||
```sh
|
||||
hc service add web tcp/8080 tcp/8443
|
||||
hc service add games udp/27015-27030 tcp/27015
|
||||
hc service add pmtu icmp/destination-unreachable icmpv6/packet-too-big
|
||||
```
|
||||
|
||||
Many are built in (`hc service list`): `ssh`, `dns` (TCP and UDP 53),
|
||||
`dot`, `http`, `https` (TCP, and UDP for QUIC), `smtp`, `submission`,
|
||||
`smtps`, `imaps`, `ntp`, `dhcp`, `dhcpv6`, `wireguard`, `openvpn`, `ipsec`,
|
||||
`snmp`, `syslog`, `mdns`, `rdp`, and `ping` (ICMP and ICMPv6 echo
|
||||
requests). Adding a service with a built-in's name replaces it. Rules may
|
||||
also give an entry directly: `--service tcp/8080`.
|
||||
|
||||
## Rules
|
||||
|
||||
Rules are kept in order, in four chains:
|
||||
|
||||
| Chain | | Hook |
|
||||
|---|---|---|
|
||||
| `input` | traffic to the firewall itself | input |
|
||||
| `forward` | traffic through it, between zones | forward |
|
||||
| `output` | traffic from it | output |
|
||||
| `early` | before connection tracking: cheap drops of what can never be right | prerouting, priority raw |
|
||||
|
||||
Each chain is checked top to bottom, and the first rule that accepts,
|
||||
drops or rejects decides. What no rule decides gets the chain's
|
||||
**policy** (Settings): by default input and forward drop, output
|
||||
accepts.
|
||||
|
||||
```sh
|
||||
hc rule add input --from trusted --service ssh --comment "SSH from home"
|
||||
hc rule add input --in wg --service dns
|
||||
hc rule add forward --in lan --out wan
|
||||
hc rule add forward --in wan -a drop --comment "nothing else gets in"
|
||||
hc rule list input
|
||||
hc rule move 12 --before 7
|
||||
```
|
||||
|
||||
A rule matches on what it gives, and all of it:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `--in`, `--out` | zones it comes in on, goes out on (`--not-in`, `--not-out`: any other). Input has no out zone, output no in zone |
|
||||
| `--from`, `--to` | sets, addresses, prefixes, ranges (`--not-from`, `--not-to`: anything else) |
|
||||
| `--service` | services or entries (`tcp/8080`, `icmp/echo-request`) |
|
||||
| `--family` | `ipv4` or `ipv6` only; otherwise it follows the addresses |
|
||||
| `--state` | connection states: `new`, `established`, `related`, `invalid`, `untracked` |
|
||||
| `--status` | connection status: `dnat` (port forwarded), `snat` |
|
||||
| `--days`, `--hours` | a time window: `--days Monday,Friday --hours 08:00-17:30`; `22:00-06:00` passes midnight. In the firewall's time zone, as the kernel knows it |
|
||||
| `--limit` | a rate: `10/minute`, or bytes: `1mbytes/second`; with `,burst=N`, `,over` (match beyond the rate) and `,per-source` or `,per-destination` ([Bandwidth and quotas](#bandwidth-and-quotas)) |
|
||||
| `--quota` | an allowance of traffic: `50gbytes,over,per-source,monthly=15` ([Bandwidth and quotas](#bandwidth-and-quotas)) |
|
||||
| `--conn-limit N` | a source with more than N connections |
|
||||
|
||||
The action is `accept`, `drop`, `reject` (an ICMP error, or with
|
||||
`--reject-with tcp-reset` a TCP reset) or `log` (log and go on).
|
||||
`--log` logs the packets of any rule ([operations.md](operations.md#the-log)).
|
||||
`--disabled` (or `hc rule disable ID`) keeps a rule without using it.
|
||||
|
||||
Examples:
|
||||
|
||||
```sh
|
||||
# SSH from the internet, at most 3 new connections a minute per source
|
||||
hc rule add input --in wan --service ssh --state new --limit 3/minute,burst=5,over,per-source -a drop --log
|
||||
hc rule add input --in wan --service ssh
|
||||
|
||||
# Children's devices offline at night
|
||||
hc rule add forward --from kids --out wan --hours 22:00-07:00 -a reject
|
||||
|
||||
# What was port forwarded may go on, the rest from the internet may not
|
||||
hc rule add forward --in wan --status dnat
|
||||
hc rule add forward --in wan -a drop
|
||||
```
|
||||
|
||||
### Bandwidth and quotas
|
||||
|
||||
A **byte rate** limits bandwidth: `--limit 20mbytes/second,over,per-source
|
||||
-a drop` drops what a device sends beyond 20 MB/s. Units are `bytes`,
|
||||
`kbytes` and `mbytes` (`kb`, `mb` for short); the burst is in the same
|
||||
unit. nftables can only hold byte rates per second (up to 4 GB/s) or per
|
||||
minute (up to 293 MB/minute); validation says so for larger ones. This is
|
||||
policing: traffic beyond the rate is dropped, not queued, so TCP slows to
|
||||
fit, and UDP loses packets. Shaping (queues, fair shares) is `tc`'s work,
|
||||
not a firewall's.
|
||||
|
||||
A **quota** is an allowance of traffic. `over` matches once it is used up,
|
||||
to drop what comes after:
|
||||
|
||||
```sh
|
||||
# Each device on the LAN: 50 GB a month, from the 15th (a billing day)
|
||||
hc rule add forward --in lan --out wan --quota 50gbytes,over,per-source,monthly=15 -a drop
|
||||
# The whole guest network: 10 GB a day together
|
||||
hc rule add forward --in guest --out wan --quota 10gbytes,over,daily -a drop
|
||||
hc quotas # what each used, and the next reset
|
||||
hc rule reset-quota 31 # start again now
|
||||
```
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `per-source`, `per-destination` | each address its own allowance; without, one allowance for everything the rule matches |
|
||||
| `daily`, `weekly`, `monthly=DAY` | when it starts again: at midnight; weekly on Mondays; monthly on that day (the last day of a shorter month). Without one, only by hand |
|
||||
|
||||
nftables counts a quota but never resets it, and forgets it when its
|
||||
table is replaced: Housecarl carries what each quota used across every
|
||||
change and restart, saves it every five minutes (and when housecarld
|
||||
stops) for after a reboot, and resets quotas when their period ends.
|
||||
A per-address quota counts an address: a device that gets a new one
|
||||
starts again. With Steward's reservations IPv4 addresses stay; IPv6 is
|
||||
grouped by /64 (below).
|
||||
|
||||
**Rules with a quota or a byte rate count every packet.** The built-in
|
||||
that accepts established connections comes before your rules, so a rule
|
||||
there only sees each connection's first packet. Rules that count traffic
|
||||
are checked before it instead, in their order (the web UI lists them
|
||||
first, marked "every packet"). So they can drop, reject or log, but not
|
||||
accept: an accept there would let packets past every later rule. Let the
|
||||
traffic in with a rule of its own. The flowtable's fast path skips the
|
||||
forward chain for established connections, so with it on, forward
|
||||
quotas and byte rates only count first packets (a warning says so).
|
||||
|
||||
**Per-address limits group addresses**: each IPv4 address on its own, and
|
||||
IPv6 addresses by /64, since one device uses several (privacy addresses
|
||||
change). `--group-v4 24` counts each /24 together; `--group-v6 128` each
|
||||
IPv6 address alone. This applies to per-address rates, connection limits
|
||||
and quotas.
|
||||
|
||||
### Raw rules
|
||||
|
||||
For what Housecarl can't say, a rule can be one nftables rule written by
|
||||
hand, placed like any other:
|
||||
|
||||
```sh
|
||||
hc rule add forward --raw 'iifname != "wan" ct state invalid ip daddr @trusted_v4 accept'
|
||||
```
|
||||
|
||||
It is checked by nftables with the rest of the ruleset, and can use the
|
||||
sets' nftables names (`@trusted_v4`). The importer makes raw rules from
|
||||
what it can't translate. It goes into the ruleset as written: add
|
||||
`counter` to it to count its packets.
|
||||
|
||||
## Built-ins
|
||||
|
||||
Housecarl adds the rules every firewall needs before yours. Each can be
|
||||
turned off (`hc settings set --builtin NAME=off`; Settings in the web
|
||||
UI), and `hc ruleset` shows them:
|
||||
|
||||
| Built-in | Default | Chains | What |
|
||||
|---|---|---|---|
|
||||
| `loopback` | on | input (and output if its policy is drop) | accept everything on `lo` |
|
||||
| `established` | on | input, forward (and output) | accept established and related connections |
|
||||
| `drop_invalid` | on | input, forward | drop packets connection tracking calls invalid |
|
||||
| `icmpv6` | on | input, forward (and output) | the ICMPv6 IPv6 can't work without (RFC 4890): errors and echo; neighbour discovery with hop limit 255 and MLD from link-local, to the firewall |
|
||||
| `ping_internal` | on | input | ping (ICMP echo) to the firewall from internal zones |
|
||||
| `dhcpv6_client` | on | input | DHCPv6 replies from link-local on external zones, so the firewall gets its prefix delegation |
|
||||
| `antispoof` | on | early | drop sources that can't come from the internet, on external zones: the antispoof lists (private, loopback, documentation and multicast ranges) and your own prefixes |
|
||||
| `mss_clamp` | off | forward | clamp TCP's MSS to the route's MTU on external zones: needed with PPPoE |
|
||||
|
||||
**Your prefixes** (`--own-prefix 2001:db8::/48`) are your delegated
|
||||
IPv6 prefix and public addresses: nothing from outside should come from
|
||||
them. The antispoof lists can be changed with `--antispoof-v4` and
|
||||
`--antispoof-v6`.
|
||||
|
||||
The **flowtable** (`--flowtable wan --flowtable eth1`) lets established
|
||||
TCP and UDP connections forwarded between those devices skip the rules,
|
||||
which helps on a slow CPU. It needs exact device names. Counters of
|
||||
forward rules then only count each connection's first packets.
|
||||
|
||||
## The ruleset
|
||||
|
||||
`hc ruleset` prints what Housecarl builds; the web UI and `hc rule list`
|
||||
show each rule's hits. The table looks like this (shortened):
|
||||
|
||||
```
|
||||
table inet housecarl {
|
||||
set trusted_v4 { type ipv4_addr; flags interval; elements = { 192.168.1.0/24, 192.168.2.0/24 } }
|
||||
set trusted_v6 { type ipv6_addr; flags interval; elements = { 2001:db8:1::/64 } }
|
||||
chain early { type filter hook prerouting priority raw; policy accept; ... }
|
||||
chain input { type filter hook input priority filter; policy drop;
|
||||
iifname "lo" accept comment "hc:b-loopback"
|
||||
ct state established,related counter accept comment "hc:b-established"
|
||||
...
|
||||
ip saddr @trusted_v4 tcp dport 22 counter accept comment "hc:r4 SSH from home"
|
||||
ip6 saddr @trusted_v6 tcp dport 22 counter accept comment "hc:r4 SSH from home"
|
||||
counter comment "hc:p-input"
|
||||
}
|
||||
chain forward { ... }
|
||||
chain output { ... }
|
||||
chain dstnat { type nat hook prerouting priority dstnat; ... }
|
||||
chain srcnat { type nat hook postrouting priority srcnat; ... }
|
||||
}
|
||||
```
|
||||
|
||||
Other tables still apply: a packet must get past every table's chains on
|
||||
its hook. The dashboard and `hc status` list them.
|
||||
@@ -0,0 +1,117 @@
|
||||
# Validation
|
||||
|
||||
Every change is checked as a whole configuration before it is applied.
|
||||
An **error** stops the change; a **warning** is reported with it, and
|
||||
stays on the dashboard and in `hc status` until fixed. Each problem has a
|
||||
stable code, shown by the CLI as `[code]` and in the API as
|
||||
`problems[].code`. `housecarld check` runs the checks on the saved
|
||||
configuration.
|
||||
|
||||
After Housecarl's own checks, **nftables** checks the rendered ruleset
|
||||
(`nft -c`). What it refuses (mostly raw rules) comes back as a
|
||||
`firewall` error with nftables' message, and nothing is changed.
|
||||
|
||||
The warnings marked *host* compare the configuration with this machine's
|
||||
interfaces.
|
||||
|
||||
## Names
|
||||
|
||||
Zones, sets, services and port forwards have names (a forward's is
|
||||
optional): a letter, then letters, digits, `-` and `_`, up to 48. Names
|
||||
are unique per kind, ignoring case.
|
||||
|
||||
| Code | | When |
|
||||
|---|---|---|
|
||||
| `zone_name`, `set_name`, `service_name`, `forward_name` | error | not a valid name, or one that looks like an address |
|
||||
| `zone_name_taken`, `set_name_taken`, `service_name_taken`, `forward_name_taken` | error | another object of the kind has that name |
|
||||
|
||||
## Settings
|
||||
|
||||
| Code | | When |
|
||||
|---|---|---|
|
||||
| `policy` | error | a policy that isn't `accept` or `drop` |
|
||||
| `settings_address` | error | an own prefix or antispoof entry that isn't an address or prefix, or is in the wrong list (an IPv6 prefix in the IPv4 list) |
|
||||
| `flowtable_device` | error | a flowtable device with a wildcard: name each device |
|
||||
| `flowtable_device_missing` | warning, *host* | a flowtable device doesn't exist; the flowtable is left out until it does |
|
||||
| `probation_range` | error | the probation isn't 10 to 3600 seconds |
|
||||
|
||||
## Zones
|
||||
|
||||
| Code | | When |
|
||||
|---|---|---|
|
||||
| `zone_empty` | error | no interfaces |
|
||||
| `zone_interface` | error | not an interface name (up to 15 characters; a trailing `*` matches many) |
|
||||
| `zone_interface_taken` | error | the interface is in another zone already |
|
||||
| `zone_interface_missing` | warning, *host* | the interface doesn't exist here (yet); its rules match once it does |
|
||||
| `no_external_zone` | warning | no zone is external: antispoofing, MSS clamping, the DHCPv6 client rule and port forwards without public addresses need one |
|
||||
|
||||
## Sets and services
|
||||
|
||||
| Code | | When |
|
||||
|---|---|---|
|
||||
| `set_entry` | error | an entry that isn't an address, prefix (without host bits) or range |
|
||||
| `set_empty` | warning | a set (not dynamic) with no entries: rules using it match nothing |
|
||||
| `set_timeout` | error | a timeout on a set that isn't dynamic, or under a second |
|
||||
| `service_empty` | error | a service with no entries |
|
||||
| `service_entry` | error | an entry that isn't `PROTO/PORTS` (`tcp`, `udp`, `sctp`; a port or range) or `icmp/TYPE`, `icmpv6/TYPE` with a type nftables knows |
|
||||
|
||||
## Matches
|
||||
|
||||
These apply to rules, source NAT and redirects.
|
||||
|
||||
| Code | | When |
|
||||
|---|---|---|
|
||||
| `unknown_zone` | error | a zone that doesn't exist |
|
||||
| `match_in_zone` | error | in zones on the output chain (it has no incoming interface) |
|
||||
| `match_out_zone` | error | out zones on the input or early chain, or on a redirect |
|
||||
| `match_address` | error | an address that can't be read |
|
||||
| `unknown_set` | error | a name that is neither an address nor a set |
|
||||
| `unknown_service` | error | a service that is neither built in nor yours |
|
||||
| `family` | error | a family that isn't `ipv4` or `ipv6` |
|
||||
| `match_never` | error | the addresses, services and family can't all match the same packet (IPv4 sources and an `icmpv6` service) |
|
||||
|
||||
## Rules
|
||||
|
||||
| Code | | When |
|
||||
|---|---|---|
|
||||
| `rule_chain` | error | a chain that isn't `input`, `forward`, `output` or `early` |
|
||||
| `rule_action` | error | an action that isn't `accept`, `drop`, `reject` or `log` |
|
||||
| `raw_rule` | error | a raw rule over several lines, or with `;` |
|
||||
| `early_conntrack` | error | connection states, limits or a connection limit in the early chain, which runs before connection tracking |
|
||||
| `early_reject` | error | reject in the early chain: drop instead |
|
||||
| `ct_state`, `ct_status` | error | an unknown connection state or status |
|
||||
| `rule_day`, `rule_hours` | error | an unknown day, or hours that aren't `HH:MM`, or only one of them |
|
||||
| `rule_limit` | error | a rate that isn't a number per second, minute, hour or day |
|
||||
| `rule_conn_limit` | error | a connection limit under 1 |
|
||||
| `rule_limit_unit` | error | a rate's unit that isn't packets, bytes, kbytes or mbytes |
|
||||
| `rule_limit_bytes` | error | a byte rate nftables can't hold: over 4 GB a second, 293 MB a minute, 4.9 MB an hour or 208 KB a day (the message gives it per second) |
|
||||
| `rule_limit_key` | error | a limit both per source and per destination |
|
||||
| `rule_quota` | error | a quota under 1 byte |
|
||||
| `rule_quota_key` | error | a quota both per source and per destination |
|
||||
| `rule_quota_reset` | error | a reset that isn't daily, weekly or monthly; a day with another reset than monthly; a day outside 1-31 |
|
||||
| `rule_group` | error | a grouping prefix outside 1-32 (IPv4) or 1-128 (IPv6) |
|
||||
| `rule_traffic_accept` | error | a rule with a quota or a byte rate that accepts: it is checked before established connections are accepted, so it can only drop, reject or log |
|
||||
| `flowtable_traffic` | warning | a forward rule with a quota or a byte rate while the flowtable is on: it only counts each connection's first packets |
|
||||
| `reject_with` | error | `tcp-reset` on a rule that matches more than TCP, or with an action other than reject |
|
||||
| `log_prefix` | error | a log prefix over 100 characters |
|
||||
|
||||
## Port forwards
|
||||
|
||||
| Code | | When |
|
||||
|---|---|---|
|
||||
| `forward_protocol` | error | no protocols, or one that isn't `tcp`, `udp` or `sctp` |
|
||||
| `forward_ports` | error | the public port isn't a port or range |
|
||||
| `forward_to_port` | error | the inside port isn't one port, or is given for a range of public ports |
|
||||
| `forward_target` | error | neither `to` nor `to6`, or `to` that isn't one IPv4 address, or `to6` one IPv6 address |
|
||||
| `forward_public` | error | no public addresses and no external zone, or an IPv6 public address |
|
||||
| `forward_overlap` | error | another forward has the same protocol, port and public address |
|
||||
| `forward_hairpin` | warning | hairpin without public addresses: inside clients' connections can't be recognised |
|
||||
|
||||
## Source NAT and redirects
|
||||
|
||||
| Code | | When |
|
||||
|---|---|---|
|
||||
| `snat_to` | error | `to` isn't one address, or of another family than the rule (give the rule the family) |
|
||||
| `snat_everything` | warning | no out zones and no sources: everything leaving the firewall is translated |
|
||||
| `redirect_service` | error | no service, or one without TCP, UDP or SCTP ports |
|
||||
| `redirect_to_port` | error | the local port isn't one port |
|
||||
@@ -2,9 +2,31 @@ module housecarl
|
||||
|
||||
go 1.27.1
|
||||
|
||||
require github.com/spf13/cobra v1.10.2
|
||||
require (
|
||||
github.com/aymanbagabas/go-udiff v0.4.1
|
||||
github.com/go-acme/lego/v4 v4.35.2
|
||||
github.com/miekg/dns v1.1.73
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/spf13/pflag v1.0.9
|
||||
go.yaml.in/yaml/v3 v3.0.5
|
||||
golang.org/x/crypto v0.57.0
|
||||
golang.org/x/sys v0.48.0
|
||||
golang.org/x/term v0.46.0
|
||||
modernc.org/sqlite v1.60.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/spf13/pflag v1.0.9 // indirect
|
||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/net v0.58.0 // indirect
|
||||
golang.org/x/text v0.42.0 // indirect
|
||||
modernc.org/libc v1.77.1 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.12.1 // indirect
|
||||
)
|
||||
@@ -1,10 +1,84 @@
|
||||
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
|
||||
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/go-acme/lego/v4 v4.35.2 h1:uVQg+KC/yj9R2g7Q9W5wDqhvQvxV5SMu5eqFVoN5xZU=
|
||||
github.com/go-acme/lego/v4 v4.35.2/go.mod h1:pX2jN5n8OphMGY1IaMjYm5DAEzguBaKRt8AvJAgJXpc=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
|
||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||
github.com/miekg/dns v1.1.73 h1:uhT8nJxmTrPJYClxVxTCX+CVn6qnzSiybRk72Z6DgrE=
|
||||
github.com/miekg/dns v1.1.73/go.mod h1:RW2Obtfd5NZHvOFe3zYG0W8koWOQtAzyHaLo8vASBuQ=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
||||
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
||||
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
|
||||
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
|
||||
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
||||
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
||||
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
|
||||
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
|
||||
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
|
||||
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
|
||||
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
|
||||
golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU=
|
||||
golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
modernc.org/cc/v4 v4.29.7 h1:q+NXGJ0bK3b4TXFYQQVr9pYETGnmwFWkrUzJnMya/Tg=
|
||||
modernc.org/cc/v4 v4.29.7/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.36.1 h1:ZNIUZAryN0UgnJwtyxrdEzcFc3yD4Cu4AzjfPXsLsIE=
|
||||
modernc.org/ccgo/v4 v4.36.1/go.mod h1:rrtGc2QkS239nYb/mQNuBMyjq3/y3ZXWbBjPoV3wqzA=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc=
|
||||
modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.77.1 h1:Ct8j47QtiZ1Enj2DtFXQtUqrPCAjdCmPjtCuvrYQ0Hs=
|
||||
modernc.org/libc v1.77.1/go.mod h1:87/pZ4L6nD1zqW4nItuS12YO7hN1igAah34xjnQo/W0=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
|
||||
modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.60.1 h1:/blz53O951KWFOso4QQvEs/Fq6cDBKLtMVrYNSeJVKw=
|
||||
modernc.org/sqlite v1.60.1/go.mod h1:1dIoEagfDE72QytD5scH1lxARtaUgKgHC/NuApA27r0=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
@@ -0,0 +1,332 @@
|
||||
// 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"`
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
// Package auth handles passwords, API tokens, sessions and permissions.
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base32"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/argon2"
|
||||
|
||||
"housecarl/internal/model"
|
||||
)
|
||||
|
||||
// Argon2id parameters (OWASP: m=19 MiB, t=2, p=1).
|
||||
const (
|
||||
argonTime = 2
|
||||
argonMemory = 19 * 1024
|
||||
argonThreads = 1
|
||||
argonKeyLen = 32
|
||||
saltLen = 16
|
||||
)
|
||||
|
||||
// MinPasswordLength is the shortest accepted password.
|
||||
const MinPasswordLength = 8
|
||||
|
||||
// HashPassword returns an argon2id hash in PHC string format.
|
||||
func HashPassword(password string) (string, error) {
|
||||
if len(password) < MinPasswordLength {
|
||||
return "", fmt.Errorf("a password needs at least %d characters", MinPasswordLength)
|
||||
}
|
||||
salt := make([]byte, saltLen)
|
||||
if _, err := rand.Read(salt); err != nil {
|
||||
return "", err
|
||||
}
|
||||
key := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
|
||||
return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", argon2.Version, argonMemory, argonTime, argonThreads,
|
||||
base64.RawStdEncoding.EncodeToString(salt), base64.RawStdEncoding.EncodeToString(key)), nil
|
||||
}
|
||||
|
||||
// dummyHash is checked when a user doesn't exist, so a login takes as
|
||||
// long whether or not the username is right.
|
||||
var dummyHash, _ = HashPassword("not-a-real-password")
|
||||
|
||||
// CheckPassword reports whether password matches hash. An empty hash never
|
||||
// matches (in the same time).
|
||||
func CheckPassword(hash, password string) bool {
|
||||
if hash == "" {
|
||||
hash = dummyHash
|
||||
password = "\x00" + password
|
||||
}
|
||||
parts := strings.Split(hash, "$")
|
||||
if len(parts) != 6 || parts[1] != "argon2id" {
|
||||
return false
|
||||
}
|
||||
var m, t uint32
|
||||
var p uint8
|
||||
for _, kv := range strings.Split(parts[3], ",") {
|
||||
k, v, _ := strings.Cut(kv, "=")
|
||||
n, err := strconv.ParseUint(v, 10, 32)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
switch k {
|
||||
case "m":
|
||||
m = uint32(n)
|
||||
case "t":
|
||||
t = uint32(n)
|
||||
case "p":
|
||||
p = uint8(n)
|
||||
}
|
||||
}
|
||||
salt, err1 := base64.RawStdEncoding.DecodeString(parts[4])
|
||||
want, err2 := base64.RawStdEncoding.DecodeString(parts[5])
|
||||
if err1 != nil || err2 != nil || m == 0 || t == 0 || p == 0 {
|
||||
return false
|
||||
}
|
||||
got := argon2.IDKey([]byte(password), salt, t, m, p, uint32(len(want)))
|
||||
return subtle.ConstantTimeCompare(got, want) == 1
|
||||
}
|
||||
|
||||
var b32 = base32.NewEncoding("abcdefghijklmnopqrstuvwxyz234567").WithPadding(base32.NoPadding)
|
||||
|
||||
// RandomString returns a random string with nbytes of entropy.
|
||||
func RandomString(nbytes int) string {
|
||||
b := make([]byte, nbytes)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return b32.EncodeToString(b)
|
||||
}
|
||||
|
||||
// TokenPrefix starts every API token, so leaked ones are easy to spot.
|
||||
const TokenPrefix = "housecarl_"
|
||||
|
||||
// NewToken makes a token: the whole string to give out, its public ID
|
||||
// (stored in clear) and the hash of its secret.
|
||||
func NewToken() (token, publicID, secretHash string) {
|
||||
publicID = RandomString(10) // 16 characters
|
||||
secret := RandomString(20) // 32 characters
|
||||
return TokenPrefix + publicID + "_" + secret, publicID, HashSecret(secret)
|
||||
}
|
||||
|
||||
// RotateToken makes a new secret for a token's public ID.
|
||||
func RotateToken(publicID string) (token, secretHash string) {
|
||||
secret := RandomString(20)
|
||||
return TokenPrefix + publicID + "_" + secret, HashSecret(secret)
|
||||
}
|
||||
|
||||
// ParseToken splits a token into its public ID and secret.
|
||||
func ParseToken(token string) (publicID, secret string, err error) {
|
||||
rest, ok := strings.CutPrefix(strings.TrimSpace(token), TokenPrefix)
|
||||
if !ok {
|
||||
return "", "", errors.New("not a Housecarl token (they start with " + TokenPrefix + ")")
|
||||
}
|
||||
publicID, secret, ok = strings.Cut(rest, "_")
|
||||
if !ok || len(publicID) != 16 || len(secret) != 32 {
|
||||
return "", "", errors.New("malformed token")
|
||||
}
|
||||
return publicID, secret, nil
|
||||
}
|
||||
|
||||
// HashSecret hashes a high-entropy secret (token secrets, session IDs).
|
||||
func HashSecret(secret string) string {
|
||||
h := sha256.Sum256([]byte(secret))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
// CheckSecret compares a secret with a stored hash in constant time.
|
||||
func CheckSecret(secret, hash string) bool {
|
||||
return subtle.ConstantTimeCompare([]byte(HashSecret(secret)), []byte(hash)) == 1
|
||||
}
|
||||
|
||||
// NewSessionID returns a session ID and its CSRF token.
|
||||
func NewSessionID() (id, csrf string) {
|
||||
return RandomString(32), RandomString(32)
|
||||
}
|
||||
|
||||
// Resources grants apply to.
|
||||
const (
|
||||
ResConfig = "config" // the firewall configuration, its history and imports
|
||||
ResSets = "sets" // dynamic sets' entries (a grant may name the sets)
|
||||
ResLog = "log" // counters, the drop log and the status
|
||||
ResAudit = "audit" // the audit log
|
||||
ResAll = "*"
|
||||
)
|
||||
|
||||
// Resources lists them in display order.
|
||||
var Resources = []string{ResConfig, ResSets, ResLog, ResAudit}
|
||||
|
||||
// Access levels.
|
||||
const (
|
||||
Read = "read"
|
||||
Write = "write"
|
||||
)
|
||||
|
||||
// Roles are ready-made sets of grants.
|
||||
var Roles = map[string][]model.Grant{
|
||||
"viewer": {{Resource: ResConfig, Access: Read}, {Resource: ResSets, Access: Read}, {Resource: ResLog, Access: Read}},
|
||||
"operator": {{Resource: ResConfig, Access: Write}, {Resource: ResSets, Access: Write}, {Resource: ResLog, Access: Read}, {Resource: ResAudit, Access: Read}},
|
||||
"log": {{Resource: ResLog, Access: Read}},
|
||||
}
|
||||
|
||||
// RoleNames lists the roles.
|
||||
func RoleNames() []string {
|
||||
var out []string
|
||||
for n := range Roles {
|
||||
out = append(out, n)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// ParseGrant reads "resource:access" ("log:read", "*:write"), and for
|
||||
// sets optionally the sets it is limited to ("sets:write:guest,portal").
|
||||
func ParseGrant(s string) (model.Grant, error) {
|
||||
parts := strings.SplitN(strings.TrimSpace(s), ":", 3)
|
||||
g := model.Grant{Resource: strings.ToLower(parts[0])}
|
||||
if len(parts) < 2 {
|
||||
return g, fmt.Errorf("grant %q: write it as resource:access, e.g. log:read, or sets:write:SET1,SET2", s)
|
||||
}
|
||||
g.Access = strings.ToLower(parts[1])
|
||||
if len(parts) == 3 {
|
||||
for _, n := range strings.Split(parts[2], ",") {
|
||||
if n = strings.TrimSpace(n); n != "" {
|
||||
g.Sets = append(g.Sets, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
return g, ValidateGrant(&g)
|
||||
}
|
||||
|
||||
// ValidateGrant checks a grant.
|
||||
func ValidateGrant(g *model.Grant) error {
|
||||
g.Resource = strings.ToLower(strings.TrimSpace(g.Resource))
|
||||
g.Access = strings.ToLower(strings.TrimSpace(g.Access))
|
||||
ok := g.Resource == ResAll
|
||||
for _, r := range Resources {
|
||||
ok = ok || g.Resource == r
|
||||
}
|
||||
if !ok {
|
||||
return fmt.Errorf("unknown resource %q (use %s or *)", g.Resource, strings.Join(Resources, ", "))
|
||||
}
|
||||
if g.Access != Read && g.Access != Write {
|
||||
return fmt.Errorf("access must be read or write, not %q", g.Access)
|
||||
}
|
||||
if len(g.Sets) > 0 && g.Resource != ResSets {
|
||||
return fmt.Errorf("only a sets grant can name sets (sets:write:%s)", strings.Join(g.Sets, ","))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// FormatGrant writes a grant as "resource:access" (and ":SET,…").
|
||||
func FormatGrant(g model.Grant) string {
|
||||
if len(g.Sets) > 0 {
|
||||
return g.Resource + ":" + g.Access + ":" + strings.Join(g.Sets, ",")
|
||||
}
|
||||
return g.Resource + ":" + g.Access
|
||||
}
|
||||
|
||||
// Principal is who makes a request.
|
||||
type Principal struct {
|
||||
User *model.User
|
||||
Token *model.Token
|
||||
Method string // session, token, socket
|
||||
IP string
|
||||
Agent string
|
||||
}
|
||||
|
||||
// Name is the user's name.
|
||||
func (p *Principal) Name() string {
|
||||
if p == nil || p.User == nil {
|
||||
return ""
|
||||
}
|
||||
return p.User.Username
|
||||
}
|
||||
|
||||
// IsAdmin reports whether the principal has full rights: an
|
||||
// administrator, not through a restricted token.
|
||||
func (p *Principal) IsAdmin() bool {
|
||||
return p.User != nil && p.User.Admin && (p.Token == nil || p.Token.Grants == nil)
|
||||
}
|
||||
|
||||
// Can reports whether the principal may read (write false) or change
|
||||
// (write true) a resource.
|
||||
func (p *Principal) Can(resource string, write bool) bool {
|
||||
if p == nil || p.User == nil {
|
||||
return false
|
||||
}
|
||||
allowed := p.User.Admin || covers(p.User.Grants, resource, write)
|
||||
if p.Token != nil && p.Token.Grants != nil {
|
||||
allowed = allowed && covers(p.Token.Grants, resource, write)
|
||||
}
|
||||
return allowed
|
||||
}
|
||||
|
||||
func covers(gs []model.Grant, resource string, write bool) bool {
|
||||
for _, g := range gs {
|
||||
if (g.Resource == ResAll || g.Resource == resource) && (!write || g.Access == Write) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// CanSet reports whether the principal may read or change the entries of
|
||||
// a dynamic set: a sets grant covers it if it names no sets, or names it.
|
||||
func (p *Principal) CanSet(name string, write bool) bool {
|
||||
if p == nil || p.User == nil {
|
||||
return false
|
||||
}
|
||||
allowed := p.User.Admin || coversSet(p.User.Grants, name, write)
|
||||
if p.Token != nil && p.Token.Grants != nil {
|
||||
allowed = allowed && coversSet(p.Token.Grants, name, write)
|
||||
}
|
||||
return allowed
|
||||
}
|
||||
|
||||
func coversSet(gs []model.Grant, name string, write bool) bool {
|
||||
for _, g := range gs {
|
||||
if (g.Resource != ResAll && g.Resource != ResSets) || (write && g.Access != Write) {
|
||||
continue
|
||||
}
|
||||
if len(g.Sets) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, s := range g.Sets {
|
||||
if strings.EqualFold(s, name) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Permissions lists what the principal may do, for display.
|
||||
func (p *Principal) Permissions() []string {
|
||||
var out []string
|
||||
for _, r := range Resources {
|
||||
switch {
|
||||
case p.Can(r, true):
|
||||
out = append(out, r+":write")
|
||||
case p.Can(r, false):
|
||||
out = append(out, r+":read")
|
||||
}
|
||||
}
|
||||
if p.IsAdmin() {
|
||||
out = append(out, "users:write")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Narrower reports whether every grant in a is covered by b.
|
||||
func Narrower(a, b []model.Grant) bool {
|
||||
for _, g := range a {
|
||||
if !covers(b, g.Resource, g.Access == Write) {
|
||||
if g.Resource != ResAll {
|
||||
return false
|
||||
}
|
||||
for _, r := range Resources {
|
||||
if !covers(b, r, g.Access == Write) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"housecarl/internal/model"
|
||||
)
|
||||
|
||||
func TestPasswords(t *testing.T) {
|
||||
h, err := HashPassword("correct horse")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !CheckPassword(h, "correct horse") || CheckPassword(h, "wrong horse") || CheckPassword("", "") {
|
||||
t.Error("password check")
|
||||
}
|
||||
if _, err := HashPassword("short"); err == nil {
|
||||
t.Error("short password accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokens(t *testing.T) {
|
||||
tok, pid, hash := NewToken()
|
||||
gotPID, secret, err := ParseToken(tok)
|
||||
if err != nil || gotPID != pid || !CheckSecret(secret, hash) {
|
||||
t.Fatalf("token %q: %v", tok, err)
|
||||
}
|
||||
if _, _, err := ParseToken("jarl_abc_def"); err == nil {
|
||||
t.Error("a Jarl token was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermissions(t *testing.T) {
|
||||
op := &model.User{Username: "op", Grants: Roles["operator"]}
|
||||
p := &Principal{User: op}
|
||||
if !p.Can(ResConfig, true) || !p.Can(ResAudit, false) || p.Can(ResAudit, true) || p.IsAdmin() {
|
||||
t.Error("operator")
|
||||
}
|
||||
admin := &model.User{Username: "admin", Admin: true}
|
||||
p = &Principal{User: admin, Token: &model.Token{Grants: []model.Grant{{Resource: ResLog, Access: Read}}}}
|
||||
if !p.Can(ResLog, false) || p.Can(ResLog, true) || p.Can(ResConfig, false) || p.IsAdmin() {
|
||||
t.Error("restricted admin token")
|
||||
}
|
||||
if !Narrower(Roles["log"], Roles["operator"]) || Narrower(Roles["operator"], Roles["viewer"]) {
|
||||
t.Error("narrower")
|
||||
}
|
||||
if _, err := ParseGrant("log"); err == nil {
|
||||
t.Error("grant without access accepted")
|
||||
}
|
||||
if g, err := ParseGrant("Config:Write"); err != nil || g.Resource != "config" {
|
||||
t.Errorf("grant %v %v", g, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetGrants(t *testing.T) {
|
||||
g, err := ParseGrant("sets:write:guest-signed-in, portal")
|
||||
if err != nil || len(g.Sets) != 2 || FormatGrant(g) != "sets:write:guest-signed-in,portal" {
|
||||
t.Fatalf("parse %+v %v", g, err)
|
||||
}
|
||||
if _, err := ParseGrant("config:write:guest"); err == nil {
|
||||
t.Error("a config grant naming sets was accepted")
|
||||
}
|
||||
user := &model.User{Username: "portal", Grants: []model.Grant{g}}
|
||||
p := &Principal{User: user}
|
||||
if !p.CanSet("guest-signed-in", true) || !p.CanSet("PORTAL", false) || p.CanSet("trusted", false) || p.Can(ResConfig, false) {
|
||||
t.Error("set-scoped user")
|
||||
}
|
||||
admin := &Principal{User: &model.User{Username: "admin", Admin: true},
|
||||
Token: &model.Token{Grants: []model.Grant{{Resource: ResSets, Access: Write, Sets: []string{"guest-signed-in"}}}}}
|
||||
if !admin.CanSet("guest-signed-in", true) || admin.CanSet("trusted", true) {
|
||||
t.Error("an admin's set-scoped token")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
package certs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
|
||||
"housecarl/internal/api"
|
||||
"housecarl/internal/auth"
|
||||
"housecarl/internal/jarl"
|
||||
)
|
||||
|
||||
// Step and Check are api.CertStep and api.CertCheck.
|
||||
type (
|
||||
Step = api.CertStep
|
||||
Check = api.CertCheck
|
||||
)
|
||||
|
||||
// note records a query and its answer, replacing an earlier answer to
|
||||
// the same question (the checks ask again until the record shows up).
|
||||
func note(s *Step, q string) {
|
||||
cmd, _, _ := strings.Cut(q, " → ")
|
||||
for i, old := range s.Queries {
|
||||
if prev, _, _ := strings.Cut(old, " → "); prev == cmd {
|
||||
s.Queries[i] = q
|
||||
return
|
||||
}
|
||||
}
|
||||
s.Queries = append(s.Queries, q)
|
||||
}
|
||||
|
||||
func add(c *Check, name, status, format string, args ...any) *Step {
|
||||
c.Steps = append(c.Steps, Step{Name: name, Status: status, Detail: fmt.Sprintf(format, args...)})
|
||||
return &c.Steps[len(c.Steps)-1]
|
||||
}
|
||||
|
||||
// How long the check waits for the record to show up, and the name
|
||||
// servers' port (tests change them).
|
||||
var (
|
||||
propagationWait = 90 * time.Second
|
||||
pollEvery = 2 * time.Second
|
||||
nsPort = "53"
|
||||
)
|
||||
|
||||
// Preflight checks, without the CA, that a certificate can be obtained:
|
||||
// Jarl answers and knows the token, a zone holds the challenge name, the
|
||||
// token may write it, a test record can be written, the zone's name
|
||||
// servers and public resolvers serve it, and it can be removed again.
|
||||
func (m *Manager) Preflight(ctx context.Context) *Check {
|
||||
c := &Check{Time: time.Now().UTC(), Domain: m.cfg.ACME.Domain}
|
||||
defer func() {
|
||||
c.OK = true
|
||||
for _, s := range c.Steps {
|
||||
if s.Status == "fail" {
|
||||
c.OK = false
|
||||
}
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.lastCheck = c
|
||||
m.mu.Unlock()
|
||||
}()
|
||||
name := jarl.ChallengeName(m.cfg.ACME.Domain)
|
||||
// A CNAME at the challenge name sends the challenge elsewhere (into a
|
||||
// zone kept for challenges); the CA and lego follow it, so the check
|
||||
// does too.
|
||||
if target := m.cname(ctx, name); target != "" {
|
||||
name = target
|
||||
}
|
||||
shown := strings.TrimSuffix(name, ".")
|
||||
|
||||
who, err := m.jarl.Whoami(ctx)
|
||||
if err != nil {
|
||||
add(c, "Jarl", "fail", "%s", explainJarl(err))
|
||||
return c
|
||||
}
|
||||
add(c, "Jarl", "ok", "the token is %s's, on Jarl %s", who.User.Username, who.Build.Label)
|
||||
|
||||
all, err := m.jarl.Zones(ctx)
|
||||
if err != nil {
|
||||
add(c, "Zone", "fail", "listing the zones: %s", explainJarl(err))
|
||||
return c
|
||||
}
|
||||
zones := jarl.ZonesFor(name, all, "")
|
||||
if len(zones) == 0 {
|
||||
add(c, "Zone", "fail", "no primary zone the token can see holds %s: add the zone to Jarl, or give the token's user access to it (zones=...)", shown)
|
||||
return c
|
||||
}
|
||||
zone := strings.TrimSuffix(zones[0].Name, ".")
|
||||
// Every view with the zone gets the record (linked zones through
|
||||
// their link), so name them all.
|
||||
var inViews []jarl.Zone
|
||||
for _, z := range all {
|
||||
if strings.EqualFold(z.Name, zones[0].Name) {
|
||||
inViews = append(inViews, z)
|
||||
}
|
||||
}
|
||||
add(c, "Zone", "ok", "%s%s", zone, viewsText(inViews))
|
||||
|
||||
value := "housecarl-check-" + auth.RandomString(8)
|
||||
if err := m.jarl.SetTXT(ctx, name, value, zones, true, true, "Housecarl's certificate check"); err != nil {
|
||||
add(c, "Permission", "fail", "%s", explainWrite(err, zone, shown))
|
||||
return c
|
||||
}
|
||||
add(c, "Permission", "ok", "the token may write TXT records at %s", shown)
|
||||
|
||||
if err := m.jarl.SetTXT(ctx, name, value, zones, true, false, "Housecarl's certificate check"); err != nil {
|
||||
add(c, "Write", "fail", "writing a test record: %s", explainWrite(err, zone, shown))
|
||||
return c
|
||||
}
|
||||
add(c, "Write", "ok", "wrote TXT %q at %s; Jarl has it in named", value, shown)
|
||||
defer func() {
|
||||
clean, cancel := context.WithTimeout(context.WithoutCancel(ctx), time.Minute)
|
||||
defer cancel()
|
||||
if err := m.jarl.SetTXT(clean, name, value, zones, false, false, "Housecarl's certificate check"); err != nil {
|
||||
add(c, "Clean up", "fail", "removing the test record: %s (remove it in Jarl by hand)", explainJarl(err))
|
||||
return
|
||||
}
|
||||
add(c, "Clean up", "ok", "removed the test record")
|
||||
}()
|
||||
|
||||
authOK := true
|
||||
if m.cfg.ACME.CheckPropagation {
|
||||
authOK = m.checkNameServers(ctx, c, zone, name, value)
|
||||
} else {
|
||||
add(c, "Name servers", "skip", "tls.acme.check_propagation is off")
|
||||
}
|
||||
m.checkResolvers(ctx, c, name, value, authOK && m.cfg.ACME.CheckPropagation)
|
||||
return c
|
||||
}
|
||||
|
||||
func viewsText(zs []jarl.Zone) string {
|
||||
var views []string
|
||||
for _, z := range zs {
|
||||
if z.View != "" {
|
||||
views = append(views, z.View)
|
||||
}
|
||||
}
|
||||
switch len(views) {
|
||||
case 0:
|
||||
return ""
|
||||
case 1:
|
||||
return " in view " + views[0]
|
||||
}
|
||||
return " in views " + strings.Join(views, ", ")
|
||||
}
|
||||
|
||||
func explainJarl(err error) string {
|
||||
var je *jarl.Error
|
||||
if errors.As(err, &je) && je.Status == 401 {
|
||||
return "Jarl refused the token (" + je.Message + "): check jarl.token or jarl.token_file"
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
func explainWrite(err error, zone, name string) string {
|
||||
var je *jarl.Error
|
||||
if errors.As(err, &je) && je.Status == 403 {
|
||||
return fmt.Sprintf("the token may not write the challenge record (%s); give it the grant: records:write zones=%s names=%s types=TXT", je.Message, zone, name)
|
||||
}
|
||||
return explainJarl(err)
|
||||
}
|
||||
|
||||
// checkNameServers asks each of the zone's public name servers directly
|
||||
// (as the CA's resolvers will) until all serve the value.
|
||||
func (m *Manager) checkNameServers(ctx context.Context, c *Check, zone, name, value string) bool {
|
||||
st := add(c, "Name servers", "fail", "")
|
||||
ns, q, err := m.lookupNS(ctx, zone)
|
||||
st.Queries = append(st.Queries, q...)
|
||||
if err != nil || len(ns) == 0 {
|
||||
st.Detail = fmt.Sprintf("the public resolvers know no name servers for %s, so the CA can't see it: is the zone delegated to your name servers? (%v)", zone, err)
|
||||
return false
|
||||
}
|
||||
type server struct {
|
||||
host string
|
||||
addrs []string
|
||||
}
|
||||
var servers []server
|
||||
for _, h := range ns {
|
||||
addrs, q := m.lookupAddrs(ctx, h)
|
||||
st.Queries = append(st.Queries, q...)
|
||||
if len(addrs) == 0 {
|
||||
st.Detail = fmt.Sprintf("name server %s has no address the public resolvers know", strings.TrimSuffix(h, "."))
|
||||
return false
|
||||
}
|
||||
servers = append(servers, server{h, addrs})
|
||||
}
|
||||
// A server passes when any of its addresses serves the value (this
|
||||
// machine may not reach its IPv6 address, for one).
|
||||
deadline := time.Now().Add(propagationWait)
|
||||
missing := servers
|
||||
for {
|
||||
var still []server
|
||||
for _, s := range missing {
|
||||
found := false
|
||||
for _, a := range s.addrs {
|
||||
ok, q := m.hasTXT(ctx, net.JoinHostPort(a, nsPort), name, value, false)
|
||||
note(st, q)
|
||||
if ok {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
still = append(still, s)
|
||||
}
|
||||
}
|
||||
missing = still
|
||||
if len(missing) == 0 {
|
||||
st.Status, st.Detail = "ok", fmt.Sprintf("all %d name servers of %s serve it", len(servers), zone)
|
||||
return true
|
||||
}
|
||||
if time.Now().After(deadline) || ctx.Err() != nil {
|
||||
var names []string
|
||||
for _, s := range missing {
|
||||
names = append(names, strings.TrimSuffix(s.host, ".")+" ("+strings.Join(s.addrs, ", ")+")")
|
||||
}
|
||||
st.Detail = fmt.Sprintf("after %s, %s still don't serve it: a secondary that doesn't get the zone's updates (check NOTIFY and transfers), or an address that doesn't reach your name server", propagationWait, strings.Join(names, ", "))
|
||||
return false
|
||||
}
|
||||
sleep(ctx, pollEvery)
|
||||
}
|
||||
}
|
||||
|
||||
// checkResolvers asks the public resolvers until each returns the value.
|
||||
// When the name servers already serve it, a resolver that doesn't is only
|
||||
// a warning: it may be holding an earlier answer.
|
||||
func (m *Manager) checkResolvers(ctx context.Context, c *Check, name, value string, soft bool) {
|
||||
st := add(c, "Public resolvers", "fail", "")
|
||||
if len(m.cfg.ACME.Resolvers) == 0 {
|
||||
st.Status, st.Detail = "skip", "no tls.acme.resolvers"
|
||||
return
|
||||
}
|
||||
deadline := time.Now().Add(propagationWait)
|
||||
missing := append([]string(nil), m.cfg.ACME.Resolvers...)
|
||||
for {
|
||||
var still []string
|
||||
for _, r := range missing {
|
||||
ok, q := m.hasTXT(ctx, r, name, value, true)
|
||||
note(st, q)
|
||||
if !ok {
|
||||
still = append(still, r)
|
||||
}
|
||||
}
|
||||
missing = still
|
||||
if len(missing) == 0 {
|
||||
st.Status, st.Detail = "ok", "every resolver sees it"
|
||||
return
|
||||
}
|
||||
if time.Now().After(deadline) || ctx.Err() != nil {
|
||||
st.Detail = fmt.Sprintf("after %s, %s don't see it", propagationWait, strings.Join(missing, ", "))
|
||||
if soft {
|
||||
st.Status = "warn"
|
||||
st.Detail += "; the name servers do, so they are probably holding an earlier answer"
|
||||
}
|
||||
return
|
||||
}
|
||||
sleep(ctx, pollEvery)
|
||||
}
|
||||
}
|
||||
|
||||
func sleep(ctx context.Context, d time.Duration) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case <-time.After(d):
|
||||
}
|
||||
}
|
||||
|
||||
// exchange asks server (HOST:PORT) one question.
|
||||
func exchange(ctx context.Context, server, name string, qtype uint16, recurse bool) (*dns.Msg, string, error) {
|
||||
if _, _, err := net.SplitHostPort(server); err != nil {
|
||||
server = net.JoinHostPort(server, "53")
|
||||
}
|
||||
m := new(dns.Msg)
|
||||
m.SetQuestion(dns.Fqdn(name), qtype)
|
||||
m.RecursionDesired = recurse
|
||||
host, port, _ := net.SplitHostPort(server)
|
||||
q := fmt.Sprintf("dig @%s -p %s %s %s", host, port, dns.TypeToString[qtype], strings.TrimSuffix(name, "."))
|
||||
if !recurse {
|
||||
q += " +norec"
|
||||
}
|
||||
c, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
cl := &dns.Client{Timeout: 5 * time.Second}
|
||||
r, _, err := cl.ExchangeContext(c, m, server)
|
||||
if err == nil && r.Truncated {
|
||||
cl.Net = "tcp"
|
||||
r, _, err = cl.ExchangeContext(c, m, server)
|
||||
}
|
||||
return r, q, err
|
||||
}
|
||||
|
||||
func (m *Manager) hasTXT(ctx context.Context, server, name, value string, recurse bool) (bool, string) {
|
||||
r, q, err := exchange(ctx, server, name, dns.TypeTXT, recurse)
|
||||
if err != nil {
|
||||
return false, q + " → " + err.Error()
|
||||
}
|
||||
for _, rr := range r.Answer {
|
||||
if t, ok := rr.(*dns.TXT); ok && strings.Join(t.Txt, "") == value {
|
||||
return true, q + " → found"
|
||||
}
|
||||
}
|
||||
return false, q + " → " + dns.RcodeToString[r.Rcode] + ", not there (yet)"
|
||||
}
|
||||
|
||||
// cname returns the target of a CNAME at name ("" when there is none).
|
||||
func (m *Manager) cname(ctx context.Context, name string) string {
|
||||
for _, res := range m.cfg.ACME.Resolvers {
|
||||
r, _, err := exchange(ctx, res, name, dns.TypeCNAME, true)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, rr := range r.Answer {
|
||||
if c, ok := rr.(*dns.CNAME); ok {
|
||||
return strings.ToLower(c.Target)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// lookupNS asks the resolvers for a zone's name servers.
|
||||
func (m *Manager) lookupNS(ctx context.Context, zone string) ([]string, []string, error) {
|
||||
var queries []string
|
||||
var last error = errors.New("no resolvers")
|
||||
for _, res := range m.cfg.ACME.Resolvers {
|
||||
r, q, err := exchange(ctx, res, zone, dns.TypeNS, true)
|
||||
queries = append(queries, q)
|
||||
if err != nil {
|
||||
last = err
|
||||
continue
|
||||
}
|
||||
var out []string
|
||||
for _, rr := range r.Answer {
|
||||
if ns, ok := rr.(*dns.NS); ok {
|
||||
out = append(out, strings.ToLower(ns.Ns))
|
||||
}
|
||||
}
|
||||
if len(out) > 0 {
|
||||
sort.Strings(out)
|
||||
return out, queries, nil
|
||||
}
|
||||
last = fmt.Errorf("%s answered %s", res, dns.RcodeToString[r.Rcode])
|
||||
}
|
||||
return nil, queries, last
|
||||
}
|
||||
|
||||
// lookupAddrs asks the resolvers for a host's addresses.
|
||||
func (m *Manager) lookupAddrs(ctx context.Context, host string) ([]string, []string) {
|
||||
var queries []string
|
||||
for _, res := range m.cfg.ACME.Resolvers {
|
||||
var out []string
|
||||
for _, t := range []uint16{dns.TypeA, dns.TypeAAAA} {
|
||||
r, q, err := exchange(ctx, res, host, t, true)
|
||||
queries = append(queries, q)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, rr := range r.Answer {
|
||||
switch a := rr.(type) {
|
||||
case *dns.A:
|
||||
out = append(out, a.A.String())
|
||||
case *dns.AAAA:
|
||||
out = append(out, a.AAAA.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(out) > 0 {
|
||||
return out, queries
|
||||
}
|
||||
}
|
||||
return nil, queries
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
package certs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
|
||||
"housecarl/internal/config"
|
||||
"housecarl/internal/jarl"
|
||||
)
|
||||
|
||||
// fakeJarl is Jarl's API as far as Housecarl uses it, and the DNS server
|
||||
// that serves what it writes (both the "public resolver" and the zone's
|
||||
// name server).
|
||||
type fakeJarl struct {
|
||||
mu sync.Mutex
|
||||
txt map[string][]string // name -> values
|
||||
canWrite bool
|
||||
token string
|
||||
served bool // the DNS server serves what is written
|
||||
zones []jarl.Zone
|
||||
writes int
|
||||
dryRuns int
|
||||
nsHasIPv6 bool
|
||||
}
|
||||
|
||||
func (f *fakeJarl) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "Bearer "+f.token {
|
||||
w.WriteHeader(401)
|
||||
w.Write([]byte(`{"error":{"code":"unauthenticated","message":"invalid API token: unknown token"}}`))
|
||||
return
|
||||
}
|
||||
switch r.URL.Path {
|
||||
case "/api/v1/auth/whoami":
|
||||
w.Write([]byte(`{"user":{"username":"housecarl"},"build":{"label":"v0.6.1"}}`))
|
||||
case "/api/v1/zones":
|
||||
json.NewEncoder(w).Encode(f.zones)
|
||||
case "/api/v1/apply":
|
||||
var b struct {
|
||||
Ops []struct {
|
||||
Op string `json:"op"`
|
||||
Data map[string]any `json:"data"`
|
||||
} `json:"ops"`
|
||||
DryRun bool `json:"dry_run"`
|
||||
}
|
||||
json.NewDecoder(r.Body).Decode(&b)
|
||||
if !f.canWrite {
|
||||
w.WriteHeader(403)
|
||||
w.Write([]byte(`{"error":{"code":"forbidden","message":"permission denied: this needs records:write"}}`))
|
||||
return
|
||||
}
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if b.DryRun {
|
||||
f.dryRuns++
|
||||
w.Write([]byte(`{}`))
|
||||
return
|
||||
}
|
||||
f.writes++
|
||||
for _, op := range b.Ops {
|
||||
name, v := op.Data["name"].(string), op.Data["data"].(string)
|
||||
var keep []string
|
||||
for _, x := range f.txt[name] {
|
||||
if x != v {
|
||||
keep = append(keep, x)
|
||||
}
|
||||
}
|
||||
if op.Op == "record.create" {
|
||||
keep = append(keep, v)
|
||||
}
|
||||
f.txt[name] = keep
|
||||
}
|
||||
w.Write([]byte(`{}`))
|
||||
default:
|
||||
w.WriteHeader(404)
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeJarl) dns(w dns.ResponseWriter, r *dns.Msg) {
|
||||
m := new(dns.Msg)
|
||||
m.SetReply(r)
|
||||
q := r.Question[0]
|
||||
hdr := dns.RR_Header{Name: q.Name, Class: dns.ClassINET, Ttl: 60}
|
||||
switch q.Qtype {
|
||||
case dns.TypeNS:
|
||||
if q.Name == "adbraun.test." {
|
||||
hdr.Rrtype = dns.TypeNS
|
||||
m.Answer = append(m.Answer, &dns.NS{Hdr: hdr, Ns: "ns1.adbraun.test."})
|
||||
} else {
|
||||
m.Rcode = dns.RcodeNameError
|
||||
}
|
||||
case dns.TypeA:
|
||||
hdr.Rrtype = dns.TypeA
|
||||
m.Answer = append(m.Answer, &dns.A{Hdr: hdr, A: net.ParseIP("127.0.0.1")})
|
||||
case dns.TypeAAAA:
|
||||
// An IPv6 address nothing answers on (the check must not fail
|
||||
// because of it).
|
||||
if f.nsHasIPv6 {
|
||||
hdr.Rrtype = dns.TypeAAAA
|
||||
m.Answer = append(m.Answer, &dns.AAAA{Hdr: hdr, AAAA: net.ParseIP("100::1")})
|
||||
}
|
||||
case dns.TypeCNAME:
|
||||
if q.Name == "_acme-challenge.dhcp.other.test." {
|
||||
hdr.Rrtype = dns.TypeCNAME
|
||||
m.Answer = append(m.Answer, &dns.CNAME{Hdr: hdr, Target: "dhcp.acme.adbraun.test."})
|
||||
}
|
||||
case dns.TypeTXT:
|
||||
f.mu.Lock()
|
||||
if f.served {
|
||||
for _, v := range f.txt[q.Name] {
|
||||
hdr.Rrtype = dns.TypeTXT
|
||||
m.Answer = append(m.Answer, &dns.TXT{Hdr: hdr, Txt: []string{v}})
|
||||
}
|
||||
}
|
||||
f.mu.Unlock()
|
||||
}
|
||||
w.WriteMsg(m)
|
||||
}
|
||||
|
||||
func setup(t *testing.T, f *fakeJarl, edit func(a *config.ACME)) (*Manager, string) {
|
||||
t.Helper()
|
||||
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv := &dns.Server{PacketConn: pc, Handler: dns.HandlerFunc(f.dns)}
|
||||
go srv.ActivateAndServe()
|
||||
t.Cleanup(func() { srv.Shutdown() })
|
||||
hs := httptest.NewServer(f)
|
||||
t.Cleanup(hs.Close)
|
||||
addr := pc.LocalAddr().String()
|
||||
_, port, _ := net.SplitHostPort(addr)
|
||||
oldPort, oldWait, oldPoll := nsPort, propagationWait, pollEvery
|
||||
nsPort, propagationWait, pollEvery = port, 2*time.Second, 100*time.Millisecond
|
||||
t.Cleanup(func() { nsPort, propagationWait, pollEvery = oldPort, oldWait, oldPoll })
|
||||
a := config.ACME{Domain: "dhcp.adbraun.test", Resolvers: []string{addr}, CheckPropagation: true}
|
||||
if edit != nil {
|
||||
edit(&a)
|
||||
}
|
||||
jc, _ := jarl.New(hs.URL, "housecarl_good", "", false)
|
||||
m := &Manager{cfg: Config{ACME: a}, jarl: jc, now: time.Now}
|
||||
return m, addr
|
||||
}
|
||||
|
||||
func steps(c *Check) string {
|
||||
var out []string
|
||||
for _, s := range c.Steps {
|
||||
out = append(out, s.Name+"="+s.Status)
|
||||
}
|
||||
return strings.Join(out, " ")
|
||||
}
|
||||
|
||||
func newFake() *fakeJarl {
|
||||
return &fakeJarl{txt: map[string][]string{}, canWrite: true, token: "housecarl_good", served: true,
|
||||
zones: []jarl.Zone{{Name: "adbraun.test.", View: "internal", LinkGroup: 7}, {Name: "adbraun.test.", View: "external", LinkGroup: 7}, {Name: "other.test.", View: "internal"}}}
|
||||
}
|
||||
|
||||
func TestPreflightOK(t *testing.T) {
|
||||
f := newFake()
|
||||
f.nsHasIPv6 = true
|
||||
m, _ := setup(t, f, nil)
|
||||
c := m.Preflight(context.Background())
|
||||
want := "Jarl=ok Zone=ok Permission=ok Write=ok Name servers=ok Public resolvers=ok Clean up=ok"
|
||||
if !c.OK || steps(c) != want {
|
||||
t.Fatalf("got %s\n%+v", steps(c), c.Steps)
|
||||
}
|
||||
if !strings.Contains(c.Steps[1].Detail, "views internal, external") {
|
||||
t.Errorf("zone step: %s", c.Steps[1].Detail)
|
||||
}
|
||||
// Linked zones are written once; the test record is gone afterwards.
|
||||
if f.dryRuns != 1 || f.writes != 2 || len(f.txt["_acme-challenge.dhcp.adbraun.test."]) != 0 {
|
||||
t.Errorf("dry runs %d writes %d records %v", f.dryRuns, f.writes, f.txt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightFailures(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
edit func(f *fakeJarl, a *config.ACME)
|
||||
want string
|
||||
text string
|
||||
}{
|
||||
{"bad token", func(f *fakeJarl, a *config.ACME) { f.token = "other" }, "Jarl=fail", "check jarl.token"},
|
||||
{"no zone", func(f *fakeJarl, a *config.ACME) { a.Domain = "dhcp.example.net" }, "Jarl=ok Zone=fail", "no primary zone the token can see"},
|
||||
{"no permission", func(f *fakeJarl, a *config.ACME) { f.canWrite = false }, "Jarl=ok Zone=ok Permission=fail",
|
||||
"records:write zones=adbraun.test names=_acme-challenge.dhcp.adbraun.test types=TXT"},
|
||||
{"not served", func(f *fakeJarl, a *config.ACME) { f.served = false },
|
||||
"Jarl=ok Zone=ok Permission=ok Write=ok Name servers=fail Public resolvers=fail Clean up=ok", "still don't serve it"},
|
||||
{"not delegated", func(f *fakeJarl, a *config.ACME) { f.zones[0].Name, f.zones[1].Name = "test.", "test." },
|
||||
"Jarl=ok Zone=ok Permission=ok Write=ok Name servers=fail Public resolvers=ok Clean up=ok", "know no name servers for test"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
f := newFake()
|
||||
var am *config.ACME
|
||||
m, _ := setup(t, f, func(a *config.ACME) { am = a })
|
||||
tc.edit(f, am)
|
||||
m.cfg.ACME = *am
|
||||
c := m.Preflight(context.Background())
|
||||
if c.OK || steps(c) != tc.want {
|
||||
t.Fatalf("got %s, want %s\n%+v", steps(c), tc.want, c.Steps)
|
||||
}
|
||||
all := ""
|
||||
for _, s := range c.Steps {
|
||||
all += s.Detail + "\n"
|
||||
}
|
||||
if !strings.Contains(all, tc.text) {
|
||||
t.Errorf("details lack %q:\n%s", tc.text, all)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolversOnlyWarnWhenNameServersServe(t *testing.T) {
|
||||
f := newFake()
|
||||
m, _ := setup(t, f, nil)
|
||||
// The "public resolver" is another server that never sees the record.
|
||||
pc, _ := net.ListenPacket("udp", "127.0.0.1:0")
|
||||
srv := &dns.Server{PacketConn: pc, Handler: dns.HandlerFunc(func(w dns.ResponseWriter, r *dns.Msg) {
|
||||
mm := new(dns.Msg)
|
||||
mm.SetReply(r)
|
||||
if r.Question[0].Qtype == dns.TypeTXT {
|
||||
mm.Rcode = dns.RcodeNameError
|
||||
} else {
|
||||
f.dns(w, r)
|
||||
return
|
||||
}
|
||||
w.WriteMsg(mm)
|
||||
})}
|
||||
go srv.ActivateAndServe()
|
||||
defer srv.Shutdown()
|
||||
m.cfg.ACME.Resolvers = []string{pc.LocalAddr().String()}
|
||||
c := m.Preflight(context.Background())
|
||||
if !c.OK || !strings.Contains(steps(c), "Public resolvers=warn") {
|
||||
t.Fatalf("got %s %+v", steps(c), c.Steps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightFollowsCNAME(t *testing.T) {
|
||||
f := newFake()
|
||||
m, _ := setup(t, f, func(a *config.ACME) { a.Domain = "dhcp.other.test" })
|
||||
c := m.Preflight(context.Background())
|
||||
if !c.OK || !strings.Contains(c.Steps[2].Detail, "dhcp.acme.adbraun.test") {
|
||||
t.Fatalf("got %s %+v", steps(c), c.Steps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestZonesFor(t *testing.T) {
|
||||
zs := []jarl.Zone{{Name: "lol.", View: "a"}, {Name: "adbraun.lol.", View: "internal", LinkGroup: 3}, {Name: "adbraun.lol.", View: "external", LinkGroup: 3},
|
||||
{Name: "adbraun.lol.", View: "guest"}, {Name: "notadbraun.lol.", View: "internal"}}
|
||||
got := jarl.ZonesFor("_acme-challenge.dhcp.adbraun.lol.", zs, "")
|
||||
if len(got) != 2 || got[0].View != "internal" || got[1].View != "guest" {
|
||||
t.Errorf("got %+v", got)
|
||||
}
|
||||
if n := jarl.ChallengeName("*.Dhcp.adbraun.lol."); n != "_acme-challenge.dhcp.adbraun.lol." {
|
||||
t.Errorf("challenge name %q", n)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,484 @@
|
||||
// Package certs obtains and renews the web UI's certificate from an ACME CA
|
||||
// (Let's Encrypt), with Jarl publishing the DNS-01 challenge
|
||||
// (docs/certificates.md). Before every order a preflight check proves the
|
||||
// challenge can be published and seen, so a broken setup never spends the
|
||||
// CA's rate limits.
|
||||
package certs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-acme/lego/v4/certificate"
|
||||
"github.com/go-acme/lego/v4/challenge/dns01"
|
||||
"github.com/go-acme/lego/v4/lego"
|
||||
legolog "github.com/go-acme/lego/v4/log"
|
||||
"github.com/go-acme/lego/v4/registration"
|
||||
|
||||
"housecarl/internal/api"
|
||||
"housecarl/internal/config"
|
||||
"housecarl/internal/jarl"
|
||||
)
|
||||
|
||||
// Store keeps the account and certificate (the database's meta table).
|
||||
type Store interface {
|
||||
GetMeta(ctx context.Context, key string) (string, error)
|
||||
SetMeta(ctx context.Context, key, value string) error
|
||||
}
|
||||
|
||||
const (
|
||||
metaAccount = "acme.account"
|
||||
metaCert = "acme.certificate"
|
||||
)
|
||||
|
||||
// Config configures the manager.
|
||||
type Config struct {
|
||||
ACME config.ACME
|
||||
Version string
|
||||
}
|
||||
|
||||
// Status is api.CertStatus.
|
||||
type Status = api.CertStatus
|
||||
|
||||
// Manager keeps the web UI's certificate.
|
||||
type Manager struct {
|
||||
cfg Config
|
||||
jarl *jarl.Client
|
||||
store Store
|
||||
log *slog.Logger
|
||||
now func() time.Time
|
||||
|
||||
mu sync.Mutex
|
||||
cert *tls.Certificate
|
||||
leaf *x509.Certificate
|
||||
issuedBy string
|
||||
working bool
|
||||
lastErr string
|
||||
lastAttempt time.Time
|
||||
nextAttempt time.Time
|
||||
lastCheck *Check
|
||||
failures int
|
||||
force bool // renew now, even if not due (Kick)
|
||||
|
||||
lego sync.Mutex // one ACME order at a time
|
||||
kick chan struct{}
|
||||
}
|
||||
|
||||
// New makes a manager and loads the stored certificate.
|
||||
func New(ctx context.Context, cfg Config, j *jarl.Client, st Store, log *slog.Logger) *Manager {
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
// lego logs each step of an order; send it to the journal with the rest.
|
||||
legolog.Logger = slog.NewLogLogger(log.With("component", "acme").Handler(), slog.LevelInfo)
|
||||
m := &Manager{cfg: cfg, jarl: j, store: st, log: log, now: time.Now, kick: make(chan struct{}, 1)}
|
||||
m.load(ctx)
|
||||
return m
|
||||
}
|
||||
|
||||
type stored struct {
|
||||
Domain string `json:"domain"`
|
||||
Directory string `json:"directory"`
|
||||
CertPEM string `json:"cert_pem"`
|
||||
KeyPEM string `json:"key_pem"`
|
||||
Obtained time.Time `json:"obtained"`
|
||||
}
|
||||
|
||||
func (m *Manager) load(ctx context.Context) {
|
||||
raw, err := m.store.GetMeta(ctx, metaCert)
|
||||
if err != nil || raw == "" {
|
||||
return
|
||||
}
|
||||
var s stored
|
||||
if json.Unmarshal([]byte(raw), &s) != nil {
|
||||
return
|
||||
}
|
||||
if err := m.use(s); err != nil {
|
||||
m.log.Warn("the stored certificate can't be used; getting a new one", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// use makes a stored certificate the one served.
|
||||
func (m *Manager) use(s stored) error {
|
||||
c, err := tls.X509KeyPair([]byte(s.CertPEM), []byte(s.KeyPEM))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
leaf, err := x509.ParseCertificate(c.Certificate[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.Leaf = leaf
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
// A certificate for another name isn't served.
|
||||
if !strings.EqualFold(s.Domain, m.cfg.ACME.Domain) {
|
||||
m.cert, m.leaf = nil, nil
|
||||
return fmt.Errorf("it is for %s, not %s", s.Domain, m.cfg.ACME.Domain)
|
||||
}
|
||||
m.cert, m.leaf, m.issuedBy = &c, leaf, s.Directory
|
||||
return nil
|
||||
}
|
||||
|
||||
// Certificate is the certificate to serve (nil when there is none yet).
|
||||
func (m *Manager) Certificate() *tls.Certificate {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.cert == nil || m.now().After(m.leaf.NotAfter) {
|
||||
return nil
|
||||
}
|
||||
return m.cert
|
||||
}
|
||||
|
||||
// Status describes the certificate.
|
||||
func (m *Manager) Status() Status {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
st := Status{Domain: m.cfg.ACME.Domain, Directory: m.cfg.ACME.DirectoryURL(), Staging: m.cfg.ACME.Staging(), State: "none",
|
||||
IssuedBy: m.issuedBy, LastError: m.lastErr, Check: m.lastCheck}
|
||||
if m.leaf != nil {
|
||||
nb, na := m.leaf.NotBefore, m.leaf.NotAfter
|
||||
ra := m.renewAt()
|
||||
st.NotBefore, st.NotAfter, st.RenewAt, st.Issuer, st.State = &nb, &na, &ra, m.leaf.Issuer.CommonName, "ok"
|
||||
}
|
||||
if m.lastErr != "" {
|
||||
st.State = "error"
|
||||
}
|
||||
if m.working {
|
||||
st.State = "working"
|
||||
}
|
||||
if !m.lastAttempt.IsZero() {
|
||||
t := m.lastAttempt
|
||||
st.LastAttempt = &t
|
||||
}
|
||||
if !m.nextAttempt.IsZero() {
|
||||
t := m.nextAttempt
|
||||
st.NextAttempt = &t
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
// renewAt is when the certificate is renewed: a third of its lifetime
|
||||
// before it ends (30 days for Let's Encrypt's 90), m.mu held.
|
||||
func (m *Manager) renewAt() time.Time {
|
||||
life := m.leaf.NotAfter.Sub(m.leaf.NotBefore)
|
||||
return m.leaf.NotAfter.Add(-life / 3).UTC().Truncate(time.Second)
|
||||
}
|
||||
|
||||
// due reports whether a new certificate is needed: none yet, another
|
||||
// directory configured, or renewal time.
|
||||
func (m *Manager) due() bool {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.force || m.leaf == nil || m.issuedBy != m.cfg.ACME.DirectoryURL() || !m.now().Before(m.renewAt())
|
||||
}
|
||||
|
||||
// Run keeps the certificate: it gets one when needed, checks twice a day
|
||||
// whether it is due, and retries failures with a growing wait.
|
||||
func (m *Manager) Run(ctx context.Context) {
|
||||
timer := time.NewTimer(5 * time.Second)
|
||||
defer timer.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-m.kick:
|
||||
case <-timer.C:
|
||||
}
|
||||
wait := 12 * time.Hour
|
||||
if m.due() {
|
||||
if err := m.Renew(ctx); err != nil {
|
||||
m.mu.Lock()
|
||||
m.failures++
|
||||
// 1h, 3h, 6h, then 12h.
|
||||
waits := []time.Duration{time.Hour, 3 * time.Hour, 6 * time.Hour, 12 * time.Hour}
|
||||
wait = waits[min(m.failures, len(waits))-1]
|
||||
m.mu.Unlock()
|
||||
m.log.Warn("getting the web UI's certificate failed; trying again later", "domain", m.cfg.ACME.Domain, "err", err, "retry_in", wait)
|
||||
}
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.nextAttempt = m.now().Add(wait).UTC()
|
||||
m.mu.Unlock()
|
||||
if !timer.Stop() {
|
||||
select {
|
||||
case <-timer.C:
|
||||
default:
|
||||
}
|
||||
}
|
||||
timer.Reset(wait)
|
||||
}
|
||||
}
|
||||
|
||||
// Kick asks Run to renew now, even if the certificate isn't due.
|
||||
func (m *Manager) Kick() {
|
||||
m.mu.Lock()
|
||||
m.force, m.failures = true, 0
|
||||
m.mu.Unlock()
|
||||
select {
|
||||
case m.kick <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// ErrBusy is returned while another order runs.
|
||||
var ErrBusy = errors.New("a certificate order is already running")
|
||||
|
||||
// Renew gets a new certificate now: the preflight check, then the order.
|
||||
func (m *Manager) Renew(ctx context.Context) error {
|
||||
if !m.lego.TryLock() {
|
||||
return ErrBusy
|
||||
}
|
||||
defer m.lego.Unlock()
|
||||
m.mu.Lock()
|
||||
m.working, m.lastAttempt = true, m.now().UTC()
|
||||
m.mu.Unlock()
|
||||
err := m.renew(ctx)
|
||||
m.mu.Lock()
|
||||
m.working, m.force = false, false
|
||||
if err != nil {
|
||||
m.lastErr = err.Error()
|
||||
} else {
|
||||
m.lastErr, m.failures = "", 0
|
||||
}
|
||||
m.mu.Unlock()
|
||||
return err
|
||||
}
|
||||
|
||||
func (m *Manager) renew(ctx context.Context) error {
|
||||
c := m.Preflight(ctx)
|
||||
if !c.OK {
|
||||
for _, s := range c.Steps {
|
||||
if s.Status == "fail" {
|
||||
return fmt.Errorf("the check before asking the CA failed at %s: %s", strings.ToLower(s.Name), s.Detail)
|
||||
}
|
||||
}
|
||||
}
|
||||
m.log.Info("the check passed; asking the CA for a certificate", "domain", m.cfg.ACME.Domain, "directory", m.cfg.ACME.DirectoryURL())
|
||||
res, err := m.obtain(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s := stored{Domain: m.cfg.ACME.Domain, Directory: m.cfg.ACME.DirectoryURL(), CertPEM: string(res.Certificate), KeyPEM: string(res.PrivateKey), Obtained: m.now().UTC()}
|
||||
if err := m.use(s); err != nil {
|
||||
return fmt.Errorf("the CA's certificate can't be used: %w", err)
|
||||
}
|
||||
b, _ := json.Marshal(s)
|
||||
if err := m.store.SetMeta(ctx, metaCert, string(b)); err != nil {
|
||||
return err
|
||||
}
|
||||
m.mu.Lock()
|
||||
na := m.leaf.NotAfter
|
||||
m.mu.Unlock()
|
||||
m.log.Info("got a new certificate for the web UI", "domain", s.Domain, "until", na.Format(time.RFC3339), "staging", m.cfg.ACME.Staging())
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- ACME
|
||||
|
||||
type account struct {
|
||||
Directory string `json:"directory"`
|
||||
Email string `json:"email,omitempty"`
|
||||
URI string `json:"uri,omitempty"`
|
||||
KeyPEM string `json:"key_pem"`
|
||||
}
|
||||
|
||||
type legoUser struct {
|
||||
email string
|
||||
reg *registration.Resource
|
||||
key crypto.PrivateKey
|
||||
}
|
||||
|
||||
func (u *legoUser) GetEmail() string { return u.email }
|
||||
func (u *legoUser) GetRegistration() *registration.Resource { return u.reg }
|
||||
func (u *legoUser) GetPrivateKey() crypto.PrivateKey { return u.key }
|
||||
|
||||
func (m *Manager) httpClient() (*http.Client, error) {
|
||||
tr := http.DefaultTransport.(*http.Transport).Clone()
|
||||
tr.TLSClientConfig = &tls.Config{MinVersion: tls.VersionTLS12}
|
||||
if f := m.cfg.ACME.CABundle; f != "" {
|
||||
pemData, err := os.ReadFile(f)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("tls.acme.ca_bundle: %w", err)
|
||||
}
|
||||
pool, err := x509.SystemCertPool()
|
||||
if err != nil || pool == nil {
|
||||
pool = x509.NewCertPool()
|
||||
}
|
||||
if !pool.AppendCertsFromPEM(pemData) {
|
||||
return nil, fmt.Errorf("tls.acme.ca_bundle: no certificates in %s", f)
|
||||
}
|
||||
tr.TLSClientConfig.RootCAs = pool
|
||||
}
|
||||
return &http.Client{Timeout: 2 * time.Minute, Transport: tr}, nil
|
||||
}
|
||||
|
||||
// client returns a lego client for the account, registering it if needed.
|
||||
func (m *Manager) client(ctx context.Context) (*lego.Client, error) {
|
||||
dir := m.cfg.ACME.DirectoryURL()
|
||||
hc, err := m.httpClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
user := &legoUser{email: m.cfg.ACME.Email}
|
||||
var a account
|
||||
if raw, _ := m.store.GetMeta(ctx, metaAccount); raw != "" && json.Unmarshal([]byte(raw), &a) == nil && a.Directory == dir && a.KeyPEM != "" {
|
||||
b, _ := pem.Decode([]byte(a.KeyPEM))
|
||||
if b == nil {
|
||||
return nil, errors.New("the stored ACME account key isn't PEM")
|
||||
}
|
||||
if user.key, err = x509.ParsePKCS8PrivateKey(b.Bytes); err != nil {
|
||||
return nil, fmt.Errorf("the stored ACME account key: %w", err)
|
||||
}
|
||||
if a.URI != "" {
|
||||
user.reg = ®istration.Resource{URI: a.URI}
|
||||
}
|
||||
} else {
|
||||
if user.key, err = ecdsa.GenerateKey(elliptic.P256(), rand.Reader); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
cfg := lego.NewConfig(user)
|
||||
cfg.CADirURL, cfg.HTTPClient, cfg.UserAgent = dir, hc, "housecarl/"+m.cfg.Version
|
||||
cfg.Certificate.Timeout = 90 * time.Second
|
||||
c, err := lego.NewClient(cfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the ACME server %s: %w", dir, err)
|
||||
}
|
||||
if user.reg != nil {
|
||||
return c, nil
|
||||
}
|
||||
if !m.cfg.ACME.AgreeTOS {
|
||||
tos := c.GetToSURL()
|
||||
if tos == "" {
|
||||
tos = "on the CA's website"
|
||||
}
|
||||
return nil, fmt.Errorf("Housecarl registers an account with %s only after you accept its terms of service (%s): set tls.acme.agree_tos: true in housecarl.yaml and restart housecarld", dir, tos)
|
||||
}
|
||||
reg, err := c.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("registering the ACME account: %w", err)
|
||||
}
|
||||
user.reg = reg
|
||||
der, err := x509.MarshalPKCS8PrivateKey(user.key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b, _ := json.Marshal(account{Directory: dir, Email: user.email, URI: reg.URI,
|
||||
KeyPEM: string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}))})
|
||||
if err := m.store.SetMeta(ctx, metaAccount, string(b)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.log.Info("ACME account registered", "directory", dir, "account", reg.URI)
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// provider publishes the challenge through Jarl.
|
||||
type provider struct {
|
||||
m *Manager
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
func (p *provider) set(domain, keyAuth string, set bool) error {
|
||||
info := dns01.GetChallengeInfo(domain, keyAuth)
|
||||
ctx := p.ctx
|
||||
if !set {
|
||||
ctx = context.WithoutCancel(ctx)
|
||||
}
|
||||
all, err := p.m.jarl.Zones(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
zones := jarl.ZonesFor(info.EffectiveFQDN, all, "")
|
||||
if len(zones) == 0 {
|
||||
return fmt.Errorf("no primary zone in Jarl holds %s", strings.TrimSuffix(info.EffectiveFQDN, "."))
|
||||
}
|
||||
return p.m.jarl.SetTXT(ctx, info.EffectiveFQDN, info.Value, zones, set, false, "Housecarl's certificate")
|
||||
}
|
||||
|
||||
func (p *provider) Present(domain, token, keyAuth string) error { return p.set(domain, keyAuth, true) }
|
||||
func (p *provider) CleanUp(domain, token, keyAuth string) error { return p.set(domain, keyAuth, false) }
|
||||
|
||||
// Timeout is how long lego waits for the record to reach the name servers.
|
||||
func (p *provider) Timeout() (timeout, interval time.Duration) {
|
||||
return 5 * time.Minute, 5 * time.Second
|
||||
}
|
||||
|
||||
func (m *Manager) obtain(ctx context.Context) (*certificate.Resource, error) {
|
||||
c, err := m.client(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
opts := []dns01.ChallengeOption{dns01.AddDNSTimeout(10 * time.Second)}
|
||||
if len(m.cfg.ACME.Resolvers) > 0 {
|
||||
opts = append(opts, dns01.AddRecursiveNameservers(m.cfg.ACME.Resolvers))
|
||||
}
|
||||
if !m.cfg.ACME.CheckPropagation {
|
||||
opts = append(opts, dns01.DisableAuthoritativeNssPropagationRequirement())
|
||||
}
|
||||
if err := c.Challenge.SetDNS01Provider(&provider{m: m, ctx: ctx}, opts...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res, err := c.Certificate.Obtain(certificate.ObtainRequest{Domains: []string{m.cfg.ACME.Domain}, Bundle: true})
|
||||
if err != nil {
|
||||
return nil, cleanLegoError(err)
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// cleanLegoError shortens lego's errors (as Jarl does): "[name] acme:
|
||||
// error: 403 :: urn:ietf:params:acme:error:unauthorized :: MSG" becomes
|
||||
// "name: the CA refused (unauthorized): MSG".
|
||||
func cleanLegoError(err error) error {
|
||||
msg := strings.TrimSpace(strings.Replace(err.Error(), "error: one or more domains had a problem:", "", 1))
|
||||
var out []string
|
||||
for _, line := range strings.Split(msg, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
domain := ""
|
||||
for strings.HasPrefix(line, "[") {
|
||||
end := strings.Index(line, "] ")
|
||||
if end < 0 {
|
||||
break
|
||||
}
|
||||
if domain == "" {
|
||||
domain = line[1:end]
|
||||
}
|
||||
line = line[end+2:]
|
||||
}
|
||||
for _, p := range []string{"acme: error presenting token: ", "acme: error: ", "acme: "} {
|
||||
line = strings.TrimPrefix(line, p)
|
||||
}
|
||||
if mm := problemRe.FindStringSubmatch(line); mm != nil {
|
||||
line = "the CA refused (" + mm[2] + "): " + mm[3]
|
||||
}
|
||||
if domain != "" {
|
||||
line = domain + ": " + line
|
||||
}
|
||||
out = append(out, line)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return err
|
||||
}
|
||||
return errors.New(strings.Join(out, "; "))
|
||||
}
|
||||
|
||||
var problemRe = regexp.MustCompile(`^(\d+) :: urn:ietf:params:acme:error:(\w+) :: (.*)$`)
|
||||
@@ -0,0 +1,205 @@
|
||||
// Package client is a small Go client for Housecarl's REST API, used by the
|
||||
// housecarl CLI.
|
||||
package client
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"housecarl/internal/api"
|
||||
"housecarl/internal/version"
|
||||
)
|
||||
|
||||
// Client talks to a Housecarl server.
|
||||
type Client struct {
|
||||
BaseURL string
|
||||
Token string
|
||||
HTTP *http.Client
|
||||
// DryRun previews changes instead of making them.
|
||||
DryRun bool
|
||||
// Changeset stages configuration changes in that changeset instead
|
||||
// of applying them (X-Housecarl-Changeset).
|
||||
Changeset int64
|
||||
// BeforeStage, if set, runs once before the first change is staged,
|
||||
// and can stop it (the CLI checks HOUSECARL_CHANGESET's changeset).
|
||||
BeforeStage func() error
|
||||
staged bool
|
||||
}
|
||||
|
||||
// Staged reports whether a request is a configuration change that a
|
||||
// changeset takes: objects and settings, and the changes that can't be
|
||||
// staged (undo, import, takeover), which the server then refuses rather
|
||||
// than apply them. Changesets' own calls and dynamic set entries aren't.
|
||||
func Staged(method, path string) bool {
|
||||
if method == http.MethodGet || strings.Contains(path, "/elements") {
|
||||
return false
|
||||
}
|
||||
for _, p := range []string{"/zones", "/sets", "/services", "/rules", "/forwards", "/snats", "/redirects", "/settings", "/history/", "/import/", "/takeover"} {
|
||||
if strings.HasPrefix(path, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Options configure New.
|
||||
type Options struct {
|
||||
Server string // https://... or unix:///run/housecarl/housecarl.sock
|
||||
Token string
|
||||
Insecure bool // skip TLS certificate checks
|
||||
CACert string // PEM file with an extra CA to trust
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// New makes a client.
|
||||
func New(o Options) (*Client, error) {
|
||||
tr := http.DefaultTransport.(*http.Transport).Clone()
|
||||
base := strings.TrimSuffix(o.Server, "/")
|
||||
if path, ok := SocketPath(o.Server); ok {
|
||||
tr.Proxy = nil
|
||||
tr.DialContext = func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||
return (&net.Dialer{}).DialContext(ctx, "unix", path)
|
||||
}
|
||||
base = "http://housecarld"
|
||||
} else if u, err := url.Parse(o.Server); err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
||||
return nil, fmt.Errorf("invalid server URL %q (use e.g. https://firewall.example.com:8070, or unix:///run/housecarl/housecarl.sock)", o.Server)
|
||||
}
|
||||
tc := &tls.Config{MinVersion: tls.VersionTLS12, InsecureSkipVerify: o.Insecure} //nolint:gosec // the user's choice
|
||||
if o.CACert != "" {
|
||||
pem, err := os.ReadFile(o.CACert)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pool, err := x509.SystemCertPool()
|
||||
if err != nil || pool == nil {
|
||||
pool = x509.NewCertPool()
|
||||
}
|
||||
if !pool.AppendCertsFromPEM(pem) {
|
||||
return nil, fmt.Errorf("no certificates in %s", o.CACert)
|
||||
}
|
||||
tc.RootCAs = pool
|
||||
}
|
||||
tr.TLSClientConfig = tc
|
||||
timeout := o.Timeout
|
||||
if timeout == 0 {
|
||||
timeout = 2 * time.Minute
|
||||
}
|
||||
return &Client{BaseURL: base, Token: o.Token, HTTP: &http.Client{Transport: tr, Timeout: timeout}}, nil
|
||||
}
|
||||
|
||||
// SocketPath returns the path of a unix:// URL.
|
||||
func SocketPath(server string) (string, bool) {
|
||||
path, ok := strings.CutPrefix(server, "unix://")
|
||||
if !ok || !strings.HasPrefix(path, "/") {
|
||||
return "", false
|
||||
}
|
||||
return path, true
|
||||
}
|
||||
|
||||
// Error is an API error.
|
||||
type Error struct {
|
||||
Status int
|
||||
Info api.ErrorInfo
|
||||
}
|
||||
|
||||
func (e *Error) Error() string {
|
||||
if e.Info.Message != "" {
|
||||
return e.Info.Message
|
||||
}
|
||||
return fmt.Sprintf("HTTP %d", e.Status)
|
||||
}
|
||||
|
||||
// Do sends a request: body as JSON, the JSON answer into out (if not nil;
|
||||
// a *string gets the answer as text).
|
||||
// Changes get dry_run=1 when the client's DryRun is set.
|
||||
func (c *Client) Do(ctx context.Context, method, path string, query url.Values, body, out any) error {
|
||||
if c.DryRun && method != http.MethodGet {
|
||||
if query == nil {
|
||||
query = url.Values{}
|
||||
}
|
||||
query.Set("dry_run", "1")
|
||||
}
|
||||
stage := c.Changeset != 0 && Staged(method, path)
|
||||
if stage && !c.staged && c.BeforeStage != nil {
|
||||
if err := c.BeforeStage(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
u := c.BaseURL + api.Prefix + path
|
||||
if len(query) > 0 {
|
||||
u += "?" + query.Encode()
|
||||
}
|
||||
var rd io.Reader
|
||||
if body != nil {
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rd = bytes.NewReader(b)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, u, rd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", "housecarl-cli/"+version.Short())
|
||||
if c.Token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.Token)
|
||||
}
|
||||
if stage {
|
||||
req.Header.Set("X-Housecarl-Changeset", strconv.FormatInt(c.Changeset, 10))
|
||||
c.staged = true
|
||||
}
|
||||
resp, err := c.HTTP.Do(req)
|
||||
if err != nil {
|
||||
if c.BaseURL == "http://housecarld" {
|
||||
return fmt.Errorf("can't reach housecarld on its local socket: %w (is housecarld running? 'systemctl status housecarl')", err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
e := &Error{Status: resp.StatusCode}
|
||||
var eb api.ErrorBody
|
||||
if json.Unmarshal(b, &eb) == nil && eb.Error.Message != "" {
|
||||
e.Info = eb.Error
|
||||
} else {
|
||||
e.Info.Message = fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
|
||||
}
|
||||
return e
|
||||
}
|
||||
if text, ok := out.(*string); ok {
|
||||
*text = string(b)
|
||||
return nil
|
||||
}
|
||||
if out != nil && len(b) > 0 {
|
||||
if err := json.Unmarshal(b, out); err != nil {
|
||||
return fmt.Errorf("reading the answer: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get is Do for GET requests.
|
||||
func (c *Client) Get(ctx context.Context, path string, query url.Values, out any) error {
|
||||
return c.Do(ctx, http.MethodGet, path, query, nil, out)
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
// Package config loads housecarld's configuration file. It only says how
|
||||
// the daemon runs (where it listens, its files, logging); the firewall's
|
||||
// configuration itself is in the database.
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.yaml.in/yaml/v3"
|
||||
)
|
||||
|
||||
// DefaultPath is where housecarld looks for its configuration.
|
||||
const DefaultPath = "/etc/housecarl/housecarl.yaml"
|
||||
|
||||
// Config is the daemon configuration.
|
||||
type Config struct {
|
||||
// Listen are the web UI's and API's addresses.
|
||||
Listen []string `yaml:"listen"`
|
||||
TLS TLS `yaml:"tls"`
|
||||
TrustedProxies []string `yaml:"trusted_proxies"`
|
||||
// Socket is a local unix socket for the API, where root and
|
||||
// housecarld's own user need no token ("" for none).
|
||||
Socket string `yaml:"socket"`
|
||||
Database string `yaml:"database"`
|
||||
Log Log `yaml:"log"`
|
||||
Session Session `yaml:"session"`
|
||||
Backup Backup `yaml:"backup"`
|
||||
// Jarl is the Jarl server Housecarl uses for its web UI's
|
||||
// certificate (docs/certificates.md).
|
||||
Jarl Jarl `yaml:"jarl"`
|
||||
// Ruleset is where the confirmed ruleset is saved for the boot unit
|
||||
// (housecarl-ruleset.service loads it before the network comes up).
|
||||
Ruleset string `yaml:"ruleset"`
|
||||
}
|
||||
|
||||
// TLS is the web UI's certificate: from files, or obtained through ACME
|
||||
// with Jarl publishing the DNS-01 challenge (docs/certificates.md).
|
||||
type TLS struct {
|
||||
CertFile string `yaml:"cert_file"`
|
||||
KeyFile string `yaml:"key_file"`
|
||||
ACME ACME `yaml:"acme"`
|
||||
}
|
||||
|
||||
// ACME obtains the web UI's certificate (docs/certificates.md).
|
||||
type ACME struct {
|
||||
// Domain is the certificate's name ("" turns ACME off).
|
||||
Domain string `yaml:"domain"`
|
||||
Email string `yaml:"email"`
|
||||
// Directory is letsencrypt, letsencrypt-staging (the default, for a
|
||||
// first certificate browsers don't trust) or an ACME directory URL.
|
||||
Directory string `yaml:"directory"`
|
||||
// AgreeTOS says you accept the CA's terms of service; no account is
|
||||
// registered without it.
|
||||
AgreeTOS bool `yaml:"agree_tos"`
|
||||
// Resolvers are public recursive resolvers, to see the challenge as
|
||||
// the CA would.
|
||||
Resolvers []string `yaml:"resolvers"`
|
||||
// CheckPropagation also asks each of the zone's public name servers
|
||||
// directly before the CA is told to look.
|
||||
CheckPropagation bool `yaml:"check_propagation"`
|
||||
// CABundle is a PEM file of extra CAs to trust for the ACME server
|
||||
// (a private CA such as step-ca, or Pebble in tests).
|
||||
CABundle string `yaml:"ca_bundle"`
|
||||
}
|
||||
|
||||
// Directory URLs for the names tls.acme.directory accepts.
|
||||
const (
|
||||
LetsEncrypt = "https://acme-v02.api.letsencrypt.org/directory"
|
||||
LetsEncryptStaging = "https://acme-staging-v02.api.letsencrypt.org/directory"
|
||||
)
|
||||
|
||||
// DirectoryURL is the ACME directory's URL.
|
||||
func (a ACME) DirectoryURL() string {
|
||||
switch a.Directory {
|
||||
case "", "letsencrypt-staging":
|
||||
return LetsEncryptStaging
|
||||
case "letsencrypt":
|
||||
return LetsEncrypt
|
||||
}
|
||||
return a.Directory
|
||||
}
|
||||
|
||||
// Staging reports whether the directory is Let's Encrypt's staging one.
|
||||
func (a ACME) Staging() bool { return a.DirectoryURL() == LetsEncryptStaging }
|
||||
|
||||
// Jarl is a Jarl server and an API token for it.
|
||||
type Jarl struct {
|
||||
// Server is Jarl's URL ("" when Housecarl doesn't use Jarl).
|
||||
Server string `yaml:"server"`
|
||||
// Token, or TokenFile holding it (better: the file can be readable by
|
||||
// the housecarl user alone).
|
||||
Token string `yaml:"token"`
|
||||
TokenFile string `yaml:"token_file"`
|
||||
// CACert is a PEM file with a CA to trust for Jarl's certificate.
|
||||
CACert string `yaml:"ca_cert"`
|
||||
Insecure bool `yaml:"insecure"`
|
||||
}
|
||||
|
||||
// Enabled reports whether a Jarl server is configured.
|
||||
func (j Jarl) Enabled() bool { return j.Server != "" }
|
||||
|
||||
// ReadToken returns the token, from the setting or the file.
|
||||
func (j Jarl) ReadToken() (string, error) {
|
||||
if j.TokenFile == "" {
|
||||
return j.Token, nil
|
||||
}
|
||||
b, err := os.ReadFile(j.TokenFile)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("jarl.token_file: %w", err)
|
||||
}
|
||||
return strings.TrimSpace(string(b)), nil
|
||||
}
|
||||
|
||||
// Log configures logging to stderr (the journal under systemd).
|
||||
type Log struct {
|
||||
Level string `yaml:"level"` // debug, info, warn, error
|
||||
Format string `yaml:"format"` // text or json
|
||||
}
|
||||
|
||||
// Session configures web UI logins.
|
||||
type Session struct {
|
||||
Lifetime Duration `yaml:"lifetime"`
|
||||
IdleTimeout Duration `yaml:"idle_timeout"`
|
||||
// CookieSecure is auto (secure over HTTPS), always or never.
|
||||
CookieSecure string `yaml:"cookie_secure"`
|
||||
}
|
||||
|
||||
// Backup configures 'housecarld backup'.
|
||||
type Backup struct {
|
||||
Dir string `yaml:"dir"`
|
||||
Retention Duration `yaml:"retention"`
|
||||
// KeepAtLeast backups are kept however old they are.
|
||||
KeepAtLeast int `yaml:"keep_at_least"`
|
||||
}
|
||||
|
||||
// Duration is a time.Duration written as "15s" or "12h"; days ("30d")
|
||||
// work too.
|
||||
type Duration time.Duration
|
||||
|
||||
// UnmarshalYAML implements yaml.Unmarshaler.
|
||||
func (d *Duration) UnmarshalYAML(n *yaml.Node) error {
|
||||
var s string
|
||||
if err := n.Decode(&s); err != nil {
|
||||
return err
|
||||
}
|
||||
v, err := ParseDuration(s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*d = Duration(v)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ParseDuration parses a Go duration, also accepting days ("30d").
|
||||
func ParseDuration(s string) (time.Duration, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if n, ok := strings.CutSuffix(s, "d"); ok {
|
||||
var days int
|
||||
if _, err := fmt.Sscanf(n, "%d", &days); err != nil || fmt.Sprint(days) != n {
|
||||
return 0, fmt.Errorf("invalid duration %q", s)
|
||||
}
|
||||
return time.Duration(days) * 24 * time.Hour, nil
|
||||
}
|
||||
return time.ParseDuration(s)
|
||||
}
|
||||
|
||||
// Default returns the defaults for Debian/Ubuntu.
|
||||
func Default() *Config {
|
||||
return &Config{
|
||||
Listen: []string{"127.0.0.1:8070"},
|
||||
TrustedProxies: []string{},
|
||||
Ruleset: "/var/lib/housecarl/ruleset.nft",
|
||||
Socket: "/run/housecarl/housecarl.sock",
|
||||
Database: "/var/lib/housecarl/housecarl.db",
|
||||
Log: Log{Level: "info", Format: "text"},
|
||||
Session: Session{Lifetime: Duration(12 * time.Hour), IdleTimeout: Duration(2 * time.Hour), CookieSecure: "auto"},
|
||||
Backup: Backup{Dir: "/var/backups/housecarl", Retention: Duration(30 * 24 * time.Hour), KeepAtLeast: 7},
|
||||
TLS: TLS{ACME: ACME{Directory: "letsencrypt-staging", Resolvers: []string{"1.1.1.1:53", "9.9.9.9:53", "8.8.8.8:53"}, CheckPropagation: true}},
|
||||
}
|
||||
}
|
||||
|
||||
// Load reads the file at path over the defaults. A missing file is fine
|
||||
// when allowMissing is set.
|
||||
func Load(path string, allowMissing bool) (*Config, error) {
|
||||
c := Default()
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) && allowMissing {
|
||||
return c, c.check()
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
dec := yaml.NewDecoder(bytes.NewReader(b))
|
||||
dec.KnownFields(true)
|
||||
if err := dec.Decode(c); err != nil && err.Error() != "EOF" {
|
||||
return nil, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
if err := c.check(); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (c *Config) check() error {
|
||||
if len(c.Listen) == 0 {
|
||||
return fmt.Errorf("listen: at least one address is needed")
|
||||
}
|
||||
if c.Socket != "" && !filepath.IsAbs(c.Socket) {
|
||||
return fmt.Errorf("socket must be an absolute path (or \"\" for none)")
|
||||
}
|
||||
if len(c.Socket) > 107 {
|
||||
return fmt.Errorf("socket: the path is %d bytes long; unix sockets allow at most 107", len(c.Socket))
|
||||
}
|
||||
if (c.TLS.CertFile == "") != (c.TLS.KeyFile == "") {
|
||||
return fmt.Errorf("tls: set both cert_file and key_file")
|
||||
}
|
||||
if err := c.checkACME(); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, p := range c.TrustedProxies {
|
||||
if _, err := ParsePrefix(p); err != nil {
|
||||
return fmt.Errorf("trusted_proxies: %v", err)
|
||||
}
|
||||
}
|
||||
if c.Ruleset != "" && !filepath.IsAbs(c.Ruleset) {
|
||||
return fmt.Errorf("ruleset must be an absolute path (or \"\" for none)")
|
||||
}
|
||||
if c.Database == "" || !filepath.IsAbs(c.Database) {
|
||||
return fmt.Errorf("database must be an absolute path")
|
||||
}
|
||||
if c.Backup.Dir != "" && !filepath.IsAbs(c.Backup.Dir) {
|
||||
return fmt.Errorf("backup.dir must be an absolute path")
|
||||
}
|
||||
switch c.Session.CookieSecure {
|
||||
case "auto", "always", "never":
|
||||
default:
|
||||
return fmt.Errorf("session.cookie_secure must be auto, always or never")
|
||||
}
|
||||
switch c.Log.Format {
|
||||
case "text", "json":
|
||||
default:
|
||||
return fmt.Errorf("log.format must be text or json")
|
||||
}
|
||||
switch c.Log.Level {
|
||||
case "debug", "info", "warn", "error":
|
||||
default:
|
||||
return fmt.Errorf("log.level must be debug, info, warn or error")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Config) checkACME() error {
|
||||
j := c.Jarl
|
||||
if j.Server != "" {
|
||||
if u, err := url.Parse(j.Server); err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
||||
return fmt.Errorf("jarl.server %q: give Jarl's URL, e.g. https://dns.example.com:8053", j.Server)
|
||||
}
|
||||
if j.Token == "" && j.TokenFile == "" {
|
||||
return fmt.Errorf("jarl: set token or token_file (a Jarl API token, docs/jarl.md)")
|
||||
}
|
||||
if j.Token != "" && j.TokenFile != "" {
|
||||
return fmt.Errorf("jarl: set token or token_file, not both")
|
||||
}
|
||||
}
|
||||
a := c.TLS.ACME
|
||||
if a.Domain == "" {
|
||||
return nil
|
||||
}
|
||||
if c.TLS.CertFile != "" {
|
||||
return fmt.Errorf("tls: cert_file and acme can't both be set; choose one")
|
||||
}
|
||||
if !j.Enabled() {
|
||||
return fmt.Errorf("tls.acme needs Jarl to publish the challenge: set jarl.server and its token (docs/certificates.md)")
|
||||
}
|
||||
d := strings.TrimSuffix(strings.ToLower(a.Domain), ".")
|
||||
if strings.HasPrefix(d, "*.") || strings.Count(d, ".") < 1 || strings.ContainsAny(d, " /:") {
|
||||
return fmt.Errorf("tls.acme.domain %q: give the full name the web UI is reached by, e.g. firewall.example.com", a.Domain)
|
||||
}
|
||||
if u := a.DirectoryURL(); !strings.HasPrefix(u, "https://") {
|
||||
return fmt.Errorf("tls.acme.directory %q: use letsencrypt, letsencrypt-staging or an https:// ACME directory URL", a.Directory)
|
||||
}
|
||||
for _, r := range a.Resolvers {
|
||||
if _, _, err := net.SplitHostPort(r); err != nil {
|
||||
return fmt.Errorf("tls.acme.resolvers: %q must be HOST:PORT, e.g. 1.1.1.1:53", r)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ParsePrefix parses an address or a prefix.
|
||||
func ParsePrefix(s string) (netip.Prefix, error) {
|
||||
if strings.Contains(s, "/") {
|
||||
return netip.ParsePrefix(s)
|
||||
}
|
||||
a, err := netip.ParseAddr(s)
|
||||
if err != nil {
|
||||
return netip.Prefix{}, err
|
||||
}
|
||||
return netip.PrefixFrom(a, a.BitLen()), nil
|
||||
}
|
||||
|
||||
// Proxies returns the trusted proxies.
|
||||
func (c *Config) Proxies() []netip.Prefix {
|
||||
var out []netip.Prefix
|
||||
for _, p := range c.TrustedProxies {
|
||||
if pf, err := ParsePrefix(p); err == nil {
|
||||
out = append(out, pf.Masked())
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Example is a commented example configuration with the defaults.
|
||||
const Example = `# Housecarl daemon configuration (/etc/housecarl/housecarl.yaml).
|
||||
# Every setting is optional; the values shown are the defaults. The
|
||||
# firewall's configuration (zones, sets, rules, NAT) is not here: it is in
|
||||
# the database, changed with hc, the API or the web UI.
|
||||
|
||||
# Addresses the web UI and API listen on. Keep this off the internet:
|
||||
# bind to localhost or a management network, and use TLS (below) or a
|
||||
# TLS-terminating reverse proxy.
|
||||
listen:
|
||||
- "127.0.0.1:8070"
|
||||
|
||||
# HTTPS for the web UI and API: a certificate from files you renew yourself
|
||||
# (reread when they change)...
|
||||
# tls:
|
||||
# cert_file: /etc/housecarl/tls/fullchain.pem
|
||||
# key_file: /etc/housecarl/tls/privkey.pem
|
||||
#
|
||||
# ...or one Housecarl obtains and renews itself from Let's Encrypt, with Jarl
|
||||
# publishing the DNS-01 challenge (needs jarl: below; docs/certificates.md).
|
||||
# Start with the staging directory: its certificate isn't trusted by
|
||||
# browsers, but it proves everything works without Let's Encrypt's rate
|
||||
# limits. Then set directory: letsencrypt.
|
||||
tls:
|
||||
acme:
|
||||
domain: "" # e.g. firewall.example.com; "" is off
|
||||
email: ""
|
||||
directory: letsencrypt-staging # letsencrypt, or an ACME directory URL
|
||||
agree_tos: false # you accept the CA's terms of service
|
||||
# Public resolvers, to see the challenge as the CA does.
|
||||
resolvers: ["1.1.1.1:53", "9.9.9.9:53", "8.8.8.8:53"]
|
||||
# Also ask the zone's public name servers directly.
|
||||
check_propagation: true
|
||||
ca_bundle: "" # extra CA for a private ACME server
|
||||
|
||||
# Jarl, the BIND manager, publishes the certificate's DNS-01 challenge
|
||||
# (docs/certificates.md). Give Housecarl a token that may only write its
|
||||
# challenge record; keep it in a file only the housecarl user can read.
|
||||
# jarl:
|
||||
# server: https://dns.example.com:8053
|
||||
# token_file: /etc/housecarl/jarl.token
|
||||
# ca_cert: "" # a CA to trust for Jarl's certificate
|
||||
# insecure: false
|
||||
|
||||
# The confirmed ruleset, saved for housecarl-ruleset.service, which loads
|
||||
# it at boot before the network comes up (and before housecarld starts).
|
||||
ruleset: /var/lib/housecarl/ruleset.nft
|
||||
|
||||
# Reverse proxies whose X-Forwarded-For and X-Forwarded-Proto are
|
||||
# believed.
|
||||
trusted_proxies: []
|
||||
|
||||
# A local socket for the API: root and housecarld's own user use it without
|
||||
# a token ('sudo hc status' just works), and changes made through it need
|
||||
# no confirmation. "" turns it off.
|
||||
socket: /run/housecarl/housecarl.sock
|
||||
|
||||
database: /var/lib/housecarl/housecarl.db
|
||||
|
||||
log:
|
||||
level: info # debug, info, warn or error
|
||||
format: text # text or json
|
||||
|
||||
session:
|
||||
lifetime: 12h
|
||||
idle_timeout: 2h
|
||||
cookie_secure: auto # auto (secure over HTTPS), always or never
|
||||
|
||||
# 'housecarld backup' (the daily housecarl-backup.timer) writes copies of the
|
||||
# database here and deletes those older than retention, keeping at least
|
||||
# keep_at_least.
|
||||
backup:
|
||||
dir: /var/backups/housecarl
|
||||
retention: 30d
|
||||
keep_at_least: 7
|
||||
`
|
||||
@@ -0,0 +1,49 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExampleIsTheDefaults(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "housecarl.yaml")
|
||||
os.WriteFile(path, []byte(Example), 0o644)
|
||||
c, err := Load(path, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(c, Default()) {
|
||||
t.Errorf("example differs from the defaults:\n%+v\n%+v", c, Default())
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrors(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"listen: []": "listen",
|
||||
"database: relative.db": "database",
|
||||
"session: {cookie_secure: x}": "cookie_secure",
|
||||
"tls: {cert_file: /a}": "tls",
|
||||
"no_such_setting: 1": "no_such_setting",
|
||||
"backup: {retention: 3x}": "duration",
|
||||
"tls: {acme: {domain: dhcp.example.com}}": "needs Jarl",
|
||||
"jarl: {server: https://dns.example.com}": "token or token_file",
|
||||
"jarl: {server: dns.example.com, token: x}": "Jarl's URL",
|
||||
"{tls: {cert_file: /a, key_file: /b, acme: {domain: d.example.com}}, jarl: {server: https://j, token: x}}": "can't both",
|
||||
"{tls: {acme: {domain: '*.example.com'}}, jarl: {server: https://j, token: x}}": "full name",
|
||||
"{tls: {acme: {domain: d.example.com, directory: http://x}}, jarl: {server: https://j, token: x}}": "https://",
|
||||
"ruleset: relative.nft": "ruleset",
|
||||
}
|
||||
for yml, want := range cases {
|
||||
path := filepath.Join(t.TempDir(), "s.yaml")
|
||||
os.WriteFile(path, []byte(yml), 0o644)
|
||||
if _, err := Load(path, false); err == nil || !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("%q: %v", yml, err)
|
||||
}
|
||||
}
|
||||
if _, err := Load("/nonexistent/housecarl.yaml", true); err != nil {
|
||||
t.Errorf("missing file allowed: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,570 @@
|
||||
// Package engine changes Housecarl's configuration. Every change, from the
|
||||
// API, the CLI or an import, goes through Apply: the change is made on a
|
||||
// copy of the state, the result is validated as a whole, the ruleset built
|
||||
// from it is checked by nftables and put in force, and only then are the
|
||||
// changed objects and a history entry saved together (if saving fails, the
|
||||
// previous ruleset is put back). Revert undoes a history entry the same
|
||||
// way.
|
||||
package engine
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"housecarl/internal/auth"
|
||||
"housecarl/internal/model"
|
||||
"housecarl/internal/store"
|
||||
"housecarl/internal/validate"
|
||||
)
|
||||
|
||||
// Kinds of errors, for the API's status codes.
|
||||
const (
|
||||
KindBadRequest = "bad_request"
|
||||
KindNotFound = "not_found"
|
||||
KindConflict = "conflict"
|
||||
KindValidation = "validation"
|
||||
// KindFirewall: nftables refused or failed to apply the ruleset.
|
||||
KindFirewall = "firewall"
|
||||
)
|
||||
|
||||
// Firewall puts configurations in force (internal/nft, through
|
||||
// internal/render).
|
||||
type Firewall interface {
|
||||
// Check builds the ruleset and has nftables check it, changing
|
||||
// nothing.
|
||||
Check(ctx context.Context, st *model.State) error
|
||||
// Apply puts the configuration's ruleset in force, in one
|
||||
// transaction.
|
||||
Apply(ctx context.Context, st *model.State) error
|
||||
}
|
||||
|
||||
// Error is a refused change.
|
||||
type Error struct {
|
||||
Kind string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Problems []validate.Problem `json:"problems,omitempty"`
|
||||
}
|
||||
|
||||
func (e *Error) Error() string { return e.Message }
|
||||
|
||||
// Errorf makes an Error.
|
||||
func Errorf(kind, format string, args ...any) *Error {
|
||||
return &Error{Kind: kind, Message: fmt.Sprintf(format, args...)}
|
||||
}
|
||||
|
||||
// AsError unwraps an Error.
|
||||
func AsError(err error) (*Error, bool) {
|
||||
var e *Error
|
||||
ok := errors.As(err, &e)
|
||||
return e, ok
|
||||
}
|
||||
|
||||
// Result describes an applied (or previewed) change.
|
||||
type Result 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"`
|
||||
// Before and After are the configurations the change goes from and
|
||||
// to (After also for a dry run), for diffs and answers.
|
||||
Before, After *model.State `json:"-"`
|
||||
}
|
||||
|
||||
// Engine holds the current configuration.
|
||||
type Engine struct {
|
||||
store *store.Store
|
||||
log *slog.Logger
|
||||
// Env describes the host's interfaces for validation (nil: skip).
|
||||
Env func() *validate.Env
|
||||
// Firewall puts each new state in force (nil: none, in tests that
|
||||
// only look at the configuration).
|
||||
Firewall Firewall
|
||||
// OnChange is called with each new state, after it was saved.
|
||||
OnChange func(*model.State)
|
||||
|
||||
mu sync.Mutex
|
||||
state *model.State
|
||||
}
|
||||
|
||||
// New loads the configuration.
|
||||
func New(ctx context.Context, st *store.Store, log *slog.Logger) (*Engine, error) {
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
s, err := st.LoadState(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
Normalize(s)
|
||||
return &Engine{store: st, log: log, state: s}, nil
|
||||
}
|
||||
|
||||
// Store is the engine's database.
|
||||
func (e *Engine) Store() *store.Store { return e.store }
|
||||
|
||||
// State returns a copy of the current configuration.
|
||||
func (e *Engine) State() *model.State {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
return e.state.Clone()
|
||||
}
|
||||
|
||||
func (e *Engine) env() *validate.Env {
|
||||
if e.Env == nil {
|
||||
return nil
|
||||
}
|
||||
return e.Env()
|
||||
}
|
||||
|
||||
// Problems validates the current configuration (for the status page).
|
||||
func (e *Engine) Problems() []validate.Problem {
|
||||
return validate.Check(e.State(), e.env())
|
||||
}
|
||||
|
||||
// Edit changes a configuration in place. NewID gives IDs for new objects.
|
||||
type Edit func(st *model.State, newID func() int64) error
|
||||
|
||||
// Options say how to apply a change.
|
||||
type Options struct {
|
||||
DryRun bool
|
||||
Via string // web, cli, api, import
|
||||
}
|
||||
|
||||
// Apply makes a change: edit runs on a copy of the configuration, the
|
||||
// result is validated, and the changed objects are saved with a history
|
||||
// entry. A change that touches nothing is not recorded.
|
||||
func (e *Engine) Apply(ctx context.Context, p *auth.Principal, description string, opts Options, edit Edit) (*Result, error) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
next := e.state.Clone()
|
||||
var idErr error
|
||||
newID := func() int64 {
|
||||
ids, err := e.store.AllocateIDs(ctx, 1)
|
||||
if err != nil {
|
||||
idErr = err
|
||||
return 0
|
||||
}
|
||||
return ids[0]
|
||||
}
|
||||
if err := edit(next, newID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if idErr != nil {
|
||||
return nil, idErr
|
||||
}
|
||||
return e.commit(ctx, p, description, opts, next, 0)
|
||||
}
|
||||
|
||||
// commit validates next and saves it as the new state.
|
||||
func (e *Engine) commit(ctx context.Context, p *auth.Principal, description string, opts Options, next *model.State, revertOf int64) (*Result, error) {
|
||||
Normalize(next)
|
||||
next.Sort()
|
||||
changes := Diff(e.state, next)
|
||||
res := &Result{DryRun: opts.DryRun, Changes: changes, Before: e.state.Clone(), After: next.Clone()}
|
||||
if len(changes) == 0 {
|
||||
return res, nil
|
||||
}
|
||||
problems := validate.Check(next, e.env())
|
||||
if validate.HasErrors(problems) {
|
||||
errs := validate.Errors(problems)
|
||||
msg := errs[0].Message
|
||||
if errs[0].Object != "" {
|
||||
msg = errs[0].Object + ": " + msg
|
||||
}
|
||||
if len(errs) > 1 {
|
||||
msg += fmt.Sprintf(" (and %d more problems)", len(errs)-1)
|
||||
}
|
||||
return res, &Error{Kind: KindValidation, Message: msg, Problems: errs}
|
||||
}
|
||||
// Only report warnings the change brings; old ones are on the status
|
||||
// page.
|
||||
res.Warnings = newProblems(validate.Check(e.state, e.env()), problems)
|
||||
if e.Firewall != nil {
|
||||
if err := e.Firewall.Check(ctx, next); err != nil {
|
||||
return res, &Error{Kind: KindFirewall, Message: "nftables refused the ruleset: " + err.Error()}
|
||||
}
|
||||
}
|
||||
if opts.DryRun {
|
||||
return res, nil
|
||||
}
|
||||
if e.Firewall != nil {
|
||||
if err := e.Firewall.Apply(ctx, next); err != nil {
|
||||
return res, &Error{Kind: KindFirewall, Message: "applying the ruleset failed, nothing was changed: " + err.Error()}
|
||||
}
|
||||
}
|
||||
entry := &model.HistoryEntry{Time: time.Now(), Username: p.Name(), Via: opts.Via, Description: description, Changes: changes, RevertOf: revertOf}
|
||||
if p != nil && p.User != nil {
|
||||
entry.UserID = p.User.ID
|
||||
}
|
||||
if entry.Description == "" {
|
||||
entry.Description = Describe(changes)
|
||||
}
|
||||
if err := e.store.SaveChanges(ctx, changes, entry); err != nil {
|
||||
// The new ruleset is in force but not saved: put the old one back.
|
||||
if e.Firewall != nil {
|
||||
if rerr := e.Firewall.Apply(ctx, e.state); rerr != nil {
|
||||
e.log.Error("saving a change failed, and putting the previous ruleset back failed too", "err", rerr)
|
||||
}
|
||||
}
|
||||
return res, err
|
||||
}
|
||||
e.state = next
|
||||
res.Applied, res.HistoryID = true, entry.ID
|
||||
e.Audit(ctx, p, "config.change", fmt.Sprintf("history %d", entry.ID), "success", entry.Description)
|
||||
e.log.Info("configuration changed", "history", entry.ID, "user", p.Name(), "via", opts.Via, "description", entry.Description)
|
||||
if e.OnChange != nil {
|
||||
e.OnChange(next.Clone())
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func newProblems(before, after []validate.Problem) []validate.Problem {
|
||||
seen := map[string]bool{}
|
||||
for _, p := range before {
|
||||
seen[p.Code+"\x00"+p.Object+"\x00"+p.Message] = true
|
||||
}
|
||||
var out []validate.Problem
|
||||
for _, p := range after {
|
||||
if !seen[p.Code+"\x00"+p.Object+"\x00"+p.Message] {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ApplyCurrent puts the current configuration in force (at start).
|
||||
func (e *Engine) ApplyCurrent(ctx context.Context) error {
|
||||
if e.Firewall == nil {
|
||||
return nil
|
||||
}
|
||||
return e.Firewall.Apply(ctx, e.State())
|
||||
}
|
||||
|
||||
// Revert undoes a history entry, if the objects it changed are still as
|
||||
// it left them.
|
||||
func (e *Engine) Revert(ctx context.Context, p *auth.Principal, id int64, opts Options) (*Result, error) {
|
||||
h, err := e.store.GetHistory(ctx, id)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return nil, Errorf(KindNotFound, "history entry %d not found", id)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if h.RevertedBy != 0 {
|
||||
return nil, Errorf(KindConflict, "history entry %d was already undone by entry %d", id, h.RevertedBy)
|
||||
}
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
cur := objects(e.state)
|
||||
next := e.state.Clone()
|
||||
for i := len(h.Changes) - 1; i >= 0; i-- {
|
||||
c := h.Changes[i]
|
||||
now := cur[key{c.Kind, c.ID}]
|
||||
if !sameJSON(now, c.After) {
|
||||
what := c.Label
|
||||
if what == "" {
|
||||
what = fmt.Sprintf("%s %d", c.Kind, c.ID)
|
||||
}
|
||||
return nil, Errorf(KindConflict, "%s %s has changed since entry %d; undo the later changes first, or change it by hand", c.Kind, what, id)
|
||||
}
|
||||
if err := put(next, c.Kind, c.ID, c.Before); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return e.commit(ctx, p, fmt.Sprintf("undo %d: %s", h.ID, h.Description), opts, next, h.ID)
|
||||
}
|
||||
|
||||
// Replace makes the configuration next as a whole (imports).
|
||||
func (e *Engine) Replace(ctx context.Context, p *auth.Principal, description string, opts Options, next *model.State) (*Result, error) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
return e.commit(ctx, p, description, opts, next.Clone(), 0)
|
||||
}
|
||||
|
||||
// Audit records an action in the audit log.
|
||||
func (e *Engine) Audit(ctx context.Context, p *auth.Principal, action, object, result, detail string) {
|
||||
ev := &model.AuditEvent{Action: action, Object: object, Result: result, Detail: detail}
|
||||
if p != nil {
|
||||
ev.Username, ev.Auth, ev.IP = p.Name(), p.Method, p.IP
|
||||
if p.User != nil {
|
||||
ev.UserID = p.User.ID
|
||||
}
|
||||
if p.Token != nil {
|
||||
ev.TokenID = p.Token.ID
|
||||
}
|
||||
}
|
||||
if err := e.store.AddAudit(ctx, ev); err != nil {
|
||||
e.log.Warn("writing the audit log failed", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- diffs
|
||||
|
||||
type key struct {
|
||||
kind string
|
||||
id int64
|
||||
}
|
||||
|
||||
func objects(st *model.State) map[key]json.RawMessage {
|
||||
m := map[key]json.RawMessage{{model.KindSettings, 0}: mustJSON(st.Settings)}
|
||||
for _, k := range model.Kinds {
|
||||
for _, o := range st.Objects(k) {
|
||||
m[key{k, o.GetID()}] = mustJSON(o)
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func labels(st *model.State) map[key]string {
|
||||
m := map[key]string{{model.KindSettings, 0}: "settings"}
|
||||
for _, k := range model.Kinds {
|
||||
for _, o := range st.Objects(k) {
|
||||
m[key{k, o.GetID()}] = o.Label()
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// Diff lists what changes from a to b: settings, then each kind of
|
||||
// object (zones, sets, services, rules, NAT), each by ID.
|
||||
func Diff(a, b *model.State) []model.Change {
|
||||
oa, ob := objects(a), objects(b)
|
||||
la, lb := labels(a), labels(b)
|
||||
var keys []key
|
||||
for k := range oa {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
for k := range ob {
|
||||
if _, ok := oa[k]; !ok {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
}
|
||||
order := map[string]int{model.KindSettings: 0}
|
||||
for i, k := range model.Kinds {
|
||||
order[k] = i + 1
|
||||
}
|
||||
sort.Slice(keys, func(i, j int) bool {
|
||||
if keys[i].kind != keys[j].kind {
|
||||
return order[keys[i].kind] < order[keys[j].kind]
|
||||
}
|
||||
return keys[i].id < keys[j].id
|
||||
})
|
||||
var out []model.Change
|
||||
for _, k := range keys {
|
||||
before, after := oa[k], ob[k]
|
||||
if sameJSON(before, after) {
|
||||
continue
|
||||
}
|
||||
label := lb[k]
|
||||
if label == "" {
|
||||
label = la[k]
|
||||
}
|
||||
out = append(out, model.Change{Kind: k.kind, ID: k.id, Label: label, Before: before, After: after})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Describe sums up changes: "create zone lan (eth1)", or
|
||||
// "3 changes" for many.
|
||||
func Describe(cs []model.Change) string {
|
||||
if len(cs) == 0 {
|
||||
return "no changes"
|
||||
}
|
||||
parts := []string{}
|
||||
for _, c := range cs {
|
||||
if c.Kind == model.KindSettings {
|
||||
parts = append(parts, "update settings")
|
||||
continue
|
||||
}
|
||||
parts = append(parts, c.Action()+" "+c.Kind+" "+c.Label)
|
||||
}
|
||||
if len(parts) > 3 {
|
||||
return fmt.Sprintf("%s and %d more changes", strings.Join(parts[:2], ", "), len(parts)-2)
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
func mustJSON(v any) json.RawMessage {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func sameJSON(a, b json.RawMessage) bool {
|
||||
if a == nil || b == nil {
|
||||
return a == nil && b == nil
|
||||
}
|
||||
return bytes.Equal(a, b)
|
||||
}
|
||||
|
||||
// put sets (or with nil data deletes) one object in st.
|
||||
func put(st *model.State, kind string, id int64, data json.RawMessage) error {
|
||||
if kind == model.KindSettings {
|
||||
if data == nil {
|
||||
return fmt.Errorf("settings can't be deleted")
|
||||
}
|
||||
var set model.Settings
|
||||
if err := json.Unmarshal(data, &set); err != nil {
|
||||
return err
|
||||
}
|
||||
st.Settings = set
|
||||
return nil
|
||||
}
|
||||
return st.Put(kind, id, data)
|
||||
}
|
||||
|
||||
// Normalize writes names, addresses, ports and lists in their canonical
|
||||
// form, so equal configurations compare equal, and spaces rule positions
|
||||
// out again when insertions have crowded them.
|
||||
func Normalize(st *model.State) {
|
||||
st.Fill()
|
||||
set := &st.Settings
|
||||
set.AntispoofV4 = normAddrs(set.AntispoofV4)
|
||||
set.AntispoofV6 = normAddrs(set.AntispoofV6)
|
||||
set.OwnPrefixes = normAddrs(set.OwnPrefixes)
|
||||
set.FlowtableDevices = normList(set.FlowtableDevices)
|
||||
for _, z := range st.Zones {
|
||||
z.Name = strings.TrimSpace(z.Name)
|
||||
z.Interfaces = normList(z.Interfaces)
|
||||
}
|
||||
for _, x := range st.Sets {
|
||||
x.Name = strings.TrimSpace(x.Name)
|
||||
x.Entries = normAddrs(x.Entries)
|
||||
}
|
||||
for _, x := range st.Services {
|
||||
x.Name = strings.TrimSpace(x.Name)
|
||||
x.Entries = normServices(x.Entries)
|
||||
}
|
||||
for _, r := range st.Rules {
|
||||
normMatch(&r.Match)
|
||||
r.CtState = normList(lower(r.CtState))
|
||||
r.CtStatus = normList(lower(r.CtStatus))
|
||||
r.Raw = strings.TrimSpace(r.Raw)
|
||||
}
|
||||
for _, f := range st.Forwards {
|
||||
f.Protocols = normList(lower(f.Protocols))
|
||||
f.PublicAddresses = normAddrs(f.PublicAddresses)
|
||||
f.Sources = normAddrs(f.Sources)
|
||||
f.HairpinSources = normAddrs(f.HairpinSources)
|
||||
f.Ports = normPorts(f.Ports)
|
||||
f.ToPort = normPorts(f.ToPort)
|
||||
f.To = normAddr(f.To)
|
||||
f.To6 = normAddr(f.To6)
|
||||
}
|
||||
for _, x := range st.SNATs {
|
||||
normMatch(&x.Match)
|
||||
x.To = normAddr(x.To)
|
||||
}
|
||||
for _, x := range st.Redirects {
|
||||
normMatch(&x.Match)
|
||||
x.ToPort = normPorts(x.ToPort)
|
||||
}
|
||||
// Positions are fractions after insertions between rules; renumber
|
||||
// only when two have come too close to put another between.
|
||||
st.Sort()
|
||||
crowded := func(pos []float64) bool {
|
||||
for i := 1; i < len(pos); i++ {
|
||||
if pos[i]-pos[i-1] < 1e-6 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
byChain := map[string][]float64{}
|
||||
for _, r := range st.Rules {
|
||||
byChain[r.Chain] = append(byChain[r.Chain], r.Position)
|
||||
}
|
||||
re := false
|
||||
for _, pos := range byChain {
|
||||
re = re || crowded(pos)
|
||||
}
|
||||
var fp, sp, rp []float64
|
||||
for _, x := range st.Forwards {
|
||||
fp = append(fp, x.Position)
|
||||
}
|
||||
for _, x := range st.SNATs {
|
||||
sp = append(sp, x.Position)
|
||||
}
|
||||
for _, x := range st.Redirects {
|
||||
rp = append(rp, x.Position)
|
||||
}
|
||||
if re || crowded(fp) || crowded(sp) || crowded(rp) {
|
||||
st.Renumber()
|
||||
}
|
||||
}
|
||||
|
||||
func normMatch(m *model.Match) {
|
||||
m.InZones = normList(m.InZones)
|
||||
m.OutZones = normList(m.OutZones)
|
||||
m.Sources = normAddrs(m.Sources)
|
||||
m.Destinations = normAddrs(m.Destinations)
|
||||
m.Services = normServices(m.Services)
|
||||
m.Family = strings.ToLower(strings.TrimSpace(m.Family))
|
||||
}
|
||||
|
||||
// normList trims, drops empty entries and duplicates, keeping the order.
|
||||
func normList(in []string) []string {
|
||||
var out []string
|
||||
seen := map[string]bool{}
|
||||
for _, s := range in {
|
||||
s = strings.TrimSpace(s)
|
||||
if s != "" && !seen[s] {
|
||||
seen[s] = true
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func lower(in []string) []string {
|
||||
out := make([]string, len(in))
|
||||
for i, s := range in {
|
||||
out[i] = strings.ToLower(s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// normAddrs writes addresses canonically; set names stay as they are.
|
||||
func normAddrs(in []string) []string {
|
||||
out := normList(in)
|
||||
for i, s := range out {
|
||||
out[i] = normAddr(s)
|
||||
}
|
||||
return normList(out)
|
||||
}
|
||||
|
||||
func normAddr(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if n, err := model.NormalizeAddress(s); err == nil {
|
||||
return n
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func normServices(in []string) []string {
|
||||
out := normList(in)
|
||||
for i, s := range out {
|
||||
if e, err := model.ParseServiceEntry(s); err == nil {
|
||||
out[i] = e.String()
|
||||
}
|
||||
}
|
||||
return normList(out)
|
||||
}
|
||||
|
||||
func normPorts(s string) string {
|
||||
if n, err := model.NormalizePorts(s); err == nil {
|
||||
return n
|
||||
}
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"housecarl/internal/auth"
|
||||
"housecarl/internal/model"
|
||||
"housecarl/internal/store"
|
||||
)
|
||||
|
||||
func newEngine(t *testing.T) *Engine {
|
||||
t.Helper()
|
||||
st, err := store.Open(":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e, err := New(context.Background(), st, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
var admin = &auth.Principal{User: &model.User{ID: 1, Username: "admin", Admin: true}, Method: "session"}
|
||||
|
||||
// fakeFirewall records what it was asked to apply.
|
||||
type fakeFirewall struct {
|
||||
applied []*model.State
|
||||
checkErr error
|
||||
applyErr error
|
||||
}
|
||||
|
||||
func (f *fakeFirewall) Check(_ context.Context, st *model.State) error { return f.checkErr }
|
||||
func (f *fakeFirewall) Apply(_ context.Context, st *model.State) error {
|
||||
if f.applyErr != nil {
|
||||
return f.applyErr
|
||||
}
|
||||
f.applied = append(f.applied, st.Clone())
|
||||
return nil
|
||||
}
|
||||
|
||||
func addWAN(t *testing.T, e *Engine) int64 {
|
||||
t.Helper()
|
||||
var id int64
|
||||
res, err := e.Apply(context.Background(), admin, "", Options{Via: "cli"}, func(st *model.State, newID func() int64) error {
|
||||
id = newID()
|
||||
st.Zones = append(st.Zones, &model.Zone{ID: id, Name: "wan", Interfaces: []string{" wan ", "wan", "ppp*"}, External: true})
|
||||
return nil
|
||||
})
|
||||
if err != nil || !res.Applied || res.HistoryID == 0 {
|
||||
t.Fatalf("apply: %+v %v", res, err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func TestApplyNormalizesAndRecords(t *testing.T) {
|
||||
e := newEngine(t)
|
||||
fw := &fakeFirewall{}
|
||||
e.Firewall = fw
|
||||
id := addWAN(t, e)
|
||||
z := e.State().ByID(model.KindZone, id).(*model.Zone)
|
||||
if strings.Join(z.Interfaces, ",") != "wan,ppp*" {
|
||||
t.Errorf("interfaces not normalized: %q", z.Interfaces)
|
||||
}
|
||||
if len(fw.applied) != 1 || len(fw.applied[0].Zones) != 1 {
|
||||
t.Errorf("firewall got %+v", fw.applied)
|
||||
}
|
||||
h, _ := e.Store().ListHistory(context.Background(), 10, 0)
|
||||
if len(h) != 1 || h[0].Description != "create zone wan" || h[0].Via != "cli" || h[0].Username != "admin" {
|
||||
t.Errorf("history %+v", h[0])
|
||||
}
|
||||
// Nothing changed: nothing recorded or applied.
|
||||
res, err := e.Apply(context.Background(), admin, "", Options{}, func(st *model.State, _ func() int64) error { return nil })
|
||||
if err != nil || res.Applied || len(res.Changes) != 0 || len(fw.applied) != 1 {
|
||||
t.Errorf("empty change: %+v %v", res, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidationAndFirewallRefusals(t *testing.T) {
|
||||
e := newEngine(t)
|
||||
fw := &fakeFirewall{}
|
||||
e.Firewall = fw
|
||||
addWAN(t, e)
|
||||
addRule := func(r *model.Rule) (*Result, error) {
|
||||
return e.Apply(context.Background(), admin, "", Options{}, func(st *model.State, newID func() int64) error {
|
||||
r.ID = newID()
|
||||
st.Rules = append(st.Rules, r)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
// Validation: an unknown set.
|
||||
_, err := addRule(&model.Rule{Chain: model.ChainInput, Position: 1, Action: model.ActionAccept, Match: model.Match{Sources: []string{"trusted"}}})
|
||||
if ee, ok := AsError(err); !ok || ee.Kind != KindValidation || ee.Problems[0].Code != "unknown_set" {
|
||||
t.Errorf("unknown set: %v", err)
|
||||
}
|
||||
// nftables refuses: nothing applied or saved.
|
||||
fw.checkErr = errors.New("syntax error")
|
||||
_, err = addRule(&model.Rule{Chain: model.ChainInput, Position: 1, Action: model.ActionAccept, Raw: "bogus"})
|
||||
if ee, ok := AsError(err); !ok || ee.Kind != KindFirewall {
|
||||
t.Errorf("refused ruleset: %v", err)
|
||||
}
|
||||
// Applying fails: nothing saved.
|
||||
fw.checkErr, fw.applyErr = nil, errors.New("netlink: busy")
|
||||
_, err = addRule(&model.Rule{Chain: model.ChainInput, Position: 1, Action: model.ActionAccept, Match: model.Match{Services: []string{"ssh"}}})
|
||||
if ee, ok := AsError(err); !ok || ee.Kind != KindFirewall || !strings.Contains(ee.Message, "nothing was changed") {
|
||||
t.Errorf("failed apply: %v", err)
|
||||
}
|
||||
if len(e.State().Rules) != 0 {
|
||||
t.Errorf("rules saved after failures: %+v", e.State().Rules)
|
||||
}
|
||||
// A dry run is checked, but neither applied nor saved.
|
||||
fw.applyErr = nil
|
||||
before := len(fw.applied)
|
||||
res, err := e.Apply(context.Background(), admin, "", Options{DryRun: true}, func(st *model.State, newID func() int64) error {
|
||||
st.Rules = append(st.Rules, &model.Rule{ID: newID(), Chain: model.ChainInput, Position: 1, Action: model.ActionDrop})
|
||||
return nil
|
||||
})
|
||||
if err != nil || res.Applied || len(res.Changes) != 1 || len(fw.applied) != before {
|
||||
t.Errorf("dry run: %+v %v", res, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevertAppliesThePreviousRuleset(t *testing.T) {
|
||||
e := newEngine(t)
|
||||
fw := &fakeFirewall{}
|
||||
e.Firewall = fw
|
||||
addWAN(t, e)
|
||||
res, err := e.Apply(context.Background(), admin, "", Options{}, func(st *model.State, newID func() int64) error {
|
||||
st.Settings.ForwardPolicy = model.PolicyAccept
|
||||
return nil
|
||||
})
|
||||
if err != nil || !res.Applied {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := e.Revert(context.Background(), admin, res.HistoryID, Options{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
last := fw.applied[len(fw.applied)-1]
|
||||
if last.Settings.ForwardPolicy != model.PolicyDrop || e.State().Settings.ForwardPolicy != model.PolicyDrop {
|
||||
t.Errorf("after revert: applied %s, state %s", last.Settings.ForwardPolicy, e.State().Settings.ForwardPolicy)
|
||||
}
|
||||
// Undoing it twice is refused.
|
||||
if _, err := e.Revert(context.Background(), admin, res.HistoryID, Options{}); err == nil {
|
||||
t.Error("second undo accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPositionsStayStableUntilCrowded(t *testing.T) {
|
||||
e := newEngine(t)
|
||||
for i := 0; i < 3; i++ {
|
||||
e.Apply(context.Background(), admin, "", Options{}, func(st *model.State, newID func() int64) error {
|
||||
st.Rules = append(st.Rules, &model.Rule{ID: newID(), Chain: model.ChainInput, Position: float64(i + 1), Action: model.ActionAccept,
|
||||
Match: model.Match{Services: []string{"ssh"}}})
|
||||
return nil
|
||||
})
|
||||
}
|
||||
// Inserting between two rules changes only the new one.
|
||||
res, err := e.Apply(context.Background(), admin, "", Options{}, func(st *model.State, newID func() int64) error {
|
||||
st.Rules = append(st.Rules, &model.Rule{ID: newID(), Chain: model.ChainInput, Position: 1.5, Action: model.ActionDrop})
|
||||
return nil
|
||||
})
|
||||
if err != nil || len(res.Changes) != 1 {
|
||||
t.Fatalf("insert: %+v %v", res, err)
|
||||
}
|
||||
var order []string
|
||||
for _, r := range e.State().Rules {
|
||||
order = append(order, r.Action)
|
||||
}
|
||||
if strings.Join(order, ",") != "accept,drop,accept,accept" {
|
||||
t.Errorf("order %v", order)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
// Package hostif reads the machine's network interfaces: their IPv4 and
|
||||
// IPv6 addresses and whether they are up. Results are cached for a few
|
||||
// seconds, since validation and the status page ask often.
|
||||
package hostif
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/netip"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Host reads this machine's interfaces.
|
||||
type Host struct {
|
||||
mu sync.Mutex
|
||||
at time.Time
|
||||
addrs map[string][]netip.Prefix
|
||||
up map[string]bool
|
||||
err error
|
||||
}
|
||||
|
||||
func (h *Host) refresh() {
|
||||
if time.Since(h.at) < 5*time.Second && (h.addrs != nil || h.err != nil) {
|
||||
return
|
||||
}
|
||||
h.at = time.Now()
|
||||
ifs, err := net.Interfaces()
|
||||
if err != nil {
|
||||
h.addrs, h.up, h.err = nil, nil, err
|
||||
return
|
||||
}
|
||||
addrs, up := map[string][]netip.Prefix{}, map[string]bool{}
|
||||
for _, i := range ifs {
|
||||
var ps []netip.Prefix
|
||||
as, _ := i.Addrs()
|
||||
for _, a := range as {
|
||||
if n, ok := a.(*net.IPNet); ok {
|
||||
if ip, ok := netip.AddrFromSlice(n.IP); ok {
|
||||
bits, _ := n.Mask.Size()
|
||||
ps = append(ps, netip.PrefixFrom(ip.Unmap(), bits))
|
||||
}
|
||||
}
|
||||
}
|
||||
addrs[i.Name] = ps
|
||||
up[i.Name] = i.Flags&net.FlagUp != 0
|
||||
}
|
||||
h.addrs, h.up, h.err = addrs, up, nil
|
||||
}
|
||||
|
||||
// All returns every interface with its addresses.
|
||||
func (h *Host) All() (map[string][]netip.Prefix, error) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.refresh()
|
||||
return h.addrs, h.err
|
||||
}
|
||||
|
||||
// Up reports whether an interface is up.
|
||||
func (h *Host) Up(name string) bool {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.refresh()
|
||||
return h.up[name]
|
||||
}
|
||||
|
||||
// Exists returns the set of interface names (nil if they can't be read).
|
||||
func (h *Host) Exists() map[string]bool {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.refresh()
|
||||
if h.err != nil {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]bool, len(h.addrs))
|
||||
for n := range h.addrs {
|
||||
out[n] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,303 @@
|
||||
package importer
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"housecarl/internal/model"
|
||||
"housecarl/internal/render"
|
||||
"housecarl/internal/validate"
|
||||
)
|
||||
|
||||
// The maintainer's router ruleset (testdata/router.nft), as nft 1.1.3
|
||||
// lists it in JSON (router.json) and text (router.txt).
|
||||
func importRouter(t *testing.T) *Result {
|
||||
t.Helper()
|
||||
js, _ := os.ReadFile("testdata/router.json")
|
||||
txt, _ := os.ReadFile("testdata/router.txt")
|
||||
defs, _ := os.ReadFile("testdata/router.nft")
|
||||
res, err := Import(Input{JSON: js, Text: string(txt), Defines: string(defs)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func TestImportRouter(t *testing.T) {
|
||||
res := importRouter(t)
|
||||
st := res.State
|
||||
for _, n := range res.Notes {
|
||||
t.Log("note:", n)
|
||||
}
|
||||
for _, r := range res.Raw {
|
||||
t.Log("raw:", r)
|
||||
}
|
||||
// Zones, named by the defines; wan is external.
|
||||
wan := st.Zone("wan")
|
||||
if wan == nil || !wan.External || wan.Interfaces[0] != "wan" {
|
||||
t.Errorf("zone wan: %+v", wan)
|
||||
}
|
||||
if z := st.Zone("dhcp"); z == nil || z.Interfaces[0] != "enp2s0" {
|
||||
t.Errorf("zone dhcp: %+v (zones %+v)", z, st.Zones)
|
||||
}
|
||||
// Sets named by the defines.
|
||||
if s := st.Set("trusted_ips"); s == nil || len(s.Entries) != 7 {
|
||||
t.Errorf("set trusted_ips: %+v", s)
|
||||
}
|
||||
if s := st.Set("masq_to_internet"); s == nil {
|
||||
t.Errorf("no set masq_to_internet (sets %v)", setNames(st))
|
||||
}
|
||||
// Built-ins: the ones at the head of their chains.
|
||||
b := st.Settings.Builtins
|
||||
if !b.Loopback || !b.Established || !b.ICMPv6 || !b.PingInternal || !b.DHCPv6Client || !b.MSSClamp || !b.Antispoof {
|
||||
t.Errorf("built-ins %+v", b)
|
||||
}
|
||||
// The forward chain's asymmetric-routing rule sits between the
|
||||
// established and invalid rules: invalid stays rules, in order.
|
||||
if b.DropInvalid {
|
||||
t.Error("drop_invalid became a built-in, which would put it before the asymmetric-routing rule")
|
||||
}
|
||||
var fwd []string
|
||||
for _, r := range st.Rules {
|
||||
if r.Chain == model.ChainForward {
|
||||
fwd = append(fwd, r.Comment+"|"+strings.Join(r.CtState, ","))
|
||||
}
|
||||
}
|
||||
if len(fwd) < 2 || fwd[0] != "Needed due to asymmetric routing (internal only)|invalid" || fwd[1] != "|invalid" {
|
||||
t.Errorf("forward rules start %q", fwd)
|
||||
}
|
||||
if st.Settings.OwnPrefixes[0] != "2a02:8013:7f3d::/48" || len(st.Settings.AntispoofV4) != 6 {
|
||||
t.Errorf("antispoof %+v %+v", st.Settings.OwnPrefixes, st.Settings.AntispoofV4)
|
||||
}
|
||||
// Port forwards with their hairpin; the forward-chain accepts are
|
||||
// folded in.
|
||||
if len(st.Forwards) != 5 {
|
||||
t.Fatalf("forwards %+v", st.Forwards)
|
||||
}
|
||||
jf := st.Forwards[0]
|
||||
if jf.Name != "jellyfin" || jf.Ports != "8096" || jf.To != "10.0.10.57" || jf.ToPort != "443" || jf.PublicAddresses[0] != "217.155.8.194" ||
|
||||
!jf.Hairpin || jf.HairpinSources[0] != "internal_net" {
|
||||
t.Errorf("jellyfin %+v", jf)
|
||||
}
|
||||
for _, r := range st.Rules {
|
||||
if r.Chain == model.ChainForward && strings.Contains(r.Comment, "Forward") && len(r.CtStatus) > 0 {
|
||||
t.Errorf("forward accept not folded in: %+v", r)
|
||||
}
|
||||
}
|
||||
// Source NAT: the masquerades.
|
||||
if len(st.SNATs) != 2 || st.SNATs[0].OutZones[0] != "wan" || st.SNATs[0].Sources[0] != "masq_to_internet" ||
|
||||
!st.SNATs[1].NotOut || st.SNATs[1].OutZones[0] != "wg0" {
|
||||
t.Errorf("snats %+v %+v", st.SNATs[0], st.SNATs[1])
|
||||
}
|
||||
// The jump to wg0_input is inlined: its rules carry the source.
|
||||
var wgDNS bool
|
||||
for _, r := range st.Rules {
|
||||
if r.Chain == model.ChainInput && len(r.Sources) == 1 && r.Sources[0] == "wg0_net" && strings.Join(r.Services, ",") == "udp/53" {
|
||||
wgDNS = true
|
||||
}
|
||||
}
|
||||
if !wgDNS {
|
||||
t.Errorf("wg0_input not inlined")
|
||||
}
|
||||
// The jump's and the rule's sources both: the rule's are kept, noted.
|
||||
if !strings.Contains(strings.Join(res.Notes, "\n"), "the rule's own (10.50.50.3, 10.50.50.6) are kept") {
|
||||
t.Errorf("no note on the overlapping sources: %q", res.Notes)
|
||||
}
|
||||
// The imported configuration is valid, and renders.
|
||||
if ps := validate.Check(st, nil); validate.HasErrors(ps) {
|
||||
t.Errorf("problems: %+v", validate.Errors(ps))
|
||||
}
|
||||
text := render.Render(st, render.Options{}).Text
|
||||
for _, want := range []string{
|
||||
`ip daddr 217.155.8.194 tcp dport 8096 counter dnat ip to 10.0.10.57:443`,
|
||||
`ip saddr @internal_net_v4 ip daddr 10.0.10.57 tcp dport 443 ct status dnat counter masquerade`,
|
||||
`oifname "wan" ip saddr @masq_to_internet_v4 counter masquerade`,
|
||||
`oifname "wan" tcp flags syn tcp option maxseg size set rt mtu`,
|
||||
`ip saddr @trusted_ips_v4 tcp dport 8053 counter accept comment "hc:r`,
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Errorf("render: missing %q", want)
|
||||
}
|
||||
}
|
||||
if t.Failed() {
|
||||
t.Log(text)
|
||||
}
|
||||
}
|
||||
|
||||
func setNames(st *model.State) []string {
|
||||
var out []string
|
||||
for _, s := range st.Sets {
|
||||
out = append(out, s.Name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestImportNeedsATable(t *testing.T) {
|
||||
js := []byte(`{"nftables":[{"table":{"family":"inet","name":"main"}},{"table":{"family":"ip","name":"nat"}}]}`)
|
||||
if _, err := Import(Input{JSON: js}); err == nil || !strings.Contains(err.Error(), "several tables") {
|
||||
t.Errorf("several tables: %v", err)
|
||||
}
|
||||
if _, err := Import(Input{JSON: js, Table: "ip nat"}); err != nil {
|
||||
t.Errorf("--table: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForwardNames(t *testing.T) {
|
||||
im := &importer{st: &model.State{}}
|
||||
for _, c := range []string{"Port forward: Jellyfin", "8096 to media", "web", "web", "-"} {
|
||||
im.st.Forwards = append(im.st.Forwards, &model.PortForward{Name: forwardName(c)})
|
||||
}
|
||||
im.nameForwards()
|
||||
var got []string
|
||||
for _, f := range im.st.Forwards {
|
||||
got = append(got, f.Name)
|
||||
}
|
||||
if want := "jellyfin fwd-8096-to-media web web-2 "; strings.Join(got, " ") != want {
|
||||
t.Errorf("names %q, want %q", strings.Join(got, " "), want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestImportedRulesetPassesNft: the ruleset rendered from the import is
|
||||
// accepted by the real nft (HOUSECARL_NFT, as in internal/render).
|
||||
func TestImportedRulesetPassesNft(t *testing.T) {
|
||||
cmd := strings.Fields(os.Getenv("HOUSECARL_NFT"))
|
||||
if len(cmd) == 0 {
|
||||
t.Skip("HOUSECARL_NFT not set")
|
||||
}
|
||||
rs := render.Render(importRouter(t).State, render.Options{})
|
||||
c := exec.Command(cmd[0], append(cmd[1:], "-c", "-f", "/dev/stdin")...)
|
||||
c.Stdin = strings.NewReader(rs.Text)
|
||||
if out, err := c.CombinedOutput(); err != nil {
|
||||
t.Errorf("nft -c: %v\n%s", err, out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestImportMaps: dnat maps become port forwards and verdict maps rules,
|
||||
// one per element (testdata/maps.nft); a map that can't be expanded is
|
||||
// left out with a note, never kept as a raw rule naming it.
|
||||
func TestImportMaps(t *testing.T) {
|
||||
js, _ := os.ReadFile("testdata/maps.json")
|
||||
txt, _ := os.ReadFile("testdata/maps.txt")
|
||||
res, err := Import(Input{JSON: js, Text: string(txt)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var fwds []string
|
||||
for _, f := range res.State.Forwards {
|
||||
fwds = append(fwds, fmt.Sprintf("%s %s/%s->%s:%s", f.Name, strings.Join(f.Protocols, "+"), f.Ports, f.To, f.ToPort))
|
||||
}
|
||||
var rules []string
|
||||
for _, r := range res.State.Rules {
|
||||
rules = append(rules, fmt.Sprintf("%s in=%s from=%s %s %s%s", r.Chain, strings.Join(r.InZones, ","), strings.Join(r.Sources, ","),
|
||||
strings.Join(r.Services, ","), r.Action, r.Raw))
|
||||
}
|
||||
want := []string{
|
||||
"portforwards-80 tcp/80->192.168.1.50:8080",
|
||||
"portforwards-443 tcp/443->192.168.1.50:",
|
||||
"hosts-2222 tcp/2222->192.168.1.60:",
|
||||
" udp/5000->192.168.1.70:5001",
|
||||
" udp/6000->192.168.1.71:",
|
||||
}
|
||||
if got := strings.Join(fwds, "\n"); got != strings.Join(want, "\n") {
|
||||
t.Errorf("forwards:\n%s\nwant:\n%s", got, strings.Join(want, "\n"))
|
||||
}
|
||||
// In order; the jump in the verdict map is inlined.
|
||||
want = []string{
|
||||
"input in= from= accept",
|
||||
"input in=lan from= tcp/22 accept",
|
||||
"input in=lan from= tcp/23 drop",
|
||||
"input in=lan from=192.168.1.0/24 tcp/80 accept",
|
||||
"input in=wan from= udp/53 accept",
|
||||
"input in=wan from= udp/123 drop",
|
||||
"input in= from=10.0.0.0/8 accept",
|
||||
"input in= from=192.0.2.1 drop",
|
||||
}
|
||||
if got := strings.Join(rules, "\n"); got != strings.Join(want, "\n") {
|
||||
t.Errorf("rules:\n%s\nwant:\n%s", got, strings.Join(want, "\n"))
|
||||
}
|
||||
notes := strings.Join(res.Notes, "\n")
|
||||
for _, n := range []string{"map portforwards: its 2 elements became 2 port forwards", "map hosts: its element became a port forward",
|
||||
"uses the map @pairs, which couldn't be expanded"} {
|
||||
if !strings.Contains(notes, n) {
|
||||
t.Errorf("note %q missing:\n%s", n, notes)
|
||||
}
|
||||
}
|
||||
if len(res.Raw) > 0 {
|
||||
t.Errorf("raw rules: %v", res.Raw)
|
||||
}
|
||||
if ps := validate.Errors(validate.Check(res.State, nil)); len(ps) > 0 {
|
||||
t.Errorf("invalid: %+v", ps)
|
||||
}
|
||||
if cmd := strings.Fields(os.Getenv("HOUSECARL_NFT")); len(cmd) > 0 {
|
||||
c := exec.Command(cmd[0], append(cmd[1:], "-c", "-f", "/dev/stdin")...)
|
||||
c.Stdin = strings.NewReader(render.Render(res.State, render.Options{}).Text)
|
||||
if out, err := c.CombinedOutput(); err != nil {
|
||||
t.Errorf("nft -c: %v\n%s", err, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestImportMeters: meters (per-address limits, connection counts and
|
||||
// quotas), byte rates and quotas become rules' limits and quotas
|
||||
// (testdata/meters.nft); the meters' sets aren't imported as address sets.
|
||||
func TestImportMeters(t *testing.T) {
|
||||
js, _ := os.ReadFile("testdata/meters.json")
|
||||
txt, _ := os.ReadFile("testdata/meters.txt")
|
||||
res, err := Import(Input{JSON: js, Text: string(txt)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got []string
|
||||
for _, r := range res.State.Rules {
|
||||
s := r.Chain
|
||||
if l := r.Limit; l != nil {
|
||||
s += fmt.Sprintf(" limit %d %s/%s burst %d over=%v src=%v dst=%v", l.Rate, l.Unit, l.Per, l.Burst, l.Over, l.PerSource, l.PerDestination)
|
||||
}
|
||||
if r.ConnLimit > 0 {
|
||||
s += fmt.Sprintf(" conn %d", r.ConnLimit)
|
||||
}
|
||||
if q := r.Quota; q != nil {
|
||||
s += fmt.Sprintf(" quota %d over=%v src=%v", q.Bytes, q.Over, q.PerSource)
|
||||
}
|
||||
if r.GroupV4 != 0 || r.GroupV6 != 0 {
|
||||
s += fmt.Sprintf(" group %d/%d", r.GroupV4, r.GroupV6)
|
||||
}
|
||||
s += " " + r.Action + r.Raw
|
||||
got = append(got, s)
|
||||
}
|
||||
want := []string{
|
||||
"input accept", // established: only in input here, so a rule
|
||||
"input limit 3 /minute burst 5 over=true src=true dst=false drop",
|
||||
"input limit 3 /minute burst 5 over=true src=true dst=false group 0/64 drop",
|
||||
"input accept",
|
||||
"forward conn 20 drop",
|
||||
"forward limit 10240 kbytes/second burst 512 over=true src=false dst=false drop",
|
||||
"forward quota 1048576000 over=true src=true group 24/0 drop",
|
||||
"forward quota 104857600000 over=true src=false drop",
|
||||
"forward accept",
|
||||
}
|
||||
if strings.Join(got, "\n") != strings.Join(want, "\n") {
|
||||
t.Errorf("rules:\n%s\nwant:\n%s\nnotes: %v", strings.Join(got, "\n"), strings.Join(want, "\n"), res.Notes)
|
||||
}
|
||||
for _, s := range res.State.Sets {
|
||||
if s.Name != "trusted" {
|
||||
t.Errorf("a meter became a set: %s", s.Name)
|
||||
}
|
||||
}
|
||||
if len(res.Raw) > 0 {
|
||||
t.Errorf("raw rules: %v", res.Raw)
|
||||
}
|
||||
if ps := validate.Errors(validate.Check(res.State, nil)); len(ps) > 0 {
|
||||
t.Errorf("invalid: %+v", ps)
|
||||
}
|
||||
if cmd := strings.Fields(os.Getenv("HOUSECARL_NFT")); len(cmd) > 0 {
|
||||
c := exec.Command(cmd[0], append(cmd[1:], "-c", "-f", "/dev/stdin")...)
|
||||
c.Stdin = strings.NewReader(render.Render(res.State, render.Options{}).Text)
|
||||
if out, err := c.CombinedOutput(); err != nil {
|
||||
t.Errorf("nft -c: %v\n%s", err, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"nftables": [{"metainfo": {"version": "1.1.3", "release_name": "Commodore Bullmoose #4", "json_schema_version": 1}}, {"table": {"family": "inet", "name": "main", "handle": 3}}, {"chain": {"family": "inet", "table": "main", "name": "web", "handle": 1}}, {"chain": {"family": "inet", "table": "main", "name": "prerouting", "handle": 2, "type": "nat", "hook": "prerouting", "prio": -100, "policy": "accept"}}, {"chain": {"family": "inet", "table": "main", "name": "input", "handle": 3, "type": "filter", "hook": "input", "prio": 0, "policy": "drop"}}, {"map": {"family": "inet", "name": "portforwards", "table": "main", "type": "inet_service", "handle": 4, "map": ["ipv4_addr", "inet_service"], "elem": [[80, {"concat": ["192.168.1.50", 8080]}], [443, {"concat": ["192.168.1.50", 443]}]]}}, {"map": {"family": "inet", "name": "hosts", "table": "main", "type": "inet_service", "handle": 5, "map": "ipv4_addr", "elem": [[2222, "192.168.1.60"]]}}, {"map": {"family": "inet", "name": "lan_services", "table": "main", "type": "inet_service", "handle": 6, "map": "verdict", "elem": [[22, {"accept": null}], [23, {"drop": null}], [80, {"jump": {"target": "web"}}]]}}, {"map": {"family": "inet", "name": "pairs", "table": "main", "type": ["ipv4_addr", "inet_service"], "handle": 7, "map": "verdict", "elem": [[{"concat": ["192.168.1.9", 22]}, {"accept": null}]]}}, {"rule": {"family": "inet", "table": "main", "chain": "web", "handle": 8, "expr": [{"match": {"op": "==", "left": {"payload": {"protocol": "ip", "field": "saddr"}}, "right": {"prefix": {"addr": "192.168.1.0", "len": 24}}}}, {"accept": null}]}}, {"rule": {"family": "inet", "table": "main", "chain": "prerouting", "handle": 9, "comment": "Port forwards", "expr": [{"match": {"op": "==", "left": {"meta": {"key": "iifname"}}, "right": "wan"}}, {"dnat": {"family": "ip", "addr": {"map": {"key": {"payload": {"protocol": "tcp", "field": "dport"}}, "data": "@portforwards"}}}}]}}, {"rule": {"family": "inet", "table": "main", "chain": "prerouting", "handle": 10, "expr": [{"match": {"op": "==", "left": {"meta": {"key": "iifname"}}, "right": "wan"}}, {"dnat": {"family": "ip", "addr": {"map": {"key": {"payload": {"protocol": "tcp", "field": "dport"}}, "data": "@hosts"}}}}]}}, {"rule": {"family": "inet", "table": "main", "chain": "prerouting", "handle": 12, "expr": [{"match": {"op": "==", "left": {"meta": {"key": "iifname"}}, "right": "wan"}}, {"dnat": {"family": "ip", "addr": {"map": {"key": {"payload": {"protocol": "udp", "field": "dport"}}, "data": {"set": [[5000, {"concat": ["192.168.1.70", 5001]}], [6000, {"concat": ["192.168.1.71", 6000]}]]}}}}}]}}, {"rule": {"family": "inet", "table": "main", "chain": "input", "handle": 13, "expr": [{"match": {"op": "in", "left": {"ct": {"key": "state"}}, "right": ["established", "related"]}}, {"accept": null}]}}, {"rule": {"family": "inet", "table": "main", "chain": "input", "handle": 14, "expr": [{"match": {"op": "==", "left": {"meta": {"key": "iifname"}}, "right": "lan"}}, {"vmap": {"key": {"payload": {"protocol": "tcp", "field": "dport"}}, "data": "@lan_services"}}]}}, {"rule": {"family": "inet", "table": "main", "chain": "input", "handle": 16, "expr": [{"match": {"op": "==", "left": {"meta": {"key": "iifname"}}, "right": "wan"}}, {"vmap": {"key": {"payload": {"protocol": "udp", "field": "dport"}}, "data": {"set": [[53, {"accept": null}], [123, {"drop": null}]]}}}]}}, {"rule": {"family": "inet", "table": "main", "chain": "input", "handle": 18, "expr": [{"vmap": {"key": {"payload": {"protocol": "ip", "field": "saddr"}}, "data": {"set": [[{"prefix": {"addr": "10.0.0.0", "len": 8}}, {"accept": null}], ["192.0.2.1", {"drop": null}]]}}}]}}, {"rule": {"family": "inet", "table": "main", "chain": "input", "handle": 19, "expr": [{"vmap": {"key": {"concat": [{"payload": {"protocol": "ip", "field": "saddr"}}, {"payload": {"protocol": "tcp", "field": "dport"}}]}, "data": "@pairs"}}]}}]}
|
||||
Vendored
+36
@@ -0,0 +1,36 @@
|
||||
# Maps and verdict maps, as found in hand-written rulesets (importer_test.go).
|
||||
table inet main {
|
||||
map portforwards {
|
||||
type inet_service : ipv4_addr . inet_service
|
||||
elements = { 443 : 192.168.1.50 . 443, 80 : 192.168.1.50 . 8080 }
|
||||
}
|
||||
map hosts {
|
||||
type inet_service : ipv4_addr
|
||||
elements = { 2222 : 192.168.1.60 }
|
||||
}
|
||||
map lan_services {
|
||||
type inet_service : verdict
|
||||
elements = { 22 : accept, 23 : drop, 80 : jump web }
|
||||
}
|
||||
map pairs {
|
||||
type ipv4_addr . inet_service : verdict
|
||||
elements = { 192.168.1.9 . 22 : accept }
|
||||
}
|
||||
chain web {
|
||||
ip saddr 192.168.1.0/24 accept
|
||||
}
|
||||
chain prerouting {
|
||||
type nat hook prerouting priority dstnat; policy accept;
|
||||
iifname "wan" dnat ip to tcp dport map @portforwards comment "Port forwards"
|
||||
iifname "wan" dnat ip to tcp dport map @hosts
|
||||
iifname "wan" dnat ip to udp dport map { 5000 : 192.168.1.70 . 5001, 6000 : 192.168.1.71 . 6000 }
|
||||
}
|
||||
chain input {
|
||||
type filter hook input priority filter; policy drop;
|
||||
ct state established,related accept
|
||||
iifname "lan" tcp dport vmap @lan_services
|
||||
iifname "wan" udp dport vmap { 53 : accept, 123 : drop }
|
||||
ip saddr vmap { 10.0.0.0/8 : accept, 192.0.2.1 : drop }
|
||||
ip saddr . tcp dport vmap @pairs
|
||||
}
|
||||
}
|
||||
Vendored
+44
@@ -0,0 +1,44 @@
|
||||
table inet main { # handle 3
|
||||
map portforwards { # handle 4
|
||||
type inet_service : ipv4_addr . inet_service
|
||||
elements = { 80 : 192.168.1.50 . 8080,
|
||||
443 : 192.168.1.50 . 443 }
|
||||
}
|
||||
|
||||
map hosts { # handle 5
|
||||
type inet_service : ipv4_addr
|
||||
elements = { 2222 : 192.168.1.60 }
|
||||
}
|
||||
|
||||
map lan_services { # handle 6
|
||||
type inet_service : verdict
|
||||
elements = { 22 : accept,
|
||||
23 : drop,
|
||||
80 : jump web }
|
||||
}
|
||||
|
||||
map pairs { # handle 7
|
||||
type ipv4_addr . inet_service : verdict
|
||||
elements = { 192.168.1.9 . 22 : accept }
|
||||
}
|
||||
|
||||
chain web { # handle 1
|
||||
ip saddr 192.168.1.0/24 accept # handle 8
|
||||
}
|
||||
|
||||
chain prerouting { # handle 2
|
||||
type nat hook prerouting priority dstnat; policy accept;
|
||||
iifname "wan" dnat ip to tcp dport map @portforwards comment "Port forwards" # handle 9
|
||||
iifname "wan" dnat ip to tcp dport map @hosts # handle 10
|
||||
iifname "wan" dnat ip to udp dport map { 5000 : 192.168.1.70 . 5001, 6000 : 192.168.1.71 . 6000 } # handle 12
|
||||
}
|
||||
|
||||
chain input { # handle 3
|
||||
type filter hook input priority filter; policy drop;
|
||||
ct state established,related accept # handle 13
|
||||
iifname "lan" tcp dport vmap @lan_services # handle 14
|
||||
iifname "wan" udp dport vmap { 53 : accept, 123 : drop } # handle 16
|
||||
ip saddr vmap { 10.0.0.0/8 : accept, 192.0.2.1 : drop } # handle 18
|
||||
ip saddr . tcp dport vmap @pairs # handle 19
|
||||
}
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"nftables": [{"metainfo": {"version": "1.1.3", "release_name": "Commodore Bullmoose #4", "json_schema_version": 1}}, {"table": {"family": "inet", "name": "main", "handle": 1}}, {"chain": {"family": "inet", "table": "main", "name": "input", "handle": 1, "type": "filter", "hook": "input", "prio": 0, "policy": "drop"}}, {"chain": {"family": "inet", "table": "main", "name": "forward", "handle": 2, "type": "filter", "hook": "forward", "prio": 0, "policy": "drop"}}, {"chain": {"family": "inet", "table": "main", "name": "output", "handle": 3, "type": "filter", "hook": "output", "prio": 0, "policy": "accept"}}, {"set": {"family": "inet", "name": "ssh_meter", "table": "main", "type": "ipv4_addr", "handle": 4, "size": 65535, "flags": ["timeout", "dynamic"], "timeout": 60}}, {"set": {"family": "inet", "name": "ssh6_meter", "table": "main", "type": "ipv6_addr", "handle": 5, "size": 65535, "flags": ["timeout", "dynamic"], "timeout": 60}}, {"set": {"family": "inet", "name": "my_connlimit", "table": "main", "type": "ipv4_addr", "handle": 6, "size": 65535, "flags": ["dynamic"]}}, {"set": {"family": "inet", "name": "guest_quota", "table": "main", "type": "ipv4_addr", "handle": 7, "size": 65535, "flags": ["dynamic"]}}, {"set": {"family": "inet", "name": "trusted", "table": "main", "type": "ipv4_addr", "handle": 8, "flags": ["interval"], "elem": [{"prefix": {"addr": "10.0.10.0", "len": 24}}]}}, {"rule": {"family": "inet", "table": "main", "chain": "input", "handle": 9, "expr": [{"match": {"op": "in", "left": {"ct": {"key": "state"}}, "right": ["established", "related"]}}, {"accept": null}]}}, {"rule": {"family": "inet", "table": "main", "chain": "input", "handle": 10, "expr": [{"match": {"op": "==", "left": {"payload": {"protocol": "tcp", "field": "dport"}}, "right": 22}}, {"match": {"op": "in", "left": {"ct": {"key": "state"}}, "right": "new"}}, {"set": {"op": "update", "elem": {"payload": {"protocol": "ip", "field": "saddr"}}, "set": "@ssh_meter", "stmt": [{"limit": {"rate": 3, "burst": 5, "per": "minute", "inv": true}}]}}, {"counter": {"packets": 0, "bytes": 0}}, {"drop": null}]}}, {"rule": {"family": "inet", "table": "main", "chain": "input", "handle": 11, "expr": [{"match": {"op": "==", "left": {"payload": {"protocol": "tcp", "field": "dport"}}, "right": 22}}, {"match": {"op": "in", "left": {"ct": {"key": "state"}}, "right": "new"}}, {"set": {"op": "update", "elem": {"&": [{"payload": {"protocol": "ip6", "field": "saddr"}}, "ffff:ffff:ffff:ffff::"]}, "set": "@ssh6_meter", "stmt": [{"limit": {"rate": 3, "burst": 5, "per": "minute", "inv": true}}]}}, {"counter": {"packets": 0, "bytes": 0}}, {"drop": null}]}}, {"rule": {"family": "inet", "table": "main", "chain": "input", "handle": 12, "expr": [{"match": {"op": "==", "left": {"payload": {"protocol": "ip", "field": "saddr"}}, "right": "@trusted"}}, {"match": {"op": "==", "left": {"payload": {"protocol": "tcp", "field": "dport"}}, "right": 22}}, {"accept": null}]}}, {"rule": {"family": "inet", "table": "main", "chain": "forward", "handle": 13, "expr": [{"match": {"op": "in", "left": {"ct": {"key": "state"}}, "right": "new"}}, {"set": {"op": "add", "elem": {"payload": {"protocol": "ip", "field": "saddr"}}, "set": "@my_connlimit", "stmt": [{"ct count": {"val": 20, "inv": true}}]}}, {"counter": {"packets": 0, "bytes": 0}}, {"drop": null}]}}, {"rule": {"family": "inet", "table": "main", "chain": "forward", "handle": 14, "expr": [{"match": {"op": "==", "left": {"meta": {"key": "iifname"}}, "right": "lan"}}, {"match": {"op": "==", "left": {"meta": {"key": "oifname"}}, "right": "wan"}}, {"limit": {"rate": 10, "burst": 512, "per": "second", "inv": true, "rate_unit": "mbytes", "burst_unit": "kbytes"}}, {"drop": null}]}}, {"rule": {"family": "inet", "table": "main", "chain": "forward", "handle": 15, "expr": [{"match": {"op": "==", "left": {"meta": {"key": "iifname"}}, "right": "guest0"}}, {"match": {"op": "==", "left": {"meta": {"key": "oifname"}}, "right": "wan"}}, {"set": {"op": "add", "elem": {"&": [{"payload": {"protocol": "ip", "field": "saddr"}}, "255.255.255.0"]}, "set": "@guest_quota", "stmt": [{"quota": {"val": 1000, "val_unit": "mbytes", "inv": true}}]}}, {"drop": null}]}}, {"rule": {"family": "inet", "table": "main", "chain": "forward", "handle": 16, "expr": [{"match": {"op": "==", "left": {"meta": {"key": "iifname"}}, "right": "lan"}}, {"match": {"op": "==", "left": {"meta": {"key": "oifname"}}, "right": "wan"}}, {"quota": {"val": 100000, "val_unit": "mbytes", "inv": true}}, {"drop": null}]}}, {"rule": {"family": "inet", "table": "main", "chain": "forward", "handle": 17, "expr": [{"match": {"op": "==", "left": {"meta": {"key": "iifname"}}, "right": "lan"}}, {"match": {"op": "==", "left": {"meta": {"key": "oifname"}}, "right": "wan"}}, {"accept": null}]}}]}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
# Meters, byte rates and quotas, as hand-written rulesets use them (importer_test.go).
|
||||
table inet main {
|
||||
set ssh_meter {
|
||||
type ipv4_addr
|
||||
flags dynamic,timeout
|
||||
timeout 1m
|
||||
}
|
||||
set ssh6_meter {
|
||||
type ipv6_addr
|
||||
flags dynamic,timeout
|
||||
timeout 1m
|
||||
}
|
||||
set my_connlimit {
|
||||
type ipv4_addr
|
||||
size 65535
|
||||
flags dynamic
|
||||
}
|
||||
set guest_quota {
|
||||
type ipv4_addr
|
||||
size 65535
|
||||
flags dynamic
|
||||
}
|
||||
set trusted {
|
||||
type ipv4_addr
|
||||
flags interval
|
||||
elements = { 10.0.10.0/24 }
|
||||
}
|
||||
chain input {
|
||||
type filter hook input priority filter; policy drop;
|
||||
ct state established,related accept
|
||||
tcp dport 22 ct state new update @ssh_meter { ip saddr limit rate over 3/minute burst 5 packets } counter drop
|
||||
tcp dport 22 ct state new update @ssh6_meter { ip6 saddr and ffff:ffff:ffff:ffff:: limit rate over 3/minute } counter drop
|
||||
ip saddr @trusted tcp dport 22 accept
|
||||
}
|
||||
chain forward {
|
||||
type filter hook forward priority filter; policy drop;
|
||||
ct state new add @my_connlimit { ip saddr ct count over 20 } counter drop
|
||||
iifname "lan" oifname "wan" limit rate over 10 mbytes/second burst 512 kbytes drop
|
||||
iifname "guest0" oifname "wan" add @guest_quota { ip saddr and 255.255.255.0 quota over 1000 mbytes } drop
|
||||
iifname "lan" oifname "wan" quota over 100000 mbytes drop
|
||||
iifname "lan" oifname "wan" accept
|
||||
}
|
||||
chain output {
|
||||
type filter hook output priority filter; policy accept;
|
||||
}
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
table inet main { # handle 1
|
||||
set ssh_meter { # handle 4
|
||||
type ipv4_addr
|
||||
size 65535
|
||||
flags dynamic,timeout
|
||||
timeout 1m
|
||||
}
|
||||
|
||||
set ssh6_meter { # handle 5
|
||||
type ipv6_addr
|
||||
size 65535
|
||||
flags dynamic,timeout
|
||||
timeout 1m
|
||||
}
|
||||
|
||||
set my_connlimit { # handle 6
|
||||
type ipv4_addr
|
||||
size 65535
|
||||
flags dynamic
|
||||
}
|
||||
|
||||
set guest_quota { # handle 7
|
||||
type ipv4_addr
|
||||
size 65535
|
||||
flags dynamic
|
||||
}
|
||||
|
||||
set trusted { # handle 8
|
||||
type ipv4_addr
|
||||
flags interval
|
||||
elements = { 10.0.10.0/24 }
|
||||
}
|
||||
|
||||
chain input { # handle 1
|
||||
type filter hook input priority filter; policy drop;
|
||||
ct state established,related accept # handle 9
|
||||
tcp dport 22 ct state new update @ssh_meter { ip saddr limit rate over 3/minute burst 5 packets } counter packets 0 bytes 0 drop # handle 10
|
||||
tcp dport 22 ct state new update @ssh6_meter { ip6 saddr & ffff:ffff:ffff:ffff:: limit rate over 3/minute burst 5 packets } counter packets 0 bytes 0 drop # handle 11
|
||||
ip saddr @trusted tcp dport 22 accept # handle 12
|
||||
}
|
||||
|
||||
chain forward { # handle 2
|
||||
type filter hook forward priority filter; policy drop;
|
||||
ct state new add @my_connlimit { ip saddr ct count over 20 } counter packets 0 bytes 0 drop # handle 13
|
||||
iifname "lan" oifname "wan" limit rate over 10 mbytes/second burst 512 kbytes drop # handle 14
|
||||
iifname "guest0" oifname "wan" add @guest_quota { ip saddr & 255.255.255.0 quota over 1000 mbytes } drop # handle 15
|
||||
iifname "lan" oifname "wan" quota over 100000 mbytes drop # handle 16
|
||||
iifname "lan" oifname "wan" accept # handle 17
|
||||
}
|
||||
|
||||
chain output { # handle 3
|
||||
type filter hook output priority filter; policy accept;
|
||||
}
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"nftables": [{"metainfo": {"version": "1.1.3", "release_name": "Commodore Bullmoose #4", "json_schema_version": 1}}, {"table": {"family": "inet", "name": "main", "handle": 1}}, {"chain": {"family": "inet", "table": "main", "name": "antispoof", "handle": 1, "type": "filter", "hook": "prerouting", "prio": -300, "policy": "accept"}}, {"chain": {"family": "inet", "table": "main", "name": "input", "handle": 2, "type": "filter", "hook": "input", "prio": 0, "policy": "drop"}}, {"chain": {"family": "inet", "table": "main", "name": "wg0_input", "handle": 3}}, {"chain": {"family": "inet", "table": "main", "name": "prerouting", "handle": 4, "type": "nat", "hook": "prerouting", "prio": -100, "policy": "accept"}}, {"chain": {"family": "inet", "table": "main", "name": "postrouting", "handle": 5, "type": "nat", "hook": "postrouting", "prio": 100, "policy": "accept"}}, {"chain": {"family": "inet", "table": "main", "name": "forward", "handle": 6, "type": "filter", "hook": "forward", "prio": 0, "policy": "drop"}}, {"chain": {"family": "inet", "table": "main", "name": "output", "handle": 7, "type": "filter", "hook": "output", "prio": 0, "policy": "accept"}}, {"rule": {"family": "inet", "table": "main", "chain": "antispoof", "handle": 9, "comment": "Private/bogus source addresses can't legitimately arrive from the internet", "expr": [{"match": {"op": "==", "left": {"meta": {"key": "iifname"}}, "right": "wan"}}, {"match": {"op": "==", "left": {"payload": {"protocol": "ip", "field": "saddr"}}, "right": {"set": [{"prefix": {"addr": "0.0.0.0", "len": 8}}, {"prefix": {"addr": "10.0.0.0", "len": 8}}, {"prefix": {"addr": "127.0.0.0", "len": 8}}, {"prefix": {"addr": "169.254.0.0", "len": 16}}, {"prefix": {"addr": "172.16.0.0", "len": 12}}, {"prefix": {"addr": "192.168.0.0", "len": 16}}]}}}, {"counter": {"packets": 0, "bytes": 0}}, {"drop": null}]}}, {"rule": {"family": "inet", "table": "main", "chain": "antispoof", "handle": 10, "comment": "Our own delegated prefix can't legitimately arrive from the internet", "expr": [{"match": {"op": "==", "left": {"meta": {"key": "iifname"}}, "right": "wan"}}, {"match": {"op": "==", "left": {"payload": {"protocol": "ip6", "field": "saddr"}}, "right": {"prefix": {"addr": "2a02:8013:7f3d::", "len": 48}}}}, {"counter": {"packets": 0, "bytes": 0}}, {"drop": null}]}}, {"rule": {"family": "inet", "table": "main", "chain": "input", "handle": 11, "expr": [{"match": {"op": "==", "left": {"meta": {"key": "iifname"}}, "right": "lo"}}, {"accept": null}]}}, {"rule": {"family": "inet", "table": "main", "chain": "input", "handle": 12, "expr": [{"match": {"op": "in", "left": {"ct": {"key": "state"}}, "right": ["established", "related"]}}, {"accept": null}]}}, {"rule": {"family": "inet", "table": "main", "chain": "input", "handle": 13, "expr": [{"match": {"op": "in", "left": {"ct": {"key": "state"}}, "right": "invalid"}}, {"drop": null}]}}, {"rule": {"family": "inet", "table": "main", "chain": "input", "handle": 15, "expr": [{"match": {"op": "!=", "left": {"meta": {"key": "iifname"}}, "right": "wan"}}, {"match": {"op": "==", "left": {"payload": {"protocol": "icmp", "field": "type"}}, "right": {"set": ["echo-reply", "echo-request"]}}}, {"counter": {"packets": 0, "bytes": 0}}, {"accept": null}]}}, {"rule": {"family": "inet", "table": "main", "chain": "input", "handle": 17, "expr": [{"match": {"op": "==", "left": {"payload": {"protocol": "icmpv6", "field": "type"}}, "right": {"set": ["destination-unreachable", "packet-too-big", "time-exceeded", "parameter-problem", "echo-request", "echo-reply"]}}}, {"counter": {"packets": 0, "bytes": 0}}, {"accept": null}]}}, {"rule": {"family": "inet", "table": "main", "chain": "input", "handle": 19, "comment": "IPv6 neighbour discovery", "expr": [{"match": {"op": "==", "left": {"payload": {"protocol": "icmpv6", "field": "type"}}, "right": {"set": ["nd-router-solicit", "nd-router-advert", "nd-neighbor-solicit", "nd-neighbor-advert"]}}}, {"match": {"op": "==", "left": {"payload": {"protocol": "ip6", "field": "hoplimit"}}, "right": 255}}, {"accept": null}]}}, {"rule": {"family": "inet", "table": "main", "chain": "input", "handle": 20, "comment": "DHCPv6 replies from Zen", "expr": [{"match": {"op": "==", "left": {"meta": {"key": "iifname"}}, "right": "wan"}}, {"match": {"op": "==", "left": {"payload": {"protocol": "udp", "field": "dport"}}, "right": 546}}, {"match": {"op": "==", "left": {"payload": {"protocol": "ip6", "field": "saddr"}}, "right": {"prefix": {"addr": "fe80::", "len": 10}}}}, {"accept": null}]}}, {"rule": {"family": "inet", "table": "main", "chain": "input", "handle": 21, "comment": "Accept new incoming wireguard connections", "expr": [{"match": {"op": "==", "left": {"payload": {"protocol": "udp", "field": "dport"}}, "right": 51820}}, {"match": {"op": "in", "left": {"ct": {"key": "state"}}, "right": "new"}}, {"accept": null}]}}, {"rule": {"family": "inet", "table": "main", "chain": "input", "handle": 22, "expr": [{"match": {"op": "==", "left": {"payload": {"protocol": "Line truncated
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
#! /sbin/nft -f
|
||||
|
||||
flush ruleset
|
||||
|
||||
define wan_int = "wan"
|
||||
define wan_ip = 217.155.8.194
|
||||
define allowed_icmp_types = { echo-request, echo-reply }
|
||||
define allowed_icmpv6_types = { destination-unreachable, packet-too-big, time-exceeded, parameter-problem, echo-request, echo-reply }
|
||||
define trusted_ips = { 10.0.10.0/24, 10.20.30.0/24, 10.55.55.0/24, 10.1.1.0/24, 10.65.65.0/24, 10.60.60.0/24, 10.50.50.0/24}
|
||||
define masq_to_internet = { 10.0.10.0/24, 10.20.30.0/24, 10.55.55.0/24, 10.1.1.0/24, 10.65.65.0/24, 10.60.60.0/24 }
|
||||
define bogons = { 0.0.0.0/8, 10.0.0.0/8, 127.0.0.0/8, 169.254.0.0/16, 172.16.0.0/12, 192.168.0.0/16 }
|
||||
define internal_net = 10.0.10.0/24
|
||||
define lan6_net = 2a02:8013:7f3d:10::/64
|
||||
define my_prefix6 = 2a02:8013:7f3d::/48
|
||||
define wg0_int = wg0
|
||||
define wg0_ip = 10.50.50.1
|
||||
define wg0_net = 10.50.50.0/24
|
||||
define ekko_wg0 = 10.50.50.2
|
||||
define epcc_wg0 = 10.50.50.3
|
||||
define oneplus9_wg0 = 10.50.50.4
|
||||
define dhcp_int = enp2s0
|
||||
define heimer_incus = 10.20.30.0/24
|
||||
define garen_incus = 10.55.55.0/24
|
||||
define jellyfin_ip = 10.0.10.57
|
||||
define jellyfin_port = 8096
|
||||
define haproxy_ip = 10.0.10.37
|
||||
define checkmk_server = 10.20.30.58
|
||||
define oracle_wg0 = 10.50.50.6/32
|
||||
define slskd_ip = 10.0.10.35
|
||||
define slskd_port = 50300
|
||||
define terraria_port = { 7777 }
|
||||
define ula_prefix = fdab:abab:abab::/48
|
||||
define lan6_ula = fdab:abab:abab:10::/64
|
||||
define dot_doh_ports = { 8443, 853 }
|
||||
define dot_doh_ip4 = { 10.0.10.5, 217.155.8.194 }
|
||||
define dot_doh_ip6 = { fdab:abab:abab:10::5, 2a02:8013:7f3d:10::5 }
|
||||
|
||||
define jf_ipv6 = 2a02:8013:7f3d:10::57
|
||||
|
||||
table inet main {
|
||||
chain antispoof {
|
||||
type filter hook prerouting priority raw; policy accept;
|
||||
iifname $wan_int ip saddr $bogons counter drop comment "Private/bogus source addresses can't legitimately arrive from the internet"
|
||||
iifname $wan_int ip6 saddr $my_prefix6 counter drop comment "Our own delegated prefix can't legitimately arrive from the internet"
|
||||
}
|
||||
|
||||
chain input {
|
||||
type filter hook input priority filter; policy drop;
|
||||
|
||||
iifname lo accept
|
||||
|
||||
ct state established,related accept
|
||||
ct state invalid drop
|
||||
|
||||
iifname != $wan_int icmp type $allowed_icmp_types counter accept
|
||||
icmpv6 type $allowed_icmpv6_types counter accept
|
||||
icmpv6 type { nd-router-solicit, nd-router-advert, nd-neighbor-solicit, nd-neighbor-advert } ip6 hoplimit 255 accept comment "IPv6 neighbour discovery"
|
||||
iifname $wan_int udp dport 546 ip6 saddr fe80::/10 accept comment "DHCPv6 replies from Zen"
|
||||
|
||||
udp dport 51820 ct state new accept comment "Accept new incoming wireguard connections"
|
||||
|
||||
ip saddr $wg0_net jump wg0_input
|
||||
|
||||
udp dport 53 accept comment "Accept DNS Traffic UDP"
|
||||
tcp dport 53 accept comment "Accept DNS Traffic TCP"
|
||||
tcp dport $dot_doh_ports ip daddr $dot_doh_ip4 accept comment "Accept DoT and DoH Traffic"
|
||||
tcp dport $dot_doh_ports ip6 daddr $dot_doh_ip6 accept comment "Accept DoT and DoH Traffic"
|
||||
#ip saddr $trusted_ips udp dport 53 accept comment "Accept DNS Traffic UDP"
|
||||
#ip saddr $trusted_ips tcp dport 53 accept comment "Accept DNS Traffic TCP"
|
||||
ip saddr $trusted_ips tcp dport 8053 accept comment "Accept Jarl traffic TCP"
|
||||
ip saddr $trusted_ips tcp dport 8067 accept comment "Accept Steward traffic TCP"
|
||||
ip saddr $trusted_ips tcp dport 22 accept
|
||||
|
||||
ip6 saddr $lan6_net udp dport 53 accept comment "Accept DNS Traffic UDP (IPv6)"
|
||||
ip6 saddr $lan6_net tcp dport 53 accept comment "Accept DNS Traffic TCP (IPv6)"
|
||||
ip6 saddr $lan6_net tcp dport 22 accept
|
||||
|
||||
iifname $dhcp_int udp dport 67 accept comment "Accept DHCP"
|
||||
iifname $dhcp_int udp dport 547 ip6 saddr fe80::/10 accept comment "Accept DHCPv6"
|
||||
ip saddr $checkmk_server tcp dport 6556 accept comment "From Checkmk To Agent"
|
||||
}
|
||||
|
||||
chain wg0_input {
|
||||
udp dport 53 accept
|
||||
tcp dport 53 accept
|
||||
ip saddr { $epcc_wg0, $oracle_wg0 } tcp dport 22 accept
|
||||
ip saddr $wg0_net ip daddr $wg0_ip drop comment "Block WireGuard clients from accessing the server itself"
|
||||
return
|
||||
}
|
||||
|
||||
chain prerouting {
|
||||
type nat hook prerouting priority dstnat; policy accept;
|
||||
# Matching on the public IP (not the WAN interface) covers both internet traffic and hairpin traffic from inside
|
||||
ip daddr $wan_ip tcp dport $jellyfin_port dnat ip to $jellyfin_ip:443 comment "Port Forward Jellyfin"
|
||||
ip daddr $wan_ip tcp dport 443 dnat ip to $haproxy_ip:443 comment "Port Forward Haproxy"
|
||||
ip daddr $wan_ip tcp dport $slskd_port dnat ip to $slskd_ip:$slskd_port comment "Port Forward soulseek"
|
||||
ip daddr $wan_ip tcp dport $terraria_port dnat ip to 10.0.10.76 comment "Port Forward terraria"
|
||||
ip daddr $wan_ip tcp dport 25 dnat ip to 10.0.10.25 comment "Port Forward SMTP"
|
||||
}
|
||||
|
||||
chain postrouting {
|
||||
type nat hook postrouting priority srcnat; policy accept;
|
||||
oifname $wan_int ip saddr $masq_to_internet masquerade comment "external masquerade"
|
||||
oifname != $wg0_int ip saddr $wg0_net masquerade
|
||||
# Same-subnet clients would otherwise get replies direct from the server, bypassing the router's NAT
|
||||
ip saddr $internal_net ip daddr { $haproxy_ip, $jellyfin_ip, $slskd_ip, 10.0.10.76, 10.0.10.25 } ct status dnat masquerade comment "Hairpin NAT same-subnet"
|
||||
}
|
||||
|
||||
chain forward {
|
||||
type filter hook forward priority filter; policy drop;
|
||||
|
||||
# PPPoE MTU is smaller than ethernet; must sit before the established rule so SYN-ACKs get clamped too
|
||||
oifname $wan_int tcp flags syn tcp option maxseg size set rt mtu comment "PPPoE MSS clamping"
|
||||
|
||||
ct state established,related accept
|
||||
iifname != $wan_int ct state invalid ip daddr $trusted_ips accept comment "Needed due to asymmetric routing (internal only)"
|
||||
ct state invalid drop
|
||||
|
||||
iifname $dhcp_int oifname $wan_int ip6 saddr $lan6_net accept comment "LAN IPv6 out"
|
||||
|
||||
# From the internet, only allow traffic that was port forwarded
|
||||
iifname $wan_int ip daddr $jellyfin_ip tcp dport 443 ct status dnat accept comment "Forward jellyfin traffic"
|
||||
iifname $wan_int ip daddr $haproxy_ip tcp dport 443 ct status dnat accept comment "Forward Haproxy traffic"
|
||||
iifname $wan_int ip daddr $slskd_ip tcp dport $slskd_port ct status dnat accept comment "Forward slskd traffic"
|
||||
iifname $wan_int ip daddr 10.0.10.76 tcp dport $terraria_port ct status dnat accept comment "Forward terraria traffic"
|
||||
iifname $wan_int ip daddr 10.0.10.25 tcp dport 25 ct status dnat accept comment "Forward SMTP"
|
||||
ip6 daddr $jf_ipv6 tcp dport 443 accept
|
||||
|
||||
iifname $wan_int counter drop comment "Nothing else gets in from the internet"
|
||||
|
||||
ip daddr $wg0_net drop comment "Drop client-to-client traffic for wg0 network"
|
||||
icmp type $allowed_icmp_types accept
|
||||
|
||||
ip saddr $wg0_net accept comment "Forward from wg0 network"
|
||||
ip saddr $trusted_ips accept comment "Forward from trusted IPs"
|
||||
|
||||
}
|
||||
|
||||
chain output {
|
||||
type filter hook output priority filter; policy accept;
|
||||
}
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
table inet main { # handle 1
|
||||
chain antispoof { # handle 1
|
||||
type filter hook prerouting priority raw; policy accept;
|
||||
iifname "wan" ip saddr { 0.0.0.0/8, 10.0.0.0/8, 127.0.0.0/8, 169.254.0.0/16, 172.16.0.0/12, 192.168.0.0/16 } counter packets 0 bytes 0 drop comment "Private/bogus source addresses can't legitimately arrive from the internet" # handle 9
|
||||
iifname "wan" ip6 saddr 2a02:8013:7f3d::/48 counter packets 0 bytes 0 drop comment "Our own delegated prefix can't legitimately arrive from the internet" # handle 10
|
||||
}
|
||||
|
||||
chain input { # handle 2
|
||||
type filter hook input priority filter; policy drop;
|
||||
iifname "lo" accept # handle 11
|
||||
ct state established,related accept # handle 12
|
||||
ct state invalid drop # handle 13
|
||||
iifname != "wan" icmp type { echo-reply, echo-request } counter packets 0 bytes 0 accept # handle 15
|
||||
icmpv6 type { destination-unreachable, packet-too-big, time-exceeded, parameter-problem, echo-request, echo-reply } counter packets 0 bytes 0 accept # handle 17
|
||||
icmpv6 type { nd-router-solicit, nd-router-advert, nd-neighbor-solicit, nd-neighbor-advert } ip6 hoplimit 255 accept comment "IPv6 neighbour discovery" # handle 19
|
||||
iifname "wan" udp dport 546 ip6 saddr fe80::/10 accept comment "DHCPv6 replies from Zen" # handle 20
|
||||
udp dport 51820 ct state new accept comment "Accept new incoming wireguard connections" # handle 21
|
||||
ip saddr 10.50.50.0/24 jump wg0_input # handle 22
|
||||
udp dport 53 accept comment "Accept DNS Traffic UDP" # handle 23
|
||||
tcp dport 53 accept comment "Accept DNS Traffic TCP" # handle 24
|
||||
tcp dport { 853, 8443 } ip daddr { 10.0.10.5, 217.155.8.194 } accept comment "Accept DoT and DoH Traffic" # handle 27
|
||||
tcp dport { 853, 8443 } ip6 daddr { 2a02:8013:7f3d:10::5, fdab:abab:abab:10::5 } accept comment "Accept DoT and DoH Traffic" # handle 30
|
||||
ip saddr { 10.0.10.0/24, 10.1.1.0/24, 10.20.30.0/24, 10.50.50.0/24, 10.55.55.0/24, 10.60.60.0/24, 10.65.65.0/24 } tcp dport 8053 accept comment "Accept Jarl traffic TCP" # handle 32
|
||||
ip saddr { 10.0.10.0/24, 10.1.1.0/24, 10.20.30.0/24, 10.50.50.0/24, 10.55.55.0/24, 10.60.60.0/24, 10.65.65.0/24 } tcp dport 8067 accept comment "Accept Steward traffic TCP" # handle 34
|
||||
ip saddr { 10.0.10.0/24, 10.1.1.0/24, 10.20.30.0/24, 10.50.50.0/24, 10.55.55.0/24, 10.60.60.0/24, 10.65.65.0/24 } tcp dport 22 accept # handle 36
|
||||
ip6 saddr 2a02:8013:7f3d:10::/64 udp dport 53 accept comment "Accept DNS Traffic UDP (IPv6)" # handle 37
|
||||
ip6 saddr 2a02:8013:7f3d:10::/64 tcp dport 53 accept comment "Accept DNS Traffic TCP (IPv6)" # handle 38
|
||||
ip6 saddr 2a02:8013:7f3d:10::/64 tcp dport 22 accept # handle 39
|
||||
iifname "enp2s0" udp dport 67 accept comment "Accept DHCP" # handle 40
|
||||
iifname "enp2s0" udp dport 547 ip6 saddr fe80::/10 accept comment "Accept DHCPv6" # handle 41
|
||||
ip saddr 10.20.30.58 tcp dport 6556 accept comment "From Checkmk To Agent" # handle 42
|
||||
}
|
||||
|
||||
chain wg0_input { # handle 3
|
||||
udp dport 53 accept # handle 43
|
||||
tcp dport 53 accept # handle 44
|
||||
ip saddr { 10.50.50.3, 10.50.50.6 } tcp dport 22 accept # handle 46
|
||||
ip saddr 10.50.50.0/24 ip daddr 10.50.50.1 drop comment "Block WireGuard clients from accessing the server itself" # handle 47
|
||||
return # handle 48
|
||||
}
|
||||
|
||||
chain prerouting { # handle 4
|
||||
type nat hook prerouting priority dstnat; policy accept;
|
||||
ip daddr 217.155.8.194 tcp dport 8096 dnat ip to 10.0.10.57:443 comment "Port Forward Jellyfin" # handle 49
|
||||
ip daddr 217.155.8.194 tcp dport 443 dnat ip to 10.0.10.37:443 comment "Port Forward Haproxy" # handle 50
|
||||
ip daddr 217.155.8.194 tcp dport 50300 dnat ip to 10.0.10.35:50300 comment "Port Forward soulseek" # handle 51
|
||||
ip daddr 217.155.8.194 tcp dport 7777 dnat ip to 10.0.10.76 comment "Port Forward terraria" # handle 52
|
||||
ip daddr 217.155.8.194 tcp dport 25 dnat ip to 10.0.10.25 comment "Port Forward SMTP" # handle 53
|
||||
}
|
||||
|
||||
chain postrouting { # handle 5
|
||||
type nat hook postrouting priority srcnat; policy accept;
|
||||
oifname "wan" ip saddr { 10.0.10.0/24, 10.1.1.0/24, 10.20.30.0/24, 10.55.55.0/24, 10.60.60.0/24, 10.65.65.0/24 } masquerade comment "external masquerade" # handle 55
|
||||
oifname != "wg0" ip saddr 10.50.50.0/24 masquerade # handle 56
|
||||
ip saddr 10.0.10.0/24 ip daddr { 10.0.10.25, 10.0.10.35, 10.0.10.37, 10.0.10.57, 10.0.10.76 } ct status dnat masquerade comment "Hairpin NAT same-subnet" # handle 58
|
||||
}
|
||||
|
||||
chain forward { # handle 6
|
||||
type filter hook forward priority filter; policy drop;
|
||||
oifname "wan" tcp flags syn tcp option maxseg size set rt mtu comment "PPPoE MSS clamping" # handle 59
|
||||
ct state established,related accept # handle 60
|
||||
iifname != "wan" ct state invalid ip daddr { 10.0.10.0/24, 10.1.1.0/24, 10.20.30.0/24, 10.50.50.0/24, 10.55.55.0/24, 10.60.60.0/24, 10.65.65.0/24 } accept comment "Needed due to asymmetric routing (internal only)" # handle 62
|
||||
ct state invalid drop # handle 63
|
||||
iifname "enp2s0" oifname "wan" ip6 saddr 2a02:8013:7f3d:10::/64 accept comment "LAN IPv6 out" # handle 64
|
||||
iifname "wan" ip daddr 10.0.10.57 tcp dport 443 ct status dnat accept comment "Forward jellyfin traffic" # handle 65
|
||||
iifname "wan" ip daddr 10.0.10.37 tcp dport 443 ct status dnat accept comment "Forward Haproxy traffic" # handle 66
|
||||
iifname "wan" ip daddr 10.0.10.35 tcp dport 50300 ct status dnat accept comment "Forward slskd traffic" # handle 67
|
||||
iifname "wan" ip daddr 10.0.10.76 tcp dport 7777 ct status dnat accept comment "Forward terraria traffic" # handle 68
|
||||
iifname "wan" ip daddr 10.0.10.25 tcp dport 25 ct status dnat accept comment "Forward SMTP" # handle 69
|
||||
ip6 daddr 2a02:8013:7f3d:10::57 tcp dport 443 accept # handle 70
|
||||
iifname "wan" counter packets 0 bytes 0 drop comment "Nothing else gets in from the internet" # handle 71
|
||||
ip daddr 10.50.50.0/24 drop comment "Drop client-to-client traffic for wg0 network" # handle 72
|
||||
icmp type { echo-reply, echo-request } accept # handle 74
|
||||
ip saddr 10.50.50.0/24 accept comment "Forward from wg0 network" # handle 75
|
||||
ip saddr { 10.0.10.0/24, 10.1.1.0/24, 10.20.30.0/24, 10.50.50.0/24, 10.55.55.0/24, 10.60.60.0/24, 10.65.65.0/24 } accept comment "Forward from trusted IPs" # handle 77
|
||||
}
|
||||
|
||||
chain output { # handle 7
|
||||
type filter hook output priority filter; policy accept;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
// Package jarl talks to Jarl, the BIND manager, through its REST API: who
|
||||
// the token belongs to, which zones it can see, and DNS-01 challenge
|
||||
// records. It writes challenge records the way 'jarl acme' does: into the
|
||||
// longest matching primary zone, in every view that has it (once per set
|
||||
// of linked zones), so the CA and internal resolvers see the same thing.
|
||||
package jarl
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"housecarl/internal/version"
|
||||
)
|
||||
|
||||
// ChallengeTTL is the TTL of challenge records (as in Jarl).
|
||||
const ChallengeTTL = 60
|
||||
|
||||
// Comment marks the challenge records Housecarl writes.
|
||||
const Comment = "ACME DNS-01 challenge (Housecarl)"
|
||||
|
||||
// Client is a Jarl server and a token.
|
||||
type Client struct {
|
||||
base string
|
||||
token string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
// New makes a client. caCert is a PEM file of a CA to trust ("" for the
|
||||
// system's).
|
||||
func New(server, token, caCert string, insecure bool) (*Client, error) {
|
||||
tc := &tls.Config{MinVersion: tls.VersionTLS12, InsecureSkipVerify: insecure} //nolint:gosec // the user's choice
|
||||
if caCert != "" {
|
||||
pem, err := os.ReadFile(caCert)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("jarl.ca_cert: %w", err)
|
||||
}
|
||||
pool, err := x509.SystemCertPool()
|
||||
if err != nil || pool == nil {
|
||||
pool = x509.NewCertPool()
|
||||
}
|
||||
if !pool.AppendCertsFromPEM(pem) {
|
||||
return nil, fmt.Errorf("jarl.ca_cert: no certificates in %s", caCert)
|
||||
}
|
||||
tc.RootCAs = pool
|
||||
}
|
||||
tr := http.DefaultTransport.(*http.Transport).Clone()
|
||||
tr.TLSClientConfig = tc
|
||||
return &Client{base: strings.TrimSuffix(server, "/") + "/api/v1", token: token, http: &http.Client{Transport: tr, Timeout: 2 * time.Minute}}, nil
|
||||
}
|
||||
|
||||
// Error is an error answer from Jarl.
|
||||
type Error struct {
|
||||
Status int
|
||||
Code string
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *Error) Error() string { return fmt.Sprintf("Jarl answered %d: %s", e.Status, e.Message) }
|
||||
|
||||
func (c *Client) do(ctx context.Context, method, path string, body, out any) error {
|
||||
var rd io.Reader
|
||||
if body != nil {
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rd = bytes.NewReader(b)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, c.base+path, rd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", "housecarl/"+version.Short())
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("can't reach Jarl at %s: %w", strings.TrimSuffix(c.base, "/api/v1"), err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
var eb struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
e := &Error{Status: resp.StatusCode, Message: strings.TrimSpace(string(b))}
|
||||
if json.Unmarshal(b, &eb) == nil && eb.Error.Message != "" {
|
||||
e.Code, e.Message = eb.Error.Code, eb.Error.Message
|
||||
}
|
||||
return e
|
||||
}
|
||||
if out != nil {
|
||||
return json.Unmarshal(b, out)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Whoami is who the token belongs to.
|
||||
type Whoami struct {
|
||||
User struct {
|
||||
Username string `json:"username"`
|
||||
} `json:"user"`
|
||||
Admin bool `json:"admin"`
|
||||
Method string `json:"method"`
|
||||
Permissions []string `json:"permissions"`
|
||||
Build struct {
|
||||
Label string `json:"label"`
|
||||
} `json:"build"`
|
||||
}
|
||||
|
||||
// Whoami asks Jarl who the token is.
|
||||
func (c *Client) Whoami(ctx context.Context) (*Whoami, error) {
|
||||
var w Whoami
|
||||
return &w, c.do(ctx, http.MethodGet, "/auth/whoami", nil, &w)
|
||||
}
|
||||
|
||||
// Zone is a primary zone the token can see.
|
||||
type Zone struct {
|
||||
Name string `json:"name"` // "adbraun.lol."
|
||||
View string `json:"view,omitempty"`
|
||||
LinkGroup int64 `json:"link_group,omitempty"`
|
||||
}
|
||||
|
||||
// Zones lists the primary zones the token can see.
|
||||
func (c *Client) Zones(ctx context.Context) ([]Zone, error) {
|
||||
var zs []Zone
|
||||
return zs, c.do(ctx, http.MethodGet, "/zones?type=primary", nil, &zs)
|
||||
}
|
||||
|
||||
func fqdn(n string) string {
|
||||
n = strings.ToLower(strings.TrimSpace(n))
|
||||
if !strings.HasSuffix(n, ".") {
|
||||
n += "."
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// ChallengeName is the record name for a domain's DNS-01 challenge.
|
||||
func ChallengeName(domain string) string {
|
||||
d := strings.TrimPrefix(strings.ToLower(strings.TrimSuffix(domain, ".")), "*.")
|
||||
if !strings.HasPrefix(d, "_acme-challenge.") {
|
||||
d = "_acme-challenge." + d
|
||||
}
|
||||
return d + "."
|
||||
}
|
||||
|
||||
// ZonesFor picks the zones a record at name goes into: the longest
|
||||
// matching zone, in every view that has it (or only view), once per set of
|
||||
// linked zones.
|
||||
func ZonesFor(name string, zones []Zone, view string) []Zone {
|
||||
name = fqdn(name)
|
||||
best := ""
|
||||
var out []Zone
|
||||
for _, z := range zones {
|
||||
if view != "" && !strings.EqualFold(z.View, view) {
|
||||
continue
|
||||
}
|
||||
zn := fqdn(z.Name)
|
||||
if name != zn && !strings.HasSuffix(name, "."+zn) && zn != "." {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case len(zn) > len(best):
|
||||
best, out = zn, []Zone{z}
|
||||
case zn == best:
|
||||
out = append(out, z)
|
||||
}
|
||||
}
|
||||
var dedup []Zone
|
||||
links := map[int64]bool{}
|
||||
for _, z := range out {
|
||||
if z.LinkGroup != 0 {
|
||||
if links[z.LinkGroup] {
|
||||
continue
|
||||
}
|
||||
links[z.LinkGroup] = true
|
||||
}
|
||||
dedup = append(dedup, z)
|
||||
}
|
||||
return dedup
|
||||
}
|
||||
|
||||
type op struct {
|
||||
Op string `json:"op"`
|
||||
Data any `json:"data"`
|
||||
}
|
||||
|
||||
type batch struct {
|
||||
Description string `json:"description"`
|
||||
Ops []op `json:"ops"`
|
||||
DryRun bool `json:"dry_run,omitempty"`
|
||||
}
|
||||
|
||||
// SetTXT publishes (set) or removes a TXT value at name in zones, as one
|
||||
// change in Jarl. Setting first removes the same value, so a retry
|
||||
// succeeds; removing a value that isn't there succeeds too. With dryRun
|
||||
// Jarl only checks the change, including the token's permission.
|
||||
func (c *Client) SetTXT(ctx context.Context, name, value string, zones []Zone, set, dryRun bool, why string) error {
|
||||
if len(zones) == 0 {
|
||||
return errors.New("no zone to write into")
|
||||
}
|
||||
var ops []op
|
||||
for _, z := range zones {
|
||||
base := map[string]any{"zone": z.Name, "name": fqdn(name), "type": "TXT", "data": value}
|
||||
if z.View != "" {
|
||||
base["view"] = z.View
|
||||
}
|
||||
del := map[string]any{"missing_ok": true}
|
||||
for k, v := range base {
|
||||
del[k] = v
|
||||
}
|
||||
ops = append(ops, op{Op: "record.delete", Data: del})
|
||||
if set {
|
||||
add := map[string]any{"ttl": ChallengeTTL, "comment": Comment}
|
||||
for k, v := range base {
|
||||
add[k] = v
|
||||
}
|
||||
ops = append(ops, op{Op: "record.create", Data: add})
|
||||
}
|
||||
}
|
||||
verb := "remove"
|
||||
if set {
|
||||
verb = "set"
|
||||
}
|
||||
desc := "ACME: " + verb + " challenge for " + strings.TrimSuffix(name, ".")
|
||||
if why != "" {
|
||||
desc += " (" + why + ")"
|
||||
}
|
||||
return c.do(ctx, http.MethodPost, "/apply", batch{Description: desc, Ops: ops, DryRun: dryRun}, nil)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Address is a parsed address entry: one address, a prefix or a range.
|
||||
type Address struct {
|
||||
Prefix netip.Prefix // a single address is a /32 or /128
|
||||
First, End netip.Addr // a range (First valid only for ranges)
|
||||
}
|
||||
|
||||
// IsV6 reports whether the entry is IPv6.
|
||||
func (a Address) IsV6() bool {
|
||||
if a.First.IsValid() {
|
||||
return a.First.Is6()
|
||||
}
|
||||
return a.Prefix.Addr().Is6()
|
||||
}
|
||||
|
||||
// String writes the entry as nftables and Housecarl write it:
|
||||
// "10.0.0.5", "10.0.0.0/24", "10.0.0.1-10.0.0.9".
|
||||
func (a Address) String() string {
|
||||
if a.First.IsValid() {
|
||||
return a.First.String() + "-" + a.End.String()
|
||||
}
|
||||
if a.Prefix.IsSingleIP() {
|
||||
return a.Prefix.Addr().String()
|
||||
}
|
||||
return a.Prefix.String()
|
||||
}
|
||||
|
||||
// ParseAddress reads an address, a prefix (host bits must be zero) or a
|
||||
// range, IPv4 or IPv6.
|
||||
func ParseAddress(s string) (Address, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if a, b, ok := strings.Cut(s, "-"); ok {
|
||||
first, err1 := netip.ParseAddr(strings.TrimSpace(a))
|
||||
end, err2 := netip.ParseAddr(strings.TrimSpace(b))
|
||||
if err1 != nil || err2 != nil || first.Is4() != end.Is4() {
|
||||
return Address{}, fmt.Errorf("%q is not an address range (write it as 10.0.0.1-10.0.0.9)", s)
|
||||
}
|
||||
first, end = first.Unmap(), end.Unmap()
|
||||
if end.Less(first) {
|
||||
return Address{}, fmt.Errorf("the range %q ends before it starts", s)
|
||||
}
|
||||
if first == end {
|
||||
return Address{Prefix: netip.PrefixFrom(first, first.BitLen())}, nil
|
||||
}
|
||||
return Address{First: first, End: end}, nil
|
||||
}
|
||||
if strings.Contains(s, "/") {
|
||||
p, err := netip.ParsePrefix(s)
|
||||
if err != nil {
|
||||
return Address{}, fmt.Errorf("%q is not an address or prefix", s)
|
||||
}
|
||||
p = netip.PrefixFrom(p.Addr().Unmap(), p.Bits())
|
||||
if p.Masked() != p {
|
||||
return Address{}, fmt.Errorf("%q has host bits set; the prefix is %s", s, p.Masked())
|
||||
}
|
||||
return Address{Prefix: p}, nil
|
||||
}
|
||||
a, err := netip.ParseAddr(s)
|
||||
if err != nil || a.Zone() != "" {
|
||||
return Address{}, fmt.Errorf("%q is not an address, prefix, range or set name", s)
|
||||
}
|
||||
a = a.Unmap()
|
||||
return Address{Prefix: netip.PrefixFrom(a, a.BitLen())}, nil
|
||||
}
|
||||
|
||||
// IsLiteral reports whether a source/destination reference is an
|
||||
// address, prefix or range rather than a set name.
|
||||
func IsLiteral(ref string) bool {
|
||||
_, err := ParseAddress(ref)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// NormalizeAddress rewrites an address entry in its canonical form.
|
||||
func NormalizeAddress(s string) (string, error) {
|
||||
a, err := ParseAddress(s)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return a.String(), nil
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Grant gives access to one resource (see internal/auth).
|
||||
type Grant struct {
|
||||
Resource string `json:"resource"` // config, sets, log, audit, *
|
||||
Access string `json:"access"` // read or write (write implies read)
|
||||
// Sets limits a "sets" grant to these dynamic sets (none: all).
|
||||
Sets []string `json:"sets,omitempty"`
|
||||
}
|
||||
|
||||
// User is a person or a service account.
|
||||
type User struct {
|
||||
ID int64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
PasswordHash string `json:"-"`
|
||||
HasPassword bool `json:"has_password"`
|
||||
Admin bool `json:"admin"`
|
||||
Grants []Grant `json:"grants"`
|
||||
Disabled bool `json:"disabled"`
|
||||
Comment string `json:"comment"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
LastLoginAt *time.Time `json:"last_login_at,omitempty"`
|
||||
}
|
||||
|
||||
// Token is an API token. The secret is shown once, when it is made; only
|
||||
// its hash is stored.
|
||||
type Token struct {
|
||||
ID int64 `json:"id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
Username string `json:"username,omitempty"`
|
||||
Name string `json:"name"`
|
||||
PublicID string `json:"public_id"`
|
||||
SecretHash string `json:"-"`
|
||||
// Grants restrict the token to part of its user's permissions; nil
|
||||
// means all of them.
|
||||
Grants []Grant `json:"grants"`
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
|
||||
LastUsedIP string `json:"last_used_ip,omitempty"`
|
||||
RevokedAt *time.Time `json:"revoked_at,omitempty"`
|
||||
// RotatedAt is when the token last got a new secret (nil: never).
|
||||
RotatedAt *time.Time `json:"rotated_at,omitempty"`
|
||||
// After a rotation with a grace period, the previous secret keeps
|
||||
// working until PrevExpiresAt.
|
||||
PrevSecretHash string `json:"-"`
|
||||
PrevExpiresAt *time.Time `json:"previous_secret_valid_until,omitempty"`
|
||||
}
|
||||
|
||||
// IssuedAt is when the token's current secret was made: its last
|
||||
// rotation, or its creation.
|
||||
func (t *Token) IssuedAt() time.Time {
|
||||
if t.RotatedAt != nil {
|
||||
return *t.RotatedAt
|
||||
}
|
||||
return t.CreatedAt
|
||||
}
|
||||
|
||||
// Session is a web UI login.
|
||||
type Session struct {
|
||||
IDHash string `json:"-"`
|
||||
UserID int64 `json:"user_id"`
|
||||
CSRFToken string `json:"-"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
LastSeenAt time.Time `json:"last_seen_at"`
|
||||
IP string `json:"ip"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
}
|
||||
|
||||
// AuditEvent records an action: logins, users and tokens, changes,
|
||||
// dynamic set entries.
|
||||
type AuditEvent struct {
|
||||
ID int64 `json:"id"`
|
||||
Time time.Time `json:"time"`
|
||||
UserID int64 `json:"user_id,omitempty"`
|
||||
Username string `json:"username,omitempty"`
|
||||
Auth string `json:"auth,omitempty"` // session, token, socket
|
||||
TokenID int64 `json:"token_id,omitempty"`
|
||||
IP string `json:"ip,omitempty"`
|
||||
Action string `json:"action"`
|
||||
Object string `json:"object,omitempty"`
|
||||
Result string `json:"result"` // success, failure, denied
|
||||
Detail string `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
// Change is one object's change: Before is nil for a create, After is nil
|
||||
// for a delete. Settings have ID 0.
|
||||
type Change struct {
|
||||
Kind string `json:"kind"`
|
||||
ID int64 `json:"id"`
|
||||
Label string `json:"label"`
|
||||
Before json.RawMessage `json:"before,omitempty"`
|
||||
After json.RawMessage `json:"after,omitempty"`
|
||||
}
|
||||
|
||||
// Action describes a change in a word.
|
||||
func (c Change) Action() string {
|
||||
switch {
|
||||
case c.Before == nil:
|
||||
return "create"
|
||||
case c.After == nil:
|
||||
return "delete"
|
||||
}
|
||||
return "update"
|
||||
}
|
||||
|
||||
// Inverse is the change that undoes c.
|
||||
func (c Change) Inverse() Change {
|
||||
c.Before, c.After = c.After, c.Before
|
||||
return c
|
||||
}
|
||||
|
||||
// HistoryEntry is a saved configuration change.
|
||||
type HistoryEntry struct {
|
||||
ID int64 `json:"id"`
|
||||
Time time.Time `json:"time"`
|
||||
UserID int64 `json:"user_id,omitempty"`
|
||||
Username string `json:"username"`
|
||||
Via string `json:"via"` // web, cli, api
|
||||
Description string `json:"description"`
|
||||
Changes []Change `json:"changes"`
|
||||
// RevertOf is the entry this one undid; RevertedBy the entry that
|
||||
// undid this one.
|
||||
RevertOf int64 `json:"revert_of,omitempty"`
|
||||
RevertedBy int64 `json:"reverted_by,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Changeset statuses.
|
||||
const (
|
||||
ChangesetOpen = "open"
|
||||
ChangesetApplied = "applied"
|
||||
ChangesetDiscarded = "discarded"
|
||||
)
|
||||
|
||||
// Changeset is a list of changes, staged to be reviewed and applied
|
||||
// together (or discarded). Applied, it is one history entry.
|
||||
type Changeset struct {
|
||||
ID int64 `json:"id"`
|
||||
UserID int64 `json:"user_id,omitempty"`
|
||||
Username string `json:"username"`
|
||||
Via string `json:"via"`
|
||||
Description string `json:"description"`
|
||||
Status string `json:"status"`
|
||||
Ops []Op `json:"ops"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
// HistoryID is the history entry an applied changeset became.
|
||||
HistoryID int64 `json:"history_id,omitempty"`
|
||||
}
|
||||
|
||||
// Operations.
|
||||
const (
|
||||
OpCreate = "create"
|
||||
OpUpdate = "update"
|
||||
OpDelete = "delete"
|
||||
OpMove = "move"
|
||||
OpSettings = "settings"
|
||||
)
|
||||
|
||||
// Op is one change, as the API received it, so it can be stored and
|
||||
// replayed on whatever the configuration is when it is applied.
|
||||
type Op struct {
|
||||
Seq int `json:"seq"`
|
||||
Op string `json:"op"` // create, update, delete, move, settings
|
||||
Kind string `json:"kind,omitempty"`
|
||||
// Ref names the object (ID, or name) for update, delete and move.
|
||||
Ref string `json:"ref,omitempty"`
|
||||
// ID is the object a create makes, given when it is staged so later
|
||||
// operations can name it.
|
||||
ID int64 `json:"id,omitempty"`
|
||||
// Data is the object (create), the fields to change (update,
|
||||
// settings) or where to move it (move).
|
||||
Data json.RawMessage `json:"data,omitempty"`
|
||||
Summary string `json:"summary"`
|
||||
AddedAt time.Time `json:"added_at"`
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
package model
|
||||
|
||||
import "fmt"
|
||||
|
||||
// Chains rules are placed in.
|
||||
const (
|
||||
// ChainEarly sees packets before connection tracking (prerouting,
|
||||
// priority raw): cheap drops, antispoofing.
|
||||
ChainEarly = "early"
|
||||
// ChainInput is traffic to the firewall itself.
|
||||
ChainInput = "input"
|
||||
// ChainForward is traffic through the firewall.
|
||||
ChainForward = "forward"
|
||||
// ChainOutput is traffic from the firewall itself.
|
||||
ChainOutput = "output"
|
||||
)
|
||||
|
||||
// Chains lists the rule chains in display order.
|
||||
var Chains = []string{ChainEarly, ChainInput, ChainForward, ChainOutput}
|
||||
|
||||
// ChainOrder is a chain's place in Chains.
|
||||
func ChainOrder(c string) int {
|
||||
for i, x := range Chains {
|
||||
if x == c {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return len(Chains)
|
||||
}
|
||||
|
||||
// Rule actions.
|
||||
const (
|
||||
ActionAccept = "accept"
|
||||
ActionDrop = "drop"
|
||||
ActionReject = "reject"
|
||||
// ActionLog only logs (and counts); the packet goes on to the next
|
||||
// rule.
|
||||
ActionLog = "log"
|
||||
)
|
||||
|
||||
// Actions lists the rule actions.
|
||||
var Actions = []string{ActionAccept, ActionDrop, ActionReject, ActionLog}
|
||||
|
||||
// Families a rule can be limited to; "" is both.
|
||||
const (
|
||||
FamilyIPv4 = "ipv4"
|
||||
FamilyIPv6 = "ipv6"
|
||||
)
|
||||
|
||||
// Zone is a group of interfaces (wildcards such as "incus*" allowed).
|
||||
// External zones face the internet: built-ins such as antispoofing and
|
||||
// MSS clamping apply to them.
|
||||
type Zone struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Interfaces []string `json:"interfaces"`
|
||||
External bool `json:"external,omitempty"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
}
|
||||
|
||||
func (z *Zone) GetID() int64 { return z.ID }
|
||||
func (z *Zone) Label() string { return z.Name }
|
||||
|
||||
// AddressSet is a named list of IPv4 and IPv6 addresses, prefixes and
|
||||
// ranges. A dynamic set also takes entries at run time, each with a
|
||||
// timeout, through its own API (a captive portal's signed-in clients).
|
||||
type AddressSet struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Entries []string `json:"entries"`
|
||||
Dynamic bool `json:"dynamic,omitempty"`
|
||||
// Timeout is a dynamic set's default entry lifetime in seconds (0:
|
||||
// entries stay until removed).
|
||||
Timeout int `json:"timeout,omitempty"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
}
|
||||
|
||||
func (s *AddressSet) GetID() int64 { return s.ID }
|
||||
func (s *AddressSet) Label() string { return s.Name }
|
||||
|
||||
// Service is a named list of ports ("tcp/22", "udp/60000-61000") and ICMP
|
||||
// types ("icmp/echo-request", "icmpv6/packet-too-big"). Rules name
|
||||
// services, or give such entries directly.
|
||||
type Service struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Entries []string `json:"entries"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
// Builtin marks the catalog's services (never stored).
|
||||
Builtin bool `json:"builtin,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Service) GetID() int64 { return s.ID }
|
||||
func (s *Service) Label() string { return s.Name }
|
||||
|
||||
// Match is what a rule, port forward, source NAT or redirect looks at.
|
||||
// Lists are ORed; the fields together are ANDed.
|
||||
type Match struct {
|
||||
// InZones and OutZones are zone names; with NotIn/NotOut, every zone
|
||||
// but those.
|
||||
InZones []string `json:"in_zones,omitempty"`
|
||||
NotIn bool `json:"not_in,omitempty"`
|
||||
OutZones []string `json:"out_zones,omitempty"`
|
||||
NotOut bool `json:"not_out,omitempty"`
|
||||
// Sources and Destinations are set names or literal addresses,
|
||||
// prefixes and ranges, IPv4 or IPv6.
|
||||
Sources []string `json:"sources,omitempty"`
|
||||
NotSources bool `json:"not_sources,omitempty"`
|
||||
Destinations []string `json:"destinations,omitempty"`
|
||||
NotDests bool `json:"not_destinations,omitempty"`
|
||||
// Services are service names or entries ("tcp/22", "icmp/echo-request").
|
||||
Services []string `json:"services,omitempty"`
|
||||
// Family limits the rule to ipv4 or ipv6 ("": what its addresses
|
||||
// and services allow).
|
||||
Family string `json:"family,omitempty"`
|
||||
}
|
||||
|
||||
// Rule is one line of a chain, checked in order.
|
||||
type Rule struct {
|
||||
ID int64 `json:"id"`
|
||||
Chain string `json:"chain"`
|
||||
Position float64 `json:"position"`
|
||||
Match
|
||||
// CtState: new, established, related, invalid, untracked.
|
||||
CtState []string `json:"ct_state,omitempty"`
|
||||
// CtStatus: dnat, snat (a connection that was NATed).
|
||||
CtStatus []string `json:"ct_status,omitempty"`
|
||||
// Days ("Monday", …) and hours ("08:00"-"17:30") the rule applies.
|
||||
Days []string `json:"days,omitempty"`
|
||||
HourFrom string `json:"hour_from,omitempty"`
|
||||
HourTo string `json:"hour_to,omitempty"`
|
||||
// Limit, if set, makes the rule match only packets within (or with
|
||||
// LimitOver, beyond) a rate.
|
||||
Limit *Limit `json:"limit,omitempty"`
|
||||
// ConnLimit matches when a source already has more than this many
|
||||
// connections (per source address, grouped as GroupV4/GroupV6 say).
|
||||
ConnLimit int `json:"conn_limit,omitempty"`
|
||||
// Quota, if set, is an allowance of bytes: the rule matches once it
|
||||
// is used up (Over), or while it lasts.
|
||||
Quota *Quota `json:"quota,omitempty"`
|
||||
// GroupV4 and GroupV6 group addresses for the per-address limits,
|
||||
// connection limit and quota: by prefix length (0: the default, each
|
||||
// IPv4 address alone, IPv6 by /64, since one device has several).
|
||||
GroupV4 int `json:"group_v4,omitempty"`
|
||||
GroupV6 int `json:"group_v6,omitempty"`
|
||||
Action string `json:"action"`
|
||||
// RejectWith: "" (ICMP port/admin unreachable as fits) or tcp-reset.
|
||||
RejectWith string `json:"reject_with,omitempty"`
|
||||
Log bool `json:"log,omitempty"`
|
||||
LogPrefix string `json:"log_prefix,omitempty"`
|
||||
// Raw, when set, is an nftables rule written by hand, used instead of
|
||||
// the fields above (the escape hatch for what Housecarl can't say).
|
||||
Raw string `json:"raw,omitempty"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
Disabled bool `json:"disabled,omitempty"`
|
||||
}
|
||||
|
||||
func (r *Rule) GetID() int64 { return r.ID }
|
||||
func (r *Rule) Label() string {
|
||||
if r.Comment != "" {
|
||||
return fmt.Sprintf("%s #%d (%s)", r.Chain, r.ID, r.Comment)
|
||||
}
|
||||
return fmt.Sprintf("%s #%d", r.Chain, r.ID)
|
||||
}
|
||||
|
||||
// Limit is a rate: Rate packets (or bytes, with Unit) per Per (second,
|
||||
// minute, hour, day), with a burst.
|
||||
type Limit struct {
|
||||
Rate int `json:"rate"`
|
||||
Per string `json:"per"`
|
||||
// Burst is in the rate's unit.
|
||||
Burst int `json:"burst,omitempty"`
|
||||
// Unit: "" for packets, or bytes, kbytes or mbytes for a byte rate
|
||||
// (bandwidth).
|
||||
Unit string `json:"unit,omitempty"`
|
||||
// Over matches packets beyond the rate instead of within it (to drop
|
||||
// floods).
|
||||
Over bool `json:"over,omitempty"`
|
||||
// PerSource counts each source address on its own (a meter), instead
|
||||
// of all traffic the rule sees together; PerDestination each
|
||||
// destination.
|
||||
PerSource bool `json:"per_source,omitempty"`
|
||||
PerDestination bool `json:"per_destination,omitempty"`
|
||||
}
|
||||
|
||||
// Byte units, as nftables writes them.
|
||||
var ByteUnits = map[string]int64{"bytes": 1, "kbytes": 1 << 10, "mbytes": 1 << 20, "gbytes": 1 << 30}
|
||||
|
||||
// Quota is an allowance of bytes. nftables counts it; Housecarl keeps the
|
||||
// count across changes and restarts, and resets it each period.
|
||||
type Quota struct {
|
||||
Bytes int64 `json:"bytes"`
|
||||
// Over matches once the allowance is used up (to drop); otherwise the
|
||||
// rule matches while it lasts (to accept).
|
||||
Over bool `json:"over,omitempty"`
|
||||
// PerSource gives each source address (grouped as the rule's
|
||||
// GroupV4/GroupV6 say) its own allowance; PerDestination each
|
||||
// destination.
|
||||
PerSource bool `json:"per_source,omitempty"`
|
||||
PerDestination bool `json:"per_destination,omitempty"`
|
||||
// Reset: "" (only by hand), daily, weekly (on Mondays) or monthly (on
|
||||
// Day, 1-31: a short month's last day when it has no such day), at
|
||||
// midnight.
|
||||
Reset string `json:"reset,omitempty"`
|
||||
Day int `json:"day,omitempty"`
|
||||
}
|
||||
|
||||
// Quota resets.
|
||||
var QuotaResets = []string{"daily", "weekly", "monthly"}
|
||||
|
||||
// PerAddress reports whether a quota (or limit) counts each address on its
|
||||
// own, and which: "saddr", "daddr" or "".
|
||||
func (q *Quota) PerAddress() string {
|
||||
switch {
|
||||
case q == nil:
|
||||
return ""
|
||||
case q.PerSource:
|
||||
return "saddr"
|
||||
case q.PerDestination:
|
||||
return "daddr"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// PerAddress: as Quota's.
|
||||
func (l *Limit) PerAddress() string {
|
||||
switch {
|
||||
case l == nil:
|
||||
return ""
|
||||
case l.PerSource:
|
||||
return "saddr"
|
||||
case l.PerDestination:
|
||||
return "daddr"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// CountsTraffic reports whether a rule measures traffic (a quota or a byte
|
||||
// rate): it must see every packet of a connection, so it is checked before
|
||||
// the built-in that accepts established connections.
|
||||
func (r *Rule) CountsTraffic() bool {
|
||||
if r.Raw != "" {
|
||||
return false
|
||||
}
|
||||
if r.Quota != nil {
|
||||
return true
|
||||
}
|
||||
if r.Limit == nil {
|
||||
return false
|
||||
}
|
||||
_, bytes := ByteUnits[r.Limit.Unit]
|
||||
return bytes
|
||||
}
|
||||
|
||||
// Groups returns a rule's address grouping, defaults applied.
|
||||
func (r *Rule) Groups() (v4, v6 int) {
|
||||
v4, v6 = r.GroupV4, r.GroupV6
|
||||
if v4 == 0 {
|
||||
v4 = 32
|
||||
}
|
||||
if v6 == 0 {
|
||||
v6 = 64
|
||||
}
|
||||
return v4, v6
|
||||
}
|
||||
|
||||
// PortForward publishes a service on an inside host: incoming connections
|
||||
// to a public address and port are sent on (DNAT) to To[:ToPort], let
|
||||
// through the forward chain, and, with Hairpin, also work from inside.
|
||||
// To6 also opens the port to an IPv6 host (a pinhole; no translation).
|
||||
type PortForward struct {
|
||||
ID int64 `json:"id"`
|
||||
Position float64 `json:"position"`
|
||||
Name string `json:"name,omitempty"`
|
||||
// Protocols: tcp, udp (default tcp).
|
||||
Protocols []string `json:"protocols"`
|
||||
// Ports is the public port or range ("8096", "7777-7780").
|
||||
Ports string `json:"ports"`
|
||||
// PublicAddresses are the IPv4 addresses (or set names) to match;
|
||||
// none means any address on the external zones' interfaces (and then
|
||||
// no hairpin).
|
||||
PublicAddresses []string `json:"public_addresses,omitempty"`
|
||||
// Sources limits who may connect (set names or addresses).
|
||||
Sources []string `json:"sources,omitempty"`
|
||||
To string `json:"to,omitempty"`
|
||||
// ToPort is the inside port ("" keeps the public one; for a range, the
|
||||
// first port of an equally long range).
|
||||
ToPort string `json:"to_port,omitempty"`
|
||||
To6 string `json:"to6,omitempty"`
|
||||
// Hairpin lets inside clients use the public address: their
|
||||
// connections are masqueraded so replies come back through the
|
||||
// firewall. HairpinSources limits that to some sources (none: all
|
||||
// inside traffic).
|
||||
Hairpin bool `json:"hairpin,omitempty"`
|
||||
HairpinSources []string `json:"hairpin_sources,omitempty"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
Disabled bool `json:"disabled,omitempty"`
|
||||
}
|
||||
|
||||
func (f *PortForward) GetID() int64 { return f.ID }
|
||||
func (f *PortForward) Label() string {
|
||||
if f.Name != "" {
|
||||
return f.Name
|
||||
}
|
||||
return fmt.Sprintf("port forward #%d (%s)", f.ID, f.Ports)
|
||||
}
|
||||
|
||||
// SourceNAT rewrites the source address of outgoing traffic: masquerade
|
||||
// (To empty: the outgoing interface's address) or SNAT to To.
|
||||
type SourceNAT struct {
|
||||
ID int64 `json:"id"`
|
||||
Position float64 `json:"position"`
|
||||
Match
|
||||
To string `json:"to,omitempty"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
Disabled bool `json:"disabled,omitempty"`
|
||||
}
|
||||
|
||||
func (s *SourceNAT) GetID() int64 { return s.ID }
|
||||
func (s *SourceNAT) Label() string {
|
||||
if s.Comment != "" {
|
||||
return fmt.Sprintf("source NAT #%d (%s)", s.ID, s.Comment)
|
||||
}
|
||||
return fmt.Sprintf("source NAT #%d", s.ID)
|
||||
}
|
||||
|
||||
// Redirect sends matching traffic to a port on the firewall itself (to
|
||||
// make every client use its DNS server, say).
|
||||
type Redirect struct {
|
||||
ID int64 `json:"id"`
|
||||
Position float64 `json:"position"`
|
||||
Match
|
||||
// ToPort is the local port (the services' first port when empty).
|
||||
ToPort string `json:"to_port,omitempty"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
Disabled bool `json:"disabled,omitempty"`
|
||||
}
|
||||
|
||||
func (r *Redirect) GetID() int64 { return r.ID }
|
||||
func (r *Redirect) Label() string {
|
||||
if r.Comment != "" {
|
||||
return fmt.Sprintf("redirect #%d (%s)", r.ID, r.Comment)
|
||||
}
|
||||
return fmt.Sprintf("redirect #%d", r.ID)
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package model
|
||||
|
||||
// FamilyRefs are a reference list's part for one address family: names of
|
||||
// sets that have entries of the family (dynamic sets always do), and
|
||||
// literal entries.
|
||||
type FamilyRefs struct {
|
||||
Sets []string
|
||||
Literals []string
|
||||
}
|
||||
|
||||
// Empty reports whether nothing of the family was referenced.
|
||||
func (f FamilyRefs) Empty() bool { return len(f.Sets) == 0 && len(f.Literals) == 0 }
|
||||
|
||||
// SplitRefs splits source or destination references by family. Unknown
|
||||
// names are left out (validation reports them).
|
||||
func (st *State) SplitRefs(refs []string) (v4, v6 FamilyRefs) {
|
||||
for _, r := range refs {
|
||||
if a, err := ParseAddress(r); err == nil {
|
||||
if a.IsV6() {
|
||||
v6.Literals = append(v6.Literals, a.String())
|
||||
} else {
|
||||
v4.Literals = append(v4.Literals, a.String())
|
||||
}
|
||||
continue
|
||||
}
|
||||
s := st.Set(r)
|
||||
if s == nil {
|
||||
continue
|
||||
}
|
||||
has4, has6 := s.Families()
|
||||
if has4 {
|
||||
v4.Sets = append(v4.Sets, s.Name)
|
||||
}
|
||||
if has6 {
|
||||
v6.Sets = append(v6.Sets, s.Name)
|
||||
}
|
||||
}
|
||||
return v4, v6
|
||||
}
|
||||
|
||||
// Families reports which families a set holds: a dynamic set may get
|
||||
// either at run time.
|
||||
func (s *AddressSet) Families() (v4, v6 bool) {
|
||||
if s.Dynamic {
|
||||
return true, true
|
||||
}
|
||||
for _, e := range s.Entries {
|
||||
if a, err := ParseAddress(e); err == nil {
|
||||
if a.IsV6() {
|
||||
v6 = true
|
||||
} else {
|
||||
v4 = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return v4, v6
|
||||
}
|
||||
|
||||
// FamilyEntries returns a set's static entries of one family.
|
||||
func (s *AddressSet) FamilyEntries(v6 bool) []string {
|
||||
var out []string
|
||||
for _, e := range s.Entries {
|
||||
if a, err := ParseAddress(e); err == nil && a.IsV6() == v6 {
|
||||
out = append(out, a.String())
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ServiceEntries resolves service references (names and entries) to
|
||||
// entries, without duplicates. Unknown names are left out.
|
||||
func (st *State) ServiceEntries(refs []string) []ServiceEntry {
|
||||
var out []ServiceEntry
|
||||
seen := map[string]bool{}
|
||||
add := func(s string) {
|
||||
e, err := ParseServiceEntry(s)
|
||||
if err != nil || seen[e.String()] {
|
||||
return
|
||||
}
|
||||
seen[e.String()] = true
|
||||
out = append(out, e)
|
||||
}
|
||||
for _, r := range refs {
|
||||
if IsServiceEntry(r) {
|
||||
add(r)
|
||||
continue
|
||||
}
|
||||
if svc := st.Service(r); svc != nil {
|
||||
for _, e := range svc.Entries {
|
||||
add(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ServiceEntry is a parsed service entry: ports of a transport protocol
|
||||
// ("tcp/22", "udp/60000-61000") or an ICMP type ("icmp/echo-request",
|
||||
// "icmpv6/packet-too-big").
|
||||
type ServiceEntry struct {
|
||||
Proto string // tcp, udp, sctp, icmp, icmpv6
|
||||
// Ports: "22" or "1000-2000" (transport protocols).
|
||||
Ports string
|
||||
// Type: the ICMP type's name (icmp, icmpv6).
|
||||
Type string
|
||||
}
|
||||
|
||||
func (e ServiceEntry) String() string {
|
||||
if e.Type != "" {
|
||||
return e.Proto + "/" + e.Type
|
||||
}
|
||||
return e.Proto + "/" + e.Ports
|
||||
}
|
||||
|
||||
// IsICMP reports whether the entry is an ICMP or ICMPv6 type.
|
||||
func (e ServiceEntry) IsICMP() bool { return e.Proto == "icmp" || e.Proto == "icmpv6" }
|
||||
|
||||
// ICMP types nftables knows, by protocol.
|
||||
var icmpTypes = map[string][]string{
|
||||
"icmp": {"echo-reply", "destination-unreachable", "source-quench", "redirect", "echo-request",
|
||||
"router-advertisement", "router-solicitation", "time-exceeded", "parameter-problem",
|
||||
"timestamp-request", "timestamp-reply", "info-request", "info-reply", "address-mask-request",
|
||||
"address-mask-reply"},
|
||||
"icmpv6": {"destination-unreachable", "packet-too-big", "time-exceeded", "parameter-problem",
|
||||
"echo-request", "echo-reply", "mld-listener-query", "mld-listener-report", "mld-listener-done",
|
||||
"mld-listener-reduction", "nd-router-solicit", "nd-router-advert", "nd-neighbor-solicit",
|
||||
"nd-neighbor-advert", "nd-redirect", "router-renumbering", "ind-neighbor-solicit",
|
||||
"ind-neighbor-advert", "mld2-listener-report"},
|
||||
}
|
||||
|
||||
// ParseServiceEntry reads "tcp/22", "udp/1000-2000", "tcp/ssh"-style
|
||||
// entries are not accepted (use the service's name instead).
|
||||
func ParseServiceEntry(s string) (ServiceEntry, error) {
|
||||
s = strings.ToLower(strings.TrimSpace(s))
|
||||
proto, rest, ok := strings.Cut(s, "/")
|
||||
if !ok {
|
||||
return ServiceEntry{}, fmt.Errorf("%q is not a service entry (tcp/22, udp/1000-2000, icmp/echo-request) or a service name", s)
|
||||
}
|
||||
switch proto {
|
||||
case "tcp", "udp", "sctp":
|
||||
ports, err := NormalizePorts(rest)
|
||||
if err != nil {
|
||||
return ServiceEntry{}, fmt.Errorf("%q: %v", s, err)
|
||||
}
|
||||
return ServiceEntry{Proto: proto, Ports: ports}, nil
|
||||
case "icmp", "icmpv6":
|
||||
for _, t := range icmpTypes[proto] {
|
||||
if t == rest {
|
||||
return ServiceEntry{Proto: proto, Type: rest}, nil
|
||||
}
|
||||
}
|
||||
return ServiceEntry{}, fmt.Errorf("%q: unknown %s type (%s)", s, proto, strings.Join(icmpTypes[proto], ", "))
|
||||
}
|
||||
return ServiceEntry{}, fmt.Errorf("%q: the protocol is tcp, udp, sctp, icmp or icmpv6", s)
|
||||
}
|
||||
|
||||
// IsServiceEntry reports whether a reference is an entry ("tcp/22")
|
||||
// rather than a service name.
|
||||
func IsServiceEntry(ref string) bool { return strings.Contains(ref, "/") }
|
||||
|
||||
// NormalizePorts checks a port or range ("22", "1000-2000").
|
||||
func NormalizePorts(s string) (string, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
a, b, isRange := strings.Cut(s, "-")
|
||||
lo, err1 := strconv.Atoi(strings.TrimSpace(a))
|
||||
hi := lo
|
||||
var err2 error
|
||||
if isRange {
|
||||
hi, err2 = strconv.Atoi(strings.TrimSpace(b))
|
||||
}
|
||||
if err1 != nil || err2 != nil || lo < 1 || hi > 65535 || hi < lo {
|
||||
return "", fmt.Errorf("%q is not a port (1-65535) or a range (1000-2000)", s)
|
||||
}
|
||||
if lo == hi {
|
||||
return strconv.Itoa(lo), nil
|
||||
}
|
||||
return fmt.Sprintf("%d-%d", lo, hi), nil
|
||||
}
|
||||
|
||||
// PortRange returns a port spec's bounds.
|
||||
func PortRange(s string) (lo, hi int) {
|
||||
a, b, isRange := strings.Cut(s, "-")
|
||||
lo, _ = strconv.Atoi(a)
|
||||
hi = lo
|
||||
if isRange {
|
||||
hi, _ = strconv.Atoi(b)
|
||||
}
|
||||
return lo, hi
|
||||
}
|
||||
|
||||
// builtinServices are the services every configuration has.
|
||||
var builtinServices = map[string][]string{
|
||||
"ssh": {"tcp/22"},
|
||||
"dns": {"udp/53", "tcp/53"},
|
||||
"dot": {"tcp/853"},
|
||||
"http": {"tcp/80"},
|
||||
"https": {"tcp/443", "udp/443"},
|
||||
"smtp": {"tcp/25"},
|
||||
"submission": {"tcp/587"},
|
||||
"smtps": {"tcp/465"},
|
||||
"imaps": {"tcp/993"},
|
||||
"ntp": {"udp/123"},
|
||||
"dhcp": {"udp/67"},
|
||||
"dhcpv6": {"udp/547"},
|
||||
"wireguard": {"udp/51820"},
|
||||
"openvpn": {"udp/1194"},
|
||||
"ping": {"icmp/echo-request", "icmpv6/echo-request"},
|
||||
"snmp": {"udp/161"},
|
||||
"syslog": {"udp/514"},
|
||||
"mdns": {"udp/5353"},
|
||||
"rdp": {"tcp/3389"},
|
||||
"ipsec": {"udp/500", "udp/4500"},
|
||||
}
|
||||
|
||||
// BuiltinService returns a built-in service by name (nil if there is
|
||||
// none).
|
||||
func BuiltinService(name string) *Service {
|
||||
e, ok := builtinServices[strings.ToLower(name)]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return &Service{Name: strings.ToLower(name), Entries: append([]string(nil), e...), Builtin: true}
|
||||
}
|
||||
|
||||
// BuiltinServices lists the built-in services by name.
|
||||
func BuiltinServices() []*Service {
|
||||
var out []*Service
|
||||
for n := range builtinServices {
|
||||
out = append(out, BuiltinService(n))
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package model
|
||||
|
||||
// Settings are the firewall-wide settings: default policies and the rules
|
||||
// Housecarl adds by itself ("built-ins"), each of which can be turned off.
|
||||
type Settings struct {
|
||||
// Policies for packets no rule decided: accept or drop.
|
||||
InputPolicy string `json:"input_policy"`
|
||||
ForwardPolicy string `json:"forward_policy"`
|
||||
OutputPolicy string `json:"output_policy"`
|
||||
// LogPolicyDrops logs (rate-limited) what the input and forward
|
||||
// policies drop.
|
||||
LogPolicyDrops bool `json:"log_policy_drops,omitempty"`
|
||||
|
||||
Builtins Builtins `json:"builtins"`
|
||||
|
||||
// AntispoofV4 and AntispoofV6 are source addresses that can't come
|
||||
// from the internet: dropped on external zones (Builtins.Antispoof),
|
||||
// together with OwnPrefixes.
|
||||
AntispoofV4 []string `json:"antispoof_v4"`
|
||||
AntispoofV6 []string `json:"antispoof_v6"`
|
||||
// OwnPrefixes are your own public prefixes (your delegated IPv6
|
||||
// prefix, say): traffic claiming to come from them can't arrive from
|
||||
// outside.
|
||||
OwnPrefixes []string `json:"own_prefixes,omitempty"`
|
||||
|
||||
// Flowtable devices: forwarded TCP and UDP connections between them
|
||||
// skip the rules once established (fast path). Empty: off. Names
|
||||
// must be exact (no wildcards).
|
||||
FlowtableDevices []string `json:"flowtable_devices,omitempty"`
|
||||
|
||||
// ProbationSeconds is how long a change made over the network has to
|
||||
// be confirmed before it is rolled back (0: the default).
|
||||
ProbationSeconds int `json:"probation_seconds,omitempty"`
|
||||
// ManualConfirm: changes made over the network wait for a person to
|
||||
// confirm them. Otherwise the client that made a change confirms it by
|
||||
// itself once it reaches the API again over a new connection, from the
|
||||
// same address: proof that it can still manage the firewall.
|
||||
ManualConfirm bool `json:"manual_confirm,omitempty"`
|
||||
}
|
||||
|
||||
// Builtins are rules Housecarl adds itself, shown as "added by Housecarl".
|
||||
type Builtins struct {
|
||||
// Loopback accepts everything on lo.
|
||||
Loopback bool `json:"loopback"`
|
||||
// Established accepts packets of established and related connections.
|
||||
Established bool `json:"established"`
|
||||
// DropInvalid drops packets conntrack finds invalid.
|
||||
DropInvalid bool `json:"drop_invalid"`
|
||||
// ICMPv6 accepts the ICMPv6 IPv6 needs (RFC 4890): errors, echo, and
|
||||
// neighbour discovery and MLD on the local link.
|
||||
ICMPv6 bool `json:"icmpv6"`
|
||||
// PingInternal accepts ICMP echo to the firewall from internal zones.
|
||||
PingInternal bool `json:"ping_internal"`
|
||||
// DHCPv6Client accepts DHCPv6 replies on external zones (an ISP's
|
||||
// prefix delegation).
|
||||
DHCPv6Client bool `json:"dhcpv6_client"`
|
||||
// Antispoof drops impossible source addresses arriving on external
|
||||
// zones (AntispoofV4, AntispoofV6, OwnPrefixes).
|
||||
Antispoof bool `json:"antispoof"`
|
||||
// MSSClamp clamps TCP MSS to the route MTU on external zones (needed
|
||||
// with PPPoE).
|
||||
MSSClamp bool `json:"mss_clamp"`
|
||||
}
|
||||
|
||||
// Policies.
|
||||
const (
|
||||
PolicyAccept = "accept"
|
||||
PolicyDrop = "drop"
|
||||
)
|
||||
|
||||
// DefaultProbation is how long a network change has to be confirmed.
|
||||
const DefaultProbation = 120
|
||||
|
||||
// DefaultSettings are the settings of a new database: drop what isn't
|
||||
// allowed in and through, allow out, every built-in on.
|
||||
func DefaultSettings() Settings {
|
||||
return Settings{
|
||||
InputPolicy: PolicyDrop,
|
||||
ForwardPolicy: PolicyDrop,
|
||||
OutputPolicy: PolicyAccept,
|
||||
Builtins: Builtins{Loopback: true, Established: true, DropInvalid: true, ICMPv6: true,
|
||||
PingInternal: true, DHCPv6Client: true, Antispoof: true, MSSClamp: false},
|
||||
AntispoofV4: []string{"0.0.0.0/8", "10.0.0.0/8", "127.0.0.0/8", "169.254.0.0/16", "172.16.0.0/12",
|
||||
"192.0.2.0/24", "192.168.0.0/16", "198.51.100.0/24", "203.0.113.0/24", "224.0.0.0/4", "240.0.0.0/4"},
|
||||
AntispoofV6: []string{"::1/128", "::/128", "fc00::/7", "2001:db8::/32", "ff00::/8"},
|
||||
}
|
||||
}
|
||||
|
||||
// Probation is the probation in seconds, with its default.
|
||||
func (s Settings) Probation() int {
|
||||
if s.ProbationSeconds > 0 {
|
||||
return s.ProbationSeconds
|
||||
}
|
||||
return DefaultProbation
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
// Package model holds Housecarl's data: the firewall configuration
|
||||
// (State), users and tokens, the history of changes and the audit log.
|
||||
package model
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// State is the whole firewall configuration. It is small and changed as a
|
||||
// whole (internal/engine); the ruleset is rendered from it
|
||||
// (internal/render).
|
||||
type State struct {
|
||||
Settings Settings `json:"settings"`
|
||||
Zones []*Zone `json:"zones"`
|
||||
Sets []*AddressSet `json:"sets"`
|
||||
Services []*Service `json:"services"`
|
||||
Rules []*Rule `json:"rules"`
|
||||
Forwards []*PortForward `json:"forwards"`
|
||||
SNATs []*SourceNAT `json:"snats"`
|
||||
Redirects []*Redirect `json:"redirects"`
|
||||
}
|
||||
|
||||
// Object kinds, for changes and the history.
|
||||
const (
|
||||
KindSettings = "settings"
|
||||
KindZone = "zone"
|
||||
KindSet = "set"
|
||||
KindService = "service"
|
||||
KindRule = "rule"
|
||||
KindForward = "forward"
|
||||
KindSNAT = "snat"
|
||||
KindRedirect = "redirect"
|
||||
)
|
||||
|
||||
// Kinds lists the object kinds (not settings) in display order.
|
||||
var Kinds = []string{KindZone, KindSet, KindService, KindRule, KindForward, KindSNAT, KindRedirect}
|
||||
|
||||
// Object is anything in the State with an ID.
|
||||
type Object interface {
|
||||
GetID() int64
|
||||
Label() string
|
||||
}
|
||||
|
||||
// NewState is an empty configuration with the default settings.
|
||||
func NewState() *State {
|
||||
st := &State{Settings: DefaultSettings()}
|
||||
st.Fill()
|
||||
return st
|
||||
}
|
||||
|
||||
// Fill replaces nil lists with empty ones (for JSON and comparisons).
|
||||
func (st *State) Fill() {
|
||||
if st.Zones == nil {
|
||||
st.Zones = []*Zone{}
|
||||
}
|
||||
if st.Sets == nil {
|
||||
st.Sets = []*AddressSet{}
|
||||
}
|
||||
if st.Services == nil {
|
||||
st.Services = []*Service{}
|
||||
}
|
||||
if st.Rules == nil {
|
||||
st.Rules = []*Rule{}
|
||||
}
|
||||
if st.Forwards == nil {
|
||||
st.Forwards = []*PortForward{}
|
||||
}
|
||||
if st.SNATs == nil {
|
||||
st.SNATs = []*SourceNAT{}
|
||||
}
|
||||
if st.Redirects == nil {
|
||||
st.Redirects = []*Redirect{}
|
||||
}
|
||||
}
|
||||
|
||||
// Objects returns the objects of a kind.
|
||||
func (st *State) Objects(kind string) []Object {
|
||||
var out []Object
|
||||
switch kind {
|
||||
case KindZone:
|
||||
for _, o := range st.Zones {
|
||||
out = append(out, o)
|
||||
}
|
||||
case KindSet:
|
||||
for _, o := range st.Sets {
|
||||
out = append(out, o)
|
||||
}
|
||||
case KindService:
|
||||
for _, o := range st.Services {
|
||||
out = append(out, o)
|
||||
}
|
||||
case KindRule:
|
||||
for _, o := range st.Rules {
|
||||
out = append(out, o)
|
||||
}
|
||||
case KindForward:
|
||||
for _, o := range st.Forwards {
|
||||
out = append(out, o)
|
||||
}
|
||||
case KindSNAT:
|
||||
for _, o := range st.SNATs {
|
||||
out = append(out, o)
|
||||
}
|
||||
case KindRedirect:
|
||||
for _, o := range st.Redirects {
|
||||
out = append(out, o)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Put replaces (or adds, or with data nil deletes) the object of a kind
|
||||
// with an ID, from its JSON.
|
||||
// Empty reports whether nothing has been configured: no objects of any
|
||||
// kind. housecarld applies no ruleset for an empty configuration, so
|
||||
// installing and starting it doesn't close the machine.
|
||||
func (st *State) Empty() bool {
|
||||
for _, k := range Kinds {
|
||||
if len(st.Objects(k)) > 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (st *State) Put(kind string, id int64, data json.RawMessage) error {
|
||||
switch kind {
|
||||
case KindZone:
|
||||
return put(&st.Zones, id, data)
|
||||
case KindSet:
|
||||
return put(&st.Sets, id, data)
|
||||
case KindService:
|
||||
return put(&st.Services, id, data)
|
||||
case KindRule:
|
||||
return put(&st.Rules, id, data)
|
||||
case KindForward:
|
||||
return put(&st.Forwards, id, data)
|
||||
case KindSNAT:
|
||||
return put(&st.SNATs, id, data)
|
||||
case KindRedirect:
|
||||
return put(&st.Redirects, id, data)
|
||||
}
|
||||
return &unknownKind{kind}
|
||||
}
|
||||
|
||||
type unknownKind struct{ kind string }
|
||||
|
||||
func (e *unknownKind) Error() string { return "unknown kind " + e.kind }
|
||||
|
||||
func put[T Object](list *[]T, id int64, data json.RawMessage) error {
|
||||
keep := (*list)[:0:0]
|
||||
for _, o := range *list {
|
||||
if o.GetID() != id {
|
||||
keep = append(keep, o)
|
||||
}
|
||||
}
|
||||
if data != nil {
|
||||
var o T
|
||||
if err := json.Unmarshal(data, &o); err != nil {
|
||||
return err
|
||||
}
|
||||
keep = append(keep, o)
|
||||
}
|
||||
*list = keep
|
||||
return nil
|
||||
}
|
||||
|
||||
// Clone returns a deep copy.
|
||||
func (st *State) Clone() *State {
|
||||
b, err := json.Marshal(st)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
var out State
|
||||
if err := json.Unmarshal(b, &out); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
out.Fill()
|
||||
return &out
|
||||
}
|
||||
|
||||
// Sort puts the objects in display order: named objects by name, rules by
|
||||
// chain and position, NAT rules by position.
|
||||
func (st *State) Sort() {
|
||||
sort.SliceStable(st.Zones, func(i, j int) bool { return st.Zones[i].Name < st.Zones[j].Name })
|
||||
sort.SliceStable(st.Sets, func(i, j int) bool { return st.Sets[i].Name < st.Sets[j].Name })
|
||||
sort.SliceStable(st.Services, func(i, j int) bool { return st.Services[i].Name < st.Services[j].Name })
|
||||
sort.SliceStable(st.Rules, func(i, j int) bool {
|
||||
a, b := st.Rules[i], st.Rules[j]
|
||||
if a.Chain != b.Chain {
|
||||
return ChainOrder(a.Chain) < ChainOrder(b.Chain)
|
||||
}
|
||||
return a.Position < b.Position
|
||||
})
|
||||
sort.SliceStable(st.Forwards, func(i, j int) bool { return st.Forwards[i].Position < st.Forwards[j].Position })
|
||||
sort.SliceStable(st.SNATs, func(i, j int) bool { return st.SNATs[i].Position < st.SNATs[j].Position })
|
||||
sort.SliceStable(st.Redirects, func(i, j int) bool { return st.Redirects[i].Position < st.Redirects[j].Position })
|
||||
}
|
||||
|
||||
// Renumber gives the rules of each chain, and the NAT rules of each kind,
|
||||
// positions 1, 2, 3, … in their current order (after an insertion at a
|
||||
// fractional position, or a deletion).
|
||||
func (st *State) Renumber() {
|
||||
st.Sort()
|
||||
n := map[string]float64{}
|
||||
for _, r := range st.Rules {
|
||||
n[r.Chain]++
|
||||
r.Position = n[r.Chain]
|
||||
}
|
||||
for i, f := range st.Forwards {
|
||||
f.Position = float64(i + 1)
|
||||
}
|
||||
for i, s := range st.SNATs {
|
||||
s.Position = float64(i + 1)
|
||||
}
|
||||
for i, r := range st.Redirects {
|
||||
r.Position = float64(i + 1)
|
||||
}
|
||||
}
|
||||
|
||||
// Zone finds a zone by name.
|
||||
func (st *State) Zone(name string) *Zone {
|
||||
for _, z := range st.Zones {
|
||||
if strings.EqualFold(z.Name, name) {
|
||||
return z
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Set finds an address set by name.
|
||||
func (st *State) Set(name string) *AddressSet {
|
||||
for _, s := range st.Sets {
|
||||
if strings.EqualFold(s.Name, name) {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Service finds a service by name: the configuration's, else the built-in
|
||||
// catalog's.
|
||||
func (st *State) Service(name string) *Service {
|
||||
for _, s := range st.Services {
|
||||
if strings.EqualFold(s.Name, name) {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return BuiltinService(name)
|
||||
}
|
||||
|
||||
// ByID finds an object of a kind by ID (nil if there is none).
|
||||
func (st *State) ByID(kind string, id int64) Object {
|
||||
for _, o := range st.Objects(kind) {
|
||||
if o.GetID() == id {
|
||||
return o
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Find finds an object of a kind by ID or name (zones, sets, services,
|
||||
// port forwards) or by ID alone (rules, source NAT, redirects).
|
||||
func (st *State) Find(kind, ref string) Object {
|
||||
for _, o := range st.Objects(kind) {
|
||||
if itoa(o.GetID()) == ref {
|
||||
return o
|
||||
}
|
||||
}
|
||||
switch kind {
|
||||
case KindZone:
|
||||
if z := st.Zone(ref); z != nil {
|
||||
return z
|
||||
}
|
||||
case KindSet:
|
||||
if s := st.Set(ref); s != nil {
|
||||
return s
|
||||
}
|
||||
case KindService:
|
||||
for _, s := range st.Services {
|
||||
if strings.EqualFold(s.Name, ref) {
|
||||
return s
|
||||
}
|
||||
}
|
||||
case KindForward:
|
||||
for _, f := range st.Forwards {
|
||||
if f.Name != "" && strings.EqualFold(f.Name, ref) {
|
||||
return f
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func itoa(n int64) string {
|
||||
b, _ := json.Marshal(n)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// ExternalInterfaces lists the interfaces of the external zones.
|
||||
func (st *State) ExternalInterfaces() []string {
|
||||
return st.zoneInterfaces(func(z *Zone) bool { return z.External })
|
||||
}
|
||||
|
||||
// InternalInterfaces lists the interfaces of the internal zones.
|
||||
func (st *State) InternalInterfaces() []string {
|
||||
return st.zoneInterfaces(func(z *Zone) bool { return !z.External })
|
||||
}
|
||||
|
||||
func (st *State) zoneInterfaces(keep func(*Zone) bool) []string {
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, z := range st.Zones {
|
||||
if !keep(z) {
|
||||
continue
|
||||
}
|
||||
for _, i := range z.Interfaces {
|
||||
if !seen[i] {
|
||||
seen[i] = true
|
||||
out = append(out, i)
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
// Package nflog reads the packets the ruleset logs (log ... group N),
|
||||
// through nfnetlink_log: housecarld binds the group and decodes each
|
||||
// packet's prefix, interfaces and headers for the drop log.
|
||||
package nflog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/netip"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
|
||||
"housecarl/internal/api"
|
||||
)
|
||||
|
||||
const (
|
||||
subsysULOG = 4
|
||||
msgPacket = 0
|
||||
msgConfig = 1
|
||||
cfgCmd = 1
|
||||
cfgMode = 2
|
||||
cmdBind = 1
|
||||
copyPacket = 2
|
||||
attrPacketHdr = 1
|
||||
attrTimestamp = 3
|
||||
attrInDev = 4
|
||||
attrOutDev = 5
|
||||
attrPayload = 9
|
||||
attrPrefix = 10
|
||||
nfnetlinkV0 = 0
|
||||
headerLen = unix.SizeofNlMsghdr
|
||||
nfgenmsgLen = 4
|
||||
copyRangeBytes = 128 // headers are all we read
|
||||
)
|
||||
|
||||
// Listen binds the log group and passes each logged packet to handle until
|
||||
// ctx ends. Errors (no CAP_NET_ADMIN, say) are logged once: the log stays
|
||||
// empty, nothing else is affected.
|
||||
func Listen(ctx context.Context, group uint16, log *slog.Logger, handle func(api.LogEntry)) {
|
||||
fd, err := open(group)
|
||||
if err != nil {
|
||||
log.Warn("can't read logged packets, so the log stays empty", "group", group, "err", err)
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
unix.Close(fd)
|
||||
}()
|
||||
names := &ifnames{m: map[uint32]string{}}
|
||||
buf := make([]byte, 1<<16)
|
||||
for {
|
||||
n, _, err := unix.Recvfrom(fd, buf, 0)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if err == unix.ENOBUFS {
|
||||
continue // packets were lost; go on
|
||||
}
|
||||
log.Warn("reading logged packets failed", "err", err)
|
||||
return
|
||||
}
|
||||
for _, e := range Parse(buf[:n], names.name) {
|
||||
handle(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func open(group uint16) (int, error) {
|
||||
fd, err := unix.Socket(unix.AF_NETLINK, unix.SOCK_RAW|unix.SOCK_CLOEXEC, unix.NETLINK_NETFILTER)
|
||||
if err != nil {
|
||||
return -1, err
|
||||
}
|
||||
if err := unix.Bind(fd, &unix.SockaddrNetlink{Family: unix.AF_NETLINK}); err != nil {
|
||||
unix.Close(fd)
|
||||
return -1, err
|
||||
}
|
||||
// Bind the group, then ask for packets (their headers).
|
||||
cmd := attr(cfgCmd, []byte{cmdBind})
|
||||
mode := make([]byte, 6)
|
||||
binary.BigEndian.PutUint32(mode, copyRangeBytes)
|
||||
mode[4] = copyPacket
|
||||
for _, a := range [][]byte{cmd, attr(cfgMode, mode)} {
|
||||
if err := request(fd, group, a); err != nil {
|
||||
unix.Close(fd)
|
||||
return -1, fmt.Errorf("binding nflog group %d: %w", group, err)
|
||||
}
|
||||
}
|
||||
return fd, nil
|
||||
}
|
||||
|
||||
func attr(typ uint16, data []byte) []byte {
|
||||
l := 4 + len(data)
|
||||
b := make([]byte, (l+3)&^3)
|
||||
binary.LittleEndian.PutUint16(b, uint16(l))
|
||||
binary.LittleEndian.PutUint16(b[2:], typ)
|
||||
copy(b[4:], data)
|
||||
return b
|
||||
}
|
||||
|
||||
// request sends a config message and waits for its acknowledgement.
|
||||
func request(fd int, group uint16, a []byte) error {
|
||||
msg := make([]byte, headerLen+nfgenmsgLen+len(a))
|
||||
binary.LittleEndian.PutUint32(msg, uint32(len(msg)))
|
||||
binary.LittleEndian.PutUint16(msg[4:], subsysULOG<<8|msgConfig)
|
||||
binary.LittleEndian.PutUint16(msg[6:], unix.NLM_F_REQUEST|unix.NLM_F_ACK)
|
||||
msg[headerLen] = unix.AF_UNSPEC
|
||||
msg[headerLen+1] = nfnetlinkV0
|
||||
binary.BigEndian.PutUint16(msg[headerLen+2:], group)
|
||||
copy(msg[headerLen+nfgenmsgLen:], a)
|
||||
if err := unix.Sendto(fd, msg, 0, &unix.SockaddrNetlink{Family: unix.AF_NETLINK}); err != nil {
|
||||
return err
|
||||
}
|
||||
buf := make([]byte, 4096)
|
||||
n, _, err := unix.Recvfrom(fd, buf, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n >= headerLen+4 && binary.LittleEndian.Uint16(buf[4:]) == unix.NLMSG_ERROR {
|
||||
if code := int32(binary.LittleEndian.Uint32(buf[headerLen:])); code != 0 {
|
||||
return unix.Errno(-code)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type ifnames struct {
|
||||
mu sync.Mutex
|
||||
m map[uint32]string
|
||||
}
|
||||
|
||||
func (c *ifnames) name(i uint32) string {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if n, ok := c.m[i]; ok {
|
||||
return n
|
||||
}
|
||||
n := fmt.Sprint(i)
|
||||
if ifi, err := net.InterfaceByIndex(int(i)); err == nil {
|
||||
n = ifi.Name
|
||||
}
|
||||
c.m[i] = n
|
||||
return n
|
||||
}
|
||||
|
||||
// Parse decodes the logged packets in a netlink datagram; ifname turns
|
||||
// interface indexes into names.
|
||||
func Parse(b []byte, ifname func(uint32) string) []api.LogEntry {
|
||||
var out []api.LogEntry
|
||||
for len(b) >= headerLen {
|
||||
l := int(binary.LittleEndian.Uint32(b))
|
||||
typ := binary.LittleEndian.Uint16(b[4:])
|
||||
if l < headerLen || l > len(b) {
|
||||
break
|
||||
}
|
||||
if typ == subsysULOG<<8|msgPacket && l >= headerLen+nfgenmsgLen {
|
||||
if e, ok := packet(b[headerLen:l], ifname); ok {
|
||||
out = append(out, e)
|
||||
}
|
||||
}
|
||||
step := (l + 3) &^ 3
|
||||
if step >= len(b) {
|
||||
break
|
||||
}
|
||||
b = b[step:]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func packet(b []byte, ifname func(uint32) string) (api.LogEntry, bool) {
|
||||
e := api.LogEntry{Time: time.Now()}
|
||||
switch b[0] {
|
||||
case unix.AF_INET:
|
||||
e.Family = "ipv4"
|
||||
case unix.AF_INET6:
|
||||
e.Family = "ipv6"
|
||||
}
|
||||
attrs := b[nfgenmsgLen:]
|
||||
var payload []byte
|
||||
for len(attrs) >= 4 {
|
||||
l := int(binary.LittleEndian.Uint16(attrs))
|
||||
typ := binary.LittleEndian.Uint16(attrs[2:]) & 0x3fff
|
||||
if l < 4 || l > len(attrs) {
|
||||
break
|
||||
}
|
||||
v := attrs[4:l]
|
||||
switch typ {
|
||||
case attrPrefix:
|
||||
e.Prefix = strings.TrimRight(string(v), "\x00")
|
||||
case attrInDev:
|
||||
if len(v) >= 4 {
|
||||
e.In = ifname(binary.BigEndian.Uint32(v))
|
||||
}
|
||||
case attrOutDev:
|
||||
if len(v) >= 4 {
|
||||
e.Out = ifname(binary.BigEndian.Uint32(v))
|
||||
}
|
||||
case attrTimestamp:
|
||||
if len(v) >= 16 {
|
||||
sec, usec := binary.BigEndian.Uint64(v), binary.BigEndian.Uint64(v[8:])
|
||||
e.Time = time.Unix(int64(sec), int64(usec)*1000)
|
||||
}
|
||||
case attrPacketHdr:
|
||||
if len(v) >= 2 && e.Family == "" {
|
||||
switch binary.BigEndian.Uint16(v) {
|
||||
case 0x0800:
|
||||
e.Family = "ipv4"
|
||||
case 0x86dd:
|
||||
e.Family = "ipv6"
|
||||
}
|
||||
}
|
||||
case attrPayload:
|
||||
payload = v
|
||||
}
|
||||
attrs = attrs[min((l+3)&^3, len(attrs)):]
|
||||
}
|
||||
decodePayload(&e, payload)
|
||||
e.RuleMark, e.Verdict = markOf(e.Prefix)
|
||||
return e, e.Prefix != "" || payload != nil
|
||||
}
|
||||
|
||||
var icmpNames = map[byte]string{0: "echo-reply", 3: "destination-unreachable", 5: "redirect", 8: "echo-request", 11: "time-exceeded", 12: "parameter-problem"}
|
||||
var icmp6Names = map[byte]string{1: "destination-unreachable", 2: "packet-too-big", 3: "time-exceeded", 4: "parameter-problem", 128: "echo-request", 129: "echo-reply",
|
||||
133: "nd-router-solicit", 134: "nd-router-advert", 135: "nd-neighbor-solicit", 136: "nd-neighbor-advert"}
|
||||
|
||||
func decodePayload(e *api.LogEntry, p []byte) {
|
||||
var proto byte
|
||||
var l4 []byte
|
||||
switch {
|
||||
case len(p) >= 20 && p[0]>>4 == 4:
|
||||
e.Family = "ipv4"
|
||||
ihl := int(p[0]&0x0f) * 4
|
||||
e.Length = int(binary.BigEndian.Uint16(p[2:]))
|
||||
proto = p[9]
|
||||
e.Source = netip.AddrFrom4([4]byte(p[12:16])).String()
|
||||
e.Dest = netip.AddrFrom4([4]byte(p[16:20])).String()
|
||||
if ihl <= len(p) {
|
||||
l4 = p[ihl:]
|
||||
}
|
||||
case len(p) >= 40 && p[0]>>4 == 6:
|
||||
e.Family = "ipv6"
|
||||
e.Length = int(binary.BigEndian.Uint16(p[4:])) + 40
|
||||
proto = p[6]
|
||||
e.Source = netip.AddrFrom16([16]byte(p[8:24])).String()
|
||||
e.Dest = netip.AddrFrom16([16]byte(p[24:40])).String()
|
||||
l4 = p[40:]
|
||||
default:
|
||||
return
|
||||
}
|
||||
switch proto {
|
||||
case 6:
|
||||
e.Protocol = "tcp"
|
||||
if len(l4) >= 14 {
|
||||
e.SrcPort, e.DestPort = int(binary.BigEndian.Uint16(l4)), int(binary.BigEndian.Uint16(l4[2:]))
|
||||
e.TCPFlags = tcpFlags(l4[13])
|
||||
}
|
||||
case 17:
|
||||
e.Protocol = "udp"
|
||||
if len(l4) >= 4 {
|
||||
e.SrcPort, e.DestPort = int(binary.BigEndian.Uint16(l4)), int(binary.BigEndian.Uint16(l4[2:]))
|
||||
}
|
||||
case 132:
|
||||
e.Protocol = "sctp"
|
||||
if len(l4) >= 4 {
|
||||
e.SrcPort, e.DestPort = int(binary.BigEndian.Uint16(l4)), int(binary.BigEndian.Uint16(l4[2:]))
|
||||
}
|
||||
case 1:
|
||||
e.Protocol = "icmp"
|
||||
if len(l4) >= 1 {
|
||||
e.ICMPType = icmpNames[l4[0]]
|
||||
if e.ICMPType == "" {
|
||||
e.ICMPType = fmt.Sprint(l4[0])
|
||||
}
|
||||
}
|
||||
case 58:
|
||||
e.Protocol = "icmpv6"
|
||||
if len(l4) >= 1 {
|
||||
e.ICMPType = icmp6Names[l4[0]]
|
||||
if e.ICMPType == "" {
|
||||
e.ICMPType = fmt.Sprint(l4[0])
|
||||
}
|
||||
}
|
||||
default:
|
||||
e.Protocol = fmt.Sprint(proto)
|
||||
}
|
||||
}
|
||||
|
||||
func tcpFlags(f byte) string {
|
||||
var out []string
|
||||
for i, n := range []string{"fin", "syn", "rst", "psh", "ack", "urg"} {
|
||||
if f&(1<<i) != 0 {
|
||||
out = append(out, n)
|
||||
}
|
||||
}
|
||||
return strings.Join(out, ",")
|
||||
}
|
||||
|
||||
// Housecarl's log prefixes: "hc input #12 drop: " and
|
||||
// "hc forward policy drop: ".
|
||||
var (
|
||||
rulePrefix = regexp.MustCompile(`^hc \w+ #(\d+) (\w+):`)
|
||||
policyPrefix = regexp.MustCompile(`^hc (\w+) policy (\w+):`)
|
||||
)
|
||||
|
||||
func markOf(prefix string) (mark, verdict string) {
|
||||
if m := rulePrefix.FindStringSubmatch(prefix); m != nil {
|
||||
return "r" + m[1], m[2]
|
||||
}
|
||||
if m := policyPrefix.FindStringSubmatch(prefix); m != nil {
|
||||
return "p-" + m[1], m[2]
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package nflog
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// message builds an nfnetlink_log packet message as the kernel sends it.
|
||||
func message(family byte, attrs ...[]byte) []byte {
|
||||
body := []byte{family, 0, 0x10, 0x4b} // nfgenmsg: family, version, group 4171
|
||||
for _, a := range attrs {
|
||||
body = append(body, a...)
|
||||
}
|
||||
msg := make([]byte, headerLen, headerLen+len(body))
|
||||
binary.LittleEndian.PutUint32(msg, uint32(headerLen+len(body)))
|
||||
binary.LittleEndian.PutUint16(msg[4:], subsysULOG<<8|msgPacket)
|
||||
return append(msg, body...)
|
||||
}
|
||||
|
||||
func be32(v uint32) []byte { b := make([]byte, 4); binary.BigEndian.PutUint32(b, v); return b }
|
||||
|
||||
func TestParse(t *testing.T) {
|
||||
// An IPv4 TCP SYN from 203.0.113.9:40000 to 217.155.8.194:22.
|
||||
ip := []byte{0x45, 0, 0, 60, 0, 0, 0, 0, 64, 6, 0, 0, 203, 0, 113, 9, 217, 155, 8, 194}
|
||||
tcp := []byte{0x9c, 0x40, 0, 22, 0, 0, 0, 0, 0, 0, 0, 0, 0x50, 0x02, 0, 0, 0, 0, 0, 0}
|
||||
m1 := message(unix.AF_INET, attr(attrPrefix, []byte("hc input #12 drop: \x00")), attr(attrInDev, be32(3)), attr(attrPayload, append(ip, tcp...)))
|
||||
// An IPv6 echo request, logged by the forward policy.
|
||||
ip6 := make([]byte, 40)
|
||||
ip6[0], ip6[6] = 0x60, 58
|
||||
ip6[8], ip6[9], ip6[23] = 0x2a, 0x02, 0x01 // source 2a02::1
|
||||
ip6[24], ip6[25], ip6[39] = 0x20, 0x01, 0x57 // destination 2001::57
|
||||
m2 := message(unix.AF_INET6, attr(attrPrefix, []byte("hc forward policy drop: \x00")), attr(attrOutDev, be32(4)), attr(attrPayload, append(ip6, 128, 0, 0, 0)))
|
||||
names := map[uint32]string{3: "wan", 4: "enp2s0"}
|
||||
es := Parse(append(m1, m2...), func(i uint32) string { return names[i] })
|
||||
if len(es) != 2 {
|
||||
t.Fatalf("%d entries", len(es))
|
||||
}
|
||||
e := es[0]
|
||||
if e.Family != "ipv4" || e.Source != "203.0.113.9" || e.Dest != "217.155.8.194" || e.Protocol != "tcp" || e.SrcPort != 40000 ||
|
||||
e.DestPort != 22 || e.TCPFlags != "syn" || e.In != "wan" || e.RuleMark != "r12" || e.Verdict != "drop" || e.Length != 60 {
|
||||
t.Errorf("v4 entry %+v", e)
|
||||
}
|
||||
e = es[1]
|
||||
if e.Family != "ipv6" || e.Source != "2a02::1" || e.Dest != "2001::57" || e.Protocol != "icmpv6" || e.ICMPType != "echo-request" ||
|
||||
e.Out != "enp2s0" || e.RuleMark != "p-forward" {
|
||||
t.Errorf("v6 entry %+v", e)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,550 @@
|
||||
// Package nft runs the nft command: it checks and applies rendered
|
||||
// rulesets, reads counters and dynamic sets back as JSON, and maps nft's
|
||||
// error messages to the objects they came from.
|
||||
//
|
||||
// The nft command is used rather than a netlink library: it checks a
|
||||
// ruleset without applying it (nft -c), applies a whole script in one
|
||||
// transaction, and its errors are readable.
|
||||
package nft
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"housecarl/internal/model"
|
||||
"housecarl/internal/render"
|
||||
)
|
||||
|
||||
// Runner runs nft.
|
||||
type Runner struct {
|
||||
// Command is how nft is run: ["nft"] (the default), or a wrapper such
|
||||
// as ["docker", "run", "--rm", "-i", "--cap-add", "NET_ADMIN", "IMAGE",
|
||||
// "nft"] in tests.
|
||||
Command []string
|
||||
}
|
||||
|
||||
// Wrapper reads HOUSECARL_NFT, a command line that runs nft (tests point
|
||||
// it at a container), or returns ["nft"].
|
||||
func Wrapper() []string {
|
||||
if v := strings.TrimSpace(os.Getenv("HOUSECARL_NFT")); v != "" {
|
||||
return strings.Fields(v)
|
||||
}
|
||||
return []string{"nft"}
|
||||
}
|
||||
|
||||
func (r *Runner) cmd(ctx context.Context, args ...string) *exec.Cmd {
|
||||
c := r.Command
|
||||
if len(c) == 0 {
|
||||
c = []string{"nft"}
|
||||
}
|
||||
return exec.CommandContext(ctx, c[0], append(append([]string{}, c[1:]...), args...)...)
|
||||
}
|
||||
|
||||
// run runs nft with args, script on stdin.
|
||||
func (r *Runner) run(ctx context.Context, script string, args ...string) ([]byte, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 60*time.Second)
|
||||
defer cancel()
|
||||
c := r.cmd(ctx, args...)
|
||||
if script != "" {
|
||||
c.Stdin = strings.NewReader(script)
|
||||
}
|
||||
var out, errb bytes.Buffer
|
||||
c.Stdout, c.Stderr = &out, &errb
|
||||
if err := c.Run(); err != nil {
|
||||
msg := strings.TrimSpace(errb.String())
|
||||
if msg == "" {
|
||||
msg = err.Error()
|
||||
}
|
||||
return out.Bytes(), &Error{Output: msg}
|
||||
}
|
||||
return out.Bytes(), nil
|
||||
}
|
||||
|
||||
// Error is nft's complaint.
|
||||
type Error struct {
|
||||
Output string
|
||||
// Object is what the first failing line came from, when known.
|
||||
Object string
|
||||
// Message is the first error line, without the file and position.
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *Error) Error() string {
|
||||
if e.Object != "" && e.Message != "" {
|
||||
return e.Object + ": " + e.Message
|
||||
}
|
||||
if e.Message != "" {
|
||||
return e.Message
|
||||
}
|
||||
return e.Output
|
||||
}
|
||||
|
||||
// errLine matches "/dev/stdin:12:3-10: Error: …".
|
||||
var errLine = regexp.MustCompile(`(?m)^[^:\n]*:(\d+):[0-9-]+: Error: (.*)$`)
|
||||
|
||||
// explain fills in Object and Message from nft's output and the rendered
|
||||
// ruleset.
|
||||
func explain(err error, rs *render.Ruleset) error {
|
||||
e, ok := err.(*Error)
|
||||
if !ok {
|
||||
return err
|
||||
}
|
||||
if m := errLine.FindStringSubmatch(e.Output); m != nil {
|
||||
line, _ := strconv.Atoi(m[1])
|
||||
e.Message = strings.TrimSpace(m[2])
|
||||
e.Object = rs.Object(line)
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// Check has nft check a ruleset without applying it.
|
||||
func (r *Runner) Check(ctx context.Context, rs *render.Ruleset) error {
|
||||
_, err := r.run(ctx, rs.Text, "-c", "-f", "/dev/stdin")
|
||||
return explain(err, rs)
|
||||
}
|
||||
|
||||
// Apply applies a ruleset (one transaction: all or nothing).
|
||||
func (r *Runner) Apply(ctx context.Context, rs *render.Ruleset) error {
|
||||
_, err := r.run(ctx, rs.Text, "-f", "/dev/stdin")
|
||||
return explain(err, rs)
|
||||
}
|
||||
|
||||
// ListTable returns Housecarl's table as nft JSON (nil if it doesn't
|
||||
// exist).
|
||||
func (r *Runner) ListTable(ctx context.Context) ([]byte, error) {
|
||||
out, err := r.run(ctx, "", "-j", "list", "table", "inet", render.Table)
|
||||
if err != nil {
|
||||
if e, ok := err.(*Error); ok && strings.Contains(e.Output, "No such file or directory") {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ListRuleset returns the whole ruleset as nft JSON (the importer's
|
||||
// input on this machine).
|
||||
func (r *Runner) ListRuleset(ctx context.Context) ([]byte, error) {
|
||||
return r.run(ctx, "", "-j", "list", "ruleset")
|
||||
}
|
||||
|
||||
// TableText returns a table as an nft script that recreates it.
|
||||
func (r *Runner) TableText(ctx context.Context, family, name string) (string, error) {
|
||||
out, err := r.run(ctx, "", "list", "table", family, name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "table " + family + " " + name + " {}\ndelete table " + family + " " + name + "\n" + string(out), nil
|
||||
}
|
||||
|
||||
// ApplyScript applies an nft script (a takeover's rollback).
|
||||
func (r *Runner) ApplyScript(ctx context.Context, script string) error {
|
||||
_, err := r.run(ctx, script, "-f", "/dev/stdin")
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteTable deletes a table (hc takeover: the ruleset Housecarl
|
||||
// replaces).
|
||||
func (r *Runner) DeleteTable(ctx context.Context, family, name string) error {
|
||||
_, err := r.run(ctx, "", "delete", "table", family, name)
|
||||
return err
|
||||
}
|
||||
|
||||
// AddElement adds an entry to a dynamic set's family part, with a timeout
|
||||
// in seconds (0: the set's default).
|
||||
func (r *Runner) AddElement(ctx context.Context, set, addr string, timeout int) error {
|
||||
a, err := model.ParseAddress(addr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
t := ""
|
||||
if timeout > 0 {
|
||||
t = fmt.Sprintf(" timeout %ds", timeout)
|
||||
}
|
||||
_, err = r.run(ctx, fmt.Sprintf("add element inet %s %s { %s%s }\n", render.Table, render.SetName(set, a.IsV6()), a, t), "-f", "/dev/stdin")
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteElement removes an entry from a dynamic set.
|
||||
func (r *Runner) DeleteElement(ctx context.Context, set, addr string) error {
|
||||
a, err := model.ParseAddress(addr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = r.run(ctx, fmt.Sprintf("delete element inet %s %s { %s }\n", render.Table, render.SetName(set, a.IsV6()), a), "-f", "/dev/stdin")
|
||||
return err
|
||||
}
|
||||
|
||||
// ---- reading JSON
|
||||
|
||||
// Table is what Housecarl reads back from its table: counters by mark
|
||||
// and the dynamic sets' entries.
|
||||
type Table struct {
|
||||
// Counters by the comment's mark ("r12", "f3", "b-established",
|
||||
// "p-input"); several nftables rules of one object add up.
|
||||
Counters map[string]Counter
|
||||
// Elements by set name (both families together).
|
||||
Elements map[string][]render.Element
|
||||
// Quotas: the rules' quotas' used bytes (quota_r12), and the
|
||||
// per-address quotas' entries by set (quota_r12_v4).
|
||||
QuotaUsage
|
||||
// Since is when the counters started (housecarld's first apply).
|
||||
Since time.Time
|
||||
// Tables are all the machine's tables ("inet main"), when the whole
|
||||
// ruleset was read.
|
||||
Tables []string
|
||||
}
|
||||
|
||||
// QuotaUsage is what the quotas used: kept across reloads, and saved in
|
||||
// the database so a reboot doesn't reset it.
|
||||
type QuotaUsage struct {
|
||||
Quotas map[string]int64 `json:"quotas,omitempty"`
|
||||
QuotaSets map[string][]render.Element `json:"quota_sets,omitempty"`
|
||||
}
|
||||
|
||||
// merge takes the larger usage of each quota and entry from o (what was
|
||||
// saved before a reboot, when the kernel's count started again).
|
||||
func (u *QuotaUsage) merge(o *QuotaUsage) {
|
||||
if o == nil {
|
||||
return
|
||||
}
|
||||
if u.Quotas == nil {
|
||||
u.Quotas = map[string]int64{}
|
||||
}
|
||||
if u.QuotaSets == nil {
|
||||
u.QuotaSets = map[string][]render.Element{}
|
||||
}
|
||||
for k, v := range o.Quotas {
|
||||
if v > u.Quotas[k] {
|
||||
u.Quotas[k] = v
|
||||
}
|
||||
}
|
||||
for set, es := range o.QuotaSets {
|
||||
for _, e := range es {
|
||||
found := false
|
||||
for i := range u.QuotaSets[set] {
|
||||
if u.QuotaSets[set][i].Addr == e.Addr {
|
||||
found = true
|
||||
if e.Used > u.QuotaSets[set][i].Used {
|
||||
u.QuotaSets[set][i].Used = e.Used
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
u.QuotaSets[set] = append(u.QuotaSets[set], e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Counter is packets and bytes.
|
||||
type Counter struct {
|
||||
Packets uint64 `json:"packets"`
|
||||
Bytes uint64 `json:"bytes"`
|
||||
}
|
||||
|
||||
type jsonDoc struct {
|
||||
Nftables []map[string]json.RawMessage `json:"nftables"`
|
||||
}
|
||||
|
||||
type jsonRule struct {
|
||||
Comment string `json:"comment"`
|
||||
Expr []json.RawMessage `json:"expr"`
|
||||
}
|
||||
|
||||
type jsonSet struct {
|
||||
Name string `json:"name"`
|
||||
Elem []json.RawMessage `json:"elem"`
|
||||
}
|
||||
|
||||
// jsonQuota is a quota's state: a named quota ({"bytes", "used"}, in
|
||||
// bytes) or one in an element ({"val", "val_unit", "used", "used_unit"}).
|
||||
type jsonQuota struct {
|
||||
Name string `json:"name"`
|
||||
Used int64 `json:"used"`
|
||||
UsedUnit string `json:"used_unit"`
|
||||
}
|
||||
|
||||
func (q jsonQuota) bytes() int64 {
|
||||
if m, ok := model.ByteUnits[q.UsedUnit]; ok {
|
||||
return q.Used * m
|
||||
}
|
||||
return q.Used
|
||||
}
|
||||
|
||||
// ParseTable reads `nft -j list table inet housecarl`.
|
||||
func ParseTable(data []byte) (*Table, error) {
|
||||
t := &Table{Counters: map[string]Counter{}, Elements: map[string][]render.Element{},
|
||||
QuotaUsage: QuotaUsage{Quotas: map[string]int64{}, QuotaSets: map[string][]render.Element{}}}
|
||||
if len(data) == 0 {
|
||||
return t, nil
|
||||
}
|
||||
var doc jsonDoc
|
||||
if err := json.Unmarshal(data, &doc); err != nil {
|
||||
return nil, fmt.Errorf("reading nft's JSON: %w", err)
|
||||
}
|
||||
for _, item := range doc.Nftables {
|
||||
if raw, ok := item["rule"]; ok {
|
||||
var r jsonRule
|
||||
if json.Unmarshal(raw, &r) != nil || !strings.HasPrefix(r.Comment, "hc:") {
|
||||
continue
|
||||
}
|
||||
mark, _, _ := strings.Cut(strings.TrimPrefix(r.Comment, "hc:"), " ")
|
||||
for _, e := range r.Expr {
|
||||
var c struct {
|
||||
Counter *Counter `json:"counter"`
|
||||
}
|
||||
if json.Unmarshal(e, &c) == nil && c.Counter != nil {
|
||||
cur := t.Counters[mark]
|
||||
cur.Packets += c.Counter.Packets
|
||||
cur.Bytes += c.Counter.Bytes
|
||||
t.Counters[mark] = cur
|
||||
}
|
||||
}
|
||||
}
|
||||
if raw, ok := item["quota"]; ok {
|
||||
var q jsonQuota
|
||||
if json.Unmarshal(raw, &q) == nil && strings.HasPrefix(q.Name, "quota_r") {
|
||||
t.Quotas[q.Name] = q.bytes()
|
||||
}
|
||||
}
|
||||
if raw, ok := item["set"]; ok {
|
||||
var s jsonSet
|
||||
if json.Unmarshal(raw, &s) != nil {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(s.Name, "quota_r") {
|
||||
for _, e := range s.Elem {
|
||||
var w struct {
|
||||
Elem struct {
|
||||
Val string `json:"val"`
|
||||
Quota *jsonQuota `json:"quota"`
|
||||
} `json:"elem"`
|
||||
}
|
||||
if json.Unmarshal(e, &w) == nil && w.Elem.Quota != nil {
|
||||
t.QuotaSets[s.Name] = append(t.QuotaSets[s.Name], render.Element{Addr: w.Elem.Val, Used: w.Elem.Quota.bytes()})
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSuffix(strings.TrimSuffix(s.Name, "_v4"), "_v6")
|
||||
if name == s.Name || strings.HasPrefix(s.Name, "meter_") || strings.HasPrefix(s.Name, "conn_") {
|
||||
continue
|
||||
}
|
||||
for _, e := range s.Elem {
|
||||
if el, ok := parseElem(e); ok {
|
||||
t.Elements[name] = append(t.Elements[name], el)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// parseElem reads a set element: "10.0.0.5", or {"elem": {"val":
|
||||
// "10.0.0.5", "timeout": 1800, "expires": 1799}}.
|
||||
func parseElem(raw json.RawMessage) (render.Element, bool) {
|
||||
var s string
|
||||
if json.Unmarshal(raw, &s) == nil {
|
||||
return render.Element{Addr: s}, true
|
||||
}
|
||||
var w struct {
|
||||
Elem struct {
|
||||
Val json.RawMessage `json:"val"`
|
||||
Expires int `json:"expires"`
|
||||
} `json:"elem"`
|
||||
}
|
||||
if json.Unmarshal(raw, &w) != nil || json.Unmarshal(w.Elem.Val, &s) != nil {
|
||||
return render.Element{}, false
|
||||
}
|
||||
return render.Element{Addr: s, Timeout: w.Elem.Expires}, true
|
||||
}
|
||||
|
||||
// ---- the firewall
|
||||
|
||||
// Firewall is the engine's Firewall: it renders configurations and has
|
||||
// nft check and apply them, carrying dynamic sets' entries over.
|
||||
type Firewall struct {
|
||||
Runner *Runner
|
||||
// Interfaces lists the interfaces that exist now (nil: assume all).
|
||||
Interfaces func() map[string]bool
|
||||
// BootFile is where the confirmed ruleset is saved for the boot unit
|
||||
// (housecarl-ruleset.service; "" for none).
|
||||
BootFile string
|
||||
|
||||
// Replacing the table resets its counters: base keeps the totals of
|
||||
// the tables replaced since housecarld started.
|
||||
mu sync.Mutex
|
||||
base map[string]Counter
|
||||
since time.Time
|
||||
// restore is the quota usage saved before housecarld (or the machine)
|
||||
// stopped, put back by the next apply.
|
||||
restore *QuotaUsage
|
||||
}
|
||||
|
||||
// RestoreQuotas has the next apply put this saved quota usage back where
|
||||
// the kernel's count is lower (after a reboot, the boot ruleset starts
|
||||
// every quota at zero).
|
||||
func (f *Firewall) RestoreQuotas(u *QuotaUsage) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.restore = u
|
||||
}
|
||||
|
||||
// ResetQuota starts a rule's quota again: the named quota, or the
|
||||
// per-address quota sets (both families).
|
||||
func (f *Firewall) ResetQuota(ctx context.Context, ruleID int64, perAddress bool) error {
|
||||
name := render.QuotaName(ruleID)
|
||||
if !perAddress {
|
||||
_, err := f.Runner.run(ctx, "", "reset", "quota", "inet", render.Table, name)
|
||||
return err
|
||||
}
|
||||
for _, fam := range []string{"_v4", "_v6"} {
|
||||
if _, err := f.Runner.run(ctx, "", "flush", "set", "inet", render.Table, name+fam); err != nil {
|
||||
if e, ok := err.(*Error); ok && strings.Contains(e.Output, "No such file or directory") {
|
||||
continue // the rule has no such family
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *Firewall) options() render.Options {
|
||||
var o render.Options
|
||||
if f.Interfaces != nil {
|
||||
o.Interfaces = f.Interfaces()
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
// Render builds the ruleset for st as Apply would.
|
||||
func (f *Firewall) Render(st *model.State) *render.Ruleset { return render.Render(st, f.options()) }
|
||||
|
||||
// Check implements engine.Firewall.
|
||||
func (f *Firewall) Check(ctx context.Context, st *model.State) error {
|
||||
return f.Runner.Check(ctx, render.Render(st, f.options()))
|
||||
}
|
||||
|
||||
// Apply implements engine.Firewall.
|
||||
func (f *Firewall) Apply(ctx context.Context, st *model.State) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
opt := f.options()
|
||||
var old *Table
|
||||
if data, err := f.Runner.ListTable(ctx); err == nil {
|
||||
if t, err := ParseTable(data); err == nil {
|
||||
opt.Elements = t.Elements
|
||||
old = t
|
||||
}
|
||||
}
|
||||
usage := QuotaUsage{}
|
||||
if old != nil {
|
||||
usage = old.QuotaUsage
|
||||
}
|
||||
usage.merge(f.restore)
|
||||
opt.Quotas, opt.QuotaSets = usage.Quotas, usage.QuotaSets
|
||||
if err := f.Runner.Apply(ctx, render.Render(st, opt)); err != nil {
|
||||
return err
|
||||
}
|
||||
f.restore = nil
|
||||
if f.base == nil {
|
||||
f.base, f.since = map[string]Counter{}, time.Now()
|
||||
}
|
||||
if old != nil {
|
||||
for k, c := range old.Counters {
|
||||
b := f.base[k]
|
||||
b.Packets += c.Packets
|
||||
b.Bytes += c.Bytes
|
||||
f.base[k] = b
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SaveBoot writes the ruleset of st for the boot unit, atomically.
|
||||
func (f *Firewall) SaveBoot(st *model.State) error {
|
||||
if f.BootFile == "" {
|
||||
return nil
|
||||
}
|
||||
rs := render.Render(st, f.options())
|
||||
tmp := f.BootFile + ".tmp"
|
||||
if err := os.MkdirAll(filepath.Dir(f.BootFile), 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(tmp, []byte(rs.Text), 0o640); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, f.BootFile)
|
||||
}
|
||||
|
||||
// Table reads Housecarl's table back, with counters totalled since
|
||||
// housecarld started (across reloads).
|
||||
func (f *Firewall) Table(ctx context.Context) (*Table, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
data, err := f.Runner.ListTable(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if data == nil {
|
||||
return nil, ErrNoTable
|
||||
}
|
||||
t, err := ParseTable(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for k, b := range f.base {
|
||||
c := t.Counters[k]
|
||||
c.Packets += b.Packets
|
||||
c.Bytes += b.Bytes
|
||||
t.Counters[k] = c
|
||||
}
|
||||
t.Since = f.since
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// ErrNoTable: Housecarl's table isn't in the ruleset.
|
||||
var ErrNoTable = errors.New("there is no table inet housecarl: Housecarl's ruleset isn't in force")
|
||||
|
||||
// AddElement adds an entry to a dynamic set.
|
||||
func (f *Firewall) AddElement(ctx context.Context, set, addr string, timeout int) error {
|
||||
return f.Runner.AddElement(ctx, set, addr, timeout)
|
||||
}
|
||||
|
||||
// DeleteElement removes an entry from a dynamic set.
|
||||
func (f *Firewall) DeleteElement(ctx context.Context, set, addr string) error {
|
||||
return f.Runner.DeleteElement(ctx, set, addr)
|
||||
}
|
||||
|
||||
// ListRuleset returns the machine's whole ruleset as JSON.
|
||||
func (f *Firewall) ListRuleset(ctx context.Context) ([]byte, error) { return f.Runner.ListRuleset(ctx) }
|
||||
|
||||
// TableText returns another table as a script that recreates it.
|
||||
func (f *Firewall) TableText(ctx context.Context, family, name string) (string, error) {
|
||||
return f.Runner.TableText(ctx, family, name)
|
||||
}
|
||||
|
||||
// ApplyScript applies an nft script.
|
||||
func (f *Firewall) ApplyScript(ctx context.Context, script string) error {
|
||||
return f.Runner.ApplyScript(ctx, script)
|
||||
}
|
||||
|
||||
// DeleteTable deletes another table (takeover).
|
||||
func (f *Firewall) DeleteTable(ctx context.Context, family, name string) error {
|
||||
if name == render.Table && family == "inet" {
|
||||
return fmt.Errorf("that is Housecarl's own table")
|
||||
}
|
||||
return f.Runner.DeleteTable(ctx, family, name)
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package nft
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"housecarl/internal/model"
|
||||
"housecarl/internal/render"
|
||||
)
|
||||
|
||||
func TestParseTable(t *testing.T) {
|
||||
data := []byte(`{"nftables": [{"metainfo": {"version": "1.1.3"}},
|
||||
{"set": {"family": "inet", "name": "guests_v4", "table": "housecarl", "type": "ipv4_addr", "flags": ["timeout"],
|
||||
"elem": [{"elem": {"val": "10.0.30.5", "timeout": 1800, "expires": 1799}}, "10.0.30.6"]}},
|
||||
{"set": {"family": "inet", "name": "meter_r4_v4", "table": "housecarl", "elem": ["10.0.0.1"]}},
|
||||
{"rule": {"family": "inet", "table": "housecarl", "chain": "input", "comment": "hc:r12 ssh",
|
||||
"expr": [{"counter": {"packets": 5, "bytes": 300}}, {"accept": null}]}},
|
||||
{"rule": {"family": "inet", "table": "housecarl", "chain": "input", "comment": "hc:r12 ssh",
|
||||
"expr": [{"counter": {"packets": 2, "bytes": 100}}, {"accept": null}]}},
|
||||
{"rule": {"family": "inet", "table": "housecarl", "chain": "input", "comment": "not ours",
|
||||
"expr": [{"counter": {"packets": 9, "bytes": 900}}]}}]}`)
|
||||
tb, err := ParseTable(data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c := tb.Counters["r12"]; c.Packets != 7 || c.Bytes != 400 || len(tb.Counters) != 1 {
|
||||
t.Errorf("counters %+v", tb.Counters)
|
||||
}
|
||||
if e := tb.Elements["guests"]; len(e) != 2 || e[0] != (render.Element{Addr: "10.0.30.5", Timeout: 1799}) || e[1].Addr != "10.0.30.6" || len(tb.Elements) != 1 {
|
||||
t.Errorf("elements %+v", tb.Elements)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExplain(t *testing.T) {
|
||||
rs := &render.Ruleset{Lines: map[int]string{10: "input #4 (ssh)"}}
|
||||
err := explain(&Error{Output: "/dev/stdin:11:3-12: Error: syntax error, unexpected junk\n\t\tjunk\n"}, rs)
|
||||
if err.Error() != "input #4 (ssh): syntax error, unexpected junk" {
|
||||
t.Errorf("explained: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFirewallInContainer applies rulesets in a container with nft
|
||||
// (HOUSECARL_NFT_IMAGE, e.g. housecarl-nft: Debian 13 with nftables):
|
||||
// dynamic sets keep their entries across a reload, counters come back,
|
||||
// and a broken rule is reported against its object.
|
||||
func TestFirewallInContainer(t *testing.T) {
|
||||
image := os.Getenv("HOUSECARL_NFT_IMAGE")
|
||||
if image == "" {
|
||||
t.Skip("HOUSECARL_NFT_IMAGE not set")
|
||||
}
|
||||
name := "housecarl-nft-test"
|
||||
exec.Command("docker", "rm", "-f", name).Run()
|
||||
if out, err := exec.Command("docker", "run", "-d", "--name", name, "--cap-add", "NET_ADMIN", image, "sleep", "300").CombinedOutput(); err != nil {
|
||||
t.Fatalf("starting the container: %v %s", err, out)
|
||||
}
|
||||
t.Cleanup(func() { exec.Command("docker", "rm", "-f", name).Run() })
|
||||
fw := &Firewall{Runner: &Runner{Command: []string{"docker", "exec", "-i", name, "nft"}}}
|
||||
ctx := context.Background()
|
||||
|
||||
st := model.NewState()
|
||||
st.Zones = []*model.Zone{{ID: 1, Name: "wan", Interfaces: []string{"eth0"}, External: true}}
|
||||
st.Sets = []*model.AddressSet{{ID: 2, Name: "guests", Dynamic: true, Timeout: 3600}}
|
||||
st.Rules = []*model.Rule{{ID: 3, Chain: model.ChainOutput, Position: 1, Action: model.ActionAccept, Match: model.Match{Destinations: []string{"guests"}}}}
|
||||
if err := fw.Check(ctx, st); err != nil {
|
||||
t.Fatalf("check: %v", err)
|
||||
}
|
||||
if _, err := fw.Table(ctx); err != ErrNoTable {
|
||||
t.Errorf("before applying, the table: %v", err)
|
||||
}
|
||||
if err := fw.Apply(ctx, st); err != nil {
|
||||
t.Fatalf("apply: %v", err)
|
||||
}
|
||||
if err := fw.Runner.AddElement(ctx, "guests", "127.0.0.2", 600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := fw.Runner.AddElement(ctx, "guests", "::2", 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
exec.Command("docker", "exec", name, "ping", "-c", "2", "-W", "1", "127.0.0.2").Run()
|
||||
// Reapplying keeps the entries.
|
||||
st.Settings.ForwardPolicy = model.PolicyAccept
|
||||
if err := fw.Apply(ctx, st); err != nil {
|
||||
t.Fatalf("reapply: %v", err)
|
||||
}
|
||||
tb, err := fw.Table(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got []string
|
||||
for _, e := range tb.Elements["guests"] {
|
||||
got = append(got, e.Addr)
|
||||
if e.Addr == "127.0.0.2" && (e.Timeout < 500 || e.Timeout > 600) {
|
||||
t.Errorf("timeout after reload: %+v", e)
|
||||
}
|
||||
}
|
||||
if strings.Join(got, ",") != "127.0.0.2,::2" {
|
||||
t.Errorf("entries after reload: %v", got)
|
||||
}
|
||||
if _, ok := tb.Counters["r3"]; !ok {
|
||||
t.Errorf("counters %+v", tb.Counters)
|
||||
}
|
||||
if tb.Counters["b-established"].Packets == 0 && tb.Counters["r3"].Packets == 0 {
|
||||
t.Logf("no packets counted (ping may be missing): %+v", tb.Counters)
|
||||
}
|
||||
// A broken raw rule: refused, named, and nothing changes.
|
||||
st.Rules = append(st.Rules, &model.Rule{ID: 4, Chain: model.ChainInput, Position: 1, Action: model.ActionAccept, Raw: "tcp dport bogus accept", Comment: "broken"})
|
||||
err = fw.Check(ctx, st)
|
||||
if err == nil || !strings.Contains(err.Error(), "input #4 (broken)") {
|
||||
t.Errorf("check of a broken rule: %v", err)
|
||||
}
|
||||
if err := fw.Apply(ctx, st); err == nil {
|
||||
t.Error("a broken ruleset was applied")
|
||||
}
|
||||
tb, _ = fw.Table(ctx)
|
||||
if len(tb.Elements["guests"]) != 2 {
|
||||
t.Errorf("a refused apply changed the table: %+v", tb.Elements)
|
||||
}
|
||||
// The table is Housecarl's alone: another one is left alone.
|
||||
exec.Command("docker", "exec", name, "nft", "add", "table", "inet", "other").Run()
|
||||
fw.Apply(ctx, st.Clone())
|
||||
if out, _ := exec.Command("docker", "exec", name, "nft", "list", "tables").CombinedOutput(); !strings.Contains(string(out), "inet other") {
|
||||
t.Errorf("another table was touched: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestQuotasInContainer: quotas keep what they used across a reload,
|
||||
// reset by hand, and come back after a reboot from what was saved.
|
||||
func TestQuotasInContainer(t *testing.T) {
|
||||
image := os.Getenv("HOUSECARL_NFT_IMAGE")
|
||||
if image == "" {
|
||||
t.Skip("HOUSECARL_NFT_IMAGE not set")
|
||||
}
|
||||
name := "housecarl-nft-quota-test"
|
||||
exec.Command("docker", "rm", "-f", name).Run()
|
||||
if out, err := exec.Command("docker", "run", "-d", "--name", name, "--cap-add", "NET_ADMIN", image, "sleep", "300").CombinedOutput(); err != nil {
|
||||
t.Fatalf("starting the container: %v %s", err, out)
|
||||
}
|
||||
t.Cleanup(func() { exec.Command("docker", "rm", "-f", name).Run() })
|
||||
fw := &Firewall{Runner: &Runner{Command: []string{"docker", "exec", "-i", name, "nft"}}}
|
||||
ctx := context.Background()
|
||||
st := model.NewState()
|
||||
// A total quota on pings to 127.0.0.2, and one per destination on
|
||||
// pings to 127.0.0.3.
|
||||
st.Rules = []*model.Rule{
|
||||
{ID: 1, Chain: model.ChainOutput, Position: 1, Action: model.ActionDrop, Match: model.Match{Destinations: []string{"127.0.0.2"}},
|
||||
Quota: &model.Quota{Bytes: 10 << 20, Over: true}},
|
||||
{ID: 2, Chain: model.ChainOutput, Position: 2, Action: model.ActionDrop, Match: model.Match{Destinations: []string{"127.0.0.3"}},
|
||||
Quota: &model.Quota{Bytes: 10 << 20, Over: true, PerDestination: true}},
|
||||
}
|
||||
if err := fw.Apply(ctx, st); err != nil {
|
||||
t.Fatalf("apply: %v", err)
|
||||
}
|
||||
ping := func(addr string) {
|
||||
exec.Command("docker", "exec", name, "ping", "-c", "3", "-s", "1000", "-W", "1", addr).Run()
|
||||
}
|
||||
ping("127.0.0.2")
|
||||
ping("127.0.0.3")
|
||||
used := func() (int64, int64) {
|
||||
tb, err := fw.Table(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var dev int64
|
||||
for _, e := range tb.QuotaSets["quota_r2_v4"] {
|
||||
if e.Addr == "127.0.0.3" {
|
||||
dev = e.Used
|
||||
}
|
||||
}
|
||||
return tb.Quotas["quota_r1"], dev
|
||||
}
|
||||
total, dev := used()
|
||||
if total < 3000 || dev < 3000 {
|
||||
t.Fatalf("used: total %d, per destination %d", total, dev)
|
||||
}
|
||||
// A reload (a change) keeps what was used.
|
||||
st.Rules[0].Comment = "changed"
|
||||
if err := fw.Apply(ctx, st); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if t2, d2 := used(); t2 < total || d2 < dev {
|
||||
t.Errorf("after a reload: total %d (was %d), per destination %d (was %d)", t2, total, d2, dev)
|
||||
}
|
||||
// Reset by hand.
|
||||
if err := fw.ResetQuota(ctx, 1, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := fw.ResetQuota(ctx, 2, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if t3, d3 := used(); t3 != 0 || d3 != 0 {
|
||||
t.Errorf("after a reset: total %d, per destination %d", t3, d3)
|
||||
}
|
||||
// A reboot: the table is gone (the boot ruleset has no usage); the
|
||||
// saved usage comes back.
|
||||
ping("127.0.0.2")
|
||||
ping("127.0.0.3")
|
||||
tb, _ := fw.Table(ctx)
|
||||
saved := tb.QuotaUsage
|
||||
exec.Command("docker", "exec", name, "nft", "delete", "table", "inet", "housecarl").Run()
|
||||
fw.RestoreQuotas(&saved)
|
||||
if err := fw.Apply(ctx, st); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if t4, d4 := used(); t4 < saved.Quotas["quota_r1"] || t4 == 0 || d4 == 0 {
|
||||
t.Errorf("after a reboot: total %d, per destination %d (saved %+v)", t4, d4, saved)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,951 @@
|
||||
// Package render builds the nftables ruleset from a configuration: one
|
||||
// table, inet housecarl, replaced as a whole in one transaction. Nothing
|
||||
// else on the machine is touched (docs/design.md).
|
||||
//
|
||||
// A rule can become several nftables rules: one per address family it
|
||||
// matches, per named set it refers to (a set can't be ORed with another in
|
||||
// one rule) and per transport protocol of its services. Each carries a
|
||||
// counter and a comment "hc:<kind><id>", which is how counters are read
|
||||
// back, and Ruleset.Lines maps each line to its object, for nftables'
|
||||
// error messages.
|
||||
package render
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"housecarl/internal/model"
|
||||
)
|
||||
|
||||
// Table is Housecarl's table.
|
||||
const Table = "housecarl"
|
||||
|
||||
// LogGroup is the netlink log group logged packets are sent to, where
|
||||
// housecarld reads them (internal/nflog).
|
||||
const LogGroup = 4171
|
||||
|
||||
// Options carry what rendering needs from the running system.
|
||||
type Options struct {
|
||||
// Interfaces that exist now; flowtable devices that don't are left
|
||||
// out (nftables refuses them). Nil: assume they all exist.
|
||||
Interfaces map[string]bool
|
||||
// Elements are dynamic sets' current entries, re-added in the same
|
||||
// transaction so a reload keeps them: set name → entries.
|
||||
Elements map[string][]Element
|
||||
// Quotas are the rules' quotas' used bytes (quota name, quota_r12),
|
||||
// and QuotaSets the per-address quotas' entries (set name,
|
||||
// quota_r12_v4), so a reload keeps what was used.
|
||||
Quotas map[string]int64
|
||||
QuotaSets map[string][]Element
|
||||
}
|
||||
|
||||
// Element is a dynamic set's entry with its remaining lifetime, or a
|
||||
// per-address quota's entry with what it used.
|
||||
type Element struct {
|
||||
Addr string `json:"address"`
|
||||
Timeout int `json:"timeout,omitempty"` // seconds left (0: none)
|
||||
Used int64 `json:"used,omitempty"` // bytes
|
||||
}
|
||||
|
||||
// QuotaName is the name of a rule's quota: the named quota of a total
|
||||
// quota, and (with _v4, _v6) the sets of a per-address one.
|
||||
func QuotaName(ruleID int64) string { return fmt.Sprintf("quota_r%d", ruleID) }
|
||||
|
||||
// Ruleset is the rendered nftables script.
|
||||
type Ruleset struct {
|
||||
Text string
|
||||
// Lines maps a line number (1-based) to the object it came from
|
||||
// ("input #12 (ssh)", "built-in: established").
|
||||
Lines map[int]string
|
||||
}
|
||||
|
||||
// Object returns what the line belongs to (nearest object above it).
|
||||
func (r *Ruleset) Object(line int) string {
|
||||
for l := line; l > 0; l-- {
|
||||
if o, ok := r.Lines[l]; ok {
|
||||
return o
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type writer struct {
|
||||
b strings.Builder
|
||||
line int
|
||||
lines map[int]string
|
||||
}
|
||||
|
||||
func (w *writer) emit(obj, format string, args ...any) {
|
||||
w.line++
|
||||
if obj != "" {
|
||||
w.lines[w.line] = obj
|
||||
}
|
||||
fmt.Fprintf(&w.b, format, args...)
|
||||
w.b.WriteByte('\n')
|
||||
}
|
||||
|
||||
// Render builds the ruleset for st.
|
||||
func Render(st *model.State, opt Options) *Ruleset {
|
||||
w := &writer{lines: map[int]string{}}
|
||||
r := &renderer{st: st, opt: opt, w: w}
|
||||
r.render()
|
||||
return &Ruleset{Text: w.b.String(), Lines: w.lines}
|
||||
}
|
||||
|
||||
type renderer struct {
|
||||
st *model.State
|
||||
opt Options
|
||||
w *writer
|
||||
// meters are the per-address limit, connection and quota sets, and
|
||||
// the named quotas, the rules need.
|
||||
meters []string
|
||||
}
|
||||
|
||||
func (r *renderer) render() {
|
||||
w := r.w
|
||||
w.emit("", "# Built by Housecarl from its database; changes made here are lost.")
|
||||
w.emit("", "table inet %s {}", Table)
|
||||
w.emit("", "delete table inet %s", Table)
|
||||
w.emit("", "table inet %s {", Table)
|
||||
r.sets()
|
||||
devices := r.flowtableDevices()
|
||||
if len(devices) > 0 {
|
||||
w.emit("built-in: flowtable", "\tflowtable ft { hook ingress priority filter; devices = { %s }; }", strings.Join(quoteAll(devices), ", "))
|
||||
}
|
||||
// The chains are written to a buffer first: per-source limits add
|
||||
// meter sets, which must come before them.
|
||||
chains := &writer{lines: map[int]string{}}
|
||||
saved := r.w
|
||||
r.w = chains
|
||||
r.early()
|
||||
r.input()
|
||||
r.forward(len(devices) > 0)
|
||||
r.output()
|
||||
r.dstnat()
|
||||
r.srcnat()
|
||||
r.w = saved
|
||||
for _, m := range r.meters {
|
||||
w.emit("built-in: meters", "%s", m)
|
||||
}
|
||||
base := w.line
|
||||
w.b.WriteString(chains.b.String())
|
||||
for l, o := range chains.lines {
|
||||
w.lines[base+l] = o
|
||||
}
|
||||
w.line = base + chains.line
|
||||
w.emit("", "}")
|
||||
r.elements()
|
||||
}
|
||||
|
||||
// ---- sets
|
||||
|
||||
func (r *renderer) sets() {
|
||||
for _, s := range r.st.Sets {
|
||||
obj := "set " + s.Name
|
||||
if s.Dynamic {
|
||||
for _, f := range []string{"v4", "v6"} {
|
||||
timeout := ""
|
||||
if s.Timeout > 0 {
|
||||
timeout = fmt.Sprintf(" timeout %ds;", s.Timeout)
|
||||
}
|
||||
r.w.emit(obj, "\tset %s { type %s; flags timeout;%s }", setName(s.Name, f == "v6"), addrType(f == "v6"), timeout)
|
||||
}
|
||||
continue
|
||||
}
|
||||
has4, has6 := s.Families()
|
||||
for _, v6 := range []bool{false, true} {
|
||||
if v6 && !has6 || !v6 && !has4 {
|
||||
continue
|
||||
}
|
||||
r.w.emit(obj, "\tset %s { type %s; flags interval; auto-merge; elements = { %s } }",
|
||||
setName(s.Name, v6), addrType(v6), strings.Join(s.FamilyEntries(v6), ", "))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *renderer) elements() {
|
||||
names := make([]string, 0, len(r.opt.Elements))
|
||||
for n := range r.opt.Elements {
|
||||
names = append(names, n)
|
||||
}
|
||||
sort.Strings(names)
|
||||
for _, n := range names {
|
||||
s := r.st.Set(n)
|
||||
if s == nil || !s.Dynamic {
|
||||
continue
|
||||
}
|
||||
for _, e := range r.opt.Elements[n] {
|
||||
a, err := model.ParseAddress(e.Addr)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
timeout := ""
|
||||
if e.Timeout > 0 {
|
||||
timeout = fmt.Sprintf(" timeout %ds", e.Timeout)
|
||||
}
|
||||
r.w.emit("set "+s.Name, "add element inet %s %s { %s%s }", Table, setName(s.Name, a.IsV6()), a.String(), timeout)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SetName is the nftables name of a set's family part.
|
||||
func SetName(name string, v6 bool) string { return setName(name, v6) }
|
||||
|
||||
func setName(name string, v6 bool) string {
|
||||
if v6 {
|
||||
return name + "_v6"
|
||||
}
|
||||
return name + "_v4"
|
||||
}
|
||||
|
||||
func addrType(v6 bool) string {
|
||||
if v6 {
|
||||
return "ipv6_addr"
|
||||
}
|
||||
return "ipv4_addr"
|
||||
}
|
||||
|
||||
func (r *renderer) flowtableDevices() []string {
|
||||
var out []string
|
||||
for _, d := range r.st.Settings.FlowtableDevices {
|
||||
if r.opt.Interfaces == nil || r.opt.Interfaces[d] {
|
||||
out = append(out, d)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---- chains
|
||||
|
||||
func (r *renderer) chain(name, hook, prio, policy string) {
|
||||
r.w.emit("", "\tchain %s {", name)
|
||||
r.w.emit("", "\t\ttype %s hook %s priority %s; policy %s;", map[bool]string{true: "nat", false: "filter"}[prio == "dstnat" || prio == "srcnat"], hook, prio, policy)
|
||||
}
|
||||
|
||||
func (r *renderer) end() { r.w.emit("", "\t}") }
|
||||
|
||||
func (r *renderer) builtin(name, format string, args ...any) {
|
||||
r.w.emit("built-in: "+name, "\t\t"+format+" comment \"hc:b-%s\"", append(args, name)...)
|
||||
}
|
||||
|
||||
func (r *renderer) early() {
|
||||
st := r.st
|
||||
r.chain("early", "prerouting", "raw", "accept")
|
||||
ext := st.ExternalInterfaces()
|
||||
if st.Settings.Builtins.Antispoof && len(ext) > 0 {
|
||||
v4 := append([]string(nil), st.Settings.AntispoofV4...)
|
||||
v6 := append([]string(nil), st.Settings.AntispoofV6...)
|
||||
for _, p := range st.Settings.OwnPrefixes {
|
||||
if a, err := model.ParseAddress(p); err == nil && a.IsV6() {
|
||||
v6 = append(v6, a.String())
|
||||
} else if err == nil {
|
||||
v4 = append(v4, a.String())
|
||||
}
|
||||
}
|
||||
if len(v4) > 0 {
|
||||
r.builtin("antispoof", "iifname %s ip saddr { %s } counter drop", ifaces(ext), strings.Join(v4, ", "))
|
||||
}
|
||||
if len(v6) > 0 {
|
||||
r.builtin("antispoof6", "iifname %s ip6 saddr { %s } counter drop", ifaces(ext), strings.Join(v6, ", "))
|
||||
}
|
||||
}
|
||||
r.rules(model.ChainEarly, true)
|
||||
r.rules(model.ChainEarly, false)
|
||||
r.end()
|
||||
}
|
||||
|
||||
var (
|
||||
icmpv6Errors = "destination-unreachable, packet-too-big, time-exceeded, parameter-problem"
|
||||
icmpv6ND = "nd-router-solicit, nd-router-advert, nd-neighbor-solicit, nd-neighbor-advert"
|
||||
icmpv6MLD = "mld-listener-query, mld-listener-report, mld-listener-done, mld2-listener-report"
|
||||
)
|
||||
|
||||
func (r *renderer) connBuiltins() {
|
||||
b := r.st.Settings.Builtins
|
||||
if b.Established {
|
||||
r.builtin("established", "ct state established,related counter accept")
|
||||
}
|
||||
if b.DropInvalid {
|
||||
r.builtin("invalid", "ct state invalid counter drop")
|
||||
}
|
||||
}
|
||||
|
||||
func (r *renderer) input() {
|
||||
st := r.st
|
||||
b := st.Settings.Builtins
|
||||
r.chain("input", "input", "filter", st.Settings.InputPolicy)
|
||||
if b.Loopback {
|
||||
r.builtin("loopback", "iifname \"lo\" accept")
|
||||
}
|
||||
// Rules that count traffic see every packet: before the established
|
||||
// connections are accepted.
|
||||
r.rules(model.ChainInput, true)
|
||||
r.connBuiltins()
|
||||
ext := st.ExternalInterfaces()
|
||||
if b.ICMPv6 {
|
||||
r.builtin("icmpv6", "icmpv6 type { %s, echo-request, echo-reply } counter accept", icmpv6Errors)
|
||||
r.builtin("icmpv6-nd", "icmpv6 type { %s } ip6 hoplimit 255 counter accept", icmpv6ND)
|
||||
r.builtin("icmpv6-mld", "ip6 saddr fe80::/10 icmpv6 type { %s } counter accept", icmpv6MLD)
|
||||
}
|
||||
if b.PingInternal {
|
||||
if len(ext) > 0 {
|
||||
r.builtin("ping", "iifname != %s icmp type echo-request counter accept", ifaces(ext))
|
||||
} else {
|
||||
r.builtin("ping", "icmp type echo-request counter accept")
|
||||
}
|
||||
}
|
||||
if b.DHCPv6Client && len(ext) > 0 {
|
||||
r.builtin("dhcpv6-client", "iifname %s ip6 saddr fe80::/10 udp dport 546 counter accept", ifaces(ext))
|
||||
}
|
||||
r.rules(model.ChainInput, false)
|
||||
r.policyTail("input", st.Settings.InputPolicy)
|
||||
r.end()
|
||||
}
|
||||
|
||||
func (r *renderer) forward(flowtable bool) {
|
||||
st := r.st
|
||||
b := st.Settings.Builtins
|
||||
r.chain("forward", "forward", "filter", st.Settings.ForwardPolicy)
|
||||
ext := st.ExternalInterfaces()
|
||||
if b.MSSClamp && len(ext) > 0 {
|
||||
r.builtin("mss-clamp", "oifname %s tcp flags syn tcp option maxseg size set rt mtu", ifaces(ext))
|
||||
}
|
||||
r.rules(model.ChainForward, true)
|
||||
if flowtable {
|
||||
r.builtin("flowtable", "meta l4proto { tcp, udp } flow add @ft")
|
||||
}
|
||||
r.connBuiltins()
|
||||
if b.ICMPv6 {
|
||||
r.builtin("icmpv6", "icmpv6 type { %s } counter accept", icmpv6Errors)
|
||||
}
|
||||
r.forwardAccepts()
|
||||
r.rules(model.ChainForward, false)
|
||||
r.policyTail("forward", st.Settings.ForwardPolicy)
|
||||
r.end()
|
||||
}
|
||||
|
||||
func (r *renderer) output() {
|
||||
st := r.st
|
||||
r.chain("output", "output", "filter", st.Settings.OutputPolicy)
|
||||
r.rules(model.ChainOutput, true)
|
||||
if st.Settings.OutputPolicy == model.PolicyDrop {
|
||||
if st.Settings.Builtins.Loopback {
|
||||
r.builtin("loopback", "oifname \"lo\" accept")
|
||||
}
|
||||
if st.Settings.Builtins.Established {
|
||||
r.builtin("established", "ct state established,related counter accept")
|
||||
}
|
||||
if st.Settings.Builtins.ICMPv6 {
|
||||
r.builtin("icmpv6", "icmpv6 type { %s, echo-request, echo-reply, %s, %s } counter accept", icmpv6Errors, icmpv6ND, icmpv6MLD)
|
||||
}
|
||||
}
|
||||
r.rules(model.ChainOutput, false)
|
||||
r.policyTail("output", st.Settings.OutputPolicy)
|
||||
r.end()
|
||||
}
|
||||
|
||||
// policyTail counts (and optionally logs) what reaches the policy.
|
||||
func (r *renderer) policyTail(chain, policy string) {
|
||||
if policy != model.PolicyDrop {
|
||||
return
|
||||
}
|
||||
obj := "policy: " + chain
|
||||
if r.st.Settings.LogPolicyDrops && chain != "output" {
|
||||
r.w.emit(obj, "\t\tlimit rate 10/second burst 20 packets log prefix %s group %d", quote("hc "+chain+" policy drop: "), LogGroup)
|
||||
}
|
||||
r.w.emit(obj, "\t\tcounter comment \"hc:p-%s\"", chain)
|
||||
}
|
||||
|
||||
// ---- rules
|
||||
|
||||
// rules writes a chain's rules: those that count traffic (traffic true),
|
||||
// or the others.
|
||||
func (r *renderer) rules(chain string, traffic bool) {
|
||||
for _, rule := range r.st.Rules {
|
||||
if rule.Chain != chain || rule.Disabled || rule.CountsTraffic() != traffic {
|
||||
continue
|
||||
}
|
||||
obj := rule.Label()
|
||||
mark := fmt.Sprintf("r%d", rule.ID)
|
||||
if rule.Raw != "" {
|
||||
r.w.emit(obj, "\t\t%s", rule.Raw)
|
||||
continue
|
||||
}
|
||||
var tail []string
|
||||
if len(rule.CtState) > 0 {
|
||||
tail = append(tail, "ct state "+list(rule.CtState))
|
||||
}
|
||||
if len(rule.CtStatus) > 0 {
|
||||
tail = append(tail, "ct status "+list(rule.CtStatus))
|
||||
}
|
||||
if len(rule.Days) > 0 {
|
||||
var ds []string
|
||||
for _, d := range rule.Days {
|
||||
ds = append(ds, quote(strings.ToUpper(d[:1])+strings.ToLower(d[1:])))
|
||||
}
|
||||
tail = append(tail, "meta day "+set(ds))
|
||||
}
|
||||
if rule.HourFrom != "" {
|
||||
tail = append(tail, hours(rule.HourFrom, rule.HourTo))
|
||||
}
|
||||
verdict := r.verdict(rule, mark)
|
||||
perAddress := rule.ConnLimit > 0 || rule.Limit.PerAddress() != "" || rule.Quota.PerAddress() != ""
|
||||
for _, line := range r.expand(rule.Match, perAddress) {
|
||||
parts := append([]string{}, line.parts...)
|
||||
parts = append(parts, tail...)
|
||||
if l := rule.Limit; l != nil {
|
||||
parts = append(parts, r.limit(rule, line, l))
|
||||
}
|
||||
if rule.ConnLimit > 0 {
|
||||
parts = append(parts, r.connLimit(rule, line))
|
||||
}
|
||||
if rule.Quota != nil {
|
||||
parts = append(parts, r.quota(rule, line))
|
||||
}
|
||||
parts = append(parts, verdict)
|
||||
r.w.emit(obj, "\t\t%s", strings.Join(nonEmpty(parts), " "))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *renderer) verdict(rule *model.Rule, mark string) string {
|
||||
var v []string
|
||||
v = append(v, "counter")
|
||||
if rule.Log || rule.Action == model.ActionLog {
|
||||
prefix := rule.LogPrefix
|
||||
if prefix == "" {
|
||||
prefix = fmt.Sprintf("hc %s #%d %s: ", rule.Chain, rule.ID, rule.Action)
|
||||
}
|
||||
v = append(v, fmt.Sprintf("log prefix %s group %d", quote(prefix), LogGroup))
|
||||
}
|
||||
switch rule.Action {
|
||||
case model.ActionAccept, model.ActionDrop:
|
||||
v = append(v, rule.Action)
|
||||
case model.ActionReject:
|
||||
if rule.RejectWith == "tcp-reset" {
|
||||
v = append(v, "reject with tcp reset")
|
||||
} else {
|
||||
v = append(v, "reject")
|
||||
}
|
||||
}
|
||||
v = append(v, "comment "+comment(mark, rule.Comment))
|
||||
return strings.Join(v, " ")
|
||||
}
|
||||
|
||||
// limit renders a rate limit: of packets or bytes; per source or
|
||||
// destination with a meter set per family, whose entries are refreshed
|
||||
// by each packet (update) and forgotten once a source is quiet.
|
||||
func (r *renderer) limit(rule *model.Rule, line expanded, l *model.Limit) string {
|
||||
unit := ""
|
||||
if _, ok := model.ByteUnits[l.Unit]; ok {
|
||||
unit = " " + l.Unit
|
||||
}
|
||||
rate := fmt.Sprintf("limit rate %s%d%s/%s", map[bool]string{true: "over ", false: ""}[l.Over], l.Rate, unit, l.Per)
|
||||
if l.Burst > 0 {
|
||||
burstUnit := " packets"
|
||||
if unit != "" {
|
||||
burstUnit = unit
|
||||
}
|
||||
rate += fmt.Sprintf(" burst %d%s", l.Burst, burstUnit)
|
||||
}
|
||||
addr := l.PerAddress()
|
||||
if addr == "" {
|
||||
return rate
|
||||
}
|
||||
name := fmt.Sprintf("meter_r%d_%s", rule.ID, line.fam)
|
||||
if !r.hasMeter(name) {
|
||||
r.meters = append(r.meters, fmt.Sprintf("\tset %s { type %s; flags dynamic,timeout; timeout 1%s; }", name, addrType(line.fam == "v6"), map[string]string{"second": "m", "minute": "m", "hour": "h", "day": "d"}[l.Per]))
|
||||
}
|
||||
return fmt.Sprintf("update @%s { %s %s }", name, r.addrKey(rule, line.fam, addr), rate)
|
||||
}
|
||||
|
||||
// addrKey is what a per-address meter counts by: "ip saddr", or grouped
|
||||
// by a prefix, "ip6 saddr and ffff:ffff:ffff:ffff::".
|
||||
func (r *renderer) addrKey(rule *model.Rule, fam, addr string) string {
|
||||
v4, v6 := rule.Groups()
|
||||
bits, size := v4, 32
|
||||
if fam == "v6" {
|
||||
bits, size = v6, 128
|
||||
}
|
||||
key := ipKeyword(fam) + " " + addr
|
||||
if bits >= size {
|
||||
return key
|
||||
}
|
||||
mask := make([]byte, size/8)
|
||||
for i := 0; i < bits; i++ {
|
||||
mask[i/8] |= 0x80 >> (i % 8)
|
||||
}
|
||||
m, _ := netip.AddrFromSlice(mask)
|
||||
return key + " and " + m.String()
|
||||
}
|
||||
|
||||
func (r *renderer) connLimit(rule *model.Rule, line expanded) string {
|
||||
name := fmt.Sprintf("conn_r%d_%s", rule.ID, line.fam)
|
||||
if !r.hasMeter(name) {
|
||||
r.meters = append(r.meters, fmt.Sprintf("\tset %s { type %s; flags dynamic; size 65535; }", name, addrType(line.fam == "v6")))
|
||||
}
|
||||
return fmt.Sprintf("add @%s { %s ct count over %d }", name, r.addrKey(rule, line.fam, "saddr"), rule.ConnLimit)
|
||||
}
|
||||
|
||||
// quota renders a rule's quota: a named quota shared by the rule's lines
|
||||
// (both families count against one allowance), or per address a set of
|
||||
// quotas per family. What was used comes back from Options, so a reload
|
||||
// doesn't reset it.
|
||||
func (r *renderer) quota(rule *model.Rule, line expanded) string {
|
||||
q := rule.Quota
|
||||
amount := quotaAmount(q)
|
||||
name := QuotaName(rule.ID)
|
||||
addr := q.PerAddress()
|
||||
if addr == "" {
|
||||
if !r.hasMeter(name) {
|
||||
used := ""
|
||||
if u := r.opt.Quotas[name]; u > 0 {
|
||||
used = fmt.Sprintf(" used %d bytes", u)
|
||||
}
|
||||
r.meters = append(r.meters, fmt.Sprintf("\tquota %s { %s%s }", name, amount, used))
|
||||
}
|
||||
return fmt.Sprintf("quota name %s", quote(name))
|
||||
}
|
||||
set := name + "_" + line.fam
|
||||
if !r.hasMeter(set) {
|
||||
var elems []string
|
||||
for _, e := range r.opt.QuotaSets[set] {
|
||||
a, err := netip.ParseAddr(e.Addr)
|
||||
if err != nil || a.Is6() != (line.fam == "v6") {
|
||||
continue
|
||||
}
|
||||
used := ""
|
||||
if e.Used > 0 {
|
||||
used = fmt.Sprintf(" used %d bytes", e.Used)
|
||||
}
|
||||
elems = append(elems, fmt.Sprintf("%s quota %s%s", a, amount, used))
|
||||
}
|
||||
list := ""
|
||||
if len(elems) > 0 {
|
||||
list = " elements = { " + strings.Join(elems, ", ") + " }"
|
||||
}
|
||||
r.meters = append(r.meters, fmt.Sprintf("\tset %s { type %s; flags dynamic; size 65535;%s }", set, addrType(line.fam == "v6"), list))
|
||||
}
|
||||
return fmt.Sprintf("add @%s { %s quota %s }", set, r.addrKey(rule, line.fam, addr), amount)
|
||||
}
|
||||
|
||||
// quotaAmount writes a quota: "over 5000 mbytes" (nftables 1.1.3 has no
|
||||
// gbytes).
|
||||
func quotaAmount(q *model.Quota) string {
|
||||
n, unit := q.Bytes, "bytes"
|
||||
for _, u := range []string{"mbytes", "kbytes"} {
|
||||
if n >= model.ByteUnits[u] && n%model.ByteUnits[u] == 0 {
|
||||
n, unit = n/model.ByteUnits[u], u
|
||||
break
|
||||
}
|
||||
}
|
||||
if q.Over {
|
||||
return fmt.Sprintf("over %d %s", n, unit)
|
||||
}
|
||||
return fmt.Sprintf("until %d %s", n, unit)
|
||||
}
|
||||
|
||||
func (r *renderer) hasMeter(name string) bool {
|
||||
for _, m := range r.meters {
|
||||
if strings.Contains(m, "set "+name+" ") || strings.Contains(m, "quota "+name+" ") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// expanded is one nftables rule's match: its family ("v4", "v6" or ""
|
||||
// for both) and its parts.
|
||||
type expanded struct {
|
||||
fam string
|
||||
parts []string
|
||||
}
|
||||
|
||||
// expand turns a Match into nftables matches: one per family, named set
|
||||
// and protocol it needs (see the package comment).
|
||||
func (r *renderer) expand(m model.Match, perSource bool) []expanded {
|
||||
st := r.st
|
||||
var head []string
|
||||
if len(m.InZones) > 0 {
|
||||
head = append(head, "iifname "+neg(m.NotIn)+ifaces(r.zoneIfaces(m.InZones)))
|
||||
}
|
||||
if len(m.OutZones) > 0 {
|
||||
head = append(head, "oifname "+neg(m.NotOut)+ifaces(r.zoneIfaces(m.OutZones)))
|
||||
}
|
||||
s4, s6 := st.SplitRefs(m.Sources)
|
||||
d4, d6 := st.SplitRefs(m.Destinations)
|
||||
entries := st.ServiceEntries(m.Services)
|
||||
hasICMP := false
|
||||
for _, e := range entries {
|
||||
hasICMP = hasICMP || e.IsICMP()
|
||||
}
|
||||
// A rule that names no addresses and no ICMP needs no family split,
|
||||
// unless it is limited to one family or keeps per-source state.
|
||||
perFamily := perSource || len(m.Sources) > 0 || len(m.Destinations) > 0 || hasICMP || m.Family != ""
|
||||
if !perFamily {
|
||||
var out []expanded
|
||||
for _, svc := range serviceAlts(entries, "") {
|
||||
out = append(out, expanded{"", append(append([]string{}, head...), svc...)})
|
||||
}
|
||||
return out
|
||||
}
|
||||
var out []expanded
|
||||
for _, fam := range []string{"v4", "v6"} {
|
||||
if m.Family == model.FamilyIPv4 && fam == "v6" || m.Family == model.FamilyIPv6 && fam == "v4" {
|
||||
continue
|
||||
}
|
||||
src, dst := s4, d4
|
||||
if fam == "v6" {
|
||||
src, dst = s6, d6
|
||||
}
|
||||
srcAlts, ok := addrAlts(fam, "saddr", src, len(m.Sources) > 0, m.NotSources)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
dstAlts, ok := addrAlts(fam, "daddr", dst, len(m.Destinations) > 0, m.NotDests)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
svcs := serviceAlts(entries, fam)
|
||||
if svcs == nil {
|
||||
continue
|
||||
}
|
||||
for _, sa := range srcAlts {
|
||||
for _, da := range dstAlts {
|
||||
for _, sv := range svcs {
|
||||
parts := append([]string{}, head...)
|
||||
if sa == "" && da == "" && !strings.HasPrefix(strings.Join(sv, " "), "icmp") {
|
||||
parts = append(parts, "meta nfproto "+map[string]string{"v4": "ipv4", "v6": "ipv6"}[fam])
|
||||
}
|
||||
parts = append(parts, sa, da)
|
||||
parts = append(parts, sv...)
|
||||
out = append(out, expanded{fam, parts})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// addrAlts returns the alternatives for one side's addresses in one
|
||||
// family: one per named set, plus one for the literals. Negated, all go
|
||||
// into one rule. ok is false when the side was given but has nothing of
|
||||
// this family (the rule can't match it).
|
||||
func addrAlts(fam, dir string, refs model.FamilyRefs, given, negated bool) (alts []string, ok bool) {
|
||||
kw := ipKeyword(fam) + " " + dir + " "
|
||||
if !given {
|
||||
return []string{""}, true
|
||||
}
|
||||
if negated {
|
||||
var parts []string
|
||||
for _, s := range refs.Sets {
|
||||
parts = append(parts, kw+"!= @"+setName(s, fam == "v6"))
|
||||
}
|
||||
if len(refs.Literals) > 0 {
|
||||
parts = append(parts, kw+"!= "+set(refs.Literals))
|
||||
}
|
||||
return []string{strings.Join(parts, " ")}, true
|
||||
}
|
||||
if refs.Empty() {
|
||||
return nil, false
|
||||
}
|
||||
for _, s := range refs.Sets {
|
||||
alts = append(alts, kw+"@"+setName(s, fam == "v6"))
|
||||
}
|
||||
if len(refs.Literals) > 0 {
|
||||
alts = append(alts, kw+set(refs.Literals))
|
||||
}
|
||||
return alts, true
|
||||
}
|
||||
|
||||
// serviceAlts groups service entries into nftables matches: one per
|
||||
// transport protocol, and one for the family's ICMP types. fam "" is both
|
||||
// families (no ICMP then). nil means nothing matches this family.
|
||||
func serviceAlts(entries []model.ServiceEntry, fam string) [][]string {
|
||||
if len(entries) == 0 {
|
||||
return [][]string{{}}
|
||||
}
|
||||
ports := map[string][]string{}
|
||||
var protos []string
|
||||
var icmp []string
|
||||
for _, e := range entries {
|
||||
switch {
|
||||
case e.Proto == "icmp" && fam == "v4", e.Proto == "icmpv6" && fam == "v6":
|
||||
icmp = append(icmp, e.Type)
|
||||
case !e.IsICMP():
|
||||
if _, ok := ports[e.Proto]; !ok {
|
||||
protos = append(protos, e.Proto)
|
||||
}
|
||||
ports[e.Proto] = append(ports[e.Proto], e.Ports)
|
||||
}
|
||||
}
|
||||
var out [][]string
|
||||
for _, p := range protos {
|
||||
out = append(out, []string{p + " dport " + set(ports[p])})
|
||||
}
|
||||
if len(icmp) > 0 {
|
||||
out = append(out, []string{map[string]string{"v4": "icmp", "v6": "icmpv6"}[fam] + " type " + set(icmp)})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (r *renderer) zoneIfaces(zones []string) []string {
|
||||
var out []string
|
||||
for _, n := range zones {
|
||||
if z := r.st.Zone(n); z != nil {
|
||||
out = append(out, z.Interfaces...)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---- NAT
|
||||
|
||||
func (r *renderer) dstnat() {
|
||||
st := r.st
|
||||
r.chain("dstnat", "prerouting", "dstnat", "accept")
|
||||
for _, x := range st.Redirects {
|
||||
if x.Disabled {
|
||||
continue
|
||||
}
|
||||
obj := x.Label()
|
||||
mark := fmt.Sprintf("d%d", x.ID)
|
||||
for _, line := range r.expand(x.Match, false) {
|
||||
to := x.ToPort
|
||||
if to == "" {
|
||||
for _, e := range st.ServiceEntries(x.Services) {
|
||||
if !e.IsICMP() {
|
||||
lo, _ := model.PortRange(e.Ports)
|
||||
to = fmt.Sprint(lo)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
r.w.emit(obj, "\t\t%s counter redirect to :%s comment %s", strings.Join(nonEmpty(line.parts), " "), to, comment(mark, x.Comment))
|
||||
}
|
||||
}
|
||||
ext := st.ExternalInterfaces()
|
||||
for _, f := range st.Forwards {
|
||||
if f.Disabled || f.To == "" {
|
||||
continue
|
||||
}
|
||||
obj := f.Label()
|
||||
mark := fmt.Sprintf("f%d", f.ID)
|
||||
where := []string{}
|
||||
if len(f.PublicAddresses) > 0 {
|
||||
pub, _ := st.SplitRefs(f.PublicAddresses)
|
||||
alts, _ := addrAlts("v4", "daddr", pub, true, false)
|
||||
where = alts
|
||||
} else {
|
||||
where = []string{"iifname " + ifaces(ext) + " meta nfproto ipv4"}
|
||||
}
|
||||
src4, _ := st.SplitRefs(f.Sources)
|
||||
srcAlts, ok := addrAlts("v4", "saddr", src4, len(f.Sources) > 0, false)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
to := f.To
|
||||
if f.ToPort != "" {
|
||||
to += ":" + f.ToPort
|
||||
}
|
||||
for _, wh := range where {
|
||||
for _, sa := range srcAlts {
|
||||
for _, p := range f.Protocols {
|
||||
r.w.emit(obj, "\t\t%s counter dnat ip to %s comment %s", strings.Join(nonEmpty([]string{wh, sa, p + " dport " + f.Ports}), " "), to, comment(mark, f.Name+" "+f.Comment))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
r.end()
|
||||
}
|
||||
|
||||
// forwardAccepts lets port-forwarded connections through, and opens the
|
||||
// IPv6 pinholes.
|
||||
func (r *renderer) forwardAccepts() {
|
||||
st := r.st
|
||||
for _, f := range st.Forwards {
|
||||
if f.Disabled {
|
||||
continue
|
||||
}
|
||||
obj := f.Label()
|
||||
mark := fmt.Sprintf("f%d", f.ID)
|
||||
ports := f.Ports
|
||||
if f.ToPort != "" {
|
||||
ports = f.ToPort
|
||||
}
|
||||
src4, src6 := st.SplitRefs(f.Sources)
|
||||
if f.To != "" {
|
||||
if srcAlts, ok := addrAlts("v4", "saddr", src4, len(f.Sources) > 0, false); ok {
|
||||
for _, sa := range srcAlts {
|
||||
for _, p := range f.Protocols {
|
||||
r.w.emit(obj, "\t\t%s counter accept comment %s", strings.Join(nonEmpty([]string{"ip daddr " + f.To, sa, p + " dport " + ports, "ct status dnat"}), " "), comment(mark, f.Name))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if f.To6 != "" {
|
||||
if srcAlts, ok := addrAlts("v6", "saddr", src6, len(f.Sources) > 0, false); ok {
|
||||
for _, sa := range srcAlts {
|
||||
for _, p := range f.Protocols {
|
||||
// No translation for IPv6: the host's own port.
|
||||
r.w.emit(obj, "\t\t%s counter accept comment %s", strings.Join(nonEmpty([]string{"ip6 daddr " + f.To6, sa, p + " dport " + ports}), " "), comment(mark+"-6", f.Name))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *renderer) srcnat() {
|
||||
st := r.st
|
||||
r.chain("srcnat", "postrouting", "srcnat", "accept")
|
||||
ext := st.ExternalInterfaces()
|
||||
for _, f := range st.Forwards {
|
||||
if f.Disabled || !f.Hairpin || f.To == "" {
|
||||
continue
|
||||
}
|
||||
obj := f.Label()
|
||||
mark := fmt.Sprintf("f%d-hairpin", f.ID)
|
||||
ports := f.Ports
|
||||
if f.ToPort != "" {
|
||||
ports = f.ToPort
|
||||
}
|
||||
var srcAlts []string
|
||||
if len(f.HairpinSources) > 0 {
|
||||
h4, _ := st.SplitRefs(f.HairpinSources)
|
||||
alts, ok := addrAlts("v4", "saddr", h4, true, false)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
srcAlts = alts
|
||||
} else if len(ext) > 0 {
|
||||
srcAlts = []string{"iifname != " + ifaces(ext)}
|
||||
} else {
|
||||
srcAlts = []string{""}
|
||||
}
|
||||
for _, sa := range srcAlts {
|
||||
for _, p := range f.Protocols {
|
||||
r.w.emit(obj, "\t\t%s counter masquerade comment %s", strings.Join(nonEmpty([]string{sa, "ip daddr " + f.To, p + " dport " + ports, "ct status dnat"}), " "), comment(mark, f.Name))
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, x := range st.SNATs {
|
||||
if x.Disabled {
|
||||
continue
|
||||
}
|
||||
obj := x.Label()
|
||||
mark := fmt.Sprintf("s%d", x.ID)
|
||||
for _, line := range r.expand(x.Match, false) {
|
||||
// Masquerading is for IPv4; IPv6 only when asked for (NAT66).
|
||||
if x.To == "" && line.fam == "v6" && x.Family != model.FamilyIPv6 {
|
||||
continue
|
||||
}
|
||||
if x.To == "" && line.fam == "" && x.Family != model.FamilyIPv6 {
|
||||
line.parts = append([]string{"meta nfproto ipv4"}, line.parts...)
|
||||
}
|
||||
action := "masquerade"
|
||||
if x.To != "" {
|
||||
if a, err := model.ParseAddress(x.To); err == nil && a.IsV6() {
|
||||
if line.fam == "v4" {
|
||||
continue
|
||||
}
|
||||
action = "snat ip6 to " + x.To
|
||||
} else {
|
||||
if line.fam == "v6" {
|
||||
continue
|
||||
}
|
||||
action = "snat ip to " + x.To
|
||||
}
|
||||
}
|
||||
r.w.emit(obj, "\t\t%s counter %s comment %s", strings.Join(nonEmpty(line.parts), " "), action, comment(mark, x.Comment))
|
||||
}
|
||||
}
|
||||
r.end()
|
||||
}
|
||||
|
||||
// ---- helpers
|
||||
|
||||
// hours writes a time window. nftables can't take a range past midnight,
|
||||
// so 22:00-06:00 becomes "not 06:00-21:59:59".
|
||||
func hours(from, to string) string {
|
||||
if from <= to {
|
||||
return fmt.Sprintf("meta hour %s-%s", quote(from), quote(to))
|
||||
}
|
||||
h, m := 0, 0
|
||||
fmt.Sscanf(from, "%d:%d", &h, &m)
|
||||
mins := h*60 + m - 1
|
||||
return fmt.Sprintf("meta hour != %s-%s", quote(to), quote(fmt.Sprintf("%02d:%02d:59", mins/60, mins%60)))
|
||||
}
|
||||
|
||||
func ipKeyword(fam string) string {
|
||||
if fam == "v6" {
|
||||
return "ip6"
|
||||
}
|
||||
return "ip"
|
||||
}
|
||||
|
||||
func neg(b bool) string {
|
||||
if b {
|
||||
return "!= "
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ifaces writes interface names: one quoted, or an anonymous set.
|
||||
func ifaces(names []string) string {
|
||||
q := quoteAll(names)
|
||||
if len(q) == 1 {
|
||||
return q[0]
|
||||
}
|
||||
return "{ " + strings.Join(q, ", ") + " }"
|
||||
}
|
||||
|
||||
func quoteAll(in []string) []string {
|
||||
out := make([]string, len(in))
|
||||
for i, s := range in {
|
||||
out[i] = quote(s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func quote(s string) string {
|
||||
return `"` + strings.NewReplacer(`"`, `'`, `\`, `/`, "\n", " ").Replace(s) + `"`
|
||||
}
|
||||
|
||||
// set writes one value, or an anonymous set of several.
|
||||
func set(vals []string) string {
|
||||
if len(vals) == 1 {
|
||||
return vals[0]
|
||||
}
|
||||
return "{ " + strings.Join(vals, ", ") + " }"
|
||||
}
|
||||
|
||||
// list writes ct state/status values: one, or a comma list.
|
||||
func list(vals []string) string {
|
||||
return strings.Join(vals, ",")
|
||||
}
|
||||
|
||||
func nonEmpty(in []string) []string {
|
||||
var out []string
|
||||
for _, s := range in {
|
||||
if s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// comment writes the "hc:<mark> text" comment counters are read back by
|
||||
// (nftables allows 128 bytes).
|
||||
func comment(mark, text string) string {
|
||||
c := strings.TrimSpace("hc:" + mark + " " + strings.TrimSpace(text))
|
||||
var b strings.Builder
|
||||
for _, r := range c {
|
||||
if r >= 32 && r != 127 && b.Len()+len(string(r)) <= 120 {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return quote(b.String())
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
package render
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"housecarl/internal/model"
|
||||
)
|
||||
|
||||
// router is a configuration like the maintainer's router: PPPoE WAN,
|
||||
// a LAN, WireGuard, Incus bridges, port forwards with hairpin NAT and an
|
||||
// IPv6 pinhole, plus the common features the tests exercise.
|
||||
func router() *model.State {
|
||||
st := model.NewState()
|
||||
st.Settings.Builtins.MSSClamp = true
|
||||
st.Settings.OwnPrefixes = []string{"2a02:8013:7f3d::/48"}
|
||||
st.Settings.FlowtableDevices = []string{"lo"}
|
||||
st.Settings.LogPolicyDrops = true
|
||||
id := int64(0)
|
||||
next := func() int64 { id++; return id }
|
||||
st.Zones = []*model.Zone{
|
||||
{ID: next(), Name: "wan", Interfaces: []string{"wan"}, External: true},
|
||||
{ID: next(), Name: "lan", Interfaces: []string{"enp2s0"}},
|
||||
{ID: next(), Name: "wg", Interfaces: []string{"wg0"}},
|
||||
{ID: next(), Name: "incus", Interfaces: []string{"incusbr*"}},
|
||||
}
|
||||
st.Sets = []*model.AddressSet{
|
||||
{ID: next(), Name: "trusted", Entries: []string{"10.0.10.0/24", "10.20.30.0/24", "10.50.50.0/24", "2a02:8013:7f3d:10::/64"}},
|
||||
{ID: next(), Name: "wg_net", Entries: []string{"10.50.50.0/24"}},
|
||||
{ID: next(), Name: "wg_admins", Entries: []string{"10.50.50.3", "10.50.50.6"}},
|
||||
{ID: next(), Name: "dns_servers", Entries: []string{"10.0.10.5", "217.155.8.194", "fdab:abab:abab:10::5", "2a02:8013:7f3d:10::5"}},
|
||||
{ID: next(), Name: "guests", Dynamic: true, Timeout: 3600},
|
||||
}
|
||||
st.Services = []*model.Service{
|
||||
{ID: next(), Name: "jarl", Entries: []string{"tcp/8053"}},
|
||||
{ID: next(), Name: "doh-dot", Entries: []string{"tcp/8443", "tcp/853"}},
|
||||
}
|
||||
pos := map[string]float64{}
|
||||
rule := func(chain string, r model.Rule) {
|
||||
pos[chain]++
|
||||
r.ID, r.Chain, r.Position = next(), chain, pos[chain]
|
||||
if r.Action == "" {
|
||||
r.Action = model.ActionAccept
|
||||
}
|
||||
st.Rules = append(st.Rules, &r)
|
||||
}
|
||||
rule(model.ChainInput, model.Rule{Match: model.Match{Services: []string{"wireguard"}}, CtState: []string{"new"}, Comment: "WireGuard"})
|
||||
rule(model.ChainInput, model.Rule{Match: model.Match{Sources: []string{"wg_net"}, Services: []string{"dns"}}})
|
||||
rule(model.ChainInput, model.Rule{Match: model.Match{Sources: []string{"wg_admins"}, Services: []string{"ssh"}}})
|
||||
rule(model.ChainInput, model.Rule{Match: model.Match{Sources: []string{"wg_net"}, Destinations: []string{"10.50.50.1"}}, Action: model.ActionDrop})
|
||||
rule(model.ChainInput, model.Rule{Match: model.Match{Services: []string{"dns"}}})
|
||||
rule(model.ChainInput, model.Rule{Match: model.Match{Destinations: []string{"dns_servers"}, Services: []string{"doh-dot"}}})
|
||||
rule(model.ChainInput, model.Rule{Match: model.Match{Sources: []string{"trusted"}, Services: []string{"ssh", "jarl", "tcp/8067"}}})
|
||||
rule(model.ChainInput, model.Rule{Match: model.Match{InZones: []string{"lan"}, Services: []string{"dhcp"}}})
|
||||
rule(model.ChainInput, model.Rule{Match: model.Match{InZones: []string{"lan"}, Sources: []string{"fe80::/10"}, Services: []string{"dhcpv6"}}})
|
||||
rule(model.ChainInput, model.Rule{Match: model.Match{InZones: []string{"wan"}, Services: []string{"ssh"}}, Action: model.ActionDrop,
|
||||
Limit: &model.Limit{Rate: 3, Per: "minute", Burst: 5, Over: true, PerSource: true}, Log: true})
|
||||
rule(model.ChainInput, model.Rule{Match: model.Match{Services: []string{"ssh"}}, ConnLimit: 10, Action: model.ActionReject, RejectWith: "tcp-reset"})
|
||||
rule(model.ChainInput, model.Rule{Match: model.Match{Services: []string{"http"}}, Days: []string{"monday", "Friday"}, HourFrom: "22:00", HourTo: "06:00"})
|
||||
rule(model.ChainForward, model.Rule{Match: model.Match{NotIn: true, InZones: []string{"wan"}, Destinations: []string{"trusted"}}, CtState: []string{"invalid"}, Comment: "asymmetric routing"})
|
||||
rule(model.ChainForward, model.Rule{Match: model.Match{InZones: []string{"lan"}, OutZones: []string{"wan"}, Sources: []string{"2a02:8013:7f3d:10::/64"}}})
|
||||
rule(model.ChainForward, model.Rule{Match: model.Match{InZones: []string{"wan"}}, Action: model.ActionDrop, Comment: "nothing else in"})
|
||||
rule(model.ChainForward, model.Rule{Match: model.Match{Destinations: []string{"wg_net"}}, Action: model.ActionDrop})
|
||||
rule(model.ChainForward, model.Rule{Match: model.Match{Services: []string{"ping"}}})
|
||||
rule(model.ChainForward, model.Rule{Match: model.Match{Sources: []string{"wg_net", "trusted", "guests"}}})
|
||||
// Bandwidth: a byte rate, per destination; per /24; quotas, total and
|
||||
// per device.
|
||||
rule(model.ChainForward, model.Rule{Match: model.Match{OutZones: []string{"lan"}}, Action: model.ActionDrop,
|
||||
Limit: &model.Limit{Rate: 5, Unit: "mbytes", Per: "second", Burst: 1, Over: true, PerDestination: true}, Comment: "5 MB/s each"})
|
||||
rule(model.ChainForward, model.Rule{Match: model.Match{InZones: []string{"wan"}}, Action: model.ActionDrop, GroupV4: 24,
|
||||
Limit: &model.Limit{Rate: 100, Unit: "kbytes", Per: "minute", Over: true, PerSource: true}, Comment: "per /24"})
|
||||
rule(model.ChainForward, model.Rule{Match: model.Match{Sources: []string{"guests"}, OutZones: []string{"wan"}}, Action: model.ActionDrop,
|
||||
Quota: &model.Quota{Bytes: 50000 << 20, Over: true, Reset: "monthly", Day: 15}, Comment: "guest quota"})
|
||||
rule(model.ChainForward, model.Rule{Match: model.Match{InZones: []string{"lan"}, OutZones: []string{"wan"}}, Action: model.ActionDrop,
|
||||
Quota: &model.Quota{Bytes: 5 << 30, Over: true, PerSource: true, Reset: "daily"}, Comment: "device quota"})
|
||||
rule(model.ChainEarly, model.Rule{Match: model.Match{InZones: []string{"wan"}, Sources: []string{"198.51.100.0/24"}}, Action: model.ActionDrop})
|
||||
rule(model.ChainOutput, model.Rule{Raw: "meta skuid 0 counter accept", Comment: "raw"})
|
||||
st.Forwards = []*model.PortForward{
|
||||
{ID: next(), Position: 1, Name: "jellyfin", Protocols: []string{"tcp"}, Ports: "8096", PublicAddresses: []string{"217.155.8.194"},
|
||||
To: "10.0.10.57", ToPort: "443", To6: "2a02:8013:7f3d:10::57", Hairpin: true, HairpinSources: []string{"10.0.10.0/24"}},
|
||||
{ID: next(), Position: 2, Name: "terraria", Protocols: []string{"tcp", "udp"}, Ports: "7777-7780", PublicAddresses: []string{"217.155.8.194"},
|
||||
To: "10.0.10.76", Hairpin: true},
|
||||
{ID: next(), Position: 3, Name: "smtp", Protocols: []string{"tcp"}, Ports: "25", To: "10.0.10.25", Sources: []string{"trusted"}},
|
||||
}
|
||||
st.SNATs = []*model.SourceNAT{
|
||||
{ID: next(), Position: 1, Match: model.Match{OutZones: []string{"wan"}, Sources: []string{"trusted"}}, Comment: "external masquerade"},
|
||||
{ID: next(), Position: 2, Match: model.Match{NotOut: true, OutZones: []string{"wg"}, Sources: []string{"wg_net"}}},
|
||||
{ID: next(), Position: 3, Match: model.Match{OutZones: []string{"wan"}, Sources: []string{"fdab:abab:abab::/48"}, Family: "ipv6"}, To: "2a02:8013:7f3d::1"},
|
||||
}
|
||||
st.Redirects = []*model.Redirect{
|
||||
{ID: next(), Position: 1, Match: model.Match{InZones: []string{"lan"}, NotDests: true, Destinations: []string{"10.0.10.5"}, Services: []string{"udp/53"}}, Comment: "force DNS"},
|
||||
}
|
||||
st.Sort()
|
||||
return st
|
||||
}
|
||||
|
||||
// quotaOptions carries quota usage, as a reload does: the guest quota's,
|
||||
// and two devices' of the per-device quota.
|
||||
func quotaOptions(st *model.State) (map[string]int64, map[string][]Element) {
|
||||
q, sets := map[string]int64{}, map[string][]Element{}
|
||||
for _, r := range st.Rules {
|
||||
switch r.Comment {
|
||||
case "guest quota":
|
||||
q[QuotaName(r.ID)] = 123456
|
||||
case "device quota":
|
||||
sets[QuotaName(r.ID)+"_v4"] = []Element{{Addr: "10.0.10.20", Used: 1 << 20}}
|
||||
sets[QuotaName(r.ID)+"_v6"] = []Element{{Addr: "2a02:8013:7f3d:10::", Used: 2048}}
|
||||
}
|
||||
}
|
||||
return q, sets
|
||||
}
|
||||
|
||||
func TestRenderRouter(t *testing.T) {
|
||||
st := router()
|
||||
q, qs := quotaOptions(st)
|
||||
rs := Render(st, Options{Elements: map[string][]Element{"guests": {{Addr: "10.0.30.5", Timeout: 1700}}}, Quotas: q, QuotaSets: qs})
|
||||
text := rs.Text
|
||||
for _, want := range []string{
|
||||
"table inet housecarl {}\ndelete table inet housecarl\ntable inet housecarl {",
|
||||
`set trusted_v4 { type ipv4_addr; flags interval; auto-merge; elements = { 10.0.10.0/24, 10.20.30.0/24, 10.50.50.0/24 } }`,
|
||||
`set trusted_v6 { type ipv6_addr; flags interval; auto-merge; elements = { 2a02:8013:7f3d:10::/64 } }`,
|
||||
`set guests_v4 { type ipv4_addr; flags timeout; timeout 3600s; }`,
|
||||
`iifname "wan" ip saddr { 0.0.0.0/8,`,
|
||||
`iifname "wan" ip6 saddr { ::1/128, ::/128, fc00::/7, 2001:db8::/32, ff00::/8, 2a02:8013:7f3d::/48 } counter drop`,
|
||||
`oifname "wan" tcp flags syn tcp option maxseg size set rt mtu comment "hc:b-mss-clamp"`,
|
||||
`udp dport 51820 ct state new counter accept comment "hc:r`,
|
||||
// DNS from the WireGuard network: v4 only, both protocols.
|
||||
`ip saddr @wg_net_v4 udp dport 53 counter accept`,
|
||||
`ip saddr @wg_net_v4 tcp dport 53 counter accept`,
|
||||
// A set holding both families: one rule per family.
|
||||
`ip daddr @dns_servers_v4 tcp dport { 8443, 853 } counter accept`,
|
||||
`ip6 daddr @dns_servers_v6 tcp dport { 8443, 853 } counter accept`,
|
||||
`ip saddr @trusted_v4 tcp dport { 22, 8053, 8067 } counter accept`,
|
||||
`ip6 saddr @trusted_v6 tcp dport { 22, 8053, 8067 } counter accept`,
|
||||
`iifname "enp2s0" ip6 saddr fe80::/10 udp dport 547 counter accept`,
|
||||
`iifname "wan" meta nfproto ipv4 tcp dport 22 update @meter_r`,
|
||||
`ip6 saddr and ffff:ffff:ffff:ffff:: limit rate over 3/minute burst 5 packets }`,
|
||||
`oifname "enp2s0" meta nfproto ipv4 update @meter_r`,
|
||||
`{ ip daddr limit rate over 5 mbytes/second burst 1 mbytes } counter drop comment "hc:r`,
|
||||
`{ ip saddr and 255.255.255.0 limit rate over 100 kbytes/minute } counter drop`,
|
||||
`quota name "quota_r`,
|
||||
`{ over 50000 mbytes used 123456 bytes }`,
|
||||
`flags dynamic; size 65535; elements = { 10.0.10.20 quota over 5120 mbytes used 1048576 bytes } }`,
|
||||
`elements = { 2a02:8013:7f3d:10:: quota over 5120 mbytes used 2048 bytes } }`,
|
||||
`{ ip6 saddr and ffff:ffff:ffff:ffff:: quota over 5120 mbytes } counter drop comment "hc:r`,
|
||||
`ip saddr limit rate over 3/minute burst 5 packets } counter log prefix "hc input #`,
|
||||
`tcp dport 22 add @conn_r`,
|
||||
`counter reject with tcp reset comment`,
|
||||
`meta day { "Monday", "Friday" } meta hour != "06:00"-"21:59:59"`,
|
||||
`iifname != "wan" ip daddr @trusted_v4 ct state invalid counter accept comment "hc:r`,
|
||||
`icmp type echo-request counter accept`,
|
||||
`icmpv6 type echo-request counter accept`,
|
||||
`ip saddr @guests_v4 counter accept`,
|
||||
`ip6 saddr @guests_v6 counter accept`,
|
||||
`meta skuid 0 counter accept`,
|
||||
`ip daddr 217.155.8.194 tcp dport 8096 counter dnat ip to 10.0.10.57:443 comment "hc:f`,
|
||||
`ip daddr 217.155.8.194 udp dport 7777-7780 counter dnat ip to 10.0.10.76 comment`,
|
||||
`iifname "wan" meta nfproto ipv4 ip saddr @trusted_v4 tcp dport 25 counter dnat ip to 10.0.10.25`,
|
||||
`ip daddr 10.0.10.57 tcp dport 443 ct status dnat counter accept`,
|
||||
`ip6 daddr 2a02:8013:7f3d:10::57 tcp dport 443 counter accept`,
|
||||
`ip saddr 10.0.10.0/24 ip daddr 10.0.10.57 tcp dport 443 ct status dnat counter masquerade`,
|
||||
`iifname != "wan" ip daddr 10.0.10.76 udp dport 7777-7780 ct status dnat counter masquerade`,
|
||||
`oifname "wan" ip saddr @trusted_v4 counter masquerade`,
|
||||
`oifname != "wg0" ip saddr @wg_net_v4 counter masquerade`,
|
||||
`oifname "wan" ip6 saddr fdab:abab:abab::/48 counter snat ip6 to 2a02:8013:7f3d::1`,
|
||||
`iifname "enp2s0" ip daddr != 10.0.10.5 udp dport 53 counter redirect to :53 comment "hc:d`,
|
||||
`limit rate 10/second burst 20 packets log prefix "hc forward policy drop: " group 4171`,
|
||||
`counter comment "hc:p-forward"`,
|
||||
`flowtable ft { hook ingress priority filter; devices = { "lo" }; }`,
|
||||
`add element inet housecarl guests_v4 { 10.0.30.5 timeout 1700s }`,
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Errorf("missing %q", want)
|
||||
}
|
||||
}
|
||||
// Rules that count traffic see every packet: in forward, after the
|
||||
// MSS clamp and before established connections are accepted.
|
||||
fwd := text[strings.Index(text, "chain forward {"):]
|
||||
quota, est, mss := strings.Index(fwd, `quota name "quota_r`), strings.Index(fwd, "hc:b-established"), strings.Index(fwd, "hc:b-mss-clamp")
|
||||
if quota < 0 || !(mss < quota && quota < est) {
|
||||
t.Errorf("the quota rule isn't between the MSS clamp and established (%d, %d, %d)", mss, quota, est)
|
||||
}
|
||||
if bw := strings.Index(fwd, "limit rate over 5 mbytes/second"); bw > est {
|
||||
t.Error("the byte rate rule comes after established")
|
||||
}
|
||||
// The trusted masquerade has no IPv6 part: masquerading the IPv6 LAN
|
||||
// isn't wanted (its sources are global).
|
||||
if strings.Contains(text, "ip6 saddr @trusted_v6 counter masquerade") {
|
||||
t.Errorf("masquerade rendered for IPv6 too")
|
||||
}
|
||||
if t.Failed() {
|
||||
t.Log(text)
|
||||
}
|
||||
// Lines map back to their objects.
|
||||
for i, l := range strings.Split(text, "\n") {
|
||||
if strings.Contains(l, "8096 counter dnat") && rs.Object(i+1) != "jellyfin" {
|
||||
t.Errorf("line %d belongs to %q", i+1, rs.Object(i+1))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenderedRulesetPassesNft has the real nft check the router's
|
||||
// ruleset (and a minimal one). Set HOUSECARL_NFT to how nft is run, e.g.
|
||||
//
|
||||
// HOUSECARL_NFT="docker run --rm -i --cap-add NET_ADMIN housecarl-nft nft"
|
||||
//
|
||||
// (nft 1.1.3, Debian 13's), or run as root with nft installed.
|
||||
func TestRenderedRulesetPassesNft(t *testing.T) {
|
||||
cmd := strings.Fields(os.Getenv("HOUSECARL_NFT"))
|
||||
if len(cmd) == 0 {
|
||||
t.Skip("HOUSECARL_NFT not set")
|
||||
}
|
||||
for name, st := range map[string]*model.State{"router": router(), "empty": model.NewState()} {
|
||||
q, qs := quotaOptions(st)
|
||||
rs := Render(st, Options{Elements: map[string][]Element{"guests": {{Addr: "10.0.30.5", Timeout: 1700}, {Addr: "2a02:8013:7f3d:30::5"}}}, Quotas: q, QuotaSets: qs})
|
||||
c := exec.CommandContext(context.Background(), cmd[0], append(cmd[1:], "-c", "-f", "/dev/stdin")...)
|
||||
c.Stdin = strings.NewReader(rs.Text)
|
||||
if out, err := c.CombinedOutput(); err != nil {
|
||||
t.Errorf("%s: nft -c: %v\n%s", name, err, out)
|
||||
for i, l := range strings.Split(rs.Text, "\n") {
|
||||
t.Logf("%3d %s", i+1, l)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"housecarl/internal/api"
|
||||
"housecarl/internal/auth"
|
||||
"housecarl/internal/model"
|
||||
"housecarl/internal/store"
|
||||
"housecarl/internal/version"
|
||||
)
|
||||
|
||||
const (
|
||||
sessionCookie = "housecarl_session"
|
||||
csrfHeader = "X-CSRF-Token"
|
||||
)
|
||||
|
||||
// rateLimiter limits failed logins and token attempts per key (address or
|
||||
// user name) over a sliding window.
|
||||
type rateLimiter struct {
|
||||
mu sync.Mutex
|
||||
hits map[string][]time.Time
|
||||
}
|
||||
|
||||
func newRateLimiter() *rateLimiter { return &rateLimiter{hits: map[string][]time.Time{}} }
|
||||
|
||||
const (
|
||||
failWindow = 15 * time.Minute
|
||||
failsPerIP = 20
|
||||
failsPerKey = 8
|
||||
)
|
||||
|
||||
func (l *rateLimiter) recent(key string, now time.Time) []time.Time {
|
||||
var keep []time.Time
|
||||
for _, t := range l.hits[key] {
|
||||
if now.Sub(t) < failWindow {
|
||||
keep = append(keep, t)
|
||||
}
|
||||
}
|
||||
l.hits[key] = keep
|
||||
return keep
|
||||
}
|
||||
|
||||
func (l *rateLimiter) blocked(key string, max int) bool {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
return len(l.recent(key, time.Now())) >= max
|
||||
}
|
||||
|
||||
func (l *rateLimiter) fail(keys ...string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
now := time.Now()
|
||||
for _, k := range keys {
|
||||
l.hits[k] = append(l.recent(k, now), now)
|
||||
}
|
||||
}
|
||||
|
||||
func (l *rateLimiter) reset(key string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
delete(l.hits, key)
|
||||
}
|
||||
|
||||
func (l *rateLimiter) cleanup() {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
now := time.Now()
|
||||
for k := range l.hits {
|
||||
if len(l.recent(k, now)) == 0 {
|
||||
delete(l.hits, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var errUnauthenticated = &httpError{http.StatusUnauthorized, "unauthenticated", "authentication required: log in, or send an API token as 'Authorization: Bearer housecarl_...'"}
|
||||
|
||||
// authenticate identifies the caller: the local socket's peer, a bearer
|
||||
// token or a session cookie (with the CSRF token for changes).
|
||||
func (s *Server) authenticate(r *http.Request) (*auth.Principal, *model.Session, error) {
|
||||
ip := s.clientIP(r)
|
||||
if pr := peerOf(r.Context()); pr != nil && r.Header.Get("Authorization") == "" {
|
||||
if trustedUID(pr.UID) {
|
||||
return socketPrincipal(pr, r.UserAgent()), nil, nil
|
||||
}
|
||||
return nil, nil, socketDenied(pr)
|
||||
}
|
||||
if h := r.Header.Get("Authorization"); h != "" {
|
||||
scheme, tok, _ := strings.Cut(h, " ")
|
||||
if !strings.EqualFold(scheme, "Bearer") {
|
||||
return nil, nil, &httpError{http.StatusUnauthorized, "unauthenticated", "unsupported authorization scheme (use Bearer with an API token)"}
|
||||
}
|
||||
if s.limiter.blocked("ip:"+ip, failsPerIP) {
|
||||
return nil, nil, &httpError{http.StatusTooManyRequests, "rate_limited", "too many failed attempts; try again later"}
|
||||
}
|
||||
p, err := s.tokenPrincipal(r.Context(), strings.TrimSpace(tok), ip, r.UserAgent())
|
||||
if err != nil {
|
||||
s.limiter.fail("ip:" + ip)
|
||||
s.eng.Audit(r.Context(), &auth.Principal{IP: ip, Method: "token"}, "auth.token_failed", "", "failure", err.Error())
|
||||
return nil, nil, &httpError{http.StatusUnauthorized, "unauthenticated", "invalid API token: " + err.Error()}
|
||||
}
|
||||
return p, nil, nil
|
||||
}
|
||||
c, err := r.Cookie(sessionCookie)
|
||||
if err != nil || c.Value == "" {
|
||||
return nil, nil, errUnauthenticated
|
||||
}
|
||||
hash := auth.HashSecret(c.Value)
|
||||
se, err := s.store.GetSession(r.Context(), hash)
|
||||
if err != nil {
|
||||
return nil, nil, &httpError{http.StatusUnauthorized, "session_expired", "your session has expired; please log in again"}
|
||||
}
|
||||
now := time.Now()
|
||||
if now.After(se.ExpiresAt) || now.Sub(se.LastSeenAt) > s.cfg.SessionIdle {
|
||||
s.store.DeleteSession(r.Context(), hash)
|
||||
return nil, nil, &httpError{http.StatusUnauthorized, "session_expired", "your session has expired; please log in again"}
|
||||
}
|
||||
u, err := s.store.GetUser(r.Context(), se.UserID)
|
||||
if err != nil || u.Disabled {
|
||||
s.store.DeleteSession(r.Context(), hash)
|
||||
return nil, nil, &httpError{http.StatusUnauthorized, "session_expired", "your account is not available; please log in again"}
|
||||
}
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
got := r.Header.Get(csrfHeader)
|
||||
if got == "" || subtle.ConstantTimeCompare([]byte(got), []byte(se.CSRFToken)) != 1 {
|
||||
return nil, nil, &httpError{http.StatusForbidden, "csrf", "missing or invalid " + csrfHeader + " header"}
|
||||
}
|
||||
}
|
||||
if now.Sub(se.LastSeenAt) > time.Minute {
|
||||
s.store.TouchSession(r.Context(), hash, now)
|
||||
}
|
||||
return &auth.Principal{User: u, Method: "session", IP: ip, Agent: r.UserAgent()}, se, nil
|
||||
}
|
||||
|
||||
func (s *Server) tokenPrincipal(ctx context.Context, tok, ip, agent string) (*auth.Principal, error) {
|
||||
pid, secret, err := auth.ParseToken(tok)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t, err := s.store.GetTokenByPublicID(ctx, pid)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return nil, errors.New("unknown token")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := time.Now()
|
||||
if !auth.CheckSecret(secret, t.SecretHash) {
|
||||
// After a rotation with a grace period the old secret works until
|
||||
// the grace period ends.
|
||||
if t.PrevSecretHash == "" || t.PrevExpiresAt == nil || !auth.CheckSecret(secret, t.PrevSecretHash) {
|
||||
return nil, errors.New("wrong token secret")
|
||||
}
|
||||
if now.After(*t.PrevExpiresAt) {
|
||||
return nil, errors.New("this is the token's old secret, from before it was rotated; its grace period has ended")
|
||||
}
|
||||
s.log.Warn("token used with its old secret; give this client the new one before the grace period ends", "token_id", t.ID, "token", t.Username+"/"+t.Name, "ip", ip, "valid_until", t.PrevExpiresAt.Format(time.RFC3339))
|
||||
}
|
||||
if t.RevokedAt != nil {
|
||||
return nil, errors.New("the token was revoked")
|
||||
}
|
||||
if t.ExpiresAt != nil && now.After(*t.ExpiresAt) {
|
||||
return nil, errors.New("the token has expired")
|
||||
}
|
||||
u, err := s.store.GetUser(ctx, t.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if u.Disabled {
|
||||
return nil, errors.New("the token's user is disabled")
|
||||
}
|
||||
if t.LastUsedAt == nil || now.Sub(*t.LastUsedAt) > time.Minute || t.LastUsedIP != ip {
|
||||
s.store.TouchToken(ctx, t.ID, ip)
|
||||
}
|
||||
return &auth.Principal{User: u, Token: t, Method: "token", IP: ip, Agent: agent}, nil
|
||||
}
|
||||
|
||||
// authed wraps a handler with authentication and error handling.
|
||||
func (s *Server) authed(h handler) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, s.cfg.MaxBodyBytes)
|
||||
p, se, err := s.authenticate(r)
|
||||
if err != nil {
|
||||
s.writeErr(w, r, err, nil)
|
||||
return
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), keyPrincipal, p)
|
||||
if se != nil {
|
||||
ctx = context.WithValue(ctx, keySession, se)
|
||||
}
|
||||
r = r.WithContext(ctx)
|
||||
if err := h(w, r, p); err != nil {
|
||||
s.writeErr(w, r, err, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// public wraps an unauthenticated handler.
|
||||
func (s *Server) public(h func(w http.ResponseWriter, r *http.Request) error) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
|
||||
if err := h(w, r); err != nil {
|
||||
s.writeErr(w, r, err, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) cookieSecure(r *http.Request) bool {
|
||||
switch s.cfg.CookieSecure {
|
||||
case "always":
|
||||
return true
|
||||
case "never":
|
||||
return false
|
||||
}
|
||||
return s.isHTTPS(r)
|
||||
}
|
||||
|
||||
func (s *Server) login(w http.ResponseWriter, r *http.Request) error {
|
||||
var in api.Login
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
return err
|
||||
}
|
||||
ip := s.clientIP(r)
|
||||
name := strings.TrimSpace(in.Username)
|
||||
userKey := "user:" + strings.ToLower(name)
|
||||
who := &auth.Principal{User: &model.User{Username: name}, IP: ip, Method: "session"}
|
||||
if s.limiter.blocked("ip:"+ip, failsPerIP) || s.limiter.blocked(userKey, failsPerKey) {
|
||||
s.eng.Audit(r.Context(), who, "auth.login_blocked", "user "+name, "denied", "too many failed attempts")
|
||||
return &httpError{http.StatusTooManyRequests, "rate_limited", "too many failed login attempts; wait a few minutes and try again"}
|
||||
}
|
||||
u, err := s.store.GetUserByName(r.Context(), name)
|
||||
hash := ""
|
||||
if err == nil {
|
||||
hash = u.PasswordHash
|
||||
}
|
||||
ok := auth.CheckPassword(hash, in.Password)
|
||||
if err != nil || !ok || u.Disabled || u.PasswordHash == "" {
|
||||
s.limiter.fail("ip:"+ip, userKey)
|
||||
reason := "wrong username or password"
|
||||
if err == nil && u.Disabled && ok {
|
||||
reason = "account disabled"
|
||||
}
|
||||
s.eng.Audit(r.Context(), who, "auth.login_failed", "user "+name, "failure", reason)
|
||||
return &httpError{http.StatusUnauthorized, "login_failed", "wrong username or password"}
|
||||
}
|
||||
s.limiter.reset(userKey)
|
||||
id, csrf := auth.NewSessionID()
|
||||
now := time.Now()
|
||||
se := &model.Session{IDHash: auth.HashSecret(id), UserID: u.ID, CSRFToken: csrf, CreatedAt: now, ExpiresAt: now.Add(s.cfg.SessionLifetime), LastSeenAt: now, IP: ip, UserAgent: r.UserAgent()}
|
||||
if err := s.store.CreateSession(r.Context(), se); err != nil {
|
||||
return err
|
||||
}
|
||||
s.store.TouchLogin(r.Context(), u.ID)
|
||||
http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: id, Path: "/", HttpOnly: true, Secure: s.cookieSecure(r), SameSite: http.SameSiteStrictMode, Expires: se.ExpiresAt})
|
||||
p := &auth.Principal{User: u, Method: "session", IP: ip, Agent: r.UserAgent()}
|
||||
s.eng.Audit(r.Context(), p, "auth.login", "user "+u.Username, "success", "")
|
||||
writeJSON(w, http.StatusOK, s.whoamiOf(p, se))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) logout(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if se, ok := r.Context().Value(keySession).(*model.Session); ok {
|
||||
s.store.DeleteSession(r.Context(), se.IDHash)
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: "", Path: "/", HttpOnly: true, Secure: s.cookieSecure(r), SameSite: http.SameSiteStrictMode, MaxAge: -1})
|
||||
s.eng.Audit(r.Context(), p, "auth.logout", "user "+p.Name(), "success", "")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) whoamiOf(p *auth.Principal, se *model.Session) api.Whoami {
|
||||
out := api.Whoami{User: p.User, Admin: p.IsAdmin(), Method: p.Method, Permissions: p.Permissions(), Build: version.Info()}
|
||||
if se != nil {
|
||||
out.CSRFToken = se.CSRFToken
|
||||
}
|
||||
if p.Token != nil {
|
||||
out.TokenID = p.Token.ID
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Server) whoami(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
se, _ := r.Context().Value(keySession).(*model.Session)
|
||||
writeJSON(w, http.StatusOK, s.whoamiOf(p, se))
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"housecarl/internal/api"
|
||||
"housecarl/internal/auth"
|
||||
"housecarl/internal/certs"
|
||||
"housecarl/internal/validate"
|
||||
)
|
||||
|
||||
func (s *Server) certificate(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := requireAdmin(p); err != nil {
|
||||
return err
|
||||
}
|
||||
out := api.CertificateResponse{Source: "none"}
|
||||
switch {
|
||||
case s.cfg.ACME != nil:
|
||||
st := s.cfg.ACME.Status()
|
||||
out.Configured, out.Source, out.Status = true, "acme", &st
|
||||
case s.cfg.TLSCertFile != "":
|
||||
out.Source = "file"
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) acmeManager() (*certs.Manager, error) {
|
||||
if s.cfg.ACME == nil {
|
||||
return nil, conflict("housecarld doesn't obtain its certificate: set tls.acme and jarl in housecarl.yaml (docs/certificates.md)")
|
||||
}
|
||||
return s.cfg.ACME, nil
|
||||
}
|
||||
|
||||
// certificateCheck: POST /certificate/check runs the preflight check now
|
||||
// (it takes up to a few minutes when the record is slow to appear).
|
||||
func (s *Server) certificateCheck(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := requireAdmin(p); err != nil {
|
||||
return err
|
||||
}
|
||||
m, err := s.acmeManager()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 4*time.Minute)
|
||||
defer cancel()
|
||||
c := m.Preflight(ctx)
|
||||
s.eng.Audit(r.Context(), p, "certificate.check", m.Status().Domain, map[bool]string{true: "success", false: "failure"}[c.OK], "")
|
||||
writeJSON(w, http.StatusOK, c)
|
||||
return nil
|
||||
}
|
||||
|
||||
// certificateRenew: POST /certificate/renew gets a new certificate now, in
|
||||
// the background (GET /certificate follows it).
|
||||
func (s *Server) certificateRenew(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := requireAdmin(p); err != nil {
|
||||
return err
|
||||
}
|
||||
m, err := s.acmeManager()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.Kick()
|
||||
s.eng.Audit(r.Context(), p, "certificate.renew", m.Status().Domain, "success", "")
|
||||
st := m.Status()
|
||||
writeJSON(w, http.StatusAccepted, api.CertificateResponse{Configured: true, Source: "acme", Status: &st})
|
||||
return nil
|
||||
}
|
||||
|
||||
// certificateProblems are the certificate's problems for /status.
|
||||
func (s *Server) certificateProblems() []validate.Problem {
|
||||
if s.cfg.ACME == nil {
|
||||
return nil
|
||||
}
|
||||
st := s.cfg.ACME.Status()
|
||||
obj := "certificate " + st.Domain
|
||||
var out []validate.Problem
|
||||
if st.LastError != "" {
|
||||
out = append(out, validate.Problem{Severity: validate.Warning, Code: "certificate_failed", Object: obj,
|
||||
Message: "getting the web UI's certificate failed: " + st.LastError})
|
||||
}
|
||||
if st.NotAfter != nil && time.Until(*st.NotAfter) < 14*24*time.Hour {
|
||||
out = append(out, validate.Problem{Severity: validate.Warning, Code: "certificate_expiring", Object: obj,
|
||||
Message: fmt.Sprintf("the certificate ends %s and hasn't been renewed", st.NotAfter.Local().Format("2006-01-02 15:04"))})
|
||||
}
|
||||
if st.State == "ok" && st.Staging {
|
||||
out = append(out, validate.Problem{Severity: validate.Warning, Code: "certificate_staging", Object: obj,
|
||||
Message: "the certificate is from Let's Encrypt's staging service, which browsers don't trust: once it works, set tls.acme.directory: letsencrypt and restart housecarld"})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,469 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"housecarl/internal/api"
|
||||
"housecarl/internal/auth"
|
||||
"housecarl/internal/engine"
|
||||
"housecarl/internal/model"
|
||||
"housecarl/internal/store"
|
||||
)
|
||||
|
||||
// Changesets: changes staged to be reviewed (validation, nftables' check,
|
||||
// the ruleset's diff) and applied together, as one history entry, or
|
||||
// discarded. The web UI stages its edits by default; the CLI and the API
|
||||
// stage when a changeset is named (?changeset=ID, X-Housecarl-Changeset).
|
||||
|
||||
// changesetParam is the changeset a request names (0: none).
|
||||
func changesetParam(r *http.Request) (int64, error) {
|
||||
v := r.URL.Query().Get("changeset")
|
||||
if v == "" {
|
||||
v = r.Header.Get("X-Housecarl-Changeset")
|
||||
}
|
||||
if v == "" {
|
||||
return 0, nil
|
||||
}
|
||||
id, err := strconv.ParseInt(v, 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
return 0, badRequest("invalid changeset %q", v)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// notStaged refuses a changeset on a change that can't be staged (undo,
|
||||
// import, takeover): it would otherwise be applied at once, unexpectedly.
|
||||
func notStaged(r *http.Request, what string) error {
|
||||
if id, err := changesetParam(r); err != nil || id == 0 {
|
||||
return err
|
||||
}
|
||||
return badRequest("%s can't be staged in a changeset; leave out --changeset (or unset HOUSECARL_CHANGESET) to do it now", what)
|
||||
}
|
||||
|
||||
// openChangeset loads a changeset to change: it must be open, and the
|
||||
// caller's own unless they are an administrator.
|
||||
func (s *Server) openChangeset(r *http.Request, p *auth.Principal, id int64) (*model.Changeset, error) {
|
||||
cs, err := s.store.GetChangeset(r.Context(), id)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return nil, notFound("there is no changeset %d", id)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cs.Status != model.ChangesetOpen {
|
||||
return nil, conflict("changeset %d is %s", id, cs.Status)
|
||||
}
|
||||
if !ownsChangeset(p, cs) && !p.IsAdmin() {
|
||||
return nil, forbidden("changeset %d is %s's, not yours", id, cs.Username)
|
||||
}
|
||||
return cs, nil
|
||||
}
|
||||
|
||||
func ownsChangeset(p *auth.Principal, cs *model.Changeset) bool {
|
||||
if p == nil || p.User == nil {
|
||||
return false
|
||||
}
|
||||
if p.User.ID != 0 {
|
||||
return cs.UserID == p.User.ID
|
||||
}
|
||||
return cs.UserID == 0 && cs.Username == p.User.Username
|
||||
}
|
||||
|
||||
// stage adds an operation to a changeset. The changeset with it must
|
||||
// still be valid and pass nftables' check; nothing is applied.
|
||||
func (s *Server) stage(w http.ResponseWriter, r *http.Request, p *auth.Principal, id int64, op model.Op) error {
|
||||
s.csMu.Lock()
|
||||
defer s.csMu.Unlock()
|
||||
cs, err := s.openChangeset(r, p, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// The operation on its own, after the changeset so far: what it
|
||||
// changes, for its summary.
|
||||
before := s.eng.State()
|
||||
if err := replay(before, cs.Ops); err != nil {
|
||||
return conflict("changeset %d can't be applied as it is (%v); review it first", id, err)
|
||||
}
|
||||
after := before.Clone()
|
||||
objID, err := applyOp(after, &op)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
engine.Normalize(after)
|
||||
after.Sort()
|
||||
changes := engine.Diff(before, after)
|
||||
if len(changes) == 0 {
|
||||
writeJSON(w, http.StatusOK, api.MutationResponse{Changes: []model.Change{}, Changeset: cs})
|
||||
return nil
|
||||
}
|
||||
op.Summary = engine.Describe(changes)
|
||||
op.AddedAt = s.nowFn()
|
||||
for _, o := range cs.Ops {
|
||||
if o.Seq >= op.Seq {
|
||||
op.Seq = o.Seq + 1
|
||||
}
|
||||
}
|
||||
if op.Seq == 0 {
|
||||
op.Seq = 1
|
||||
}
|
||||
ops := append(append([]model.Op{}, cs.Ops...), op)
|
||||
dry := queryBool(r, "dry_run")
|
||||
// The whole changeset, checked as it would be applied.
|
||||
res, err := s.eng.Apply(r.Context(), p, "", engine.Options{DryRun: true, Via: via(r, p)}, func(st *model.State, _ func() int64) error {
|
||||
return replay(st, ops)
|
||||
})
|
||||
if err != nil {
|
||||
s.writeErr(w, r, err, res)
|
||||
return nil
|
||||
}
|
||||
if !dry {
|
||||
cs.Ops = ops
|
||||
if err := s.store.UpdateChangeset(r.Context(), cs); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
out := api.MutationResponse{Staged: !dry, DryRun: dry, Changes: changes, Warnings: res.Warnings, Changeset: cs}
|
||||
if object := objectOf(&op, objID); object != nil {
|
||||
if v := object(after); v != nil {
|
||||
out.Object, _ = json.Marshal(v)
|
||||
}
|
||||
}
|
||||
if dry {
|
||||
out.Diff = s.rulesetDiff(res.Before, res.After)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) createChangeset(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := require(p, auth.ResConfig, true); err != nil {
|
||||
return err
|
||||
}
|
||||
var in api.ChangesetInput
|
||||
if r.ContentLength != 0 {
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
cs := &model.Changeset{Username: p.Name(), Via: via(r, p), Description: strings.TrimSpace(in.Description), Status: model.ChangesetOpen}
|
||||
if p.User != nil {
|
||||
cs.UserID = p.User.ID
|
||||
}
|
||||
if err := s.store.CreateChangeset(r.Context(), cs); err != nil {
|
||||
return err
|
||||
}
|
||||
s.eng.Audit(r.Context(), p, "changeset.create", fmt.Sprintf("changeset %d", cs.ID), "success", cs.Description)
|
||||
writeJSON(w, http.StatusCreated, cs)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) listChangesets(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := require(p, auth.ResConfig, false); err != nil {
|
||||
return err
|
||||
}
|
||||
var uid int64
|
||||
if queryBool(r, "mine") {
|
||||
uid = -1
|
||||
if p.User != nil && p.User.ID != 0 {
|
||||
uid = p.User.ID
|
||||
}
|
||||
}
|
||||
list, err := s.store.ListChangesets(r.Context(), r.URL.Query().Get("status"), max(uid, 0), queryInt(r, "limit", 100))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if uid == -1 { // the socket's users have no user ID: by name
|
||||
mine := []*model.Changeset{}
|
||||
for _, cs := range list {
|
||||
if ownsChangeset(p, cs) {
|
||||
mine = append(mine, cs)
|
||||
}
|
||||
}
|
||||
list = mine
|
||||
}
|
||||
writeJSON(w, http.StatusOK, list)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) getChangeset(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := require(p, auth.ResConfig, false); err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cs, err := s.store.GetChangeset(r.Context(), id)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return notFound("there is no changeset %d", id)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writeJSON(w, http.StatusOK, cs)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) updateChangeset(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := require(p, auth.ResConfig, true); err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var in api.ChangesetInput
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
return err
|
||||
}
|
||||
s.csMu.Lock()
|
||||
defer s.csMu.Unlock()
|
||||
cs, err := s.openChangeset(r, p, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cs.Description = strings.TrimSpace(in.Description)
|
||||
if err := s.store.UpdateChangeset(r.Context(), cs); err != nil {
|
||||
return err
|
||||
}
|
||||
writeJSON(w, http.StatusOK, cs)
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeOp takes one operation out of a changeset. Later ones that
|
||||
// needed it show up in the preview.
|
||||
func (s *Server) removeOp(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := require(p, auth.ResConfig, true); err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
seq, err := strconv.Atoi(r.PathValue("seq"))
|
||||
if err != nil {
|
||||
return badRequest("invalid operation %q", r.PathValue("seq"))
|
||||
}
|
||||
s.csMu.Lock()
|
||||
defer s.csMu.Unlock()
|
||||
cs, err := s.openChangeset(r, p, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
kept := []model.Op{}
|
||||
for _, o := range cs.Ops {
|
||||
if o.Seq != seq {
|
||||
kept = append(kept, o)
|
||||
}
|
||||
}
|
||||
if len(kept) == len(cs.Ops) {
|
||||
return notFound("changeset %d has no operation %d", id, seq)
|
||||
}
|
||||
cs.Ops = kept
|
||||
if err := s.store.UpdateChangeset(r.Context(), cs); err != nil {
|
||||
return err
|
||||
}
|
||||
writeJSON(w, http.StatusOK, cs)
|
||||
return nil
|
||||
}
|
||||
|
||||
// previewChangeset says what applying a changeset would do, without
|
||||
// doing it: its changes, problems, nftables' check, the ruleset's diff.
|
||||
func (s *Server) previewChangeset(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := require(p, auth.ResConfig, false); err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cs, err := s.store.GetChangeset(r.Context(), id)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return notFound("there is no changeset %d", id)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := s.eng.Apply(r.Context(), p, "", engine.Options{DryRun: true, Via: via(r, p)}, func(st *model.State, _ func() int64) error {
|
||||
return replay(st, cs.Ops)
|
||||
})
|
||||
out := api.ChangesetPreview{Valid: err == nil && res != nil && len(res.Changes) > 0, Changes: []model.Change{}}
|
||||
if res != nil {
|
||||
if res.Changes != nil {
|
||||
out.Changes = res.Changes
|
||||
}
|
||||
out.Warnings = res.Warnings
|
||||
}
|
||||
if err != nil {
|
||||
out.Error = errorInfo(err)
|
||||
} else if res != nil {
|
||||
out.Diff = s.rulesetDiff(res.Before, res.After)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
return nil
|
||||
}
|
||||
|
||||
// errorInfo is an error as the API reports it.
|
||||
func errorInfo(err error) *api.ErrorInfo {
|
||||
var oe *opError
|
||||
if errors.As(err, &oe) {
|
||||
info, _ := oe.info()
|
||||
return &info
|
||||
}
|
||||
var he *httpError
|
||||
if errors.As(err, &he) {
|
||||
return &api.ErrorInfo{Code: he.code, Message: he.msg}
|
||||
}
|
||||
if ee, ok := engine.AsError(err); ok {
|
||||
return &api.ErrorInfo{Code: ee.Kind, Message: ee.Message, Problems: ee.Problems}
|
||||
}
|
||||
return &api.ErrorInfo{Code: "internal", Message: err.Error()}
|
||||
}
|
||||
|
||||
// applyChangeset applies a changeset's operations as one change: one
|
||||
// history entry, and (over the network) on probation like any change.
|
||||
func (s *Server) applyChangeset(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := require(p, auth.ResConfig, true); err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var in api.ChangesetInput
|
||||
if r.ContentLength != 0 {
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
s.csMu.Lock()
|
||||
defer s.csMu.Unlock()
|
||||
cs, err := s.openChangeset(r, p, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d := strings.TrimSpace(in.Description); d != "" {
|
||||
cs.Description = d
|
||||
}
|
||||
if len(cs.Ops) == 0 {
|
||||
return badRequest("changeset %d has no changes to apply", id)
|
||||
}
|
||||
res, err := s.eng.Apply(r.Context(), p, cs.Description, engine.Options{Via: via(r, p)}, func(st *model.State, _ func() int64) error {
|
||||
return replay(st, cs.Ops)
|
||||
})
|
||||
if err != nil {
|
||||
s.eng.Audit(r.Context(), p, "changeset.apply", fmt.Sprintf("changeset %d", id), "failure", err.Error())
|
||||
s.writeErr(w, r, err, res)
|
||||
return nil
|
||||
}
|
||||
cs.Status, cs.HistoryID = model.ChangesetApplied, res.HistoryID
|
||||
if err := s.store.UpdateChangeset(r.Context(), cs); err != nil {
|
||||
return err
|
||||
}
|
||||
s.eng.Audit(r.Context(), p, "changeset.apply", fmt.Sprintf("changeset %d", id), "success", fmt.Sprintf("history %d", res.HistoryID))
|
||||
s.afterChange(r.Context(), p, res)
|
||||
out := s.mutation(res, nil)
|
||||
out.ConfirmToken = s.issueCheck(w, r, p, res)
|
||||
out.Changeset = cs
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) discardChangeset(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := require(p, auth.ResConfig, true); err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.csMu.Lock()
|
||||
defer s.csMu.Unlock()
|
||||
cs, err := s.openChangeset(r, p, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cs.Status = model.ChangesetDiscarded
|
||||
if err := s.store.UpdateChangeset(r.Context(), cs); err != nil {
|
||||
return err
|
||||
}
|
||||
s.eng.Audit(r.Context(), p, "changeset.discard", fmt.Sprintf("changeset %d", id), "success", fmt.Sprintf("%d operations", len(cs.Ops)))
|
||||
writeJSON(w, http.StatusOK, cs)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- reading the configuration as a changeset will make it
|
||||
|
||||
// viewState is the configuration a read shows: the current one, or with
|
||||
// a changeset named, as it will be once that is applied (draft true).
|
||||
// A changeset that no longer applies shows the current configuration,
|
||||
// with the reason in the X-Housecarl-Changeset-Error header.
|
||||
func (s *Server) viewState(w http.ResponseWriter, r *http.Request) (cur, view *model.State, draft bool, err error) {
|
||||
cur = s.eng.State()
|
||||
id, err := changesetParam(r)
|
||||
if err != nil || id == 0 {
|
||||
return cur, cur, false, err
|
||||
}
|
||||
cs, err := s.store.GetChangeset(r.Context(), id)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return nil, nil, false, notFound("there is no changeset %d", id)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, nil, false, err
|
||||
}
|
||||
if cs.Status != model.ChangesetOpen {
|
||||
return cur, cur, false, nil
|
||||
}
|
||||
next := cur.Clone()
|
||||
if err := replay(next, cs.Ops); err != nil {
|
||||
w.Header().Set("X-Housecarl-Changeset-Error", err.Error())
|
||||
return cur, cur, false, nil
|
||||
}
|
||||
engine.Normalize(next)
|
||||
next.Sort()
|
||||
return cur, next, true, nil
|
||||
}
|
||||
|
||||
// withPending lists a kind's objects as a changeset will leave them, each
|
||||
// marked "pending": "new", "changed" or "deleted" (deleted ones stay in
|
||||
// the list, in their place).
|
||||
func withPending(cur, next *model.State, kind string) []any {
|
||||
was := map[int64]string{}
|
||||
for _, o := range cur.Objects(kind) {
|
||||
b, _ := json.Marshal(o)
|
||||
was[o.GetID()] = string(b)
|
||||
}
|
||||
// The deleted ones go back in, so they sort into their places.
|
||||
merged := next.Clone()
|
||||
deleted := map[int64]bool{}
|
||||
for _, o := range cur.Objects(kind) {
|
||||
if merged.ByID(kind, o.GetID()) == nil {
|
||||
b, _ := json.Marshal(o)
|
||||
merged.Put(kind, o.GetID(), b)
|
||||
deleted[o.GetID()] = true
|
||||
}
|
||||
}
|
||||
merged.Sort()
|
||||
out := []any{}
|
||||
for _, o := range merged.Objects(kind) {
|
||||
var m map[string]any
|
||||
b, _ := json.Marshal(o)
|
||||
json.Unmarshal(b, &m)
|
||||
old, existed := was[o.GetID()]
|
||||
switch {
|
||||
case deleted[o.GetID()]:
|
||||
m["pending"] = "deleted"
|
||||
case !existed:
|
||||
m["pending"] = "new"
|
||||
case old != string(b):
|
||||
m["pending"] = "changed"
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,532 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
udiff "github.com/aymanbagabas/go-udiff"
|
||||
|
||||
"housecarl/internal/api"
|
||||
"housecarl/internal/auth"
|
||||
"housecarl/internal/engine"
|
||||
"housecarl/internal/model"
|
||||
"housecarl/internal/store"
|
||||
)
|
||||
|
||||
// change makes an operation: at once through the engine, or, with a
|
||||
// changeset named (?changeset=ID or X-Housecarl-Changeset), staged in it.
|
||||
// A network change starts (or joins) the probation.
|
||||
func (s *Server) change(w http.ResponseWriter, r *http.Request, p *auth.Principal, op model.Op) error {
|
||||
if err := require(p, auth.ResConfig, true); err != nil {
|
||||
return err
|
||||
}
|
||||
if op.Op == model.OpCreate && op.ID == 0 {
|
||||
ids, err := s.store.AllocateIDs(r.Context(), 1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
op.ID = ids[0]
|
||||
}
|
||||
csID, err := changesetParam(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if csID != 0 {
|
||||
return s.stage(w, r, p, csID, op)
|
||||
}
|
||||
var id int64
|
||||
res, err := s.eng.Apply(r.Context(), p, "", engine.Options{DryRun: queryBool(r, "dry_run"), Via: via(r, p)}, func(st *model.State, _ func() int64) error {
|
||||
var err error
|
||||
id, err = applyOp(st, &op)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
s.writeErr(w, r, err, res)
|
||||
return nil
|
||||
}
|
||||
s.afterChange(r.Context(), p, res)
|
||||
out := s.mutation(res, objectOf(&op, id))
|
||||
out.ConfirmToken = s.issueCheck(w, r, p, res)
|
||||
writeJSON(w, s.mutationStatus(res, op.Op == model.OpCreate), out)
|
||||
return nil
|
||||
}
|
||||
|
||||
// mutation is the answer to a change: what changed, warnings, the object
|
||||
// (as it is after the change), the probation, and for a dry run the
|
||||
// ruleset's diff.
|
||||
func (s *Server) mutation(res *engine.Result, object func(st *model.State) any) api.MutationResponse {
|
||||
out := api.MutationResponse{Applied: res.Applied, DryRun: res.DryRun, HistoryID: res.HistoryID, Changes: res.Changes, Warnings: res.Warnings}
|
||||
if out.Changes == nil {
|
||||
out.Changes = []model.Change{}
|
||||
}
|
||||
if pr := s.probationStatus(); res.Applied && (len(pr.Pending) > 0 || pr.Takeovers > 0) {
|
||||
out.ConfirmBy = &pr.Deadline
|
||||
}
|
||||
if object != nil && res.After != nil && (res.Applied || res.DryRun) {
|
||||
if v := object(res.After); v != nil {
|
||||
out.Object, _ = json.Marshal(v)
|
||||
}
|
||||
}
|
||||
if res.DryRun && len(res.Changes) > 0 {
|
||||
out.Diff = s.rulesetDiff(res.Before, res.After)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// mutationStatus is 201 for an applied create, else 200.
|
||||
func (s *Server) mutationStatus(res *engine.Result, creates bool) int {
|
||||
if res.Applied && creates {
|
||||
return http.StatusCreated
|
||||
}
|
||||
return http.StatusOK
|
||||
}
|
||||
|
||||
// respondChange answers a change made without an operation (undo).
|
||||
func (s *Server) respondChange(w http.ResponseWriter, r *http.Request, p *auth.Principal, res *engine.Result, object func(st *model.State) any, creates bool) {
|
||||
out := s.mutation(res, object)
|
||||
out.ConfirmToken = s.issueCheck(w, r, p, res)
|
||||
writeJSON(w, s.mutationStatus(res, creates), out)
|
||||
}
|
||||
|
||||
// rulesetDiff is the unified diff of the rulesets two configurations
|
||||
// make: the file housecarl-ruleset.service loads, now and after.
|
||||
func (s *Server) rulesetDiff(before, after *model.State) string {
|
||||
if before == nil || after == nil {
|
||||
return ""
|
||||
}
|
||||
return udiff.Unified("ruleset.nft (now)", "ruleset.nft (after)", s.fw.Render(before).Text, s.fw.Render(after).Text)
|
||||
}
|
||||
|
||||
// kindOf maps the API's plural path names to kinds.
|
||||
var kindOf = map[string]string{
|
||||
"zones": model.KindZone, "sets": model.KindSet, "services": model.KindService, "rules": model.KindRule,
|
||||
"forwards": model.KindForward, "snats": model.KindSNAT, "redirects": model.KindRedirect,
|
||||
}
|
||||
|
||||
func pathKind(r *http.Request) (string, error) {
|
||||
k, ok := kindOf[r.PathValue("kind")]
|
||||
if !ok {
|
||||
return "", &httpError{status: http.StatusNotFound, code: "not_found", msg: "no such API endpoint: " + r.URL.Path}
|
||||
}
|
||||
return k, nil
|
||||
}
|
||||
|
||||
func findObject(st *model.State, kind, ref string) (model.Object, error) {
|
||||
if o := st.Find(kind, ref); o != nil {
|
||||
return o, nil
|
||||
}
|
||||
return nil, notFound("no %s %s", kind, ref)
|
||||
}
|
||||
|
||||
// ---- objects
|
||||
|
||||
func (s *Server) listObjects(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := require(p, auth.ResConfig, false); err != nil {
|
||||
return err
|
||||
}
|
||||
kind, err := pathKind(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cur, st, draft, err := s.viewState(w, r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out := []any{}
|
||||
c := r.URL.Query().Get("chain")
|
||||
if draft {
|
||||
// Marked new, changed or deleted.
|
||||
for _, o := range withPending(cur, st, kind) {
|
||||
if c != "" && kind == model.KindRule && o.(map[string]any)["chain"] != c {
|
||||
continue
|
||||
}
|
||||
out = append(out, o)
|
||||
}
|
||||
} else {
|
||||
for _, o := range st.Objects(kind) {
|
||||
if c != "" {
|
||||
if rule, ok := o.(*model.Rule); ok && rule.Chain != c {
|
||||
continue
|
||||
}
|
||||
}
|
||||
out = append(out, o)
|
||||
}
|
||||
}
|
||||
// Services: the built-in ones too, unless overridden.
|
||||
if kind == model.KindService && !queryBool(r, "own") {
|
||||
for _, b := range model.BuiltinServices() {
|
||||
if mine := st.Find(kind, b.Name); mine == nil {
|
||||
out = append(out, b)
|
||||
}
|
||||
}
|
||||
name := func(x any) string {
|
||||
if m, ok := x.(map[string]any); ok {
|
||||
n, _ := m["name"].(string)
|
||||
return n
|
||||
}
|
||||
return x.(*model.Service).Name
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool { return name(out[i]) < name(out[j]) })
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) getObject(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := require(p, auth.ResConfig, false); err != nil {
|
||||
return err
|
||||
}
|
||||
kind, err := pathKind(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, st, _, err := s.viewState(w, r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
o, err := findObject(st, kind, r.PathValue("ref"))
|
||||
if err != nil && kind == model.KindService {
|
||||
if b := model.BuiltinService(r.PathValue("ref")); b != nil {
|
||||
writeJSON(w, http.StatusOK, b)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writeJSON(w, http.StatusOK, o)
|
||||
return nil
|
||||
}
|
||||
|
||||
// decodeObject reads a kind's object from JSON (unknown fields refused).
|
||||
func decodeObject(kind string, data []byte) (model.Object, error) {
|
||||
var o model.Object
|
||||
switch kind {
|
||||
case model.KindZone:
|
||||
o = &model.Zone{}
|
||||
case model.KindSet:
|
||||
o = &model.AddressSet{}
|
||||
case model.KindService:
|
||||
o = &model.Service{}
|
||||
case model.KindRule:
|
||||
o = &model.Rule{}
|
||||
case model.KindForward:
|
||||
o = &model.PortForward{}
|
||||
case model.KindSNAT:
|
||||
o = &model.SourceNAT{}
|
||||
case model.KindRedirect:
|
||||
o = &model.Redirect{}
|
||||
}
|
||||
dec := json.NewDecoder(strings.NewReader(string(data)))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(o); err != nil {
|
||||
return nil, badRequest("%s: %v", kind, err)
|
||||
}
|
||||
return o, nil
|
||||
}
|
||||
|
||||
// lastPosition is after the last rule of the chain (or NAT rule of the
|
||||
// kind).
|
||||
func lastPosition(st *model.State, kind, chain string) float64 {
|
||||
max := 0.0
|
||||
for _, o := range st.Objects(kind) {
|
||||
var pos float64
|
||||
switch x := o.(type) {
|
||||
case *model.Rule:
|
||||
if x.Chain != chain {
|
||||
continue
|
||||
}
|
||||
pos = x.Position
|
||||
case *model.PortForward:
|
||||
pos = x.Position
|
||||
case *model.SourceNAT:
|
||||
pos = x.Position
|
||||
case *model.Redirect:
|
||||
pos = x.Position
|
||||
}
|
||||
if pos > max {
|
||||
max = pos
|
||||
}
|
||||
}
|
||||
return max + 1
|
||||
}
|
||||
|
||||
func setID(o model.Object, id int64) {
|
||||
switch x := o.(type) {
|
||||
case *model.Zone:
|
||||
x.ID = id
|
||||
case *model.AddressSet:
|
||||
x.ID = id
|
||||
case *model.Service:
|
||||
x.ID = id
|
||||
case *model.Rule:
|
||||
x.ID = id
|
||||
case *model.PortForward:
|
||||
x.ID = id
|
||||
case *model.SourceNAT:
|
||||
x.ID = id
|
||||
case *model.Redirect:
|
||||
x.ID = id
|
||||
}
|
||||
}
|
||||
|
||||
func position(o model.Object) *float64 {
|
||||
switch x := o.(type) {
|
||||
case *model.Rule:
|
||||
return &x.Position
|
||||
case *model.PortForward:
|
||||
return &x.Position
|
||||
case *model.SourceNAT:
|
||||
return &x.Position
|
||||
case *model.Redirect:
|
||||
return &x.Position
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func chainOf(o model.Object) string {
|
||||
if r, ok := o.(*model.Rule); ok {
|
||||
return r.Chain
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *Server) createObject(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
kind, err := pathKind(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var raw json.RawMessage
|
||||
if err := decodeJSON(r, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
// Refuse a bad object now, not when it is applied.
|
||||
if _, err := decodeObject(kind, raw); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.change(w, r, p, model.Op{Op: model.OpCreate, Kind: kind, Data: raw})
|
||||
}
|
||||
|
||||
func (s *Server) updateObject(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
kind, err := pathKind(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var patch map[string]json.RawMessage
|
||||
if err := decodeJSON(r, &patch); err != nil {
|
||||
return err
|
||||
}
|
||||
data, _ := json.Marshal(patch)
|
||||
return s.change(w, r, p, model.Op{Op: model.OpUpdate, Kind: kind, Ref: r.PathValue("ref"), Data: data})
|
||||
}
|
||||
|
||||
func (s *Server) deleteObject(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
kind, err := pathKind(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.change(w, r, p, model.Op{Op: model.OpDelete, Kind: kind, Ref: r.PathValue("ref")})
|
||||
}
|
||||
|
||||
// usedBy names an object that refers to o (zones, sets and services are
|
||||
// referred to by name), or "".
|
||||
func usedBy(st *model.State, kind string, o model.Object) string {
|
||||
name := o.Label()
|
||||
has := func(list []string) bool {
|
||||
for _, x := range list {
|
||||
if strings.EqualFold(x, name) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
uses := func(m model.Match) bool {
|
||||
switch kind {
|
||||
case model.KindZone:
|
||||
return has(m.InZones) || has(m.OutZones)
|
||||
case model.KindSet:
|
||||
return has(m.Sources) || has(m.Destinations)
|
||||
case model.KindService:
|
||||
return has(m.Services)
|
||||
}
|
||||
return false
|
||||
}
|
||||
for _, x := range st.Rules {
|
||||
if uses(x.Match) {
|
||||
return x.Label()
|
||||
}
|
||||
}
|
||||
for _, x := range st.SNATs {
|
||||
if uses(x.Match) {
|
||||
return x.Label()
|
||||
}
|
||||
}
|
||||
for _, x := range st.Redirects {
|
||||
if uses(x.Match) {
|
||||
return x.Label()
|
||||
}
|
||||
}
|
||||
if kind == model.KindSet {
|
||||
for _, f := range st.Forwards {
|
||||
if has(f.Sources) || has(f.PublicAddresses) || has(f.HairpinSources) {
|
||||
return f.Label()
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// moveObject reorders a rule or NAT rule (moveOp).
|
||||
func (s *Server) moveObject(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
kind, err := pathKind(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var in api.MoveInput
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
return err
|
||||
}
|
||||
data, _ := json.Marshal(in)
|
||||
return s.change(w, r, p, model.Op{Op: model.OpMove, Kind: kind, Ref: r.PathValue("ref"), Data: data})
|
||||
}
|
||||
|
||||
// ---- settings
|
||||
|
||||
func (s *Server) getSettings(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := require(p, auth.ResConfig, false); err != nil {
|
||||
return err
|
||||
}
|
||||
_, st, _, err := s.viewState(w, r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writeJSON(w, http.StatusOK, st.Settings)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) updateSettings(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
var patch map[string]json.RawMessage
|
||||
if err := decodeJSON(r, &patch); err != nil {
|
||||
return err
|
||||
}
|
||||
data, _ := json.Marshal(patch)
|
||||
return s.change(w, r, p, model.Op{Op: model.OpSettings, Data: data})
|
||||
}
|
||||
|
||||
// ---- the ruleset
|
||||
|
||||
func (s *Server) ruleset(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := require(p, auth.ResConfig, false); err != nil {
|
||||
return err
|
||||
}
|
||||
_, st, _, err := s.viewState(w, r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rs := s.fw.Render(st)
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.Write([]byte(rs.Text))
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- history
|
||||
|
||||
func (s *Server) listHistory(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := require(p, auth.ResConfig, false); err != nil {
|
||||
return err
|
||||
}
|
||||
hs, err := s.store.ListHistory(r.Context(), queryInt(r, "limit", 100), int64(queryInt(r, "before", 0)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writeJSON(w, http.StatusOK, hs)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) getHistory(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := require(p, auth.ResConfig, false); err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
h, err := s.store.GetHistory(r.Context(), id)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return notFound("history entry %d not found", id)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writeJSON(w, http.StatusOK, h)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) revertHistory(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := require(p, auth.ResConfig, true); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := notStaged(r, "an undo"); err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := s.eng.Revert(r.Context(), p, id, engine.Options{DryRun: queryBool(r, "dry_run"), Via: via(r, p)})
|
||||
if err != nil {
|
||||
s.writeErr(w, r, err, res)
|
||||
return nil
|
||||
}
|
||||
s.afterChange(r.Context(), p, res)
|
||||
s.respondChange(w, r, p, res, nil, false)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- probation
|
||||
|
||||
func (s *Server) getProbation(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := require(p, auth.ResConfig, false); err != nil {
|
||||
return err
|
||||
}
|
||||
writeJSON(w, http.StatusOK, s.probationStatus())
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) confirmChanges(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := require(p, auth.ResConfig, true); err != nil {
|
||||
return err
|
||||
}
|
||||
var in api.ConfirmInput
|
||||
if r.ContentLength != 0 {
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
confirm := func() (int, error) { return s.confirm(r.Context(), p) }
|
||||
if in.Token != "" {
|
||||
confirm = func() (int, error) { return s.confirmToken(r, p, in.Token) }
|
||||
}
|
||||
n, err := confirm()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]int{"confirmed": n})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) rollbackChanges(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := require(p, auth.ResConfig, true); err != nil {
|
||||
return err
|
||||
}
|
||||
ps := s.probationStatus()
|
||||
n := len(ps.Pending) + ps.Takeovers
|
||||
if n > 0 {
|
||||
s.rollback("rolled back by " + p.Name())
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]int{"rolled_back": n})
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"housecarl/internal/api"
|
||||
"housecarl/internal/auth"
|
||||
"housecarl/internal/engine"
|
||||
"housecarl/internal/importer"
|
||||
"housecarl/internal/model"
|
||||
)
|
||||
|
||||
// importNft makes an nftables ruleset Housecarl's configuration
|
||||
// (internal/importer); ?dry_run=1 shows the result without applying it.
|
||||
func (s *Server) importNft(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := require(p, auth.ResConfig, true); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := notStaged(r, "an import"); err != nil {
|
||||
return err
|
||||
}
|
||||
var in api.ImportInput
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := importer.Import(importer.Input{JSON: in.JSON, Text: in.Text, Defines: in.Defines, Table: in.Table})
|
||||
if err != nil {
|
||||
return badRequest("%v", err)
|
||||
}
|
||||
cur := s.eng.State()
|
||||
if !in.Replace && hasConfig(cur) {
|
||||
return conflict("Housecarl already has a configuration; import with replace (--replace) to replace it")
|
||||
}
|
||||
// The importer numbers objects from 1; give them IDs of their own.
|
||||
if err := s.renumber(r, res.State); err != nil {
|
||||
return err
|
||||
}
|
||||
opts := engine.Options{DryRun: queryBool(r, "dry_run"), Via: via(r, p)}
|
||||
er, err := s.eng.Replace(r.Context(), p, "import from nftables ("+tableName(in.Table)+")", opts, res.State)
|
||||
out := api.ImportResult{State: res.State, Notes: res.Notes, Raw: res.Raw}
|
||||
if out.Notes == nil {
|
||||
out.Notes = []string{}
|
||||
}
|
||||
if out.Raw == nil {
|
||||
out.Raw = []string{}
|
||||
}
|
||||
if err != nil {
|
||||
s.writeErr(w, r, err, out)
|
||||
return nil
|
||||
}
|
||||
s.afterChange(r.Context(), p, er)
|
||||
out.MutationResponse = api.MutationResponse{Applied: er.Applied, DryRun: er.DryRun, HistoryID: er.HistoryID, Changes: er.Changes, Warnings: er.Warnings}
|
||||
if out.Changes == nil {
|
||||
out.Changes = []model.Change{}
|
||||
}
|
||||
if pr := s.probationStatus(); er.Applied && len(pr.Pending) > 0 {
|
||||
out.ConfirmBy = &pr.Deadline
|
||||
}
|
||||
out.ConfirmToken = s.issueCheck(w, r, p, er)
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
return nil
|
||||
}
|
||||
|
||||
func tableName(t string) string {
|
||||
if t == "" {
|
||||
return "the ruleset"
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func hasConfig(st *model.State) bool {
|
||||
for _, k := range model.Kinds {
|
||||
if len(st.Objects(k)) > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// renumber gives every object of an imported state a fresh ID.
|
||||
func (s *Server) renumber(r *http.Request, st *model.State) error {
|
||||
n := 0
|
||||
for _, k := range model.Kinds {
|
||||
n += len(st.Objects(k))
|
||||
}
|
||||
if n == 0 {
|
||||
return nil
|
||||
}
|
||||
ids, err := s.store.AllocateIDs(r.Context(), n)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
i := 0
|
||||
for _, k := range model.Kinds {
|
||||
for _, o := range st.Objects(k) {
|
||||
setID(o, ids[i])
|
||||
i++
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// takeover deletes the tables Housecarl replaces (the ruleset imported).
|
||||
// Over the network it is on probation: unless confirmed, the tables come
|
||||
// back.
|
||||
func (s *Server) takeover(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := requireAdmin(p); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := notStaged(r, "a takeover"); err != nil {
|
||||
return err
|
||||
}
|
||||
var in api.TakeoverInput
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(in.Tables) == 0 {
|
||||
return badRequest("name the tables to delete (\"inet main\")")
|
||||
}
|
||||
var done []string
|
||||
for _, t := range in.Tables {
|
||||
fam, name, ok := strings.Cut(strings.TrimSpace(t), " ")
|
||||
if !ok || name == "" {
|
||||
return badRequest("%q: write a table as FAMILY NAME (\"inet main\")", t)
|
||||
}
|
||||
if fam == "inet" && name == "housecarl" {
|
||||
return badRequest("that is Housecarl's own table")
|
||||
}
|
||||
script, err := s.fw.TableText(r.Context(), fam, name)
|
||||
if err != nil {
|
||||
return notFound("there is no table %s %s", fam, name)
|
||||
}
|
||||
if queryBool(r, "dry_run") {
|
||||
done = append(done, t)
|
||||
continue
|
||||
}
|
||||
if err := s.fw.DeleteTable(r.Context(), fam, name); err != nil {
|
||||
return fmt.Errorf("deleting table %s: %w", t, err)
|
||||
}
|
||||
s.startProbation(r.Context(), p, 0, script)
|
||||
s.eng.Audit(r.Context(), p, "takeover", t, "success", "")
|
||||
done = append(done, t)
|
||||
}
|
||||
msg := "deleted " + strings.Join(done, ", ")
|
||||
if queryBool(r, "dry_run") {
|
||||
msg = "would delete " + strings.Join(done, ", ")
|
||||
} else if pr := s.probationStatus(); pr.Takeovers > 0 {
|
||||
msg += fmt.Sprintf("; confirm with 'hc confirm' within %ds, or they come back", pr.SecondsLeft)
|
||||
}
|
||||
answer := map[string]any{"deleted": done, "message": msg}
|
||||
if !queryBool(r, "dry_run") && len(done) > 0 {
|
||||
if tok := s.issueCheck(w, r, p, nil); tok != "" {
|
||||
answer["confirm_token"] = tok
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, answer)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"housecarl/internal/api"
|
||||
"housecarl/internal/auth"
|
||||
"housecarl/internal/model"
|
||||
"housecarl/internal/render"
|
||||
"housecarl/internal/store"
|
||||
"housecarl/internal/validate"
|
||||
"housecarl/internal/version"
|
||||
)
|
||||
|
||||
// ---- status
|
||||
|
||||
func (s *Server) status(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if !p.Can(auth.ResConfig, false) && !p.Can(auth.ResLog, false) {
|
||||
return forbidden("permission denied: this needs config:read or log:read")
|
||||
}
|
||||
st := s.eng.State()
|
||||
out := api.Status{Build: version.Info(), Started: s.started, Problems: s.eng.Problems(), Probation: s.probationStatus(),
|
||||
Interfaces: s.hostInterfaces(st), Counts: map[string]int{}, OtherTables: []string{}}
|
||||
if out.Problems == nil {
|
||||
out.Problems = []validate.Problem{}
|
||||
}
|
||||
if p.IsAdmin() {
|
||||
out.Database = s.store.Path()
|
||||
}
|
||||
for _, k := range model.Kinds {
|
||||
out.Counts[k] = len(st.Objects(k))
|
||||
}
|
||||
if _, err := s.fw.Table(r.Context()); err != nil && st.Empty() {
|
||||
out.Unconfigured = true
|
||||
} else if err != nil {
|
||||
out.ApplyError = err.Error()
|
||||
} else {
|
||||
out.Applied = true
|
||||
}
|
||||
if data, err := s.fw.ListRuleset(r.Context()); err == nil {
|
||||
out.OtherTables = otherTables(data)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
return nil
|
||||
}
|
||||
|
||||
// hostInterfaces lists the machine's interfaces with their zones.
|
||||
func (s *Server) hostInterfaces(st *model.State) []api.HostInterface {
|
||||
out := []api.HostInterface{}
|
||||
if s.ifaces == nil {
|
||||
return out
|
||||
}
|
||||
all, err := s.ifaces.All()
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
for name, ps := range all {
|
||||
hi := api.HostInterface{Name: name, Addresses: []string{}, Up: s.ifaces.Up(name), Zone: zoneOf(st, name)}
|
||||
for _, a := range ps {
|
||||
hi.Addresses = append(hi.Addresses, a.String())
|
||||
}
|
||||
out = append(out, hi)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out
|
||||
}
|
||||
|
||||
// zoneOf names the zone an interface is in: an exact name, else the
|
||||
// longest matching wildcard.
|
||||
func zoneOf(st *model.State, name string) string {
|
||||
best, bestLen := "", -1
|
||||
for _, z := range st.Zones {
|
||||
for _, i := range z.Interfaces {
|
||||
if i == name {
|
||||
return z.Name
|
||||
}
|
||||
if p, ok := strings.CutSuffix(i, "*"); ok && strings.HasPrefix(name, p) && len(p) > bestLen {
|
||||
best, bestLen = z.Name, len(p)
|
||||
}
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
func (s *Server) interfaces(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := require(p, auth.ResConfig, false); err != nil {
|
||||
return err
|
||||
}
|
||||
writeJSON(w, http.StatusOK, s.hostInterfaces(s.eng.State()))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) counters(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := require(p, auth.ResLog, false); err != nil {
|
||||
return err
|
||||
}
|
||||
t, err := s.fw.Table(r.Context())
|
||||
if err != nil && s.eng.State().Empty() {
|
||||
// Nothing applied yet: nothing counted.
|
||||
writeJSON(w, http.StatusOK, api.Counters{Since: s.started, Counters: map[string]api.Counter{}})
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out := api.Counters{Since: t.Since, Counters: map[string]api.Counter{}}
|
||||
for k, c := range t.Counters {
|
||||
out.Counters[k] = api.Counter{Packets: c.Packets, Bytes: c.Bytes}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) listAudit(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := require(p, auth.ResAudit, false); err != nil {
|
||||
return err
|
||||
}
|
||||
q := r.URL.Query()
|
||||
evs, err := s.store.ListAudit(r.Context(), store.AuditFilter{Username: q.Get("user"), Action: q.Get("action"), Result: q.Get("result"),
|
||||
Search: q.Get("q"), Before: int64(queryInt(r, "before", 0)), Limit: queryInt(r, "limit", 200)})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writeJSON(w, http.StatusOK, evs)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) catalog(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
roles := map[string][]string{}
|
||||
for n, gs := range auth.Roles {
|
||||
roles[n] = grantStrings(gs)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"services": model.BuiltinServices(), "chains": model.Chains, "actions": model.Actions,
|
||||
"roles": roles, "log_group": render.LogGroup,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- dynamic sets' entries
|
||||
|
||||
func (s *Server) dynamicSet(r *http.Request, p *auth.Principal, write bool) (*model.AddressSet, error) {
|
||||
name := r.PathValue("ref")
|
||||
if !p.CanSet(name, write) {
|
||||
access := "read"
|
||||
if write {
|
||||
access = "write"
|
||||
}
|
||||
return nil, forbidden("permission denied: this needs sets:%s for %s", access, name)
|
||||
}
|
||||
set := s.eng.State().Set(name)
|
||||
if set == nil {
|
||||
return nil, notFound("no set %s", name)
|
||||
}
|
||||
if !set.Dynamic {
|
||||
return nil, badRequest("set %s isn't dynamic: change its entries with PATCH /sets/%s", set.Name, set.Name)
|
||||
}
|
||||
return set, nil
|
||||
}
|
||||
|
||||
func (s *Server) listElements(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
set, err := s.dynamicSet(r, p, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
t, err := s.fw.Table(r.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out := []api.Element{}
|
||||
for _, e := range t.Elements[set.Name] {
|
||||
out = append(out, api.Element{Address: e.Addr, Timeout: e.Timeout})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) addElement(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
set, err := s.dynamicSet(r, p, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var in api.Element
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
return err
|
||||
}
|
||||
a, err := model.ParseAddress(in.Address)
|
||||
if err != nil || !a.Prefix.IsSingleIP() {
|
||||
return badRequest("a dynamic set takes single addresses (%q)", in.Address)
|
||||
}
|
||||
if in.Timeout < 0 || in.Timeout > 366*24*3600 {
|
||||
return badRequest("the timeout is in seconds, up to a year")
|
||||
}
|
||||
if err := s.fw.AddElement(r.Context(), set.Name, a.String(), in.Timeout); err != nil {
|
||||
return err
|
||||
}
|
||||
s.eng.Audit(r.Context(), p, "set.add", set.Name+" "+a.String(), "success", "")
|
||||
writeJSON(w, http.StatusCreated, api.Element{Address: a.String(), Timeout: in.Timeout})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) deleteElement(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
set, err := s.dynamicSet(r, p, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
a, err := model.ParseAddress(r.PathValue("addr"))
|
||||
if err != nil {
|
||||
return badRequest("%v", err)
|
||||
}
|
||||
if err := s.fw.DeleteElement(r.Context(), set.Name, a.String()); err != nil {
|
||||
return notFound("%s isn't in set %s", a, set.Name)
|
||||
}
|
||||
s.eng.Audit(r.Context(), p, "set.delete", set.Name+" "+a.String(), "success", "")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- the log
|
||||
|
||||
// logRing keeps the latest logged packets.
|
||||
type logRing struct {
|
||||
mu sync.Mutex
|
||||
max int
|
||||
list []api.LogEntry
|
||||
}
|
||||
|
||||
func newLogRing(max int) *logRing { return &logRing{max: max} }
|
||||
|
||||
// Add records a logged packet.
|
||||
func (l *logRing) Add(e api.LogEntry) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
l.list = append(l.list, e)
|
||||
if len(l.list) > l.max {
|
||||
l.list = append([]api.LogEntry(nil), l.list[len(l.list)-l.max:]...)
|
||||
}
|
||||
}
|
||||
|
||||
// Latest returns up to n entries, newest first, after a time.
|
||||
func (l *logRing) Latest(n int, after time.Time, keep func(api.LogEntry) bool) []api.LogEntry {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
out := []api.LogEntry{}
|
||||
for i := len(l.list) - 1; i >= 0 && (n <= 0 || len(out) < n); i-- {
|
||||
e := l.list[i]
|
||||
if !e.Time.After(after) {
|
||||
break
|
||||
}
|
||||
if keep == nil || keep(e) {
|
||||
out = append(out, e)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// LogPacket records a packet the firewall logged (internal/nflog calls
|
||||
// it).
|
||||
func (s *Server) LogPacket(e api.LogEntry) { s.logs.Add(e) }
|
||||
|
||||
func (s *Server) listLog(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := require(p, auth.ResLog, false); err != nil {
|
||||
return err
|
||||
}
|
||||
q := r.URL.Query()
|
||||
var after time.Time
|
||||
if v := q.Get("after"); v != "" {
|
||||
after, _ = time.Parse(time.RFC3339Nano, v)
|
||||
}
|
||||
search := strings.ToLower(q.Get("q"))
|
||||
writeJSON(w, http.StatusOK, s.logs.Latest(queryInt(r, "limit", 200), after, func(e api.LogEntry) bool {
|
||||
if search == "" {
|
||||
return true
|
||||
}
|
||||
return strings.Contains(strings.ToLower(e.Prefix+" "+e.Source+" "+e.Dest+" "+e.In+" "+e.Out+" "+e.Protocol), search)
|
||||
}))
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,484 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"housecarl/internal/api"
|
||||
"housecarl/internal/auth"
|
||||
"housecarl/internal/config"
|
||||
"housecarl/internal/model"
|
||||
"housecarl/internal/store"
|
||||
)
|
||||
|
||||
func (s *Server) changeOwnPassword(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if p.Token != nil {
|
||||
return forbidden("passwords can't be changed with an API token; log in to the web UI")
|
||||
}
|
||||
if p.User.ID == 0 {
|
||||
return badRequest("%s on the local socket isn't a Housecarl user; use 'hc user update USER --password'", p.Name())
|
||||
}
|
||||
var in api.PasswordChange
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
return err
|
||||
}
|
||||
u, err := s.store.GetUser(r.Context(), p.User.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if u.PasswordHash != "" && !auth.CheckPassword(u.PasswordHash, in.Current) {
|
||||
s.eng.Audit(r.Context(), p, "user.password", "user "+u.Username, "failure", "wrong current password")
|
||||
return &httpError{http.StatusForbidden, "wrong_password", "the current password is wrong"}
|
||||
}
|
||||
hash, err := auth.HashPassword(in.Password)
|
||||
if err != nil {
|
||||
return badRequest("%v", err)
|
||||
}
|
||||
u.PasswordHash = hash
|
||||
if err := s.store.UpdateUser(r.Context(), u); err != nil {
|
||||
return err
|
||||
}
|
||||
keep := ""
|
||||
if se, ok := r.Context().Value(keySession).(*model.Session); ok {
|
||||
keep = se.IDHash
|
||||
}
|
||||
s.store.DeleteUserSessions(r.Context(), u.ID, keep)
|
||||
s.eng.Audit(r.Context(), p, "user.password", "user "+u.Username, "success", "")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return nil
|
||||
}
|
||||
|
||||
func grantsFrom(strs []string, role string) ([]model.Grant, bool, error) {
|
||||
var out []model.Grant
|
||||
set := false
|
||||
if role != "" {
|
||||
gs, ok := auth.Roles[role]
|
||||
if !ok {
|
||||
return nil, false, badRequest("unknown role %q (use %s)", role, strings.Join(auth.RoleNames(), ", "))
|
||||
}
|
||||
out = append(out, gs...)
|
||||
set = true
|
||||
}
|
||||
for _, g := range strs {
|
||||
gr, err := auth.ParseGrant(g)
|
||||
if err != nil {
|
||||
return nil, false, badRequest("%v", err)
|
||||
}
|
||||
out = append(out, gr)
|
||||
set = true
|
||||
}
|
||||
if set && out == nil {
|
||||
out = []model.Grant{}
|
||||
}
|
||||
return out, set, nil
|
||||
}
|
||||
|
||||
func grantStrings(gs []model.Grant) []string {
|
||||
out := []string{}
|
||||
for _, g := range gs {
|
||||
out = append(out, auth.FormatGrant(g))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func presentToken(t *model.Token) api.Token {
|
||||
out := api.Token{Token: t, GrantStrings: grantStrings(t.Grants)}
|
||||
if t.Grants == nil {
|
||||
out.GrantStrings = []string{"(all of the user's)"}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Server) createTokenFor(w http.ResponseWriter, r *http.Request, p *auth.Principal, u *model.User) error {
|
||||
var in api.TokenInput
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(in.Name) == "" {
|
||||
return badRequest("a token needs a name")
|
||||
}
|
||||
grants, set, err := grantsFrom(in.Grants, in.Role)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var exp *time.Time
|
||||
switch {
|
||||
case in.ExpiresAt != nil:
|
||||
if in.ExpiresAt.Before(time.Now()) {
|
||||
return badRequest("expires_at is in the past")
|
||||
}
|
||||
exp = in.ExpiresAt
|
||||
case in.ExpiresIn != "":
|
||||
d, err := config.ParseDuration(in.ExpiresIn)
|
||||
if err != nil || d <= 0 {
|
||||
return badRequest("invalid expires_in %q (use e.g. 90d or 720h)", in.ExpiresIn)
|
||||
}
|
||||
t := time.Now().Add(d)
|
||||
exp = &t
|
||||
}
|
||||
// A restricted token can only make tokens no wider than itself.
|
||||
if p.Token != nil && p.Token.Grants != nil {
|
||||
if !set {
|
||||
grants, set = p.Token.Grants, true
|
||||
} else if !auth.Narrower(grants, p.Token.Grants) {
|
||||
return forbidden("a restricted token can only create tokens with a subset of its own grants")
|
||||
}
|
||||
}
|
||||
secret, pid, hash := auth.NewToken()
|
||||
t := &model.Token{UserID: u.ID, Name: strings.TrimSpace(in.Name), PublicID: pid, SecretHash: hash, ExpiresAt: exp}
|
||||
if set {
|
||||
t.Grants = grants
|
||||
}
|
||||
if err := s.store.CreateToken(r.Context(), t); err != nil {
|
||||
return err
|
||||
}
|
||||
t.Username = u.Username
|
||||
s.eng.Audit(r.Context(), p, "token.create", "token "+u.Username+"/"+t.Name, "success", strings.Join(grantStrings(t.Grants), " "))
|
||||
out := presentToken(t)
|
||||
out.Secret = secret
|
||||
writeJSON(w, http.StatusCreated, out)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) createOwnToken(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if p.User.ID == 0 {
|
||||
return badRequest("%s on the local socket isn't a Housecarl user and has no tokens; make the token for a user (hc token create NAME --user admin)", p.Name())
|
||||
}
|
||||
return s.createTokenFor(w, r, p, p.User)
|
||||
}
|
||||
|
||||
func (s *Server) listTokens(w http.ResponseWriter, r *http.Request, userID int64) error {
|
||||
ts, err := s.store.ListTokens(r.Context(), userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out := []api.Token{}
|
||||
for _, t := range ts {
|
||||
if t.RevokedAt != nil && !queryBool(r, "all") {
|
||||
continue
|
||||
}
|
||||
out = append(out, presentToken(t))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) listOwnTokens(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if p.User.ID == 0 {
|
||||
writeJSON(w, http.StatusOK, []api.Token{})
|
||||
return nil
|
||||
}
|
||||
return s.listTokens(w, r, p.User.ID)
|
||||
}
|
||||
|
||||
func (s *Server) listAllTokens(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := requireAdmin(p); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.listTokens(w, r, 0)
|
||||
}
|
||||
|
||||
func (s *Server) revokeToken(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
t, err := s.store.GetToken(r.Context(), id)
|
||||
if err != nil || t.UserID != p.User.ID && !p.IsAdmin() {
|
||||
return notFound("token %d not found", id)
|
||||
}
|
||||
if err := s.store.RevokeToken(r.Context(), id); err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return conflict("token %d is already revoked", id)
|
||||
}
|
||||
return err
|
||||
}
|
||||
s.eng.Audit(r.Context(), p, "token.revoke", "token "+t.Username+"/"+t.Name, "success", "")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return nil
|
||||
}
|
||||
|
||||
// maxRotationGrace is the longest an old secret may keep working.
|
||||
const maxRotationGrace = 7 * 24 * time.Hour
|
||||
|
||||
// rotateToken: POST /tokens/{id}/rotate gives a token a new secret. It
|
||||
// keeps its ID, name, user and grants, so only the programs that use it
|
||||
// need the new secret. The old secret stops at once, or after the grace
|
||||
// period. A token that expires gets its lifetime again, from now.
|
||||
func (s *Server) rotateToken(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var in api.TokenRotation
|
||||
if err := decodeOptional(r, &in); err != nil {
|
||||
return err
|
||||
}
|
||||
t, err := s.store.GetToken(r.Context(), id)
|
||||
if err != nil || t.UserID != p.User.ID && !p.IsAdmin() {
|
||||
return notFound("token %d not found", id)
|
||||
}
|
||||
// A restricted token may rotate itself and tokens no wider than
|
||||
// itself, but not a wider one: it would learn that token's secret.
|
||||
if !p.IsAdmin() && p.Token != nil && p.Token.Grants != nil && p.Token.ID != t.ID && (t.Grants == nil || !auth.Narrower(t.Grants, p.Token.Grants)) {
|
||||
return forbidden("a restricted token can only rotate itself and tokens with a subset of its own grants")
|
||||
}
|
||||
if t.RevokedAt != nil {
|
||||
return conflict("token %d is revoked; make a new one", id)
|
||||
}
|
||||
now := time.Now()
|
||||
var exp *time.Time
|
||||
switch {
|
||||
case in.ExpiresIn != "":
|
||||
if in.NoExpiry {
|
||||
return badRequest("no_expiry can't be combined with expires_in")
|
||||
}
|
||||
d, err := config.ParseDuration(in.ExpiresIn)
|
||||
if err != nil || d <= 0 {
|
||||
return badRequest("invalid expires_in %q (use e.g. 90d or 720h)", in.ExpiresIn)
|
||||
}
|
||||
e := now.Add(d)
|
||||
exp = &e
|
||||
case in.NoExpiry:
|
||||
case t.ExpiresAt != nil:
|
||||
if life := t.ExpiresAt.Sub(t.IssuedAt()); life > 0 {
|
||||
e := now.Add(life)
|
||||
exp = &e
|
||||
}
|
||||
}
|
||||
var prevUntil *time.Time
|
||||
if in.Grace != "" {
|
||||
g, err := config.ParseDuration(in.Grace)
|
||||
if err != nil || g < 0 || g > maxRotationGrace {
|
||||
return badRequest("invalid grace %q: use a duration up to 7d, e.g. 30m, 24h or 2d", in.Grace)
|
||||
}
|
||||
until := now.Add(g)
|
||||
if t.ExpiresAt != nil && t.ExpiresAt.Before(until) {
|
||||
until = *t.ExpiresAt // the old secret never outlives its token
|
||||
}
|
||||
if g > 0 && until.After(now) {
|
||||
prevUntil = &until
|
||||
}
|
||||
}
|
||||
secret, hash := auth.RotateToken(t.PublicID)
|
||||
if err := s.store.RotateToken(r.Context(), t.ID, hash, exp, prevUntil); err != nil {
|
||||
return err
|
||||
}
|
||||
if t, err = s.store.GetToken(r.Context(), id); err != nil {
|
||||
return err
|
||||
}
|
||||
detail := "the old secret stops now"
|
||||
if prevUntil != nil {
|
||||
detail = "the old secret works until " + prevUntil.UTC().Format(time.RFC3339)
|
||||
}
|
||||
s.eng.Audit(r.Context(), p, "token.rotate", "token "+t.Username+"/"+t.Name, "success", detail)
|
||||
out := presentToken(t)
|
||||
out.Secret = secret
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
return nil
|
||||
}
|
||||
|
||||
func presentUser(u *model.User) api.User {
|
||||
return api.User{User: u, GrantStrings: grantStrings(u.Grants)}
|
||||
}
|
||||
|
||||
func (s *Server) listUsers(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := requireAdmin(p); err != nil {
|
||||
return err
|
||||
}
|
||||
us, err := s.store.ListUsers(r.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out := []api.User{}
|
||||
for _, u := range us {
|
||||
out = append(out, presentUser(u))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidUsername checks a user name.
|
||||
func ValidUsername(n string) error {
|
||||
if len(n) < 1 || len(n) > 64 {
|
||||
return badRequest("user names have 1 to 64 characters")
|
||||
}
|
||||
for _, c := range n {
|
||||
if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || strings.ContainsRune("._-@", c)) {
|
||||
return badRequest("user names may have letters, digits, '.', '_', '-' and '@'")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyUserInput(u *model.User, in *api.UserInput) error {
|
||||
role := ""
|
||||
if in.Role != nil {
|
||||
role = *in.Role
|
||||
}
|
||||
var strs []string
|
||||
if in.Grants != nil {
|
||||
strs = *in.Grants
|
||||
}
|
||||
grants, set, err := grantsFrom(strs, role)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if set || in.Grants != nil {
|
||||
if grants == nil {
|
||||
grants = []model.Grant{}
|
||||
}
|
||||
u.Grants = grants
|
||||
}
|
||||
if in.Admin != nil {
|
||||
u.Admin = *in.Admin
|
||||
}
|
||||
if in.Disabled != nil {
|
||||
u.Disabled = *in.Disabled
|
||||
}
|
||||
if in.Comment != nil {
|
||||
u.Comment = *in.Comment
|
||||
}
|
||||
if in.Password != nil {
|
||||
if *in.Password == "" {
|
||||
u.PasswordHash = "" // tokens only
|
||||
} else {
|
||||
h, err := auth.HashPassword(*in.Password)
|
||||
if err != nil {
|
||||
return badRequest("%v", err)
|
||||
}
|
||||
u.PasswordHash = h
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) createUser(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := requireAdmin(p); err != nil {
|
||||
return err
|
||||
}
|
||||
var in api.UserInput
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
return err
|
||||
}
|
||||
if in.Username == nil {
|
||||
return badRequest("a user needs a username")
|
||||
}
|
||||
u := &model.User{Username: strings.TrimSpace(*in.Username)}
|
||||
if err := ValidUsername(u.Username); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := applyUserInput(u, &in); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.store.CreateUser(r.Context(), u); err != nil {
|
||||
if errors.Is(err, store.ErrConflict) {
|
||||
return conflict("user %s already exists", u.Username)
|
||||
}
|
||||
return err
|
||||
}
|
||||
s.eng.Audit(r.Context(), p, "user.create", "user "+u.Username, "success", strings.Join(grantStrings(u.Grants), " "))
|
||||
writeJSON(w, http.StatusCreated, presentUser(u))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) userByPath(r *http.Request) (*model.User, error) {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u, err := s.store.GetUser(r.Context(), id)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return nil, notFound("user %d not found", id)
|
||||
}
|
||||
return u, err
|
||||
}
|
||||
|
||||
func (s *Server) updateUser(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := requireAdmin(p); err != nil {
|
||||
return err
|
||||
}
|
||||
u, err := s.userByPath(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var in api.UserInput
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
return err
|
||||
}
|
||||
wasAdmin := u.Admin && !u.Disabled
|
||||
if in.Username != nil {
|
||||
u.Username = strings.TrimSpace(*in.Username)
|
||||
if err := ValidUsername(u.Username); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := applyUserInput(u, &in); err != nil {
|
||||
return err
|
||||
}
|
||||
if wasAdmin && (!u.Admin || u.Disabled) {
|
||||
if n, err := s.store.CountAdmins(r.Context()); err == nil && n <= 1 {
|
||||
return conflict("%s is the last administrator; make someone else an administrator first", u.Username)
|
||||
}
|
||||
}
|
||||
if err := s.store.UpdateUser(r.Context(), u); err != nil {
|
||||
if errors.Is(err, store.ErrConflict) {
|
||||
return conflict("user %s already exists", u.Username)
|
||||
}
|
||||
return err
|
||||
}
|
||||
if u.Disabled || in.Password != nil {
|
||||
s.store.DeleteUserSessions(r.Context(), u.ID, "")
|
||||
}
|
||||
s.eng.Audit(r.Context(), p, "user.update", "user "+u.Username, "success", strings.Join(grantStrings(u.Grants), " "))
|
||||
writeJSON(w, http.StatusOK, presentUser(u))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) deleteUser(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := requireAdmin(p); err != nil {
|
||||
return err
|
||||
}
|
||||
u, err := s.userByPath(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if u.ID == p.User.ID {
|
||||
return conflict("you can't delete yourself")
|
||||
}
|
||||
if u.Admin && !u.Disabled {
|
||||
if n, err := s.store.CountAdmins(r.Context()); err == nil && n <= 1 {
|
||||
return conflict("%s is the last administrator", u.Username)
|
||||
}
|
||||
}
|
||||
if err := s.store.DeleteUser(r.Context(), u.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
s.eng.Audit(r.Context(), p, "user.delete", "user "+u.Username, "success", "")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) listUserTokens(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
u, err := s.userByPath(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if u.ID != p.User.ID && !p.IsAdmin() {
|
||||
return forbidden("only administrators can see other users' tokens")
|
||||
}
|
||||
return s.listTokens(w, r, u.ID)
|
||||
}
|
||||
|
||||
func (s *Server) createUserToken(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
u, err := s.userByPath(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if u.ID != p.User.ID && !p.IsAdmin() {
|
||||
return forbidden("only administrators can make tokens for other users")
|
||||
}
|
||||
return s.createTokenFor(w, r, p, u)
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"housecarl/internal/api"
|
||||
"housecarl/internal/engine"
|
||||
"housecarl/internal/model"
|
||||
)
|
||||
|
||||
// Every configuration change is an operation (model.Op): applied at once,
|
||||
// or staged in a changeset and replayed when the changeset is applied.
|
||||
// applyOp is the one place an operation becomes an edit.
|
||||
|
||||
// applyOp makes an operation's change to st. It returns the ID of the
|
||||
// object it made or changed (0 for deletes and settings).
|
||||
func applyOp(st *model.State, op *model.Op) (int64, error) {
|
||||
switch op.Op {
|
||||
case model.OpCreate:
|
||||
return createOp(st, op)
|
||||
case model.OpUpdate:
|
||||
return updateOp(st, op)
|
||||
case model.OpDelete:
|
||||
return 0, deleteOp(st, op)
|
||||
case model.OpMove:
|
||||
return moveOp(st, op)
|
||||
case model.OpSettings:
|
||||
return 0, settingsOp(st, op)
|
||||
}
|
||||
return 0, badRequest("unknown operation %q", op.Op)
|
||||
}
|
||||
|
||||
func createOp(st *model.State, op *model.Op) (int64, error) {
|
||||
o, err := decodeObject(op.Kind, op.Data)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if svc, ok := o.(*model.Service); ok {
|
||||
svc.Builtin = false
|
||||
}
|
||||
if rule, ok := o.(*model.Rule); ok && rule.Action == "" {
|
||||
rule.Action = model.ActionAccept
|
||||
}
|
||||
if f, ok := o.(*model.PortForward); ok && len(f.Protocols) == 0 {
|
||||
f.Protocols = []string{"tcp"}
|
||||
}
|
||||
if st.ByID(op.Kind, op.ID) != nil {
|
||||
return 0, conflict("%s %d exists already", op.Kind, op.ID)
|
||||
}
|
||||
setID(o, op.ID)
|
||||
if pos := position(o); pos != nil && *pos == 0 {
|
||||
*pos = lastPosition(st, op.Kind, chainOf(o))
|
||||
}
|
||||
data, _ := json.Marshal(o)
|
||||
return op.ID, st.Put(op.Kind, op.ID, data)
|
||||
}
|
||||
|
||||
func updateOp(st *model.State, op *model.Op) (int64, error) {
|
||||
var patch map[string]json.RawMessage
|
||||
if err := json.Unmarshal(op.Data, &patch); err != nil {
|
||||
return 0, badRequest("%s: %v", op.Kind, err)
|
||||
}
|
||||
delete(patch, "id")
|
||||
o, err := findObject(st, op.Kind, op.Ref)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
id := o.GetID()
|
||||
// The patch's fields over the object's.
|
||||
var cur map[string]json.RawMessage
|
||||
b, _ := json.Marshal(o)
|
||||
json.Unmarshal(b, &cur)
|
||||
for k, v := range patch {
|
||||
if string(v) == "null" {
|
||||
delete(cur, k)
|
||||
} else {
|
||||
cur[k] = v
|
||||
}
|
||||
}
|
||||
merged, _ := json.Marshal(cur)
|
||||
next, err := decodeObject(op.Kind, merged)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
setID(next, id)
|
||||
data, _ := json.Marshal(next)
|
||||
return id, st.Put(op.Kind, id, data)
|
||||
}
|
||||
|
||||
func deleteOp(st *model.State, op *model.Op) error {
|
||||
o, err := findObject(st, op.Kind, op.Ref)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if used := usedBy(st, op.Kind, o); used != "" {
|
||||
return conflict("%s %s is used by %s; change that first", op.Kind, o.Label(), used)
|
||||
}
|
||||
return st.Put(op.Kind, o.GetID(), nil)
|
||||
}
|
||||
|
||||
// moveOp reorders a rule or NAT rule: before or after another, or to the
|
||||
// start or end; a rule can change chain at the same time.
|
||||
func moveOp(st *model.State, op *model.Op) (int64, error) {
|
||||
var in api.MoveInput
|
||||
if err := json.Unmarshal(op.Data, &in); err != nil {
|
||||
return 0, badRequest("move: %v", err)
|
||||
}
|
||||
o, err := findObject(st, op.Kind, op.Ref)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
id := o.GetID()
|
||||
pos := position(o)
|
||||
if pos == nil {
|
||||
return 0, badRequest("only rules, port forwards, source NAT rules and redirects have an order")
|
||||
}
|
||||
if rule, ok := o.(*model.Rule); ok && in.Chain != "" {
|
||||
rule.Chain = in.Chain
|
||||
}
|
||||
chain := chainOf(o)
|
||||
// The others in order.
|
||||
var others []model.Object
|
||||
for _, x := range st.Objects(op.Kind) {
|
||||
if x.GetID() != id && chainOf(x) == chain {
|
||||
others = append(others, x)
|
||||
}
|
||||
}
|
||||
sort.SliceStable(others, func(i, j int) bool { return *position(others[i]) < *position(others[j]) })
|
||||
place := func(i int) { // before others[i] (i == len: at the end)
|
||||
switch {
|
||||
case len(others) == 0:
|
||||
*pos = 1
|
||||
case i == 0:
|
||||
*pos = *position(others[0]) - 1
|
||||
case i == len(others):
|
||||
*pos = *position(others[len(others)-1]) + 1
|
||||
default:
|
||||
*pos = (*position(others[i-1]) + *position(others[i])) / 2
|
||||
}
|
||||
}
|
||||
index := func(ref int64) (int, error) {
|
||||
for i, x := range others {
|
||||
if x.GetID() == ref {
|
||||
return i, nil
|
||||
}
|
||||
}
|
||||
return 0, badRequest("%d isn't another %s of the same chain", ref, op.Kind)
|
||||
}
|
||||
switch {
|
||||
case in.Before != 0:
|
||||
i, err := index(in.Before)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
place(i)
|
||||
case in.After != 0:
|
||||
i, err := index(in.After)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
place(i + 1)
|
||||
case in.To == "start":
|
||||
place(0)
|
||||
case in.To == "end" || in.Chain != "":
|
||||
place(len(others))
|
||||
default:
|
||||
return 0, badRequest("say where: before, after, or to start or end")
|
||||
}
|
||||
data, _ := json.Marshal(o)
|
||||
return id, st.Put(op.Kind, id, data)
|
||||
}
|
||||
|
||||
func settingsOp(st *model.State, op *model.Op) error {
|
||||
var patch map[string]json.RawMessage
|
||||
if err := json.Unmarshal(op.Data, &patch); err != nil {
|
||||
return badRequest("settings: %v", err)
|
||||
}
|
||||
var cur map[string]json.RawMessage
|
||||
b, _ := json.Marshal(st.Settings)
|
||||
json.Unmarshal(b, &cur)
|
||||
for k, v := range patch {
|
||||
// Built-ins merge one by one.
|
||||
if k == "builtins" {
|
||||
var cb, pb map[string]json.RawMessage
|
||||
json.Unmarshal(cur[k], &cb)
|
||||
if err := json.Unmarshal(v, &pb); err != nil {
|
||||
return badRequest("builtins: %v", err)
|
||||
}
|
||||
for bk, bv := range pb {
|
||||
cb[bk] = bv
|
||||
}
|
||||
v, _ = json.Marshal(cb)
|
||||
}
|
||||
cur[k] = v
|
||||
}
|
||||
merged, _ := json.Marshal(cur)
|
||||
var next model.Settings
|
||||
dec := json.NewDecoder(strings.NewReader(string(merged)))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&next); err != nil {
|
||||
return badRequest("settings: %v", err)
|
||||
}
|
||||
st.Settings = next
|
||||
return nil
|
||||
}
|
||||
|
||||
// replay applies a changeset's operations to st, in order. An operation
|
||||
// that fails is named in the error (opError).
|
||||
func replay(st *model.State, ops []model.Op) error {
|
||||
for i := range ops {
|
||||
if _, err := applyOp(st, &ops[i]); err != nil {
|
||||
return &opError{seq: ops[i].Seq, summary: ops[i].Summary, err: err}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// opError: an operation of a changeset that can't be applied (the
|
||||
// objects it names changed since it was staged, say).
|
||||
type opError struct {
|
||||
seq int
|
||||
summary string
|
||||
err error
|
||||
}
|
||||
|
||||
func (e *opError) Error() string {
|
||||
return fmt.Sprintf("operation %d (%s) can't be applied: %v", e.seq, e.summary, e.err)
|
||||
}
|
||||
|
||||
func (e *opError) Unwrap() error { return e.err }
|
||||
|
||||
// info is the error for the API, with the inner error's status.
|
||||
func (e *opError) info() (api.ErrorInfo, int) {
|
||||
info := api.ErrorInfo{Code: "conflict", Message: e.Error(), Op: e.seq}
|
||||
status := http.StatusConflict
|
||||
var he *httpError
|
||||
if errors.As(e.err, &he) {
|
||||
info.Code, status = he.code, he.status
|
||||
}
|
||||
if ee, ok := engine.AsError(e.err); ok {
|
||||
info.Code, info.Problems, status = ee.Kind, ee.Problems, engineStatus(ee.Kind)
|
||||
}
|
||||
return info, status
|
||||
}
|
||||
|
||||
// objectOf picks what an operation's answer returns from a state.
|
||||
func objectOf(op *model.Op, id int64) func(st *model.State) any {
|
||||
switch op.Op {
|
||||
case model.OpDelete:
|
||||
return nil
|
||||
case model.OpSettings:
|
||||
return func(st *model.State) any { return st.Settings }
|
||||
}
|
||||
return func(st *model.State) any {
|
||||
if o := st.ByID(op.Kind, id); o != nil {
|
||||
return o
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"housecarl/internal/api"
|
||||
"housecarl/internal/auth"
|
||||
"housecarl/internal/engine"
|
||||
"housecarl/internal/model"
|
||||
)
|
||||
|
||||
// A change made over the network is applied on probation: unless it is
|
||||
// confirmed in time (POST /confirm, hc confirm, the web UI's banner), it is
|
||||
// undone, so a change that cuts the user off from the firewall undoes
|
||||
// itself. Changes through the local socket (root on the firewall) need no
|
||||
// confirmation, but join a probation already running, so a rollback undoes
|
||||
// everything since the last confirmed state. The pending entries are kept
|
||||
// in the database: housecarld starting with any rolls them back.
|
||||
// Housecarl adds no rule of its own to keep the API reachable.
|
||||
|
||||
const probationKey = "probation"
|
||||
|
||||
type probation struct {
|
||||
mu sync.Mutex
|
||||
pending []int64
|
||||
deadline time.Time
|
||||
timer *time.Timer
|
||||
// restores are nft scripts that bring back tables a takeover deleted.
|
||||
restores []string
|
||||
// check is the latest change's confirmation token (reachCheck): only
|
||||
// it can confirm, since the ruleset in force is the one it tests.
|
||||
check *reachCheck
|
||||
}
|
||||
|
||||
// reachCheck is what a client must show to confirm its change by itself:
|
||||
// the token, from the same user (and API token), the same address, and a
|
||||
// new connection (another remote address and port than the change's).
|
||||
type reachCheck struct {
|
||||
token string
|
||||
userID int64
|
||||
username string
|
||||
tokenID int64
|
||||
ip string
|
||||
remote string
|
||||
}
|
||||
|
||||
// system is the principal of rollbacks.
|
||||
var system = &auth.Principal{User: &model.User{Username: "housecarl"}, Method: "system"}
|
||||
|
||||
type savedProbation struct {
|
||||
Pending []int64 `json:"pending"`
|
||||
Deadline time.Time `json:"deadline"`
|
||||
Restores []string `json:"restores,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) saveProbation(ctx context.Context) {
|
||||
b, _ := json.Marshal(savedProbation{Pending: s.probation.pending, Deadline: s.probation.deadline, Restores: s.probation.restores})
|
||||
if err := s.store.SetMeta(ctx, probationKey, string(b)); err != nil {
|
||||
s.log.Error("saving the probation failed", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// afterChange is called after every applied change.
|
||||
func (s *Server) afterChange(ctx context.Context, p *auth.Principal, res *engine.Result) {
|
||||
if res == nil || !res.Applied || res.HistoryID == 0 {
|
||||
return
|
||||
}
|
||||
s.startProbation(ctx, p, res.HistoryID, "")
|
||||
}
|
||||
|
||||
// startProbation puts a change (a history entry, or a takeover's restore
|
||||
// script) on probation, or, through the socket with nothing pending,
|
||||
// makes it permanent at once.
|
||||
func (s *Server) startProbation(ctx context.Context, p *auth.Principal, historyID int64, restore string) {
|
||||
pr := &s.probation
|
||||
pr.mu.Lock()
|
||||
defer pr.mu.Unlock()
|
||||
if p.Method == "socket" && len(pr.pending) == 0 && len(pr.restores) == 0 {
|
||||
s.saveBoot()
|
||||
return
|
||||
}
|
||||
if historyID != 0 {
|
||||
pr.pending = append(pr.pending, historyID)
|
||||
}
|
||||
if restore != "" {
|
||||
pr.restores = append(pr.restores, restore)
|
||||
}
|
||||
if p.Method != "socket" || pr.deadline.IsZero() {
|
||||
d := time.Duration(s.eng.State().Settings.Probation()) * time.Second
|
||||
if s.probationFor > 0 {
|
||||
d = s.probationFor
|
||||
}
|
||||
pr.deadline = s.nowFn().Add(d)
|
||||
if pr.timer != nil {
|
||||
pr.timer.Stop()
|
||||
}
|
||||
pr.timer = time.AfterFunc(d, func() { s.rollback("not confirmed in time") })
|
||||
}
|
||||
s.saveProbation(ctx)
|
||||
}
|
||||
|
||||
// issueCheck gives the client that made a network change on probation a
|
||||
// token to confirm it by itself, unless the settings want a person to
|
||||
// confirm. The answer then closes its connection (HTTP/2: a GOAWAY), so
|
||||
// the confirmation has to open a new one through the new ruleset; an
|
||||
// established connection would get through whatever the change did.
|
||||
func (s *Server) issueCheck(w http.ResponseWriter, r *http.Request, p *auth.Principal, res *engine.Result) string {
|
||||
if p == nil || p.Method == "socket" || (res != nil && !res.Applied) || s.eng.State().Settings.ManualConfirm {
|
||||
return ""
|
||||
}
|
||||
pr := &s.probation
|
||||
pr.mu.Lock()
|
||||
defer pr.mu.Unlock()
|
||||
if len(pr.pending) == 0 && len(pr.restores) == 0 {
|
||||
return ""
|
||||
}
|
||||
c := &reachCheck{token: auth.RandomString(24), username: p.Name(), ip: s.clientIP(r), remote: r.RemoteAddr}
|
||||
if p.User != nil {
|
||||
c.userID = p.User.ID
|
||||
}
|
||||
if p.Token != nil {
|
||||
c.tokenID = p.Token.ID
|
||||
}
|
||||
pr.check = c
|
||||
w.Header().Set("Connection", "close")
|
||||
return c.token
|
||||
}
|
||||
|
||||
// confirmToken confirms with a change's token: the proof that the client
|
||||
// that made it still reaches the API, from where it made it.
|
||||
func (s *Server) confirmToken(r *http.Request, p *auth.Principal, token string) (int, error) {
|
||||
pr := &s.probation
|
||||
pr.mu.Lock()
|
||||
c := pr.check
|
||||
pending := len(pr.pending) + len(pr.restores)
|
||||
pr.mu.Unlock()
|
||||
var tokenID int64
|
||||
if p.Token != nil {
|
||||
tokenID = p.Token.ID
|
||||
}
|
||||
switch {
|
||||
case pending == 0:
|
||||
return 0, nil
|
||||
case c == nil || subtle.ConstantTimeCompare([]byte(c.token), []byte(token)) != 1:
|
||||
return 0, conflict("this confirmation is for an earlier change: a newer one is on probation, and only its own client (or a person) can confirm it")
|
||||
case p.User == nil || p.User.ID != c.userID || p.Name() != c.username || tokenID != c.tokenID:
|
||||
return 0, forbidden("only the client that made the change can confirm it this way")
|
||||
case s.clientIP(r) != c.ip:
|
||||
return 0, forbidden("the confirmation comes from %s, the change from %s: it must come from the same place", s.clientIP(r), c.ip)
|
||||
case r.RemoteAddr == c.remote:
|
||||
return 0, conflict("the confirmation came over the change's own connection, which proves nothing; it must open a new one")
|
||||
}
|
||||
return s.confirmWith(r.Context(), p, fmt.Sprintf("confirmed automatically: %s reached the API again over a new connection", c.ip))
|
||||
}
|
||||
|
||||
func (s *Server) saveBoot() {
|
||||
if err := s.fw.SaveBoot(s.eng.State()); err != nil {
|
||||
s.log.Error("saving the ruleset for the next boot failed", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// probationStatus describes what is pending.
|
||||
func (s *Server) probationStatus() api.Probation {
|
||||
pr := &s.probation
|
||||
pr.mu.Lock()
|
||||
defer pr.mu.Unlock()
|
||||
out := api.Probation{Pending: append([]int64{}, pr.pending...), Takeovers: len(pr.restores)}
|
||||
if len(pr.pending) > 0 || len(pr.restores) > 0 {
|
||||
out.Deadline = pr.deadline
|
||||
out.SecondsLeft = int(pr.deadline.Sub(s.nowFn()).Seconds() + 0.5)
|
||||
if out.SecondsLeft < 0 {
|
||||
out.SecondsLeft = 0
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// confirm makes the pending changes permanent (a person's confirmation).
|
||||
func (s *Server) confirm(ctx context.Context, p *auth.Principal) (int, error) {
|
||||
return s.confirmWith(ctx, p, "")
|
||||
}
|
||||
|
||||
func (s *Server) confirmWith(ctx context.Context, p *auth.Principal, how string) (int, error) {
|
||||
pr := &s.probation
|
||||
pr.mu.Lock()
|
||||
defer pr.mu.Unlock()
|
||||
n := len(pr.pending) + len(pr.restores)
|
||||
if n == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
if pr.timer != nil {
|
||||
pr.timer.Stop()
|
||||
}
|
||||
pr.pending, pr.restores, pr.deadline, pr.timer, pr.check = nil, nil, time.Time{}, nil, nil
|
||||
s.saveProbation(ctx)
|
||||
s.saveBoot()
|
||||
detail := fmt.Sprintf("%d changes confirmed", n)
|
||||
if how != "" {
|
||||
detail += "; " + how
|
||||
}
|
||||
s.eng.Audit(ctx, p, "config.confirm", "", "success", detail)
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// rollback undoes the pending changes, newest first.
|
||||
func (s *Server) rollback(why string) {
|
||||
ctx := context.Background()
|
||||
pr := &s.probation
|
||||
pr.mu.Lock()
|
||||
defer pr.mu.Unlock()
|
||||
pending, restores := pr.pending, pr.restores
|
||||
pr.pending, pr.restores, pr.deadline, pr.timer, pr.check = nil, nil, time.Time{}, nil, nil
|
||||
for i := len(restores) - 1; i >= 0; i-- {
|
||||
if err := s.fw.ApplyScript(ctx, restores[i]); err != nil {
|
||||
s.log.Error("bringing back a table a takeover deleted failed", "err", err)
|
||||
s.eng.Audit(ctx, system, "takeover.rollback", "", "failure", err.Error())
|
||||
} else {
|
||||
s.eng.Audit(ctx, system, "takeover.rollback", "", "success", why)
|
||||
s.log.Warn("brought back a table an unconfirmed takeover deleted", "why", why)
|
||||
}
|
||||
}
|
||||
for i := len(pending) - 1; i >= 0; i-- {
|
||||
id := pending[i]
|
||||
if _, err := s.eng.Revert(ctx, system, id, engine.Options{Via: "rollback"}); err != nil {
|
||||
s.log.Error("rolling back an unconfirmed change failed", "history", id, "err", err)
|
||||
s.eng.Audit(ctx, system, "config.rollback", fmt.Sprintf("history %d", id), "failure", err.Error())
|
||||
continue
|
||||
}
|
||||
s.eng.Audit(ctx, system, "config.rollback", fmt.Sprintf("history %d", id), "success", why)
|
||||
s.log.Warn("rolled back an unconfirmed change", "history", id, "why", why)
|
||||
}
|
||||
s.saveProbation(ctx)
|
||||
}
|
||||
|
||||
// ResumeProbation rolls back what was pending when housecarld stopped. It
|
||||
// returns whether anything was rolled back.
|
||||
func (s *Server) ResumeProbation(ctx context.Context) bool {
|
||||
v, err := s.store.GetMeta(ctx, probationKey)
|
||||
if err != nil || v == "" {
|
||||
return false
|
||||
}
|
||||
var saved savedProbation
|
||||
if json.Unmarshal([]byte(v), &saved) != nil || len(saved.Pending) == 0 && len(saved.Restores) == 0 {
|
||||
return false
|
||||
}
|
||||
s.probation.mu.Lock()
|
||||
s.probation.pending, s.probation.restores = saved.Pending, saved.Restores
|
||||
s.probation.mu.Unlock()
|
||||
s.rollback("housecarld restarted before they were confirmed")
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"housecarl/internal/api"
|
||||
"housecarl/internal/auth"
|
||||
"housecarl/internal/model"
|
||||
"housecarl/internal/nft"
|
||||
"housecarl/internal/render"
|
||||
)
|
||||
|
||||
// Quotas: nftables counts them, and never resets them. housecarld resets
|
||||
// each at the start of its period (daily, weekly, monthly), or when asked,
|
||||
// and saves what they used every few minutes and when it stops, so a
|
||||
// reboot (whose boot ruleset starts them at zero) doesn't lose it.
|
||||
|
||||
const (
|
||||
quotaResetsKey = "quota_resets" // rule mark → when its quota was last reset
|
||||
QuotaUsageKey = "quota_usage" // nft.QuotaUsage, saved
|
||||
)
|
||||
|
||||
// periodStart is when the current period of a reset began (zero for a
|
||||
// quota reset only by hand), in local time.
|
||||
func periodStart(now time.Time, reset string, day int) time.Time {
|
||||
y, m, d := now.Date()
|
||||
midnight := time.Date(y, m, d, 0, 0, 0, 0, now.Location())
|
||||
switch reset {
|
||||
case "daily":
|
||||
return midnight
|
||||
case "weekly":
|
||||
back := (int(now.Weekday()) + 6) % 7 // Monday
|
||||
return midnight.AddDate(0, 0, -back)
|
||||
case "monthly":
|
||||
if day < 1 {
|
||||
day = 1
|
||||
}
|
||||
start := monthDay(y, m, day, now.Location())
|
||||
if start.After(now) {
|
||||
py, pm, _ := time.Date(y, m, 1, 0, 0, 0, 0, now.Location()).AddDate(0, -1, 0).Date()
|
||||
start = monthDay(py, pm, day, now.Location())
|
||||
}
|
||||
return start
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
// monthDay is midnight on a day of a month, or on its last day when the
|
||||
// month is shorter.
|
||||
func monthDay(y int, m time.Month, day int, loc *time.Location) time.Time {
|
||||
last := time.Date(y, m+1, 0, 0, 0, 0, 0, loc).Day()
|
||||
if day > last {
|
||||
day = last
|
||||
}
|
||||
return time.Date(y, m, day, 0, 0, 0, 0, loc)
|
||||
}
|
||||
|
||||
// nextReset is when the period after the current one starts.
|
||||
func nextReset(now time.Time, reset string, day int) time.Time {
|
||||
start := periodStart(now, reset, day)
|
||||
switch reset {
|
||||
case "daily":
|
||||
return start.AddDate(0, 0, 1)
|
||||
case "weekly":
|
||||
return start.AddDate(0, 0, 7)
|
||||
case "monthly":
|
||||
y, m, _ := start.Date()
|
||||
ny, nm, _ := time.Date(y, m, 1, 0, 0, 0, 0, now.Location()).AddDate(0, 1, 0).Date()
|
||||
if day < 1 {
|
||||
day = 1
|
||||
}
|
||||
return monthDay(ny, nm, day, now.Location())
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
func (s *Server) quotaResets(ctx context.Context) map[string]time.Time {
|
||||
out := map[string]time.Time{}
|
||||
if v, err := s.store.GetMeta(ctx, quotaResetsKey); err == nil && v != "" {
|
||||
json.Unmarshal([]byte(v), &out)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Server) saveQuotaResets(ctx context.Context, m map[string]time.Time) {
|
||||
b, _ := json.Marshal(m)
|
||||
if err := s.store.SetMeta(ctx, quotaResetsKey, string(b)); err != nil {
|
||||
s.log.Error("saving the quotas' reset times failed", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// RunQuotas resets quotas when their period ends and saves their usage,
|
||||
// until ctx ends (then it saves once more).
|
||||
func (s *Server) RunQuotas(ctx context.Context) {
|
||||
s.checkQuotas(ctx)
|
||||
tick := time.NewTicker(time.Minute)
|
||||
defer tick.Stop()
|
||||
n := 0
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
s.SaveQuotaUsage(context.Background())
|
||||
return
|
||||
case <-tick.C:
|
||||
s.checkQuotas(ctx)
|
||||
if n++; n%5 == 0 {
|
||||
s.SaveQuotaUsage(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// checkQuotas resets the quotas whose period has ended since their last
|
||||
// reset. A quota seen for the first time starts its period now.
|
||||
func (s *Server) checkQuotas(ctx context.Context) {
|
||||
st := s.eng.State()
|
||||
resets := s.quotaResets(ctx)
|
||||
now := s.nowFn()
|
||||
changed := false
|
||||
seen := map[string]bool{}
|
||||
for _, r := range st.Rules {
|
||||
if r.Quota == nil || r.Disabled || r.Raw != "" {
|
||||
continue
|
||||
}
|
||||
key := fmt.Sprintf("r%d", r.ID)
|
||||
seen[key] = true
|
||||
last, ok := resets[key]
|
||||
if !ok {
|
||||
resets[key], changed = now, true
|
||||
continue
|
||||
}
|
||||
start := periodStart(now, r.Quota.Reset, r.Quota.Day)
|
||||
if start.IsZero() || !last.Before(start) {
|
||||
continue
|
||||
}
|
||||
if err := s.fw.ResetQuota(ctx, r.ID, r.Quota.PerAddress() != ""); err != nil {
|
||||
s.log.Error("resetting a quota failed", "rule", r.Label(), "err", err)
|
||||
continue
|
||||
}
|
||||
s.log.Info("quota reset for its new period", "rule", r.Label(), "period", r.Quota.Reset)
|
||||
resets[key], changed = now, true
|
||||
s.forgetQuotaUsage(ctx, r.ID)
|
||||
}
|
||||
for key := range resets {
|
||||
if !seen[key] {
|
||||
delete(resets, key)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
s.saveQuotaResets(ctx, resets)
|
||||
}
|
||||
}
|
||||
|
||||
// SaveQuotaUsage saves what the quotas used, for after a reboot.
|
||||
func (s *Server) SaveQuotaUsage(ctx context.Context) {
|
||||
t, err := s.fw.Table(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
b, _ := json.Marshal(t.QuotaUsage)
|
||||
if err := s.store.SetMeta(ctx, QuotaUsageKey, string(b)); err != nil {
|
||||
s.log.Error("saving the quotas' usage failed", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// forgetQuotaUsage drops a reset quota from the saved usage, so a restart
|
||||
// doesn't bring back what it used before.
|
||||
func (s *Server) forgetQuotaUsage(ctx context.Context, ruleID int64) {
|
||||
v, err := s.store.GetMeta(ctx, QuotaUsageKey)
|
||||
if err != nil || v == "" {
|
||||
return
|
||||
}
|
||||
var u nft.QuotaUsage
|
||||
if json.Unmarshal([]byte(v), &u) != nil {
|
||||
return
|
||||
}
|
||||
name := render.QuotaName(ruleID)
|
||||
delete(u.Quotas, name)
|
||||
delete(u.QuotaSets, name+"_v4")
|
||||
delete(u.QuotaSets, name+"_v6")
|
||||
b, _ := json.Marshal(u)
|
||||
s.store.SetMeta(ctx, QuotaUsageKey, string(b))
|
||||
}
|
||||
|
||||
// listQuotas answers what each rule's quota used.
|
||||
func (s *Server) listQuotas(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := require(p, auth.ResLog, false); err != nil {
|
||||
return err
|
||||
}
|
||||
st := s.eng.State()
|
||||
var usage nft.QuotaUsage
|
||||
if t, err := s.fw.Table(r.Context()); err == nil {
|
||||
usage = t.QuotaUsage
|
||||
}
|
||||
resets := s.quotaResets(r.Context())
|
||||
now := s.nowFn()
|
||||
out := []api.QuotaInfo{}
|
||||
for _, rule := range st.Rules {
|
||||
if rule.Quota == nil {
|
||||
continue
|
||||
}
|
||||
q := api.QuotaInfo{RuleID: rule.ID, Rule: rule.Label(), Quota: *rule.Quota}
|
||||
name := render.QuotaName(rule.ID)
|
||||
if rule.Quota.PerAddress() == "" {
|
||||
q.Used = usage.Quotas[name]
|
||||
} else {
|
||||
v4, v6 := rule.Groups()
|
||||
for _, fam := range []string{"_v4", "_v6"} {
|
||||
for _, e := range usage.QuotaSets[name+fam] {
|
||||
addr := e.Addr
|
||||
if fam == "_v4" && v4 < 32 {
|
||||
addr = fmt.Sprintf("%s/%d", addr, v4)
|
||||
} else if fam == "_v6" && v6 < 128 {
|
||||
addr = fmt.Sprintf("%s/%d", addr, v6)
|
||||
}
|
||||
q.Addresses = append(q.Addresses, api.QuotaAddress{Address: addr, Used: e.Used})
|
||||
q.Used += e.Used
|
||||
}
|
||||
}
|
||||
sort.Slice(q.Addresses, func(i, j int) bool { return q.Addresses[i].Used > q.Addresses[j].Used })
|
||||
}
|
||||
if last, ok := resets[fmt.Sprintf("r%d", rule.ID)]; ok {
|
||||
q.LastReset = &last
|
||||
}
|
||||
if next := nextReset(now, rule.Quota.Reset, rule.Quota.Day); !next.IsZero() {
|
||||
q.NextReset = &next
|
||||
}
|
||||
out = append(out, q)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
return nil
|
||||
}
|
||||
|
||||
// resetQuota starts a rule's quota again now.
|
||||
func (s *Server) resetQuota(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := require(p, auth.ResConfig, true); err != nil {
|
||||
return err
|
||||
}
|
||||
o, err := findObject(s.eng.State(), model.KindRule, r.PathValue("ref"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rule := o.(*model.Rule)
|
||||
if rule.Quota == nil {
|
||||
return badRequest("%s has no quota", rule.Label())
|
||||
}
|
||||
if err := s.fw.ResetQuota(r.Context(), rule.ID, rule.Quota.PerAddress() != ""); err != nil {
|
||||
return err
|
||||
}
|
||||
resets := s.quotaResets(r.Context())
|
||||
resets[fmt.Sprintf("r%d", rule.ID)] = s.nowFn()
|
||||
s.saveQuotaResets(r.Context(), resets)
|
||||
s.forgetQuotaUsage(r.Context(), rule.ID)
|
||||
s.eng.Audit(r.Context(), p, "quota.reset", rule.Label(), "success", "")
|
||||
writeJSON(w, http.StatusOK, map[string]any{"reset": rule.Label()})
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"housecarl/internal/api"
|
||||
"housecarl/internal/auth"
|
||||
"housecarl/internal/engine"
|
||||
"housecarl/internal/store"
|
||||
)
|
||||
|
||||
// httpError is an error with an HTTP status.
|
||||
type httpError struct {
|
||||
status int
|
||||
code string
|
||||
msg string
|
||||
}
|
||||
|
||||
func (e *httpError) Error() string { return e.msg }
|
||||
|
||||
func badRequest(format string, args ...any) error {
|
||||
return &httpError{http.StatusBadRequest, "bad_request", fmt.Sprintf(format, args...)}
|
||||
}
|
||||
|
||||
func notFound(format string, args ...any) error {
|
||||
return &httpError{http.StatusNotFound, "not_found", fmt.Sprintf(format, args...)}
|
||||
}
|
||||
|
||||
func forbidden(format string, args ...any) error {
|
||||
return &httpError{http.StatusForbidden, "forbidden", fmt.Sprintf(format, args...)}
|
||||
}
|
||||
|
||||
func conflict(format string, args ...any) error {
|
||||
return &httpError{http.StatusConflict, "conflict", fmt.Sprintf(format, args...)}
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(status)
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetEscapeHTML(false)
|
||||
enc.Encode(v)
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, code, msg string) {
|
||||
writeJSON(w, status, api.ErrorBody{Error: api.ErrorInfo{Code: code, Message: msg}})
|
||||
}
|
||||
|
||||
func engineStatus(kind string) int {
|
||||
switch kind {
|
||||
case engine.KindBadRequest:
|
||||
return http.StatusBadRequest
|
||||
case engine.KindNotFound:
|
||||
return http.StatusNotFound
|
||||
case engine.KindConflict:
|
||||
return http.StatusConflict
|
||||
case engine.KindValidation, engine.KindFirewall:
|
||||
return http.StatusUnprocessableEntity
|
||||
}
|
||||
return http.StatusInternalServerError
|
||||
}
|
||||
|
||||
// writeErr writes an error response. result, if given, is the preview of
|
||||
// a refused change.
|
||||
func (s *Server) writeErr(w http.ResponseWriter, r *http.Request, err error, result any) {
|
||||
var oe *opError
|
||||
if errors.As(err, &oe) {
|
||||
info, status := oe.info()
|
||||
writeJSON(w, status, api.ErrorBody{Error: info})
|
||||
return
|
||||
}
|
||||
var he *httpError
|
||||
if errors.As(err, &he) {
|
||||
writeError(w, he.status, he.code, he.msg)
|
||||
return
|
||||
}
|
||||
if ee, ok := engine.AsError(err); ok {
|
||||
body := api.ErrorBody{Error: api.ErrorInfo{Code: ee.Kind, Message: ee.Message, Problems: ee.Problems}}
|
||||
if result != nil {
|
||||
body.Error.Result, _ = json.Marshal(result)
|
||||
}
|
||||
writeJSON(w, engineStatus(ee.Kind), body)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "not found")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, store.ErrConflict) {
|
||||
writeError(w, http.StatusConflict, "conflict", "already exists")
|
||||
return
|
||||
}
|
||||
s.log.Error("request failed", "method", r.Method, "path", r.URL.Path, "err", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal", "internal server error: "+err.Error())
|
||||
}
|
||||
|
||||
// handler is an authenticated API handler.
|
||||
type handler func(w http.ResponseWriter, r *http.Request, p *auth.Principal) error
|
||||
|
||||
// decodeJSON reads a JSON body into v, refusing unknown fields.
|
||||
func decodeJSON(r *http.Request, v any) error {
|
||||
ct := r.Header.Get("Content-Type")
|
||||
if ct != "" && !strings.HasPrefix(ct, "application/json") {
|
||||
return badRequest("expected a JSON body (Content-Type: application/json)")
|
||||
}
|
||||
dec := json.NewDecoder(r.Body)
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(v); err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
return badRequest("the request body is empty")
|
||||
}
|
||||
var mbe *http.MaxBytesError
|
||||
if errors.As(err, &mbe) {
|
||||
return &httpError{http.StatusRequestEntityTooLarge, "too_large", "the request body is too large"}
|
||||
}
|
||||
return badRequest("invalid JSON: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// decodeOptional is decodeJSON, but an empty body leaves v alone.
|
||||
func decodeOptional(r *http.Request, v any) error {
|
||||
b, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(string(b)) == "" {
|
||||
return nil
|
||||
}
|
||||
dec := json.NewDecoder(strings.NewReader(string(b)))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(v); err != nil {
|
||||
return badRequest("invalid JSON: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func pathID(r *http.Request, name string) (int64, error) {
|
||||
v := r.PathValue(name)
|
||||
id, err := strconv.ParseInt(v, 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
return 0, badRequest("invalid %s %q", name, v)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func queryBool(r *http.Request, name string) bool {
|
||||
switch strings.ToLower(r.URL.Query().Get(name)) {
|
||||
case "1", "true", "yes", "on":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func queryInt(r *http.Request, name string, def int) int {
|
||||
if v := r.URL.Query().Get(name); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func require(p *auth.Principal, resource string, write bool) error {
|
||||
if p.Can(resource, write) {
|
||||
return nil
|
||||
}
|
||||
access := "read"
|
||||
if write {
|
||||
access = "write"
|
||||
}
|
||||
return forbidden("permission denied: this needs %s:%s", resource, access)
|
||||
}
|
||||
|
||||
func requireAdmin(p *auth.Principal) error {
|
||||
if !p.IsAdmin() {
|
||||
return forbidden("this needs an administrator (and not a restricted token)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// via names where a request came from, for the history.
|
||||
func via(r *http.Request, p *auth.Principal) string {
|
||||
switch {
|
||||
case strings.Contains(r.UserAgent(), "housecarl-cli"):
|
||||
return "cli"
|
||||
case p.Method == "session":
|
||||
return "web"
|
||||
}
|
||||
return "api"
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"housecarl/internal/api"
|
||||
"housecarl/internal/version"
|
||||
)
|
||||
|
||||
func (s *Server) routes() {
|
||||
m := s.mux
|
||||
p := api.Prefix
|
||||
m.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "version": version.String()})
|
||||
})
|
||||
// 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("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+"/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
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package server
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// otherTables lists the tables in an `nft -j list ruleset` other than
|
||||
// Housecarl's ("inet main").
|
||||
func otherTables(data []byte) []string {
|
||||
var doc struct {
|
||||
Nftables []map[string]json.RawMessage `json:"nftables"`
|
||||
}
|
||||
out := []string{}
|
||||
if json.Unmarshal(data, &doc) != nil {
|
||||
return out
|
||||
}
|
||||
for _, item := range doc.Nftables {
|
||||
raw, ok := item["table"]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
var t struct{ Family, Name string }
|
||||
if json.Unmarshal(raw, &t) == nil && !(t.Family == "inet" && t.Name == "housecarl") {
|
||||
out = append(out, t.Family+" "+t.Name)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,474 @@
|
||||
// Package server is Housecarl's HTTP API and serves the web UI.
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"housecarl/internal/auth"
|
||||
"housecarl/internal/certs"
|
||||
"housecarl/internal/engine"
|
||||
"housecarl/internal/model"
|
||||
"housecarl/internal/nft"
|
||||
"housecarl/internal/render"
|
||||
"housecarl/internal/store"
|
||||
"housecarl/internal/version"
|
||||
)
|
||||
|
||||
// Config configures the HTTP server.
|
||||
type Config struct {
|
||||
Listen []string
|
||||
TLSCertFile string
|
||||
TLSKeyFile string
|
||||
TrustedProxies []netip.Prefix
|
||||
SessionLifetime time.Duration
|
||||
SessionIdle time.Duration
|
||||
// CookieSecure is auto (secure over HTTPS), always or never.
|
||||
CookieSecure string
|
||||
MaxBodyBytes int64
|
||||
// Socket is the local unix socket ("" for none).
|
||||
Socket string
|
||||
// ACME keeps the certificate housecarld obtains itself (tls.acme; nil
|
||||
// when off).
|
||||
ACME *certs.Manager
|
||||
}
|
||||
|
||||
// Interfaces lists the host's interfaces with their addresses, and
|
||||
// whether each is up.
|
||||
type Interfaces interface {
|
||||
All() (map[string][]netip.Prefix, error)
|
||||
Up(name string) bool
|
||||
}
|
||||
|
||||
// Firewall is what the server needs from the running firewall beyond the
|
||||
// engine (internal/nft.Firewall).
|
||||
type Firewall interface {
|
||||
Render(st *model.State) *render.Ruleset
|
||||
Table(ctx context.Context) (*nft.Table, error)
|
||||
AddElement(ctx context.Context, set, addr string, timeout int) error
|
||||
DeleteElement(ctx context.Context, set, addr string) error
|
||||
ListRuleset(ctx context.Context) ([]byte, error)
|
||||
DeleteTable(ctx context.Context, family, name string) error
|
||||
TableText(ctx context.Context, family, name string) (string, error)
|
||||
ApplyScript(ctx context.Context, script string) error
|
||||
SaveBoot(st *model.State) error
|
||||
// ResetQuota starts a rule's quota again (perAddress: its sets).
|
||||
ResetQuota(ctx context.Context, ruleID int64, perAddress bool) error
|
||||
}
|
||||
|
||||
// Server is the HTTP server.
|
||||
type Server struct {
|
||||
cfg Config
|
||||
eng *engine.Engine
|
||||
fw Firewall
|
||||
ifaces Interfaces
|
||||
store *store.Store
|
||||
log *slog.Logger
|
||||
mux *http.ServeMux
|
||||
limiter *rateLimiter
|
||||
web fs.FS
|
||||
started time.Time
|
||||
certs *certReloader
|
||||
|
||||
mu sync.Mutex
|
||||
srvs []*http.Server
|
||||
|
||||
probation probation
|
||||
logs *logRing
|
||||
// csMu orders changes to changesets (staging, applying, discarding).
|
||||
csMu sync.Mutex
|
||||
nowFn func() time.Time
|
||||
// probationFor overrides the settings' probation (tests).
|
||||
probationFor time.Duration
|
||||
}
|
||||
|
||||
// New makes a server. web holds the web UI (nil: API only).
|
||||
func New(cfg Config, eng *engine.Engine, fw Firewall, ifaces Interfaces, log *slog.Logger, web fs.FS) *Server {
|
||||
if cfg.SessionLifetime == 0 {
|
||||
cfg.SessionLifetime = 12 * time.Hour
|
||||
}
|
||||
if cfg.SessionIdle == 0 {
|
||||
cfg.SessionIdle = 2 * time.Hour
|
||||
}
|
||||
if cfg.MaxBodyBytes == 0 {
|
||||
cfg.MaxBodyBytes = 8 << 20
|
||||
}
|
||||
if cfg.CookieSecure == "" {
|
||||
cfg.CookieSecure = "auto"
|
||||
}
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
s := &Server{cfg: cfg, eng: eng, fw: fw, ifaces: ifaces, store: eng.Store(), log: log, mux: http.NewServeMux(), limiter: newRateLimiter(),
|
||||
web: web, started: time.Now(), logs: newLogRing(2000), nowFn: time.Now}
|
||||
s.routes()
|
||||
return s
|
||||
}
|
||||
|
||||
// Handler returns the HTTP handler with all middleware.
|
||||
func (s *Server) Handler() http.Handler {
|
||||
return s.recoverer(s.securityHeaders(s.logRequests(s.mux)))
|
||||
}
|
||||
|
||||
func lowPort(addr string) bool {
|
||||
_, port, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
n, err := strconv.Atoi(port)
|
||||
return err == nil && n > 0 && n < 1024
|
||||
}
|
||||
|
||||
// Serve listens until ctx ends. ready is called once listening.
|
||||
func (s *Server) Serve(ctx context.Context, ready func()) error {
|
||||
if len(s.cfg.Listen) == 0 {
|
||||
return errors.New("no listen addresses configured")
|
||||
}
|
||||
// With a certificate (tls.cert_file, or one housecarld obtains, which
|
||||
// may turn up later), each listener serves HTTPS and plain HTTP side
|
||||
// by side (sniff.go); plain HTTP is redirected once there is one.
|
||||
var tlsCfg *tls.Config
|
||||
if s.cfg.TLSCertFile != "" {
|
||||
cr, err := newCertReloader(s.cfg.TLSCertFile, s.cfg.TLSKeyFile, s.log)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.certs = cr
|
||||
}
|
||||
if s.certs != nil || s.cfg.ACME != nil {
|
||||
tlsCfg = &tls.Config{MinVersion: tls.VersionTLS12, GetCertificate: s.webCertificate, NextProtos: []string{"h2", "http/1.1"}}
|
||||
}
|
||||
errc := make(chan error, len(s.cfg.Listen)+1)
|
||||
var listeners []net.Listener
|
||||
for _, addr := range s.cfg.Listen {
|
||||
ln, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
for _, l := range listeners {
|
||||
l.Close()
|
||||
}
|
||||
if errors.Is(err, syscall.EACCES) && lowPort(addr) {
|
||||
return fmt.Errorf("listening on %s: %w (ports below 1024 need CAP_NET_BIND_SERVICE, which housecarl.service grants)", addr, err)
|
||||
}
|
||||
return fmt.Errorf("listening on %s: %w", addr, err)
|
||||
}
|
||||
listeners = append(listeners, ln)
|
||||
}
|
||||
for i, ln := range listeners {
|
||||
handler := s.Handler()
|
||||
if tlsCfg != nil {
|
||||
handler = s.requireHTTPS(handler)
|
||||
}
|
||||
srv := &http.Server{
|
||||
Handler: handler,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
ReadTimeout: 2 * time.Minute,
|
||||
WriteTimeout: 5 * time.Minute,
|
||||
IdleTimeout: 2 * time.Minute,
|
||||
TLSConfig: tlsCfg,
|
||||
ErrorLog: slog.NewLogLogger(s.log.Handler(), slog.LevelWarn),
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.srvs = append(s.srvs, srv)
|
||||
s.mu.Unlock()
|
||||
scheme := "http"
|
||||
if tlsCfg != nil {
|
||||
if s.httpsAvailable() {
|
||||
scheme = "https"
|
||||
}
|
||||
ln = newSniffListener(ln, tlsCfg)
|
||||
}
|
||||
if !s.httpsAvailable() && s.cfg.ACME == nil {
|
||||
if host, _, err := net.SplitHostPort(s.cfg.Listen[i]); err == nil {
|
||||
if ip, err := netip.ParseAddr(host); host == "" || err == nil && !ip.IsLoopback() {
|
||||
s.log.Warn("serving plain HTTP on a non-loopback address: passwords and API tokens travel unencrypted; configure tls or a TLS reverse proxy", "addr", s.cfg.Listen[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
s.log.Info("listening", "addr", s.cfg.Listen[i], "url", scheme+"://"+ln.Addr().String())
|
||||
go func(srv *http.Server, ln net.Listener) {
|
||||
if err := srv.Serve(ln); !errors.Is(err, http.ErrServerClosed) {
|
||||
errc <- err
|
||||
}
|
||||
}(srv, ln)
|
||||
}
|
||||
s.serveSocket(errc)
|
||||
if ready != nil {
|
||||
ready()
|
||||
}
|
||||
go s.housekeeping(ctx)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case err := <-errc:
|
||||
s.shutdown()
|
||||
return err
|
||||
}
|
||||
s.shutdown()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) serveSocket(errc chan error) {
|
||||
if s.cfg.Socket == "" {
|
||||
return
|
||||
}
|
||||
ln, err := listenSocket(s.cfg.Socket)
|
||||
if err != nil {
|
||||
s.log.Warn("not listening on the local socket", "path", s.cfg.Socket, "err", err)
|
||||
return
|
||||
}
|
||||
srv := &http.Server{
|
||||
Handler: s.Handler(),
|
||||
ConnContext: socketConnContext,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
WriteTimeout: 5 * time.Minute,
|
||||
IdleTimeout: 2 * time.Minute,
|
||||
ErrorLog: slog.NewLogLogger(s.log.Handler(), slog.LevelWarn),
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.srvs = append(s.srvs, srv)
|
||||
s.mu.Unlock()
|
||||
s.log.Info("listening", "socket", s.cfg.Socket)
|
||||
go func() {
|
||||
if err := srv.Serve(ln); !errors.Is(err, http.ErrServerClosed) {
|
||||
errc <- err
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *Server) shutdown() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for _, srv := range s.srvs {
|
||||
srv.Shutdown(ctx)
|
||||
}
|
||||
if s.cfg.Socket != "" {
|
||||
os.Remove(s.cfg.Socket)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) housekeeping(ctx context.Context) {
|
||||
t := time.NewTicker(10 * time.Minute)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
if err := s.store.PurgeSessions(ctx, time.Now()); err != nil {
|
||||
s.log.Warn("purging sessions failed", "err", err)
|
||||
}
|
||||
s.limiter.cleanup()
|
||||
if s.certs != nil {
|
||||
s.certs.reloadIfChanged()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ReloadTLS rereads the certificate (on SIGHUP).
|
||||
func (s *Server) ReloadTLS() {
|
||||
if s.certs != nil {
|
||||
s.certs.reload()
|
||||
}
|
||||
}
|
||||
|
||||
// certReloader serves a certificate that is reread when its file changes.
|
||||
type certReloader struct {
|
||||
certFile, keyFile string
|
||||
log *slog.Logger
|
||||
mu sync.RWMutex
|
||||
cert *tls.Certificate
|
||||
modTime time.Time
|
||||
}
|
||||
|
||||
func newCertReloader(certFile, keyFile string, log *slog.Logger) (*certReloader, error) {
|
||||
cr := &certReloader{certFile: certFile, keyFile: keyFile, log: log}
|
||||
if err := cr.load(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cr, nil
|
||||
}
|
||||
|
||||
func (cr *certReloader) load() error {
|
||||
cert, err := tls.LoadX509KeyPair(cr.certFile, cr.keyFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("loading the TLS certificate: %w", err)
|
||||
}
|
||||
fi, _ := os.Stat(cr.certFile)
|
||||
cr.mu.Lock()
|
||||
cr.cert = &cert
|
||||
if fi != nil {
|
||||
cr.modTime = fi.ModTime()
|
||||
}
|
||||
cr.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cr *certReloader) reload() {
|
||||
if err := cr.load(); err != nil {
|
||||
cr.log.Error("reloading the TLS certificate failed", "err", err)
|
||||
return
|
||||
}
|
||||
cr.log.Info("TLS certificate reloaded", "file", cr.certFile)
|
||||
}
|
||||
|
||||
func (cr *certReloader) reloadIfChanged() {
|
||||
fi, err := os.Stat(cr.certFile)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
cr.mu.RLock()
|
||||
changed := fi.ModTime().After(cr.modTime)
|
||||
cr.mu.RUnlock()
|
||||
if changed {
|
||||
cr.reload()
|
||||
}
|
||||
}
|
||||
|
||||
func (cr *certReloader) get(*tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||
cr.mu.RLock()
|
||||
defer cr.mu.RUnlock()
|
||||
return cr.cert, nil
|
||||
}
|
||||
|
||||
// ---- middleware
|
||||
|
||||
type ctxKey int
|
||||
|
||||
const (
|
||||
keyPrincipal ctxKey = iota
|
||||
keySession
|
||||
)
|
||||
|
||||
func (s *Server) recoverer(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
if v := recover(); v != nil {
|
||||
if v == http.ErrAbortHandler {
|
||||
panic(v)
|
||||
}
|
||||
s.log.Error("panic serving a request", "path", r.URL.Path, "panic", v)
|
||||
writeError(w, http.StatusInternalServerError, "internal", "internal server error")
|
||||
}
|
||||
}()
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) securityHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
h := w.Header()
|
||||
h.Set("Server", "Housecarl/"+version.Short())
|
||||
h.Set("X-Content-Type-Options", "nosniff")
|
||||
h.Set("X-Frame-Options", "DENY")
|
||||
h.Set("Referrer-Policy", "no-referrer")
|
||||
h.Set("Content-Security-Policy", "default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'")
|
||||
h.Set("Cross-Origin-Opener-Policy", "same-origin")
|
||||
if s.isHTTPS(r) {
|
||||
h.Set("Strict-Transport-Security", "max-age=31536000")
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
type statusWriter struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
}
|
||||
|
||||
func (w *statusWriter) WriteHeader(code int) {
|
||||
w.status = code
|
||||
w.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func (w *statusWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter }
|
||||
|
||||
func (s *Server) logRequests(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
sw := &statusWriter{ResponseWriter: w, status: 200}
|
||||
next.ServeHTTP(sw, r)
|
||||
if r.URL.Path == "/healthz" || strings.HasPrefix(r.URL.Path, "/assets/") {
|
||||
return
|
||||
}
|
||||
user := ""
|
||||
if p, ok := r.Context().Value(keyPrincipal).(*auth.Principal); ok {
|
||||
user = p.Name()
|
||||
}
|
||||
s.log.Debug("http", "method", r.Method, "path", r.URL.Path, "status", sw.status, "duration", time.Since(start).Round(time.Millisecond), "ip", s.clientIP(r), "user", user)
|
||||
})
|
||||
}
|
||||
|
||||
// clientIP is the client's address, from X-Forwarded-For when the request
|
||||
// came through a trusted proxy.
|
||||
func (s *Server) clientIP(r *http.Request) string {
|
||||
if peerOf(r.Context()) != nil {
|
||||
return "local"
|
||||
}
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
host = r.RemoteAddr
|
||||
}
|
||||
peer, err := netip.ParseAddr(host)
|
||||
if err != nil || !s.trusted(peer) {
|
||||
return host
|
||||
}
|
||||
parts := strings.Split(r.Header.Get("X-Forwarded-For"), ",")
|
||||
for i := len(parts) - 1; i >= 0; i-- {
|
||||
a, err := netip.ParseAddr(strings.TrimSpace(parts[i]))
|
||||
if err != nil {
|
||||
return host
|
||||
}
|
||||
if !s.trusted(a) {
|
||||
return a.String()
|
||||
}
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
func (s *Server) trusted(a netip.Addr) bool {
|
||||
a = a.Unmap()
|
||||
for _, p := range s.cfg.TrustedProxies {
|
||||
if p.Contains(a) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isHTTPS reports whether the request reached Housecarl over HTTPS: TLS to
|
||||
// housecarld, the local socket, or a trusted proxy that says so (the last
|
||||
// X-Forwarded-Proto value).
|
||||
func (s *Server) isHTTPS(r *http.Request) bool {
|
||||
if r.TLS != nil || peerOf(r.Context()) != nil {
|
||||
return true
|
||||
}
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
peer, err := netip.ParseAddr(host)
|
||||
if err != nil || !s.trusted(peer) {
|
||||
return false
|
||||
}
|
||||
vals := r.Header.Values("X-Forwarded-Proto")
|
||||
if len(vals) == 0 {
|
||||
return false
|
||||
}
|
||||
parts := strings.Split(strings.Join(vals, ","), ",")
|
||||
return strings.EqualFold(strings.TrimSpace(parts[len(parts)-1]), "https")
|
||||
}
|
||||
@@ -0,0 +1,797 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"housecarl/internal/api"
|
||||
"housecarl/internal/auth"
|
||||
"housecarl/internal/engine"
|
||||
"housecarl/internal/model"
|
||||
"housecarl/internal/nft"
|
||||
"housecarl/internal/render"
|
||||
"housecarl/internal/store"
|
||||
)
|
||||
|
||||
// fakeFW stands in for nftables: it records the applied states and keeps
|
||||
// dynamic sets' entries.
|
||||
type fakeFW struct {
|
||||
mu sync.Mutex
|
||||
applied []*model.State
|
||||
boot *model.State
|
||||
elements map[string][]render.Element
|
||||
applyErr error
|
||||
deleted []string
|
||||
restored []string
|
||||
resets []string
|
||||
}
|
||||
|
||||
func (f *fakeFW) Check(_ context.Context, st *model.State) error { return nil }
|
||||
func (f *fakeFW) Apply(_ context.Context, st *model.State) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.applyErr != nil {
|
||||
return f.applyErr
|
||||
}
|
||||
f.applied = append(f.applied, st.Clone())
|
||||
return nil
|
||||
}
|
||||
func (f *fakeFW) last() *model.State {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.applied[len(f.applied)-1]
|
||||
}
|
||||
func (f *fakeFW) Render(st *model.State) *render.Ruleset { return render.Render(st, render.Options{}) }
|
||||
func (f *fakeFW) Table(context.Context) (*nft.Table, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if len(f.applied) == 0 {
|
||||
return nil, errors.New("No such file or directory: list table inet housecarl")
|
||||
}
|
||||
return &nft.Table{Counters: map[string]nft.Counter{"r1": {Packets: 3, Bytes: 180}}, Elements: f.elements}, nil
|
||||
}
|
||||
func (f *fakeFW) AddElement(_ context.Context, set, addr string, timeout int) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.elements[set] = append(f.elements[set], render.Element{Addr: addr, Timeout: timeout})
|
||||
return nil
|
||||
}
|
||||
func (f *fakeFW) DeleteElement(_ context.Context, set, addr string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
keep := f.elements[set][:0]
|
||||
for _, e := range f.elements[set] {
|
||||
if e.Addr != addr {
|
||||
keep = append(keep, e)
|
||||
}
|
||||
}
|
||||
f.elements[set] = keep
|
||||
return nil
|
||||
}
|
||||
func (f *fakeFW) ListRuleset(context.Context) ([]byte, error) {
|
||||
return []byte(`{"nftables":[{"table":{"family":"inet","name":"main"}},{"table":{"family":"inet","name":"housecarl"}}]}`), nil
|
||||
}
|
||||
func (f *fakeFW) DeleteTable(_ context.Context, fam, name string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.deleted = append(f.deleted, fam+" "+name)
|
||||
return nil
|
||||
}
|
||||
func (f *fakeFW) ResetQuota(_ context.Context, id int64, perAddress bool) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.resets = append(f.resets, fmt.Sprintf("r%d:%v", id, perAddress))
|
||||
return nil
|
||||
}
|
||||
func (f *fakeFW) TableText(_ context.Context, fam, name string) (string, error) {
|
||||
return "table " + fam + " " + name + " {}", nil
|
||||
}
|
||||
func (f *fakeFW) ApplyScript(_ context.Context, script string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.restored = append(f.restored, script)
|
||||
return nil
|
||||
}
|
||||
func (f *fakeFW) SaveBoot(st *model.State) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.boot = st.Clone()
|
||||
return nil
|
||||
}
|
||||
|
||||
type hostIfaces map[string][]netip.Prefix
|
||||
|
||||
func (h hostIfaces) All() (map[string][]netip.Prefix, error) { return h, nil }
|
||||
func (h hostIfaces) Up(string) bool { return true }
|
||||
|
||||
type env struct {
|
||||
t *testing.T
|
||||
ts *httptest.Server
|
||||
srv *Server
|
||||
eng *engine.Engine
|
||||
fw *fakeFW
|
||||
token string // the admin's API token
|
||||
}
|
||||
|
||||
func newEnv(t *testing.T) *env {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
st, err := store.Open(":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hash, _ := auth.HashPassword("secret-pass-1")
|
||||
admin := &model.User{Username: "admin", PasswordHash: hash, Admin: true}
|
||||
if err := st.CreateUser(ctx, admin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tok, pid, sh := auth.NewToken()
|
||||
st.CreateToken(ctx, &model.Token{UserID: admin.ID, Name: "test", PublicID: pid, SecretHash: sh})
|
||||
eng, err := engine.New(ctx, st, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fw := &fakeFW{elements: map[string][]render.Element{}}
|
||||
eng.Firewall = fw
|
||||
ifs := hostIfaces{"wan": {netip.MustParsePrefix("217.155.8.194/32")}, "eth1": {netip.MustParsePrefix("10.0.10.1/24")}}
|
||||
srv := New(Config{}, eng, fw, ifs, nil, nil)
|
||||
ts := httptest.NewServer(srv.Handler())
|
||||
t.Cleanup(ts.Close)
|
||||
return &env{t: t, ts: ts, srv: srv, eng: eng, fw: fw, token: tok}
|
||||
}
|
||||
|
||||
// call makes a request with a token; it returns the status and decodes
|
||||
// the body into out (if given).
|
||||
func (e *env) call(token, method, path string, body any, out any) int {
|
||||
e.t.Helper()
|
||||
var rd io.Reader
|
||||
if body != nil {
|
||||
b, _ := json.Marshal(body)
|
||||
rd = bytes.NewReader(b)
|
||||
}
|
||||
req, _ := http.NewRequest(method, e.ts.URL+api.Prefix+path, rd)
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
if out != nil && len(b) > 0 {
|
||||
if err := json.Unmarshal(b, out); err != nil {
|
||||
e.t.Fatalf("%s %s: %v: %s", method, path, err, b)
|
||||
}
|
||||
}
|
||||
return resp.StatusCode
|
||||
}
|
||||
|
||||
func ptr[T any](v T) *T { return &v }
|
||||
|
||||
func itoa(n int64) string { b, _ := json.Marshal(n); return string(b) }
|
||||
|
||||
func TestLoginSessionAndCSRF(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
jar, _ := cookiejar.New(nil)
|
||||
c := &http.Client{Jar: jar}
|
||||
post := func(path, body, csrf string) *http.Response {
|
||||
req, _ := http.NewRequest("POST", e.ts.URL+api.Prefix+path, strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if csrf != "" {
|
||||
req.Header.Set("X-CSRF-Token", csrf)
|
||||
}
|
||||
resp, err := c.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
if r := post("/auth/login", `{"username":"admin","password":"wrong"}`, ""); r.StatusCode != 401 {
|
||||
t.Errorf("wrong password: %d", r.StatusCode)
|
||||
}
|
||||
r := post("/auth/login", `{"username":"admin","password":"secret-pass-1"}`, "")
|
||||
var me api.Whoami
|
||||
json.NewDecoder(r.Body).Decode(&me)
|
||||
if r.StatusCode != 200 || me.CSRFToken == "" || !me.Admin {
|
||||
t.Fatalf("login: %d %+v", r.StatusCode, me)
|
||||
}
|
||||
body := `{"name":"lan","interfaces":["eth1"]}`
|
||||
if r := post("/zones", body, ""); r.StatusCode != 403 {
|
||||
t.Errorf("change without CSRF token: %d", r.StatusCode)
|
||||
}
|
||||
if r := post("/zones", body, me.CSRFToken); r.StatusCode != 201 {
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
t.Errorf("change with CSRF token: %d %s", r.StatusCode, b)
|
||||
}
|
||||
h, _ := e.srv.store.ListHistory(context.Background(), 1, 0)
|
||||
if len(h) != 1 || h[0].Via != "web" || h[0].Username != "admin" || h[0].Description != "create zone lan" {
|
||||
t.Errorf("history %+v", h)
|
||||
}
|
||||
}
|
||||
|
||||
func TestObjectsFlow(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
var stat api.Status
|
||||
if e.call(e.token, "GET", "/status", nil, &stat); !stat.Unconfigured || stat.Applied || stat.ApplyError != "" {
|
||||
t.Errorf("empty configuration: %+v", stat)
|
||||
}
|
||||
var mr api.MutationResponse
|
||||
if code := e.call(e.token, "POST", "/zones", model.Zone{Name: "wan", Interfaces: []string{"wan"}, External: true}, &mr); code != 201 || !mr.Applied {
|
||||
t.Fatalf("zone: %d %+v", code, mr)
|
||||
}
|
||||
e.call(e.token, "POST", "/zones", model.Zone{Name: "lan", Interfaces: []string{"eth1"}}, nil)
|
||||
if code := e.call(e.token, "POST", "/sets", model.AddressSet{Name: "trusted", Entries: []string{"10.0.10.0/24", "2a02:8013:7f3d:10::0/64"}}, &mr); code != 201 {
|
||||
t.Fatalf("set: %d %+v", code, mr)
|
||||
}
|
||||
var set model.AddressSet
|
||||
json.Unmarshal(mr.Object, &set)
|
||||
if set.Entries[1] != "2a02:8013:7f3d:10::/64" {
|
||||
t.Errorf("set entries not normalized: %v", set.Entries)
|
||||
}
|
||||
// Rules go to the end of their chain.
|
||||
var ids []int64
|
||||
for _, svc := range []string{"ssh", "dns", "https"} {
|
||||
var r model.Rule
|
||||
e.call(e.token, "POST", "/rules", model.Rule{Chain: "input", Match: model.Match{Sources: []string{"trusted"}, Services: []string{svc}}}, &mr)
|
||||
json.Unmarshal(mr.Object, &r)
|
||||
if r.Action != "accept" {
|
||||
t.Errorf("default action %q", r.Action)
|
||||
}
|
||||
ids = append(ids, r.ID)
|
||||
}
|
||||
// Move https first; PATCH changes only the given fields.
|
||||
if code := e.call(e.token, "POST", "/rules/"+itoa(ids[2])+"/move", api.MoveInput{To: "start"}, nil); code != 200 {
|
||||
t.Errorf("move: %d", code)
|
||||
}
|
||||
if code := e.call(e.token, "PATCH", "/rules/"+itoa(ids[0]), map[string]any{"comment": "admins", "log": true}, &mr); code != 200 {
|
||||
t.Errorf("patch: %d %+v", code, mr)
|
||||
}
|
||||
var rules []model.Rule
|
||||
e.call(e.token, "GET", "/rules?chain=input", nil, &rules)
|
||||
if len(rules) != 3 || rules[0].ID != ids[2] || rules[1].Comment != "admins" || !rules[1].Log || rules[1].Sources[0] != "trusted" {
|
||||
t.Errorf("rules after move and patch: %+v", rules)
|
||||
}
|
||||
// Validation: an unknown service, refused with its code.
|
||||
var eb api.ErrorBody
|
||||
if code := e.call(e.token, "POST", "/rules", model.Rule{Chain: "input", Match: model.Match{Services: []string{"gopher"}}}, &eb); code != 422 || eb.Error.Problems[0].Code != "unknown_service" {
|
||||
t.Errorf("unknown service: %d %+v", code, eb)
|
||||
}
|
||||
// Unknown fields are refused.
|
||||
if code := e.call(e.token, "POST", "/zones", map[string]any{"name": "x", "interfaces": []string{"x"}, "colour": "red"}, &eb); code != 400 {
|
||||
t.Errorf("unknown field: %d", code)
|
||||
}
|
||||
// A set in use can't be deleted.
|
||||
if code := e.call(e.token, "DELETE", "/sets/trusted", nil, &eb); code != 409 || !strings.Contains(eb.Error.Message, "used by input") {
|
||||
t.Errorf("deleting a used set: %d %+v", code, eb)
|
||||
}
|
||||
// Services list includes the built-in ones.
|
||||
var svcs []model.Service
|
||||
e.call(e.token, "GET", "/services", nil, &svcs)
|
||||
found := false
|
||||
for _, s := range svcs {
|
||||
found = found || s.Name == "ssh" && s.Builtin
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("no built-in ssh in %+v", svcs)
|
||||
}
|
||||
// Settings merge, built-ins one by one.
|
||||
if code := e.call(e.token, "PATCH", "/settings", map[string]any{"builtins": map[string]bool{"mss_clamp": true}, "own_prefixes": []string{"2a02:8013:7f3d::/48"}}, &mr); code != 200 {
|
||||
t.Fatalf("settings: %d %+v", code, mr)
|
||||
}
|
||||
s := e.eng.State().Settings
|
||||
if !s.Builtins.MSSClamp || !s.Builtins.Established || s.OwnPrefixes[0] != "2a02:8013:7f3d::/48" {
|
||||
t.Errorf("settings %+v", s)
|
||||
}
|
||||
// The firewall got the latest state; the ruleset is there to see.
|
||||
if len(e.fw.last().Rules) != 3 {
|
||||
t.Errorf("applied %+v", e.fw.last().Rules)
|
||||
}
|
||||
req, _ := http.NewRequest("GET", e.ts.URL+api.Prefix+"/ruleset", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+e.token)
|
||||
resp, _ := http.DefaultClient.Do(req)
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
if !strings.Contains(string(b), "ip6 saddr @trusted_v6 udp dport 53 counter accept") {
|
||||
t.Errorf("ruleset:\n%s", b)
|
||||
}
|
||||
var st api.Status
|
||||
if code := e.call(e.token, "GET", "/status", nil, &st); code != 200 || st.Counts["rule"] != 3 || len(st.OtherTables) != 1 || st.OtherTables[0] != "inet main" {
|
||||
t.Errorf("status: %d %+v", code, st)
|
||||
}
|
||||
var ifs []api.HostInterface
|
||||
e.call(e.token, "GET", "/interfaces", nil, &ifs)
|
||||
if len(ifs) != 2 || ifs[0].Name != "eth1" || ifs[0].Zone != "lan" {
|
||||
t.Errorf("interfaces %+v", ifs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbation(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.srv.probationFor = 300 * time.Millisecond
|
||||
ctx := context.Background()
|
||||
// A network change: pending, then rolled back by itself.
|
||||
var mr api.MutationResponse
|
||||
e.call(e.token, "POST", "/zones", model.Zone{Name: "wan", Interfaces: []string{"wan"}, External: true}, &mr)
|
||||
var pr api.Probation
|
||||
e.call(e.token, "GET", "/probation", nil, &pr)
|
||||
if len(pr.Pending) != 1 || pr.Pending[0] != mr.HistoryID || pr.SecondsLeft > 1 {
|
||||
t.Fatalf("probation %+v", pr)
|
||||
}
|
||||
if v, _ := e.srv.store.GetMeta(ctx, probationKey); !strings.Contains(v, itoa(mr.HistoryID)) {
|
||||
t.Errorf("probation not saved: %q", v)
|
||||
}
|
||||
time.Sleep(700 * time.Millisecond)
|
||||
if len(e.eng.State().Zones) != 0 || len(e.fw.last().Zones) != 0 {
|
||||
t.Errorf("not rolled back: state %+v applied %+v", e.eng.State().Zones, e.fw.last().Zones)
|
||||
}
|
||||
e.call(e.token, "GET", "/probation", nil, &pr)
|
||||
if len(pr.Pending) != 0 {
|
||||
t.Errorf("still pending: %+v", pr)
|
||||
}
|
||||
// Confirmed in time: kept, and saved for the boot.
|
||||
e.srv.probationFor = 5 * time.Second
|
||||
e.call(e.token, "POST", "/zones", model.Zone{Name: "wan", Interfaces: []string{"wan"}, External: true}, nil)
|
||||
e.call(e.token, "POST", "/zones", model.Zone{Name: "lan", Interfaces: []string{"eth1"}}, nil)
|
||||
var conf map[string]int
|
||||
if code := e.call(e.token, "POST", "/confirm", nil, &conf); code != 200 || conf["confirmed"] != 2 {
|
||||
t.Errorf("confirm: %d %+v", code, conf)
|
||||
}
|
||||
if len(e.eng.State().Zones) != 2 || e.fw.boot == nil || len(e.fw.boot.Zones) != 2 {
|
||||
t.Errorf("after confirm: state %+v boot %+v", e.eng.State().Zones, e.fw.boot)
|
||||
}
|
||||
// Through the socket: no probation, saved for the boot at once.
|
||||
sock := &auth.Principal{User: &model.User{Username: "root", Admin: true}, Method: "socket"}
|
||||
res, err := e.eng.Apply(ctx, sock, "", engine.Options{Via: "cli"}, func(st *model.State, newID func() int64) error {
|
||||
st.Settings.ForwardPolicy = model.PolicyAccept
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.srv.afterChange(ctx, sock, res)
|
||||
if len(e.srv.probationStatus().Pending) != 0 || e.fw.boot.Settings.ForwardPolicy != model.PolicyAccept {
|
||||
t.Errorf("socket change: %+v boot %s", e.srv.probationStatus(), e.fw.boot.Settings.ForwardPolicy)
|
||||
}
|
||||
// Pending when housecarld stops: rolled back when it starts.
|
||||
e.srv.probationFor = time.Hour
|
||||
e.call(e.token, "PATCH", "/zones/lan", map[string]any{"interfaces": []string{"eth1", "eth2"}}, nil)
|
||||
srv2 := New(Config{}, e.eng, e.fw, nil, nil, nil)
|
||||
if !srv2.ResumeProbation(ctx) || len(e.eng.State().Zone("lan").Interfaces) != 1 {
|
||||
t.Errorf("restart rollback: %+v", e.eng.State().Zone("lan"))
|
||||
}
|
||||
h, _ := e.srv.store.ListHistory(ctx, 1, 0)
|
||||
if h[0].Via != "rollback" || h[0].Username != "housecarl" {
|
||||
t.Errorf("rollback history %+v", h[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDynamicSetElements(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.call(e.token, "POST", "/sets", model.AddressSet{Name: "guests", Dynamic: true, Timeout: 3600}, nil)
|
||||
e.call(e.token, "POST", "/sets", model.AddressSet{Name: "trusted", Entries: []string{"10.0.10.0/24"}}, nil)
|
||||
// A token that may only change the guests set (a captive portal).
|
||||
var tok api.Token
|
||||
if code := e.call(e.token, "POST", "/me/tokens", api.TokenInput{Name: "portal", Grants: []string{"sets:write:guests"}}, &tok); code != 201 {
|
||||
t.Fatalf("token: %d", code)
|
||||
}
|
||||
if code := e.call(tok.Secret, "POST", "/sets/guests/elements", api.Element{Address: "10.0.30.5", Timeout: 1800}, nil); code != 201 {
|
||||
t.Errorf("add: %d", code)
|
||||
}
|
||||
if code := e.call(tok.Secret, "POST", "/sets/guests/elements", api.Element{Address: "10.0.30.0/24"}, nil); code != 400 {
|
||||
t.Errorf("a prefix: %d", code)
|
||||
}
|
||||
var els []api.Element
|
||||
e.call(tok.Secret, "GET", "/sets/guests/elements", nil, &els)
|
||||
if len(els) != 1 || els[0].Address != "10.0.30.5" || els[0].Timeout != 1800 {
|
||||
t.Errorf("elements %+v", els)
|
||||
}
|
||||
if code := e.call(tok.Secret, "POST", "/sets/trusted/elements", api.Element{Address: "10.0.10.9"}, nil); code != 403 {
|
||||
t.Errorf("another set: %d", code)
|
||||
}
|
||||
if code := e.call(e.token, "POST", "/sets/trusted/elements", api.Element{Address: "10.0.10.9"}, nil); code != 400 {
|
||||
t.Errorf("a static set: %d", code)
|
||||
}
|
||||
if code := e.call(tok.Secret, "PATCH", "/sets/guests", map[string]any{"timeout": 60}, nil); code != 403 {
|
||||
t.Errorf("changing the set itself: %d", code)
|
||||
}
|
||||
if code := e.call(tok.Secret, "DELETE", "/sets/guests/elements/10.0.30.5", nil, nil); code != 204 {
|
||||
t.Errorf("delete: %d", code)
|
||||
}
|
||||
evs, _ := e.srv.store.ListAudit(context.Background(), store.AuditFilter{Action: "set."})
|
||||
if len(evs) != 2 || evs[0].Action != "set.delete" {
|
||||
t.Errorf("audit %+v", evs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermissions(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
var tok api.Token
|
||||
if code := e.call(e.token, "POST", "/me/tokens", api.TokenInput{Name: "monitor", Role: "log"}, &tok); code != 201 || tok.Secret == "" {
|
||||
t.Fatalf("token: %d %+v", code, tok)
|
||||
}
|
||||
if code := e.call(tok.Secret, "GET", "/counters", nil, nil); code != 200 {
|
||||
t.Errorf("counters with a log:read token: %d", code)
|
||||
}
|
||||
if code := e.call(tok.Secret, "GET", "/rules", nil, nil); code != 403 {
|
||||
t.Errorf("rules with a log:read token: %d", code)
|
||||
}
|
||||
var eb api.ErrorBody
|
||||
if code := e.call(tok.Secret, "POST", "/zones", model.Zone{Name: "lan", Interfaces: []string{"eth1"}}, &eb); code != 403 || !strings.Contains(eb.Error.Message, "config:write") {
|
||||
t.Errorf("change with a read token: %d %+v", code, eb)
|
||||
}
|
||||
if code := e.call(tok.Secret, "GET", "/users", nil, nil); code != 403 {
|
||||
t.Errorf("users with a restricted token: %d", code)
|
||||
}
|
||||
// A restricted token can't make a wider one.
|
||||
if code := e.call(tok.Secret, "POST", "/me/tokens", api.TokenInput{Name: "wider", Role: "operator"}, nil); code != 403 {
|
||||
t.Errorf("wider token: %d", code)
|
||||
}
|
||||
if code := e.call("", "GET", "/status", nil, nil); code != 401 {
|
||||
t.Errorf("no token: %d", code)
|
||||
}
|
||||
if code := e.call("housecarl_aaaaaaaaaaaaaaaa_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "GET", "/status", nil, nil); code != 401 {
|
||||
t.Errorf("bad token: %d", code)
|
||||
}
|
||||
// The last administrator stays.
|
||||
var users []api.User
|
||||
e.call(e.token, "GET", "/users", nil, &users)
|
||||
if code := e.call(e.token, "PATCH", "/users/"+itoa(users[0].ID), api.UserInput{Admin: ptr(false)}, &eb); code != 409 {
|
||||
t.Errorf("demoting the last admin: %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenRotation(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
var tok api.Token
|
||||
e.call(e.token, "POST", "/me/tokens", api.TokenInput{Name: "script", ExpiresIn: "30d"}, &tok)
|
||||
var rot api.Token
|
||||
if code := e.call(e.token, "POST", "/tokens/"+itoa(tok.ID)+"/rotate", api.TokenRotation{Grace: "1h"}, &rot); code != 200 || rot.Secret == "" || rot.Secret == tok.Secret || rot.PrevExpiresAt == nil {
|
||||
t.Fatalf("rotate: %d %+v", code, rot)
|
||||
}
|
||||
if rot.ID != tok.ID || rot.Name != "script" || rot.ExpiresAt == nil || rot.ExpiresAt.Sub(*tok.ExpiresAt) < 0 {
|
||||
t.Errorf("rotated token %+v", rot)
|
||||
}
|
||||
if code := e.call(rot.Secret, "GET", "/auth/whoami", nil, nil); code != 200 {
|
||||
t.Errorf("new secret: %d", code)
|
||||
}
|
||||
if code := e.call(tok.Secret, "GET", "/auth/whoami", nil, nil); code != 200 {
|
||||
t.Errorf("old secret in its grace period: %d", code)
|
||||
}
|
||||
var me api.Whoami
|
||||
e.call(rot.Secret, "GET", "/auth/whoami", nil, &me)
|
||||
if me.TokenID != tok.ID {
|
||||
t.Errorf("whoami token id %d, want %d", me.TokenID, tok.ID)
|
||||
}
|
||||
// Without a grace period the old secret stops at once.
|
||||
var rot2 api.Token
|
||||
e.call(e.token, "POST", "/tokens/"+itoa(tok.ID)+"/rotate", nil, &rot2)
|
||||
if code := e.call(rot.Secret, "GET", "/auth/whoami", nil, nil); code != 401 {
|
||||
t.Errorf("old secret without grace: %d", code)
|
||||
}
|
||||
if code := e.call(rot2.Secret, "GET", "/auth/whoami", nil, nil); code != 200 {
|
||||
t.Errorf("newest secret: %d", code)
|
||||
}
|
||||
if code := e.call(e.token, "POST", "/tokens/"+itoa(tok.ID)+"/rotate", api.TokenRotation{Grace: "30d"}, nil); code != 400 {
|
||||
t.Errorf("a grace over 7 days: %d", code)
|
||||
}
|
||||
// A restricted token can't rotate a wider one.
|
||||
var narrow api.Token
|
||||
e.call(e.token, "POST", "/me/tokens", api.TokenInput{Name: "narrow", Role: "log"}, &narrow)
|
||||
if code := e.call(narrow.Secret, "POST", "/tokens/"+itoa(tok.ID)+"/rotate", nil, nil); code != 403 {
|
||||
t.Errorf("narrow token rotating a wider one: %d", code)
|
||||
}
|
||||
if code := e.call(narrow.Secret, "POST", "/tokens/"+itoa(narrow.ID)+"/rotate", nil, nil); code != 200 {
|
||||
t.Errorf("a token rotating itself: %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportAndTakeover(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.srv.probationFor = 300 * time.Millisecond
|
||||
js, _ := os.ReadFile("../importer/testdata/router.json")
|
||||
txt, _ := os.ReadFile("../importer/testdata/router.txt")
|
||||
defs, _ := os.ReadFile("../importer/testdata/router.nft")
|
||||
in := api.ImportInput{JSON: js, Text: string(txt), Defines: string(defs)}
|
||||
var res api.ImportResult
|
||||
if code := e.call(e.token, "POST", "/import/nft?dry_run=1", in, &res); code != 200 || !res.DryRun || len(res.State.Forwards) != 5 || len(res.Notes) == 0 {
|
||||
t.Fatalf("dry run: %d %+v", code, res.MutationResponse)
|
||||
}
|
||||
if len(e.eng.State().Rules) != 0 {
|
||||
t.Error("a dry run changed the configuration")
|
||||
}
|
||||
if code := e.call(e.token, "POST", "/import/nft", in, &res); code != 200 || !res.Applied || res.ConfirmBy == nil {
|
||||
t.Fatalf("import: %d %+v", code, res.MutationResponse)
|
||||
}
|
||||
st := e.eng.State()
|
||||
if len(st.Forwards) != 5 || st.Zone("wan") == nil || st.Forwards[0].ID < 2 {
|
||||
t.Errorf("imported: %+v", st.Forwards)
|
||||
}
|
||||
// IDs are the store's: a new object doesn't collide.
|
||||
var mr api.MutationResponse
|
||||
if code := e.call(e.token, "POST", "/zones", model.Zone{Name: "extra", Interfaces: []string{"eth9"}}, &mr); code != 201 {
|
||||
t.Errorf("a zone after the import: %d %+v", code, mr)
|
||||
}
|
||||
// A second import needs --replace.
|
||||
if code := e.call(e.token, "POST", "/import/nft", in, nil); code != 409 {
|
||||
t.Errorf("import over a configuration: %d", code)
|
||||
}
|
||||
e.call(e.token, "POST", "/confirm", nil, nil)
|
||||
// Takeover: the old table goes, and comes back unless confirmed.
|
||||
var out map[string]any
|
||||
if code := e.call(e.token, "POST", "/takeover", api.TakeoverInput{Tables: []string{"inet main"}}, &out); code != 200 || len(e.fw.deleted) != 1 {
|
||||
t.Fatalf("takeover: %d %+v", code, out)
|
||||
}
|
||||
if !strings.Contains(out["message"].(string), "come back") {
|
||||
t.Errorf("takeover message %q", out["message"])
|
||||
}
|
||||
time.Sleep(700 * time.Millisecond)
|
||||
if len(e.fw.restored) != 1 || e.fw.restored[0] != "table inet main {}" {
|
||||
t.Errorf("not restored: %+v", e.fw.restored)
|
||||
}
|
||||
if code := e.call(e.token, "POST", "/takeover", api.TakeoverInput{Tables: []string{"inet housecarl"}}, nil); code != 400 {
|
||||
t.Errorf("taking over Housecarl's own table: %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChangesets: changes staged in a changeset are checked as they are
|
||||
// added, shown in reads that name the changeset, previewed with the
|
||||
// ruleset's diff, and applied together as one history entry, on
|
||||
// probation; another user can't use the changeset.
|
||||
func TestChangesets(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.srv.probationFor = 5 * time.Second
|
||||
e.call(e.token, "POST", "/zones", model.Zone{Name: "lan", Interfaces: []string{"eth1"}}, nil)
|
||||
e.call(e.token, "POST", "/confirm", nil, nil)
|
||||
applied := len(e.fw.applied)
|
||||
var cs model.Changeset
|
||||
if code := e.call(e.token, "POST", "/changesets", api.ChangesetInput{Description: "trusted ssh"}, &cs); code != 201 || cs.Status != "open" {
|
||||
t.Fatalf("create: %d %+v", code, cs)
|
||||
}
|
||||
q := "?changeset=" + itoa(cs.ID)
|
||||
// A rule naming a set the changeset hasn't made yet is refused.
|
||||
var eb api.ErrorBody
|
||||
rule := model.Rule{Chain: "input", Match: model.Match{Sources: []string{"trusted"}, Services: []string{"ssh"}}, Action: "accept"}
|
||||
if code := e.call(e.token, "POST", "/rules"+q, rule, &eb); code != 422 || eb.Error.Code != "validation" {
|
||||
t.Errorf("staging an invalid rule: %d %+v", code, eb)
|
||||
}
|
||||
var mr api.MutationResponse
|
||||
if code := e.call(e.token, "POST", "/sets"+q, model.AddressSet{Name: "trusted", Entries: []string{"10.0.10.0/24"}}, &mr); code != 200 || !mr.Staged || mr.Applied {
|
||||
t.Fatalf("stage set: %d %+v", code, mr)
|
||||
}
|
||||
if code := e.call(e.token, "POST", "/rules"+q, rule, &mr); code != 200 || !mr.Staged || len(mr.Changeset.Ops) != 2 {
|
||||
t.Fatalf("stage rule: %d %+v", code, mr)
|
||||
}
|
||||
var created model.Rule
|
||||
json.Unmarshal(mr.Object, &created)
|
||||
// Later operations can name what the changeset made.
|
||||
if code := e.call(e.token, "PATCH", "/rules/"+itoa(created.ID)+q, map[string]any{"comment": "SSH from home"}, &mr); code != 200 || len(mr.Changeset.Ops) != 3 {
|
||||
t.Fatalf("stage update: %d %+v", code, mr)
|
||||
}
|
||||
if code := e.call(e.token, "DELETE", "/zones/lan"+q, nil, &mr); code != 200 {
|
||||
t.Fatalf("stage delete: %d %+v", code, mr)
|
||||
}
|
||||
// Nothing applied yet.
|
||||
if len(e.fw.applied) != applied || len(e.eng.State().Sets) != 0 {
|
||||
t.Fatalf("staging applied something")
|
||||
}
|
||||
// Reads naming the changeset show it, marked.
|
||||
var zones []map[string]any
|
||||
e.call(e.token, "GET", "/zones"+q, nil, &zones)
|
||||
if len(zones) != 1 || zones[0]["pending"] != "deleted" {
|
||||
t.Errorf("zones in the changeset: %+v", zones)
|
||||
}
|
||||
var rules []map[string]any
|
||||
e.call(e.token, "GET", "/rules"+q+"&chain=input", nil, &rules)
|
||||
if len(rules) != 1 || rules[0]["pending"] != "new" || rules[0]["comment"] != "SSH from home" {
|
||||
t.Errorf("rules in the changeset: %+v", rules)
|
||||
}
|
||||
var plain []map[string]any
|
||||
e.call(e.token, "GET", "/zones", nil, &plain)
|
||||
if len(plain) != 1 || plain[0]["pending"] != nil {
|
||||
t.Errorf("zones without the changeset: %+v", plain)
|
||||
}
|
||||
// The preview: valid, with the ruleset's diff.
|
||||
var pv api.ChangesetPreview
|
||||
e.call(e.token, "GET", "/changesets/"+itoa(cs.ID)+"/preview", nil, &pv)
|
||||
if !pv.Valid || !strings.Contains(pv.Diff, "+\t\tip saddr @trusted_v4 tcp dport 22 counter accept") || !strings.Contains(pv.Diff, "--- ruleset.nft (now)") {
|
||||
t.Errorf("preview: %+v", pv)
|
||||
}
|
||||
// Another user can't stage into it, apply or discard it.
|
||||
ctx := context.Background()
|
||||
other := &model.User{Username: "sam", Grants: auth.Roles["operator"]}
|
||||
e.srv.store.CreateUser(ctx, other)
|
||||
otok, pid, sh := auth.NewToken()
|
||||
e.srv.store.CreateToken(ctx, &model.Token{UserID: other.ID, Name: "t", PublicID: pid, SecretHash: sh})
|
||||
if code := e.call(otok, "POST", "/changesets/"+itoa(cs.ID)+"/apply", nil, &eb); code != 403 || !strings.Contains(eb.Error.Message, "admin's") {
|
||||
t.Errorf("another user's apply: %d %+v", code, eb)
|
||||
}
|
||||
// Removing an operation; then the changeset is applied as one change.
|
||||
var after model.Changeset
|
||||
if code := e.call(e.token, "DELETE", "/changesets/"+itoa(cs.ID)+"/ops/4", nil, &after); code != 200 || len(after.Ops) != 3 {
|
||||
t.Fatalf("remove op: %d %+v", code, after)
|
||||
}
|
||||
if code := e.call(e.token, "POST", "/changesets/"+itoa(cs.ID)+"/apply", nil, &mr); code != 200 || !mr.Applied || mr.ConfirmBy == nil || mr.Changeset.Status != "applied" {
|
||||
t.Fatalf("apply: %d %+v", code, mr)
|
||||
}
|
||||
if len(e.fw.applied) != applied+1 || len(e.eng.State().Rules) != 1 || len(e.eng.State().Zones) != 1 {
|
||||
t.Errorf("applied: %d rulesets, state %+v", len(e.fw.applied)-applied, e.eng.State())
|
||||
}
|
||||
h, _ := e.srv.store.GetHistory(ctx, mr.HistoryID)
|
||||
if h.Description != "trusted ssh" || len(h.Changes) != 2 {
|
||||
t.Errorf("history: %+v", h)
|
||||
}
|
||||
// An applied changeset takes no more.
|
||||
if code := e.call(e.token, "POST", "/sets"+q, model.AddressSet{Name: "x", Entries: []string{"10.0.0.1"}}, &eb); code != 409 || !strings.Contains(eb.Error.Message, "is applied") {
|
||||
t.Errorf("staging into an applied changeset: %d %+v", code, eb)
|
||||
}
|
||||
// A changeset that no longer applies names the operation.
|
||||
var cs2 model.Changeset
|
||||
e.call(e.token, "POST", "/changesets", nil, &cs2)
|
||||
e.call(e.token, "PATCH", "/rules/"+itoa(created.ID)+"?changeset="+itoa(cs2.ID), map[string]any{"comment": "x"}, nil)
|
||||
e.call(e.token, "DELETE", "/rules/"+itoa(created.ID), nil, nil)
|
||||
e.call(e.token, "GET", "/changesets/"+itoa(cs2.ID)+"/preview", nil, &pv)
|
||||
if pv.Valid || pv.Error == nil || pv.Error.Op != 1 || !strings.Contains(pv.Error.Message, "operation 1") {
|
||||
t.Errorf("stale preview: %+v", pv)
|
||||
}
|
||||
if code := e.call(e.token, "POST", "/changesets/"+itoa(cs2.ID)+"/discard", nil, &after); code != 200 || after.Status != "discarded" {
|
||||
t.Errorf("discard: %d %+v", code, after)
|
||||
}
|
||||
// A dry run shows the ruleset's diff.
|
||||
if code := e.call(e.token, "POST", "/sets?dry_run=1", model.AddressSet{Name: "y", Entries: []string{"10.0.0.2"}}, &mr); code != 200 || !strings.Contains(mr.Diff, "+\tset y_v4") {
|
||||
t.Errorf("dry run diff: %d %q", code, mr.Diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuotaPeriods(t *testing.T) {
|
||||
at := func(s string) time.Time { v, _ := time.ParseInLocation("2006-01-02 15:04", s, time.Local); return v }
|
||||
for _, c := range []struct {
|
||||
now, reset string
|
||||
day int
|
||||
start string
|
||||
next string
|
||||
}{
|
||||
{"2026-10-19 10:00", "daily", 0, "2026-10-19 00:00", "2026-10-20 00:00"},
|
||||
{"2026-10-19 10:00", "weekly", 0, "2026-10-19 00:00", "2026-10-26 00:00"}, // a Monday
|
||||
{"2026-10-25 23:59", "weekly", 0, "2026-10-19 00:00", "2026-10-26 00:00"}, // a Sunday
|
||||
{"2026-10-19 10:00", "monthly", 15, "2026-10-15 00:00", "2026-11-15 00:00"},
|
||||
{"2026-10-10 10:00", "monthly", 15, "2026-09-15 00:00", "2026-10-15 00:00"},
|
||||
{"2026-02-28 10:00", "monthly", 31, "2026-02-28 00:00", "2026-03-31 00:00"}, // February has no 31st
|
||||
{"2026-03-15 10:00", "monthly", 31, "2026-02-28 00:00", "2026-03-31 00:00"},
|
||||
{"2026-01-01 00:00", "monthly", 0, "2026-01-01 00:00", "2026-02-01 00:00"},
|
||||
} {
|
||||
now := at(c.now)
|
||||
if got := periodStart(now, c.reset, c.day); !got.Equal(at(c.start)) {
|
||||
t.Errorf("%s %s %d: period from %s, want %s", c.now, c.reset, c.day, got.Format("2006-01-02 15:04"), c.start)
|
||||
}
|
||||
if got := nextReset(now, c.reset, c.day); !got.Equal(at(c.next)) {
|
||||
t.Errorf("%s %s %d: next reset %s, want %s", c.now, c.reset, c.day, got.Format("2006-01-02 15:04"), c.next)
|
||||
}
|
||||
}
|
||||
if !periodStart(time.Now(), "", 0).IsZero() {
|
||||
t.Error("a quota reset by hand has no period")
|
||||
}
|
||||
}
|
||||
|
||||
// TestQuotaResets: a quota starts its period when first seen, is reset
|
||||
// when the next one starts, and by hand.
|
||||
func TestQuotaResets(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
ctx := context.Background()
|
||||
now := time.Date(2026, 10, 19, 23, 50, 0, 0, time.Local)
|
||||
e.srv.nowFn = func() time.Time { return now }
|
||||
var mr api.MutationResponse
|
||||
rule := model.Rule{Chain: "forward", Action: "drop", Quota: &model.Quota{Bytes: 5 << 30, Over: true, PerSource: true, Reset: "daily"}}
|
||||
if code := e.call(e.token, "POST", "/rules", rule, &mr); code != 201 {
|
||||
t.Fatalf("rule: %d %+v", code, mr)
|
||||
}
|
||||
var created model.Rule
|
||||
json.Unmarshal(mr.Object, &created)
|
||||
e.srv.checkQuotas(ctx)
|
||||
if len(e.fw.resets) != 0 {
|
||||
t.Fatalf("reset on first sight: %v", e.fw.resets)
|
||||
}
|
||||
now = now.Add(5 * time.Minute) // the same day
|
||||
e.srv.checkQuotas(ctx)
|
||||
now = now.Add(10 * time.Minute) // past midnight
|
||||
e.srv.checkQuotas(ctx)
|
||||
e.srv.checkQuotas(ctx)
|
||||
want := fmt.Sprintf("r%d:true", created.ID)
|
||||
if strings.Join(e.fw.resets, ",") != want {
|
||||
t.Errorf("resets %v, want one %s", e.fw.resets, want)
|
||||
}
|
||||
var qs []api.QuotaInfo
|
||||
e.call(e.token, "GET", "/quotas", nil, &qs)
|
||||
if len(qs) != 1 || qs[0].NextReset == nil || qs[0].NextReset.Day() != 21 {
|
||||
t.Errorf("quotas %+v", qs)
|
||||
}
|
||||
if code := e.call(e.token, "POST", "/rules/"+itoa(created.ID)+"/reset-quota", nil, nil); code != 200 || len(e.fw.resets) != 2 {
|
||||
t.Errorf("reset by hand: %d %v", code, e.fw.resets)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAutoConfirm: the client that made a change confirms it by itself:
|
||||
// with its token, from the same address, over a new connection. Nobody
|
||||
// else can, an older token can't, and with manual_confirm there is none.
|
||||
func TestAutoConfirm(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.srv.probationFor = 5 * time.Second
|
||||
post := func(token, path string, body any) (*http.Response, []byte) {
|
||||
b, _ := json.Marshal(body)
|
||||
req, _ := http.NewRequest("POST", e.ts.URL+api.Prefix+path, bytes.NewReader(b))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
out, _ := io.ReadAll(resp.Body)
|
||||
return resp, out
|
||||
}
|
||||
resp, body := post(e.token, "/zones", model.Zone{Name: "lan", Interfaces: []string{"eth1"}})
|
||||
var mr api.MutationResponse
|
||||
json.Unmarshal(body, &mr)
|
||||
if mr.ConfirmToken == "" || !resp.Close {
|
||||
t.Fatalf("no token, or the connection stays open: %+v close=%v", mr, resp.Close)
|
||||
}
|
||||
first := mr.ConfirmToken
|
||||
// A newer change: only its token confirms now.
|
||||
_, body = post(e.token, "/zones", model.Zone{Name: "dmz", Interfaces: []string{"eth2"}})
|
||||
json.Unmarshal(body, &mr)
|
||||
if resp, _ := post(e.token, "/confirm", api.ConfirmInput{Token: first}); resp.StatusCode != 409 {
|
||||
t.Errorf("an older token: %d", resp.StatusCode)
|
||||
}
|
||||
// Another user can't use it.
|
||||
ctx := context.Background()
|
||||
other := &model.User{Username: "sam", Grants: auth.Roles["operator"]}
|
||||
e.srv.store.CreateUser(ctx, other)
|
||||
otok, pid, sh := auth.NewToken()
|
||||
e.srv.store.CreateToken(ctx, &model.Token{UserID: other.ID, Name: "t", PublicID: pid, SecretHash: sh})
|
||||
if resp, _ := post(otok, "/confirm", api.ConfirmInput{Token: mr.ConfirmToken}); resp.StatusCode != 403 {
|
||||
t.Errorf("another user's confirmation: %d", resp.StatusCode)
|
||||
}
|
||||
// Not over the change's own connection, nor from elsewhere.
|
||||
c := e.srv.probation.check
|
||||
p := &auth.Principal{User: &model.User{ID: c.userID, Username: c.username}, Method: "token", Token: &model.Token{ID: c.tokenID}}
|
||||
same := httptest.NewRequest("POST", "/", nil)
|
||||
same.RemoteAddr = c.remote
|
||||
if _, err := e.srv.confirmToken(same, p, c.token); err == nil || !strings.Contains(err.Error(), "new one") {
|
||||
t.Errorf("over the same connection: %v", err)
|
||||
}
|
||||
elsewhere := httptest.NewRequest("POST", "/", nil)
|
||||
elsewhere.RemoteAddr = "192.0.2.9:4444"
|
||||
if _, err := e.srv.confirmToken(elsewhere, p, c.token); err == nil || !strings.Contains(err.Error(), "same place") {
|
||||
t.Errorf("from elsewhere: %v", err)
|
||||
}
|
||||
// The client itself, over a new connection: confirmed.
|
||||
resp, body = post(e.token, "/confirm", api.ConfirmInput{Token: mr.ConfirmToken})
|
||||
if resp.StatusCode != 200 || !strings.Contains(string(body), `"confirmed":2`) {
|
||||
t.Fatalf("auto-confirm: %d %s", resp.StatusCode, body)
|
||||
}
|
||||
if pr := e.srv.probationStatus(); len(pr.Pending) != 0 {
|
||||
t.Errorf("still pending: %+v", pr)
|
||||
}
|
||||
// manual_confirm: no token; a person confirms.
|
||||
e.call(e.token, "PATCH", "/settings", map[string]any{"manual_confirm": true}, nil)
|
||||
e.call(e.token, "POST", "/confirm", nil, nil)
|
||||
_, body = post(e.token, "/zones", model.Zone{Name: "guest", Interfaces: []string{"eth3"}})
|
||||
mr = api.MutationResponse{}
|
||||
json.Unmarshal(body, &mr)
|
||||
if mr.ConfirmToken != "" || mr.ConfirmBy == nil {
|
||||
t.Errorf("manual confirmation: %+v", mr)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// sniffListener serves TLS and plain HTTP on the same port: it looks at a
|
||||
// connection's first byte and hands the server a *tls.Conn when it is a
|
||||
// TLS handshake (0x16), the plain connection otherwise. That way housecarld
|
||||
// can start serving HTTPS when a certificate turns up (one it obtains,
|
||||
// docs/certificates.md) without a restart, and a plain-HTTP bookmark gets
|
||||
// a redirect instead of an error.
|
||||
type sniffListener struct {
|
||||
net.Listener
|
||||
cfg *tls.Config
|
||||
ch chan net.Conn
|
||||
errc chan error
|
||||
done chan struct{}
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func newSniffListener(ln net.Listener, cfg *tls.Config) *sniffListener {
|
||||
l := &sniffListener{Listener: ln, cfg: cfg, ch: make(chan net.Conn), errc: make(chan error, 1), done: make(chan struct{})}
|
||||
go l.loop()
|
||||
return l
|
||||
}
|
||||
|
||||
func (l *sniffListener) loop() {
|
||||
for {
|
||||
c, err := l.Listener.Accept()
|
||||
if err != nil {
|
||||
var ne net.Error
|
||||
if errors.As(err, &ne) && ne.Timeout() {
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case l.errc <- err:
|
||||
default:
|
||||
}
|
||||
return
|
||||
}
|
||||
go l.classify(c)
|
||||
}
|
||||
}
|
||||
|
||||
func (l *sniffListener) classify(c net.Conn) {
|
||||
c.SetReadDeadline(time.Now().Add(10 * time.Second))
|
||||
br := bufio.NewReader(c)
|
||||
b, err := br.Peek(1)
|
||||
c.SetReadDeadline(time.Time{})
|
||||
if err != nil {
|
||||
c.Close()
|
||||
return
|
||||
}
|
||||
var out net.Conn = &peekedConn{Conn: c, r: br}
|
||||
if b[0] == 0x16 {
|
||||
out = tls.Server(out, l.cfg)
|
||||
}
|
||||
select {
|
||||
case l.ch <- out:
|
||||
case <-l.done:
|
||||
c.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func (l *sniffListener) Accept() (net.Conn, error) {
|
||||
select {
|
||||
case c := <-l.ch:
|
||||
return c, nil
|
||||
case err := <-l.errc:
|
||||
return nil, err
|
||||
case <-l.done:
|
||||
return nil, net.ErrClosed
|
||||
}
|
||||
}
|
||||
|
||||
func (l *sniffListener) Close() error {
|
||||
l.once.Do(func() { close(l.done) })
|
||||
return l.Listener.Close()
|
||||
}
|
||||
|
||||
// peekedConn reads through the buffer that holds the peeked byte.
|
||||
type peekedConn struct {
|
||||
net.Conn
|
||||
r *bufio.Reader
|
||||
}
|
||||
|
||||
func (c *peekedConn) Read(p []byte) (int, error) { return c.r.Read(p) }
|
||||
|
||||
// webCertificate is the certificate housecarld serves: tls.cert_file's, or
|
||||
// the one it obtains itself (tls.acme).
|
||||
func (s *Server) webCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||
if s.certs != nil {
|
||||
return s.certs.get(hello)
|
||||
}
|
||||
if s.cfg.ACME != nil {
|
||||
if c := s.cfg.ACME.Certificate(); c != nil {
|
||||
return c, nil
|
||||
}
|
||||
}
|
||||
return nil, errNoCertificate
|
||||
}
|
||||
|
||||
var errNoCertificate = errors.New("no certificate for HTTPS yet")
|
||||
|
||||
// httpsAvailable reports whether housecarld has a certificate to serve.
|
||||
func (s *Server) httpsAvailable() bool {
|
||||
return s.certs != nil || s.cfg.ACME != nil && s.cfg.ACME.Certificate() != nil
|
||||
}
|
||||
|
||||
// requireHTTPS sends plain-HTTP requests to HTTPS once there is a
|
||||
// certificate: browsers (GET and HEAD) get a redirect, anything else an
|
||||
// error, so tokens and passwords aren't sent in the clear. Requests from
|
||||
// loopback (tools on this server) and from trusted proxies (which
|
||||
// terminate TLS themselves) are served as they are.
|
||||
func (s *Server) requireHTTPS(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.TLS != nil || !s.httpsAvailable() {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
host, _, _ := net.SplitHostPort(r.RemoteAddr)
|
||||
if a, err := netip.ParseAddr(host); err == nil && (a.Unmap().IsLoopback() || s.trusted(a.Unmap())) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
target := "https://" + r.Host + r.URL.RequestURI()
|
||||
if r.Method == http.MethodGet || r.Method == http.MethodHead {
|
||||
http.Redirect(w, r, target, http.StatusPermanentRedirect)
|
||||
return
|
||||
}
|
||||
msg := "housecarld serves HTTPS on this port; use " + strings.TrimSuffix(target, r.URL.RequestURI())
|
||||
if r.Header.Get("Authorization") != "" {
|
||||
msg += ". This request's token crossed the network unencrypted; if that network isn't trusted, rotate the token"
|
||||
}
|
||||
writeError(w, http.StatusBadRequest, "https_required", msg)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"io"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestHTTPSAndPlainOnOnePort serves a certificate file: the same port
|
||||
// answers HTTPS, and plain HTTP from loopback.
|
||||
func TestHTTPSAndPlainOnOnePort(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
tmpl := &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "dhcp.test"}, DNSNames: []string{"dhcp.test"},
|
||||
NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour)}
|
||||
der, _ := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||
kd, _ := x509.MarshalPKCS8PrivateKey(key)
|
||||
os.WriteFile(filepath.Join(dir, "c.pem"), pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0o600)
|
||||
os.WriteFile(filepath.Join(dir, "k.pem"), pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: kd}), 0o600)
|
||||
ln, _ := net.Listen("tcp", "127.0.0.1:0")
|
||||
addr := ln.Addr().String()
|
||||
ln.Close()
|
||||
e := newEnv(t)
|
||||
e.srv.cfg.Listen, e.srv.cfg.TLSCertFile, e.srv.cfg.TLSKeyFile = []string{addr}, filepath.Join(dir, "c.pem"), filepath.Join(dir, "k.pem")
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
ready := make(chan struct{})
|
||||
go e.srv.Serve(ctx, func() { close(ready) })
|
||||
<-ready
|
||||
pool := x509.NewCertPool()
|
||||
pool.AddCert(func() *x509.Certificate { c, _ := x509.ParseCertificate(der); return c }())
|
||||
hc := &http.Client{Timeout: 5 * time.Second, Transport: &http.Transport{TLSClientConfig: &tls.Config{RootCAs: pool, ServerName: "dhcp.test"}}}
|
||||
for _, url := range []string{"https://" + addr + "/healthz", "http://" + addr + "/healthz"} {
|
||||
resp, err := hc.Get(url)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", url, err)
|
||||
}
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != 200 || !strings.Contains(string(b), `"ok":true`) {
|
||||
t.Errorf("%s: %d %s", url, resp.StatusCode, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package server
|
||||
|
||||
// The local unix socket (socket in housecarl.yaml, /run/housecarl/housecarl.sock by
|
||||
// default). It serves the same API as the network listeners, but a request
|
||||
// on it without an Authorization header is trusted by who sent it: the
|
||||
// kernel reports the connecting process's user (SO_PEERCRED), and root and
|
||||
// housecarld's own user get administrator rights without a token. Both can
|
||||
// already do everything (read the database, run 'housecarld user', stop the
|
||||
// service), so this adds convenience, not power: 'sudo hc status'
|
||||
// works on the server with nothing configured, and a lost token or
|
||||
// password never locks out root.
|
||||
//
|
||||
// Anyone else who can reach the socket must send an API token, which then
|
||||
// gives that token's permissions, as on the network. The socket file is
|
||||
// mode 0600 in a 0750 directory, so normally only root and the housecarl user
|
||||
// can connect at all; the user check doesn't rely on that.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"syscall"
|
||||
|
||||
"housecarl/internal/auth"
|
||||
"housecarl/internal/model"
|
||||
)
|
||||
|
||||
// peer is the process at the other end of a unix socket connection.
|
||||
type peer struct {
|
||||
UID int
|
||||
PID int
|
||||
}
|
||||
|
||||
type peerKey struct{}
|
||||
|
||||
// peerOf returns the socket peer of a request, or nil for network
|
||||
// requests.
|
||||
func peerOf(ctx context.Context) *peer {
|
||||
p, _ := ctx.Value(peerKey{}).(*peer)
|
||||
return p
|
||||
}
|
||||
|
||||
// socketConnContext records the peer's credentials on each connection to
|
||||
// the unix socket.
|
||||
func socketConnContext(ctx context.Context, c net.Conn) context.Context {
|
||||
uc, ok := c.(*net.UnixConn)
|
||||
if !ok {
|
||||
return ctx
|
||||
}
|
||||
p := &peer{UID: -1}
|
||||
if raw, err := uc.SyscallConn(); err == nil {
|
||||
raw.Control(func(fd uintptr) {
|
||||
if cred, err := syscall.GetsockoptUcred(int(fd), syscall.SOL_SOCKET, syscall.SO_PEERCRED); err == nil {
|
||||
p.UID, p.PID = int(cred.Uid), int(cred.Pid)
|
||||
}
|
||||
})
|
||||
}
|
||||
// Even when the credentials can't be read, the request is marked as a
|
||||
// socket request (with no trusted user).
|
||||
return context.WithValue(ctx, peerKey{}, p)
|
||||
}
|
||||
|
||||
// trustedUID reports whether a socket peer gets administrator rights
|
||||
// without a token: root and the user housecarld runs as.
|
||||
func trustedUID(uid int) bool {
|
||||
return uid == 0 || uid == os.Getuid()
|
||||
}
|
||||
|
||||
// socketPrincipal is the administrator for a trusted socket peer. It isn't
|
||||
// a Housecarl user (ID 0), so it has no tokens or password; the audit log and
|
||||
// history show it by its system user name, e.g. "root", with method
|
||||
// "socket".
|
||||
func socketPrincipal(p *peer, agent string) *auth.Principal {
|
||||
name := "uid " + strconv.Itoa(p.UID)
|
||||
if u, err := user.LookupId(strconv.Itoa(p.UID)); err == nil {
|
||||
name = u.Username
|
||||
}
|
||||
return &auth.Principal{User: &model.User{ID: 0, Username: name, Admin: true}, Method: "socket", IP: "local", Agent: agent}
|
||||
}
|
||||
|
||||
// listenSocket creates the unix socket, replacing a stale one left by a
|
||||
// previous run.
|
||||
func listenSocket(path string) (net.Listener, error) {
|
||||
if len(path) > 107 {
|
||||
return nil, fmt.Errorf("the path is %d bytes long; unix sockets allow at most 107", len(path))
|
||||
}
|
||||
// Under systemd the directory is RuntimeDirectory=housecarl; elsewhere
|
||||
// make it.
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if fi, err := os.Lstat(path); err == nil {
|
||||
if fi.Mode()&fs.ModeSocket == 0 {
|
||||
return nil, fmt.Errorf("%s exists and isn't a socket", path)
|
||||
}
|
||||
// A socket nobody answers on is left over from a previous run.
|
||||
if c, err := net.Dial("unix", path); err == nil {
|
||||
c.Close()
|
||||
return nil, fmt.Errorf("%s is in use by another housecarld", path)
|
||||
}
|
||||
os.Remove(path)
|
||||
}
|
||||
ln, err := net.Listen("unix", path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.Chmod(path, 0o600); err != nil {
|
||||
ln.Close()
|
||||
return nil, err
|
||||
}
|
||||
return ln, nil
|
||||
}
|
||||
|
||||
// socketDenied explains a refused tokenless request on the socket.
|
||||
func socketDenied(p *peer) error {
|
||||
who := "an unknown user"
|
||||
if p.UID >= 0 {
|
||||
who = "uid " + strconv.Itoa(p.UID)
|
||||
if u, err := user.LookupId(strconv.Itoa(p.UID)); err == nil {
|
||||
who = u.Username
|
||||
}
|
||||
}
|
||||
return &httpError{http.StatusUnauthorized, "unauthenticated", "the local socket gives administrator rights without a token only to root and housecarld's own user, not to " + who + "; send an API token (--token), or run the command as root"}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"housecarl/internal/model"
|
||||
)
|
||||
|
||||
const changesetCols = `id, user_id, username, via, description, status, ops, created_at, updated_at, history_id`
|
||||
|
||||
func scanChangeset(sc interface{ Scan(...any) error }) (*model.Changeset, error) {
|
||||
cs := &model.Changeset{}
|
||||
var uid, hid sql.NullInt64
|
||||
var ops, created, updated string
|
||||
if err := sc.Scan(&cs.ID, &uid, &cs.Username, &cs.Via, &cs.Description, &cs.Status, &ops, &created, &updated, &hid); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cs.UserID, cs.HistoryID = uid.Int64, hid.Int64
|
||||
cs.CreatedAt, cs.UpdatedAt = parseTS(created), parseTS(updated)
|
||||
if err := json.Unmarshal([]byte(ops), &cs.Ops); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cs.Ops == nil {
|
||||
cs.Ops = []model.Op{}
|
||||
}
|
||||
return cs, nil
|
||||
}
|
||||
|
||||
// CreateChangeset saves a new changeset and sets its ID.
|
||||
func (s *Store) CreateChangeset(ctx context.Context, cs *model.Changeset) error {
|
||||
now := time.Now()
|
||||
cs.CreatedAt, cs.UpdatedAt = now, now
|
||||
if cs.Ops == nil {
|
||||
cs.Ops = []model.Op{}
|
||||
}
|
||||
ops, _ := json.Marshal(cs.Ops)
|
||||
res, err := s.db.ExecContext(ctx, `INSERT INTO changesets (user_id, username, via, description, status, ops, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, nullID(cs.UserID), cs.Username, cs.Via, cs.Description, cs.Status, string(ops), ts(now), ts(now))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cs.ID, err = res.LastInsertId()
|
||||
return err
|
||||
}
|
||||
|
||||
// GetChangeset loads one changeset.
|
||||
func (s *Store) GetChangeset(ctx context.Context, id int64) (*model.Changeset, error) {
|
||||
cs, err := scanChangeset(s.db.QueryRowContext(ctx, `SELECT `+changesetCols+` FROM changesets WHERE id = ?`, id))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return cs, err
|
||||
}
|
||||
|
||||
// ListChangesets lists changesets, newest first: of a status ("" any),
|
||||
// and of a user (0 anyone's).
|
||||
func (s *Store) ListChangesets(ctx context.Context, status string, userID int64, limit int) ([]*model.Changeset, error) {
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 100
|
||||
}
|
||||
q := `SELECT ` + changesetCols + ` FROM changesets WHERE 1=1`
|
||||
var args []any
|
||||
if status != "" {
|
||||
q += ` AND status = ?`
|
||||
args = append(args, status)
|
||||
}
|
||||
if userID != 0 {
|
||||
q += ` AND user_id = ?`
|
||||
args = append(args, userID)
|
||||
}
|
||||
q += ` ORDER BY id DESC LIMIT ?`
|
||||
args = append(args, limit)
|
||||
rows, err := s.db.QueryContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []*model.Changeset{}
|
||||
for rows.Next() {
|
||||
cs, err := scanChangeset(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, cs)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// UpdateChangeset saves a changeset's description, status, operations and
|
||||
// history entry.
|
||||
func (s *Store) UpdateChangeset(ctx context.Context, cs *model.Changeset) error {
|
||||
cs.UpdatedAt = time.Now()
|
||||
ops, _ := json.Marshal(cs.Ops)
|
||||
res, err := s.db.ExecContext(ctx, `UPDATE changesets SET description = ?, status = ?, ops = ?, updated_at = ?, history_id = ? WHERE id = ?`,
|
||||
cs.Description, cs.Status, string(ops), ts(cs.UpdatedAt), nullID(cs.HistoryID), cs.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func nullID(id int64) any {
|
||||
if id == 0 {
|
||||
return nil
|
||||
}
|
||||
return id
|
||||
}
|
||||
Loaded 100 of 132 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user