Compare commits

...
Author SHA1 Message Date
abrown38andClaude Opus 5.5 6f912dc137 Release 0.2.0-rc.3+5ec943e
release / release (push) Successful in 1m5s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 00:08:21 +01:00
abrown38andClaude Opus 5.5 5ec943ebff Match fields: "any of"/"any but" only with entries, never negate empty
The choice shows once the list has a row (empty is "any"), an empty
list is never sent as negated, a negated list is marked by a red bar
instead of red inputs (which read as errors), and the field's own label
names the choice for screen readers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 00:08:21 +01:00
abrown38andClaude Opus 5.5 4676d17aea Release 0.2.0-rc.3+52e6a98
release / release (push) Successful in 1m5s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 00:03:52 +01:00
abrown38andClaude Opus 5.5 52e6a987cc Match fields say "any of" or "any but" first
The zone, From and To fields start with an any of / any but choice
instead of a checkbox under the list, and an "any but" list is tinted
red. The release script encodes a + (build metadata) in its URLs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 00:03:52 +01:00
abrown38andClaude Opus 5.5 5020ff1185 Release 0.2.0-rc.3
release / release (push) Successful in 1m4s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 23:51:54 +01:00
abrown38andClaude Opus 5.5 e841986a86 Rule dialog: only the zone fields the chain can use
Input and early have no outgoing interface, output no incoming one, so
the dialog hides those fields (they could only be refused) and a zone
left in a hidden field isn't sent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 23:51:54 +01:00
abrown38andClaude Opus 5.5 0e958289de Web UI: administrators make tokens for other users
New token on a user's row under Users, with the Account page's dialog
(now tokenDialog in users.js), offering only roles within that user's
permissions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 23:49:12 +01:00
11 changed files with 141 additions and 52 deletions

No files matched your search

