Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5020ff1185 | ||
|
|
e841986a86 | ||
|
|
0e958289de | ||
|
|
9ce4190e01 | ||
|
|
92147c8681 |
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
|
||||
@@ -103,6 +106,22 @@ All notable changes to Housecarl are documented here. The format follows
|
||||
|
||||
### Changed
|
||||
|
||||
- The ruleset's diff (Changes, `hc changeset diff`, `--dry-run`) names
|
||||
the chain each part is in on its `@@` line (`@@ -40,6 +40,8 @@ chain
|
||||
forward`), as `git diff` names the function, so a rule added deep in a
|
||||
long chain says whether it is in input, forward or output.
|
||||
- **History shows the ruleset's diff.** An entry's page (and `hc history
|
||||
diff ID`, `GET /history/{id}/diff`) shows what the change did to the
|
||||
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.
|
||||
- Set names starting with `ban_` are now reserved (`set_name_reserved`):
|
||||
ban lists' sets use them.
|
||||
- **Rules read as a direction.** A match's zones are written `lan → wan`,
|
||||
|
||||
@@ -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.1): 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
|
||||
|
||||
+40
-1
@@ -66,6 +66,30 @@ func historyCmd() *cobra.Command {
|
||||
})
|
||||
},
|
||||
})
|
||||
cmd.AddCommand(&cobra.Command{
|
||||
Use: "diff ID", Short: "Show what a change did to the ruleset", Args: cobra.ExactArgs(1),
|
||||
Long: `Shows the ruleset's diff for a change: the configuration before and after
|
||||
it, rendered by this version of Housecarl.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var d struct {
|
||||
Diff string `json:"diff"`
|
||||
}
|
||||
if err := c.Get(ctx(), "/history/"+args[0]+"/diff", nil, &d); err != nil {
|
||||
return err
|
||||
}
|
||||
return output(d, func(w io.Writer) {
|
||||
if d.Diff == "" {
|
||||
fmt.Fprintln(w, "the ruleset didn't change")
|
||||
return
|
||||
}
|
||||
printDiff(w, d.Diff)
|
||||
})
|
||||
},
|
||||
})
|
||||
cmd.AddCommand(&cobra.Command{
|
||||
Use: "undo ID",
|
||||
Short: "Undo a change",
|
||||
@@ -88,11 +112,13 @@ change touched has changed since; undo the later changes first.`,
|
||||
}
|
||||
|
||||
// diffJSON lists the fields that differ between two versions of an
|
||||
// object: "name: office -> lab".
|
||||
// object: "name: office -> lab". An object within (settings' builtins)
|
||||
// is compared field by field: "builtins.drop_invalid: false -> true".
|
||||
func diffJSON(before, after json.RawMessage) []string {
|
||||
var a, b map[string]any
|
||||
json.Unmarshal(before, &a)
|
||||
json.Unmarshal(after, &b)
|
||||
a, b = flatten(a, "", map[string]any{}), flatten(b, "", map[string]any{})
|
||||
keys := map[string]bool{}
|
||||
for k := range a {
|
||||
keys[k] = true
|
||||
@@ -132,6 +158,19 @@ func diffJSON(before, after json.RawMessage) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// flatten lifts the fields of objects within m to dotted keys; lists
|
||||
// stay whole.
|
||||
func flatten(m map[string]any, prefix string, out map[string]any) map[string]any {
|
||||
for k, v := range m {
|
||||
if o, ok := v.(map[string]any); ok && len(o) > 0 {
|
||||
flatten(o, prefix+k+".", out)
|
||||
} else {
|
||||
out[prefix+k] = v
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func whoamiCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "whoami", Short: "Show who you are to the server", Args: cobra.NoArgs,
|
||||
|
||||
+4
-1
@@ -57,7 +57,9 @@ Calls that change the configuration answer:
|
||||
one). Missing when a person must confirm (`manual_confirm`).
|
||||
* `?dry_run=1` checks the change, with nftables too, and returns what it
|
||||
would do, with `"applied": false, "dry_run": true`, and `diff`: the
|
||||
ruleset's unified diff.
|
||||
ruleset's unified diff, each `@@` line followed by the chain (or other
|
||||
block of the table) the hunk starts in, as in `@@ -40,6 +40,8 @@ chain
|
||||
forward`.
|
||||
* `?changeset=ID` (or the header `X-Housecarl-Changeset: ID`) stages the
|
||||
change in that changeset instead ([Changesets](#changesets)): the answer
|
||||
has `"staged": true`, `changes` (this operation's) and `changeset`.
|
||||
@@ -237,6 +239,7 @@ list's `never`), `422 list_full`, `409` for a disabled list.
|
||||
|---|---|
|
||||
| `GET /history[?limit=&before=]` | changes, newest first |
|
||||
| `GET /history/{id}` | one, with each object before and after |
|
||||
| `GET /history/{id}/diff` | `{"diff"}`: what it did to the ruleset, as a unified diff with each hunk's chain (the configuration before and after it, got back from the current one, rendered by this version) |
|
||||
| `POST /history/{id}/revert` | undo it; 409 if an object it touched has changed since, or it was undone already |
|
||||
| `POST /import/nft` | `{"json": NFT_JSON, "text": "nft -a list ruleset output", "defines": "ruleset file", "table": "inet main", "zones": {"enp2s0": "lan"}, "replace": false}` ([import.md](import.md)); answers with `state` (the imported configuration), `notes` and `raw` too |
|
||||
| `POST /takeover` | `{"tables": ["inet main"]}`: delete these tables (administrators); on probation over the network; 409 in shadow mode |
|
||||
|
||||
+4
-2
@@ -60,7 +60,8 @@ applied and saved (history 12; undo with 'hc history undo 12')
|
||||
```
|
||||
|
||||
`-n` shows what would change without changing it, with the diff of the
|
||||
ruleset; nftables checks it too.
|
||||
ruleset (each `@@` line names the chain the lines below it are in);
|
||||
nftables checks it too.
|
||||
|
||||
## Changesets
|
||||
|
||||
@@ -231,7 +232,8 @@ See [bans.md](bans.md).
|
||||
| `confirm` | keep the changes on probation; the ruleset is saved for the boot |
|
||||
| `rollback` | undo the changes on probation now |
|
||||
| `history list [--limit N]` | changes, newest first |
|
||||
| `history show ID` | each object before and after |
|
||||
| `history show ID` | each object before and after (a changed setting by its own fields: `builtins.drop_invalid: false -> true`) |
|
||||
| `history diff ID` | what the change did to the ruleset |
|
||||
| `history undo ID` | apply the change backwards (refused if something it touched has changed since) |
|
||||
| `import [FILE\|-] [--text FILE] [--defines FILE] [--table T] [--zone IFACE=NAME]... [--replace]` | [import.md](import.md) |
|
||||
| `takeover TABLE... [-y]` | delete tables Housecarl's ruleset replaces (administrators); not in shadow mode |
|
||||
|
||||
+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:
|
||||
|
||||
@@ -322,6 +322,22 @@ func Undo(st *model.State, h *model.HistoryEntry) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Rewind applies a history entry backwards to st without Undo's check:
|
||||
// stepping back through the entries newest first brings back the
|
||||
// configuration as it was before each.
|
||||
func Rewind(st *model.State, h *model.HistoryEntry) error {
|
||||
for i := len(h.Changes) - 1; i >= 0; i-- {
|
||||
c := h.Changes[i]
|
||||
if c.Kind == model.KindSettings && c.Before == nil {
|
||||
continue
|
||||
}
|
||||
if err := put(st, c.Kind, c.ID, c.Before); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Replace makes the configuration next as a whole (imports).
|
||||
func (e *Engine) Replace(ctx context.Context, p *auth.Principal, description string, opts Options, next *model.State) (*Result, error) {
|
||||
e.mu.Lock()
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
udiff "github.com/aymanbagabas/go-udiff"
|
||||
|
||||
"housecarl/internal/api"
|
||||
"housecarl/internal/model"
|
||||
)
|
||||
|
||||
// TestBlockHeaders: each hunk says which chain it is in, even when the
|
||||
// chain's own line is far above it.
|
||||
func TestBlockHeaders(t *testing.T) {
|
||||
var b strings.Builder
|
||||
b.WriteString("table inet housecarl {\n\tset lan_v4 { type ipv4_addr; }\n\tchain input {\n")
|
||||
for i := 0; i < 20; i++ {
|
||||
b.WriteString("\t\tcounter comment \"in\"\n")
|
||||
}
|
||||
b.WriteString("\t}\n\tchain forward {\n")
|
||||
for i := 0; i < 20; i++ {
|
||||
b.WriteString("\t\tcounter comment \"fw\"\n")
|
||||
}
|
||||
b.WriteString("\t}\n}\n")
|
||||
now := b.String()
|
||||
then := strings.Replace(now, "\t\tcounter comment \"in\"\n\t}", "\t\tcounter comment \"in\"\n\t\taccept comment \"new in\"\n\t}", 1)
|
||||
then = strings.Replace(then, "\t\tcounter comment \"fw\"\n\t}", "\t\taccept comment \"new fw\"\n\t}", 1)
|
||||
|
||||
diff := blockHeaders(udiff.Unified("a", "b", now, then), now, then)
|
||||
var heads []string
|
||||
for _, l := range strings.Split(diff, "\n") {
|
||||
if strings.HasPrefix(l, "@@") {
|
||||
heads = append(heads, l)
|
||||
}
|
||||
}
|
||||
if len(heads) != 2 || !strings.HasSuffix(heads[0], "@@ chain input") || !strings.HasSuffix(heads[1], "@@ chain forward") {
|
||||
t.Fatalf("hunk headers %q, want chain input then chain forward\n%s", heads, diff)
|
||||
}
|
||||
if blockHeaders("", now, then) != "" {
|
||||
t.Fatal("no diff should stay empty")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHistoryDiff: an entry's diff is its own change, rebuilt from the
|
||||
// current configuration however many entries came after it.
|
||||
func TestHistoryDiff(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
var mr api.MutationResponse
|
||||
e.call(e.token, "POST", "/zones", model.Zone{Name: "lan", Interfaces: []string{"eth1"}}, nil)
|
||||
e.call(e.token, "POST", "/rules", model.Rule{Chain: "input", Match: model.Match{Services: []string{"ssh"}}, Comment: "first"}, &mr)
|
||||
first := mr.HistoryID
|
||||
e.call(e.token, "POST", "/rules", model.Rule{Chain: "forward", Match: model.Match{Services: []string{"http"}}, Comment: "second"}, nil)
|
||||
e.call(e.token, "PATCH", "/settings", map[string]any{"builtins": map[string]any{"drop_invalid": true}}, nil)
|
||||
|
||||
var out struct{ Diff string }
|
||||
if code := e.call(e.token, "GET", "/history/"+itoa(first)+"/diff", nil, &out); code != 200 {
|
||||
t.Fatalf("diff: %d", code)
|
||||
}
|
||||
var added []string
|
||||
for _, l := range strings.Split(out.Diff, "\n") {
|
||||
if strings.HasPrefix(l, "+") && !strings.HasPrefix(l, "+++") {
|
||||
added = append(added, l)
|
||||
}
|
||||
}
|
||||
if len(added) == 0 || !strings.Contains(strings.Join(added, "\n"), ` first"`) || strings.Contains(out.Diff, ` second"`) || !strings.Contains(out.Diff, "@@ chain input") {
|
||||
t.Errorf("entry %d's diff:\n%s", first, out.Diff)
|
||||
}
|
||||
if code := e.call(e.token, "GET", "/history/9999/diff", nil, nil); code != 404 {
|
||||
t.Errorf("unknown entry: %d", code)
|
||||
}
|
||||
}
|
||||
@@ -101,7 +101,68 @@ func (s *Server) rulesetDiff(before, after *model.State) string {
|
||||
if before == nil || after == nil {
|
||||
return ""
|
||||
}
|
||||
return udiff.Unified("ruleset.nft (now)", "ruleset.nft (after)", s.fw.Render(before).Text, s.fw.Render(after).Text)
|
||||
now, then := s.fw.Render(before).Text, s.fw.Render(after).Text
|
||||
return blockHeaders(udiff.Unified("ruleset.nft (now)", "ruleset.nft (after)", now, then), now, then)
|
||||
}
|
||||
|
||||
// blockHeaders names, after each hunk's @@ line, the chain (or other
|
||||
// block of the table) the hunk starts in, as git names the function:
|
||||
// "@@ -40,6 +40,8 @@ chain forward". Without it a change deep in a long
|
||||
// chain doesn't say which chain it is in.
|
||||
func blockHeaders(diff, now, then string) string {
|
||||
if diff == "" {
|
||||
return diff
|
||||
}
|
||||
oldBlocks, newBlocks := enclosingBlocks(now), enclosingBlocks(then)
|
||||
lines := strings.SplitAfter(diff, "\n")
|
||||
for i, l := range lines {
|
||||
if !strings.HasPrefix(l, "@@ ") {
|
||||
continue
|
||||
}
|
||||
// "@@ -a,b +c,d @@": the new side's start, or the old side's
|
||||
// when nothing of the hunk is left.
|
||||
f := strings.Fields(l)
|
||||
if len(f) < 4 || f[3] != "@@" {
|
||||
continue
|
||||
}
|
||||
block := blockAt(newBlocks, f[2])
|
||||
if block == "" {
|
||||
block = blockAt(oldBlocks, f[1])
|
||||
}
|
||||
if block != "" {
|
||||
lines[i] = strings.TrimRight(l, "\n") + " " + block + "\n"
|
||||
}
|
||||
}
|
||||
return strings.Join(lines, "")
|
||||
}
|
||||
|
||||
// enclosingBlocks gives each line of a rendered ruleset the block of the
|
||||
// table it is in ("chain input"), "" outside one. A block opens with a
|
||||
// line of one tab ending in "{" and closes with "\t}".
|
||||
func enclosingBlocks(text string) []string {
|
||||
var out []string
|
||||
cur := ""
|
||||
for _, l := range strings.Split(text, "\n") {
|
||||
if strings.HasPrefix(l, "\t") && !strings.HasPrefix(l, "\t\t") && strings.HasSuffix(l, "{") {
|
||||
cur = strings.TrimSpace(strings.TrimSuffix(l, "{"))
|
||||
}
|
||||
out = append(out, cur)
|
||||
if l == "\t}" {
|
||||
cur = ""
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// blockAt is the block at a hunk side's start ("+40,8" or "-40"); lines
|
||||
// count from 1, and an empty side (",0") sits after its line.
|
||||
func blockAt(blocks []string, side string) string {
|
||||
start, _, _ := strings.Cut(side[1:], ",")
|
||||
n, err := strconv.Atoi(start)
|
||||
if err != nil || n < 1 || n > len(blocks) {
|
||||
return ""
|
||||
}
|
||||
return blocks[n-1]
|
||||
}
|
||||
|
||||
// kindOf maps the API's plural path names to kinds.
|
||||
@@ -616,6 +677,38 @@ func (s *Server) getHistory(w http.ResponseWriter, r *http.Request, p *auth.Prin
|
||||
return nil
|
||||
}
|
||||
|
||||
// historyDiff is the ruleset's diff for one history entry: the
|
||||
// configurations before and after it, got back by stepping back from
|
||||
// the current one through every later entry, rendered by this version.
|
||||
func (s *Server) historyDiff(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := require(p, auth.ResConfig, false); err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hs, err := s.store.HistorySince(r.Context(), id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(hs) == 0 || hs[len(hs)-1].ID != id {
|
||||
return notFound("history entry %d not found", id)
|
||||
}
|
||||
st := s.eng.State()
|
||||
var after *model.State
|
||||
for _, h := range hs {
|
||||
if h.ID == id {
|
||||
after = st.Clone()
|
||||
}
|
||||
if err := engine.Rewind(st, h); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"diff": s.rulesetDiff(st, after)})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) revertHistory(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := require(p, auth.ResConfig, true); err != nil {
|
||||
return err
|
||||
|
||||
@@ -75,6 +75,7 @@ func (s *Server) routes() {
|
||||
m.HandleFunc("GET "+p+"/stats/heatmap", s.authed(s.statsHeatmap))
|
||||
m.HandleFunc("GET "+p+"/history", s.authed(s.listHistory))
|
||||
m.HandleFunc("GET "+p+"/history/{id}", s.authed(s.getHistory))
|
||||
m.HandleFunc("GET "+p+"/history/{id}/diff", s.authed(s.historyDiff))
|
||||
m.HandleFunc("POST "+p+"/history/{id}/revert", s.authed(s.revertHistory))
|
||||
m.HandleFunc("GET "+p+"/probation", s.authed(s.getProbation))
|
||||
m.HandleFunc("POST "+p+"/confirm", s.authed(s.confirmChanges))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -63,3 +63,21 @@ func (s *Store) GetHistory(ctx context.Context, id int64) (*model.HistoryEntry,
|
||||
}
|
||||
return e, err
|
||||
}
|
||||
|
||||
// HistorySince lists the entries from id on, newest first.
|
||||
func (s *Store) HistorySince(ctx context.Context, id int64) ([]*model.HistoryEntry, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT `+historyCols+` FROM history WHERE id >= ? ORDER BY id DESC`, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*model.HistoryEntry
|
||||
for rows.Next() {
|
||||
e, err := scanHistory(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
} },
|
||||
]);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
// History: every configuration change, with undo.
|
||||
import { h, mount, table, time, confirmDialog, modal, errorBox, badge } from "../ui.js";
|
||||
import { h, mount, table, time, confirmDialog, modal, errorBox, badge, diffView } from "../ui.js";
|
||||
import { get, change, can, state } from "../api.js";
|
||||
import { rerender } from "../app.js";
|
||||
|
||||
@@ -29,7 +29,7 @@ async function undo(e) {
|
||||
}
|
||||
|
||||
async function entry(el, id) {
|
||||
const e = await get("/history/" + id);
|
||||
const [e, d] = await Promise.all([get("/history/" + id), get("/history/" + id + "/diff").catch(() => null)]);
|
||||
mount(el,
|
||||
h("nav", { class: "back-link" }, h("a", { href: "#/history" }, "← History")),
|
||||
h("div", { class: "page-head" },
|
||||
@@ -40,7 +40,11 @@ async function entry(el, id) {
|
||||
can("config", true) && !e.reverted_by ? h("div", { class: "actions" }, h("button", { onclick: () => undo(e) }, "Undo")) : null),
|
||||
e.changes.map((c) => h("div", { class: "panel" },
|
||||
h("h3", null, action(c) + " " + c.kind + " ", h("span", { class: "mono" }, c.label)),
|
||||
fieldsTable(c))));
|
||||
fieldsTable(c))),
|
||||
d ? h("div", { class: "panel" },
|
||||
h("h3", null, "Ruleset"),
|
||||
h("p", { class: "muted small" }, "What this change did to the ruleset, rendered by this version of Housecarl."),
|
||||
diffView(d.diff)) : null);
|
||||
}
|
||||
|
||||
function action(c) {
|
||||
@@ -50,9 +54,10 @@ function action(c) {
|
||||
}
|
||||
|
||||
// fieldsTable lists the fields that differ (all of them for a create or
|
||||
// delete).
|
||||
// delete). An object within (settings' builtins) is listed by its own
|
||||
// fields, "builtins.drop_invalid", so only what changed shows.
|
||||
function fieldsTable(c) {
|
||||
const a = c.before || {}, b = c.after || {};
|
||||
const a = flatten(c.before || {}), b = flatten(c.after || {});
|
||||
const keys = [...new Set([...Object.keys(a), ...Object.keys(b)])].sort();
|
||||
const show = (v, k) => {
|
||||
if (v === undefined || v === "") return "–";
|
||||
@@ -68,3 +73,14 @@ function fieldsTable(c) {
|
||||
c.after ? { label: c.before ? "After" : "Value", render: (k) => h("span", { class: "mono small" }, show(b[k], k)) } : null,
|
||||
].filter(Boolean), rows);
|
||||
}
|
||||
|
||||
// flatten lifts the fields of objects within an object to dotted keys
|
||||
// ({builtins: {loopback: true}} → {"builtins.loopback": true}); lists
|
||||
// stay whole.
|
||||
function flatten(o, prefix = "", out = {}) {
|
||||
for (const [k, v] of Object.entries(o)) {
|
||||
if (v && typeof v === "object" && !Array.isArray(v) && Object.keys(v).length) flatten(v, prefix + k + ".", out);
|
||||
else out[prefix + k] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -409,6 +409,7 @@ input.wide { width: 100%; }
|
||||
.diff .add { background: var(--add); color: var(--add-text); }
|
||||
.diff .del { background: var(--del); color: var(--del-text); }
|
||||
.diff .hunk { background: var(--hunk); color: var(--accent); }
|
||||
.diff .hunk .block { font-weight: 700; color: var(--text); }
|
||||
.diff .file { font-weight: 700; padding-top: 6px; }
|
||||
select.inline { width: auto; display: inline-block; }
|
||||
/* The rule dialog's optional parts: "+" buttons, and ✕ to close one. */
|
||||
|
||||
@@ -200,7 +200,12 @@ export function diffView(text) {
|
||||
if (line.startsWith("+++") || line.startsWith("---")) cls = "file";
|
||||
else if (line.startsWith("+")) cls = "add";
|
||||
else if (line.startsWith("-")) cls = "del";
|
||||
else if (line.startsWith("@@")) cls = "hunk";
|
||||
else if (line.startsWith("@@")) {
|
||||
// "@@ -a,b +c,d @@ chain forward": the chain the hunk is in.
|
||||
const m = line.match(/^(@@ .*? @@)(?: (.+))?$/);
|
||||
box.append(h("div", { class: "hunk" }, m ? [m[1], m[2] ? [" ", h("span", { class: "block" }, m[2])] : null] : line));
|
||||
continue;
|
||||
}
|
||||
box.append(h("div", { class: cls }, line || " "));
|
||||
}
|
||||
return box;
|
||||
|
||||
@@ -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