Start Housecarl: the logo, the wordmark and the skeleton
Housecarl will be a firewall manager for nftables, built the way Jarl and Steward are: housecarld (a daemon with a REST API and web UI), hc (the CLI) and SQLite. - The logo: a carved Nordic doorway in Jarl's blue (artwork/logo.svg), with a small version without the carvings, lighter on dark backgrounds. scripts/logo.py builds the web UI's copies and scripts/banner.py the README banner. - The HOUSECARL wordmark in Jarl's and Steward's angular letters, with new H, O, U and C (assets/brand.js), and the UI's color tokens. - housecarld and hc print their version; the Makefile, version package, AGENTS.md, CHANGELOG and the design sketch (docs/design.md). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
54beb0bba3
24 files changed
+949
No files matched your search
@@ -0,0 +1,7 @@
|
|||||||
|
/bin/
|
||||||
|
/dist/
|
||||||
|
*.db
|
||||||
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
|
/hc
|
||||||
|
/housecarld
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
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
|
||||||
|
CLI; SQLite holds everything. It is the third of a set with Jarl (the BIND
|
||||||
|
manager, `../jarl`) and Steward (the DHCP server, `../steward`) and copies
|
||||||
|
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
|
||||||
|
it; never read nftables' state back as configuration (an importer would
|
||||||
|
be the only exception).
|
||||||
|
* **Every change goes through one engine**: edit a copy of the
|
||||||
|
configuration, validate the whole result, render, check with
|
||||||
|
`nft -c -f`, apply with `nft -f`, and save the objects, the history
|
||||||
|
entry and the audit record together. Never write to the store from a
|
||||||
|
handler.
|
||||||
|
* **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).
|
||||||
|
* **Every user-visible change updates `CHANGELOG.md`** under
|
||||||
|
`## [Unreleased]`, and the matching `docs/` page in the same change.
|
||||||
|
* **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
|
||||||
|
installed, so fix imports by hand.
|
||||||
|
|
||||||
|
## The logo and wordmark
|
||||||
|
|
||||||
|
* The masters are `artwork/logo.svg` (the carved doorway) and
|
||||||
|
`artwork/logo-small.svg` (without its carvings, for under about 64 px).
|
||||||
|
Each is one evenodd path in a shared viewBox; their color comes from
|
||||||
|
CSS (`--logo`, lighter in the dark theme).
|
||||||
|
* `make logo` (`scripts/logo.py`) copies them into the web UI
|
||||||
|
(`assets/logo.js`, and `assets/logo.svg`, the favicon, from the small
|
||||||
|
one) and runs `scripts/banner.py`, which rebuilds `artwork/banner.svg`.
|
||||||
|
Never edit those outputs by hand.
|
||||||
|
* The wordmark's letters are paths in `assets/brand.js`, in the angular
|
||||||
|
style of Jarl's and Steward's (A, R and L are Jarl's, S and E Steward's).
|
||||||
|
`banner.py` reads them from there.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
```sh
|
||||||
|
make test # vet + gofmt check + unit tests
|
||||||
|
```
|
||||||
|
|
||||||
|
## Web UI rules
|
||||||
|
|
||||||
|
As in Jarl and Steward:
|
||||||
|
|
||||||
|
* Build DOM only with `h()` from `ui.js`, never `innerHTML`.
|
||||||
|
* The CSP is `self` only: no inline styles or scripts. Use a class, or set
|
||||||
|
`el.style.prop`.
|
||||||
|
* Colors are the CSS custom properties in `style.css`, with light and dark
|
||||||
|
values; reuse them.
|
||||||
|
* Rebuild housecarld after editing, since the files are embedded.
|
||||||
|
* Function over polish. Leave pick-list checkboxes unticked by default.
|
||||||
|
|
||||||
|
## Git and releases
|
||||||
|
|
||||||
|
The maintainer's rules:
|
||||||
|
|
||||||
|
* **Commit only when asked. Never push.** The maintainer pushes, to Gitea
|
||||||
|
at gitea.adbraun.lol.
|
||||||
|
* **Branches** are `dev-X.Y.Z`, never named like a tag.
|
||||||
|
* **A release** is a commit "Release X": it sets `VERSION`, updates the
|
||||||
|
README status line, and (for a final release) moves `[Unreleased]` to
|
||||||
|
`## [X] - YYYY-MM-DD`. The annotated tag is `vX` with message
|
||||||
|
"Housecarl X".
|
||||||
|
|
||||||
|
## Shell hygiene
|
||||||
|
|
||||||
|
* **Never kill processes by pattern** (`pkill -f`). The pattern matches
|
||||||
|
the shell running it. Find the PID with `ps` and kill that.
|
||||||
|
* **Run long-lived test servers in the background mode of your tool**, not
|
||||||
|
with `&` in a shell that exits.
|
||||||
|
* **Never apply a test ruleset to this machine's own nftables.** Test in a
|
||||||
|
network namespace or a container.
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to Housecarl are documented here. The format follows
|
||||||
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow
|
||||||
|
[Semantic Versioning](https://semver.org/).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
The start of Housecarl, a firewall manager for nftables, built the way
|
||||||
|
Jarl and Steward are.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **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).
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
@AGENTS.md
|
||||||
|
|
||||||
|
## Claude Code specifics
|
||||||
|
|
||||||
|
- End commit messages with a `Co-Authored-By:` trailer naming the model,
|
||||||
|
e.g. `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
|
||||||
|
- Keep temporary scripts, screenshots and test
|
||||||
|
servers' work directories in the session scratchpad, not in the repo.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 abrown38
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# Housecarl build.
|
||||||
|
#
|
||||||
|
# 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 dist static binaries for linux/amd64 and linux/arm64 in dist/
|
||||||
|
# make cli-dist the CLI alone for Linux, macOS and Windows in dist/
|
||||||
|
# (housecarl-cli-<version>-<os>-<arch>[.exe])
|
||||||
|
#
|
||||||
|
# Installing, the systemd unit and the Debian packages come with the daemon
|
||||||
|
# (as in Steward's Makefile).
|
||||||
|
|
||||||
|
VERSION ?= $(shell cat VERSION 2>/dev/null || echo 0.0.0-dev)
|
||||||
|
# RELEASE_TAG marks a build of a release (the web UI then shows and links
|
||||||
|
# the release rather than the commit): set when HEAD is tagged v$(VERSION)
|
||||||
|
# and no tracked file is changed.
|
||||||
|
RELEASE_TAG ?= $(shell t=v$(VERSION); git tag --points-at HEAD 2>/dev/null | grep -qx "$$t" && git diff --quiet HEAD 2>/dev/null && echo $$t)
|
||||||
|
# DESCRIBE places other builds relative to the last tag ("3 commits after
|
||||||
|
# v0.2.0-rc.1").
|
||||||
|
DESCRIBE ?= $(shell git describe --tags --long --match 'v[0-9]*' --abbrev=12 HEAD 2>/dev/null)
|
||||||
|
LDFLAGS := -s -w -X housecarl/internal/version.Version=$(VERSION) -X housecarl/internal/version.Tag=$(RELEASE_TAG) \
|
||||||
|
-X housecarl/internal/version.Describe=$(DESCRIBE)
|
||||||
|
GOFLAGS := -trimpath
|
||||||
|
export CGO_ENABLED := 0
|
||||||
|
|
||||||
|
.PHONY: all build completion test vet logo dist cli-dist clean
|
||||||
|
|
||||||
|
all: build
|
||||||
|
|
||||||
|
build:
|
||||||
|
go build $(GOFLAGS) -ldflags '$(LDFLAGS)' -o bin/housecarld ./cmd/housecarld
|
||||||
|
go build $(GOFLAGS) -ldflags '$(LDFLAGS)' -o bin/hc ./cmd/hc
|
||||||
|
$(MAKE) --no-print-directory completion
|
||||||
|
|
||||||
|
# Bash completion scripts, generated by the binaries themselves.
|
||||||
|
completion:
|
||||||
|
@mkdir -p bin/completion
|
||||||
|
bin/hc completion bash > bin/completion/hc
|
||||||
|
bin/housecarld completion bash > bin/completion/housecarld
|
||||||
|
|
||||||
|
vet:
|
||||||
|
go vet ./...
|
||||||
|
@test -z "$$(gofmt -l cmd internal)" || { echo "gofmt needed:"; gofmt -l cmd internal; exit 1; }
|
||||||
|
|
||||||
|
test: vet
|
||||||
|
go test ./...
|
||||||
|
|
||||||
|
logo:
|
||||||
|
python3 scripts/logo.py
|
||||||
|
|
||||||
|
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 && \
|
||||||
|
GOOS=linux GOARCH=$$arch go build $(GOFLAGS) -ldflags '$(LDFLAGS)' -o dist/housecarl-$(VERSION)-linux-$$arch/hc ./cmd/hc && \
|
||||||
|
tar -C dist -czf dist/housecarl-$(VERSION)-linux-$$arch.tar.gz housecarl-$(VERSION)-linux-$$arch || exit 1; \
|
||||||
|
done
|
||||||
|
|
||||||
|
# The platforms cli-dist builds the CLI for.
|
||||||
|
CLI_PLATFORMS ?= linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64 windows/arm64
|
||||||
|
|
||||||
|
cli-dist:
|
||||||
|
@mkdir -p dist
|
||||||
|
for p in $(CLI_PLATFORMS); do \
|
||||||
|
os=$${p%/*} arch=$${p#*/} ext=; [ $$os = windows ] && ext=.exe; \
|
||||||
|
GOOS=$$os GOARCH=$$arch go build $(GOFLAGS) -ldflags '$(LDFLAGS)' -o dist/housecarl-cli-$(VERSION)-$$os-$$arch$$ext ./cmd/hc || exit 1; \
|
||||||
|
done
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -rf bin dist
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<p align="center"><img src="artwork/banner.svg" width="340" alt="Housecarl: a carved Nordic doorway, above the HOUSECARL wordmark"></p>
|
||||||
|
|
||||||
|
# Housecarl
|
||||||
|
|
||||||
|
*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
|
||||||
|
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
|
||||||
|
DHCP server, and works the same way: every change is checked as a whole
|
||||||
|
before it is used, is recorded in a history where it can be undone, and is
|
||||||
|
audited.
|
||||||
|
|
||||||
|
In Skyrim, a housecarl is the Jarl's sworn bodyguard: it stands at the
|
||||||
|
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
|
||||||
|
|
||||||
|
* **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.
|
||||||
|
|
||||||
|
See [docs/design.md](docs/design.md) for the plan and the open questions.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## Licence
|
||||||
|
|
||||||
|
MIT, see [LICENSE](LICENSE).
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 666 479.2" role="img" aria-label="Housecarl">
|
||||||
|
<!-- Generated by scripts/banner.py from internal/web/static/assets/logo.js and brand.js. -->
|
||||||
|
<title>Housecarl</title><style>.m{fill:#1d5287}.t{fill:#1d2330}.a{fill:#2f5fb3}.l{fill:none;stroke:#2f5fb3;stroke-width:2.6}.g{fill:#2f5fb3;font:700 17px system-ui,sans-serif;letter-spacing:.5px}@media (prefers-color-scheme:dark){.m{fill:#6d9be8}.t{fill:#e3e7ee}.a,.g{fill:#6d9be8}.l{stroke:#6d9be8}}</style>
|
||||||
|
<svg x="195.2" width="275.6" height="275.6" viewBox="26.5 120 769 769"><path class="m" fill-rule="evenodd" d="M406.5 139.5 L256.5 227.5 L256 228.5 L274.5 258.5 L411 179 L547.5 258.5 L566 228.5 L565.5 227.5 L415.5 139.5 Z M404 211.5 L285 279.5 L279 287 L201.5 354 L157 311.5 L157 278.5 L201 252.5 L218 271 L204.5 286 L195.5 280.5 L182 295 L209 324.5 L260 275 L207.5 212 L124.5 260.5 L124.5 334 L152 364 L152 755.5 L124.5 785 L124.5 828.5 L260 828.5 L260 793 L236 766.5 L236 358 L411 255.5 L586 358 L586 766.5 L562 793 L562 828.5 L697.5 828.5 L697.5 785 L670 755.5 L670 364 L697.5 334 L697.5 260.5 L614.5 212 L562 275 L613 324.5 L640 295 L626.5 280.5 L617.5 286 L604 271 L621 252.5 L665 278.5 L665 311.5 L620.5 354 L543 287 L537 279.5 L411 207 Z M182 416.5 L222.5 460 L222.5 486 L195.5 515 L219.5 541 L219.5 551 L210 561.5 L180 529 L180 503 L207 473 L179.5 443.5 L179.5 416.5 Z M642.5 416.5 L642.5 443.5 L615 473 L642 503 L642 529 L612 561.5 L602.5 551 L602.5 541 L626.5 515 L599.5 486 L599.5 460 L640 416.5 Z M209.5 565.5 L218 574.5 L218 648.5 L176.5 603.5 L205.5 568 L208 565 Z M614 565 L616.5 568 L645.5 603.5 L604 648.5 L604 574.5 L612.5 565.5 Z M184 644 L222.5 686 L222.5 699.5 L222 705.5 L180 751.5 L180 723.5 L205 696 L179.5 668.5 L179.5 641.5 Z M642.5 641.5 L642.5 668.5 L617 696 L642 723.5 L642 751.5 L600 705.5 L599.5 699.5 L599.5 686 L638 644 Z M206.5 594 L197 605 L206 615 L215.5 604.5 L206 594.5 Z M616 594.5 L606.5 604.5 L616 615 L625 605 L615.5 594 Z M392.5 297.5 L261 376 L261 757.5 L288 787.5 L288 829 L399.5 829 L399.5 595 L363 555.5 L400 515 L400 295 L399 294 Z M423 294 L422 295 L422 515 L459 555.5 L422.5 595 L422.5 829 L534 829 L534 787.5 L561 757.5 L561 376 L429.5 297.5 Z M372 350 L372 434.5 L359.5 448 L343 430.5 L353 419 L353 395 L319.5 415 L319.5 444 L370 499.5 L319 555 L370.5 611 L354.5 628.5 L287.5 556 L337.5 501.5 L294 454.5 L294 395.5 Z M528 395.5 L528 454.5 L484.5 501.5 L534.5 556 L467.5 628.5 L451.5 611 L503 555 L452 499.5 L502.5 444 L502.5 415 L469 395 L469 419 L479 430.5 L462.5 448 L450 434.5 L450 350 Z M321 617.5 L342 640.5 L320 665 L320 688 L353 724.5 L353 691 L342.5 679.5 L359.5 661.5 L371.5 675 L371 769 L295.5 705.5 L295 643.5 L319.5 617 Z M502.5 617 L527 643.5 L526.5 705.5 L451 769 L450.5 675 L462.5 661.5 L479.5 679.5 L469 691 L469 724.5 L502 688 L502 665 L480 640.5 L501 617.5 Z M409.5 534.5 L391.5 555 L411 576 L430.5 555 L411 533 Z"/></svg>
|
||||||
|
<svg y="303.2" width="666" height="176" viewBox="-4 -4 666 176"><g class="t" fill-rule="evenodd"><path transform="translate(0 0)" d="M0 0 H18 V40 L32 32 L46 40 V0 H64 V100 H46 V58 L32 50 L18 58 V100 H0 Z"/><path transform="translate(76 0)" d="M20 0 H44 L64 20 V80 L44 100 H20 L0 80 V20 Z M24 16 H40 L46 22 V78 L40 84 H24 L18 78 V22 Z"/><path transform="translate(152 0)" d="M0 0 H18 V78 L24 84 H40 L46 78 V0 H64 V80 L44 100 H20 L0 80 Z"/><path transform="translate(228 0)" d="M10 0 H54 L58 30 L40 16 H18 V41 H48 L58 51 V90 L48 100 H4 L0 70 L18 84 H40 V59 H10 L0 49 V10 Z"/><path transform="translate(298 0)" d="M0 0 H52 L56 18 H18 V41 H44 V59 H18 V82 H40 L58 70 L54 100 H0 Z"/><path transform="translate(368 0)" d="M20 0 H56 L60 30 L42 16 H24 L18 22 V78 L24 84 H42 L60 70 L56 100 H20 L0 80 V20 Z"/><path transform="translate(440 0)" d="M35 0 L46 10 L64 90 L70 100 H50 L44 76 L35 62 L26 76 L20 100 H0 L6 90 L24 10 Z M35 26 L41 40 L35 54 L29 40 Z"/><path transform="translate(522 0)" d="M0 0 H48 L60 12 V40 L50 50 L66 100 H46 L34 60 H18 V100 H0 Z M18 14 H41 L46 19 V35 L41 46 H18 Z"/><path transform="translate(600 0)" d="M0 0 H18 V84 H40 L58 70 L54 100 H0 Z"/></g><g transform="translate(0 118)"><path class="a" d="M2 18 L269 15.6 V20.4 Z M656 18 L389 15.6 V20.4 Z"/><circle class="l" cx="277" cy="18" r="4.6"/><path class="l" d="M281.6 18 H305 M353 18 H376.4"/><circle class="l" cx="329" cy="18" r="24"/><circle class="l" cx="381" cy="18" r="4.6"/><text class="g" x="329" y="24.5" text-anchor="middle">FW</text></g></svg>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.3 KiB |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="26.5 120 769 769">
|
||||||
|
<!-- The Housecarl logo for small sizes: the doorway without its carvings. -->
|
||||||
|
<style>path{fill:#1d5287}@media (prefers-color-scheme:dark){path{fill:#6d9be8}}</style>
|
||||||
|
<path fill-rule="evenodd" d="M406.5 139.5 L256.5 227.5 L256 228.5 L274.5 258.5 L411 179 L547.5 258.5 L566 228.5 L565.5 227.5 L415.5 139.5 Z M404 211.5 L285 279.5 L279 287 L201.5 354 L157 311.5 L157 278.5 L201 252.5 L218 271 L204.5 286 L195.5 280.5 L182 295 L209 324.5 L260 275 L207.5 212 L124.5 260.5 L124.5 334 L152 364 L152 755.5 L124.5 785 L124.5 828.5 L260 828.5 L260 793 L236 766.5 L236 358 L411 255.5 L586 358 L586 766.5 L562 793 L562 828.5 L697.5 828.5 L697.5 785 L670 755.5 L670 364 L697.5 334 L697.5 260.5 L614.5 212 L562 275 L613 324.5 L640 295 L626.5 280.5 L617.5 286 L604 271 L621 252.5 L665 278.5 L665 311.5 L620.5 354 L543 287 L537 279.5 L411 207 Z M392.5 297.5 L261 376 L261 757.5 L288 787.5 L288 829 L399.5 829 L399.5 595 L363 555.5 L400 515 L400 295 L399 294 Z M423 294 L422 295 L422 515 L459 555.5 L422.5 595 L422.5 829 L534 829 L534 787.5 L561 757.5 L561 376 L429.5 297.5 Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="26.5 120 769 769">
|
||||||
|
<!-- The Housecarl logo: a carved Nordic doorway (the master; scripts/logo.py builds the web UI's copies). -->
|
||||||
|
<style>path{fill:#1d5287}@media (prefers-color-scheme:dark){path{fill:#6d9be8}}</style>
|
||||||
|
<path fill-rule="evenodd" d="M406.5 139.5 L256.5 227.5 L256 228.5 L274.5 258.5 L411 179 L547.5 258.5 L566 228.5 L565.5 227.5 L415.5 139.5 Z M404 211.5 L285 279.5 L279 287 L201.5 354 L157 311.5 L157 278.5 L201 252.5 L218 271 L204.5 286 L195.5 280.5 L182 295 L209 324.5 L260 275 L207.5 212 L124.5 260.5 L124.5 334 L152 364 L152 755.5 L124.5 785 L124.5 828.5 L260 828.5 L260 793 L236 766.5 L236 358 L411 255.5 L586 358 L586 766.5 L562 793 L562 828.5 L697.5 828.5 L697.5 785 L670 755.5 L670 364 L697.5 334 L697.5 260.5 L614.5 212 L562 275 L613 324.5 L640 295 L626.5 280.5 L617.5 286 L604 271 L621 252.5 L665 278.5 L665 311.5 L620.5 354 L543 287 L537 279.5 L411 207 Z M182 416.5 L222.5 460 L222.5 486 L195.5 515 L219.5 541 L219.5 551 L210 561.5 L180 529 L180 503 L207 473 L179.5 443.5 L179.5 416.5 Z M642.5 416.5 L642.5 443.5 L615 473 L642 503 L642 529 L612 561.5 L602.5 551 L602.5 541 L626.5 515 L599.5 486 L599.5 460 L640 416.5 Z M209.5 565.5 L218 574.5 L218 648.5 L176.5 603.5 L205.5 568 L208 565 Z M614 565 L616.5 568 L645.5 603.5 L604 648.5 L604 574.5 L612.5 565.5 Z M184 644 L222.5 686 L222.5 699.5 L222 705.5 L180 751.5 L180 723.5 L205 696 L179.5 668.5 L179.5 641.5 Z M642.5 641.5 L642.5 668.5 L617 696 L642 723.5 L642 751.5 L600 705.5 L599.5 699.5 L599.5 686 L638 644 Z M206.5 594 L197 605 L206 615 L215.5 604.5 L206 594.5 Z M616 594.5 L606.5 604.5 L616 615 L625 605 L615.5 594 Z M392.5 297.5 L261 376 L261 757.5 L288 787.5 L288 829 L399.5 829 L399.5 595 L363 555.5 L400 515 L400 295 L399 294 Z M423 294 L422 295 L422 515 L459 555.5 L422.5 595 L422.5 829 L534 829 L534 787.5 L561 757.5 L561 376 L429.5 297.5 Z M372 350 L372 434.5 L359.5 448 L343 430.5 L353 419 L353 395 L319.5 415 L319.5 444 L370 499.5 L319 555 L370.5 611 L354.5 628.5 L287.5 556 L337.5 501.5 L294 454.5 L294 395.5 Z M528 395.5 L528 454.5 L484.5 501.5 L534.5 556 L467.5 628.5 L451.5 611 L503 555 L452 499.5 L502.5 444 L502.5 415 L469 395 L469 419 L479 430.5 L462.5 448 L450 434.5 L450 350 Z M321 617.5 L342 640.5 L320 665 L320 688 L353 724.5 L353 691 L342.5 679.5 L359.5 661.5 L371.5 675 L371 769 L295.5 705.5 L295 643.5 L319.5 617 Z M502.5 617 L527 643.5 L526.5 705.5 L451 769 L450.5 675 L462.5 661.5 L479.5 679.5 L469 691 L469 724.5 L502 688 L502 665 L480 640.5 L501 617.5 Z M409.5 534.5 L391.5 555 L411 576 L430.5 555 L411 533 Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.5 KiB |
@@ -0,0 +1,42 @@
|
|||||||
|
// 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.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"housecarl/internal/version"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if err := rootCmd().Execute(); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "error:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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).`,
|
||||||
|
SilenceUsage: true,
|
||||||
|
SilenceErrors: true,
|
||||||
|
Version: version.String(),
|
||||||
|
}
|
||||||
|
root.AddCommand(versionCmd())
|
||||||
|
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()) },
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
// 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.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"housecarl/internal/version"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if err := rootCmd().Execute(); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "error:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func rootCmd() *cobra.Command {
|
||||||
|
root := &cobra.Command{
|
||||||
|
Use: "housecarld",
|
||||||
|
Short: "Housecarl's firewall daemon, API and web UI",
|
||||||
|
Long: `housecarld will keep a Linux firewall's nftables ruleset, built from its
|
||||||
|
database, and serve the REST API and web UI that change it. It doesn't yet:
|
||||||
|
Housecarl is at its start (docs/design.md).`,
|
||||||
|
SilenceUsage: true,
|
||||||
|
SilenceErrors: true,
|
||||||
|
Version: version.String(),
|
||||||
|
}
|
||||||
|
root.AddCommand(versionCmd())
|
||||||
|
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("housecarld", version.String()) },
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# Housecarl's design
|
||||||
|
|
||||||
|
Housecarl is a firewall manager with the same shape as
|
||||||
|
[Jarl](https://gitea.adbraun.lol/adam/jarl) and
|
||||||
|
[Steward](https://gitea.adbraun.lol/adam/steward): one daemon,
|
||||||
|
`housecarld`, that serves a REST API and a web UI; one CLI, `hc`, that
|
||||||
|
talks to that API; SQLite for everything. As Jarl does for BIND, it keeps
|
||||||
|
a Linux machine's **nftables** ruleset: the database is the source of
|
||||||
|
truth, and the ruleset is built from it.
|
||||||
|
|
||||||
|
This page is the starting sketch. Nothing here is built yet; it records
|
||||||
|
what was decided and what is still open.
|
||||||
|
|
||||||
|
## How a change is made
|
||||||
|
|
||||||
|
As in Jarl, every change goes through one engine:
|
||||||
|
|
||||||
|
1. the request becomes a change to a copy of the configuration;
|
||||||
|
2. the whole result is validated, with errors that say what is wrong and
|
||||||
|
how to fix it;
|
||||||
|
3. the ruleset is rendered, and checked with `nft -c -f` (a dry run by
|
||||||
|
nftables itself);
|
||||||
|
4. it is applied with `nft -f`, which replaces Housecarl's table in one
|
||||||
|
transaction: the old rules stay in force until the new ones are all in;
|
||||||
|
5. the change, its history entry and the audit record are saved together.
|
||||||
|
|
||||||
|
Dry runs, history with undo, users, sessions and scoped API tokens come
|
||||||
|
from the same machinery, as in Jarl and Steward.
|
||||||
|
|
||||||
|
### Not locking yourself out
|
||||||
|
|
||||||
|
A firewall managed over the network can cut off its own manager. Housecarl
|
||||||
|
should guard against that from the start:
|
||||||
|
|
||||||
|
* **Confirm or roll back.** A risky change (one touching the rules for the
|
||||||
|
machine itself) is applied on probation: unless it is confirmed within a
|
||||||
|
minute or two, from the web UI or `hc confirm`, the previous ruleset comes
|
||||||
|
back by itself. Network equipment calls this "commit confirmed".
|
||||||
|
* **A management rule that can't be dropped by accident**: the API and SSH
|
||||||
|
stay reachable from the management network unless that rule is removed
|
||||||
|
on purpose.
|
||||||
|
|
||||||
|
## Its own table only
|
||||||
|
|
||||||
|
Housecarl writes one nftables table of its own (`inet housecarl`) and
|
||||||
|
leaves every other table alone, such as Docker's or libvirt's. It never
|
||||||
|
flushes the whole ruleset.
|
||||||
|
|
||||||
|
## Concepts (draft)
|
||||||
|
|
||||||
|
* **Zones**: interfaces or VLANs grouped by trust (`lan`, `iot`, `guest`,
|
||||||
|
`wan`), and the default policy between each pair.
|
||||||
|
* **Address sets**: named sets of addresses or prefixes, which rules refer
|
||||||
|
to. An entry may have a **timeout**, which nftables enforces by itself, so
|
||||||
|
an entry expires even if housecarld is down. Other programs can be given
|
||||||
|
a token that may only change one set.
|
||||||
|
* **Rules** between zones: allow or drop, by set, port and protocol, with
|
||||||
|
logging and counters.
|
||||||
|
* **NAT**: masquerading out of the WAN zone, and port forwards.
|
||||||
|
* **Services**: names for ports (`ssh`, `https`, `dns`).
|
||||||
|
|
||||||
|
## Working with Steward and Jarl
|
||||||
|
|
||||||
|
* **Steward's captive portal** (parked until Housecarl exists): a guest
|
||||||
|
network's zone lets clients that haven't signed in reach only DNS and
|
||||||
|
the portal, and sends their web traffic to it. When a guest signs in,
|
||||||
|
Steward adds its address to a set (`guest-signed-in`) for the session's
|
||||||
|
length, with a token that may only change that set. Addresses, not MAC
|
||||||
|
addresses: matching by MAC only works on the guests' own network
|
||||||
|
segment.
|
||||||
|
* **Names instead of addresses**: Steward knows every reservation's and
|
||||||
|
lease's address, so rules could name a device ("the printer") or a
|
||||||
|
subnet; Jarl knows the names in DNS.
|
||||||
|
* **Steward's deny lists** could also be enforced here, so a denied device
|
||||||
|
with an address set by hand is still blocked.
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
* IPv6 from the start, or IPv4 first as Steward did?
|
||||||
|
* Import an existing nftables (or iptables) ruleset, as Jarl imports a BIND
|
||||||
|
configuration?
|
||||||
|
* Connection tracking defaults: established and related traffic allowed
|
||||||
|
everywhere unless a rule says otherwise?
|
||||||
|
* Does Housecarl also run the router's interfaces and routes, or only the
|
||||||
|
firewall? (Only the firewall, for now.)
|
||||||
|
* Rate limits and flow offloading: later.
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
module housecarl
|
||||||
|
|
||||||
|
go 1.27.1
|
||||||
|
|
||||||
|
require github.com/spf13/cobra v1.10.2
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
|
github.com/spf13/pflag v1.0.9 // indirect
|
||||||
|
)
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
|
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||||
|
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||||
|
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||||
|
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
// Package version holds Housecarl's version, set at build time with
|
||||||
|
//
|
||||||
|
// go build -ldflags "-X housecarl/internal/version.Version=1.2.3 -X housecarl/internal/version.Tag=v1.2.3"
|
||||||
|
//
|
||||||
|
// The Makefile sets Version from the VERSION file, and Tag only when the
|
||||||
|
// build is of that release: HEAD is the tag v<VERSION> and no tracked file
|
||||||
|
// is changed. Only such a build reports a version number. Other builds
|
||||||
|
// are development builds, identified by their commit (which Go records
|
||||||
|
// from git by itself) and by where they stand relative to the last tag
|
||||||
|
// (Describe, from git describe).
|
||||||
|
package version
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"runtime/debug"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Version is the Housecarl version (the VERSION file).
|
||||||
|
var Version = "0.1.0-dev"
|
||||||
|
|
||||||
|
// Tag is the release tag the build is of ("" when it isn't a release).
|
||||||
|
var Tag = ""
|
||||||
|
|
||||||
|
// Commit is the git commit the build is of. It is read from the build
|
||||||
|
// information when not set at build time.
|
||||||
|
var Commit = ""
|
||||||
|
|
||||||
|
// Describe is 'git describe --tags --long' of the build's commit
|
||||||
|
// ("v0.2.0-rc.1-3-gca0f148b78b6"), set by the Makefile; it says how many
|
||||||
|
// commits a development build is past the last tag.
|
||||||
|
var Describe = ""
|
||||||
|
|
||||||
|
// Repo is Housecarl's source repository.
|
||||||
|
const Repo = "https://gitea.adbraun.lol/adam/housecarl"
|
||||||
|
|
||||||
|
// Build describes what a binary was built from.
|
||||||
|
type Build struct {
|
||||||
|
Version string `json:"version"`
|
||||||
|
Tag string `json:"tag,omitempty"`
|
||||||
|
Commit string `json:"commit,omitempty"`
|
||||||
|
Modified bool `json:"modified,omitempty"` // built with uncommitted changes
|
||||||
|
// Since is the last tag before a development build's commit, and
|
||||||
|
// Ahead how many commits the build is past it (0: the tag's commit,
|
||||||
|
// with uncommitted changes).
|
||||||
|
Since string `json:"since,omitempty"`
|
||||||
|
Ahead int `json:"ahead,omitempty"`
|
||||||
|
// Label is the short form to show: the tag of a release, otherwise
|
||||||
|
// the abbreviated commit (or the version when the commit is unknown).
|
||||||
|
Label string `json:"label"`
|
||||||
|
// URL is the release or commit in the repository.
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var info = sync.OnceValue(func() Build {
|
||||||
|
commit, modified := Commit, false
|
||||||
|
if bi, ok := debug.ReadBuildInfo(); ok {
|
||||||
|
for _, s := range bi.Settings {
|
||||||
|
switch s.Key {
|
||||||
|
case "vcs.revision":
|
||||||
|
if commit == "" {
|
||||||
|
commit = s.Value
|
||||||
|
}
|
||||||
|
case "vcs.modified":
|
||||||
|
modified = s.Value == "true"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b := describe(Version, Tag, commit, modified)
|
||||||
|
b.Since, b.Ahead = parseDescribe(Describe, b.Commit)
|
||||||
|
return b
|
||||||
|
})
|
||||||
|
|
||||||
|
// parseDescribe splits git describe --long output, if it is of commit.
|
||||||
|
func parseDescribe(d, commit string) (since string, ahead int) {
|
||||||
|
m := describeRe.FindStringSubmatch(d)
|
||||||
|
if m == nil || commit == "" || !strings.HasPrefix(commit, m[3]) {
|
||||||
|
return "", 0
|
||||||
|
}
|
||||||
|
n, _ := strconv.Atoi(m[2])
|
||||||
|
return m[1], n
|
||||||
|
}
|
||||||
|
|
||||||
|
var describeRe = regexp.MustCompile(`^(v.+)-(\d+)-g([0-9a-f]+)$`)
|
||||||
|
|
||||||
|
func describe(version, tag, commit string, modified bool) Build {
|
||||||
|
b := Build{Version: version, Tag: tag, Commit: commit, Modified: modified}
|
||||||
|
switch {
|
||||||
|
case tag != "":
|
||||||
|
b.Modified = false // the Makefile only sets Tag for clean trees
|
||||||
|
b.Label, b.URL = tag, Repo+"/releases/tag/"+tag
|
||||||
|
case commit != "":
|
||||||
|
b.Label, b.URL = short(commit), Repo+"/commit/"+commit
|
||||||
|
if modified {
|
||||||
|
b.Label += "-modified"
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
b.Label, b.URL = version, Repo
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// Info describes this build.
|
||||||
|
func Info() Build { return info() }
|
||||||
|
|
||||||
|
// String is the version for logs and 'version' commands. Only a release
|
||||||
|
// build gives a version ("0.2.0"); any other build says it's a
|
||||||
|
// development build: "dev a1b2c3d4e5f6 (3 commits after v0.2.0-rc.1)".
|
||||||
|
func String() string { return Info().String() }
|
||||||
|
|
||||||
|
func (b Build) String() string {
|
||||||
|
switch {
|
||||||
|
case b.Tag != "":
|
||||||
|
return b.Version
|
||||||
|
case b.Commit == "":
|
||||||
|
// No git information (built from a source archive).
|
||||||
|
return b.Version + " (source build)"
|
||||||
|
}
|
||||||
|
s := "dev " + b.Label
|
||||||
|
switch {
|
||||||
|
case b.Since == "":
|
||||||
|
case b.Ahead == 1:
|
||||||
|
s += " (1 commit after " + b.Since + ")"
|
||||||
|
case b.Ahead > 1:
|
||||||
|
s += " (" + strconv.Itoa(b.Ahead) + " commits after " + b.Since + ")"
|
||||||
|
case b.Modified:
|
||||||
|
s += " (" + b.Since + " with uncommitted changes)"
|
||||||
|
default:
|
||||||
|
s += " (at " + b.Since + ")"
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// Short is the version for a User-Agent: the version of a release,
|
||||||
|
// otherwise the label ("a1b2c3d4e5f6").
|
||||||
|
func Short() string {
|
||||||
|
if b := Info(); b.Tag == "" && b.Commit != "" {
|
||||||
|
return b.Label
|
||||||
|
}
|
||||||
|
return Version
|
||||||
|
}
|
||||||
|
|
||||||
|
func short(commit string) string {
|
||||||
|
if len(commit) > 12 {
|
||||||
|
return commit[:12]
|
||||||
|
}
|
||||||
|
return commit
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package version
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestDescribe(t *testing.T) {
|
||||||
|
const c = "4d00be5e1f0a9b8c7d6e5f4a3b2c1d0e9f8a7b6c"
|
||||||
|
for _, tc := range []struct {
|
||||||
|
tag, commit string
|
||||||
|
modified bool
|
||||||
|
label, url string
|
||||||
|
}{
|
||||||
|
{"v0.1.0", c, true, "v0.1.0", Repo + "/releases/tag/v0.1.0"},
|
||||||
|
{"", c, false, "4d00be5e1f0a", Repo + "/commit/" + c},
|
||||||
|
{"", c, true, "4d00be5e1f0a-modified", Repo + "/commit/" + c},
|
||||||
|
{"", "", false, "0.1.0", Repo},
|
||||||
|
} {
|
||||||
|
b := describe("0.1.0", tc.tag, tc.commit, tc.modified)
|
||||||
|
if b.Label != tc.label || b.URL != tc.url {
|
||||||
|
t.Errorf("describe(%q, %q, %v) = %q %q, want %q %q", tc.tag, tc.commit, tc.modified, b.Label, b.URL, tc.label, tc.url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestString(t *testing.T) {
|
||||||
|
const c = "ca0f148b78b6e1f0a9b8c7d6e5f4a3b2c1d0e9f8"
|
||||||
|
build := func(tag, commit string, modified bool, desc string) Build {
|
||||||
|
b := describe("0.2.0-rc.1", tag, commit, modified)
|
||||||
|
b.Since, b.Ahead = parseDescribe(desc, b.Commit)
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
for _, tc := range []struct {
|
||||||
|
b Build
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{build("v0.2.0-rc.1", c, false, "v0.2.0-rc.1-0-gca0f148b78b6"), "0.2.0-rc.1"},
|
||||||
|
{build("", c, false, "v0.2.0-rc.1-3-gca0f148b78b6"), "dev ca0f148b78b6 (3 commits after v0.2.0-rc.1)"},
|
||||||
|
{build("", c, true, "v0.2.0-rc.1-1-gca0f148b78b6"), "dev ca0f148b78b6-modified (1 commit after v0.2.0-rc.1)"},
|
||||||
|
{build("", c, true, "v0.2.0-rc.1-0-gca0f148b78b6"), "dev ca0f148b78b6-modified (v0.2.0-rc.1 with uncommitted changes)"},
|
||||||
|
{build("", c, false, "v0.1.0-0-gca0f148b78b6"), "dev ca0f148b78b6 (at v0.1.0)"},
|
||||||
|
{build("", c, false, ""), "dev ca0f148b78b6"},
|
||||||
|
// A describe of another commit (stale ldflags) is ignored.
|
||||||
|
{build("", c, false, "v0.2.0-rc.1-3-gdeadbeef0000"), "dev ca0f148b78b6"},
|
||||||
|
{build("", "", false, ""), "0.2.0-rc.1 (source build)"},
|
||||||
|
} {
|
||||||
|
if got := tc.b.String(); got != tc.want {
|
||||||
|
t.Errorf("String() = %q, want %q", got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
// The Housecarl logo and the login page's wordmark, drawn as inline SVG so
|
||||||
|
// they appear together with the page instead of loading afterwards.
|
||||||
|
//
|
||||||
|
// The "HOUSECARL" wordmark: the angular letters of Jarl's and Steward's
|
||||||
|
// wordmarks (A, R and L are Jarl's, S and E Steward's), with an H whose
|
||||||
|
// crossbar rises like the doorway's roof. Drawn as paths so no font is
|
||||||
|
// needed. The login page shows it over a rule with an FW node; the header
|
||||||
|
// shows the letters alone. The letters use the text color and the rule the
|
||||||
|
// accent color.
|
||||||
|
import * as logoData from "./logo.js";
|
||||||
|
|
||||||
|
const NS = "http://www.w3.org/2000/svg";
|
||||||
|
|
||||||
|
function el(tag, attrs, ...children) {
|
||||||
|
const e = document.createElementNS(NS, tag);
|
||||||
|
for (const [k, v] of Object.entries(attrs || {})) e.setAttribute(k, v);
|
||||||
|
for (const c of children) e.append(c instanceof Node ? c : document.createTextNode(c));
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Letters on a 100-unit cap height; counters are cut with evenodd.
|
||||||
|
const letters = [
|
||||||
|
// H: two stems; the crossbar rises to a peak, like the doorway's roof
|
||||||
|
{ x: 0, d: "M0 0 H18 V40 L32 32 L46 40 V0 H64 V100 H46 V58 L32 50 L18 58 V100 H0 Z" },
|
||||||
|
// O: an octagon, with the cut corners of R's bowl
|
||||||
|
{ x: 76, d: "M20 0 H44 L64 20 V80 L44 100 H20 L0 80 V20 Z M24 16 H40 L46 22 V78 L40 84 H24 L18 78 V22 Z" },
|
||||||
|
// U: cut corners at the foot
|
||||||
|
{ x: 152, d: "M0 0 H18 V78 L24 84 H40 L46 78 V0 H64 V80 L44 100 H20 L0 80 Z" },
|
||||||
|
// S and E: Steward's
|
||||||
|
{ x: 228, d: "M10 0 H54 L58 30 L40 16 H18 V41 H48 L58 51 V90 L48 100 H4 L0 70 L18 84 H40 V59 H10 L0 49 V10 Z" },
|
||||||
|
{ x: 298, d: "M0 0 H52 L56 18 H18 V41 H44 V59 H18 V82 H40 L58 70 L54 100 H0 Z" },
|
||||||
|
// C: S's downward barb at the top, E's upswept spike at the foot
|
||||||
|
{ x: 368, d: "M20 0 H56 L60 30 L42 16 H24 L18 22 V78 L24 84 H42 L60 70 L56 100 H20 L0 80 V20 Z" },
|
||||||
|
// A, R and L: Jarl's
|
||||||
|
{ x: 440, d: "M35 0 L46 10 L64 90 L70 100 H50 L44 76 L35 62 L26 76 L20 100 H0 L6 90 L24 10 Z M35 26 L41 40 L35 54 L29 40 Z" },
|
||||||
|
{ x: 522, d: "M0 0 H48 L60 12 V40 L50 50 L66 100 H46 L34 60 H18 V100 H0 Z M18 14 H41 L46 19 V35 L41 46 H18 Z" },
|
||||||
|
{ x: 600, d: "M0 0 H18 V84 H40 L58 70 L54 100 H0 Z" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const W = 658; // the letters' width
|
||||||
|
|
||||||
|
// wordmark returns the login page's wordmark, letters over the rule.
|
||||||
|
// wordmark({ rule: false, height: 19, cls: "brand-name" }) returns just the
|
||||||
|
// letters, height pixels tall.
|
||||||
|
export function wordmark({ rule = true, height, cls = "wordmark" } = {}) {
|
||||||
|
const letterG = el("g", { fill: "currentColor", "fill-rule": "evenodd" },
|
||||||
|
...letters.map((l) => el("path", { d: l.d, transform: `translate(${l.x} 0)` })));
|
||||||
|
if (!rule) {
|
||||||
|
const attrs = { class: cls, viewBox: `-2 -2 ${W + 4} 104`, role: "img", "aria-label": "Housecarl" };
|
||||||
|
if (height) Object.assign(attrs, { height, width: Math.round(height * (W + 4) / 104) });
|
||||||
|
return el("svg", attrs, el("title", null, "Housecarl"), letterG);
|
||||||
|
}
|
||||||
|
const c = W / 2;
|
||||||
|
const ruleG = el("g", { class: "wordmark-rule", transform: "translate(0 118)" },
|
||||||
|
el("path", { d: `M2 18 L${c - 60} 15.6 V20.4 Z M${W - 2} 18 L${c + 60} 15.6 V20.4 Z`, class: "fill" }),
|
||||||
|
el("circle", { cx: c - 52, cy: 18, r: 4.6, class: "line" }),
|
||||||
|
el("path", { d: `M${c - 47.4} 18 H${c - 24} M${c + 24} 18 H${c + 47.4}`, class: "line" }),
|
||||||
|
el("circle", { cx: c, cy: 18, r: 24, class: "line" }),
|
||||||
|
el("circle", { cx: c + 52, cy: 18, r: 4.6, class: "line" }),
|
||||||
|
el("text", { x: c, y: 24.5, "text-anchor": "middle", class: "tag" }, "FW"));
|
||||||
|
return el("svg", { class: cls, viewBox: `-4 -4 ${W + 8} 176`, role: "img", "aria-label": "Housecarl" },
|
||||||
|
el("title", null, "Housecarl"), letterG, ruleG);
|
||||||
|
}
|
||||||
|
|
||||||
|
// logo returns the Housecarl logo (artwork/logo.svg, via scripts/logo.py)
|
||||||
|
// as an SVG of the given size in pixels: the full doorway, or with small
|
||||||
|
// the one without carvings (for sizes under about 64 pixels). Its color
|
||||||
|
// is the .logo-mark rule in style.css.
|
||||||
|
export function logo(size, { small = false, cls = "logo" } = {}) {
|
||||||
|
return el("svg", { class: cls, width: size, height: size, viewBox: logoData.viewBox, "aria-hidden": "true", focusable: "false" },
|
||||||
|
el("path", { d: small ? logoData.small : logoData.full, "fill-rule": "evenodd", class: "logo-mark" }));
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
// The Housecarl logo, from artwork/logo.svg and artwork/logo-small.svg by
|
||||||
|
// scripts/logo.py; don't edit. brand.js draws it; logo.svg is the small one.
|
||||||
|
export const viewBox = "26.5 120 769 769";
|
||||||
|
export const full = "M406.5 139.5 L256.5 227.5 L256 228.5 L274.5 258.5 L411 179 L547.5 258.5 L566 228.5 L565.5 227.5 L415.5 139.5 Z M404 211.5 L285 279.5 L279 287 L201.5 354 L157 311.5 L157 278.5 L201 252.5 L218 271 L204.5 286 L195.5 280.5 L182 295 L209 324.5 L260 275 L207.5 212 L124.5 260.5 L124.5 334 L152 364 L152 755.5 L124.5 785 L124.5 828.5 L260 828.5 L260 793 L236 766.5 L236 358 L411 255.5 L586 358 L586 766.5 L562 793 L562 828.5 L697.5 828.5 L697.5 785 L670 755.5 L670 364 L697.5 334 L697.5 260.5 L614.5 212 L562 275 L613 324.5 L640 295 L626.5 280.5 L617.5 286 L604 271 L621 252.5 L665 278.5 L665 311.5 L620.5 354 L543 287 L537 279.5 L411 207 Z M182 416.5 L222.5 460 L222.5 486 L195.5 515 L219.5 541 L219.5 551 L210 561.5 L180 529 L180 503 L207 473 L179.5 443.5 L179.5 416.5 Z M642.5 416.5 L642.5 443.5 L615 473 L642 503 L642 529 L612 561.5 L602.5 551 L602.5 541 L626.5 515 L599.5 486 L599.5 460 L640 416.5 Z M209.5 565.5 L218 574.5 L218 648.5 L176.5 603.5 L205.5 568 L208 565 Z M614 565 L616.5 568 L645.5 603.5 L604 648.5 L604 574.5 L612.5 565.5 Z M184 644 L222.5 686 L222.5 699.5 L222 705.5 L180 751.5 L180 723.5 L205 696 L179.5 668.5 L179.5 641.5 Z M642.5 641.5 L642.5 668.5 L617 696 L642 723.5 L642 751.5 L600 705.5 L599.5 699.5 L599.5 686 L638 644 Z M206.5 594 L197 605 L206 615 L215.5 604.5 L206 594.5 Z M616 594.5 L606.5 604.5 L616 615 L625 605 L615.5 594 Z M392.5 297.5 L261 376 L261 757.5 L288 787.5 L288 829 L399.5 829 L399.5 595 L363 555.5 L400 515 L400 295 L399 294 Z M423 294 L422 295 L422 515 L459 555.5 L422.5 595 L422.5 829 L534 829 L534 787.5 L561 757.5 L561 376 L429.5 297.5 Z M372 350 L372 434.5 L359.5 448 L343 430.5 L353 419 L353 395 L319.5 415 L319.5 444 L370 499.5 L319 555 L370.5 611 L354.5 628.5 L287.5 556 L337.5 501.5 L294 454.5 L294 395.5 Z M528 395.5 L528 454.5 L484.5 501.5 L534.5 556 L467.5 628.5 L451.5 611 L503 555 L452 499.5 L502.5 444 L502.5 415 L469 395 L469 419 L479 430.5 L462.5 448 L450 434.5 L450 350 Z M321 617.5 L342 640.5 L320 665 L320 688 L353 724.5 L353 691 L342.5 679.5 L359.5 661.5 L371.5 675 L371 769 L295.5 705.5 L295 643.5 L319.5 617 Z M502.5 617 L527 643.5 L526.5 705.5 L451 769 L450.5 675 L462.5 661.5 L479.5 679.5 L469 691 L469 724.5 L502 688 L502 665 L480 640.5 L501 617.5 Z M409.5 534.5 L391.5 555 L411 576 L430.5 555 L411 533 Z";
|
||||||
|
export const small = "M406.5 139.5 L256.5 227.5 L256 228.5 L274.5 258.5 L411 179 L547.5 258.5 L566 228.5 L565.5 227.5 L415.5 139.5 Z M404 211.5 L285 279.5 L279 287 L201.5 354 L157 311.5 L157 278.5 L201 252.5 L218 271 L204.5 286 L195.5 280.5 L182 295 L209 324.5 L260 275 L207.5 212 L124.5 260.5 L124.5 334 L152 364 L152 755.5 L124.5 785 L124.5 828.5 L260 828.5 L260 793 L236 766.5 L236 358 L411 255.5 L586 358 L586 766.5 L562 793 L562 828.5 L697.5 828.5 L697.5 785 L670 755.5 L670 364 L697.5 334 L697.5 260.5 L614.5 212 L562 275 L613 324.5 L640 295 L626.5 280.5 L617.5 286 L604 271 L621 252.5 L665 278.5 L665 311.5 L620.5 354 L543 287 L537 279.5 L411 207 Z M392.5 297.5 L261 376 L261 757.5 L288 787.5 L288 829 L399.5 829 L399.5 595 L363 555.5 L400 515 L400 295 L399 294 Z M423 294 L422 295 L422 515 L459 555.5 L422.5 595 L422.5 829 L534 829 L534 787.5 L561 757.5 L561 376 L429.5 297.5 Z";
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="26.5 120 769 769">
|
||||||
|
<!-- The Housecarl logo for small sizes: the doorway without its carvings. -->
|
||||||
|
<style>path{fill:#1d5287}@media (prefers-color-scheme:dark){path{fill:#6d9be8}}</style>
|
||||||
|
<path fill-rule="evenodd" d="M406.5 139.5 L256.5 227.5 L256 228.5 L274.5 258.5 L411 179 L547.5 258.5 L566 228.5 L565.5 227.5 L415.5 139.5 Z M404 211.5 L285 279.5 L279 287 L201.5 354 L157 311.5 L157 278.5 L201 252.5 L218 271 L204.5 286 L195.5 280.5 L182 295 L209 324.5 L260 275 L207.5 212 L124.5 260.5 L124.5 334 L152 364 L152 755.5 L124.5 785 L124.5 828.5 L260 828.5 L260 793 L236 766.5 L236 358 L411 255.5 L586 358 L586 766.5 L562 793 L562 828.5 L697.5 828.5 L697.5 785 L670 755.5 L670 364 L697.5 334 L697.5 260.5 L614.5 212 L562 275 L613 324.5 L640 295 L626.5 280.5 L617.5 286 L604 271 L621 252.5 L665 278.5 L665 311.5 L620.5 354 L543 287 L537 279.5 L411 207 Z M392.5 297.5 L261 376 L261 757.5 L288 787.5 L288 829 L399.5 829 L399.5 595 L363 555.5 L400 515 L400 295 L399 294 Z M423 294 L422 295 L422 515 L459 555.5 L422.5 595 L422.5 829 L534 829 L534 787.5 L561 757.5 L561 376 L429.5 297.5 Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,79 @@
|
|||||||
|
/* Housecarl web UI. Plain CSS, light and dark themes (the same as Jarl's and
|
||||||
|
Steward's). For now only the colors and the brand: the pages come later. */
|
||||||
|
:root {
|
||||||
|
--bg: #f6f7f9;
|
||||||
|
--panel: #ffffff;
|
||||||
|
--text: #1d2330;
|
||||||
|
--muted: #5f6b7d;
|
||||||
|
--hatch: rgba(95, 107, 125, 0.13);
|
||||||
|
--border: #d9dee6;
|
||||||
|
--accent: #2f5fb3;
|
||||||
|
--accent-text: #ffffff;
|
||||||
|
--accent-soft: #e5edfa;
|
||||||
|
--ok: #1e7a46;
|
||||||
|
--ok-soft: #e3f4ea;
|
||||||
|
--warn: #8a5a00;
|
||||||
|
--warn-soft: #fdf2d9;
|
||||||
|
--err: #b3261e;
|
||||||
|
--err-soft: #fbe7e5;
|
||||||
|
--info-soft: #eef1f6;
|
||||||
|
--code-bg: #f0f2f5;
|
||||||
|
--add: #e6f6ea;
|
||||||
|
--add-text: #135c2c;
|
||||||
|
--del: #fde8e7;
|
||||||
|
--del-text: #8c1d18;
|
||||||
|
--hunk: #e8eefb;
|
||||||
|
--shadow: 0 1px 2px rgba(20, 30, 50, 0.08);
|
||||||
|
--radius: 6px;
|
||||||
|
--vc-green: #3d8160; --vc-teal: #2f7c80; --vc-purple: #7a5ba8; --vc-pink: #a8557f;
|
||||||
|
--vc-orange: #a8652f; --vc-yellow: #8a7424; --vc-red: #b0534c; --vc-grey: #6b7280;
|
||||||
|
--mono: ui-monospace, "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace;
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root:not([data-theme="light"]) {
|
||||||
|
--bg: #14171c;
|
||||||
|
--panel: #1c2027;
|
||||||
|
--text: #e3e7ee;
|
||||||
|
--muted: #9aa5b5;
|
||||||
|
--hatch: rgba(154, 165, 181, 0.12);
|
||||||
|
--border: #2f3540;
|
||||||
|
--accent: #6d9be8;
|
||||||
|
--accent-text: #0f1520;
|
||||||
|
--accent-soft: #23314a;
|
||||||
|
--ok: #59c287;
|
||||||
|
--ok-soft: #173326;
|
||||||
|
--warn: #e5b450;
|
||||||
|
--warn-soft: #3a2f15;
|
||||||
|
--err: #f08077;
|
||||||
|
--err-soft: #3c1f1d;
|
||||||
|
--info-soft: #232833;
|
||||||
|
--code-bg: #242933;
|
||||||
|
--add: #183a25;
|
||||||
|
--add-text: #9fe3b5;
|
||||||
|
--del: #43201e;
|
||||||
|
--del-text: #f7b2ac;
|
||||||
|
--hunk: #23314a;
|
||||||
|
--shadow: none;
|
||||||
|
--vc-green: #8fcfa9; --vc-teal: #87cdd0; --vc-purple: #bca6e6; --vc-pink: #e6a6c6;
|
||||||
|
--vc-orange: #ebbb8f; --vc-yellow: #e3d08e; --vc-red: #eba29c; --vc-grey: #aab2bf;
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] {
|
||||||
|
--bg: #14171c; --panel: #1c2027; --text: #e3e7ee; --muted: #9aa5b5; --border: #2f3540; --hatch: rgba(154, 165, 181, 0.12);
|
||||||
|
--accent: #6d9be8; --accent-text: #0f1520; --accent-soft: #23314a; --ok: #59c287; --ok-soft: #173326;
|
||||||
|
--warn: #e5b450; --warn-soft: #3a2f15; --err: #f08077; --err-soft: #3c1f1d; --info-soft: #232833;
|
||||||
|
--code-bg: #242933; --add: #183a25; --add-text: #9fe3b5; --del: #43201e; --del-text: #f7b2ac; --hunk: #23314a;
|
||||||
|
|
||||||
|
/* The logo's own blue (artwork/logo.svg), lighter on dark backgrounds. */
|
||||||
|
:root { --logo: #1d5287; }
|
||||||
|
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { --logo: #6d9be8; } }
|
||||||
|
:root[data-theme="dark"] { --logo: #6d9be8; }
|
||||||
|
.logo-mark { fill: var(--logo); }
|
||||||
|
|
||||||
|
.wordmark { display: block; width: 220px; height: auto; margin: 0 auto; color: var(--text); }
|
||||||
|
.wordmark-rule .fill { fill: var(--accent); }
|
||||||
|
.wordmark-rule .line { fill: none; stroke: var(--accent); stroke-width: 2.6; }
|
||||||
|
.wordmark-rule .tag { fill: var(--accent); font: 700 17px system-ui, sans-serif; letter-spacing: 0.5px; }
|
||||||
|
.login .wordmark { width: 290px; }
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# Builds artwork/banner.svg, the README's banner: the logo above the
|
||||||
|
# "HOUSECARL" wordmark and its FW rule, laid out as on the web UI's login
|
||||||
|
# page. It reads both from the web UI's own sources, so it matches the login
|
||||||
|
# page:
|
||||||
|
#
|
||||||
|
# internal/web/static/assets/logo.js the logo's paths (from scripts/logo.py)
|
||||||
|
# internal/web/static/assets/brand.js the wordmark's letters and width
|
||||||
|
#
|
||||||
|
# The rule is drawn here with the same numbers as wordmark() in brand.js;
|
||||||
|
# keep the two in step. The logo, letters and rule take the UI's colors,
|
||||||
|
# switching to the dark theme's when the viewer prefers dark. Needs only
|
||||||
|
# Python; run from the repository root after changing either file
|
||||||
|
# (scripts/logo.py runs it):
|
||||||
|
#
|
||||||
|
# python3 scripts/banner.py
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
ASSETS = "internal/web/static/assets"
|
||||||
|
OUT = "artwork/banner.svg"
|
||||||
|
|
||||||
|
# The logo's blue, the UI's --text and --accent (style.css), light and dark.
|
||||||
|
STYLE = (".m{fill:#1d5287}.t{fill:#1d2330}.a{fill:#2f5fb3}.l{fill:none;stroke:#2f5fb3;stroke-width:2.6}"
|
||||||
|
".g{fill:#2f5fb3;font:700 17px system-ui,sans-serif;letter-spacing:.5px}"
|
||||||
|
"@media (prefers-color-scheme:dark){.m{fill:#6d9be8}.t{fill:#e3e7ee}.a,.g{fill:#6d9be8}.l{stroke:#6d9be8}}")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
logo = open(f"{ASSETS}/logo.js").read()
|
||||||
|
view_box = re.search(r'viewBox = "([^"]*)"', logo).group(1)
|
||||||
|
full = json.loads(re.search(r"^export const full = (\".*\");$", logo, re.M).group(1))
|
||||||
|
|
||||||
|
brand = open(f"{ASSETS}/brand.js").read()
|
||||||
|
letters = re.findall(r'\{ x: (\d+), d: "([^"]*)" \}', brand)
|
||||||
|
w = int(re.search(r"^const W = (\d+);", brand, re.M).group(1))
|
||||||
|
if len(letters) != 9:
|
||||||
|
sys.exit("banner.py: brand.js no longer looks as expected; update the script")
|
||||||
|
|
||||||
|
# The login page's sizes in pixels (style.css): a 120px logo, 12px above
|
||||||
|
# a 290px wordmark whose viewBox is W+8 units wide.
|
||||||
|
mark_w, mark_h = w + 8, 176
|
||||||
|
unit = mark_w / 290
|
||||||
|
logo_size, gap = round(120 * unit, 1), round(12 * unit, 1)
|
||||||
|
height = logo_size + gap + mark_h
|
||||||
|
|
||||||
|
c = w / 2
|
||||||
|
rule = (f'<path class="a" d="M2 18 L{c - 60:g} 15.6 V20.4 Z M{w - 2} 18 L{c + 60:g} 15.6 V20.4 Z"/>'
|
||||||
|
f'<circle class="l" cx="{c - 52:g}" cy="18" r="4.6"/>'
|
||||||
|
f'<path class="l" d="M{c - 47.4:g} 18 H{c - 24:g} M{c + 24:g} 18 H{c + 47.4:g}"/>'
|
||||||
|
f'<circle class="l" cx="{c:g}" cy="18" r="24"/>'
|
||||||
|
f'<circle class="l" cx="{c + 52:g}" cy="18" r="4.6"/>'
|
||||||
|
f'<text class="g" x="{c:g}" y="24.5" text-anchor="middle">FW</text>')
|
||||||
|
|
||||||
|
svg = [f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {mark_w} {height:g}" role="img" aria-label="Housecarl">',
|
||||||
|
f"<!-- Generated by scripts/banner.py from {ASSETS}/logo.js and brand.js. -->",
|
||||||
|
f"<title>Housecarl</title><style>{STYLE}</style>",
|
||||||
|
f'<svg x="{(mark_w - logo_size) / 2:g}" width="{logo_size:g}" height="{logo_size:g}" viewBox="{view_box}">'
|
||||||
|
f'<path class="m" fill-rule="evenodd" d="{full}"/></svg>',
|
||||||
|
f'<svg y="{logo_size + gap:g}" width="{mark_w}" height="{mark_h}" viewBox="-4 -4 {mark_w} {mark_h}">'
|
||||||
|
'<g class="t" fill-rule="evenodd">'
|
||||||
|
+ "".join(f'<path transform="translate({x} 0)" d="{d}"/>' for x, d in letters) + "</g>"
|
||||||
|
f'<g transform="translate(0 118)">{rule}</g></svg>',
|
||||||
|
"</svg>"]
|
||||||
|
with open(OUT, "w") as f:
|
||||||
|
f.write("\n".join(svg) + "\n")
|
||||||
|
print(f"{OUT}: {mark_w}x{height:g}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# Builds the web UI's logo from the masters: artwork/logo.svg (a carved
|
||||||
|
# Nordic doorway) and artwork/logo-small.svg (the same doorway without its
|
||||||
|
# carvings, for small sizes). Each master is one evenodd path.
|
||||||
|
#
|
||||||
|
# internal/web/static/assets/logo.svg the favicon (a copy of the small one)
|
||||||
|
# internal/web/static/assets/logo.js both paths for the web UI, which
|
||||||
|
# draws them inline (brand.js), so the
|
||||||
|
# logo appears together with the page
|
||||||
|
#
|
||||||
|
# It then runs scripts/banner.py to rebuild the README's banner. Run from
|
||||||
|
# the repository root after changing a master:
|
||||||
|
#
|
||||||
|
# python3 scripts/logo.py
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
|
||||||
|
MASTERS = {"full": "artwork/logo.svg", "small": "artwork/logo-small.svg"}
|
||||||
|
OUT = "internal/web/static/assets"
|
||||||
|
SVG = "{http://www.w3.org/2000/svg}"
|
||||||
|
|
||||||
|
|
||||||
|
def read(path):
|
||||||
|
root = ET.parse(path).getroot()
|
||||||
|
paths = root.findall(f"{SVG}path")
|
||||||
|
if len(paths) != 1:
|
||||||
|
sys.exit(f"logo.py: {path} should hold exactly one <path>")
|
||||||
|
return root.attrib["viewBox"], paths[0].attrib["d"]
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
found = {name: read(path) for name, path in MASTERS.items()}
|
||||||
|
views = {vb for vb, _ in found.values()}
|
||||||
|
if len(views) != 1:
|
||||||
|
sys.exit("logo.py: the full and small logos must share one viewBox")
|
||||||
|
with open(os.path.join(OUT, "logo.js"), "w") as f:
|
||||||
|
f.write("// The Housecarl logo, from artwork/logo.svg and artwork/logo-small.svg by\n"
|
||||||
|
"// scripts/logo.py; don't edit. brand.js draws it; logo.svg is the small one.\n"
|
||||||
|
f"export const viewBox = {json.dumps(views.pop())};\n"
|
||||||
|
f"export const full = {json.dumps(found['full'][1])};\n"
|
||||||
|
f"export const small = {json.dumps(found['small'][1])};\n")
|
||||||
|
shutil.copyfile(MASTERS["small"], os.path.join(OUT, "logo.svg"))
|
||||||
|
for name in ("logo.svg", "logo.js"):
|
||||||
|
print(f"{OUT}/{name}: {os.path.getsize(os.path.join(OUT, name))} bytes")
|
||||||
|
subprocess.run([sys.executable, "scripts/banner.py"], check=True)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Reference in new issue
Block a user