+13
View File
@@ -8,6 +8,9 @@ All notable changes to Housecarl are documented here. The format follows
### Added
- An administrator can make an API token for another user in the web
UI: New token on the user's row under Users (the CLI's `hc token create
--user` and the API could already).
- **Ban lists** ([docs/bans.md](docs/bans.md)). A program (Guard, which
reads logs, or fail2ban) bans an address, of every port or of some,
for a time; the ban list says what the address is kept from: the
@@ -112,6 +115,16 @@ All notable changes to Housecarl are documented here. The format follows
ruleset, for entries made before this version too: the configuration
before and after it is got back from the current one and rendered by
this version.
- Negating a match is said first: the zone, From and To fields (rules
and NAT) start with "any of" / "any but" instead of a checkbox under
the list, shown once the list has an entry, and an "any but" list is
marked with a red bar. An empty list is never saved as negated (it
matched everything anyway, but would have negated what was added to
it later).
- The rule dialog shows only the zone fields its chain can use: no
"Going out on" for input and early, no "Coming in on" for output
(they could only be refused). A zone left in a field the chain hides
isn't sent.
- History lists a changed setting by its own fields
(`builtins.drop_invalid: false → true`), on the web page and in `hc
history show`, instead of the whole `builtins` before and after.
+1 -1
View File
@@ -128,7 +128,7 @@ The web UI is on `http://127.0.0.1:8070/` (see `listen` in
## Status
Housecarl 0.2.0 is in testing (0.2.0-rc.2): zones, address sets and
Housecarl 0.2.0 is in testing (0.2.0-rc.3+5ec943e): zones, address sets and
services with ordered rules, IPv4 and IPv6, port forwards with hairpin NAT
and IPv6 pinholes, source NAT and redirects, built-ins, probation for
network changes (confirmed by the client that made them), the ruleset
+1 -1
View File
@@ -1 +1 @@
0.2.0-rc.2
0.2.0-rc.3+5ec943e
+7 -3
View File
@@ -54,9 +54,13 @@ hc token create portal --user portal --grant sets:write:guests --expires 365d
```
The secret is shown once; only its hash is stored. A restricted token can
only create tokens with a subset of its own grants. The web UI only offers
roles within what your account may do; a token with more would get no
more than its user anyway.
only create tokens with a subset of its own grants.
In the web UI, Account → New token makes one of your own; an
administrator makes one for another user with New token on that user's
row under Users (`--user` in the CLI, `POST /users/{id}/tokens` in the
API). The dialog only offers roles within what the token's user may do; a
token with more would get no more than its user anyway.
**Rotating** gives a token a new secret, keeping its ID, name and
permissions, so only the programs that use it need updating:
+27
View File
@@ -1335,3 +1335,30 @@ func TestCreatePlaced(t *testing.T) {
t.Errorf("%d rules", n)
}
}
// TestTokenForUser: an administrator makes a token for another user (the
// web UI's New token on a user's row); that user can't make one for
// someone else.
func TestTokenForUser(t *testing.T) {
e := newEnv(t)
var u api.User
if code := e.call(e.token, "POST", "/users", api.UserInput{Username: ptr("portal"), Grants: &[]string{"sets:write"}}, &u); code != 201 {
t.Fatalf("user: %d", code)
}
var tok api.Token
if code := e.call(e.token, "POST", "/users/"+itoa(u.ID)+"/tokens", api.TokenInput{Name: "portal", ExpiresIn: "30d"}, &tok); code != 201 || tok.Secret == "" || tok.UserID != u.ID {
t.Fatalf("token for portal: %d %+v", code, tok)
}
if code := e.call(tok.Secret, "GET", "/rules", nil, nil); code != 403 {
t.Errorf("the token can do more than its user: %d", code)
}
var users []api.User
e.call(e.token, "GET", "/users", nil, &users)
for _, o := range users {
if o.ID != u.ID {
if code := e.call(tok.Secret, "POST", "/users/"+itoa(o.ID)+"/tokens", api.TokenInput{Name: "x"}, nil); code != 403 {
t.Errorf("a token for another user, made by a non-administrator: %d", code)
}
}
}
}
+4 -32
View File
@@ -1,8 +1,8 @@
// Your account: password and API tokens.
import { h, mount, table, time, modal, input, field, select, errorBox, toast, copyBox } from "../ui.js";
import { get, api, state, catalog, can } from "../api.js";
import { h, mount, table, time, input, field, errorBox, toast } from "../ui.js";
import { get, api, state, catalog } from "../api.js";
import { rerender } from "../app.js";
import { revoke, rotateDialog } from "./users.js";
import { revoke, rotateDialog, tokenDialog } from "./users.js";
export async function render(el) {
const me = state.me;
@@ -30,7 +30,7 @@ export async function render(el) {
h("div", { class: "panel" },
h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h2", null, "API tokens"),
h("div", { class: "muted" }, "For the hc CLI and scripts: 'hc config set token'.")),
h("button", { onclick: () => newToken(cat) }, "New token")),
h("button", { onclick: () => tokenDialog(cat) }, "New token")),
table([
{ label: "Name", render: (t) => t.name },
{ label: "May", render: (t) => h("span", { class: "mono small" }, t.grant_strings.join(" ")) },
@@ -41,31 +41,3 @@ export async function render(el) {
h("button", { class: "small reveal", onclick: () => revoke(t) }, "Revoke")] },
], tokens, { empty: "No tokens." })));
}
async function newToken(cat) {
const name = input({ placeholder: "laptop, backup script…" });
// Only roles within what the user may do: a token can never do more
// than its user, so offering more would only mislead.
const roles = Object.keys(cat.roles).filter((r) => cat.roles[r].every((g) => {
const [res, acc] = g.split(":");
return can(res, acc === "write");
}));
const role = select([["", "everything you may do"], ...roles.map((r) => [r, r + ": " + cat.roles[r].join(" ")])], "");
const expires = select([["", "never"], ["30d", "30 days"], ["90d", "90 days"], ["365d", "a year"]], "90d");
const err = h("div");
await modal("New API token", h("div", null, h("div", { class: "form-grid" }, field("Name", name), field("May do", role), field("Expires", expires)), err), [
{ label: "Cancel", value: null },
{ label: "Create", kind: "primary", onclick: async () => {
mount(err);
try {
const t = await api("POST", "/me/tokens", { body: { name: name.value.trim(), role: role.value || undefined, expires_in: expires.value || undefined } });
await modal("Token created", h("div", null, h("p", null, "Copy it now: it is not shown again."), copyBox(t.secret)));
rerender();
return true;
} catch (e) {
mount(err, errorBox(e));
return false;
}
} },
]);
}
@@ -260,6 +260,9 @@ export async function ruleDialog(r, chain) {
r = r || { chain, action: "accept" };
const ch = select(["input", "forward", "output", "early"], r.chain || chain);
const match = matchEditor(r, n);
const chainZones = () => match.zones(ch.value !== "output", ch.value === "forward" || ch.value === "output");
ch.addEventListener("change", chainZones);
chainZones();
const action = select([["accept", "accept"], ["drop", "drop"], ["reject", "reject"], ["log", "log only (and go on)"]], r.action);
const rejectWith = select([["", "with an ICMP error"], ["tcp-reset", "with a TCP reset (TCP rules only)"]], r.reject_with || "");
const every = checkbox("every packet: also stop connections already open, not just new ones", r.every_packet);
@@ -371,6 +374,7 @@ export async function ruleDialog(r, chain) {
const addersLabel = h("label");
sections.raw.onShow = (on) => {
for (const n of [...matchNodes, ...actionField, ...logField]) n.classList.toggle("hidden", on);
if (!on) chainZones();
for (const [k, sec] of Object.entries(sections)) {
if (k === "raw") continue;
if (on && sec.open) for (const n of sec.nodes) n.classList.add("hidden");
+38 -1
View File
@@ -1,6 +1,6 @@
// Users and everyone's tokens (administrators).
import { h, mount, table, time, fmtTime, modal, input, field, checkbox, select, errorBox, confirmDialog, toast, badge, copyBox } from "../ui.js";
import { get, api, catalog } from "../api.js";
import { get, api, catalog, can } from "../api.js";
import { rerender } from "../app.js";
export async function render(el) {
@@ -16,6 +16,7 @@ export async function render(el) {
{ label: "Last login", render: (u) => u.last_login_at ? time(u.last_login_at) : "never" },
{ label: "Comment", render: (u) => u.comment },
{ label: "", tdClass: "actions-cell", render: (u) => [
h("button", { class: "small reveal", onclick: () => tokenDialog(cat, u) }, "New token"), " ",
h("button", { class: "small reveal", onclick: () => userDialog(u, cat) }, "Edit"), " ",
h("button", { class: "small reveal", onclick: () => del(u) }, "Delete")] },
], users)),
@@ -32,6 +33,42 @@ export async function render(el) {
], tokens, { empty: "No tokens." })));
}
// tokenDialog makes an API token: the viewer's own, or (administrators)
// another user's. It shows the secret once.
export async function tokenDialog(cat, user) {
const name = input({ placeholder: "laptop, backup script…" });
// Only roles within what the user may do: a token can never do more
// than its user, so offering more would only mislead.
const may = (res, write) => {
if (!user) return can(res, write);
if (user.admin) return true;
const gs = user.grant_strings || [];
return gs.includes(res + ":write") || !write && gs.includes(res + ":read");
};
const roles = Object.keys(cat.roles).filter((r) => cat.roles[r].every((g) => {
const [res, acc] = g.split(":");
return may(res, acc === "write");
}));
const role = select([["", user ? "everything " + user.username + " may do" : "everything you may do"], ...roles.map((r) => [r, r + ": " + cat.roles[r].join(" ")])], "");
const expires = select([["", "never"], ["30d", "30 days"], ["90d", "90 days"], ["365d", "a year"]], "90d");
const err = h("div");
await modal(user ? "New API token for " + user.username : "New API token", h("div", null, h("div", { class: "form-grid" }, field("Name", name), field("May do", role), field("Expires", expires)), err), [
{ label: "Cancel", value: null },
{ label: "Create", kind: "primary", onclick: async () => {
mount(err);
try {
const t = await api("POST", user ? "/users/" + user.id + "/tokens" : "/me/tokens", { body: { name: name.value.trim(), role: role.value || undefined, expires_in: expires.value || undefined } });
await modal("Token created", h("div", null, h("p", null, "Copy it now: it is not shown again." + (user ? " Give it to " + user.username + "; 'hc config set token' sets it in the CLI." : "")), copyBox(t.secret)));
rerender();
return true;
} catch (e) {
mount(err, errorBox(e));
return false;
}
} },
]);
}
// rotateDialog gives a token a new secret. It keeps its name, ID and
// permissions; only the programs that use it need the new secret.
export async function rotateDialog(t) {
+4
View File
@@ -379,6 +379,10 @@ pre.wrap { white-space: pre-wrap; word-break: break-all; font-size: 12px; }
.list-rows { display: flex; flex-direction: column; gap: 6px; margin-bottom: 6px; }
.list-row { display: flex; gap: 6px; align-items: center; }
.list-row input { width: 26em; max-width: 100%; }
.negatable { display: flex; gap: 8px; align-items: flex-start; flex-wrap: wrap; }
.negatable > .list-editor { flex: 1; min-width: 0; }
.negatable.negated > select { color: var(--err); border-color: var(--err); font-weight: 600; }
.negatable.negated .list-rows { border-left: 3px solid var(--err); padding-left: 8px; }
input.short { width: 7em; }
.builtins { margin-bottom: 10px; line-height: 1.6; }
.policy { margin-top: 10px; }
+39 -12
View File
@@ -1,6 +1,6 @@
// Widgets shared by several pages: list editors with suggestions, the
// match editor of rules and NAT, descriptions, hit counts, durations.
import { h, input, select, field, checkbox } from "./ui.js";
import { h, input, select, field } from "./ui.js";
import { get, api } from "./api.js";
// ---- durations
@@ -65,33 +65,60 @@ export function listEditor(values, opts = {}) {
// ---- matches
// negatable puts "any of" / "any but" in front of a list editor, so a
// field says first whether it matches its entries or everything else;
// "any but" marks the list. The choice shows once the list has a row
// (empty is "any"), and an empty list is never negated. value() is the
// list, negated() the choice.
function negatable(list, not) {
const mode = select([["", "any of"], ["not", "any but"]], not ? "not" : "", { class: "inline" });
const box = h("div", { class: "negatable" }, mode, list);
const rows = () => list.querySelector(".list-rows").children.length;
const update = () => {
mode.classList.toggle("hidden", rows() === 0);
box.classList.toggle("negated", mode.value === "not" && rows() > 0);
};
mode.addEventListener("change", update);
// Add and remove act on their own click first; this runs after.
list.addEventListener("click", update);
update();
box.value = list.value;
box.negated = () => mode.value === "not" && list.value().length > 0;
return box;
}
// matchEditor edits what a rule, source NAT or redirect matches. It
// returns the form-grid fields; value() is the match's fields.
export function matchEditor(m, n, opts = {}) {
m = m || {};
const zones = (list) => listEditor(list, { suggestions: n.zones, placeholder: "zone", mono: false, addLabel: "Add zone" });
const addrs = (list) => listEditor(list, { suggestions: n.sets, placeholder: "a set, 192.168.1.0/24, 2001:db8:1::/64, 192.168.1.100-192.168.1.199", addLabel: "Add" });
const inZ = zones(m.in_zones), outZ = zones(m.out_zones);
const notIn = checkbox("any zone but these", m.not_in), notOut = checkbox("any zone but these", m.not_out);
const src = addrs(m.sources), dst = addrs(m.destinations);
const notSrc = checkbox("any source but these", m.not_sources), notDst = checkbox("any destination but these", m.not_destinations);
const inZ = negatable(zones(m.in_zones), m.not_in), outZ = negatable(zones(m.out_zones), m.not_out);
const src = negatable(addrs(m.sources), m.not_sources), dst = negatable(addrs(m.destinations), m.not_destinations);
const svc = listEditor(m.services, { suggestions: n.services, placeholder: "ssh, https, tcp/8080, udp/1000-2000, icmp/echo-request", addLabel: "Add service" });
const fam = select([["", "IPv4 and IPv6"], ["ipv4", "IPv4 only"], ["ipv6", "IPv6 only"]], m.family || "");
const inField = opts.in !== false ? field("Coming in on", inZ) : null;
const outField = opts.out !== false ? field("Going out on", outZ) : null;
const fields = [
opts.in !== false ? field("Coming in on", h("div", null, inZ, notIn)) : null,
opts.out !== false ? field("Going out on", h("div", null, outZ, notOut)) : null,
field("From", h("div", null, src, notSrc), "Sets, addresses, prefixes or ranges, IPv4 and IPv6 mixed. Empty: any."),
field("To", h("div", null, dst, notDst)),
inField,
outField,
field("From", src, "Sets, addresses, prefixes or ranges, IPv4 and IPv6 mixed. Empty: any."),
field("To", dst),
field("Services", svc, "Named services (built in, or yours) or entries such as tcp/8080. Empty: any traffic."),
field("Family", fam),
];
fields.value = () => {
const v = { sources: src.value(), not_sources: notSrc.input.checked, destinations: dst.value(), not_destinations: notDst.input.checked,
const v = { sources: src.value(), not_sources: src.negated(), destinations: dst.value(), not_destinations: dst.negated(),
services: svc.value(), family: fam.value };
if (opts.in !== false) Object.assign(v, { in_zones: inZ.value(), not_in: notIn.input.checked });
if (opts.out !== false) Object.assign(v, { out_zones: outZ.value(), not_out: notOut.input.checked });
// A hidden zone field (the chain has no such interface) matches nothing.
if (inField) Object.assign(v, inField[1].classList.contains("hidden") ? { in_zones: [], not_in: false } : { in_zones: inZ.value(), not_in: inZ.negated() });
if (outField) Object.assign(v, outField[1].classList.contains("hidden") ? { out_zones: [], not_out: false } : { out_zones: outZ.value(), not_out: outZ.negated() });
return v;
};
// zones shows the zone fields a chain can use: input and early have no
// outgoing interface, output no incoming one.
const show = (f, on) => f && f.filter(Boolean).forEach((e) => e.classList.toggle("hidden", !on));
fields.zones = (inOn, outOn) => { show(inField, inOn); show(outField, outOn); };
return fields;
}
+3 -2
View File
@@ -114,7 +114,7 @@ done
files="$files dist/SHA256SUMS"
# The release (created, or found when publishing again).
release=$(call 404 "$api/releases/tags/$tag")
release=$(call 404 "$api/releases/tags/$(printf '%s' "$tag" | sed 's/+/%2B/g')")
id=$(printf '%s\n' "$release" | jq -r '.id // empty')
if [ -z "$id" ]; then
body=$(jq -n --arg tag "$tag" --arg notes "$notes" --argjson pre "$prerelease" \
@@ -127,7 +127,8 @@ for f in $files; do
name=${f#dist/}
old=$(printf '%s\n' "$release" | jq -r --arg n "$name" '.assets[]? | select(.name == $n) | .id')
[ -z "$old" ] || call "" -X DELETE "$api/releases/$id/assets/$old" >/dev/null
call "" -X POST -F "attachment=@$f" "$api/releases/$id/assets?name=$name" >/dev/null
# A + (build metadata, 0.2.0-rc.3+1a2b3c4) would be a space in a query.
call "" -X POST -F "attachment=@$f" "$api/releases/$id/assets?name=$(printf '%s' "$name" | sed 's/+/%2B/g')" >/dev/null
echo "gitea-release: attached $name"
done