Compare commits
2
Commits
main
...
v0.1.0-rc.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eaadbe6a3d | ||
|
|
34292eeebb |
No files matched your search
@@ -0,0 +1,69 @@
|
||||
# Builds and publishes a release when a version tag (v0.2.0, v1.0.0-rc.1)
|
||||
# is pushed:
|
||||
# * a Gitea release with the CHANGELOG notes, the linux tarballs, the CLI
|
||||
# for Linux, macOS and Windows, the .debs and SHA256SUMS;
|
||||
# * the .debs (amd64, arm64) in the owner's Debian package registry:
|
||||
# distribution "stable", or "testing" for a pre-release (a version
|
||||
# with a hyphen), which Gitea also marks as a pre-release.
|
||||
#
|
||||
# Needs a repository secret RELEASE_TOKEN: an access token of a user who
|
||||
# can write to the repository, with the write:repository and write:package
|
||||
# scopes. docs/development.md ("Releasing") has the steps.
|
||||
name: release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Check the tag against VERSION and CHANGELOG.md
|
||||
run: |
|
||||
version=$(cat VERSION)
|
||||
if [ "$GITHUB_REF_NAME" != "v$version" ]; then
|
||||
echo "::error::tag $GITHUB_REF_NAME does not match VERSION ($version)"
|
||||
exit 1
|
||||
fi
|
||||
# The release notes (a pre-release may use its final version's
|
||||
# section or Unreleased; see scripts/gitea-release.sh).
|
||||
if ! scripts/gitea-release.sh notes > /dev/null; then
|
||||
echo "::error::no release notes for $version in CHANGELOG.md"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Tools
|
||||
run: |
|
||||
missing=
|
||||
for t in jq dpkg-deb curl; do command -v $t >/dev/null || missing="$missing $t"; done
|
||||
if [ -n "$missing" ]; then
|
||||
sudo=; [ "$(id -u)" = 0 ] || sudo=sudo
|
||||
$sudo apt-get update -qq && $sudo apt-get install -y -qq jq dpkg curl
|
||||
fi
|
||||
|
||||
- name: Test
|
||||
run: make test
|
||||
|
||||
- name: Build
|
||||
env:
|
||||
# Checked against VERSION above; don't depend on the checkout
|
||||
# having the tag.
|
||||
RELEASE_TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
export DEB_MAINTAINER="$(git log -1 --format='%an <%ae>')"
|
||||
make dist
|
||||
make cli-dist
|
||||
make deb DEB_ARCH=amd64
|
||||
make deb DEB_ARCH=arm64
|
||||
|
||||
- name: Publish
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
run: scripts/gitea-release.sh
|
||||
@@ -1,8 +1,9 @@
|
||||
# Working on Housecarl
|
||||
|
||||
Notes for coding agents (and people) changing this repository. Read this
|
||||
first, then [docs/design.md](docs/design.md), the plan and its open
|
||||
questions.
|
||||
first, then [docs/design.md](docs/design.md) (how it fits together and
|
||||
what 0.1 leaves out) and [docs/development.md](docs/development.md)
|
||||
(layout, tests, releasing).
|
||||
|
||||
Housecarl is a firewall manager for Linux: `housecarld` keeps the
|
||||
machine's nftables ruleset and serves a REST API and a web UI; `hc` is the
|
||||
@@ -12,9 +13,6 @@ their ways: the same auth, socket and web UI helpers, the same release
|
||||
process, the same look. When in doubt, do it the way Jarl and Steward do.
|
||||
Go module `housecarl`, `CGO_ENABLED=0`, Linux only for housecarld.
|
||||
|
||||
Housecarl is at its start: only the version commands, the build and the
|
||||
brand exist. Most rules below are for what is coming.
|
||||
|
||||
## Ground rules
|
||||
|
||||
* **The database is the source of truth.** The ruleset is rendered from
|
||||
@@ -28,11 +26,17 @@ brand exist. Most rules below are for what is coming.
|
||||
* **Housecarl's own table only.** Write `inet housecarl` and nothing
|
||||
else; never flush the whole ruleset (Docker, libvirt and others keep
|
||||
theirs).
|
||||
* **Don't lock the user out.** Changes to the rules for the machine itself
|
||||
are applied on probation and rolled back unless confirmed
|
||||
(docs/design.md).
|
||||
* **Don't lock the user out.** Changes made over the network are applied
|
||||
on probation and rolled back unless confirmed (docs/design.md). An
|
||||
empty configuration applies nothing. But never force a rule that keeps
|
||||
SSH or the web UI open: the maintainer doesn't want one.
|
||||
* **Stay within nftables 1.1.3** (Debian 13). The dev machine's newer nft
|
||||
accepts things 1.1.3 refuses; the tests with `HOUSECARL_NFT` and the
|
||||
e2e test use 1.1.3.
|
||||
* **Every user-visible change updates `CHANGELOG.md`** under
|
||||
`## [Unreleased]`, and the matching `docs/` page in the same change.
|
||||
New validation codes get a case in `internal/validate/validate_test.go`
|
||||
and a row in `docs/validation.md`.
|
||||
* **Errors** say what is wrong and how to fix it, naming the object.
|
||||
* **Schema changes** append a migration; never edit a shipped one.
|
||||
* `make test` runs `go vet` and a `gofmt -l` check. `goimports` isn't
|
||||
@@ -56,8 +60,25 @@ brand exist. Most rules below are for what is coming.
|
||||
|
||||
```sh
|
||||
make test # vet + gofmt check + unit tests
|
||||
make e2e # real traffic through a router container (needs Docker)
|
||||
```
|
||||
|
||||
* **Docker** on this machine needs `DOCKER_HOST=unix:///var/run/docker.sock`.
|
||||
`docker build` can't reach the network here: images are made with
|
||||
`docker run` + `docker commit` (scripts/e2e.sh makes `housecarl-nft`).
|
||||
The e2e test prints `RESULT: N passed, 0 failed`; run it after any
|
||||
change to `render`, `nft`, `engine`, `server`, `importer` or the CLI.
|
||||
* **Real nft in unit tests:**
|
||||
`HOUSECARL_NFT="docker run --rm -i --cap-add NET_ADMIN housecarl-nft nft"`
|
||||
and `HOUSECARL_NFT_IMAGE=housecarl-nft`; without them those tests skip.
|
||||
Unprivileged namespaces (`unshare -rn`) are blocked here by AppArmor.
|
||||
* **The systemd sandbox is only tested by a package test**: `make deb`,
|
||||
installed in a `debian:trixie` container running systemd
|
||||
(`--privileged --cgroupns=host /sbin/init`).
|
||||
* **The web UI** has no JS tests. Bundle `assets/app.js` with esbuild to
|
||||
catch syntax and import errors, and look at it in headless Chrome
|
||||
against the e2e lab (`KEEP=1`, web UI on `10.88.10.1:8070`).
|
||||
|
||||
## Web UI rules
|
||||
|
||||
As in Jarl and Steward:
|
||||
|
||||
+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,100 @@ 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 0.1.0 is in testing (0.1.0-rc.1): zones, address sets and
|
||||
services with ordered rules, IPv4 and IPv6, port forwards with hairpin NAT
|
||||
and IPv6 pinholes, source NAT and redirects, built-ins, probation for
|
||||
network changes, the ruleset at boot, hits and the drop log, nftables
|
||||
import, a web UI, REST API and CLI.
|
||||
|
||||
## Licence
|
||||
|
||||
|
||||
+630
@@ -0,0 +1,630 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/term"
|
||||
|
||||
"housecarl/internal/api"
|
||||
"housecarl/internal/model"
|
||||
)
|
||||
|
||||
func historyCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{Use: "history", Short: "The history of configuration changes, and undo"}
|
||||
var limit int
|
||||
list := &cobra.Command{
|
||||
Use: "list", Short: "List recent changes", Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var hs []model.HistoryEntry
|
||||
if err := c.Get(ctx(), "/history", q("limit", strconv.Itoa(limit)), &hs); err != nil {
|
||||
return err
|
||||
}
|
||||
return output(hs, func(w io.Writer) {
|
||||
fmt.Fprintln(w, "ID\tWHEN\tBY\tVIA\tCHANGES\tDESCRIPTION")
|
||||
for _, h := range hs {
|
||||
desc := h.Description
|
||||
if h.RevertedBy != 0 {
|
||||
desc += fmt.Sprintf(" (undone by %d)", h.RevertedBy)
|
||||
}
|
||||
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%d\t%s\n", h.ID, h.Time.Local().Format("2006-01-02 15:04"), h.Username, h.Via, len(h.Changes), desc)
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
list.Flags().IntVar(&limit, "limit", 30, "how many")
|
||||
cmd.AddCommand(list)
|
||||
cmd.AddCommand(&cobra.Command{
|
||||
Use: "show ID", Short: "Show a change: each object before and after", Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var h model.HistoryEntry
|
||||
if err := c.Get(ctx(), "/history/"+args[0], nil, &h); err != nil {
|
||||
return err
|
||||
}
|
||||
return output(h, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "%d\t%s by %s (%s): %s\n", h.ID, h.Time.Local().Format("2006-01-02 15:04:05"), h.Username, h.Via, h.Description)
|
||||
for _, ch := range h.Changes {
|
||||
fmt.Fprintf(w, "\n%s %s %s\n", ch.Action(), ch.Kind, ch.Label)
|
||||
for _, line := range diffJSON(ch.Before, ch.After) {
|
||||
fmt.Fprintln(w, " "+line)
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
cmd.AddCommand(&cobra.Command{
|
||||
Use: "undo ID",
|
||||
Short: "Undo a change",
|
||||
Long: `Undoes a change by applying it backwards. It is refused when an object the
|
||||
change touched has changed since; undo the later changes first.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var resp api.MutationResponse
|
||||
if err := c.Do(ctx(), "POST", "/history/"+args[0]+"/revert", nil, nil, &resp); err != nil {
|
||||
return err
|
||||
}
|
||||
return report(&resp)
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
// diffJSON lists the fields that differ between two versions of an
|
||||
// object: "name: office -> lab".
|
||||
func diffJSON(before, after json.RawMessage) []string {
|
||||
var a, b map[string]any
|
||||
json.Unmarshal(before, &a)
|
||||
json.Unmarshal(after, &b)
|
||||
keys := map[string]bool{}
|
||||
for k := range a {
|
||||
keys[k] = true
|
||||
}
|
||||
for k := range b {
|
||||
keys[k] = true
|
||||
}
|
||||
var names []string
|
||||
for k := range keys {
|
||||
names = append(names, k)
|
||||
}
|
||||
sort.Strings(names)
|
||||
var out []string
|
||||
show := func(v any, ok bool) string {
|
||||
if !ok {
|
||||
return "-"
|
||||
}
|
||||
x, _ := json.Marshal(v)
|
||||
return string(x)
|
||||
}
|
||||
for _, k := range names {
|
||||
va, oka := a[k]
|
||||
vb, okb := b[k]
|
||||
sa, sb := show(va, oka), show(vb, okb)
|
||||
if sa == sb {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case before == nil:
|
||||
out = append(out, k+": "+sb)
|
||||
case after == nil:
|
||||
out = append(out, k+": "+sa)
|
||||
default:
|
||||
out = append(out, k+": "+sa+" -> "+sb)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func whoamiCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "whoami", Short: "Show who you are to the server", Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var me api.Whoami
|
||||
if err := c.Get(ctx(), "/auth/whoami", nil, &me); err != nil {
|
||||
return err
|
||||
}
|
||||
return output(me, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "User:\t%s (%s)\n", me.User.Username, me.Method)
|
||||
fmt.Fprintf(w, "Administrator:\t%v\n", me.Admin)
|
||||
fmt.Fprintf(w, "May:\t%s\n", strings.Join(me.Permissions, ", "))
|
||||
fmt.Fprintf(w, "Server:\tHousecarl %s\n", me.Build.String())
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func readSecret(prompt string) (string, error) {
|
||||
fmt.Fprint(os.Stderr, prompt)
|
||||
if term.IsTerminal(int(os.Stdin.Fd())) {
|
||||
b, err := term.ReadPassword(int(os.Stdin.Fd()))
|
||||
fmt.Fprintln(os.Stderr)
|
||||
return strings.TrimSpace(string(b)), err
|
||||
}
|
||||
var s string
|
||||
_, err := fmt.Fscanln(os.Stdin, &s)
|
||||
return strings.TrimSpace(s), err
|
||||
}
|
||||
|
||||
func userID(name string) (int64, error) {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var us []api.User
|
||||
if err := c.Get(ctx(), "/users", nil, &us); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for _, u := range us {
|
||||
if strings.EqualFold(u.Username, name) || strconv.FormatInt(u.ID, 10) == name {
|
||||
return u.ID, nil
|
||||
}
|
||||
}
|
||||
return 0, fmt.Errorf("no user %s", name)
|
||||
}
|
||||
|
||||
func userCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{Use: "user", Aliases: []string{"users"}, Short: "Users (administrators only)"}
|
||||
cmd.AddCommand(&cobra.Command{
|
||||
Use: "list", Short: "List users", Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var us []api.User
|
||||
if err := c.Get(ctx(), "/users", nil, &us); err != nil {
|
||||
return err
|
||||
}
|
||||
return output(us, func(w io.Writer) {
|
||||
fmt.Fprintln(w, "ID\tUSER\tADMIN\tGRANTS\tPASSWORD\tDISABLED\tLAST LOGIN")
|
||||
for _, u := range us {
|
||||
login := "-"
|
||||
if u.LastLoginAt != nil {
|
||||
login = fmtWhen(*u.LastLoginAt)
|
||||
}
|
||||
fmt.Fprintf(w, "%d\t%s\t%v\t%s\t%v\t%v\t%s\n", u.ID, u.Username, u.Admin, orDash(strings.Join(u.GrantStrings, " ")), u.HasPassword, u.Disabled, login)
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
var admin, noPassword bool
|
||||
var role, comment string
|
||||
var grants []string
|
||||
add := &cobra.Command{
|
||||
Use: "add USER",
|
||||
Short: "Add a user (asks for the password)",
|
||||
Long: `Adds a user. Without --admin, give what they may do: a role (viewer,
|
||||
operator, log) or grants such as --grant config:read --grant sets:write:guests.
|
||||
--no-password makes a service account that only uses API tokens.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
in := api.UserInput{Username: &args[0], Admin: &admin}
|
||||
if !noPassword {
|
||||
pw, err := readSecret("Password for " + args[0] + ": ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
in.Password = &pw
|
||||
}
|
||||
if role != "" {
|
||||
in.Role = &role
|
||||
}
|
||||
if grants != nil {
|
||||
in.Grants = &grants
|
||||
}
|
||||
if comment != "" {
|
||||
in.Comment = &comment
|
||||
}
|
||||
var u api.User
|
||||
if err := c.Do(ctx(), "POST", "/users", nil, in, &u); err != nil {
|
||||
return err
|
||||
}
|
||||
if !flagQuiet {
|
||||
fmt.Printf("added user %s (%d)\n", u.Username, u.ID)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
f := add.Flags()
|
||||
f.BoolVar(&admin, "admin", false, "an administrator (may do everything)")
|
||||
f.StringVar(&role, "role", "", "a role: viewer, operator or log")
|
||||
f.StringArrayVar(&grants, "grant", nil, "a grant, resource:access (config, sets, log, audit; read or write; sets:write:SET,SET)")
|
||||
f.BoolVar(&noPassword, "no-password", false, "no password: API tokens only")
|
||||
f.StringVar(&comment, "comment", "", "a comment")
|
||||
cmd.AddCommand(add)
|
||||
|
||||
var setPassword, disable, enable bool
|
||||
update := &cobra.Command{
|
||||
Use: "update USER", Short: "Change a user", Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
id, err := userID(args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c, _ := newClient()
|
||||
in := api.UserInput{}
|
||||
fl := cmd.Flags()
|
||||
if fl.Changed("admin") {
|
||||
in.Admin = &admin
|
||||
}
|
||||
if fl.Changed("role") {
|
||||
in.Role = &role
|
||||
}
|
||||
if fl.Changed("grant") {
|
||||
in.Grants = &grants
|
||||
}
|
||||
if fl.Changed("comment") {
|
||||
in.Comment = &comment
|
||||
}
|
||||
if disable || enable {
|
||||
in.Disabled = &disable
|
||||
}
|
||||
if setPassword {
|
||||
pw, err := readSecret("New password for " + args[0] + ": ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
in.Password = &pw
|
||||
}
|
||||
var u api.User
|
||||
if err := c.Do(ctx(), "PATCH", "/users/"+strconv.FormatInt(id, 10), nil, in, &u); err != nil {
|
||||
return err
|
||||
}
|
||||
if !flagQuiet {
|
||||
fmt.Printf("updated user %s\n", u.Username)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
f = update.Flags()
|
||||
f.BoolVar(&admin, "admin", false, "make (or with --admin=false, unmake) an administrator")
|
||||
f.StringVar(&role, "role", "", "replace the grants with a role's")
|
||||
f.StringArrayVar(&grants, "grant", nil, "replace the grants (repeat for several)")
|
||||
f.BoolVar(&setPassword, "password", false, "set a new password (asks for it)")
|
||||
f.BoolVar(&disable, "disable", false, "disable the user (logs them out)")
|
||||
f.BoolVar(&enable, "enable", false, "enable the user again")
|
||||
f.StringVar(&comment, "comment", "", "a comment")
|
||||
cmd.AddCommand(update)
|
||||
cmd.AddCommand(&cobra.Command{
|
||||
Use: "delete USER", Short: "Delete a user and their tokens", Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
id, err := userID(args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c, _ := newClient()
|
||||
if err := c.Do(ctx(), "DELETE", "/users/"+strconv.FormatInt(id, 10), nil, nil, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
if !flagQuiet {
|
||||
fmt.Println("deleted user", args[0])
|
||||
}
|
||||
return nil
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
func tokenCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{Use: "token", Aliases: []string{"tokens"}, Short: "API tokens"}
|
||||
var all bool
|
||||
var user string
|
||||
list := &cobra.Command{
|
||||
Use: "list", Short: "List your tokens (--all: everyone's, for administrators)", Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path := "/me/tokens"
|
||||
if all {
|
||||
path = "/tokens"
|
||||
}
|
||||
var ts []api.Token
|
||||
if err := c.Get(ctx(), path, nil, &ts); err != nil {
|
||||
return err
|
||||
}
|
||||
return output(ts, func(w io.Writer) {
|
||||
fmt.Fprintln(w, "ID\tUSER\tNAME\tGRANTS\tEXPIRES\tLAST USED")
|
||||
for _, t := range ts {
|
||||
exp, used := "never", "never"
|
||||
if t.ExpiresAt != nil {
|
||||
exp = fmtWhen(*t.ExpiresAt)
|
||||
}
|
||||
if t.LastUsedAt != nil {
|
||||
used = fmtWhen(*t.LastUsedAt) + " from " + t.LastUsedIP
|
||||
}
|
||||
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\t%s\n", t.ID, t.Username, t.Name, strings.Join(t.GrantStrings, " "), exp, used)
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
list.Flags().BoolVar(&all, "all", false, "everyone's tokens")
|
||||
cmd.AddCommand(list)
|
||||
var role, expires string
|
||||
var grants []string
|
||||
var save bool
|
||||
create := &cobra.Command{
|
||||
Use: "create NAME",
|
||||
Short: "Make an API token (the secret is shown once)",
|
||||
Long: `Makes an API token. It has all of its user's permissions unless --role or
|
||||
--grant narrows it. On the server as root, --user names whose token it is.
|
||||
--save puts it in the CLI's configuration.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path := "/me/tokens"
|
||||
if user != "" {
|
||||
id, err := userID(user)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path = "/users/" + strconv.FormatInt(id, 10) + "/tokens"
|
||||
}
|
||||
in := api.TokenInput{Name: args[0], Role: role, Grants: grants, ExpiresIn: expires}
|
||||
var t api.Token
|
||||
if err := c.Do(ctx(), "POST", path, nil, in, &t); err != nil {
|
||||
return err
|
||||
}
|
||||
if save {
|
||||
cfg, err := loadCLIConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.Token = t.Secret
|
||||
if err := saveCLIConfig(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, "saved in", flagConfig)
|
||||
}
|
||||
if flagOutput == "json" || flagOutput == "yaml" {
|
||||
return output(t, nil)
|
||||
}
|
||||
fmt.Println(t.Secret)
|
||||
if !flagQuiet {
|
||||
fmt.Fprintln(os.Stderr, "this is the only time the token is shown; keep it safe")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
f := create.Flags()
|
||||
f.StringVar(&role, "role", "", "narrow it to a role: viewer, operator or log")
|
||||
f.StringArrayVar(&grants, "grant", nil, "narrow it to grants (resource:access)")
|
||||
f.StringVar(&expires, "expires", "", "expire after this long (90d, 720h)")
|
||||
f.StringVar(&user, "user", "", "the user it belongs to (administrators)")
|
||||
f.BoolVar(&save, "save", false, "save it as this CLI's token")
|
||||
cmd.AddCommand(create)
|
||||
var grace, rexp string
|
||||
var current, rsave bool
|
||||
rotate := &cobra.Command{
|
||||
Use: "rotate [ID]",
|
||||
Short: "Give a token a new secret (shown once)",
|
||||
Long: `Gives a token a new secret. It keeps its ID, name and permissions, so only
|
||||
the programs that use it need the new secret. The old secret stops at once,
|
||||
or after --grace (up to 7d) so you can update them without an outage. A
|
||||
token that expires gets its lifetime again, from now, unless --expires says
|
||||
otherwise (90d, or never).
|
||||
|
||||
hc token rotate --current --save # this CLI's own token`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id := ""
|
||||
switch {
|
||||
case current && len(args) == 0:
|
||||
var me api.Whoami
|
||||
if err := c.Get(ctx(), "/auth/whoami", nil, &me); err != nil {
|
||||
return err
|
||||
}
|
||||
if me.TokenID == 0 {
|
||||
return fmt.Errorf("this CLI isn't using a token (%s); give the token's ID", me.Method)
|
||||
}
|
||||
id = strconv.FormatInt(me.TokenID, 10)
|
||||
case len(args) == 1 && !current:
|
||||
id = args[0]
|
||||
default:
|
||||
return fmt.Errorf("give the token's ID, or --current")
|
||||
}
|
||||
body := api.TokenRotation{Grace: grace}
|
||||
switch rexp {
|
||||
case "":
|
||||
case "never":
|
||||
body.NoExpiry = true
|
||||
default:
|
||||
body.ExpiresIn = rexp
|
||||
}
|
||||
var t api.Token
|
||||
if err := c.Do(ctx(), "POST", "/tokens/"+id+"/rotate", nil, body, &t); err != nil {
|
||||
return err
|
||||
}
|
||||
if rsave {
|
||||
cfg, err := loadCLIConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.Token = t.Secret
|
||||
if err := saveCLIConfig(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, "saved in", flagConfig)
|
||||
}
|
||||
if flagOutput == "json" || flagOutput == "yaml" {
|
||||
return output(t, nil)
|
||||
}
|
||||
fmt.Println(t.Secret)
|
||||
if !flagQuiet {
|
||||
if t.PrevExpiresAt != nil {
|
||||
fmt.Fprintf(os.Stderr, "the old secret works until %s\n", t.PrevExpiresAt.Local().Format("2006-01-02 15:04"))
|
||||
} else {
|
||||
fmt.Fprintln(os.Stderr, "the old secret no longer works")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
f = rotate.Flags()
|
||||
f.StringVar(&grace, "grace", "", "keep the old secret working this long (30m, 24h, up to 7d)")
|
||||
f.StringVar(&rexp, "expires", "", "a new lifetime from now (90d), or never")
|
||||
f.BoolVar(¤t, "current", false, "rotate the token this CLI uses")
|
||||
f.BoolVar(&rsave, "save", false, "save the new secret as this CLI's token")
|
||||
cmd.AddCommand(rotate)
|
||||
cmd.AddCommand(&cobra.Command{
|
||||
Use: "revoke ID", Short: "Revoke a token", Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.Do(ctx(), "DELETE", "/tokens/"+args[0], nil, nil, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
if !flagQuiet {
|
||||
fmt.Println("revoked token", args[0])
|
||||
}
|
||||
return nil
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
func passwordCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "password", Short: "Change your own password", Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cur, err := readSecret("Current password: ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pw, err := readSecret("New password: ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.Do(ctx(), "POST", "/me/password", nil, api.PasswordChange{Current: cur, Password: pw}, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println("password changed")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func auditCmd() *cobra.Command {
|
||||
var user, action, search string
|
||||
var limit int
|
||||
cmd := &cobra.Command{
|
||||
Use: "audit", Short: "Show the audit log: logins, users, tokens, changes, confirmations, set entries", Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var evs []model.AuditEvent
|
||||
if err := c.Get(ctx(), "/audit", q("user", user, "action", action, "q", search, "limit", strconv.Itoa(limit)), &evs); err != nil {
|
||||
return err
|
||||
}
|
||||
return output(evs, func(w io.Writer) {
|
||||
fmt.Fprintln(w, "WHEN\tUSER\tFROM\tACTION\tOBJECT\tRESULT\tDETAIL")
|
||||
for _, e := range evs {
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", e.Time.Local().Format("2006-01-02 15:04:05"), orDash(e.Username), orDash(e.IP), e.Action, orDash(e.Object), e.Result, e.Detail)
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
f := cmd.Flags()
|
||||
f.StringVar(&user, "user", "", "only this user's")
|
||||
f.StringVar(&action, "action", "", "only actions starting with this (auth., config., set.)")
|
||||
f.StringVar(&search, "search", "", "only events whose object or detail contains this")
|
||||
f.IntVar(&limit, "limit", 100, "how many")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func configCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{Use: "config", Short: "This CLI's settings: the server and token"}
|
||||
cmd.AddCommand(&cobra.Command{
|
||||
Use: "show", Short: "Show the CLI's configuration", Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cfg, err := loadCLIConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tok := "-"
|
||||
if cfg.Token != "" {
|
||||
tok = cfg.Token[:min(len(cfg.Token), 24)] + "…"
|
||||
}
|
||||
fmt.Printf("file: %s\nserver: %s\ntoken: %s\ninsecure: %v\nca_cert: %s\n", flagConfig, orDash(cfg.Server)+" (empty: the local socket)", tok, cfg.Insecure, orDash(cfg.CACert))
|
||||
return nil
|
||||
},
|
||||
})
|
||||
cmd.AddCommand(&cobra.Command{
|
||||
Use: "set KEY [VALUE]",
|
||||
Short: "Set server, token (asks when no value), insecure or ca_cert",
|
||||
Args: cobra.RangeArgs(1, 2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cfg, err := loadCLIConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
val := ""
|
||||
if len(args) == 2 {
|
||||
val = args[1]
|
||||
} else if args[0] == "token" {
|
||||
if val, err = readSecret("API token: "); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
switch args[0] {
|
||||
case "server":
|
||||
cfg.Server = val
|
||||
case "token":
|
||||
cfg.Token = val
|
||||
case "insecure":
|
||||
cfg.Insecure = val == "true" || val == "yes" || val == "on"
|
||||
case "ca_cert", "ca-cert":
|
||||
cfg.CACert = val
|
||||
default:
|
||||
return fmt.Errorf("unknown setting %q (use server, token, insecure or ca_cert)", args[0])
|
||||
}
|
||||
if err := saveCLIConfig(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, "saved in", flagConfig)
|
||||
return nil
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"housecarl/internal/api"
|
||||
)
|
||||
|
||||
func certCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "cert",
|
||||
Aliases: []string{"certificate"},
|
||||
Short: "The web UI's certificate, obtained through Jarl (administrators)",
|
||||
Long: `Shows the certificate housecarld obtains itself for its web UI (tls.acme in
|
||||
housecarl.yaml, with Jarl publishing the DNS-01 challenge; docs/certificates.md).
|
||||
'check' tests the whole path without asking the CA; 'renew' gets a new
|
||||
certificate now.`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error { return certStatus() },
|
||||
}
|
||||
cmd.AddCommand(&cobra.Command{Use: "status", Short: "Show the certificate", Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error { return certStatus() }})
|
||||
cmd.AddCommand(&cobra.Command{
|
||||
Use: "check",
|
||||
Short: "Check that the challenge can be published and seen, without the CA",
|
||||
Long: `Runs the check housecarld runs before every certificate order: Jarl answers
|
||||
and knows the token, a zone holds _acme-challenge.DOMAIN, the token may
|
||||
write it, a test record is written, the zone's name servers and the public
|
||||
resolvers serve it, and it is removed again. It can take a minute or two.`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.HTTP.Timeout = 5 * time.Minute
|
||||
if !flagQuiet && flagOutput == "table" {
|
||||
fmt.Fprintln(os.Stderr, "checking (this can take a minute or two)…")
|
||||
}
|
||||
var ch api.CertCheck
|
||||
if err := c.Do(ctx(), "POST", "/certificate/check", nil, nil, &ch); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := output(ch, func(w io.Writer) { printCheck(w, &ch) }); err != nil {
|
||||
return err
|
||||
}
|
||||
if !ch.OK {
|
||||
return fmt.Errorf("the check failed")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
})
|
||||
var wait bool
|
||||
renew := &cobra.Command{
|
||||
Use: "renew",
|
||||
Short: "Get a new certificate now (the check runs first)",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var r api.CertificateResponse
|
||||
if err := c.Do(ctx(), "POST", "/certificate/renew", nil, nil, &r); err != nil {
|
||||
return err
|
||||
}
|
||||
if !wait {
|
||||
fmt.Println("renewing in the background; follow it with 'hc cert'")
|
||||
return nil
|
||||
}
|
||||
start := time.Now()
|
||||
for {
|
||||
time.Sleep(3 * time.Second)
|
||||
if err := c.Get(ctx(), "/certificate", nil, &r); err != nil {
|
||||
return err
|
||||
}
|
||||
st := r.Status
|
||||
if st.State != "working" && st.LastAttempt != nil && !st.LastAttempt.Before(start.Add(-time.Second)) {
|
||||
break
|
||||
}
|
||||
}
|
||||
if err := certStatus(); err != nil {
|
||||
return err
|
||||
}
|
||||
if r.Status.LastError != "" {
|
||||
return fmt.Errorf("renewing failed")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
renew.Flags().BoolVarP(&wait, "wait", "w", false, "wait until it is done")
|
||||
cmd.AddCommand(renew)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func certStatus() error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var r api.CertificateResponse
|
||||
if err := c.Get(ctx(), "/certificate", nil, &r); err != nil {
|
||||
return err
|
||||
}
|
||||
return output(r, func(w io.Writer) {
|
||||
switch r.Source {
|
||||
case "file":
|
||||
fmt.Fprintln(w, "Source:\ttls.cert_file (renewed by you)")
|
||||
return
|
||||
case "none":
|
||||
fmt.Fprintln(w, "Source:\tnone: the web UI is plain HTTP (set tls.acme or tls.cert_file in housecarl.yaml; docs/certificates.md)")
|
||||
return
|
||||
}
|
||||
st := r.Status
|
||||
fmt.Fprintf(w, "Domain:\t%s\n", st.Domain)
|
||||
ca := st.Directory
|
||||
if st.Staging {
|
||||
ca += " (staging: browsers don't trust it)"
|
||||
}
|
||||
fmt.Fprintf(w, "CA:\t%s\n", ca)
|
||||
fmt.Fprintf(w, "State:\t%s\n", st.State)
|
||||
if st.NotAfter != nil {
|
||||
fmt.Fprintf(w, "Valid:\t%s to %s, from %s\n", st.NotBefore.Local().Format("2006-01-02"), st.NotAfter.Local().Format("2006-01-02 15:04"), st.Issuer)
|
||||
fmt.Fprintf(w, "Renews:\t%s\n", st.RenewAt.Local().Format("2006-01-02"))
|
||||
}
|
||||
if st.LastAttempt != nil {
|
||||
fmt.Fprintf(w, "Last attempt:\t%s\n", st.LastAttempt.Local().Format("2006-01-02 15:04"))
|
||||
}
|
||||
if st.NextAttempt != nil && st.State != "working" {
|
||||
fmt.Fprintf(w, "Next check:\t%s\n", st.NextAttempt.Local().Format("2006-01-02 15:04"))
|
||||
}
|
||||
if st.LastError != "" {
|
||||
fmt.Fprintf(w, "Error:\t%s\n", st.LastError)
|
||||
}
|
||||
if st.Check != nil {
|
||||
fmt.Fprintf(w, "\nLast check, %s:\n", st.Check.Time.Local().Format("2006-01-02 15:04"))
|
||||
printCheck(w, st.Check)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func printCheck(w io.Writer, c *api.CertCheck) {
|
||||
for _, s := range c.Steps {
|
||||
mark := map[string]string{"ok": "ok ", "fail": "FAIL", "warn": "warn", "skip": "skip"}[s.Status]
|
||||
fmt.Fprintf(w, " %s\t%s\t%s\n", mark, s.Name, s.Detail)
|
||||
if s.Status == "fail" && len(s.Queries) > 0 {
|
||||
for _, q := range s.Queries {
|
||||
fmt.Fprintf(w, " \t\t%s\n", strings.TrimSpace(q))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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));
|
||||
}
|
||||
Loaded 100 of 119 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user