Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f912dc137 | ||
|
|
5ec943ebff | ||
|
|
4676d17aea | ||
|
|
52e6a987cc | ||
|
|
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
|
### 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
|
- **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,
|
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
|
for a time; the ban list says what the address is kept from: the
|
||||||
@@ -103,6 +106,28 @@ All notable changes to Housecarl are documented here. The format follows
|
|||||||
|
|
||||||
### Changed
|
### 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.
|
||||||
|
- 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.
|
||||||
- Set names starting with `ban_` are now reserved (`set_name_reserved`):
|
- Set names starting with `ban_` are now reserved (`set_name_reserved`):
|
||||||
ban lists' sets use them.
|
ban lists' sets use them.
|
||||||
- **Rules read as a direction.** A match's zones are written `lan → wan`,
|
- **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
|
## 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+5ec943e): zones, address sets and
|
||||||
services with ordered rules, IPv4 and IPv6, port forwards with hairpin NAT
|
services with ordered rules, IPv4 and IPv6, port forwards with hairpin NAT
|
||||||
and IPv6 pinholes, source NAT and redirects, built-ins, probation for
|
and IPv6 pinholes, source NAT and redirects, built-ins, probation for
|
||||||
network changes (confirmed by the client that made them), the ruleset
|
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{
|
cmd.AddCommand(&cobra.Command{
|
||||||
Use: "undo ID",
|
Use: "undo ID",
|
||||||
Short: "Undo a change",
|
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
|
// 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 {
|
func diffJSON(before, after json.RawMessage) []string {
|
||||||
var a, b map[string]any
|
var a, b map[string]any
|
||||||
json.Unmarshal(before, &a)
|
json.Unmarshal(before, &a)
|
||||||
json.Unmarshal(after, &b)
|
json.Unmarshal(after, &b)
|
||||||
|
a, b = flatten(a, "", map[string]any{}), flatten(b, "", map[string]any{})
|
||||||
keys := map[string]bool{}
|
keys := map[string]bool{}
|
||||||
for k := range a {
|
for k := range a {
|
||||||
keys[k] = true
|
keys[k] = true
|
||||||
@@ -132,6 +158,19 @@ func diffJSON(before, after json.RawMessage) []string {
|
|||||||
return out
|
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 {
|
func whoamiCmd() *cobra.Command {
|
||||||
return &cobra.Command{
|
return &cobra.Command{
|
||||||
Use: "whoami", Short: "Show who you are to the server", Args: cobra.NoArgs,
|
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`).
|
one). Missing when a person must confirm (`manual_confirm`).
|
||||||
* `?dry_run=1` checks the change, with nftables too, and returns what it
|
* `?dry_run=1` checks the change, with nftables too, and returns what it
|
||||||
would do, with `"applied": false, "dry_run": true`, and `diff`: the
|
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
|
* `?changeset=ID` (or the header `X-Housecarl-Changeset: ID`) stages the
|
||||||
change in that changeset instead ([Changesets](#changesets)): the answer
|
change in that changeset instead ([Changesets](#changesets)): the answer
|
||||||
has `"staged": true`, `changes` (this operation's) and `changeset`.
|
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[?limit=&before=]` | changes, newest first |
|
||||||
| `GET /history/{id}` | one, with each object before and after |
|
| `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 /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 /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 |
|
| `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
|
`-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
|
## Changesets
|
||||||
|
|
||||||
@@ -231,7 +232,8 @@ See [bans.md](bans.md).
|
|||||||
| `confirm` | keep the changes on probation; the ruleset is saved for the boot |
|
| `confirm` | keep the changes on probation; the ruleset is saved for the boot |
|
||||||
| `rollback` | undo the changes on probation now |
|
| `rollback` | undo the changes on probation now |
|
||||||
| `history list [--limit N]` | changes, newest first |
|
| `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) |
|
| `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) |
|
| `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 |
|
| `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
|
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
|
only create tokens with a subset of its own grants.
|
||||||
roles within what your account may do; a token with more would get no
|
|
||||||
more than its user anyway.
|
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
|
**Rotating** gives a token a new secret, keeping its ID, name and
|
||||||
permissions, so only the programs that use it need updating:
|
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
|
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).
|
// 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) {
|
func (e *Engine) Replace(ctx context.Context, p *auth.Principal, description string, opts Options, next *model.State) (*Result, error) {
|
||||||
e.mu.Lock()
|
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 {
|
if before == nil || after == nil {
|
||||||
return ""
|
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.
|
// 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
|
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 {
|
func (s *Server) revertHistory(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
if err := require(p, auth.ResConfig, true); err != nil {
|
if err := require(p, auth.ResConfig, true); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ func (s *Server) routes() {
|
|||||||
m.HandleFunc("GET "+p+"/stats/heatmap", s.authed(s.statsHeatmap))
|
m.HandleFunc("GET "+p+"/stats/heatmap", s.authed(s.statsHeatmap))
|
||||||
m.HandleFunc("GET "+p+"/history", s.authed(s.listHistory))
|
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}", 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("POST "+p+"/history/{id}/revert", s.authed(s.revertHistory))
|
||||||
m.HandleFunc("GET "+p+"/probation", s.authed(s.getProbation))
|
m.HandleFunc("GET "+p+"/probation", s.authed(s.getProbation))
|
||||||
m.HandleFunc("POST "+p+"/confirm", s.authed(s.confirmChanges))
|
m.HandleFunc("POST "+p+"/confirm", s.authed(s.confirmChanges))
|
||||||
|
|||||||
@@ -1335,3 +1335,30 @@ func TestCreatePlaced(t *testing.T) {
|
|||||||
t.Errorf("%d rules", n)
|
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
|
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.
|
// Your account: password and API tokens.
|
||||||
import { h, mount, table, time, modal, input, field, select, errorBox, toast, copyBox } from "../ui.js";
|
import { h, mount, table, time, input, field, errorBox, toast } from "../ui.js";
|
||||||
import { get, api, state, catalog, can } from "../api.js";
|
import { get, api, state, catalog } from "../api.js";
|
||||||
import { rerender } from "../app.js";
|
import { rerender } from "../app.js";
|
||||||
import { revoke, rotateDialog } from "./users.js";
|
import { revoke, rotateDialog, tokenDialog } from "./users.js";
|
||||||
|
|
||||||
export async function render(el) {
|
export async function render(el) {
|
||||||
const me = state.me;
|
const me = state.me;
|
||||||
@@ -30,7 +30,7 @@ export async function render(el) {
|
|||||||
h("div", { class: "panel" },
|
h("div", { class: "panel" },
|
||||||
h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h2", null, "API tokens"),
|
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("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([
|
table([
|
||||||
{ label: "Name", render: (t) => t.name },
|
{ label: "Name", render: (t) => t.name },
|
||||||
{ label: "May", render: (t) => h("span", { class: "mono small" }, t.grant_strings.join(" ")) },
|
{ 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")] },
|
h("button", { class: "small reveal", onclick: () => revoke(t) }, "Revoke")] },
|
||||||
], tokens, { empty: "No tokens." })));
|
], 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.
|
// 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 { get, change, can, state } from "../api.js";
|
||||||
import { rerender } from "../app.js";
|
import { rerender } from "../app.js";
|
||||||
|
|
||||||
@@ -29,7 +29,7 @@ async function undo(e) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function entry(el, id) {
|
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,
|
mount(el,
|
||||||
h("nav", { class: "back-link" }, h("a", { href: "#/history" }, "← History")),
|
h("nav", { class: "back-link" }, h("a", { href: "#/history" }, "← History")),
|
||||||
h("div", { class: "page-head" },
|
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),
|
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" },
|
e.changes.map((c) => h("div", { class: "panel" },
|
||||||
h("h3", null, action(c) + " " + c.kind + " ", h("span", { class: "mono" }, c.label)),
|
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) {
|
function action(c) {
|
||||||
@@ -50,9 +54,10 @@ function action(c) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// fieldsTable lists the fields that differ (all of them for a create or
|
// 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) {
|
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 keys = [...new Set([...Object.keys(a), ...Object.keys(b)])].sort();
|
||||||
const show = (v, k) => {
|
const show = (v, k) => {
|
||||||
if (v === undefined || v === "") return "–";
|
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,
|
c.after ? { label: c.before ? "After" : "Value", render: (k) => h("span", { class: "mono small" }, show(b[k], k)) } : null,
|
||||||
].filter(Boolean), rows);
|
].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" };
|
r = r || { chain, action: "accept" };
|
||||||
const ch = select(["input", "forward", "output", "early"], r.chain || chain);
|
const ch = select(["input", "forward", "output", "early"], r.chain || chain);
|
||||||
const match = matchEditor(r, n);
|
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 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 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);
|
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");
|
const addersLabel = h("label");
|
||||||
sections.raw.onShow = (on) => {
|
sections.raw.onShow = (on) => {
|
||||||
for (const n of [...matchNodes, ...actionField, ...logField]) n.classList.toggle("hidden", on);
|
for (const n of [...matchNodes, ...actionField, ...logField]) n.classList.toggle("hidden", on);
|
||||||
|
if (!on) chainZones();
|
||||||
for (const [k, sec] of Object.entries(sections)) {
|
for (const [k, sec] of Object.entries(sections)) {
|
||||||
if (k === "raw") continue;
|
if (k === "raw") continue;
|
||||||
if (on && sec.open) for (const n of sec.nodes) n.classList.add("hidden");
|
if (on && sec.open) for (const n of sec.nodes) n.classList.add("hidden");
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Users and everyone's tokens (administrators).
|
// 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 { 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";
|
import { rerender } from "../app.js";
|
||||||
|
|
||||||
export async function render(el) {
|
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: "Last login", render: (u) => u.last_login_at ? time(u.last_login_at) : "never" },
|
||||||
{ label: "Comment", render: (u) => u.comment },
|
{ label: "Comment", render: (u) => u.comment },
|
||||||
{ label: "", tdClass: "actions-cell", render: (u) => [
|
{ 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: () => userDialog(u, cat) }, "Edit"), " ",
|
||||||
h("button", { class: "small reveal", onclick: () => del(u) }, "Delete")] },
|
h("button", { class: "small reveal", onclick: () => del(u) }, "Delete")] },
|
||||||
], users)),
|
], users)),
|
||||||
@@ -32,6 +33,42 @@ export async function render(el) {
|
|||||||
], tokens, { empty: "No tokens." })));
|
], 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
|
// rotateDialog gives a token a new secret. It keeps its name, ID and
|
||||||
// permissions; only the programs that use it need the new secret.
|
// permissions; only the programs that use it need the new secret.
|
||||||
export async function rotateDialog(t) {
|
export async function rotateDialog(t) {
|
||||||
|
|||||||
@@ -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-rows { display: flex; flex-direction: column; gap: 6px; margin-bottom: 6px; }
|
||||||
.list-row { display: flex; gap: 6px; align-items: center; }
|
.list-row { display: flex; gap: 6px; align-items: center; }
|
||||||
.list-row input { width: 26em; max-width: 100%; }
|
.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; }
|
input.short { width: 7em; }
|
||||||
.builtins { margin-bottom: 10px; line-height: 1.6; }
|
.builtins { margin-bottom: 10px; line-height: 1.6; }
|
||||||
.policy { margin-top: 10px; }
|
.policy { margin-top: 10px; }
|
||||||
@@ -409,6 +413,7 @@ input.wide { width: 100%; }
|
|||||||
.diff .add { background: var(--add); color: var(--add-text); }
|
.diff .add { background: var(--add); color: var(--add-text); }
|
||||||
.diff .del { background: var(--del); color: var(--del-text); }
|
.diff .del { background: var(--del); color: var(--del-text); }
|
||||||
.diff .hunk { background: var(--hunk); color: var(--accent); }
|
.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; }
|
.diff .file { font-weight: 700; padding-top: 6px; }
|
||||||
select.inline { width: auto; display: inline-block; }
|
select.inline { width: auto; display: inline-block; }
|
||||||
/* The rule dialog's optional parts: "+" buttons, and ✕ to close one. */
|
/* 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";
|
if (line.startsWith("+++") || line.startsWith("---")) cls = "file";
|
||||||
else if (line.startsWith("+")) cls = "add";
|
else if (line.startsWith("+")) cls = "add";
|
||||||
else if (line.startsWith("-")) cls = "del";
|
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 || " "));
|
box.append(h("div", { class: cls }, line || " "));
|
||||||
}
|
}
|
||||||
return box;
|
return box;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Widgets shared by several pages: list editors with suggestions, the
|
// Widgets shared by several pages: list editors with suggestions, the
|
||||||
// match editor of rules and NAT, descriptions, hit counts, durations.
|
// 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";
|
import { get, api } from "./api.js";
|
||||||
|
|
||||||
// ---- durations
|
// ---- durations
|
||||||
@@ -65,33 +65,60 @@ export function listEditor(values, opts = {}) {
|
|||||||
|
|
||||||
// ---- matches
|
// ---- 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
|
// matchEditor edits what a rule, source NAT or redirect matches. It
|
||||||
// returns the form-grid fields; value() is the match's fields.
|
// returns the form-grid fields; value() is the match's fields.
|
||||||
export function matchEditor(m, n, opts = {}) {
|
export function matchEditor(m, n, opts = {}) {
|
||||||
m = m || {};
|
m = m || {};
|
||||||
const zones = (list) => listEditor(list, { suggestions: n.zones, placeholder: "zone", mono: false, addLabel: "Add zone" });
|
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 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 inZ = negatable(zones(m.in_zones), m.not_in), outZ = negatable(zones(m.out_zones), m.not_out);
|
||||||
const notIn = checkbox("any zone but these", m.not_in), notOut = checkbox("any zone but these", m.not_out);
|
const src = negatable(addrs(m.sources), m.not_sources), dst = negatable(addrs(m.destinations), m.not_destinations);
|
||||||
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 svc = listEditor(m.services, { suggestions: n.services, placeholder: "ssh, https, tcp/8080, udp/1000-2000, icmp/echo-request", addLabel: "Add service" });
|
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 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 = [
|
const fields = [
|
||||||
opts.in !== false ? field("Coming in on", h("div", null, inZ, notIn)) : null,
|
inField,
|
||||||
opts.out !== false ? field("Going out on", h("div", null, outZ, notOut)) : null,
|
outField,
|
||||||
field("From", h("div", null, src, notSrc), "Sets, addresses, prefixes or ranges, IPv4 and IPv6 mixed. Empty: any."),
|
field("From", src, "Sets, addresses, prefixes or ranges, IPv4 and IPv6 mixed. Empty: any."),
|
||||||
field("To", h("div", null, dst, notDst)),
|
field("To", dst),
|
||||||
field("Services", svc, "Named services (built in, or yours) or entries such as tcp/8080. Empty: any traffic."),
|
field("Services", svc, "Named services (built in, or yours) or entries such as tcp/8080. Empty: any traffic."),
|
||||||
field("Family", fam),
|
field("Family", fam),
|
||||||
];
|
];
|
||||||
fields.value = () => {
|
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 };
|
services: svc.value(), family: fam.value };
|
||||||
if (opts.in !== false) Object.assign(v, { in_zones: inZ.value(), not_in: notIn.input.checked });
|
// A hidden zone field (the chain has no such interface) matches nothing.
|
||||||
if (opts.out !== false) Object.assign(v, { out_zones: outZ.value(), not_out: notOut.input.checked });
|
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;
|
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;
|
return fields;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ done
|
|||||||
files="$files dist/SHA256SUMS"
|
files="$files dist/SHA256SUMS"
|
||||||
|
|
||||||
# The release (created, or found when publishing again).
|
# 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')
|
id=$(printf '%s\n' "$release" | jq -r '.id // empty')
|
||||||
if [ -z "$id" ]; then
|
if [ -z "$id" ]; then
|
||||||
body=$(jq -n --arg tag "$tag" --arg notes "$notes" --argjson pre "$prerelease" \
|
body=$(jq -n --arg tag "$tag" --arg notes "$notes" --argjson pre "$prerelease" \
|
||||||
@@ -127,7 +127,8 @@ for f in $files; do
|
|||||||
name=${f#dist/}
|
name=${f#dist/}
|
||||||
old=$(printf '%s\n' "$release" | jq -r --arg n "$name" '.assets[]? | select(.name == $n) | .id')
|
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
|
[ -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"
|
echo "gitea-release: attached $name"
|
||||||
done
|
done
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user