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-