Build Housecarl 0.1.0: the nftables firewall manager
Zones, address sets (IPv4 and IPv6 mixed, dynamic sets with timeouts) and services, ordered rules in the early, input, forward and output chains, port forwards with hairpin NAT and IPv6 pinholes, source NAT, redirects, and switchable built-ins (loopback, conntrack, ICMPv6, antispoofing, MSS clamping, DHCPv6 client, flowtable). Every change is validated, checked with nft -c and applied in one transaction to Housecarl's own table, inet housecarl; changes made over the network roll back unless confirmed, and the confirmed ruleset is loaded at boot by housecarl-ruleset.service. An empty configuration applies nothing. Also: hit counters and the drop log (nflog), 'hc import' of an existing nftables ruleset and 'hc takeover', the hc CLI, the REST API and the web UI, users, scoped tokens (sets:write:NAME), the audit log, the web UI's certificate through Jarl, Debian packages, docs, unit tests with real nft 1.1.3, and an e2e test with real traffic (71 checks). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
54beb0bba3
commit
34292eeebb
118 files changed
+22165
-114
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:
|
||||
|
||||
+51
-5
@@ -6,14 +6,60 @@ 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.
|
||||
- **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 (`hc confirm`, a banner in the web UI);
|
||||
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).
|
||||
- **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), 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,97 @@ 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 (`jellyfin`, 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.
|
||||
* **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. 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 10.0.10.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 enp2s0
|
||||
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 is heading for its first release, 0.1.0: everything above
|
||||
works and is tested against real traffic, but it isn't released yet.
|
||||
|
||||
## 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,483 @@
|
||||
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()))
|
||||
})
|
||||
},
|
||||
})
|
||||
var input, forward, output, logDrops, probation 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 2a02:8013:7f3d::/48
|
||||
hc settings set --forward-policy drop --log-drops on
|
||||
hc settings set --flowtable wan --flowtable enp2s0`,
|
||||
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("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)")
|
||||
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 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 (trusted_ips, wg0_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"])
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVarP(&yes, "yes", "y", false, "don't ask")
|
||||
return cmd
|
||||
}
|
||||
+421
-14
@@ -1,42 +1,449 @@
|
||||
// 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
|
||||
)
|
||||
|
||||
// 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
|
||||
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.
|
||||
|
||||
A change made over the network is on probation: confirm it with 'hc
|
||||
confirm' (within 2 minutes by default) or it is rolled back by itself, so a
|
||||
change that cuts you off undoes itself. 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)")
|
||||
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(), logCmd(), interfaceCmd())
|
||||
add("changes", 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)
|
||||
} else if ip, ok := m["ip"]; ok {
|
||||
fmt.Println(ip)
|
||||
}
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
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.DryRun:
|
||||
fmt.Fprintln(w, "dry run: nothing was changed")
|
||||
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 {
|
||||
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 enp2s0`,
|
||||
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 10.0.10.0/24 10.20.30.0/24 2a02:8013:7f3d:10::/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 jellyfin tcp/8096 tcp/8920
|
||||
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
|
||||
}
|
||||
+621
@@ -0,0 +1,621 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"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 string
|
||||
connLimit 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, with ,burst=N ,over (beyond the rate) ,per-source")
|
||||
f.IntVar(&r.connLimit, "conn-limit", 0, "match a source with more than this many connections")
|
||||
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")
|
||||
}
|
||||
|
||||
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]), "/")
|
||||
n, err := strconv.Atoi(rate)
|
||||
if !ok || err != nil {
|
||||
return nil, fmt.Errorf("--limit %q: write it as 10/minute, with ,burst=N ,over ,per-source", s)
|
||||
}
|
||||
l := &model.Limit{Rate: n, Per: strings.TrimSuffix(per, "s")}
|
||||
for _, p := range parts[1:] {
|
||||
k, v, _ := strings.Cut(strings.TrimSpace(p), "=")
|
||||
switch k {
|
||||
case "burst":
|
||||
if l.Burst, err = strconv.Atoi(v); err != nil {
|
||||
return nil, fmt.Errorf("--limit: burst=%q isn't a number", v)
|
||||
}
|
||||
case "over":
|
||||
l.Over = true
|
||||
case "per-source":
|
||||
l.PerSource = true
|
||||
default:
|
||||
return nil, fmt.Errorf("--limit: unknown %q (burst=N, over, per-source)", k)
|
||||
}
|
||||
}
|
||||
return l, nil
|
||||
}
|
||||
|
||||
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)
|
||||
rule.Limit = l
|
||||
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 2a02:8013:7f3d:10::/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},
|
||||
} {
|
||||
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
|
||||
}
|
||||
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)
|
||||
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 {
|
||||
s := fmt.Sprintf("limit %d/%s", l.Rate, l.Per)
|
||||
if l.Over {
|
||||
s = "over " + s
|
||||
}
|
||||
if l.PerSource {
|
||||
s += " per source"
|
||||
}
|
||||
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 8096 --to 10.0.10.57:443 --to6 2a02:8013:7f3d:10::57 --public 217.155.8.194 --hairpin --name jellyfin
|
||||
hc forward add 7777-7780 --proto tcp,udp --to 10.0.10.76 --public 217.155.8.194 --hairpin --name terraria
|
||||
hc forward add 25 --to 10.0.10.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 fdab:abab:abab::/48 --family ipv6 --snat-to 2a02:8013:7f3d::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 10.0.10.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
|
||||
}
|
||||
+635
-9
@@ -1,17 +1,46 @@
|
||||
// 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"
|
||||
"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)
|
||||
@@ -21,19 +50,616 @@ func main() {
|
||||
|
||||
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).`,
|
||||
Use: "housecarld",
|
||||
Short: "Housecarl's firewall daemon, API and web UI",
|
||||
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.
|
||||
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)
|
||||
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")
|
||||
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
|
||||
+181
@@ -0,0 +1,181 @@
|
||||
# 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.
|
||||
* `?dry_run=1` checks the change, with nftables too, and returns what it
|
||||
would do, with `"applied": false, "dry_run": true`.
|
||||
* `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 /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:7f3d::/48"], "antispoof_v4": ["10.0.0.0/8", "..."], "antispoof_v6": ["fc00::/7", "..."],
|
||||
"flowtable_devices": ["wan", "enp2s0"], "probation_seconds": 120}
|
||||
```
|
||||
|
||||
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 /{kind}/{ref}/move` | `{"before": ID}`, `{"after": ID}` or `{"to": "start"\|"end"}`; a rule can take `"chain"` too |
|
||||
|
||||
The objects:
|
||||
|
||||
```json
|
||||
{"name": "lan", "interfaces": ["enp2s0", "enp2s0.*"], "external": false, "comment": ""}
|
||||
{"name": "trusted", "entries": ["10.0.10.0/24", "2001:db8:7f3d:10::/64"], "dynamic": false, "timeout": 0, "comment": ""}
|
||||
{"name": "jellyfin", "entries": ["tcp/8096", "tcp/8920"], "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, "per": "minute", "burst": 5, "over": true, "per_source": true}, "conn_limit": 0,
|
||||
"action": "drop", "reject_with": "", "log": true, "log_prefix": "",
|
||||
"raw": "", "comment": "", "disabled": false}
|
||||
{"name": "jellyfin", "protocols": ["tcp"], "ports": "8096", "public_addresses": ["203.0.113.7"], "sources": [],
|
||||
"to": "10.0.10.57", "to_port": "443", "to6": "2001:db8:7f3d:10::57", "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`).
|
||||
|
||||
### 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 |
|
||||
| `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: ["10.0.10.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.
|
||||
+193
@@ -0,0 +1,193 @@
|
||||
# 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 |
|
||||
| `-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:
|
||||
|
||||
```
|
||||
$ hc rule add input --from trusted --service ssh --comment "SSH from home"
|
||||
created rule input #31 (from trusted · ssh)
|
||||
applied on probation (history 12): confirm with 'hc confirm' within 2m0s, or it is rolled back
|
||||
$ hc confirm
|
||||
confirmed 1 change; it is kept and loaded at boot
|
||||
```
|
||||
|
||||
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; nftables checks it
|
||||
too.
|
||||
|
||||
## 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`) |
|
||||
| `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 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[,burst=N][,over][,per-source]`, `--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]` | 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: ["10.0.10.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.
|
||||
+100
-56
@@ -8,79 +8,123 @@ 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.
|
||||
|
||||
### Not locking yourself out
|
||||
|
||||
A firewall managed over the network can cut off its own manager. Housecarl
|
||||
should guard against that from the start:
|
||||
|
||||
* **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.
|
||||
from the same machinery as in Jarl and Steward.
|
||||
|
||||
## 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**: 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.
|
||||
|
||||
* 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`).
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
# 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 trusted_ips = { ... }` becomes the set `trusted_ips`).
|
||||
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.
|
||||
* **Masquerade and SNAT** become source NAT; **redirect** becomes a
|
||||
redirect.
|
||||
* **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 10.0.10.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 enp2s0
|
||||
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:7f3d::/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 8096 --to 10.0.10.57:443 --public 203.0.113.7 --hairpin --name jellyfin
|
||||
hc forward add 7777-7780 --proto tcp,udp --to 10.0.10.76 --public 203.0.113.7 --hairpin --name terraria
|
||||
hc forward add 25 --to 10.0.10.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 8096 --to 10.0.10.57:443 --to6 2001:db8:7f3d:10::57 --public 203.0.113.7 --name jellyfin
|
||||
```
|
||||
|
||||
IPv6 clients connect to `[2001:db8:7f3d:10::57]: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:abab::/48 --family ipv6 --snat-to 2001:db8:7f3d::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 10.0.10.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,139 @@
|
||||
# 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).
|
||||
|
||||
## Changes on probation
|
||||
|
||||
Changes made over the network roll back unless confirmed
|
||||
([design.md](design.md#not-locking-yourself-out)):
|
||||
|
||||
```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.
|
||||
|
||||
## 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.7:22 tcp syn
|
||||
10:02:15 hc forward policy drop: wan enp2s0 198.51.100.9:443 -> 10.0.10.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,86 @@
|
||||
# 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 (the web UI shows the forms locked) | change them, 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), confirmations and rollbacks, imports, takeovers and
|
||||
dynamic set entries are recorded with who, from where and how: `session`,
|
||||
`token` or `socket`.
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
# Zones, sets, services and rules
|
||||
|
||||
## Zones
|
||||
|
||||
A zone names one or more interfaces, so rules can say "from the LAN"
|
||||
rather than `enp2s0`. 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 enp2s0 --interface 'enp2s0.*'
|
||||
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 10.0.10.0/24 10.20.30.0/24 2001:db8:7f3d:10::/64
|
||||
hc set add dns-servers 10.0.10.5 2001:db8:7f3d:10::5 10.0.10.250-10.0.10.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 10.0.10.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:7f3d:30::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 jellyfin tcp/8096 tcp/8920
|
||||
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`, with `,burst=N`, `,over` (match beyond the rate, to drop a flood) and `,per-source` (a rate for each source address) |
|
||||
| `--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
|
||||
```
|
||||
|
||||
### 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:7f3d::/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 enp2s0`) 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 = { 10.0.10.0/24, 10.20.30.0/24 } }
|
||||
set trusted_v6 { type ipv6_addr; flags interval; elements = { 2001:db8:7f3d:10::/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,108 @@
|
||||
# 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 |
|
||||
| `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,30 @@ module housecarl
|
||||
|
||||
go 1.27.1
|
||||
|
||||
require github.com/spf13/cobra v1.10.2
|
||||
require (
|
||||
github.com/go-acme/lego/v4 v4.35.2
|
||||
github.com/miekg/dns v1.1.73
|
||||
github.com/spf13/cobra v1.10.2
|
||||
go.yaml.in/yaml/v3 v3.0.5
|
||||
golang.org/x/crypto v0.57.0
|
||||
golang.org/x/net v0.58.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/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
|
||||
github.com/spf13/pflag v1.0.9 // indirect
|
||||
golang.org/x/sys v0.48.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,82 @@
|
||||
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,276 @@
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
@@ -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,171 @@
|
||||
// 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"
|
||||
"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
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
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)
|
||||
}
|
||||
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,567 @@
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// 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}
|
||||
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 (enp2s0)", 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,175 @@
|
||||
package importer
|
||||
|
||||
import (
|
||||
"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)
|
||||
}
|
||||
}
|
||||
+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,250 @@
|
||||
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).
|
||||
ConnLimit int `json:"conn_limit,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 per Per (second, minute, hour, day), with
|
||||
// a burst.
|
||||
type Limit struct {
|
||||
Rate int `json:"rate"`
|
||||
Per string `json:"per"`
|
||||
Burst int `json:"burst,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.
|
||||
PerSource bool `json:"per_source,omitempty"`
|
||||
}
|
||||
|
||||
// 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,90 @@
|
||||
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"`
|
||||
}
|
||||
|
||||
// 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,431 @@
|
||||
// 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
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// 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{}}
|
||||
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["set"]; ok {
|
||||
var s jsonSet
|
||||
if json.Unmarshal(raw, &s) != nil {
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
if err := f.Runner.Apply(ctx, render.Render(st, opt)); err != nil {
|
||||
return err
|
||||
}
|
||||
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,127 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,838 @@
|
||||
// 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"
|
||||
"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
|
||||
}
|
||||
|
||||
// Element is a dynamic set's entry with its remaining lifetime.
|
||||
type Element struct {
|
||||
Addr string `json:"address"`
|
||||
Timeout int `json:"timeout,omitempty"` // seconds left (0: none)
|
||||
}
|
||||
|
||||
// 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-source limit and connection sets 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)
|
||||
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")
|
||||
}
|
||||
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)
|
||||
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))
|
||||
}
|
||||
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)
|
||||
r.policyTail("forward", st.Settings.ForwardPolicy)
|
||||
r.end()
|
||||
}
|
||||
|
||||
func (r *renderer) output() {
|
||||
st := r.st
|
||||
r.chain("output", "output", "filter", st.Settings.OutputPolicy)
|
||||
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)
|
||||
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
|
||||
|
||||
func (r *renderer) rules(chain string) {
|
||||
for _, rule := range r.st.Rules {
|
||||
if rule.Chain != chain || rule.Disabled {
|
||||
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)
|
||||
perSource := rule.ConnLimit > 0 || rule.Limit != nil && rule.Limit.PerSource
|
||||
for _, line := range r.expand(rule.Match, perSource) {
|
||||
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))
|
||||
}
|
||||
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, per source with a meter set per family.
|
||||
func (r *renderer) limit(rule *model.Rule, line expanded, l *model.Limit) string {
|
||||
rate := fmt.Sprintf("limit rate %s%d/%s", map[bool]string{true: "over ", false: ""}[l.Over], l.Rate, l.Per)
|
||||
if l.Burst > 0 {
|
||||
rate += fmt.Sprintf(" burst %d packets", l.Burst)
|
||||
}
|
||||
if !l.PerSource {
|
||||
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("add @%s { %s saddr %s }", name, ipKeyword(line.fam), rate)
|
||||
}
|
||||
|
||||
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 saddr ct count over %d }", name, ipKeyword(line.fam), rule.ConnLimit)
|
||||
}
|
||||
|
||||
func (r *renderer) hasMeter(name string) bool {
|
||||
for _, m := range r.meters {
|
||||
if strings.Contains(m, "set "+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,180 @@
|
||||
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"}}})
|
||||
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
|
||||
}
|
||||
|
||||
func TestRenderRouter(t *testing.T) {
|
||||
rs := Render(router(), Options{Elements: map[string][]Element{"guests": {{Addr: "10.0.30.5", Timeout: 1700}}}})
|
||||
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 add @meter_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)
|
||||
}
|
||||
}
|
||||
// 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()} {
|
||||
rs := Render(st, Options{Elements: map[string][]Element{"guests": {{Addr: "10.0.30.5", Timeout: 1700}, {Addr: "2a02:8013:7f3d:30::5"}}}})
|
||||
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,588 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"housecarl/internal/api"
|
||||
"housecarl/internal/auth"
|
||||
"housecarl/internal/engine"
|
||||
"housecarl/internal/model"
|
||||
"housecarl/internal/store"
|
||||
)
|
||||
|
||||
// change applies an edit through the engine and answers it; object picks
|
||||
// what to return from the new state. A network change starts (or joins)
|
||||
// the probation.
|
||||
func (s *Server) change(w http.ResponseWriter, r *http.Request, p *auth.Principal, description string, edit engine.Edit, object func(st *model.State) any) error {
|
||||
if err := require(p, auth.ResConfig, true); err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := s.eng.Apply(r.Context(), p, description, engine.Options{DryRun: queryBool(r, "dry_run"), Via: via(r, p)}, edit)
|
||||
if err != nil {
|
||||
s.writeErr(w, r, err, res)
|
||||
return nil
|
||||
}
|
||||
s.afterChange(r.Context(), p, res)
|
||||
// POST to a collection creates; POST to an object (move) changes it.
|
||||
s.respondChange(w, r, res, object, r.Method == http.MethodPost && r.PathValue("ref") == "")
|
||||
return nil
|
||||
}
|
||||
|
||||
// respondChange answers a change: 201 when it created something.
|
||||
func (s *Server) respondChange(w http.ResponseWriter, r *http.Request, res *engine.Result, object func(st *model.State) any, creates bool) {
|
||||
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.Applied || res.DryRun) {
|
||||
if v := object(s.eng.State()); v != nil {
|
||||
out.Object, _ = json.Marshal(v)
|
||||
}
|
||||
}
|
||||
status := http.StatusOK
|
||||
if res.Applied && creates {
|
||||
status = http.StatusCreated
|
||||
}
|
||||
writeJSON(w, status, out)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
st := s.eng.State()
|
||||
out := []any{}
|
||||
q := r.URL.Query()
|
||||
for _, o := range st.Objects(kind) {
|
||||
if c := q.Get("chain"); 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)
|
||||
}
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
return out[i].(*model.Service).Name < out[j].(*model.Service).Name
|
||||
})
|
||||
}
|
||||
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 := s.eng.State()
|
||||
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
|
||||
}
|
||||
o, err := decodeObject(kind, raw)
|
||||
if err != nil {
|
||||
return 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"}
|
||||
}
|
||||
var id int64
|
||||
return s.change(w, r, p, "", func(st *model.State, newID func() int64) error {
|
||||
id = newID()
|
||||
setID(o, id)
|
||||
if pos := position(o); pos != nil && *pos == 0 {
|
||||
*pos = lastPosition(st, kind, chainOf(o))
|
||||
}
|
||||
data, _ := json.Marshal(o)
|
||||
return st.Put(kind, id, data)
|
||||
}, func(st *model.State) any { return st.ByID(kind, id) })
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
delete(patch, "id")
|
||||
var id int64
|
||||
return s.change(w, r, p, "", func(st *model.State, _ func() int64) error {
|
||||
o, err := findObject(st, kind, r.PathValue("ref"))
|
||||
if err != nil {
|
||||
return 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(kind, merged)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
setID(next, id)
|
||||
data, _ := json.Marshal(next)
|
||||
return st.Put(kind, id, data)
|
||||
}, func(st *model.State) any { return st.ByID(kind, id) })
|
||||
}
|
||||
|
||||
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, "", func(st *model.State, _ func() int64) error {
|
||||
o, err := findObject(st, kind, r.PathValue("ref"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if used := usedBy(st, kind, o); used != "" {
|
||||
return conflict("%s %s is used by %s; change that first", kind, o.Label(), used)
|
||||
}
|
||||
return st.Put(kind, o.GetID(), nil)
|
||||
}, nil)
|
||||
}
|
||||
|
||||
// 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: before or after another, or to
|
||||
// the start or end; a rule can change chain at the same time.
|
||||
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
|
||||
}
|
||||
var id int64
|
||||
return s.change(w, r, p, "", func(st *model.State, _ func() int64) error {
|
||||
o, err := findObject(st, kind, r.PathValue("ref"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id = o.GetID()
|
||||
pos := position(o)
|
||||
if pos == nil {
|
||||
return 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(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, kind)
|
||||
}
|
||||
switch {
|
||||
case in.Before != 0:
|
||||
i, err := index(in.Before)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
place(i)
|
||||
case in.After != 0:
|
||||
i, err := index(in.After)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
place(i + 1)
|
||||
case in.To == "start":
|
||||
place(0)
|
||||
case in.To == "end" || in.Chain != "":
|
||||
place(len(others))
|
||||
default:
|
||||
return badRequest("say where: before, after, or to start or end")
|
||||
}
|
||||
data, _ := json.Marshal(o)
|
||||
return st.Put(kind, id, data)
|
||||
}, func(st *model.State) any { return st.ByID(kind, id) })
|
||||
}
|
||||
|
||||
// ---- 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
|
||||
}
|
||||
writeJSON(w, http.StatusOK, s.eng.State().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
|
||||
}
|
||||
return s.change(w, r, p, "", func(st *model.State, _ func() int64) error {
|
||||
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
|
||||
}, func(st *model.State) any { return st.Settings })
|
||||
}
|
||||
|
||||
// ---- 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
|
||||
}
|
||||
rs := s.fw.Render(s.eng.State())
|
||||
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
|
||||
}
|
||||
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, 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
|
||||
}
|
||||
n, err := s.confirm(r.Context(), p)
|
||||
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,147 @@
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
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)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"deleted": done, "message": msg})
|
||||
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,178 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"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
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
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.
|
||||
func (s *Server) confirm(ctx context.Context, p *auth.Principal) (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 = nil, nil, time.Time{}, nil
|
||||
s.saveProbation(ctx)
|
||||
s.saveBoot()
|
||||
s.eng.Audit(ctx, p, "config.confirm", "", "success", fmt.Sprintf("%d changes confirmed", n))
|
||||
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 = nil, nil, time.Time{}, 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,192 @@
|
||||
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 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,123 @@
|
||||
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+"/{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,470 @@
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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
|
||||
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,540 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"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
|
||||
}
|
||||
|
||||
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) 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)
|
||||
}
|
||||
}
|
||||
@@ -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,65 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
"housecarl/internal/model"
|
||||
)
|
||||
|
||||
func scanHistory(sc interface{ Scan(...any) error }) (*model.HistoryEntry, error) {
|
||||
e := &model.HistoryEntry{}
|
||||
var t, changes string
|
||||
var uid, revertOf, revertedBy sql.NullInt64
|
||||
if err := sc.Scan(&e.ID, &t, &uid, &e.Username, &e.Via, &e.Description, &changes, &revertOf, &revertedBy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e.Time = parseTS(t)
|
||||
e.UserID, e.RevertOf, e.RevertedBy = uid.Int64, revertOf.Int64, revertedBy.Int64
|
||||
if err := json.Unmarshal([]byte(changes), &e.Changes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
|
||||
const historyCols = `id, time, user_id, username, via, description, changes, revert_of, reverted_by`
|
||||
|
||||
// ListHistory lists entries, newest first; before > 0 pages back.
|
||||
func (s *Store) ListHistory(ctx context.Context, limit int, before int64) ([]*model.HistoryEntry, error) {
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 100
|
||||
}
|
||||
q := `SELECT ` + historyCols + ` FROM history`
|
||||
var args []any
|
||||
if before > 0 {
|
||||
q += ` WHERE id < ?`
|
||||
args = append(args, before)
|
||||
}
|
||||
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.HistoryEntry{}
|
||||
for rows.Next() {
|
||||
e, err := scanHistory(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetHistory loads one entry.
|
||||
func (s *Store) GetHistory(ctx context.Context, id int64) (*model.HistoryEntry, error) {
|
||||
e, err := scanHistory(s.db.QueryRowContext(ctx, `SELECT `+historyCols+` FROM history WHERE id = ?`, id))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return e, err
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"housecarl/internal/model"
|
||||
)
|
||||
|
||||
const settingsKey = "settings"
|
||||
|
||||
// LoadState reads the whole configuration.
|
||||
func (s *Store) LoadState(ctx context.Context) (*model.State, error) {
|
||||
st := model.NewState()
|
||||
if v, err := s.GetMeta(ctx, settingsKey); err != nil {
|
||||
return nil, err
|
||||
} else if v != "" {
|
||||
if err := json.Unmarshal([]byte(v), &st.Settings); err != nil {
|
||||
return nil, fmt.Errorf("reading settings: %w", err)
|
||||
}
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT kind, id, data FROM objects ORDER BY kind, id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var kind, data string
|
||||
var id int64
|
||||
if err := rows.Scan(&kind, &id, &data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := st.Put(kind, id, json.RawMessage(data)); err != nil {
|
||||
return nil, fmt.Errorf("reading %s %d: %w", kind, id, err)
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
st.Sort()
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// entry is marked reverted.
|
||||
func (s *Store) SaveChanges(ctx context.Context, changes []model.Change, e *model.HistoryEntry) error {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
for _, c := range changes {
|
||||
if err := applyChange(ctx, tx, c); err != nil {
|
||||
return fmt.Errorf("saving %s %d: %w", c.Kind, c.ID, err)
|
||||
}
|
||||
}
|
||||
if e != nil {
|
||||
var uid any
|
||||
if e.UserID != 0 {
|
||||
uid = e.UserID
|
||||
}
|
||||
var revertOf any
|
||||
if e.RevertOf != 0 {
|
||||
revertOf = e.RevertOf
|
||||
}
|
||||
res, err := tx.ExecContext(ctx, `INSERT INTO history (time, user_id, username, via, description, changes, revert_of) VALUES (?,?,?,?,?,?,?)`,
|
||||
ts(e.Time), uid, e.Username, e.Via, e.Description, jsonText(e.Changes), revertOf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if e.ID, err = res.LastInsertId(); err != nil {
|
||||
return err
|
||||
}
|
||||
if e.RevertOf != 0 {
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE history SET reverted_by = ? WHERE id = ?`, e.ID, e.RevertOf); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func applyChange(ctx context.Context, tx *sql.Tx, c model.Change) error {
|
||||
if c.Kind == model.KindSettings {
|
||||
if c.After == nil {
|
||||
return fmt.Errorf("settings can't be deleted")
|
||||
}
|
||||
_, err := tx.ExecContext(ctx, `INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`, settingsKey, string(c.After))
|
||||
return err
|
||||
}
|
||||
if c.After == nil {
|
||||
_, err := tx.ExecContext(ctx, `DELETE FROM objects WHERE kind = ? AND id = ?`, c.Kind, c.ID)
|
||||
return err
|
||||
}
|
||||
_, err := tx.ExecContext(ctx, `INSERT INTO objects (kind, id, data) VALUES (?, ?, ?)
|
||||
ON CONFLICT(kind, id) DO UPDATE SET data = excluded.data`, c.Kind, c.ID, string(c.After))
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
// Package store keeps Housecarl's data in SQLite: the configuration, the
|
||||
// history of changes, users, tokens, sessions and the audit log.
|
||||
//
|
||||
// The configuration is loaded into a model.State as a whole and saved as
|
||||
// changes (model.Change), one JSON row per object.
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// ErrNotFound is returned when a row does not exist.
|
||||
var ErrNotFound = errors.New("not found")
|
||||
|
||||
// ErrConflict is returned for unique constraint violations.
|
||||
var ErrConflict = errors.New("already exists")
|
||||
|
||||
// Store is a Housecarl database.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// DB exposes the database (backups, tests).
|
||||
func (s *Store) DB() *sql.DB { return s.db }
|
||||
|
||||
// Path is the database file ("" in memory).
|
||||
func (s *Store) Path() string {
|
||||
var file string
|
||||
if err := s.db.QueryRow(`SELECT file FROM pragma_database_list WHERE name = 'main'`).Scan(&file); err != nil {
|
||||
return ""
|
||||
}
|
||||
return file
|
||||
}
|
||||
|
||||
// Open opens (creating if needed) the database at path and applies
|
||||
// migrations. ":memory:" makes a throwaway database.
|
||||
func Open(path string) (*Store, error) {
|
||||
dsn := "file:" + path + "?_pragma=busy_timeout(10000)&_pragma=foreign_keys(1)&_pragma=synchronous(NORMAL)&_txlock=immediate&_pragma=journal_mode(WAL)"
|
||||
if path == ":memory:" {
|
||||
dsn = "file::memory:?_pragma=foreign_keys(1)&_txlock=immediate"
|
||||
}
|
||||
db, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if path == ":memory:" {
|
||||
db.SetMaxOpenConns(1)
|
||||
}
|
||||
s := &Store{db: db}
|
||||
if err := s.migrate(context.Background()); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("migrating database: %w", err)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// OpenExisting opens a database without creating or migrating it, for
|
||||
// commands that run next to a running housecarld ('housecarld backup').
|
||||
func OpenExisting(path string) (*Store, error) {
|
||||
db, err := sql.Open("sqlite", "file:"+path+"?mode=rw&_pragma=busy_timeout(10000)&_pragma=foreign_keys(1)&_txlock=immediate")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := db.Ping(); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return &Store{db: db}, nil
|
||||
}
|
||||
|
||||
// Close closes the database.
|
||||
func (s *Store) Close() error { return s.db.Close() }
|
||||
|
||||
// SchemaVersion is the schema version this Housecarl uses.
|
||||
func SchemaVersion() int { return len(migrations) }
|
||||
|
||||
// Append new migrations; never edit one that has shipped.
|
||||
var migrations = []string{
|
||||
// 1: initial schema
|
||||
`
|
||||
CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
||||
CREATE TABLE seq (name TEXT PRIMARY KEY, value INTEGER NOT NULL);
|
||||
INSERT INTO seq (name, value) VALUES ('object', 0);
|
||||
|
||||
-- The configuration: one row per object (zone, set, service, rule,
|
||||
-- forward, snat, redirect), as JSON. The settings are in meta.
|
||||
CREATE TABLE objects (
|
||||
kind TEXT NOT NULL,
|
||||
id INTEGER NOT NULL,
|
||||
data TEXT NOT NULL,
|
||||
PRIMARY KEY (kind, id)
|
||||
);
|
||||
|
||||
CREATE TABLE history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
time TEXT NOT NULL,
|
||||
user_id INTEGER,
|
||||
username TEXT NOT NULL,
|
||||
via TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
changes TEXT NOT NULL,
|
||||
revert_of INTEGER,
|
||||
reverted_by INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||
password_hash TEXT NOT NULL DEFAULT '',
|
||||
admin INTEGER NOT NULL DEFAULT 0,
|
||||
grants TEXT NOT NULL DEFAULT '[]',
|
||||
disabled INTEGER NOT NULL DEFAULT 0,
|
||||
comment TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
last_login_at TEXT
|
||||
);
|
||||
CREATE TABLE tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
public_id TEXT NOT NULL UNIQUE,
|
||||
secret_hash TEXT NOT NULL,
|
||||
grants TEXT,
|
||||
expires_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
last_used_at TEXT,
|
||||
last_used_ip TEXT NOT NULL DEFAULT '',
|
||||
revoked_at TEXT,
|
||||
rotated_at TEXT,
|
||||
prev_secret_hash TEXT NOT NULL DEFAULT '',
|
||||
prev_expires_at TEXT
|
||||
);
|
||||
CREATE TABLE sessions (
|
||||
id_hash TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
csrf_token TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
last_seen_at TEXT NOT NULL,
|
||||
ip TEXT NOT NULL DEFAULT '',
|
||||
user_agent TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE TABLE audit (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
time TEXT NOT NULL,
|
||||
user_id INTEGER,
|
||||
username TEXT NOT NULL DEFAULT '',
|
||||
auth TEXT NOT NULL DEFAULT '',
|
||||
token_id INTEGER,
|
||||
ip TEXT NOT NULL DEFAULT '',
|
||||
action TEXT NOT NULL,
|
||||
object TEXT NOT NULL DEFAULT '',
|
||||
result TEXT NOT NULL,
|
||||
detail TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE INDEX audit_time ON audit (time);
|
||||
`,
|
||||
}
|
||||
|
||||
func (s *Store) migrate(ctx context.Context) error {
|
||||
if _, err := s.db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL)`); err != nil {
|
||||
return err
|
||||
}
|
||||
var v int
|
||||
err := s.db.QueryRowContext(ctx, `SELECT version FROM schema_version`).Scan(&v)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
if _, err := s.db.ExecContext(ctx, `INSERT INTO schema_version (version) VALUES (0)`); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
if v > len(migrations) {
|
||||
return fmt.Errorf("database schema version %d is newer than this Housecarl (%d); upgrade Housecarl", v, len(migrations))
|
||||
}
|
||||
for i := v; i < len(migrations); i++ {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, migrations[i]); err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("migration %d: %w", i+1, err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE schema_version SET version = ?`, i+1); err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Version returns the database's schema version.
|
||||
func (s *Store) Version(ctx context.Context) (int, error) {
|
||||
var v int
|
||||
err := s.db.QueryRowContext(ctx, `SELECT version FROM schema_version`).Scan(&v)
|
||||
return v, err
|
||||
}
|
||||
|
||||
// AllocateIDs reserves n object IDs (all kinds share one sequence, so an
|
||||
// ID names one object).
|
||||
func (s *Store) AllocateIDs(ctx context.Context, n int) ([]int64, error) {
|
||||
if n <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var last int64
|
||||
if err := s.db.QueryRowContext(ctx, `UPDATE seq SET value = value + ? WHERE name = 'object' RETURNING value`, n).Scan(&last); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids := make([]int64, n)
|
||||
for i := range ids {
|
||||
ids[i] = last - int64(n) + 1 + int64(i)
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// GetMeta reads a meta value ("" when unset).
|
||||
func (s *Store) GetMeta(ctx context.Context, key string) (string, error) {
|
||||
var v string
|
||||
err := s.db.QueryRowContext(ctx, `SELECT value FROM meta WHERE key = ?`, key).Scan(&v)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", nil
|
||||
}
|
||||
return v, err
|
||||
}
|
||||
|
||||
// SetMeta writes a meta value.
|
||||
func (s *Store) SetMeta(ctx context.Context, key, value string) error {
|
||||
_, err := s.db.ExecContext(ctx, `INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`, key, value)
|
||||
return err
|
||||
}
|
||||
|
||||
// Backup writes a consistent copy of the database to path.
|
||||
func (s *Store) Backup(ctx context.Context, path string) error {
|
||||
_, err := s.db.ExecContext(ctx, `VACUUM INTO ?`, path)
|
||||
return err
|
||||
}
|
||||
|
||||
// ---- helpers
|
||||
|
||||
const tsLayout = "2006-01-02T15:04:05.000000000Z07:00"
|
||||
|
||||
func ts(t time.Time) string { return t.UTC().Format(tsLayout) }
|
||||
|
||||
func tsPtr(t *time.Time) any {
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
return ts(*t)
|
||||
}
|
||||
|
||||
func parseTS(s string) time.Time {
|
||||
t, err := time.Parse(time.RFC3339Nano, s)
|
||||
if err != nil {
|
||||
return time.Time{}
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func parseTSPtr(s sql.NullString) *time.Time {
|
||||
if !s.Valid || s.String == "" {
|
||||
return nil
|
||||
}
|
||||
t := parseTS(s.String)
|
||||
return &t
|
||||
}
|
||||
|
||||
func boolInt(b bool) int {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func jsonText(v any) string {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func isUnique(err error) bool {
|
||||
return err != nil && strings.Contains(err.Error(), "UNIQUE constraint failed")
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"housecarl/internal/model"
|
||||
)
|
||||
|
||||
func raw(v any) json.RawMessage {
|
||||
b, _ := json.Marshal(v)
|
||||
return b
|
||||
}
|
||||
|
||||
func TestStateAndHistory(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, err := Open(filepath.Join(t.TempDir(), "housecarl.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Close()
|
||||
st, err := s.LoadState(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st.Settings.InputPolicy != model.PolicyDrop || len(st.Zones) != 0 || !st.Settings.Builtins.Established {
|
||||
t.Fatalf("new database state: %+v", st)
|
||||
}
|
||||
ids, _ := s.AllocateIDs(ctx, 2)
|
||||
z := &model.Zone{ID: ids[0], Name: "wan", Interfaces: []string{"wan"}, External: true}
|
||||
r := &model.Rule{ID: ids[1], Chain: model.ChainInput, Position: 1, Action: model.ActionAccept,
|
||||
Match: model.Match{Services: []string{"ssh"}, Sources: []string{"10.0.10.0/24"}}}
|
||||
set := st.Settings
|
||||
set.ForwardPolicy = model.PolicyAccept
|
||||
e := &model.HistoryEntry{Time: time.Now(), Username: "admin", Via: "cli", Description: "add wan",
|
||||
Changes: []model.Change{
|
||||
{Kind: model.KindZone, ID: z.ID, After: raw(z)},
|
||||
{Kind: model.KindRule, ID: r.ID, After: raw(r)},
|
||||
{Kind: model.KindSettings, Before: raw(st.Settings), After: raw(set)},
|
||||
}}
|
||||
if err := s.SaveChanges(ctx, e.Changes, e); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st, _ = s.LoadState(ctx)
|
||||
if len(st.Zones) != 1 || !st.Zones[0].External || st.Zones[0].Interfaces[0] != "wan" {
|
||||
t.Errorf("zones %+v", st.Zones)
|
||||
}
|
||||
if len(st.Rules) != 1 || st.Rules[0].Services[0] != "ssh" || st.Settings.ForwardPolicy != model.PolicyAccept {
|
||||
t.Errorf("rules %+v settings %+v", st.Rules, st.Settings)
|
||||
}
|
||||
// Undo: the inverse changes, marked as a revert.
|
||||
var inv []model.Change
|
||||
for _, c := range e.Changes {
|
||||
inv = append(inv, c.Inverse())
|
||||
}
|
||||
u := &model.HistoryEntry{Time: time.Now(), Username: "admin", Via: "web", Description: "undo", Changes: inv, RevertOf: e.ID}
|
||||
if err := s.SaveChanges(ctx, inv, u); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st, _ = s.LoadState(ctx)
|
||||
if len(st.Zones) != 0 || len(st.Rules) != 0 || st.Settings.ForwardPolicy != model.PolicyDrop {
|
||||
t.Errorf("after undo: %+v", st)
|
||||
}
|
||||
h, err := s.GetHistory(ctx, e.ID)
|
||||
if err != nil || h.RevertedBy != u.ID || len(h.Changes) != 3 {
|
||||
t.Errorf("history %+v %v", h, err)
|
||||
}
|
||||
list, _ := s.ListHistory(ctx, 10, 0)
|
||||
if len(list) != 2 || list[0].ID != u.ID {
|
||||
t.Errorf("history list %+v", list)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsersAndAudit(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, err := Open(":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
u := &model.User{Username: "admin", PasswordHash: "x", Admin: true}
|
||||
if err := s.CreateUser(ctx, u); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.CreateUser(ctx, &model.User{Username: "ADMIN"}); !errors.Is(err, ErrConflict) {
|
||||
t.Errorf("duplicate user: %v", err)
|
||||
}
|
||||
tok := &model.Token{UserID: u.ID, Name: "ci", PublicID: "p1", SecretHash: "h"}
|
||||
if err := s.CreateToken(ctx, tok); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := s.GetTokenByPublicID(ctx, "p1")
|
||||
if err != nil || got.Username != "admin" || got.Grants != nil {
|
||||
t.Errorf("token %+v %v", got, err)
|
||||
}
|
||||
s.AddAudit(ctx, &model.AuditEvent{Username: "admin", Action: "auth.login", Result: "success"})
|
||||
s.AddAudit(ctx, &model.AuditEvent{Username: "admin", Action: "set.add", Object: "guest 10.0.30.5", Result: "success"})
|
||||
evs, _ := s.ListAudit(ctx, AuditFilter{Action: "set."})
|
||||
if len(evs) != 1 || evs[0].Object != "guest 10.0.30.5" {
|
||||
t.Errorf("audit %+v", evs)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"housecarl/internal/model"
|
||||
)
|
||||
|
||||
const userCols = `id, username, password_hash, admin, grants, disabled, comment, created_at, updated_at, last_login_at`
|
||||
|
||||
func scanUser(sc interface{ Scan(...any) error }) (*model.User, error) {
|
||||
u := &model.User{}
|
||||
var grants, ca, ua string
|
||||
var admin, disabled int
|
||||
var ll sql.NullString
|
||||
if err := sc.Scan(&u.ID, &u.Username, &u.PasswordHash, &admin, &grants, &disabled, &u.Comment, &ca, &ua, &ll); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u.Admin, u.Disabled = admin != 0, disabled != 0
|
||||
u.HasPassword = u.PasswordHash != ""
|
||||
if err := json.Unmarshal([]byte(grants), &u.Grants); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if u.Grants == nil {
|
||||
u.Grants = []model.Grant{}
|
||||
}
|
||||
u.CreatedAt, u.UpdatedAt, u.LastLoginAt = parseTS(ca), parseTS(ua), parseTSPtr(ll)
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// CreateUser inserts a user.
|
||||
func (s *Store) CreateUser(ctx context.Context, u *model.User) error {
|
||||
now := time.Now()
|
||||
u.CreatedAt, u.UpdatedAt = now, now
|
||||
if u.Grants == nil {
|
||||
u.Grants = []model.Grant{}
|
||||
}
|
||||
res, err := s.db.ExecContext(ctx, `INSERT INTO users (username, password_hash, admin, grants, disabled, comment, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)`,
|
||||
u.Username, u.PasswordHash, boolInt(u.Admin), jsonText(u.Grants), boolInt(u.Disabled), u.Comment, ts(now), ts(now))
|
||||
if isUnique(err) {
|
||||
return ErrConflict
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
u.ID, err = res.LastInsertId()
|
||||
u.HasPassword = u.PasswordHash != ""
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateUser saves a user.
|
||||
func (s *Store) UpdateUser(ctx context.Context, u *model.User) error {
|
||||
u.UpdatedAt = time.Now()
|
||||
res, err := s.db.ExecContext(ctx, `UPDATE users SET username = ?, password_hash = ?, admin = ?, grants = ?, disabled = ?, comment = ?, updated_at = ? WHERE id = ?`,
|
||||
u.Username, u.PasswordHash, boolInt(u.Admin), jsonText(u.Grants), boolInt(u.Disabled), u.Comment, ts(u.UpdatedAt), u.ID)
|
||||
if isUnique(err) {
|
||||
return ErrConflict
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
u.HasPassword = u.PasswordHash != ""
|
||||
return nil
|
||||
}
|
||||
|
||||
// TouchLogin records a successful login.
|
||||
func (s *Store) TouchLogin(ctx context.Context, id int64) error {
|
||||
_, err := s.db.ExecContext(ctx, `UPDATE users SET last_login_at = ? WHERE id = ?`, ts(time.Now()), id)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetUser loads a user by ID.
|
||||
func (s *Store) GetUser(ctx context.Context, id int64) (*model.User, error) {
|
||||
u, err := scanUser(s.db.QueryRowContext(ctx, `SELECT `+userCols+` FROM users WHERE id = ?`, id))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return u, err
|
||||
}
|
||||
|
||||
// GetUserByName loads a user by name (case-insensitive).
|
||||
func (s *Store) GetUserByName(ctx context.Context, name string) (*model.User, error) {
|
||||
u, err := scanUser(s.db.QueryRowContext(ctx, `SELECT `+userCols+` FROM users WHERE username = ?`, name))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return u, err
|
||||
}
|
||||
|
||||
// ListUsers lists all users by name.
|
||||
func (s *Store) ListUsers(ctx context.Context) ([]*model.User, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT `+userCols+` FROM users ORDER BY username COLLATE NOCASE`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*model.User
|
||||
for rows.Next() {
|
||||
u, err := scanUser(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, u)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// CountAdmins counts the enabled administrators.
|
||||
func (s *Store) CountAdmins(ctx context.Context) (int, error) {
|
||||
var n int
|
||||
err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE admin = 1 AND disabled = 0`).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// DeleteUser removes a user with their tokens and sessions.
|
||||
func (s *Store) DeleteUser(ctx context.Context, id int64) error {
|
||||
res, err := s.db.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const tokenCols = `t.id, t.user_id, u.username, t.name, t.public_id, t.secret_hash, t.grants, t.expires_at, t.created_at, t.last_used_at, t.last_used_ip, t.revoked_at, t.rotated_at, t.prev_secret_hash, t.prev_expires_at`
|
||||
|
||||
func scanToken(sc interface{ Scan(...any) error }) (*model.Token, error) {
|
||||
t := &model.Token{}
|
||||
var grants, exp, lu, rev, rot, pexp sql.NullString
|
||||
var ca string
|
||||
if err := sc.Scan(&t.ID, &t.UserID, &t.Username, &t.Name, &t.PublicID, &t.SecretHash, &grants, &exp, &ca, &lu, &t.LastUsedIP, &rev, &rot, &t.PrevSecretHash, &pexp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if grants.Valid {
|
||||
if err := json.Unmarshal([]byte(grants.String), &t.Grants); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if t.Grants == nil {
|
||||
t.Grants = []model.Grant{}
|
||||
}
|
||||
}
|
||||
t.ExpiresAt, t.LastUsedAt, t.RevokedAt = parseTSPtr(exp), parseTSPtr(lu), parseTSPtr(rev)
|
||||
t.RotatedAt, t.PrevExpiresAt = parseTSPtr(rot), parseTSPtr(pexp)
|
||||
t.CreatedAt = parseTS(ca)
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// CreateToken inserts a token.
|
||||
func (s *Store) CreateToken(ctx context.Context, t *model.Token) error {
|
||||
t.CreatedAt = time.Now()
|
||||
var grants any
|
||||
if t.Grants != nil {
|
||||
grants = jsonText(t.Grants)
|
||||
}
|
||||
res, err := s.db.ExecContext(ctx, `INSERT INTO tokens (user_id, name, public_id, secret_hash, grants, expires_at, created_at) VALUES (?,?,?,?,?,?,?)`,
|
||||
t.UserID, t.Name, t.PublicID, t.SecretHash, grants, tsPtr(t.ExpiresAt), ts(t.CreatedAt))
|
||||
if isUnique(err) {
|
||||
return ErrConflict
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
t.ID, err = res.LastInsertId()
|
||||
return err
|
||||
}
|
||||
|
||||
// GetTokenByPublicID loads a token by its public ID.
|
||||
func (s *Store) GetTokenByPublicID(ctx context.Context, pid string) (*model.Token, error) {
|
||||
t, err := scanToken(s.db.QueryRowContext(ctx, `SELECT `+tokenCols+` FROM tokens t JOIN users u ON u.id = t.user_id WHERE t.public_id = ?`, pid))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return t, err
|
||||
}
|
||||
|
||||
// GetToken loads a token by ID.
|
||||
func (s *Store) GetToken(ctx context.Context, id int64) (*model.Token, error) {
|
||||
t, err := scanToken(s.db.QueryRowContext(ctx, `SELECT `+tokenCols+` FROM tokens t JOIN users u ON u.id = t.user_id WHERE t.id = ?`, id))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return t, err
|
||||
}
|
||||
|
||||
// ListTokens lists the tokens of one user (userID > 0) or of all.
|
||||
func (s *Store) ListTokens(ctx context.Context, userID int64) ([]*model.Token, error) {
|
||||
q := `SELECT ` + tokenCols + ` FROM tokens t JOIN users u ON u.id = t.user_id`
|
||||
var args []any
|
||||
if userID > 0 {
|
||||
q += ` WHERE t.user_id = ?`
|
||||
args = append(args, userID)
|
||||
}
|
||||
q += ` ORDER BY t.id`
|
||||
rows, err := s.db.QueryContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*model.Token
|
||||
for rows.Next() {
|
||||
t, err := scanToken(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// RevokeToken marks a token revoked.
|
||||
func (s *Store) RevokeToken(ctx context.Context, id int64) error {
|
||||
res, err := s.db.ExecContext(ctx, `UPDATE tokens SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL`, ts(time.Now()), id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RotateToken gives a token a new secret and expiry. With prevUntil set,
|
||||
// the old secret keeps working until then; otherwise it stops at once. A
|
||||
// revoked token can't be rotated (ErrNotFound).
|
||||
func (s *Store) RotateToken(ctx context.Context, id int64, secretHash string, expiresAt, prevUntil *time.Time) error {
|
||||
prevHash := `''`
|
||||
if prevUntil != nil {
|
||||
prevHash = `secret_hash`
|
||||
}
|
||||
res, err := s.db.ExecContext(ctx, `UPDATE tokens SET prev_secret_hash = `+prevHash+`, prev_expires_at = ?, secret_hash = ?, expires_at = ?, rotated_at = ? WHERE id = ? AND revoked_at IS NULL`,
|
||||
tsPtr(prevUntil), secretHash, tsPtr(expiresAt), ts(time.Now()), id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TouchToken records a token's use.
|
||||
func (s *Store) TouchToken(ctx context.Context, id int64, ip string) error {
|
||||
_, err := s.db.ExecContext(ctx, `UPDATE tokens SET last_used_at = ?, last_used_ip = ? WHERE id = ?`, ts(time.Now()), ip, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// CreateSession stores a session.
|
||||
func (s *Store) CreateSession(ctx context.Context, se *model.Session) error {
|
||||
_, err := s.db.ExecContext(ctx, `INSERT INTO sessions (id_hash, user_id, csrf_token, created_at, expires_at, last_seen_at, ip, user_agent) VALUES (?,?,?,?,?,?,?,?)`,
|
||||
se.IDHash, se.UserID, se.CSRFToken, ts(se.CreatedAt), ts(se.ExpiresAt), ts(se.LastSeenAt), se.IP, se.UserAgent)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetSession loads a session by the hash of its ID.
|
||||
func (s *Store) GetSession(ctx context.Context, idHash string) (*model.Session, error) {
|
||||
se := &model.Session{}
|
||||
var ca, ea, ls string
|
||||
err := s.db.QueryRowContext(ctx, `SELECT id_hash, user_id, csrf_token, created_at, expires_at, last_seen_at, ip, user_agent FROM sessions WHERE id_hash = ?`, idHash).
|
||||
Scan(&se.IDHash, &se.UserID, &se.CSRFToken, &ca, &ea, &ls, &se.IP, &se.UserAgent)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
se.CreatedAt, se.ExpiresAt, se.LastSeenAt = parseTS(ca), parseTS(ea), parseTS(ls)
|
||||
return se, nil
|
||||
}
|
||||
|
||||
// TouchSession updates a session's last-seen time.
|
||||
func (s *Store) TouchSession(ctx context.Context, idHash string, at time.Time) error {
|
||||
_, err := s.db.ExecContext(ctx, `UPDATE sessions SET last_seen_at = ? WHERE id_hash = ?`, ts(at), idHash)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteSession removes a session.
|
||||
func (s *Store) DeleteSession(ctx context.Context, idHash string) error {
|
||||
_, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE id_hash = ?`, idHash)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteUserSessions removes a user's sessions, except keep (if set).
|
||||
func (s *Store) DeleteUserSessions(ctx context.Context, userID int64, keep string) error {
|
||||
_, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE user_id = ? AND id_hash != ?`, userID, keep)
|
||||
return err
|
||||
}
|
||||
|
||||
// PurgeSessions removes expired sessions.
|
||||
func (s *Store) PurgeSessions(ctx context.Context, now time.Time) error {
|
||||
_, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE expires_at < ?`, ts(now))
|
||||
return err
|
||||
}
|
||||
|
||||
// AddAudit stores an audit event.
|
||||
func (s *Store) AddAudit(ctx context.Context, ev *model.AuditEvent) error {
|
||||
if ev.Time.IsZero() {
|
||||
ev.Time = time.Now()
|
||||
}
|
||||
var uid, tid any
|
||||
if ev.UserID != 0 {
|
||||
uid = ev.UserID
|
||||
}
|
||||
if ev.TokenID != 0 {
|
||||
tid = ev.TokenID
|
||||
}
|
||||
res, err := s.db.ExecContext(ctx, `INSERT INTO audit (time, user_id, username, auth, token_id, ip, action, object, result, detail) VALUES (?,?,?,?,?,?,?,?,?,?)`,
|
||||
ts(ev.Time), uid, ev.Username, ev.Auth, tid, ev.IP, ev.Action, ev.Object, ev.Result, ev.Detail)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ev.ID, _ = res.LastInsertId()
|
||||
return nil
|
||||
}
|
||||
|
||||
// AuditFilter selects audit events.
|
||||
type AuditFilter struct {
|
||||
Username string
|
||||
Action string // prefix: "auth." or "set.add"
|
||||
Result string
|
||||
Search string // in the object or the detail
|
||||
Before int64 // IDs below this (paging)
|
||||
Limit int
|
||||
}
|
||||
|
||||
// ListAudit lists audit events, newest first.
|
||||
func (s *Store) ListAudit(ctx context.Context, f AuditFilter) ([]*model.AuditEvent, error) {
|
||||
var where []string
|
||||
var args []any
|
||||
if f.Username != "" {
|
||||
where = append(where, "username = ? COLLATE NOCASE")
|
||||
args = append(args, f.Username)
|
||||
}
|
||||
if f.Action != "" {
|
||||
where = append(where, "action LIKE ? ESCAPE '\\'")
|
||||
args = append(args, likeEscape(f.Action)+"%")
|
||||
}
|
||||
if f.Result != "" {
|
||||
where = append(where, "result = ?")
|
||||
args = append(args, f.Result)
|
||||
}
|
||||
if f.Search != "" {
|
||||
where = append(where, "(object LIKE ? ESCAPE '\\' OR detail LIKE ? ESCAPE '\\')")
|
||||
p := "%" + likeEscape(f.Search) + "%"
|
||||
args = append(args, p, p)
|
||||
}
|
||||
if f.Before > 0 {
|
||||
where = append(where, "id < ?")
|
||||
args = append(args, f.Before)
|
||||
}
|
||||
q := `SELECT id, time, user_id, username, auth, token_id, ip, action, object, result, detail FROM audit`
|
||||
if len(where) > 0 {
|
||||
q += " WHERE " + strings.Join(where, " AND ")
|
||||
}
|
||||
limit := f.Limit
|
||||
if limit <= 0 || limit > 1000 {
|
||||
limit = 200
|
||||
}
|
||||
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.AuditEvent{}
|
||||
for rows.Next() {
|
||||
ev := &model.AuditEvent{}
|
||||
var t string
|
||||
var uid, tid sql.NullInt64
|
||||
if err := rows.Scan(&ev.ID, &t, &uid, &ev.Username, &ev.Auth, &tid, &ev.IP, &ev.Action, &ev.Object, &ev.Result, &ev.Detail); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ev.Time = parseTS(t)
|
||||
ev.UserID, ev.TokenID = uid.Int64, tid.Int64
|
||||
out = append(out, ev)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func likeEscape(s string) string {
|
||||
return strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(s)
|
||||
}
|
||||
@@ -0,0 +1,511 @@
|
||||
// Package validate checks a whole configuration. Every problem has a
|
||||
// stable code (docs/validation.md); errors stop a change from being saved,
|
||||
// warnings are reported with it.
|
||||
package validate
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"housecarl/internal/model"
|
||||
)
|
||||
|
||||
// Severities.
|
||||
const (
|
||||
Error = "error"
|
||||
Warning = "warning"
|
||||
)
|
||||
|
||||
// Problem is one finding.
|
||||
type Problem struct {
|
||||
Severity string `json:"severity"`
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
// Object names what it is about: "zone wan", "input #12 (ssh)".
|
||||
Object string `json:"object,omitempty"`
|
||||
}
|
||||
|
||||
// Env describes the host, for checks against its interfaces. A nil Env
|
||||
// skips them (tests, imports for another machine).
|
||||
type Env struct {
|
||||
// Interfaces maps each interface name to its addresses.
|
||||
Interfaces map[string][]netip.Prefix
|
||||
}
|
||||
|
||||
// HasErrors reports whether any problem is an error.
|
||||
func HasErrors(ps []Problem) bool {
|
||||
for _, p := range ps {
|
||||
if p.Severity == Error {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Errors returns only the errors.
|
||||
func Errors(ps []Problem) []Problem {
|
||||
var out []Problem
|
||||
for _, p := range ps {
|
||||
if p.Severity == Error {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type checker struct {
|
||||
st *model.State
|
||||
env *Env
|
||||
out []Problem
|
||||
}
|
||||
|
||||
func (c *checker) add(sev, code, object, format string, args ...any) {
|
||||
c.out = append(c.out, Problem{Severity: sev, Code: code, Object: object, Message: fmt.Sprintf(format, args...)})
|
||||
}
|
||||
|
||||
// Check validates a configuration.
|
||||
func Check(st *model.State, env *Env) []Problem {
|
||||
c := &checker{st: st, env: env}
|
||||
c.settings()
|
||||
c.zones()
|
||||
c.sets()
|
||||
c.services()
|
||||
for _, r := range st.Rules {
|
||||
c.rule(r)
|
||||
}
|
||||
c.forwards()
|
||||
for _, x := range st.SNATs {
|
||||
c.snat(x)
|
||||
}
|
||||
for _, x := range st.Redirects {
|
||||
c.redirect(x)
|
||||
}
|
||||
return c.out
|
||||
}
|
||||
|
||||
var nameRe = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_-]{0,47}$`)
|
||||
|
||||
// ifaceRe: Linux interface names (15 bytes), with an optional trailing *
|
||||
// for nftables' wildcard.
|
||||
var ifaceRe = regexp.MustCompile(`^[A-Za-z0-9_.:@-]{1,15}\*?$|^\*$`)
|
||||
|
||||
func (c *checker) name(kind, obj, name string, seen map[string]string) {
|
||||
switch {
|
||||
case !nameRe.MatchString(name):
|
||||
c.add(Error, kind+"_name", obj, "%q isn't a valid name: start with a letter; then letters, digits, - and _ (up to 48)", name)
|
||||
case model.IsLiteral(name):
|
||||
c.add(Error, kind+"_name", obj, "%q looks like an address; give it a name", name)
|
||||
default:
|
||||
k := strings.ToLower(name)
|
||||
if o, ok := seen[k]; ok {
|
||||
c.add(Error, kind+"_name_taken", obj, "the name %s is already used by %s", name, o)
|
||||
}
|
||||
seen[k] = obj
|
||||
}
|
||||
}
|
||||
|
||||
func (c *checker) settings() {
|
||||
s := c.st.Settings
|
||||
for _, p := range []struct{ name, v string }{{"input", s.InputPolicy}, {"forward", s.ForwardPolicy}, {"output", s.OutputPolicy}} {
|
||||
if p.v != model.PolicyAccept && p.v != model.PolicyDrop {
|
||||
c.add(Error, "policy", "settings", "the %s policy is %q; use accept or drop", p.name, p.v)
|
||||
}
|
||||
}
|
||||
for _, l := range []struct {
|
||||
name string
|
||||
list []string
|
||||
v6 int // 0 v4, 1 v6, 2 either
|
||||
}{{"antispoof_v4", s.AntispoofV4, 0}, {"antispoof_v6", s.AntispoofV6, 1}, {"own_prefixes", s.OwnPrefixes, 2}} {
|
||||
for _, e := range l.list {
|
||||
a, err := model.ParseAddress(e)
|
||||
if err != nil {
|
||||
c.add(Error, "settings_address", "settings", "%s: %v", l.name, err)
|
||||
continue
|
||||
}
|
||||
if l.v6 != 2 && a.IsV6() != (l.v6 == 1) {
|
||||
c.add(Error, "settings_address", "settings", "%s: %s is the wrong address family", l.name, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, d := range s.FlowtableDevices {
|
||||
if !ifaceRe.MatchString(d) || strings.HasSuffix(d, "*") {
|
||||
c.add(Error, "flowtable_device", "settings", "flowtable device %q: give an exact interface name (no wildcards)", d)
|
||||
} else if c.env != nil {
|
||||
if _, ok := c.env.Interfaces[d]; !ok {
|
||||
c.add(Warning, "flowtable_device_missing", "settings", "flowtable device %s doesn't exist here; the flowtable is left out until it does", d)
|
||||
}
|
||||
}
|
||||
}
|
||||
if p := s.ProbationSeconds; p != 0 && (p < 10 || p > 3600) {
|
||||
c.add(Error, "probation_range", "settings", "the probation is %d seconds; use 10 to 3600", p)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *checker) zones() {
|
||||
seen := map[string]string{}
|
||||
owner := map[string]string{}
|
||||
external := false
|
||||
for _, z := range c.st.Zones {
|
||||
obj := "zone " + z.Name
|
||||
c.name("zone", obj, z.Name, seen)
|
||||
external = external || z.External
|
||||
if len(z.Interfaces) == 0 {
|
||||
c.add(Error, "zone_empty", obj, "the zone has no interfaces")
|
||||
}
|
||||
for _, i := range z.Interfaces {
|
||||
if !ifaceRe.MatchString(i) {
|
||||
c.add(Error, "zone_interface", obj, "%q isn't an interface name (up to 15 characters; a trailing * matches many)", i)
|
||||
continue
|
||||
}
|
||||
if o, ok := owner[i]; ok {
|
||||
c.add(Error, "zone_interface_taken", obj, "interface %s is already in %s; an interface belongs to one zone", i, o)
|
||||
}
|
||||
owner[i] = obj
|
||||
if c.env != nil && !strings.HasSuffix(i, "*") {
|
||||
if _, ok := c.env.Interfaces[i]; !ok {
|
||||
c.add(Warning, "zone_interface_missing", obj, "interface %s doesn't exist here (yet); its rules apply once it does", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(c.st.Zones) > 0 && !external {
|
||||
c.add(Warning, "no_external_zone", "zones", "no zone is external: antispoofing, MSS clamping and the DHCPv6 client rule need one, and port forwards without public addresses")
|
||||
}
|
||||
}
|
||||
|
||||
func (c *checker) sets() {
|
||||
seen := map[string]string{}
|
||||
for _, s := range c.st.Sets {
|
||||
obj := "set " + s.Name
|
||||
c.name("set", obj, s.Name, seen)
|
||||
for _, e := range s.Entries {
|
||||
if _, err := model.ParseAddress(e); err != nil {
|
||||
c.add(Error, "set_entry", obj, "%v", err)
|
||||
}
|
||||
}
|
||||
if len(s.Entries) == 0 && !s.Dynamic {
|
||||
c.add(Warning, "set_empty", obj, "the set is empty: rules using it match nothing")
|
||||
}
|
||||
if s.Timeout < 0 || s.Timeout > 0 && !s.Dynamic {
|
||||
c.add(Error, "set_timeout", obj, "only a dynamic set has a timeout, of 1 second or more")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *checker) services() {
|
||||
seen := map[string]string{}
|
||||
for _, s := range c.st.Services {
|
||||
obj := "service " + s.Name
|
||||
c.name("service", obj, s.Name, seen)
|
||||
if len(s.Entries) == 0 {
|
||||
c.add(Error, "service_empty", obj, "the service has no ports or ICMP types")
|
||||
}
|
||||
for _, e := range s.Entries {
|
||||
if _, err := model.ParseServiceEntry(e); err != nil {
|
||||
c.add(Error, "service_entry", obj, "%v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// match checks a Match and returns which families it can match.
|
||||
func (c *checker) match(obj string, m model.Match, allowIn, allowOut bool) (v4, v6 bool) {
|
||||
for _, z := range m.InZones {
|
||||
if !allowIn {
|
||||
c.add(Error, "match_in_zone", obj, "this chain has no incoming interface; leave out the in zones")
|
||||
break
|
||||
}
|
||||
if c.st.Zone(z) == nil {
|
||||
c.add(Error, "unknown_zone", obj, "there is no zone %s", z)
|
||||
}
|
||||
}
|
||||
for _, z := range m.OutZones {
|
||||
if !allowOut {
|
||||
c.add(Error, "match_out_zone", obj, "this chain has no outgoing interface; leave out the out zones")
|
||||
break
|
||||
}
|
||||
if c.st.Zone(z) == nil {
|
||||
c.add(Error, "unknown_zone", obj, "there is no zone %s", z)
|
||||
}
|
||||
}
|
||||
refs := func(what string, list []string) (bool, bool) {
|
||||
if len(list) == 0 {
|
||||
return true, true
|
||||
}
|
||||
for _, r := range list {
|
||||
if !model.IsLiteral(r) && c.st.Set(r) == nil {
|
||||
if _, err := model.ParseAddress(r); strings.ContainsAny(r, "./:") {
|
||||
c.add(Error, "match_address", obj, "%s: %v", what, err)
|
||||
} else {
|
||||
c.add(Error, "unknown_set", obj, "%s: there is no set %s", what, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
f4, f6 := c.st.SplitRefs(list)
|
||||
return !f4.Empty(), !f6.Empty()
|
||||
}
|
||||
s4, s6 := refs("sources", m.Sources)
|
||||
d4, d6 := refs("destinations", m.Destinations)
|
||||
v4, v6 = s4 && d4, s6 && d6
|
||||
icmp4, icmp6, ports := false, false, false
|
||||
for _, r := range m.Services {
|
||||
if model.IsServiceEntry(r) {
|
||||
if _, err := model.ParseServiceEntry(r); err != nil {
|
||||
c.add(Error, "service_entry", obj, "%v", err)
|
||||
}
|
||||
} else if c.st.Service(r) == nil {
|
||||
c.add(Error, "unknown_service", obj, "there is no service %s (built-in or yours)", r)
|
||||
}
|
||||
}
|
||||
for _, e := range c.st.ServiceEntries(m.Services) {
|
||||
switch e.Proto {
|
||||
case "icmp":
|
||||
icmp4 = true
|
||||
case "icmpv6":
|
||||
icmp6 = true
|
||||
default:
|
||||
ports = true
|
||||
}
|
||||
}
|
||||
if !ports && (icmp4 || icmp6) {
|
||||
v4, v6 = v4 && icmp4, v6 && icmp6
|
||||
}
|
||||
switch m.Family {
|
||||
case "":
|
||||
case model.FamilyIPv4:
|
||||
v6 = false
|
||||
case model.FamilyIPv6:
|
||||
v4 = false
|
||||
default:
|
||||
c.add(Error, "family", obj, "the family is %q; use ipv4, ipv6 or nothing for both", m.Family)
|
||||
}
|
||||
if !v4 && !v6 {
|
||||
c.add(Error, "match_never", obj, "the addresses, services and family together can never match (an IPv4-only part and an IPv6-only part)")
|
||||
}
|
||||
return v4, v6
|
||||
}
|
||||
|
||||
var (
|
||||
ctStates = map[string]bool{"new": true, "established": true, "related": true, "invalid": true, "untracked": true}
|
||||
ctStatuses = map[string]bool{"dnat": true, "snat": true, "confirmed": true, "assured": true, "expected": true}
|
||||
days = map[string]bool{"monday": true, "tuesday": true, "wednesday": true, "thursday": true, "friday": true, "saturday": true, "sunday": true}
|
||||
hourRe = regexp.MustCompile(`^([01][0-9]|2[0-3]):[0-5][0-9]$`)
|
||||
limitPer = map[string]bool{"second": true, "minute": true, "hour": true, "day": true}
|
||||
)
|
||||
|
||||
func (c *checker) rule(r *model.Rule) {
|
||||
obj := r.Label()
|
||||
switch r.Chain {
|
||||
case model.ChainEarly, model.ChainInput, model.ChainForward, model.ChainOutput:
|
||||
default:
|
||||
c.add(Error, "rule_chain", obj, "the chain is %q; use %s", r.Chain, strings.Join(model.Chains, ", "))
|
||||
return
|
||||
}
|
||||
actionOK := false
|
||||
for _, a := range model.Actions {
|
||||
actionOK = actionOK || r.Action == a
|
||||
}
|
||||
if !actionOK {
|
||||
c.add(Error, "rule_action", obj, "the action is %q; use %s", r.Action, strings.Join(model.Actions, ", "))
|
||||
}
|
||||
if r.Raw != "" {
|
||||
if strings.ContainsAny(r.Raw, ";\n\r") {
|
||||
c.add(Error, "raw_rule", obj, "a raw rule is one nftables rule on one line, without ;")
|
||||
}
|
||||
return
|
||||
}
|
||||
in := r.Chain != model.ChainOutput
|
||||
out := r.Chain == model.ChainForward || r.Chain == model.ChainOutput
|
||||
c.match(obj, r.Match, in, out)
|
||||
if r.Chain == model.ChainEarly {
|
||||
if len(r.CtState) > 0 || len(r.CtStatus) > 0 || r.ConnLimit > 0 {
|
||||
c.add(Error, "early_conntrack", obj, "the early chain runs before connection tracking: leave out connection states and limits")
|
||||
}
|
||||
if r.Action == model.ActionReject {
|
||||
c.add(Error, "early_reject", obj, "the early chain can't reject; drop instead")
|
||||
}
|
||||
}
|
||||
for _, s := range r.CtState {
|
||||
if !ctStates[s] {
|
||||
c.add(Error, "ct_state", obj, "%q isn't a connection state (new, established, related, invalid, untracked)", s)
|
||||
}
|
||||
}
|
||||
for _, s := range r.CtStatus {
|
||||
if !ctStatuses[s] {
|
||||
c.add(Error, "ct_status", obj, "%q isn't a connection status (dnat, snat, confirmed, assured, expected)", s)
|
||||
}
|
||||
}
|
||||
for _, d := range r.Days {
|
||||
if !days[strings.ToLower(d)] {
|
||||
c.add(Error, "rule_day", obj, "%q isn't a day (Monday … Sunday)", d)
|
||||
}
|
||||
}
|
||||
if (r.HourFrom == "") != (r.HourTo == "") {
|
||||
c.add(Error, "rule_hours", obj, "give both hours of the time window, from and to (08:00, 17:30)")
|
||||
} else if r.HourFrom != "" && (!hourRe.MatchString(r.HourFrom) || !hourRe.MatchString(r.HourTo)) {
|
||||
c.add(Error, "rule_hours", obj, "write hours as HH:MM (08:00, 17:30)")
|
||||
}
|
||||
if l := r.Limit; l != nil {
|
||||
if l.Rate < 1 || !limitPer[l.Per] || l.Burst < 0 {
|
||||
c.add(Error, "rule_limit", obj, "a rate is a number of packets per second, minute, hour or day, with an optional burst")
|
||||
}
|
||||
}
|
||||
if r.ConnLimit < 0 {
|
||||
c.add(Error, "rule_conn_limit", obj, "the connection limit must be 1 or more")
|
||||
}
|
||||
if r.RejectWith != "" {
|
||||
if r.RejectWith != "tcp-reset" {
|
||||
c.add(Error, "reject_with", obj, "reject with is %q; use tcp-reset, or nothing for an ICMP error", r.RejectWith)
|
||||
} else {
|
||||
for _, e := range c.st.ServiceEntries(r.Services) {
|
||||
if e.Proto != "tcp" {
|
||||
c.add(Error, "reject_with", obj, "tcp-reset needs a rule that matches TCP only (%s isn't)", e)
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(r.Services) == 0 {
|
||||
c.add(Error, "reject_with", obj, "tcp-reset needs a rule that matches TCP only; add a tcp service")
|
||||
}
|
||||
}
|
||||
if r.Action != model.ActionReject {
|
||||
c.add(Error, "reject_with", obj, "reject with only goes with the reject action")
|
||||
}
|
||||
}
|
||||
if len(r.LogPrefix) > 100 {
|
||||
c.add(Error, "log_prefix", obj, "the log prefix is %d characters; 100 at most", len(r.LogPrefix))
|
||||
}
|
||||
}
|
||||
|
||||
func (c *checker) forwards() {
|
||||
type slot struct {
|
||||
addr, proto string
|
||||
lo, hi int
|
||||
}
|
||||
var taken []struct {
|
||||
s slot
|
||||
obj string
|
||||
}
|
||||
hasExternal := len(c.st.ExternalInterfaces()) > 0
|
||||
seen := map[string]string{}
|
||||
for _, f := range c.st.Forwards {
|
||||
obj := f.Label()
|
||||
if f.Name != "" {
|
||||
c.name("forward", obj, f.Name, seen)
|
||||
}
|
||||
if len(f.Protocols) == 0 {
|
||||
c.add(Error, "forward_protocol", obj, "give the protocols: tcp, udp or both")
|
||||
}
|
||||
for _, p := range f.Protocols {
|
||||
if p != "tcp" && p != "udp" && p != "sctp" {
|
||||
c.add(Error, "forward_protocol", obj, "the protocol is %q; use tcp, udp or sctp", p)
|
||||
}
|
||||
}
|
||||
if _, err := model.NormalizePorts(f.Ports); err != nil {
|
||||
c.add(Error, "forward_ports", obj, "public port: %v", err)
|
||||
continue
|
||||
}
|
||||
lo, hi := model.PortRange(f.Ports)
|
||||
if f.ToPort != "" {
|
||||
if _, err := model.NormalizePorts(f.ToPort); err != nil || strings.Contains(f.ToPort, "-") {
|
||||
c.add(Error, "forward_to_port", obj, "the inside port must be one port")
|
||||
} else if hi != lo {
|
||||
c.add(Error, "forward_to_port", obj, "a range of public ports keeps its ports: leave out the inside port")
|
||||
}
|
||||
}
|
||||
if f.To == "" && f.To6 == "" {
|
||||
c.add(Error, "forward_target", obj, "give the inside host: an IPv4 address (to), an IPv6 one (to6), or both")
|
||||
}
|
||||
if f.To != "" {
|
||||
if a, err := netip.ParseAddr(f.To); err != nil || !a.Is4() {
|
||||
c.add(Error, "forward_target", obj, "to must be one IPv4 address (IPv6 hosts are reached with to6, without translation)")
|
||||
}
|
||||
}
|
||||
if f.To6 != "" {
|
||||
if a, err := netip.ParseAddr(f.To6); err != nil || !a.Is6() || a.Is4In6() {
|
||||
c.add(Error, "forward_target", obj, "to6 must be one IPv6 address")
|
||||
}
|
||||
}
|
||||
if f.To != "" && len(f.PublicAddresses) == 0 && !hasExternal {
|
||||
c.add(Error, "forward_public", obj, "without public addresses, a port forward listens on the external zones, and there is none")
|
||||
}
|
||||
for _, a := range f.PublicAddresses {
|
||||
if pa, err := model.ParseAddress(a); err == nil {
|
||||
if pa.IsV6() {
|
||||
c.add(Error, "forward_public", obj, "public addresses are IPv4 (%s isn't); IPv6 hosts are reached with to6", a)
|
||||
}
|
||||
} else if c.st.Set(a) == nil {
|
||||
c.add(Error, "unknown_set", obj, "public addresses: %s is neither an address nor a set", a)
|
||||
}
|
||||
}
|
||||
c.match(obj, model.Match{Sources: f.Sources}, true, false)
|
||||
if f.Hairpin && len(f.PublicAddresses) == 0 {
|
||||
c.add(Warning, "forward_hairpin", obj, "hairpin needs public addresses to match inside clients' connections; add them")
|
||||
}
|
||||
for _, x := range f.HairpinSources {
|
||||
if !model.IsLiteral(x) && c.st.Set(x) == nil {
|
||||
c.add(Error, "unknown_set", obj, "hairpin sources: %s is neither an address nor a set", x)
|
||||
}
|
||||
}
|
||||
if f.Disabled {
|
||||
continue
|
||||
}
|
||||
addrs := f.PublicAddresses
|
||||
if len(addrs) == 0 {
|
||||
addrs = []string{"(external zones)"}
|
||||
}
|
||||
for _, a := range addrs {
|
||||
for _, p := range f.Protocols {
|
||||
s := slot{a, p, lo, hi}
|
||||
for _, t := range taken {
|
||||
if t.s.addr == s.addr && t.s.proto == s.proto && s.lo <= t.s.hi && t.s.lo <= s.hi {
|
||||
c.add(Error, "forward_overlap", obj, "%s port %s on %s is already forwarded by %s", p, f.Ports, a, t.obj)
|
||||
}
|
||||
}
|
||||
taken = append(taken, struct {
|
||||
s slot
|
||||
obj string
|
||||
}{s, obj})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *checker) snat(x *model.SourceNAT) {
|
||||
obj := x.Label()
|
||||
v4, v6 := c.match(obj, x.Match, true, true)
|
||||
if x.To != "" {
|
||||
a, err := netip.ParseAddr(x.To)
|
||||
switch {
|
||||
case err != nil:
|
||||
c.add(Error, "snat_to", obj, "to must be one address (or empty to masquerade)")
|
||||
case a.Is4() && v6 && !v4 || a.Is6() && v4 && !v6:
|
||||
c.add(Error, "snat_to", obj, "to is %s, but the rule only matches the other family", x.To)
|
||||
case a.Is4() && v6 || a.Is6() && v4:
|
||||
c.add(Error, "snat_to", obj, "to is one family's address: limit the rule to that family (family %s)", map[bool]string{true: "ipv4", false: "ipv6"}[a.Is4()])
|
||||
}
|
||||
}
|
||||
if len(x.OutZones) == 0 && len(x.Sources) == 0 {
|
||||
c.add(Warning, "snat_everything", obj, "no out zones and no sources: everything leaving the firewall is translated")
|
||||
}
|
||||
}
|
||||
|
||||
func (c *checker) redirect(x *model.Redirect) {
|
||||
obj := x.Label()
|
||||
c.match(obj, x.Match, true, false)
|
||||
entries := c.st.ServiceEntries(x.Services)
|
||||
if len(entries) == 0 {
|
||||
c.add(Error, "redirect_service", obj, "give the ports to redirect (a service such as dns, or tcp/80)")
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsICMP() {
|
||||
c.add(Error, "redirect_service", obj, "only TCP, UDP and SCTP ports can be redirected (%s)", e)
|
||||
}
|
||||
}
|
||||
if x.ToPort != "" {
|
||||
if _, err := model.NormalizePorts(x.ToPort); err != nil || strings.Contains(x.ToPort, "-") {
|
||||
c.add(Error, "redirect_to_port", obj, "the local port must be one port")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package validate
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"housecarl/internal/model"
|
||||
)
|
||||
|
||||
// base is a valid configuration each case changes.
|
||||
func base() *model.State {
|
||||
return &model.State{
|
||||
Settings: model.DefaultSettings(),
|
||||
Zones: []*model.Zone{
|
||||
{ID: 1, Name: "wan", Interfaces: []string{"wan"}, External: true},
|
||||
{ID: 2, Name: "lan", Interfaces: []string{"eth1"}},
|
||||
},
|
||||
Sets: []*model.AddressSet{{ID: 3, Name: "trusted", Entries: []string{"10.0.10.0/24", "2001:db8:10::/64"}}},
|
||||
Services: []*model.Service{{ID: 4, Name: "jellyfin", Entries: []string{"tcp/8096"}}},
|
||||
Rules: []*model.Rule{
|
||||
{ID: 5, Chain: model.ChainInput, Position: 1, Match: model.Match{Sources: []string{"trusted"}, Services: []string{"ssh"}}, Action: model.ActionAccept},
|
||||
{ID: 6, Chain: model.ChainForward, Position: 1, Match: model.Match{InZones: []string{"lan"}, OutZones: []string{"wan"}}, Action: model.ActionAccept},
|
||||
},
|
||||
Forwards: []*model.PortForward{{ID: 7, Position: 1, Name: "jellyfin", Protocols: []string{"tcp"}, Ports: "8096", To: "10.0.10.57",
|
||||
PublicAddresses: []string{"203.0.113.7"}, Hairpin: true}},
|
||||
SNATs: []*model.SourceNAT{{ID: 8, Position: 1, Match: model.Match{OutZones: []string{"wan"}, Sources: []string{"trusted"}}}},
|
||||
}
|
||||
}
|
||||
|
||||
func codes(ps []Problem) string {
|
||||
var out []string
|
||||
for _, p := range ps {
|
||||
out = append(out, p.Code)
|
||||
}
|
||||
return strings.Join(out, " ")
|
||||
}
|
||||
|
||||
func TestBaseIsValid(t *testing.T) {
|
||||
env := &Env{Interfaces: map[string][]netip.Prefix{"wan": nil, "eth1": nil}}
|
||||
if ps := Check(base(), env); len(ps) > 0 {
|
||||
t.Fatalf("problems: %+v", ps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodes(t *testing.T) {
|
||||
rule := func(st *model.State) *model.Rule { return st.Rules[0] }
|
||||
cases := []struct {
|
||||
code string
|
||||
edit func(st *model.State)
|
||||
}{
|
||||
{"zone_name", func(st *model.State) { st.Zones[0].Name = "1wan" }},
|
||||
{"zone_name_taken", func(st *model.State) { st.Zones[1].Name = "WAN" }},
|
||||
{"forward_name", func(st *model.State) { st.Forwards[0].Name = "jelly fin" }},
|
||||
{"policy", func(st *model.State) { st.Settings.InputPolicy = "reject" }},
|
||||
{"settings_address", func(st *model.State) { st.Settings.AntispoofV4 = []string{"fc00::/7"} }},
|
||||
{"flowtable_device", func(st *model.State) { st.Settings.FlowtableDevices = []string{"eth*"} }},
|
||||
{"probation_range", func(st *model.State) { st.Settings.ProbationSeconds = 5 }},
|
||||
{"zone_empty", func(st *model.State) { st.Zones[1].Interfaces = nil }},
|
||||
{"zone_interface", func(st *model.State) { st.Zones[1].Interfaces = []string{"a-very-long-interface"} }},
|
||||
{"zone_interface_taken", func(st *model.State) { st.Zones[1].Interfaces = []string{"wan"} }},
|
||||
{"no_external_zone", func(st *model.State) {
|
||||
st.Zones[0].External = false
|
||||
st.Forwards[0].PublicAddresses = []string{"203.0.113.7"}
|
||||
}},
|
||||
{"set_entry", func(st *model.State) { st.Sets[0].Entries = []string{"10.0.10.5/24"} }},
|
||||
{"set_empty", func(st *model.State) { st.Sets[0].Entries = nil }},
|
||||
{"set_timeout", func(st *model.State) { st.Sets[0].Timeout = 60 }},
|
||||
{"service_empty", func(st *model.State) { st.Services[0].Entries = nil }},
|
||||
{"service_entry", func(st *model.State) { st.Services[0].Entries = []string{"tcp/99999"} }},
|
||||
{"unknown_zone", func(st *model.State) { st.Rules[1].InZones = []string{"dmz"} }},
|
||||
{"match_in_zone", func(st *model.State) {
|
||||
st.Rules = append(st.Rules, &model.Rule{ID: 9, Chain: model.ChainOutput, Position: 1, Match: model.Match{InZones: []string{"lan"}}, Action: "accept"})
|
||||
}},
|
||||
{"match_out_zone", func(st *model.State) { rule(st).OutZones = []string{"wan"} }},
|
||||
{"unknown_set", func(st *model.State) { rule(st).Sources = []string{"friends"} }},
|
||||
{"unknown_service", func(st *model.State) { rule(st).Services = []string{"gopher"} }},
|
||||
{"family", func(st *model.State) { rule(st).Family = "ipv5" }},
|
||||
{"match_never", func(st *model.State) {
|
||||
rule(st).Sources = []string{"10.0.0.1"}
|
||||
rule(st).Services = []string{"icmpv6/echo-request"}
|
||||
}},
|
||||
{"rule_chain", func(st *model.State) { rule(st).Chain = "prerouting" }},
|
||||
{"rule_action", func(st *model.State) { rule(st).Action = "allow" }},
|
||||
{"raw_rule", func(st *model.State) { rule(st).Raw = "tcp dport 22 accept; udp dport 53 accept" }},
|
||||
{"early_conntrack", func(st *model.State) { rule(st).Chain = model.ChainEarly; rule(st).CtState = []string{"new"} }},
|
||||
{"early_reject", func(st *model.State) { rule(st).Chain = model.ChainEarly; rule(st).Action = model.ActionReject }},
|
||||
{"ct_state", func(st *model.State) { rule(st).CtState = []string{"fresh"} }},
|
||||
{"ct_status", func(st *model.State) { rule(st).CtStatus = []string{"translated"} }},
|
||||
{"rule_day", func(st *model.State) { rule(st).Days = []string{"Caturday"} }},
|
||||
{"rule_hours", func(st *model.State) { rule(st).HourFrom = "08:00" }},
|
||||
{"rule_limit", func(st *model.State) { rule(st).Limit = &model.Limit{Rate: 10, Per: "week"} }},
|
||||
{"rule_conn_limit", func(st *model.State) { rule(st).ConnLimit = -1 }},
|
||||
{"reject_with", func(st *model.State) { rule(st).RejectWith = "tcp-reset" }},
|
||||
{"log_prefix", func(st *model.State) { rule(st).Log = true; rule(st).LogPrefix = strings.Repeat("x", 101) }},
|
||||
{"forward_protocol", func(st *model.State) { st.Forwards[0].Protocols = []string{"icmp"} }},
|
||||
{"forward_ports", func(st *model.State) { st.Forwards[0].Ports = "http" }},
|
||||
{"forward_to_port", func(st *model.State) { st.Forwards[0].Ports = "8000-8010"; st.Forwards[0].ToPort = "80" }},
|
||||
{"forward_target", func(st *model.State) { st.Forwards[0].To = "2001:db8::57" }},
|
||||
{"forward_public", func(st *model.State) { st.Forwards[0].PublicAddresses = []string{"2001:db8::1"} }},
|
||||
{"forward_overlap", func(st *model.State) {
|
||||
f := *st.Forwards[0]
|
||||
f.ID, f.Name = 10, "other"
|
||||
st.Forwards = append(st.Forwards, &f)
|
||||
}},
|
||||
{"forward_hairpin", func(st *model.State) { st.Forwards[0].PublicAddresses = nil }},
|
||||
{"snat_to", func(st *model.State) { st.SNATs[0].To = "2001:db8::1" }},
|
||||
{"snat_everything", func(st *model.State) { st.SNATs[0].OutZones, st.SNATs[0].Sources = nil, nil }},
|
||||
{"redirect_service", func(st *model.State) {
|
||||
st.Redirects = []*model.Redirect{{ID: 11, Position: 1, Match: model.Match{InZones: []string{"lan"}}}}
|
||||
}},
|
||||
{"redirect_to_port", func(st *model.State) {
|
||||
st.Redirects = []*model.Redirect{{ID: 11, Position: 1, Match: model.Match{InZones: []string{"lan"}, Services: []string{"dns"}}, ToPort: "53-54"}}
|
||||
}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
st := base()
|
||||
c.edit(st)
|
||||
if got := codes(Check(st, nil)); !strings.Contains(" "+got+" ", " "+c.code+" ") {
|
||||
t.Errorf("%s: got [%s]", c.code, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostWarnings(t *testing.T) {
|
||||
st := base()
|
||||
st.Settings.FlowtableDevices = []string{"wan"}
|
||||
env := &Env{Interfaces: map[string][]netip.Prefix{"eth1": nil}}
|
||||
got := codes(Check(st, env))
|
||||
for _, want := range []string{"zone_interface_missing", "flowtable_device_missing"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("%s missing: [%s]", want, got)
|
||||
}
|
||||
}
|
||||
for _, p := range Check(st, env) {
|
||||
if p.Severity != Warning {
|
||||
t.Errorf("a missing interface is only a warning: %+v", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
// API client and shared application state.
|
||||
import { toast } from "./ui.js";
|
||||
|
||||
export const state = {
|
||||
me: null, // whoami response
|
||||
csrf: "",
|
||||
catalog: null,
|
||||
};
|
||||
|
||||
export class APIError extends Error {
|
||||
constructor(status, body) {
|
||||
const e = (body && body.error) || {};
|
||||
super(e.message || ("HTTP " + status));
|
||||
this.status = status;
|
||||
this.code = e.code || "";
|
||||
this.details = e;
|
||||
}
|
||||
}
|
||||
|
||||
let onUnauthorized = () => {};
|
||||
export function setUnauthorizedHandler(fn) { onUnauthorized = fn; }
|
||||
|
||||
// onChange is told about every applied change (the probation banner).
|
||||
let onChange = () => {};
|
||||
export function setChangeHandler(fn) { onChange = fn; }
|
||||
|
||||
// api calls the REST API. opts: {body, query}.
|
||||
export async function api(method, path, opts = {}) {
|
||||
const headers = { "Accept": "application/json" };
|
||||
let body;
|
||||
if (opts.body !== undefined) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
body = JSON.stringify(opts.body);
|
||||
}
|
||||
if (method !== "GET" && state.csrf) headers["X-CSRF-Token"] = state.csrf;
|
||||
let url = "/api/v1" + path;
|
||||
if (opts.query) {
|
||||
const q = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(opts.query)) if (v !== undefined && v !== null && v !== "") q.set(k, v);
|
||||
const s = q.toString();
|
||||
if (s) url += (url.includes("?") ? "&" : "?") + s;
|
||||
}
|
||||
const resp = await fetch(url, { method, headers, body, credentials: "same-origin" });
|
||||
if (resp.status === 204) return null;
|
||||
const ct = resp.headers.get("Content-Type") || "";
|
||||
const data = ct.includes("application/json") ? await resp.json() : await resp.text();
|
||||
if (!resp.ok) {
|
||||
if (resp.status === 401 && !path.startsWith("/auth/login")) onUnauthorized();
|
||||
throw new APIError(resp.status, data);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
export const get = (path, query) => api("GET", path, { query });
|
||||
|
||||
// change sends a configuration change and reports it: a toast with the
|
||||
// history entry (to undo it from History) and one per new warning. It
|
||||
// returns the response.
|
||||
export async function change(method, path, body, opts = {}) {
|
||||
const r = await api(method, path, { body, query: opts.query });
|
||||
if (!opts.quiet) report(r);
|
||||
return r;
|
||||
}
|
||||
|
||||
export function report(r) {
|
||||
if (!r) return;
|
||||
if (r.dry_run) toast("Checked by Housecarl and nftables: nothing was changed");
|
||||
else if (r.applied && r.confirm_by) toast("Applied on probation (history #" + r.history_id + "): confirm it in the banner", "warn", 6000);
|
||||
else if (r.applied) toast("Applied and saved (history #" + r.history_id + ")");
|
||||
else if (!(r.changes || []).length) toast("Nothing to change");
|
||||
for (const w of r.warnings || []) toast(w.message + (w.object ? " (" + w.object + ")" : ""), "warn", 10000);
|
||||
if (r.applied) onChange();
|
||||
}
|
||||
|
||||
// can reports whether the user may read (or write) a resource.
|
||||
export function can(resource, write) {
|
||||
const me = state.me;
|
||||
if (!me) return false;
|
||||
if (me.admin) return true;
|
||||
const perms = me.permissions || [];
|
||||
return perms.includes(resource + ":write") || (!write && perms.includes(resource + ":read"));
|
||||
}
|
||||
|
||||
// catalog returns the option catalog (cached).
|
||||
export async function catalog() {
|
||||
if (!state.catalog) state.catalog = await get("/catalog");
|
||||
return state.catalog;
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
// Housecarl web UI: router, layout, login and the probation banner.
|
||||
import { h, mount, toast, errorBox } from "./ui.js";
|
||||
import { wordmark, logo } from "./brand.js";
|
||||
import { api, get, state, can, setUnauthorizedHandler, setChangeHandler } from "./api.js";
|
||||
import * as dashboard from "./pages/dashboard.js";
|
||||
import * as rules from "./pages/rules.js";
|
||||
import * as nat from "./pages/nat.js";
|
||||
import * as zones from "./pages/zones.js";
|
||||
import * as sets from "./pages/sets.js";
|
||||
import * as services from "./pages/services.js";
|
||||
import * as log from "./pages/log.js";
|
||||
import * as settings from "./pages/settings.js";
|
||||
import * as importPage from "./pages/import.js";
|
||||
import * as history from "./pages/history.js";
|
||||
import * as audit from "./pages/audit.js";
|
||||
import * as users from "./pages/users.js";
|
||||
import * as account from "./pages/account.js";
|
||||
import * as certificate from "./pages/certificate.js";
|
||||
|
||||
const routes = [
|
||||
{ pattern: /^$/, page: dashboard, nav: "dashboard" },
|
||||
{ pattern: /^rules$/, page: rules, nav: "rules" },
|
||||
{ pattern: /^nat$/, page: nat, nav: "nat" },
|
||||
{ pattern: /^zones$/, page: zones, nav: "zones" },
|
||||
{ pattern: /^sets$/, page: sets, nav: "sets" },
|
||||
{ pattern: /^services$/, page: services, nav: "services" },
|
||||
{ pattern: /^log$/, page: log, nav: "log" },
|
||||
{ pattern: /^settings$/, page: settings, nav: "settings" },
|
||||
{ pattern: /^import$/, page: importPage, nav: "import" },
|
||||
{ pattern: /^history$/, page: history, nav: "history" },
|
||||
{ pattern: /^history\/(\d+)$/, page: history, nav: "history", params: ["id"] },
|
||||
{ pattern: /^audit$/, page: audit, nav: "audit" },
|
||||
{ pattern: /^users$/, page: users, nav: "users" },
|
||||
{ pattern: /^account$/, page: account, nav: "account" },
|
||||
{ pattern: /^certificate$/, page: certificate, nav: "certificate" },
|
||||
];
|
||||
|
||||
// The top bar. ["key", "#/href", "Label", permission?]
|
||||
const navItems = [
|
||||
["dashboard", "#/", "Dashboard"],
|
||||
["rules", "#/rules", "Rules", "config"],
|
||||
["nat", "#/nat", "NAT", "config"],
|
||||
{ menu: "Objects", items: [
|
||||
["zones", "#/zones", "Zones", "config"],
|
||||
["sets", "#/sets", "Address sets", "config"],
|
||||
["services", "#/services", "Services", "config"],
|
||||
] },
|
||||
["log", "#/log", "Log", "log"],
|
||||
["history", "#/history", "History", "config"],
|
||||
{ menu: "Server", items: [
|
||||
["settings", "#/settings", "Settings and built-ins", "config"],
|
||||
["import", "#/import", "Import a ruleset", "config"],
|
||||
["certificate", "#/certificate", "Certificate", "admin"],
|
||||
["audit", "#/audit", "Audit log", "audit"],
|
||||
] },
|
||||
];
|
||||
|
||||
const app = document.getElementById("app");
|
||||
let mainEl, navEl, bannerEl, current = { key: "" }, routeGen = 0;
|
||||
|
||||
function parseHash() {
|
||||
const raw = location.hash.replace(/^#\/?/, "");
|
||||
const [path, qs] = raw.split("?");
|
||||
return { path: path.replace(/\/$/, ""), query: new URLSearchParams(qs || "") };
|
||||
}
|
||||
|
||||
function renderLogin(message) {
|
||||
const user = h("input", { name: "username", autocomplete: "username", placeholder: "Username", required: true });
|
||||
const pass = h("input", { name: "password", type: "password", autocomplete: "current-password", placeholder: "Password", required: true });
|
||||
const err = h("div");
|
||||
const form = h("form", { onsubmit: async (e) => {
|
||||
e.preventDefault();
|
||||
mount(err);
|
||||
try {
|
||||
const me = await api("POST", "/auth/login", { body: { username: user.value, password: pass.value } });
|
||||
await loggedIn(me);
|
||||
} catch (ex) {
|
||||
mount(err, errorBox(ex));
|
||||
pass.select();
|
||||
}
|
||||
} }, user, pass, h("button", { class: "primary", type: "submit" }, "Log in"), err);
|
||||
mount(app, h("div", { class: "login panel" },
|
||||
logo(120, { cls: "login-logo" }),
|
||||
h("h1", null, wordmark()),
|
||||
h("div", { class: "tag" }, "Keeper of the hold's door"),
|
||||
message ? h("div", { class: "msg warn" }, message) : null,
|
||||
form));
|
||||
user.focus();
|
||||
}
|
||||
|
||||
async function loggedIn(me) {
|
||||
state.me = me;
|
||||
state.csrf = me.csrf_token || "";
|
||||
layout();
|
||||
window.addEventListener("hashchange", route);
|
||||
await route();
|
||||
}
|
||||
|
||||
function layout() {
|
||||
navEl = h("nav", { class: "main" });
|
||||
mainEl = h("main");
|
||||
bannerEl = h("div", { class: "probation-banner hidden", role: "alert" });
|
||||
const me = state.me;
|
||||
const header = h("header", { class: "top" },
|
||||
h("a", { class: "brand", href: "#/", title: "Dashboard" },
|
||||
logo(30, { small: true }),
|
||||
wordmark({ rule: false, height: 17, cls: "brand-name" })),
|
||||
navEl,
|
||||
h("div", { class: "header-right" },
|
||||
menu(h("span", { class: "user", title: (me.permissions || []).join("\n") }, me.user.username), [
|
||||
h("a", { href: "#/account" }, "Account"),
|
||||
me.admin ? h("a", { href: "#/users" }, "Users") : null,
|
||||
h("button", { type: "button", class: "menu-item", onclick: logout }, "Log out"),
|
||||
], { right: true, active: ["account", "users"].includes(current.nav), label: "Account menu" })));
|
||||
mount(app, h("div", { class: "shell" }, header, bannerEl, mainEl, footer(me.build)));
|
||||
renderNav();
|
||||
refreshProbation();
|
||||
}
|
||||
|
||||
// ---- probation: changes made here roll back unless confirmed
|
||||
|
||||
let probationTimer = null, deadline = 0, pending = 0;
|
||||
|
||||
// refreshProbation asks whether changes wait for confirmation, and shows
|
||||
// the banner with a countdown while they do.
|
||||
export async function refreshProbation() {
|
||||
if (!bannerEl || !can("config", false)) return;
|
||||
let pr;
|
||||
try { pr = await get("/probation"); } catch { return; }
|
||||
pending = (pr.pending || []).length + (pr.takeovers || 0);
|
||||
deadline = Date.now() + (pr.seconds_left || 0) * 1000;
|
||||
drawBanner();
|
||||
clearInterval(probationTimer);
|
||||
if (pending) probationTimer = setInterval(drawBanner, 1000);
|
||||
}
|
||||
|
||||
function drawBanner() {
|
||||
const left = Math.max(0, Math.round((deadline - Date.now()) / 1000));
|
||||
if (!pending) {
|
||||
bannerEl.classList.add("hidden");
|
||||
return;
|
||||
}
|
||||
if (left === 0) {
|
||||
// Rolled back by now: look again (and redraw the page).
|
||||
clearInterval(probationTimer);
|
||||
setTimeout(async () => { await refreshProbation(); rerender(); toast("The unconfirmed changes were rolled back", "warn", 8000); }, 1500);
|
||||
}
|
||||
bannerEl.classList.remove("hidden");
|
||||
const write = can("config", true);
|
||||
mount(bannerEl,
|
||||
h("strong", null, pending === 1 ? "1 change is on probation" : pending + " changes are on probation"),
|
||||
" — rolled back in ", h("span", { class: "mono" }, left + "s"), " unless you confirm them. If this page stops answering, wait: the firewall puts itself back.",
|
||||
write ? h("span", { class: "banner-actions" },
|
||||
h("button", { class: "primary small", onclick: async () => {
|
||||
try { await api("POST", "/confirm"); toast("Confirmed: kept, and loaded at boot"); } catch (e) { toast(e.message, "err"); }
|
||||
refreshProbation();
|
||||
} }, "Confirm"), " ",
|
||||
h("button", { class: "small", onclick: async () => {
|
||||
try { await api("POST", "/rollback"); toast("Rolled back", "warn"); } catch (e) { toast(e.message, "err"); }
|
||||
await refreshProbation();
|
||||
rerender();
|
||||
} }, "Roll back now")) : null);
|
||||
}
|
||||
|
||||
// footer names the build: the release, or the commit for other builds.
|
||||
function footer(b) {
|
||||
if (!b) return null;
|
||||
let title = b.tag ? "Housecarl " + b.version + " (release " + b.tag + ")" : "Housecarl " + b.version;
|
||||
if (!b.tag && b.commit) {
|
||||
title = "Development build of commit " + b.commit + (b.modified ? " with uncommitted changes" : "");
|
||||
if (b.since) title += b.ahead ? ", " + b.ahead + (b.ahead === 1 ? " commit" : " commits") + " after " + b.since : ", at " + b.since;
|
||||
}
|
||||
return h("footer", { class: "site" }, "Powered by ",
|
||||
h("a", { href: b.url, target: "_blank", rel: "noopener", title }, "Housecarl " + (b.tag || b.commit ? b.label : b.version)));
|
||||
}
|
||||
|
||||
function renderNav() {
|
||||
const allowed = (n) => !n[3] || (n[3] === "admin" ? state.me.admin : can(n[3], false));
|
||||
const link = ([key, href, label]) => h("a", { href, class: current.nav === key ? "active" : "" }, label);
|
||||
mount(navEl, navItems.map((n) => {
|
||||
if (!n.menu) return allowed(n) ? link(n) : null;
|
||||
const items = n.items.filter(allowed);
|
||||
if (!items.length) return null;
|
||||
return menu(n.menu, items.map(link), { active: items.some((i) => i[0] === current.nav), label: n.menu + " menu" });
|
||||
}));
|
||||
}
|
||||
|
||||
// menu is a drop-down that closes when an item is picked, on a click
|
||||
// elsewhere and on Escape.
|
||||
function menu(label, items, opts = {}) {
|
||||
const d = h("details", { class: "menu" + (opts.right ? " right" : "") },
|
||||
h("summary", { class: opts.active ? "active" : "", "aria-label": opts.label || null }, label, h("span", { class: "caret", "aria-hidden": "true" }, "▾")),
|
||||
h("div", { class: "menu-list", role: "menu" }, items.filter(Boolean)));
|
||||
d.addEventListener("click", (e) => { if (e.target.closest(".menu-list a, .menu-list button")) d.open = false; });
|
||||
return d;
|
||||
}
|
||||
|
||||
document.addEventListener("click", (e) => {
|
||||
for (const d of document.querySelectorAll("details.menu[open]")) if (!d.contains(e.target)) d.open = false;
|
||||
});
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key !== "Escape") return;
|
||||
for (const d of document.querySelectorAll("details.menu[open]")) { d.open = false; d.querySelector("summary").focus(); }
|
||||
});
|
||||
|
||||
async function logout() {
|
||||
try { await api("POST", "/auth/logout"); } catch { /* ignore */ }
|
||||
state.me = null;
|
||||
state.csrf = "";
|
||||
window.removeEventListener("hashchange", route);
|
||||
renderLogin("You have logged out.");
|
||||
}
|
||||
|
||||
// stopTimers lets a page that refreshes itself stop when it is left.
|
||||
let pageCleanup = null;
|
||||
export function onLeave(fn) { pageCleanup = fn; }
|
||||
|
||||
async function route() {
|
||||
if (!state.me) return;
|
||||
if (pageCleanup) { pageCleanup(); pageCleanup = null; }
|
||||
const { path, query } = parseHash();
|
||||
for (const r of routes) {
|
||||
const m = path.match(r.pattern);
|
||||
if (!m) continue;
|
||||
const params = {};
|
||||
(r.params || []).forEach((p, i) => { params[p] = m[i + 1]; });
|
||||
current = { key: path, nav: r.nav, page: r.page, params, query };
|
||||
renderNav();
|
||||
const gen = ++routeGen;
|
||||
const old = mainEl.firstChild;
|
||||
const slow = setTimeout(() => { if (gen === routeGen && mainEl.firstChild === old) mount(mainEl, h("div", { class: "muted" }, "Loading…")); }, 300);
|
||||
try {
|
||||
await r.page.render(mainEl, params, query);
|
||||
} catch (e) {
|
||||
if (gen === routeGen) mount(mainEl, h("h1", null, "Something went wrong"), errorBox(e));
|
||||
} finally {
|
||||
clearTimeout(slow);
|
||||
}
|
||||
return;
|
||||
}
|
||||
mount(mainEl, h("h1", null, "Not found"), h("p", null, h("a", { href: "#/" }, "Back to the dashboard")));
|
||||
}
|
||||
|
||||
// rerender draws the current page again (after a change).
|
||||
export function rerender() {
|
||||
if (current.page) route();
|
||||
}
|
||||
|
||||
setChangeHandler(() => refreshProbation());
|
||||
|
||||
setUnauthorizedHandler(() => {
|
||||
if (state.me) {
|
||||
state.me = null;
|
||||
window.removeEventListener("hashchange", route);
|
||||
renderLogin("Your session has expired. Please log in again.");
|
||||
}
|
||||
});
|
||||
|
||||
(async function boot() {
|
||||
try {
|
||||
await loggedIn(await get("/auth/whoami"));
|
||||
} catch (e) {
|
||||
if (e.status === 401) renderLogin();
|
||||
else mount(app, h("div", { class: "login panel" }, h("h1", null, "Housecarl"), errorBox(e)));
|
||||
}
|
||||
})();
|
||||
|
||||
window.addEventListener("unhandledrejection", (e) => {
|
||||
toast(e.reason && e.reason.message ? e.reason.message : String(e.reason), "err");
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
// Your account: password and API tokens.
|
||||
import { h, mount, table, time, modal, input, field, select, errorBox, toast, copyBox } from "../ui.js";
|
||||
import { get, api, state, catalog, can } from "../api.js";
|
||||
import { rerender } from "../app.js";
|
||||
import { revoke, rotateDialog } from "./users.js";
|
||||
|
||||
export async function render(el) {
|
||||
const me = state.me;
|
||||
const [tokens, cat] = await Promise.all([get("/me/tokens"), catalog()]);
|
||||
const cur = input({ type: "password", autocomplete: "current-password" });
|
||||
const pw = input({ type: "password", autocomplete: "new-password" });
|
||||
const pw2 = input({ type: "password", autocomplete: "new-password" });
|
||||
const err = h("div");
|
||||
const change = async () => {
|
||||
mount(err);
|
||||
if (pw.value !== pw2.value) { mount(err, h("div", { class: "msg err" }, "The new passwords don't match.")); return; }
|
||||
try {
|
||||
await api("POST", "/me/password", { body: { current: cur.value, password: pw.value } });
|
||||
toast("Password changed; your other sessions are logged out");
|
||||
cur.value = pw.value = pw2.value = "";
|
||||
} catch (e) { mount(err, errorBox(e)); }
|
||||
};
|
||||
mount(el,
|
||||
h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, me.user.username),
|
||||
h("div", { class: "muted" }, me.admin ? "Administrator" : "May: " + (me.permissions.join(", ") || "nothing")))),
|
||||
me.method === "session" ? h("div", { class: "panel" },
|
||||
h("h2", null, "Password"),
|
||||
h("div", { class: "form-grid" }, field("Current password", cur), field("New password", pw), field("Repeat", pw2)), err,
|
||||
h("div", { class: "actions", style: { marginTop: "12px" } }, h("button", { class: "primary", onclick: change }, "Change password"))) : null,
|
||||
h("div", { class: "panel" },
|
||||
h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h2", null, "API tokens"),
|
||||
h("div", { class: "muted" }, "For the hc CLI and scripts: 'hc config set token'.")),
|
||||
h("button", { onclick: () => newToken(cat) }, "New token")),
|
||||
table([
|
||||
{ label: "Name", render: (t) => t.name },
|
||||
{ label: "May", render: (t) => h("span", { class: "mono small" }, t.grant_strings.join(" ")) },
|
||||
{ label: "Expires", render: (t) => t.expires_at ? time(t.expires_at) : "never" },
|
||||
{ label: "Last used", render: (t) => t.last_used_at ? time(t.last_used_at) : "never" },
|
||||
{ label: "", tdClass: "actions-cell", render: (t) => [
|
||||
h("button", { class: "small reveal", onclick: () => rotateDialog(t) }, "Rotate"), " ",
|
||||
h("button", { class: "small reveal", onclick: () => revoke(t) }, "Revoke")] },
|
||||
], tokens, { empty: "No tokens." })));
|
||||
}
|
||||
|
||||
async function newToken(cat) {
|
||||
const name = input({ placeholder: "laptop, backup script…" });
|
||||
// Only roles within what the user may do: a token can never do more
|
||||
// than its user, so offering more would only mislead.
|
||||
const roles = Object.keys(cat.roles).filter((r) => cat.roles[r].every((g) => {
|
||||
const [res, acc] = g.split(":");
|
||||
return can(res, acc === "write");
|
||||
}));
|
||||
const role = select([["", "everything you may do"], ...roles.map((r) => [r, r + ": " + cat.roles[r].join(" ")])], "");
|
||||
const expires = select([["", "never"], ["30d", "30 days"], ["90d", "90 days"], ["365d", "a year"]], "90d");
|
||||
const err = h("div");
|
||||
await modal("New API token", h("div", null, h("div", { class: "form-grid" }, field("Name", name), field("May do", role), field("Expires", expires)), err), [
|
||||
{ label: "Cancel", value: null },
|
||||
{ label: "Create", kind: "primary", onclick: async () => {
|
||||
mount(err);
|
||||
try {
|
||||
const t = await api("POST", "/me/tokens", { body: { name: name.value.trim(), role: role.value || undefined, expires_in: expires.value || undefined } });
|
||||
await modal("Token created", h("div", null, h("p", null, "Copy it now: it is not shown again."), copyBox(t.secret)));
|
||||
rerender();
|
||||
return true;
|
||||
} catch (e) {
|
||||
mount(err, errorBox(e));
|
||||
return false;
|
||||
}
|
||||
} },
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// The audit log: logins, users, tokens, changes, confirmations and
|
||||
// rollbacks, takeovers, dynamic set entries.
|
||||
import { h, mount, table, time, input, debounce, badge } from "../ui.js";
|
||||
import { get } from "../api.js";
|
||||
|
||||
export async function render(el) {
|
||||
const search = input({ type: "search", placeholder: "user, action, object or detail" });
|
||||
const out = h("div");
|
||||
const draw = async () => {
|
||||
const q = search.value.trim();
|
||||
const evs = await get("/audit", { q, limit: 300 });
|
||||
mount(out, table([
|
||||
{ label: "When", render: (e) => time(e.time) },
|
||||
{ label: "User", render: (e) => [e.username || "–", e.auth ? h("span", { class: "muted small" }, " " + e.auth) : null] },
|
||||
{ label: "From", render: (e) => h("span", { class: "mono small" }, e.ip || "") },
|
||||
{ label: "Action", render: (e) => h("span", { class: "mono" }, e.action) },
|
||||
{ label: "Object", render: (e) => e.object },
|
||||
{ label: "Result", render: (e) => badge(e.result, e.result === "success" ? "ok" : "err") },
|
||||
{ label: "Detail", render: (e) => h("span", { class: "small" }, e.detail) },
|
||||
], evs, { empty: "Nothing logged" + (q ? " that matches." : " yet.") }));
|
||||
};
|
||||
search.addEventListener("input", debounce(draw, 300));
|
||||
mount(el, h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, "Audit log"))),
|
||||
h("div", { class: "filter-row" }, search), h("div", { class: "panel" }, out));
|
||||
await draw();
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// The web UI's certificate: obtained through Jarl (tls.acme), its state,
|
||||
// the last check, and buttons to test and renew.
|
||||
import { h, mount, table, time, fmtTime, badge, errorBox, toast } from "../ui.js";
|
||||
import { get, api } from "../api.js";
|
||||
import { onLeave, rerender } from "../app.js";
|
||||
|
||||
const stateBadge = {
|
||||
ok: ["valid", "ok"], working: ["working…", "accent"], error: ["failing", "err"], none: ["none yet", ""],
|
||||
};
|
||||
|
||||
export async function render(el) {
|
||||
const r = await get("/certificate");
|
||||
if (r.source !== "acme") {
|
||||
mount(el, h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, "Certificate"))),
|
||||
h("div", { class: "panel" },
|
||||
r.source === "file"
|
||||
? h("p", null, "The web UI uses the certificate in tls.cert_file, which you renew yourself.")
|
||||
: h("p", null, "The web UI is plain HTTP. Housecarl can get its own certificate from Let's Encrypt, with Jarl publishing the challenge: set tls.acme and jarl in housecarl.yaml (see docs/certificates.md)."),
|
||||
));
|
||||
return;
|
||||
}
|
||||
const st = r.status;
|
||||
const out = h("div");
|
||||
const checkBox = h("div");
|
||||
const busy = st.state === "working";
|
||||
const testBtn = h("button", { disabled: busy, onclick: async () => {
|
||||
testBtn.disabled = true;
|
||||
mount(checkBox, h("div", { class: "msg info" }, h("span", { class: "spinner" }), "Checking: writing a test record through Jarl and waiting until it is seen (up to a few minutes)…"));
|
||||
try {
|
||||
const c = await api("POST", "/certificate/check");
|
||||
mount(checkBox, checkView(c));
|
||||
} catch (e) { mount(checkBox, errorBox(e)); }
|
||||
testBtn.disabled = false;
|
||||
} }, "Test");
|
||||
const renewBtn = h("button", { class: "primary", disabled: busy, onclick: async () => {
|
||||
try {
|
||||
await api("POST", "/certificate/renew");
|
||||
toast("Getting a new certificate…");
|
||||
setTimeout(rerender, 1500);
|
||||
} catch (e) { toast(e.message, "err", 8000); }
|
||||
} }, st.not_after ? "Renew now" : "Get the certificate now");
|
||||
const [label, kind] = stateBadge[st.state] || [st.state, ""];
|
||||
mount(out,
|
||||
h("div", { class: "panel" },
|
||||
h("dl", { class: "kv" },
|
||||
h("dt", null, "Name"), h("dd", null, h("strong", null, st.domain), " ", badge(label, kind)),
|
||||
h("dt", null, "From"), h("dd", null, st.staging ? ["Let's Encrypt staging ", badge("not trusted by browsers", "warn")] : st.directory),
|
||||
st.not_after ? [h("dt", null, "Valid until"), h("dd", null, fmtTime(st.not_after), " (", days(st.not_after), "), issued by ", st.issuer)] : null,
|
||||
st.renew_at ? [h("dt", null, "Renews"), h("dd", null, fmtTime(st.renew_at))] : null,
|
||||
st.last_attempt ? [h("dt", null, "Last attempt"), h("dd", null, time(st.last_attempt))] : null,
|
||||
st.next_attempt && !busy ? [h("dt", null, "Next check"), h("dd", null, time(st.next_attempt))] : null),
|
||||
st.last_error ? h("div", { class: "msg err" }, st.last_error) : null,
|
||||
st.staging && st.state === "ok" ? h("div", { class: "msg warn" }, "This certificate is from Let's Encrypt's staging service, to prove everything works. Browsers warn about it. Set tls.acme.directory: letsencrypt in housecarl.yaml and restart housecarld for a trusted one.") : null,
|
||||
h("div", { class: "actions mt" }, testBtn, renewBtn,
|
||||
h("span", { class: "muted small" }, "Test runs the check without asking Let's Encrypt; a certificate order always runs it first."))),
|
||||
checkBox);
|
||||
if (st.check) mount(checkBox, checkView(st.check));
|
||||
mount(el, h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, "Certificate"),
|
||||
h("div", { class: "muted" }, "The web UI's certificate, obtained from the CA with Jarl publishing the DNS-01 challenge."))), out);
|
||||
if (busy) {
|
||||
const t = setTimeout(rerender, 3000);
|
||||
onLeave(() => clearTimeout(t));
|
||||
}
|
||||
}
|
||||
|
||||
// days says how long until a time: "in 89 days", "in 5 hours".
|
||||
function days(iso) {
|
||||
const ms = new Date(iso) - Date.now();
|
||||
if (ms <= 0) return "ended";
|
||||
const d = Math.floor(ms / 86400000);
|
||||
return d >= 2 ? "in " + d + " days" : "in " + Math.max(1, Math.round(ms / 3600000)) + " hours";
|
||||
}
|
||||
|
||||
function checkView(c) {
|
||||
const mark = { ok: ["ok", "ok"], fail: ["failed", "err"], warn: ["warning", "warn"], skip: ["skipped", ""] };
|
||||
return h("div", { class: "panel" },
|
||||
h("h2", null, "Check, ", time(c.time), " ", c.ok ? badge("passed", "ok") : badge("failed", "err")),
|
||||
table([
|
||||
{ label: "Step", render: (s) => h("strong", null, s.name) },
|
||||
{ label: "", render: (s) => badge(...(mark[s.status] || [s.status, ""])) },
|
||||
{ label: "What happened", render: (s) => [s.detail,
|
||||
(s.queries || []).length ? h("details", { class: "small", open: s.status === "fail" },
|
||||
h("summary", null, "DNS questions (" + s.queries.length + ")"),
|
||||
h("pre", { class: "wrap" }, s.queries.join("\n"))) : null] },
|
||||
], c.steps));
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// Dashboard: the ruleset's state, problems, interfaces, the busiest rules
|
||||
// and the latest drops.
|
||||
import { h, mount, table, badge, problemsList, time } from "../ui.js";
|
||||
import { get, can } from "../api.js";
|
||||
import { onLeave } from "../app.js";
|
||||
import { describe, hits, humanCount } from "../widgets.js";
|
||||
|
||||
export async function render(el) {
|
||||
const draw = async () => {
|
||||
const st = await get("/status");
|
||||
const config = can("config", false), logs = can("log", false);
|
||||
const [rules, cs, log] = await Promise.all([
|
||||
config ? get("/rules") : [],
|
||||
logs ? get("/counters").then((c) => c.counters).catch(() => ({})) : {},
|
||||
logs ? get("/log", { limit: 8 }).catch(() => []) : [],
|
||||
]);
|
||||
const busiest = rules.filter((r) => cs["r" + r.id] && cs["r" + r.id].packets > 0)
|
||||
.sort((a, b) => cs["r" + b.id].packets - cs["r" + a.id].packets).slice(0, 8);
|
||||
const card = (label, value, sub, href) => {
|
||||
const body = [h("div", { class: "label" }, label), h("div", { class: "value" }, String(value)), h("div", { class: "sub" }, sub)];
|
||||
return href ? h("a", { class: "card link", href }, body) : h("div", { class: "card" }, body);
|
||||
};
|
||||
const policyDrops = (c) => cs["p-" + c] ? humanCount(cs["p-" + c].packets) : "–";
|
||||
mount(el,
|
||||
h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, "Dashboard"))),
|
||||
st.unconfigured ? h("div", { class: "msg info" }, "Nothing is configured yet, so Housecarl applies no ruleset. Write the rules that let you in (SSH, this web UI) first: the input policy drops what no rule accepts. Or ",
|
||||
h("a", { href: "#/import" }, "import your ruleset"), ".") :
|
||||
!st.applied ? h("div", { class: "msg err" }, "The ruleset isn't in force: ", st.apply_error) : null,
|
||||
st.other_tables.length ? h("div", { class: "msg info" }, "Other tables in the ruleset: ", h("span", { class: "mono" }, st.other_tables.join(", ")),
|
||||
". A packet must get past them too. Once Housecarl's ruleset replaces one, 'hc takeover' deletes it.") : null,
|
||||
h("div", { class: "cards" },
|
||||
card("Ruleset", st.applied ? "in force" : st.unconfigured ? "none yet" : "not in force", "table inet housecarl", null),
|
||||
card("Rules", st.counts.rule, st.counts.zone + " zones · " + st.counts.set + " sets", "#/rules"),
|
||||
card("NAT", st.counts.forward + st.counts.snat + st.counts.redirect, st.counts.forward + " port forwards", "#/nat"),
|
||||
card("Dropped by policy", policyDrops("input") + " / " + policyDrops("forward"), "input / forward", logs ? "#/log" : null),
|
||||
card("Problems", st.problems.length, st.problems.length ? "see below" : "none", null)),
|
||||
st.problems.length ? h("div", { class: "panel" }, h("h2", null, "Problems"), problemsList(st.problems)) : null,
|
||||
h("div", { class: "panel" }, h("h2", null, "Interfaces"), table([
|
||||
{ label: "Interface", render: (i) => [h("span", { class: "dot " + (i.up ? "ok" : "err") }), h("span", { class: "mono" }, h("strong", null, i.name))] },
|
||||
{ label: "Zone", render: (i) => i.zone || h("span", { class: "muted" }, "none") },
|
||||
{ label: "Addresses", render: (i) => h("span", { class: "mono small" }, i.addresses.join(", ")) },
|
||||
], st.interfaces.filter((i) => i.name !== "lo"))),
|
||||
busiest.length ? h("div", { class: "panel" },
|
||||
h("div", { class: "panel-head" }, h("h2", null, "Busiest rules"), h("a", { class: "button small", href: "#/rules" }, "All rules")),
|
||||
table([
|
||||
{ label: "Chain", render: (r) => r.chain },
|
||||
{ label: "Rule", render: (r) => r.raw || describe(r) },
|
||||
{ label: "Action", render: (r) => badge(r.action, { accept: "ok", drop: "err", reject: "warn" }[r.action]) },
|
||||
{ label: "Hits", render: (r) => hits(cs, "r" + r.id) },
|
||||
{ label: "Comment", render: (r) => r.comment },
|
||||
], busiest)) : null,
|
||||
log.length ? h("div", { class: "panel" },
|
||||
h("div", { class: "panel-head" }, h("h2", null, "Latest logged packets"), h("a", { class: "button small", href: "#/log" }, "The log")),
|
||||
table([
|
||||
{ label: "When", render: (e) => time(e.time) },
|
||||
{ label: "Why", render: (e) => e.prefix },
|
||||
{ label: "From", render: (e) => h("span", { class: "mono" }, e.source) },
|
||||
{ label: "To", render: (e) => h("span", { class: "mono" }, e.destination, e.destination_port ? ":" + e.destination_port : "") },
|
||||
{ label: "Protocol", render: (e) => e.protocol },
|
||||
], log)) : null);
|
||||
};
|
||||
await draw();
|
||||
const t = setInterval(() => { if (!document.hidden) draw().catch(() => {}); }, 15000);
|
||||
onLeave(() => clearInterval(t));
|
||||
}
|
||||
Loaded 100 of 118 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user