From 34292eeebba69ace2d049f1869ebc24a135f31b4 Mon Sep 17 00:00:00 2001 From: abrown38 Date: Mon, 19 Oct 2026 07:57:14 +0100 Subject: [PATCH] Build Housecarl 0.1.0: the nftables firewall manager Zones, address sets (IPv4 and IPv6 mixed, dynamic sets with timeouts) and services, ordered rules in the early, input, forward and output chains, port forwards with hairpin NAT and IPv6 pinholes, source NAT, redirects, and switchable built-ins (loopback, conntrack, ICMPv6, antispoofing, MSS clamping, DHCPv6 client, flowtable). Every change is validated, checked with nft -c and applied in one transaction to Housecarl's own table, inet housecarl; changes made over the network roll back unless confirmed, and the confirmed ruleset is loaded at boot by housecarl-ruleset.service. An empty configuration applies nothing. Also: hit counters and the drop log (nflog), 'hc import' of an existing nftables ruleset and 'hc takeover', the hc CLI, the REST API and the web UI, users, scoped tokens (sets:write:NAME), the audit log, the web UI's certificate through Jarl, Debian packages, docs, unit tests with real nft 1.1.3, and an e2e test with real traffic (71 checks). Co-Authored-By: Claude Opus 5.5 --- .gitea/workflows/release.yml | 69 + AGENTS.md | 37 +- CHANGELOG.md | 56 +- Makefile | 82 +- README.md | 101 +- cmd/hc/admin.go | 630 ++++++++ cmd/hc/cert.go | 158 +++ cmd/hc/firewall.go | 483 +++++++ cmd/hc/main.go | 435 +++++- cmd/hc/main_test.go | 42 + cmd/hc/objects.go | 461 ++++++ cmd/hc/rules.go | 621 ++++++++ cmd/housecarld/lock.go | 40 + cmd/housecarld/main.go | 644 ++++++++- deploy/debian/control-housecarl-cli.in | 17 + deploy/debian/control-housecarl.in | 22 + deploy/debian/postinst | 63 + deploy/debian/postrm | 35 + deploy/debian/prerm | 16 + deploy/systemd/housecarl-backup.service | 44 + deploy/systemd/housecarl-backup.timer | 20 + deploy/systemd/housecarl-ruleset.service | 21 + deploy/systemd/housecarl.service | 63 + deploy/sysusers.d/housecarl.conf | 2 + deploy/tmpfiles.d/housecarl.conf | 8 + docs/api.md | 181 +++ docs/certificates.md | 120 ++ docs/cli.md | 193 +++ docs/configuration.md | 52 + docs/design.md | 156 +- docs/development.md | 137 ++ docs/import.md | 120 ++ docs/install.md | 138 ++ docs/jarl.md | 85 ++ docs/nat.md | 82 ++ docs/operations.md | 139 ++ docs/permissions.md | 86 ++ docs/rules.md | 201 +++ docs/validation.md | 108 ++ go.mod | 23 +- go.sum | 72 + internal/api/types.go | 276 ++++ internal/auth/auth.go | 335 +++++ internal/auth/auth_test.go | 73 + internal/certs/check.go | 379 +++++ internal/certs/check_test.go | 266 ++++ internal/certs/manager.go | 484 +++++++ internal/client/client.go | 171 +++ internal/config/config.go | 396 ++++++ internal/config/config_test.go | 49 + internal/engine/engine.go | 567 ++++++++ internal/engine/engine_test.go | 175 +++ internal/hostif/hostif.go | 79 ++ internal/importer/importer.go | 1262 +++++++++++++++++ internal/importer/importer_test.go | 175 +++ internal/importer/testdata/router.json | 1 + internal/importer/testdata/router.nft | 142 ++ internal/importer/testdata/router.txt | 81 ++ internal/jarl/jarl.go | 249 ++++ internal/model/addr.go | 87 ++ internal/model/auth.go | 133 ++ internal/model/objects.go | 250 ++++ internal/model/resolve.go | 95 ++ internal/model/services.go | 146 ++ internal/model/settings.go | 90 ++ internal/model/state.go | 328 +++++ internal/nflog/nflog.go | 320 +++++ internal/nflog/nflog_test.go | 50 + internal/nft/nft.go | 431 ++++++ internal/nft/nft_test.go | 127 ++ internal/render/render.go | 838 +++++++++++ internal/render/render_test.go | 180 +++ internal/server/authn.go | 292 ++++ internal/server/h_cert.go | 94 ++ internal/server/h_config.go | 588 ++++++++ internal/server/h_import.go | 147 ++ internal/server/h_status.go | 282 ++++ internal/server/h_users.go | 484 +++++++ internal/server/probation.go | 178 +++ internal/server/respond.go | 192 +++ internal/server/routes.go | 123 ++ internal/server/routes_cfg.go | 26 + internal/server/server.go | 470 ++++++ internal/server/server_test.go | 540 +++++++ internal/server/sniff.go | 147 ++ internal/server/sniff_test.go | 58 + internal/server/socket.go | 130 ++ internal/store/history.go | 65 + internal/store/state.go | 99 ++ internal/store/store.go | 297 ++++ internal/store/store_test.go | 105 ++ internal/store/users.go | 392 +++++ internal/validate/validate.go | 511 +++++++ internal/validate/validate_test.go | 140 ++ internal/web/static/assets/api.js | 88 ++ internal/web/static/assets/app.js | 270 ++++ internal/web/static/assets/pages/account.js | 71 + internal/web/static/assets/pages/audit.js | 26 + .../web/static/assets/pages/certificate.js | 86 ++ internal/web/static/assets/pages/dashboard.js | 65 + internal/web/static/assets/pages/history.js | 69 + internal/web/static/assets/pages/import.js | 41 + internal/web/static/assets/pages/log.js | 34 + internal/web/static/assets/pages/nat.js | 131 ++ internal/web/static/assets/pages/rules.js | 155 ++ internal/web/static/assets/pages/services.js | 49 + internal/web/static/assets/pages/sets.js | 78 + internal/web/static/assets/pages/settings.js | 63 + internal/web/static/assets/pages/users.js | 121 ++ internal/web/static/assets/pages/zones.js | 55 + internal/web/static/assets/style.css | 318 ++++- internal/web/static/assets/ui.js | 236 +++ internal/web/static/assets/widgets.js | 134 ++ internal/web/static/index.html | 16 + internal/web/web.go | 26 + scripts/e2e.sh | 219 +++ scripts/gitea-release.sh | 150 ++ scripts/mkdeb.sh | 120 ++ 118 files changed, 22165 insertions(+), 114 deletions(-) create mode 100644 .gitea/workflows/release.yml create mode 100644 cmd/hc/admin.go create mode 100644 cmd/hc/cert.go create mode 100644 cmd/hc/firewall.go create mode 100644 cmd/hc/main_test.go create mode 100644 cmd/hc/objects.go create mode 100644 cmd/hc/rules.go create mode 100644 cmd/housecarld/lock.go create mode 100644 deploy/debian/control-housecarl-cli.in create mode 100644 deploy/debian/control-housecarl.in create mode 100755 deploy/debian/postinst create mode 100755 deploy/debian/postrm create mode 100755 deploy/debian/prerm create mode 100644 deploy/systemd/housecarl-backup.service create mode 100644 deploy/systemd/housecarl-backup.timer create mode 100644 deploy/systemd/housecarl-ruleset.service create mode 100644 deploy/systemd/housecarl.service create mode 100644 deploy/sysusers.d/housecarl.conf create mode 100644 deploy/tmpfiles.d/housecarl.conf create mode 100644 docs/api.md create mode 100644 docs/certificates.md create mode 100644 docs/cli.md create mode 100644 docs/configuration.md create mode 100644 docs/development.md create mode 100644 docs/import.md create mode 100644 docs/install.md create mode 100644 docs/jarl.md create mode 100644 docs/nat.md create mode 100644 docs/operations.md create mode 100644 docs/permissions.md create mode 100644 docs/rules.md create mode 100644 docs/validation.md create mode 100644 internal/api/types.go create mode 100644 internal/auth/auth.go create mode 100644 internal/auth/auth_test.go create mode 100644 internal/certs/check.go create mode 100644 internal/certs/check_test.go create mode 100644 internal/certs/manager.go create mode 100644 internal/client/client.go create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go create mode 100644 internal/engine/engine.go create mode 100644 internal/engine/engine_test.go create mode 100644 internal/hostif/hostif.go create mode 100644 internal/importer/importer.go create mode 100644 internal/importer/importer_test.go create mode 100644 internal/importer/testdata/router.json create mode 100644 internal/importer/testdata/router.nft create mode 100644 internal/importer/testdata/router.txt create mode 100644 internal/jarl/jarl.go create mode 100644 internal/model/addr.go create mode 100644 internal/model/auth.go create mode 100644 internal/model/objects.go create mode 100644 internal/model/resolve.go create mode 100644 internal/model/services.go create mode 100644 internal/model/settings.go create mode 100644 internal/model/state.go create mode 100644 internal/nflog/nflog.go create mode 100644 internal/nflog/nflog_test.go create mode 100644 internal/nft/nft.go create mode 100644 internal/nft/nft_test.go create mode 100644 internal/render/render.go create mode 100644 internal/render/render_test.go create mode 100644 internal/server/authn.go create mode 100644 internal/server/h_cert.go create mode 100644 internal/server/h_config.go create mode 100644 internal/server/h_import.go create mode 100644 internal/server/h_status.go create mode 100644 internal/server/h_users.go create mode 100644 internal/server/probation.go create mode 100644 internal/server/respond.go create mode 100644 internal/server/routes.go create mode 100644 internal/server/routes_cfg.go create mode 100644 internal/server/server.go create mode 100644 internal/server/server_test.go create mode 100644 internal/server/sniff.go create mode 100644 internal/server/sniff_test.go create mode 100644 internal/server/socket.go create mode 100644 internal/store/history.go create mode 100644 internal/store/state.go create mode 100644 internal/store/store.go create mode 100644 internal/store/store_test.go create mode 100644 internal/store/users.go create mode 100644 internal/validate/validate.go create mode 100644 internal/validate/validate_test.go create mode 100644 internal/web/static/assets/api.js create mode 100644 internal/web/static/assets/app.js create mode 100644 internal/web/static/assets/pages/account.js create mode 100644 internal/web/static/assets/pages/audit.js create mode 100644 internal/web/static/assets/pages/certificate.js create mode 100644 internal/web/static/assets/pages/dashboard.js create mode 100644 internal/web/static/assets/pages/history.js create mode 100644 internal/web/static/assets/pages/import.js create mode 100644 internal/web/static/assets/pages/log.js create mode 100644 internal/web/static/assets/pages/nat.js create mode 100644 internal/web/static/assets/pages/rules.js create mode 100644 internal/web/static/assets/pages/services.js create mode 100644 internal/web/static/assets/pages/sets.js create mode 100644 internal/web/static/assets/pages/settings.js create mode 100644 internal/web/static/assets/pages/users.js create mode 100644 internal/web/static/assets/pages/zones.js create mode 100644 internal/web/static/assets/ui.js create mode 100644 internal/web/static/assets/widgets.js create mode 100644 internal/web/static/index.html create mode 100644 internal/web/web.go create mode 100755 scripts/e2e.sh create mode 100755 scripts/gitea-release.sh create mode 100755 scripts/mkdeb.sh diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..8d798fa --- /dev/null +++ b/.gitea/workflows/release.yml @@ -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 diff --git a/AGENTS.md b/AGENTS.md index a0a3281..2e85be3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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: diff --git a/CHANGELOG.md b/CHANGELOG.md index f3a92ba..c8383a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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). diff --git a/Makefile b/Makefile index 1e1abf7..156c1ab 100644 --- a/Makefile +++ b/Makefile @@ -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---[.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 && \ diff --git a/README.md b/README.md index cddad88..45d1d43 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ *Every Jarl has a housecarl at his side; this one keeps the door.* -Housecarl will be a firewall for Linux that you manage through a web UI, a +Housecarl is a firewall for Linux that you manage through a web UI, a REST API and a command line client (`hc`), all three backed by the same API. It is the third of a set with [Jarl](https://gitea.adbraun.lol/adam/jarl), the BIND manager, and [Steward](https://gitea.adbraun.lol/adam/steward), the @@ -17,24 +17,97 @@ door and decides who comes in. Housecarl does that for your network. It keeps the machine's nftables ruleset, built from its own database, in one table of its own. -## What it will do +## What it does -* **Zones** for your interfaces and VLANs (`lan`, `iot`, `guest`, `wan`), - with a default policy between each pair, and rules between them. -* **Address sets** that rules refer to, whose entries can expire by - themselves; another program can be allowed to change just one set. That - is how Steward's captive portal will let a guest in. -* **NAT**: masquerading and port forwards. -* **Changes you can trust**: validated as a whole, checked by nftables - before they are applied, applied in one transaction, and undoable. A - change that could lock you out is rolled back unless you confirm it. +* **Zones, sets and services, then rules.** Name your interfaces (`wan`, + `lan`, `wg`), your addresses (`trusted`, IPv4 and IPv6 in one set) and + your ports (`jellyfin`, or the built-in `ssh`, `dns`, `https`…), and + write ordered rules with them: in, out or through the firewall, by + zone, address, service, connection state, time of day, rate per + source, or connection count. Anything else can be written as an + nftables rule by hand. +* **IPv4 and IPv6 together.** One rule covers both; Housecarl writes the + nftables rules each family needs. +* **NAT in one object.** A port forward is the DNAT, the forward accept + and the hairpin NAT together, and can open the same port to an IPv6 + host. Masquerading, SNAT and redirects too. +* **The rules every firewall needs**, built in and each one switchable: + loopback, established connections, invalid packets, the ICMPv6 that + IPv6 needs, antispoofing for your prefixes, MSS clamping for PPPoE, the + DHCPv6 client, and a flowtable fast path. +* **Changes you can trust.** Each change is validated as a whole, checked + by nftables itself, and applied in one transaction. A change made over + the network is on probation: unless you confirm it within two minutes, + it rolls itself back, so a rule that cuts you off undoes itself. +* **Your existing ruleset, imported.** `hc import` turns an nftables + ruleset into zones, sets, services, rules and port forwards, keeping the + rules' order, and keeps what it can't translate as raw rules + ([docs/import.md](docs/import.md)). +* **Easy to watch.** Hits per rule, a live log of what was dropped and + why, and the history of every change, field by field, with undo. +* **Its own table only.** Docker, Incus and libvirt keep theirs. The + confirmed ruleset is loaded at boot, before the network comes up, even + if housecarld doesn't start. +* **Sets other programs can fill.** A dynamic set's entries expire by + themselves, and a token can be limited to one set: that is how + Steward's captive portal will let a guest in. +* **Users, tokens and HTTPS** as in Jarl and Steward, with the web UI's + certificate from Let's Encrypt through Jarl + ([docs/certificates.md](docs/certificates.md)). -See [docs/design.md](docs/design.md) for the plan and the open questions. +Not yet: rules by MAC address or DNS name, and iptables import +([docs/design.md](docs/design.md)). + +## Quick start + +On Debian 13, from the package registry (see +[docs/install.md](docs/install.md) for other ways): + +```sh +sudo apt install housecarl +sudo -u housecarl housecarld init # asks for the admin password +sudo systemctl enable --now housecarl # applies nothing until configured + +# Import the ruleset you have... +sudo hc import --defines /etc/nftables.conf -n # look first +sudo hc import --defines /etc/nftables.conf +sudo hc takeover "inet main" +sudo systemctl disable nftables.service + +# ...or start from scratch: let yourself in first +sudo hc set add admins 10.0.10.0/24 +sudo hc rule add input --from admins --service ssh +sudo hc zone add wan --interface ppp0 --external +sudo hc zone add lan --interface enp2s0 +sudo hc rule add forward --in lan --out wan +sudo hc snat add --out wan +sudo hc status +``` + +The web UI is on `http://127.0.0.1:8070/` (see `listen` in +`/etc/housecarl/housecarl.yaml`); write a rule that lets you reach it. + +## Documentation + +* [install.md](docs/install.md): packages, building from source, first steps +* [rules.md](docs/rules.md): zones, address sets, services, rules and the built-ins +* [nat.md](docs/nat.md): port forwards, hairpin NAT, IPv6 pinholes, source NAT, redirects +* [import.md](docs/import.md): moving from an existing nftables ruleset +* [operations.md](docs/operations.md): probation, the boot, hits and the log, backups, troubleshooting +* [certificates.md](docs/certificates.md): HTTPS with a certificate from Let's Encrypt, through Jarl +* [jarl.md](docs/jarl.md): connecting Housecarl to Jarl, and what its token may do +* [cli.md](docs/cli.md): the `hc` command line client +* [api.md](docs/api.md): the REST API +* [configuration.md](docs/configuration.md): `/etc/housecarl/housecarl.yaml` +* [permissions.md](docs/permissions.md): users, roles, grants and API tokens +* [validation.md](docs/validation.md): every check and what it means +* [design.md](docs/design.md): how Housecarl is built, and what 0.1 leaves out +* [development.md](docs/development.md): building, testing, releasing ## Status -Housecarl is at its start: it has its name, its logo and the skeleton of -the daemon (`housecarld`) and CLI (`hc`). There is nothing to run yet. +Housecarl is heading for its first release, 0.1.0: everything above +works and is tested against real traffic, but it isn't released yet. ## Licence diff --git a/cmd/hc/admin.go b/cmd/hc/admin.go new file mode 100644 index 0000000..6e537d7 --- /dev/null +++ b/cmd/hc/admin.go @@ -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 +} diff --git a/cmd/hc/cert.go b/cmd/hc/cert.go new file mode 100644 index 0000000..cbb68ee --- /dev/null +++ b/cmd/hc/cert.go @@ -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)) + } + } + } +} diff --git a/cmd/hc/firewall.go b/cmd/hc/firewall.go new file mode 100644 index 0000000..3b8cb65 --- /dev/null +++ b/cmd/hc/firewall.go @@ -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 +} diff --git a/cmd/hc/main.go b/cmd/hc/main.go index 96e1bcd..b777df5 100644 --- a/cmd/hc/main.go +++ b/cmd/hc/main.go @@ -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. diff --git a/cmd/hc/main_test.go b/cmd/hc/main_test.go new file mode 100644 index 0000000..116fc77 --- /dev/null +++ b/cmd/hc/main_test.go @@ -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) + } +} diff --git a/cmd/hc/objects.go b/cmd/hc/objects.go new file mode 100644 index 0000000..5ae9e48 --- /dev/null +++ b/cmd/hc/objects.go @@ -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 +} diff --git a/cmd/hc/rules.go b/cmd/hc/rules.go new file mode 100644 index 0000000..a8948a5 --- /dev/null +++ b/cmd/hc/rules.go @@ -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 +} diff --git a/cmd/housecarld/lock.go b/cmd/housecarld/lock.go new file mode 100644 index 0000000..0ef64b4 --- /dev/null +++ b/cmd/housecarld/lock.go @@ -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 +} diff --git a/cmd/housecarld/main.go b/cmd/housecarld/main.go index 25a2853..c647f01 100644 --- a/cmd/housecarld/main.go +++ b/cmd/housecarld/main.go @@ -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-