Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5020ff1185 | ||
|
|
e841986a86 | ||
|
|
0e958289de |
No files matched your search
@@ -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,10 @@ 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.
|
||||
- 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.
|
||||
|
||||
@@ -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): 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
|
||||
|
||||
+7
-3
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -77,9 +77,11 @@ export function matchEditor(m, n, opts = {}) {
|
||||
const notSrc = checkbox("any source but these", m.not_sources), notDst = checkbox("any destination but these", 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", h("div", null, inZ, notIn)) : null;
|
||||
const outField = opts.out !== false ? field("Going out on", h("div", null, outZ, notOut)) : 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,
|
||||
inField,
|
||||
outField,
|
||||
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)),
|
||||
field("Services", svc, "Named services (built in, or yours) or entries such as tcp/8080. Empty: any traffic."),
|
||||
@@ -88,10 +90,15 @@ export function matchEditor(m, n, opts = {}) {
|
||||
fields.value = () => {
|
||||
const v = { sources: src.value(), not_sources: notSrc.input.checked, destinations: dst.value(), not_destinations: notDst.input.checked,
|
||||
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: notIn.input.checked });
|
||||
if (outField) Object.assign(v, outField[1].classList.contains("hidden") ? { out_zones: [], not_out: false } : { out_zones: outZ.value(), not_out: notOut.input.checked });
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user