Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98e11a8bec | ||
|
|
9a165cf70b | ||
|
|
d25e12995e | ||
|
|
e4efdf9c78 | ||
|
|
fc390eb57c | ||
|
|
09504e5555 | ||
|
|
2880f7ddc9 | ||
|
|
ebec2cfcca |
No files matched your search
+34
-3
@@ -30,19 +30,46 @@ the way Jarl and Steward are.
|
|||||||
invalid packets, ICMPv6 (RFC 4890), ping from internal zones, DHCPv6
|
invalid packets, ICMPv6 (RFC 4890), ping from internal zones, DHCPv6
|
||||||
client replies, antispoofing with your own prefixes, MSS clamping, and
|
client replies, antispoofing with your own prefixes, MSS clamping, and
|
||||||
a flowtable fast path.
|
a flowtable fast path.
|
||||||
|
- **Changesets, as in Jarl:** changes staged to be reviewed and applied
|
||||||
|
together, as one history entry. Each is checked as it is staged; the
|
||||||
|
review shows the problems, nftables' check and the diff of the ruleset.
|
||||||
|
The web UI collects edits as pending changes by default (pending rows,
|
||||||
|
a bar with Review and Apply now, a Changes page), with a switch for live
|
||||||
|
edits, and Undo in History is staged like any edit; hc stages with
|
||||||
|
`-C ID` or `HOUSECARL_CHANGESET`, and has `hc
|
||||||
|
changeset create/list/show/diff/apply/discard/remove`. An applied
|
||||||
|
changeset is on probation like any change. `--dry-run` shows the
|
||||||
|
ruleset's diff too (docs/design.md, docs/cli.md).
|
||||||
|
- **Bandwidth limits and quotas:** rate limits in bytes (`--limit
|
||||||
|
20mbytes/second,over,per-source`), per source or per destination;
|
||||||
|
quotas, an allowance of traffic per rule or per address, reset daily,
|
||||||
|
weekly, monthly on a chosen day, or by hand, kept across changes,
|
||||||
|
restarts and reboots (`hc quotas`, `hc rule reset-quota`, usage on the
|
||||||
|
rules page). Rules that count traffic are checked before established
|
||||||
|
connections are accepted, so they see every packet. Per-address limits
|
||||||
|
group IPv6 by /64 by default (`--group-v4`, `--group-v6`). Meters,
|
||||||
|
byte rates and quotas in an imported ruleset carry over
|
||||||
|
(docs/rules.md).
|
||||||
- **Changes checked twice and applied atomically:** validation of the
|
- **Changes checked twice and applied atomically:** validation of the
|
||||||
whole configuration with stable problem codes (docs/validation.md),
|
whole configuration with stable problem codes (docs/validation.md),
|
||||||
then `nft -c`, then one `nft -f` transaction that replaces Housecarl's
|
then `nft -c`, then one `nft -f` transaction that replaces Housecarl's
|
||||||
own table, `inet housecarl`, and nothing else. Dynamic sets' entries
|
own table, `inet housecarl`, and nothing else. Dynamic sets' entries
|
||||||
and the counters carry across.
|
and the counters carry across.
|
||||||
- **Probation:** a change made over the network rolls itself back unless
|
- **Probation:** a change made over the network rolls itself back unless
|
||||||
confirmed within two minutes (`hc confirm`, a banner in the web UI);
|
confirmed within two minutes. The web UI and `hc` confirm their own
|
||||||
|
changes once they reach Housecarl again over a new connection, from the
|
||||||
|
same address (a change that cuts its client off can't, and rolls back);
|
||||||
|
`--no-confirm` and the "a person confirms each change" setting leave it
|
||||||
|
to `hc confirm` or the banner;
|
||||||
changes through the local socket are kept at once; a restart rolls
|
changes through the local socket are kept at once; a restart rolls
|
||||||
back what was pending. No rule is forced to keep SSH or the web UI
|
back what was pending. No rule is forced to keep SSH or the web UI
|
||||||
open.
|
open.
|
||||||
- **At boot:** the confirmed ruleset is loaded by
|
- **At boot:** the confirmed ruleset is loaded by
|
||||||
`housecarl-ruleset.service` before the network comes up, without
|
`housecarl-ruleset.service` before the network comes up, without
|
||||||
housecarld. An empty configuration applies nothing.
|
housecarld. An empty configuration applies nothing.
|
||||||
|
- **Warnings about the machine:** while `nftables.service` is enabled
|
||||||
|
(it loads `/etc/nftables.conf` at boot, and flushes every table when
|
||||||
|
stopped), and while the table an import came from still applies.
|
||||||
- **Hits and the log:** packet and byte counters for every rule, NAT rule,
|
- **Hits and the log:** packet and byte counters for every rule, NAT rule,
|
||||||
built-in and policy; packets logged by rules and policies, read from
|
built-in and policy; packets logged by rules and policies, read from
|
||||||
netlink log group 4171 (`hc log -f`, the Log page).
|
netlink log group 4171 (`hc log -f`, the Log page).
|
||||||
@@ -52,10 +79,14 @@ the way Jarl and Steward are.
|
|||||||
- **Import** of an existing nftables ruleset (`hc import`): rules keep
|
- **Import** of an existing nftables ruleset (`hc import`): rules keep
|
||||||
their order, defines name the sets, DNAT becomes port forwards, what
|
their order, defines name the sets, DNAT becomes port forwards, what
|
||||||
can't be translated stays as raw rules, with notes; `hc takeover`
|
can't be translated stays as raw rules, with notes; `hc takeover`
|
||||||
deletes the old table, on probation (docs/import.md).
|
deletes the old table, on probation (docs/import.md). DNAT maps and
|
||||||
|
verdict maps are expanded into one port forward or rule per element.
|
||||||
- **The `hc` CLI, the REST API and the web UI** (dashboard, rules per
|
- **The `hc` CLI, the REST API and the web UI** (dashboard, rules per
|
||||||
chain with hits and reordering, NAT, zones, sets, services, the log,
|
chain with hits and reordering, NAT, zones, sets, services, the log,
|
||||||
settings, import, history with undo), with users, roles, scoped API
|
settings, import, history with undo; the rule dialog keeps the less
|
||||||
|
used parts, such as rate limits, quotas and time windows, behind "+"
|
||||||
|
buttons; each chain's built-ins are listed, with their hits, and
|
||||||
|
switched in a window), with users, roles, scoped API
|
||||||
tokens, sessions, the audit log, and the web UI's certificate from
|
tokens, sessions, the audit log, and the web UI's certificate from
|
||||||
Let's Encrypt through Jarl, as in Steward.
|
Let's Encrypt through Jarl, as in Steward.
|
||||||
- **Packaging:** Debian packages `housecarl` and `housecarl-cli`, a
|
- **Packaging:** Debian packages `housecarl` and `housecarl-cli`, a
|
||||||
|
|||||||
@@ -21,11 +21,14 @@ table of its own.
|
|||||||
|
|
||||||
* **Zones, sets and services, then rules.** Name your interfaces (`wan`,
|
* **Zones, sets and services, then rules.** Name your interfaces (`wan`,
|
||||||
`lan`, `wg`), your addresses (`trusted`, IPv4 and IPv6 in one set) and
|
`lan`, `wg`), your addresses (`trusted`, IPv4 and IPv6 in one set) and
|
||||||
your ports (`jellyfin`, or the built-in `ssh`, `dns`, `https`…), and
|
your ports (`web`, or the built-in `ssh`, `dns`, `https`…), and
|
||||||
write ordered rules with them: in, out or through the firewall, by
|
write ordered rules with them: in, out or through the firewall, by
|
||||||
zone, address, service, connection state, time of day, rate per
|
zone, address, service, connection state, time of day, rate per
|
||||||
source, or connection count. Anything else can be written as an
|
source, or connection count. Anything else can be written as an
|
||||||
nftables rule by hand.
|
nftables rule by hand.
|
||||||
|
* **Bandwidth and quotas.** Byte-rate limits per device, and traffic
|
||||||
|
allowances that reset daily, weekly or monthly (or by hand), counted
|
||||||
|
across changes and reboots.
|
||||||
* **IPv4 and IPv6 together.** One rule covers both; Housecarl writes the
|
* **IPv4 and IPv6 together.** One rule covers both; Housecarl writes the
|
||||||
nftables rules each family needs.
|
nftables rules each family needs.
|
||||||
* **NAT in one object.** A port forward is the DNAT, the forward accept
|
* **NAT in one object.** A port forward is the DNAT, the forward accept
|
||||||
@@ -36,7 +39,9 @@ table of its own.
|
|||||||
IPv6 needs, antispoofing for your prefixes, MSS clamping for PPPoE, the
|
IPv6 needs, antispoofing for your prefixes, MSS clamping for PPPoE, the
|
||||||
DHCPv6 client, and a flowtable fast path.
|
DHCPv6 client, and a flowtable fast path.
|
||||||
* **Changes you can trust.** Each change is validated as a whole, checked
|
* **Changes you can trust.** Each change is validated as a whole, checked
|
||||||
by nftables itself, and applied in one transaction. A change made over
|
by nftables itself, and applied in one transaction. As in Jarl, edits
|
||||||
|
can be collected in a changeset, reviewed with the diff of the ruleset,
|
||||||
|
and applied (or discarded) together. A change made over
|
||||||
the network is on probation: unless you confirm it within two minutes,
|
the network is on probation: unless you confirm it within two minutes,
|
||||||
it rolls itself back, so a rule that cuts you off undoes itself.
|
it rolls itself back, so a rule that cuts you off undoes itself.
|
||||||
* **Your existing ruleset, imported.** `hc import` turns an nftables
|
* **Your existing ruleset, imported.** `hc import` turns an nftables
|
||||||
@@ -75,10 +80,10 @@ sudo hc takeover "inet main"
|
|||||||
sudo systemctl disable nftables.service
|
sudo systemctl disable nftables.service
|
||||||
|
|
||||||
# ...or start from scratch: let yourself in first
|
# ...or start from scratch: let yourself in first
|
||||||
sudo hc set add admins 10.0.10.0/24
|
sudo hc set add admins 192.168.1.0/24
|
||||||
sudo hc rule add input --from admins --service ssh
|
sudo hc rule add input --from admins --service ssh
|
||||||
sudo hc zone add wan --interface ppp0 --external
|
sudo hc zone add wan --interface ppp0 --external
|
||||||
sudo hc zone add lan --interface enp2s0
|
sudo hc zone add lan --interface eth1
|
||||||
sudo hc rule add forward --in lan --out wan
|
sudo hc rule add forward --in lan --out wan
|
||||||
sudo hc snat add --out wan
|
sudo hc snat add --out wan
|
||||||
sudo hc status
|
sudo hc status
|
||||||
@@ -106,11 +111,13 @@ The web UI is on `http://127.0.0.1:8070/` (see `listen` in
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Housecarl 0.1.0 is in testing (0.1.0-rc.1): zones, address sets and
|
Housecarl 0.1.0 is in testing (0.1.0-rc.4): 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, the ruleset at boot, hits and the drop log, nftables
|
network changes (confirmed by the client that made them), the ruleset
|
||||||
import, a web UI, REST API and CLI.
|
at boot, hits and the drop log, nftables
|
||||||
|
import, changesets reviewed with the ruleset's diff (as in Jarl),
|
||||||
|
bandwidth limits and quotas, a web UI, REST API and CLI.
|
||||||
|
|
||||||
## Licence
|
## Licence
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,377 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"housecarl/internal/api"
|
||||||
|
"housecarl/internal/client"
|
||||||
|
"housecarl/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Changesets: configuration changes staged to be reviewed and applied
|
||||||
|
// together. -C/--changeset stages into a changeset as given (housecarld
|
||||||
|
// still refuses one that isn't open, or another user's unless you are an
|
||||||
|
// administrator). One from HOUSECARL_CHANGESET is checked first: an
|
||||||
|
// exported ID outlives its changeset (it gets applied), and IDs keep
|
||||||
|
// growing, so a stale or mistyped one could name someone else's later. It
|
||||||
|
// is used only when it is open and yours, and hc says on stderr which
|
||||||
|
// changeset that is.
|
||||||
|
|
||||||
|
var flagChangeset int64
|
||||||
|
|
||||||
|
// envChangeset returns the changeset in HOUSECARL_CHANGESET (0 if unset).
|
||||||
|
func envChangeset() (int64, error) {
|
||||||
|
v := os.Getenv("HOUSECARL_CHANGESET")
|
||||||
|
if v == "" {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
n, err := strconv.ParseInt(v, 10, 64)
|
||||||
|
if err != nil || n <= 0 {
|
||||||
|
return 0, fmt.Errorf("invalid HOUSECARL_CHANGESET %q", v)
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const startHint = "export HOUSECARL_CHANGESET=$(hc changeset create -q)"
|
||||||
|
|
||||||
|
// checkEnvChangeset checks the changeset from HOUSECARL_CHANGESET before
|
||||||
|
// it is used. With mustOwn, it must also be open and the caller's own
|
||||||
|
// (for staging into it, applying or discarding it).
|
||||||
|
func checkEnvChangeset(c *client.Client, id int64, mustOwn bool) (*model.Changeset, error) {
|
||||||
|
var cs model.Changeset
|
||||||
|
if err := c.Do(ctx(), "GET", fmt.Sprintf("/changesets/%d", id), nil, nil, &cs); err != nil {
|
||||||
|
var ce *client.Error
|
||||||
|
if errors.As(err, &ce) && ce.Status == 404 {
|
||||||
|
return nil, fmt.Errorf("HOUSECARL_CHANGESET is %d, but there is no changeset %d: start one with '%s', or 'unset HOUSECARL_CHANGESET' to make changes at once", id, id, startHint)
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if mustOwn {
|
||||||
|
if cs.Status != model.ChangesetOpen {
|
||||||
|
return nil, fmt.Errorf("HOUSECARL_CHANGESET is %d, but changeset %d is %s: 'unset HOUSECARL_CHANGESET' to make changes at once, or start a new one with '%s'", id, id, cs.Status, startHint)
|
||||||
|
}
|
||||||
|
var me api.Whoami
|
||||||
|
if err := c.Do(ctx(), "GET", "/auth/whoami", nil, nil, &me); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
mine := me.User != nil && cs.UserID == me.User.ID && (me.User.ID != 0 || cs.Username == me.User.Username)
|
||||||
|
if !mine {
|
||||||
|
return nil, fmt.Errorf("HOUSECARL_CHANGESET is %d, but changeset %d is %s's (made via %s), not yours, so it isn't used from HOUSECARL_CHANGESET; give it as -C %d to use it anyway, or 'unset HOUSECARL_CHANGESET'", id, id, cs.Username, cs.Via, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !flagQuiet {
|
||||||
|
desc := cs.Description
|
||||||
|
if desc == "" {
|
||||||
|
desc = "no description"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(os.Stderr, "changeset %d (%s, %s) from HOUSECARL_CHANGESET\n", id, desc, plural(len(cs.Ops), "operation"))
|
||||||
|
}
|
||||||
|
return &cs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// setupChangeset makes the client stage configuration changes: in
|
||||||
|
// --changeset's changeset, else HOUSECARL_CHANGESET's (checked before
|
||||||
|
// the first change).
|
||||||
|
func setupChangeset(c *client.Client) error {
|
||||||
|
if flagChangeset > 0 {
|
||||||
|
c.Changeset = flagChangeset
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
n, err := envChangeset()
|
||||||
|
if err != nil || n == 0 {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.Changeset = n
|
||||||
|
c.BeforeStage = func() error {
|
||||||
|
_, err := checkEnvChangeset(c, n, true)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// changesetArg returns the changeset a changeset subcommand works on: the
|
||||||
|
// ID argument, else --changeset, else HOUSECARL_CHANGESET (checked).
|
||||||
|
// fromEnv says it came from HOUSECARL_CHANGESET.
|
||||||
|
func changesetArg(c *client.Client, args []string, mustOwn bool) (id string, fromEnv bool, err error) {
|
||||||
|
if len(args) > 0 {
|
||||||
|
return args[0], false, nil
|
||||||
|
}
|
||||||
|
if flagChangeset > 0 {
|
||||||
|
return strconv.FormatInt(flagChangeset, 10), false, nil
|
||||||
|
}
|
||||||
|
n, err := envChangeset()
|
||||||
|
if err != nil {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
return "", false, errors.New("which changeset? Give its ID, or set HOUSECARL_CHANGESET (" + startHint + ")")
|
||||||
|
}
|
||||||
|
if _, err := checkEnvChangeset(c, n, mustOwn); err != nil {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
return strconv.FormatInt(n, 10), true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// envChangesetDone reminds that HOUSECARL_CHANGESET still names a
|
||||||
|
// changeset that was just applied or discarded.
|
||||||
|
func envChangesetDone(id, what string) {
|
||||||
|
if !flagQuiet {
|
||||||
|
fmt.Fprintf(os.Stderr, "HOUSECARL_CHANGESET still names changeset %s, which is now %s: run 'unset HOUSECARL_CHANGESET' (or export a new one) before making more changes\n", id, what)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func plural(n int, word string) string {
|
||||||
|
if n == 1 {
|
||||||
|
return "1 " + word
|
||||||
|
}
|
||||||
|
return strconv.Itoa(n) + " " + word + "s"
|
||||||
|
}
|
||||||
|
|
||||||
|
// printDiff prints a unified diff, colored on a terminal.
|
||||||
|
func printDiff(w io.Writer, diff string) {
|
||||||
|
color := isTerminal(os.Stdout)
|
||||||
|
for _, line := range strings.Split(strings.TrimSuffix(diff, "\n"), "\n") {
|
||||||
|
switch {
|
||||||
|
case !color:
|
||||||
|
case strings.HasPrefix(line, "+++"), strings.HasPrefix(line, "---"):
|
||||||
|
line = "\x1b[1m" + line + "\x1b[0m"
|
||||||
|
case strings.HasPrefix(line, "+"):
|
||||||
|
line = "\x1b[32m" + line + "\x1b[0m"
|
||||||
|
case strings.HasPrefix(line, "-"):
|
||||||
|
line = "\x1b[31m" + line + "\x1b[0m"
|
||||||
|
case strings.HasPrefix(line, "@@"):
|
||||||
|
line = "\x1b[36m" + line + "\x1b[0m"
|
||||||
|
}
|
||||||
|
fmt.Fprintln(w, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isTerminal(f *os.File) bool {
|
||||||
|
fi, err := f.Stat()
|
||||||
|
return err == nil && fi.Mode()&os.ModeCharDevice != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func changesetCmd() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "changeset",
|
||||||
|
Aliases: []string{"changesets", "cs"},
|
||||||
|
Short: "Stage changes to review and apply together",
|
||||||
|
Long: `A changeset collects configuration changes to apply together, as one
|
||||||
|
change in the history. Each change is checked as it is staged; 'hc
|
||||||
|
changeset diff' shows what applying them would do: the problems, nftables'
|
||||||
|
check and the ruleset's diff. Over the network an applied changeset is on
|
||||||
|
probation like any change.
|
||||||
|
|
||||||
|
The web UI stages its edits in a changeset by default; hc and the API
|
||||||
|
make changes at once unless one is named with -C ID or HOUSECARL_CHANGESET.
|
||||||
|
Changesets staged in the web UI show up here too, and the other way round.`,
|
||||||
|
Example: ` export HOUSECARL_CHANGESET=$(hc changeset create "guest network" -q)
|
||||||
|
hc zone add guest -i eth1.30
|
||||||
|
hc rule add forward --in guest --out wan
|
||||||
|
hc changeset diff # HOUSECARL_CHANGESET's
|
||||||
|
hc changeset apply
|
||||||
|
unset HOUSECARL_CHANGESET`,
|
||||||
|
}
|
||||||
|
cmd.AddCommand(&cobra.Command{
|
||||||
|
Use: "create [DESCRIPTION]", Short: "Start a changeset (-q prints only its ID)", Args: cobra.MaximumNArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
in := api.ChangesetInput{}
|
||||||
|
if len(args) > 0 {
|
||||||
|
in.Description = args[0]
|
||||||
|
}
|
||||||
|
var cs model.Changeset
|
||||||
|
if err := c.Do(ctx(), "POST", "/changesets", nil, in, &cs); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if flagQuiet || flagOutput == "id" {
|
||||||
|
fmt.Println(cs.ID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
fmt.Printf("created changeset %d\nstage changes with -C %d (or export HOUSECARL_CHANGESET=%d), then 'hc changeset apply'\n", cs.ID, cs.ID, cs.ID)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
var status string
|
||||||
|
var mine bool
|
||||||
|
list := &cobra.Command{
|
||||||
|
Use: "list", Short: "List changesets (open ones by default)", Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var css []*model.Changeset
|
||||||
|
query := q("status", status)
|
||||||
|
if mine {
|
||||||
|
query.Set("mine", "1")
|
||||||
|
}
|
||||||
|
if err := c.Do(ctx(), "GET", "/changesets", query, nil, &css); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return output(css, func(w io.Writer) {
|
||||||
|
fmt.Fprintln(w, "ID\tSTATUS\tBY\tVIA\tOPERATIONS\tUPDATED\tDESCRIPTION")
|
||||||
|
for _, cs := range css {
|
||||||
|
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%d\t%s\t%s\n", cs.ID, cs.Status, cs.Username, cs.Via, len(cs.Ops), cs.UpdatedAt.Local().Format("2006-01-02 15:04"), cs.Description)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
list.Flags().StringVar(&status, "status", "open", "open, applied, discarded, or \"\" for all")
|
||||||
|
list.Flags().BoolVar(&mine, "mine", false, "only your own")
|
||||||
|
cmd.AddCommand(list)
|
||||||
|
cmd.AddCommand(&cobra.Command{
|
||||||
|
Use: "show [ID]", Short: "Show a changeset's operations (default: -C or HOUSECARL_CHANGESET)", Args: cobra.MaximumNArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
id, _, err := changesetArg(c, args, false)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var cs model.Changeset
|
||||||
|
if err := c.Do(ctx(), "GET", "/changesets/"+id, nil, nil, &cs); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return output(&cs, func(w io.Writer) {
|
||||||
|
fmt.Fprintf(w, "Changeset:\t%d (%s)\n", cs.ID, cs.Status)
|
||||||
|
fmt.Fprintf(w, "By:\t%s via %s, %s\n", cs.Username, cs.Via, cs.CreatedAt.Local().Format("2006-01-02 15:04"))
|
||||||
|
if cs.Description != "" {
|
||||||
|
fmt.Fprintf(w, "Description:\t%s\n", cs.Description)
|
||||||
|
}
|
||||||
|
if cs.HistoryID != 0 {
|
||||||
|
fmt.Fprintf(w, "History:\t%d\n", cs.HistoryID)
|
||||||
|
}
|
||||||
|
fmt.Fprintln(w, "\nOP\tCHANGE")
|
||||||
|
for _, o := range cs.Ops {
|
||||||
|
fmt.Fprintf(w, "%d\t%s\n", o.Seq, o.Summary)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
cmd.AddCommand(&cobra.Command{
|
||||||
|
Use: "diff [ID]", Short: "Check an open changeset and show what applying it would change (default: -C or HOUSECARL_CHANGESET)", Args: cobra.MaximumNArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
id, _, err := changesetArg(c, args, false)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var pv api.ChangesetPreview
|
||||||
|
if err := c.Do(ctx(), "GET", "/changesets/"+id+"/preview", nil, nil, &pv); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if flagOutput == "json" || flagOutput == "yaml" {
|
||||||
|
return output(&pv, nil)
|
||||||
|
}
|
||||||
|
for _, ch := range pv.Changes {
|
||||||
|
if ch.Kind == model.KindSettings {
|
||||||
|
fmt.Printf("would %s settings\n", ch.Action())
|
||||||
|
} else {
|
||||||
|
fmt.Printf("would %s %s %s\n", ch.Action(), ch.Kind, ch.Label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
printProblems(os.Stderr, pv.Warnings)
|
||||||
|
if pv.Error != nil {
|
||||||
|
printProblems(os.Stderr, pv.Error.Problems)
|
||||||
|
return fmt.Errorf("changeset %s can't be applied: %s", id, pv.Error.Message)
|
||||||
|
}
|
||||||
|
if !pv.Valid {
|
||||||
|
fmt.Println("nothing to change")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
printDiff(os.Stdout, pv.Diff)
|
||||||
|
fmt.Printf("\nvalid: nftables accepts the new ruleset; apply with 'hc changeset apply %s'\n", id)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
var desc string
|
||||||
|
apply := &cobra.Command{
|
||||||
|
Use: "apply [ID]", Short: "Apply an open changeset (default: -C or HOUSECARL_CHANGESET)", Args: cobra.MaximumNArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
id, fromEnv, err := changesetArg(c, args, true)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var resp api.MutationResponse
|
||||||
|
if err := c.Do(ctx(), "POST", "/changesets/"+id+"/apply", nil, api.ChangesetInput{Description: desc}, &resp); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := report(&resp); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if fromEnv {
|
||||||
|
envChangesetDone(id, "applied")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
apply.Flags().StringVar(&desc, "description", "", "the history entry's description (default: the changeset's)")
|
||||||
|
cmd.AddCommand(apply)
|
||||||
|
cmd.AddCommand(&cobra.Command{
|
||||||
|
Use: "discard [ID]", Short: "Throw away an open changeset (default: -C or HOUSECARL_CHANGESET)", Args: cobra.MaximumNArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
id, fromEnv, err := changesetArg(c, args, true)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := c.Do(ctx(), "POST", "/changesets/"+id+"/discard", nil, nil, nil); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !flagQuiet {
|
||||||
|
fmt.Printf("discarded changeset %s\n", id)
|
||||||
|
}
|
||||||
|
if fromEnv {
|
||||||
|
envChangesetDone(id, "discarded")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
cmd.AddCommand(&cobra.Command{
|
||||||
|
Use: "remove [ID] OP", Short: "Remove one operation from an open changeset (default: -C or HOUSECARL_CHANGESET)", Args: cobra.RangeArgs(1, 2),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
op := args[len(args)-1]
|
||||||
|
id, _, err := changesetArg(c, args[:len(args)-1], true)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var cs model.Changeset
|
||||||
|
if err := c.Do(ctx(), "DELETE", "/changesets/"+id+"/ops/"+op, nil, nil, &cs); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !flagQuiet {
|
||||||
|
fmt.Printf("removed operation %s; changeset %s has %s\n", op, id, plural(len(cs.Ops), "operation"))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
+59
-4
@@ -125,10 +125,15 @@ func settingsCmd() *cobra.Command {
|
|||||||
fmt.Fprintf(w, "Antispoof IPv6:\t%s\n", list(s.AntispoofV6))
|
fmt.Fprintf(w, "Antispoof IPv6:\t%s\n", list(s.AntispoofV6))
|
||||||
fmt.Fprintf(w, "Flowtable:\t%s\n", list(s.FlowtableDevices))
|
fmt.Fprintf(w, "Flowtable:\t%s\n", list(s.FlowtableDevices))
|
||||||
fmt.Fprintf(w, "Probation:\t%s\n", fmtSeconds(s.Probation()))
|
fmt.Fprintf(w, "Probation:\t%s\n", fmtSeconds(s.Probation()))
|
||||||
|
if s.ManualConfirm {
|
||||||
|
fmt.Fprintln(w, "Confirmation:\tby a person ('hc confirm', the web UI's banner)")
|
||||||
|
} else {
|
||||||
|
fmt.Fprintln(w, "Confirmation:\tautomatic, by the client that made the change, once it reaches Housecarl again over a new connection")
|
||||||
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
var input, forward, output, logDrops, probation string
|
var input, forward, output, logDrops, probation, autoConf string
|
||||||
var builtins, own, flowtable, spoof4, spoof6 []string
|
var builtins, own, flowtable, spoof4, spoof6 []string
|
||||||
set := &cobra.Command{
|
set := &cobra.Command{
|
||||||
Use: "set [flags]", Short: "Change settings (only the flags given)", Args: cobra.NoArgs,
|
Use: "set [flags]", Short: "Change settings (only the flags given)", Args: cobra.NoArgs,
|
||||||
@@ -136,9 +141,9 @@ func settingsCmd() *cobra.Command {
|
|||||||
turned off: loopback, established, drop_invalid, icmpv6, ping_internal,
|
turned off: loopback, established, drop_invalid, icmpv6, ping_internal,
|
||||||
dhcpv6_client, antispoof, mss_clamp.
|
dhcpv6_client, antispoof, mss_clamp.
|
||||||
|
|
||||||
hc settings set --builtin mss_clamp=on --own-prefix 2a02:8013:7f3d::/48
|
hc settings set --builtin mss_clamp=on --own-prefix 2001:db8::/48
|
||||||
hc settings set --forward-policy drop --log-drops on
|
hc settings set --forward-policy drop --log-drops on
|
||||||
hc settings set --flowtable wan --flowtable enp2s0`,
|
hc settings set --flowtable wan --flowtable eth1`,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
fl := cmd.Flags()
|
fl := cmd.Flags()
|
||||||
p := patch{}
|
p := patch{}
|
||||||
@@ -172,6 +177,13 @@ dhcpv6_client, antispoof, mss_clamp.
|
|||||||
p[map[string]string{"own-prefix": "own_prefixes", "flowtable": "flowtable_devices", "antispoof-v4": "antispoof_v4", "antispoof-v6": "antispoof_v6"}[flag]] = v
|
p[map[string]string{"own-prefix": "own_prefixes", "flowtable": "flowtable_devices", "antispoof-v4": "antispoof_v4", "antispoof-v6": "antispoof_v6"}[flag]] = v
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if fl.Changed("auto-confirm") {
|
||||||
|
on, err := onOff("auto-confirm", autoConf)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
p["manual_confirm"] = !on
|
||||||
|
}
|
||||||
if fl.Changed("probation") {
|
if fl.Changed("probation") {
|
||||||
n, err := parseSeconds(probation)
|
n, err := parseSeconds(probation)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -193,6 +205,7 @@ dhcpv6_client, antispoof, mss_clamp.
|
|||||||
f.StringArrayVar(&spoof4, "antispoof-v4", nil, "IPv4 sources dropped from the internet (replaces)")
|
f.StringArrayVar(&spoof4, "antispoof-v4", nil, "IPv4 sources dropped from the internet (replaces)")
|
||||||
f.StringArrayVar(&spoof6, "antispoof-v6", nil, "IPv6 sources dropped from the internet (replaces)")
|
f.StringArrayVar(&spoof6, "antispoof-v6", nil, "IPv6 sources dropped from the internet (replaces)")
|
||||||
f.StringVar(&probation, "probation", "", "how long a network change has to be confirmed (2m)")
|
f.StringVar(&probation, "probation", "", "how long a network change has to be confirmed (2m)")
|
||||||
|
f.StringVar(&autoConf, "auto-confirm", "", "on: the client that made a change confirms it once it reaches Housecarl again over a new connection; off: a person confirms")
|
||||||
cmd.AddCommand(set)
|
cmd.AddCommand(set)
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
@@ -252,6 +265,41 @@ func countersCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func quotasCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "quotas", Aliases: []string{"quota"}, Short: "What the rules' quotas used, and when they reset", Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
qs, err := get[[]api.QuotaInfo]("/quotas", nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return output(qs, func(w io.Writer) {
|
||||||
|
fmt.Fprintln(w, "RULE\tUSED\tOF\tRESETS\tNEXT RESET")
|
||||||
|
for _, q := range qs {
|
||||||
|
resets := q.Quota.Reset
|
||||||
|
if resets == "" {
|
||||||
|
resets = "by hand"
|
||||||
|
} else if resets == "monthly" && q.Quota.Day > 1 {
|
||||||
|
resets = fmt.Sprintf("monthly (%d)", q.Quota.Day)
|
||||||
|
}
|
||||||
|
next := "-"
|
||||||
|
if q.NextReset != nil {
|
||||||
|
next = q.NextReset.Local().Format("2006-01-02 15:04")
|
||||||
|
}
|
||||||
|
of := humanBytes(q.Quota.Bytes)
|
||||||
|
if q.Quota.PerSource || q.Quota.PerDestination {
|
||||||
|
of += " each"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", q.Rule, humanBytes(q.Used), of, resets, next)
|
||||||
|
for _, a := range q.Addresses {
|
||||||
|
fmt.Fprintf(w, " %s\t%s\t\t\t\n", a.Address, humanBytes(a.Used))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func logCmd() *cobra.Command {
|
func logCmd() *cobra.Command {
|
||||||
var follow bool
|
var follow bool
|
||||||
var limit int
|
var limit int
|
||||||
@@ -383,7 +431,7 @@ forwards and NAT. The input is nft's JSON; on the firewall:
|
|||||||
Without a FILE, hc runs 'nft -j list ruleset' and 'nft -a list ruleset'
|
Without a FILE, hc runs 'nft -j list ruleset' and 'nft -a list ruleset'
|
||||||
itself (root on the firewall); from elsewhere, give the JSON as FILE and
|
itself (root on the firewall); from elsewhere, give the JSON as FILE and
|
||||||
the text with --text. --defines reads the ruleset file's define lines, to
|
the text with --text. --defines reads the ruleset file's define lines, to
|
||||||
name the sets as you did (trusted_ips, wg0_net, …). -n shows the result without
|
name the sets as you did (admins, lan_net, …). -n shows the result without
|
||||||
saving; what can't be expressed becomes raw rules, listed with notes.
|
saving; what can't be expressed becomes raw rules, listed with notes.
|
||||||
Once it looks right, import without -n, check it with 'hc ruleset', and
|
Once it looks right, import without -n, check it with 'hc ruleset', and
|
||||||
'hc takeover inet main' deletes the old table.`,
|
'hc takeover inet main' deletes the old table.`,
|
||||||
@@ -475,6 +523,13 @@ come back. Don't forget the boot: disable nftables.service, or empty
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
fmt.Println(out["message"])
|
fmt.Println(out["message"])
|
||||||
|
if tok, _ := out["confirm_token"].(string); tok != "" && !flagNoConfirm {
|
||||||
|
if err := autoConfirm(tok); err != nil {
|
||||||
|
fmt.Printf("couldn't confirm it (%v): confirm with 'hc confirm', or the tables come back\n", err)
|
||||||
|
} else {
|
||||||
|
fmt.Println("confirmed: this client reached Housecarl again over a new connection")
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
+55
-9
@@ -45,6 +45,10 @@ var (
|
|||||||
flagInsecure bool
|
flagInsecure bool
|
||||||
flagCACert string
|
flagCACert string
|
||||||
flagQuiet bool
|
flagQuiet bool
|
||||||
|
flagNoConfirm bool
|
||||||
|
// lastClient is the client the command made (to confirm a change by
|
||||||
|
// itself after it).
|
||||||
|
lastClient *client.Client
|
||||||
)
|
)
|
||||||
|
|
||||||
// DefaultSocket is housecarld's local socket.
|
// DefaultSocket is housecarld's local socket.
|
||||||
@@ -132,6 +136,10 @@ func newClient() (*client.Client, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
c.DryRun = flagDryRun
|
c.DryRun = flagDryRun
|
||||||
|
if err := setupChangeset(c); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
lastClient = c
|
||||||
return c, nil
|
return c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,11 +151,15 @@ func rootCmd() *cobra.Command {
|
|||||||
|
|
||||||
Every change is checked as a whole, then by nftables, before it is put in
|
Every change is checked as a whole, then by nftables, before it is put in
|
||||||
force, and is recorded in the history, where 'hc history undo ID' takes it
|
force, and is recorded in the history, where 'hc history undo ID' takes it
|
||||||
back. --dry-run shows what a change would do.
|
back. --dry-run shows what a change would do, with the ruleset's diff.
|
||||||
|
Changes are made at once; to review several and apply them together,
|
||||||
|
stage them in a changeset ('hc changeset --help', -C ID or
|
||||||
|
HOUSECARL_CHANGESET).
|
||||||
|
|
||||||
A change made over the network is on probation: confirm it with 'hc
|
A change made over the network is on probation: hc confirms it by itself
|
||||||
confirm' (within 2 minutes by default) or it is rolled back by itself, so a
|
once it reaches Housecarl again over a new connection, and a change that
|
||||||
change that cuts you off undoes itself. On the firewall itself, root uses
|
cuts you off can't be confirmed, so it is rolled back by itself (within 2
|
||||||
|
minutes by default). --no-confirm leaves it to 'hc confirm'. On the firewall itself, root uses
|
||||||
housecarld's local socket: no token, no probation. From elsewhere, set the
|
housecarld's local socket: no token, no probation. From elsewhere, set the
|
||||||
server and an API token once:
|
server and an API token once:
|
||||||
|
|
||||||
@@ -169,6 +181,8 @@ Exit codes: 0 ok, 1 error, 3 not authenticated or not allowed, 4 not found,
|
|||||||
pf.BoolVar(&flagInsecure, "insecure", false, "don't verify the server's TLS certificate")
|
pf.BoolVar(&flagInsecure, "insecure", false, "don't verify the server's TLS certificate")
|
||||||
pf.StringVar(&flagCACert, "ca-cert", "", "PEM file with a CA to trust for the server's certificate")
|
pf.StringVar(&flagCACert, "ca-cert", "", "PEM file with a CA to trust for the server's certificate")
|
||||||
pf.BoolVarP(&flagQuiet, "quiet", "q", false, "print only errors (and IDs of new objects)")
|
pf.BoolVarP(&flagQuiet, "quiet", "q", false, "print only errors (and IDs of new objects)")
|
||||||
|
pf.BoolVar(&flagNoConfirm, "no-confirm", false, "leave a change on probation instead of confirming it once this client reaches Housecarl again")
|
||||||
|
pf.Int64VarP(&flagChangeset, "changeset", "C", 0, "stage configuration changes in this changeset instead of applying them (env HOUSECARL_CHANGESET)")
|
||||||
root.AddGroup(&cobra.Group{ID: "fw", Title: "Firewall:"}, &cobra.Group{ID: "changes", Title: "Changes:"}, &cobra.Group{ID: "admin", Title: "Administration:"})
|
root.AddGroup(&cobra.Group{ID: "fw", Title: "Firewall:"}, &cobra.Group{ID: "changes", Title: "Changes:"}, &cobra.Group{ID: "admin", Title: "Administration:"})
|
||||||
add := func(group string, cmds ...*cobra.Command) {
|
add := func(group string, cmds ...*cobra.Command) {
|
||||||
for _, c := range cmds {
|
for _, c := range cmds {
|
||||||
@@ -177,8 +191,8 @@ Exit codes: 0 ok, 1 error, 3 not authenticated or not allowed, 4 not found,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
add("fw", statusCmd(), zoneCmd(), setCmd(), serviceCmd(), ruleCmd(), forwardCmd(), snatCmd(), redirectCmd(), settingsCmd(),
|
add("fw", statusCmd(), zoneCmd(), setCmd(), serviceCmd(), ruleCmd(), forwardCmd(), snatCmd(), redirectCmd(), settingsCmd(),
|
||||||
rulesetCmd(), countersCmd(), logCmd(), interfaceCmd())
|
rulesetCmd(), countersCmd(), quotasCmd(), logCmd(), interfaceCmd())
|
||||||
add("changes", confirmCmd(), rollbackCmd(), historyCmd(), importCmd(), takeoverCmd())
|
add("changes", changesetCmd(), confirmCmd(), rollbackCmd(), historyCmd(), importCmd(), takeoverCmd())
|
||||||
add("admin", whoamiCmd(), userCmd(), tokenCmd(), passwordCmd(), certCmd(), auditCmd(), configCmd())
|
add("admin", whoamiCmd(), userCmd(), tokenCmd(), passwordCmd(), certCmd(), auditCmd(), configCmd())
|
||||||
return root
|
return root
|
||||||
}
|
}
|
||||||
@@ -237,8 +251,6 @@ func outputIDs(v any) error {
|
|||||||
if m, ok := it.(map[string]any); ok {
|
if m, ok := it.(map[string]any); ok {
|
||||||
if id, ok := m["id"]; ok {
|
if id, ok := m["id"]; ok {
|
||||||
fmt.Println(id)
|
fmt.Println(id)
|
||||||
} else if ip, ok := m["ip"]; ok {
|
|
||||||
fmt.Println(ip)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -294,6 +306,19 @@ func exitCode(err error) int {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// autoConfirm confirms a change with its token: the request goes over a
|
||||||
|
// new connection (the server closed the change's), so it gets through
|
||||||
|
// only if the new ruleset still lets this client reach Housecarl.
|
||||||
|
func autoConfirm(token string) error {
|
||||||
|
if lastClient == nil {
|
||||||
|
return errors.New("no client")
|
||||||
|
}
|
||||||
|
c, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
var out map[string]int
|
||||||
|
return lastClient.Do(c, "POST", "/confirm", nil, api.ConfirmInput{Token: token}, &out)
|
||||||
|
}
|
||||||
|
|
||||||
// report prints the outcome of a change.
|
// report prints the outcome of a change.
|
||||||
func report(resp *api.MutationResponse) error {
|
func report(resp *api.MutationResponse) error {
|
||||||
switch flagOutput {
|
switch flagOutput {
|
||||||
@@ -310,6 +335,9 @@ func report(resp *api.MutationResponse) error {
|
|||||||
if resp.DryRun {
|
if resp.DryRun {
|
||||||
return "would " + c.Action()
|
return "would " + c.Action()
|
||||||
}
|
}
|
||||||
|
if resp.Staged {
|
||||||
|
return "staged: " + c.Action()
|
||||||
|
}
|
||||||
return c.Action() + "d"
|
return c.Action() + "d"
|
||||||
}
|
}
|
||||||
if !flagQuiet || resp.DryRun {
|
if !flagQuiet || resp.DryRun {
|
||||||
@@ -322,8 +350,26 @@ func report(resp *api.MutationResponse) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
switch {
|
switch {
|
||||||
|
case resp.Staged && resp.Changeset != nil:
|
||||||
|
if !flagQuiet {
|
||||||
|
cs := resp.Changeset
|
||||||
|
fmt.Fprintf(w, "staged in changeset %d as operation %d (%s now); review with 'hc changeset diff %d', apply with 'hc changeset apply %d'\n",
|
||||||
|
cs.ID, cs.Ops[len(cs.Ops)-1].Seq, plural(len(cs.Ops), "operation"), cs.ID, cs.ID)
|
||||||
|
}
|
||||||
case resp.DryRun:
|
case resp.DryRun:
|
||||||
|
if resp.Diff != "" && !flagQuiet {
|
||||||
|
fmt.Fprintln(w)
|
||||||
|
printDiff(w, resp.Diff)
|
||||||
|
fmt.Fprintln(w)
|
||||||
|
}
|
||||||
fmt.Fprintln(w, "dry run: nothing was changed")
|
fmt.Fprintln(w, "dry run: nothing was changed")
|
||||||
|
case resp.Applied && resp.ConfirmBy != nil && resp.ConfirmToken != "" && !flagNoConfirm:
|
||||||
|
if err := autoConfirm(resp.ConfirmToken); err != nil {
|
||||||
|
fmt.Fprintf(w, "applied on probation (history %d), but couldn't confirm it: %v\nIt is rolled back within %s unless confirmed ('hc confirm')\n",
|
||||||
|
resp.HistoryID, err, time.Until(*resp.ConfirmBy).Round(time.Second))
|
||||||
|
} else if !flagQuiet {
|
||||||
|
fmt.Fprintf(w, "applied and confirmed (history %d): this client reached Housecarl again over a new connection\n", resp.HistoryID)
|
||||||
|
}
|
||||||
case resp.Applied && resp.ConfirmBy != nil:
|
case resp.Applied && resp.ConfirmBy != nil:
|
||||||
fmt.Fprintf(w, "applied on probation (history %d): confirm with 'hc confirm' within %s, or it is rolled back\n",
|
fmt.Fprintf(w, "applied on probation (history %d): confirm with 'hc confirm' within %s, or it is rolled back\n",
|
||||||
resp.HistoryID, time.Until(*resp.ConfirmBy).Round(time.Second))
|
resp.HistoryID, time.Until(*resp.ConfirmBy).Round(time.Second))
|
||||||
@@ -332,7 +378,7 @@ func report(resp *api.MutationResponse) error {
|
|||||||
case !resp.Applied && len(resp.Changes) == 0 && !flagQuiet:
|
case !resp.Applied && len(resp.Changes) == 0 && !flagQuiet:
|
||||||
fmt.Fprintln(w, "nothing to change")
|
fmt.Fprintln(w, "nothing to change")
|
||||||
}
|
}
|
||||||
if flagQuiet && len(resp.Object) > 0 && resp.Applied {
|
if flagQuiet && len(resp.Object) > 0 && (resp.Applied || resp.Staged) {
|
||||||
outputIDs(resp.Object)
|
outputIDs(resp.Object)
|
||||||
}
|
}
|
||||||
printProblems(os.Stderr, resp.Warnings)
|
printProblems(os.Stderr, resp.Warnings)
|
||||||
|
|||||||
+3
-3
@@ -144,7 +144,7 @@ many: --interface 'incusbr*'. --external marks a zone facing the internet:
|
|||||||
antispoofing, MSS clamping and port forwards apply there.
|
antispoofing, MSS clamping and port forwards apply there.
|
||||||
|
|
||||||
hc zone add wan --interface wan --external
|
hc zone add wan --interface wan --external
|
||||||
hc zone add lan --interface enp2s0`,
|
hc zone add lan --interface eth1`,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
return change("POST", "/zones", model.Zone{Name: args[0], Interfaces: ifaces, External: external, Comment: comment})
|
return change("POST", "/zones", model.Zone{Name: args[0], Interfaces: ifaces, External: external, Comment: comment})
|
||||||
},
|
},
|
||||||
@@ -267,7 +267,7 @@ func setCmd() *cobra.Command {
|
|||||||
Use: "add NAME [ENTRY...]", Short: "Add a set", Args: cobra.MinimumNArgs(1),
|
Use: "add NAME [ENTRY...]", Short: "Add a set", Args: cobra.MinimumNArgs(1),
|
||||||
Long: `Adds a set of addresses, prefixes and ranges, IPv4 and IPv6 mixed:
|
Long: `Adds a set of addresses, prefixes and ranges, IPv4 and IPv6 mixed:
|
||||||
|
|
||||||
hc set add trusted 10.0.10.0/24 10.20.30.0/24 2a02:8013:7f3d:10::/64
|
hc set add trusted 192.168.1.0/24 192.168.2.0/24 2001:db8:1::/64
|
||||||
hc set add guests --dynamic --timeout 24h
|
hc set add guests --dynamic --timeout 24h
|
||||||
|
|
||||||
A dynamic set takes entries at run time, each with its own timeout ('hc
|
A dynamic set takes entries at run time, each with its own timeout ('hc
|
||||||
@@ -425,7 +425,7 @@ func serviceCmd() *cobra.Command {
|
|||||||
Use: "add NAME ENTRY...", Short: "Add a service", Args: cobra.MinimumNArgs(2),
|
Use: "add NAME ENTRY...", Short: "Add a service", Args: cobra.MinimumNArgs(2),
|
||||||
Long: `Adds a service: ports of a protocol, and ICMP types.
|
Long: `Adds a service: ports of a protocol, and ICMP types.
|
||||||
|
|
||||||
hc service add jellyfin tcp/8096 tcp/8920
|
hc service add web tcp/8080 tcp/8443
|
||||||
hc service add games udp/27015-27030 tcp/27015
|
hc service add games udp/27015-27030 tcp/27015
|
||||||
hc service add pmtu icmp/destination-unreachable icmpv6/packet-too-big`,
|
hc service add pmtu icmp/destination-unreachable icmpv6/packet-too-big`,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
|||||||
+168
-18
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -96,8 +97,8 @@ type ruleFlags struct {
|
|||||||
matchFlags
|
matchFlags
|
||||||
action, rejectWith, comment, logPrefix, raw string
|
action, rejectWith, comment, logPrefix, raw string
|
||||||
states, statuses, days []string
|
states, statuses, days []string
|
||||||
hours, limit string
|
hours, limit, quota string
|
||||||
connLimit int
|
connLimit, groupV4, groupV6 int
|
||||||
log, disabled bool
|
log, disabled bool
|
||||||
before, after int64
|
before, after int64
|
||||||
first bool
|
first bool
|
||||||
@@ -111,8 +112,11 @@ func (r *ruleFlags) register(f *pflag.FlagSet) {
|
|||||||
f.StringSliceVar(&r.statuses, "status", nil, "connection status: dnat, snat")
|
f.StringSliceVar(&r.statuses, "status", nil, "connection status: dnat, snat")
|
||||||
f.StringSliceVar(&r.days, "days", nil, "days it applies: Monday,Tuesday,…")
|
f.StringSliceVar(&r.days, "days", nil, "days it applies: Monday,Tuesday,…")
|
||||||
f.StringVar(&r.hours, "hours", "", "hours it applies: 08:00-17:30 (22:00-06:00 passes midnight)")
|
f.StringVar(&r.hours, "hours", "", "hours it applies: 08:00-17:30 (22:00-06:00 passes midnight)")
|
||||||
f.StringVar(&r.limit, "limit", "", "a rate: 10/minute, with ,burst=N ,over (beyond the rate) ,per-source")
|
f.StringVar(&r.limit, "limit", "", "a rate: 10/minute, or bytes: 1mbytes/second, 500kbytes/minute; with ,burst=N ,over (beyond the rate) ,per-source or ,per-destination")
|
||||||
f.IntVar(&r.connLimit, "conn-limit", 0, "match a source with more than this many connections")
|
f.IntVar(&r.connLimit, "conn-limit", 0, "match a source with more than this many connections")
|
||||||
|
f.StringVar(&r.quota, "quota", "", "an allowance: 5000mbytes or 5gbytes, with ,over (match once used up) ,per-source or ,per-destination, and ,daily ,weekly or ,monthly=DAY to reset")
|
||||||
|
f.IntVar(&r.groupV4, "group-v4", 0, "per-source/destination limits and quotas count IPv4 addresses by this prefix (default 32: each address)")
|
||||||
|
f.IntVar(&r.groupV6, "group-v6", 0, "…and IPv6 addresses by this prefix (default 64: one device or network)")
|
||||||
f.BoolVar(&r.log, "log", false, "log matching packets ('hc log')")
|
f.BoolVar(&r.log, "log", false, "log matching packets ('hc log')")
|
||||||
f.StringVar(&r.logPrefix, "log-prefix", "", "the log line's prefix")
|
f.StringVar(&r.logPrefix, "log-prefix", "", "the log line's prefix")
|
||||||
f.StringVar(&r.raw, "raw", "", "an nftables rule written by hand instead of the match flags")
|
f.StringVar(&r.raw, "raw", "", "an nftables rule written by hand instead of the match flags")
|
||||||
@@ -120,35 +124,124 @@ func (r *ruleFlags) register(f *pflag.FlagSet) {
|
|||||||
f.BoolVar(&r.disabled, "disabled", false, "keep the rule but don't use it")
|
f.BoolVar(&r.disabled, "disabled", false, "keep the rule but don't use it")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var amountRe = regexp.MustCompile(`^(\d+)\s*([a-zA-Z]*)$`)
|
||||||
|
|
||||||
|
// byteUnit reads a unit: "" (packets, for rates), bytes, kbytes, mbytes,
|
||||||
|
// gbytes, or b, kb, mb, gb in any case.
|
||||||
|
func byteUnit(u string) (string, bool) {
|
||||||
|
switch strings.ToLower(u) {
|
||||||
|
case "", "packets", "packet", "p":
|
||||||
|
return "", true
|
||||||
|
case "bytes", "byte", "b":
|
||||||
|
return "bytes", true
|
||||||
|
case "kbytes", "kb", "k":
|
||||||
|
return "kbytes", true
|
||||||
|
case "mbytes", "mb", "m":
|
||||||
|
return "mbytes", true
|
||||||
|
case "gbytes", "gb", "g":
|
||||||
|
return "gbytes", true
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
func parseLimit(s string) (*model.Limit, error) {
|
func parseLimit(s string) (*model.Limit, error) {
|
||||||
if s == "" {
|
if s == "" {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
parts := strings.Split(s, ",")
|
parts := strings.Split(s, ",")
|
||||||
rate, per, ok := strings.Cut(strings.TrimSpace(parts[0]), "/")
|
rate, per, ok := strings.Cut(strings.TrimSpace(parts[0]), "/")
|
||||||
n, err := strconv.Atoi(rate)
|
m := amountRe.FindStringSubmatch(strings.TrimSpace(rate))
|
||||||
if !ok || err != nil {
|
unit, unitOK := "", false
|
||||||
return nil, fmt.Errorf("--limit %q: write it as 10/minute, with ,burst=N ,over ,per-source", s)
|
if m != nil {
|
||||||
|
unit, unitOK = byteUnit(m[2])
|
||||||
}
|
}
|
||||||
l := &model.Limit{Rate: n, Per: strings.TrimSuffix(per, "s")}
|
if !ok || m == nil || !unitOK || unit == "gbytes" {
|
||||||
|
return nil, fmt.Errorf("--limit %q: write it as 10/minute, or as bytes 1mbytes/second (bytes, kbytes, mbytes), with ,burst=N ,over ,per-source", s)
|
||||||
|
}
|
||||||
|
n, _ := strconv.Atoi(m[1])
|
||||||
|
l := &model.Limit{Rate: n, Unit: unit, Per: strings.TrimSuffix(per, "s")}
|
||||||
for _, p := range parts[1:] {
|
for _, p := range parts[1:] {
|
||||||
k, v, _ := strings.Cut(strings.TrimSpace(p), "=")
|
k, v, _ := strings.Cut(strings.TrimSpace(p), "=")
|
||||||
switch k {
|
switch k {
|
||||||
case "burst":
|
case "burst":
|
||||||
if l.Burst, err = strconv.Atoi(v); err != nil {
|
b := amountRe.FindStringSubmatch(v)
|
||||||
return nil, fmt.Errorf("--limit: burst=%q isn't a number", v)
|
bu, buOK := "", false
|
||||||
|
if b != nil {
|
||||||
|
bu, buOK = byteUnit(b[2])
|
||||||
}
|
}
|
||||||
|
if b == nil || !buOK || (b[2] != "" && bu != unit) {
|
||||||
|
return nil, fmt.Errorf("--limit: burst=%q: a number, in the rate's unit", v)
|
||||||
|
}
|
||||||
|
l.Burst, _ = strconv.Atoi(b[1])
|
||||||
case "over":
|
case "over":
|
||||||
l.Over = true
|
l.Over = true
|
||||||
case "per-source":
|
case "per-source":
|
||||||
l.PerSource = true
|
l.PerSource = true
|
||||||
|
case "per-destination":
|
||||||
|
l.PerDestination = true
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("--limit: unknown %q (burst=N, over, per-source)", k)
|
return nil, fmt.Errorf("--limit: unknown %q (burst=N, over, per-source, per-destination)", k)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return l, nil
|
return l, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parseQuota reads --quota: 5000mbytes,over,per-source,monthly=15.
|
||||||
|
func parseQuota(s string) (*model.Quota, error) {
|
||||||
|
if s == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
parts := strings.Split(s, ",")
|
||||||
|
m := amountRe.FindStringSubmatch(strings.TrimSpace(parts[0]))
|
||||||
|
unit, ok := "", false
|
||||||
|
if m != nil {
|
||||||
|
unit, ok = byteUnit(m[2])
|
||||||
|
}
|
||||||
|
if m == nil || !ok || unit == "" {
|
||||||
|
return nil, fmt.Errorf("--quota %q: write it as 5000mbytes or 5gbytes, with ,over ,per-source ,daily ,weekly or ,monthly=DAY", s)
|
||||||
|
}
|
||||||
|
n, _ := strconv.ParseInt(m[1], 10, 64)
|
||||||
|
q := &model.Quota{Bytes: n * model.ByteUnits[unit]}
|
||||||
|
for _, p := range parts[1:] {
|
||||||
|
k, v, _ := strings.Cut(strings.TrimSpace(p), "=")
|
||||||
|
switch k {
|
||||||
|
case "over":
|
||||||
|
q.Over = true
|
||||||
|
case "per-source":
|
||||||
|
q.PerSource = true
|
||||||
|
case "per-destination":
|
||||||
|
q.PerDestination = true
|
||||||
|
case "daily", "weekly":
|
||||||
|
q.Reset = k
|
||||||
|
case "monthly":
|
||||||
|
q.Reset = k
|
||||||
|
if v != "" {
|
||||||
|
d, err := strconv.Atoi(v)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("--quota: monthly=%q: the day of the month, 1 to 31", v)
|
||||||
|
}
|
||||||
|
q.Day = d
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("--quota: unknown %q (over, per-source, per-destination, daily, weekly, monthly=DAY)", k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return q, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// humanBytes writes bytes for people: "1.8 GB".
|
||||||
|
func humanBytes(n int64) string {
|
||||||
|
switch {
|
||||||
|
case n >= 1<<30:
|
||||||
|
return strconv.FormatFloat(float64(n)/(1<<30), 'f', 1, 64) + " GB"
|
||||||
|
case n >= 1<<20:
|
||||||
|
return strconv.FormatFloat(float64(n)/(1<<20), 'f', 1, 64) + " MB"
|
||||||
|
case n >= 1<<10:
|
||||||
|
return strconv.FormatFloat(float64(n)/(1<<10), 'f', 1, 64) + " KB"
|
||||||
|
}
|
||||||
|
return strconv.FormatInt(n, 10) + " bytes"
|
||||||
|
}
|
||||||
|
|
||||||
func (r *ruleFlags) rule(chain string) (model.Rule, error) {
|
func (r *ruleFlags) rule(chain string) (model.Rule, error) {
|
||||||
rule := model.Rule{Chain: chain, Match: r.match(), Action: r.action, RejectWith: r.rejectWith, CtState: r.states, CtStatus: r.statuses,
|
rule := model.Rule{Chain: chain, Match: r.match(), Action: r.action, RejectWith: r.rejectWith, CtState: r.states, CtStatus: r.statuses,
|
||||||
Days: r.days, ConnLimit: r.connLimit, Log: r.log, LogPrefix: r.logPrefix, Raw: r.raw, Comment: r.comment, Disabled: r.disabled}
|
Days: r.days, ConnLimit: r.connLimit, Log: r.log, LogPrefix: r.logPrefix, Raw: r.raw, Comment: r.comment, Disabled: r.disabled}
|
||||||
@@ -160,7 +253,12 @@ func (r *ruleFlags) rule(chain string) (model.Rule, error) {
|
|||||||
rule.HourFrom, rule.HourTo = from, to
|
rule.HourFrom, rule.HourTo = from, to
|
||||||
}
|
}
|
||||||
l, err := parseLimit(r.limit)
|
l, err := parseLimit(r.limit)
|
||||||
|
if err != nil {
|
||||||
|
return rule, err
|
||||||
|
}
|
||||||
rule.Limit = l
|
rule.Limit = l
|
||||||
|
rule.Quota, err = parseQuota(r.quota)
|
||||||
|
rule.GroupV4, rule.GroupV6 = r.groupV4, r.groupV6
|
||||||
return rule, err
|
return rule, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,6 +285,9 @@ func ruleCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
extra := ruleExtras(r)
|
extra := ruleExtras(r)
|
||||||
action := r.Action
|
action := r.Action
|
||||||
|
if r.Raw != "" {
|
||||||
|
action = "-" // the text's own
|
||||||
|
}
|
||||||
if r.Log {
|
if r.Log {
|
||||||
action += "+log"
|
action += "+log"
|
||||||
}
|
}
|
||||||
@@ -200,7 +301,11 @@ func ruleCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
cmd.AddCommand(ls)
|
cmd.AddCommand(ls)
|
||||||
cmd.AddCommand(showCmd("rules", "rule", func(w io.Writer, r model.Rule) {
|
cmd.AddCommand(showCmd("rules", "rule", func(w io.Writer, r model.Rule) {
|
||||||
|
if r.Raw == "" {
|
||||||
fmt.Fprintf(w, "ID:\t%d\nChain:\t%s\nMatch:\t%s%s\nAction:\t%s\n", r.ID, r.Chain, describe(r.Match), ruleExtras(r), r.Action)
|
fmt.Fprintf(w, "ID:\t%d\nChain:\t%s\nMatch:\t%s%s\nAction:\t%s\n", r.ID, r.Chain, describe(r.Match), ruleExtras(r), r.Action)
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(w, "ID:\t%d\nChain:\t%s\n", r.ID, r.Chain)
|
||||||
|
}
|
||||||
if r.Raw != "" {
|
if r.Raw != "" {
|
||||||
fmt.Fprintf(w, "Raw:\t%s\n", r.Raw)
|
fmt.Fprintf(w, "Raw:\t%s\n", r.Raw)
|
||||||
}
|
}
|
||||||
@@ -221,7 +326,7 @@ output (from it) or early (before connection tracking: cheap drops).
|
|||||||
|
|
||||||
hc rule add input --from trusted --service ssh
|
hc rule add input --from trusted --service ssh
|
||||||
hc rule add input --in wg --service dns
|
hc rule add input --in wg --service dns
|
||||||
hc rule add forward --in lan --out wan --from 2a02:8013:7f3d:10::/64
|
hc rule add forward --in lan --out wan --from 2001:db8:1::/64
|
||||||
hc rule add input --in wan --service ssh --limit 3/minute,burst=5,over,per-source -a drop --log
|
hc rule add input --in wan --service ssh --limit 3/minute,burst=5,over,per-source -a drop --log
|
||||||
hc rule add forward --in wan -a drop --comment "nothing else gets in"
|
hc rule add forward --in wan -a drop --comment "nothing else gets in"
|
||||||
hc rule add forward --raw 'iifname != "wan" ct state invalid ip daddr @trusted_v4 accept'`,
|
hc rule add forward --raw 'iifname != "wan" ct state invalid ip daddr @trusted_v4 accept'`,
|
||||||
@@ -277,7 +382,7 @@ output (from it) or early (before connection tracking: cheap drops).
|
|||||||
"action": {"action", r.Action}, "reject-with": {"reject_with", r.RejectWith}, "state": {"ct_state", r.CtState},
|
"action": {"action", r.Action}, "reject-with": {"reject_with", r.RejectWith}, "state": {"ct_state", r.CtState},
|
||||||
"status": {"ct_status", r.CtStatus}, "days": {"days", r.Days}, "conn-limit": {"conn_limit", r.ConnLimit},
|
"status": {"ct_status", r.CtStatus}, "days": {"days", r.Days}, "conn-limit": {"conn_limit", r.ConnLimit},
|
||||||
"log": {"log", r.Log}, "log-prefix": {"log_prefix", r.LogPrefix}, "raw": {"raw", r.Raw}, "comment": {"comment", r.Comment},
|
"log": {"log", r.Log}, "log-prefix": {"log_prefix", r.LogPrefix}, "raw": {"raw", r.Raw}, "comment": {"comment", r.Comment},
|
||||||
"disabled": {"disabled", r.Disabled},
|
"disabled": {"disabled", r.Disabled}, "group-v4": {"group_v4", r.GroupV4}, "group-v6": {"group_v6", r.GroupV6},
|
||||||
} {
|
} {
|
||||||
if fl.Changed(flag) {
|
if fl.Changed(flag) {
|
||||||
p[field[0].(string)] = field[1]
|
p[field[0].(string)] = field[1]
|
||||||
@@ -289,6 +394,9 @@ output (from it) or early (before connection tracking: cheap drops).
|
|||||||
if fl.Changed("limit") {
|
if fl.Changed("limit") {
|
||||||
p["limit"] = r.Limit
|
p["limit"] = r.Limit
|
||||||
}
|
}
|
||||||
|
if fl.Changed("quota") {
|
||||||
|
p["quota"] = r.Quota
|
||||||
|
}
|
||||||
return change("PATCH", objPath("rules", args[0]), p)
|
return change("PATCH", objPath("rules", args[0]), p)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -316,6 +424,22 @@ output (from it) or early (before connection tracking: cheap drops).
|
|||||||
move.Flags().BoolVar(&last, "last", false, "last in its chain")
|
move.Flags().BoolVar(&last, "last", false, "last in its chain")
|
||||||
move.Flags().StringVar(&chain, "chain", "", "to the end of another chain")
|
move.Flags().StringVar(&chain, "chain", "", "to the end of another chain")
|
||||||
cmd.AddCommand(move)
|
cmd.AddCommand(move)
|
||||||
|
cmd.AddCommand(&cobra.Command{
|
||||||
|
Use: "reset-quota ID", Short: "Start a rule's quota again now (every address's, for a per-address quota)", Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
c, err := newClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := c.Do(ctx(), "POST", objPath("rules", args[0])+"/reset-quota", nil, nil, nil); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !flagQuiet {
|
||||||
|
fmt.Printf("reset the quota of rule %s\n", args[0])
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
for _, on := range []bool{true, false} {
|
for _, on := range []bool{true, false} {
|
||||||
verb, short := "disable", "Keep a rule but don't use it"
|
verb, short := "disable", "Keep a rule but don't use it"
|
||||||
if on {
|
if on {
|
||||||
@@ -345,13 +469,39 @@ func ruleExtras(r model.Rule) string {
|
|||||||
x = append(x, r.HourFrom+"-"+r.HourTo)
|
x = append(x, r.HourFrom+"-"+r.HourTo)
|
||||||
}
|
}
|
||||||
if l := r.Limit; l != nil {
|
if l := r.Limit; l != nil {
|
||||||
s := fmt.Sprintf("limit %d/%s", l.Rate, l.Per)
|
unit := ""
|
||||||
|
if l.Unit != "" && l.Unit != "packets" {
|
||||||
|
unit = " " + l.Unit
|
||||||
|
}
|
||||||
|
s := fmt.Sprintf("limit %d%s/%s", l.Rate, unit, l.Per)
|
||||||
if l.Over {
|
if l.Over {
|
||||||
s = "over " + s
|
s = "over " + s
|
||||||
}
|
}
|
||||||
if l.PerSource {
|
if l.PerSource {
|
||||||
s += " per source"
|
s += " per source"
|
||||||
}
|
}
|
||||||
|
if l.PerDestination {
|
||||||
|
s += " per destination"
|
||||||
|
}
|
||||||
|
x = append(x, s)
|
||||||
|
}
|
||||||
|
if q := r.Quota; q != nil {
|
||||||
|
s := "quota " + humanBytes(q.Bytes)
|
||||||
|
if q.Over {
|
||||||
|
s = "over " + s
|
||||||
|
}
|
||||||
|
if q.PerSource {
|
||||||
|
s += " per source"
|
||||||
|
}
|
||||||
|
if q.PerDestination {
|
||||||
|
s += " per destination"
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case q.Reset == "monthly" && q.Day > 1:
|
||||||
|
s += fmt.Sprintf(" monthly (%d)", q.Day)
|
||||||
|
case q.Reset != "":
|
||||||
|
s += " " + q.Reset
|
||||||
|
}
|
||||||
x = append(x, s)
|
x = append(x, s)
|
||||||
}
|
}
|
||||||
if r.ConnLimit > 0 {
|
if r.ConnLimit > 0 {
|
||||||
@@ -412,9 +562,9 @@ through, and with --hairpin makes the public address work from inside too.
|
|||||||
--to6 also opens the port to an IPv6 host (no translation there: its own
|
--to6 also opens the port to an IPv6 host (no translation there: its own
|
||||||
port).
|
port).
|
||||||
|
|
||||||
hc forward add 8096 --to 10.0.10.57:443 --to6 2a02:8013:7f3d:10::57 --public 217.155.8.194 --hairpin --name jellyfin
|
hc forward add 8443 --to 192.168.1.10:443 --to6 2001:db8:1::10 --public 203.0.113.10 --hairpin --name web
|
||||||
hc forward add 7777-7780 --proto tcp,udp --to 10.0.10.76 --public 217.155.8.194 --hairpin --name terraria
|
hc forward add 27015-27030 --proto tcp,udp --to 192.168.1.20 --public 203.0.113.10 --hairpin --name game
|
||||||
hc forward add 25 --to 10.0.10.25 --from trusted`,
|
hc forward add 25 --to 192.168.1.25 --from trusted`,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
pf.Ports = args[0]
|
pf.Ports = args[0]
|
||||||
if to != "" {
|
if to != "" {
|
||||||
@@ -528,7 +678,7 @@ traffic leaving the firewall. IPv4 only, unless --family ipv6 (NAT66).
|
|||||||
|
|
||||||
hc snat add --out wan --from trusted
|
hc snat add --out wan --from trusted
|
||||||
hc snat add --out wg --not-out --from wg_net
|
hc snat add --out wg --not-out --from wg_net
|
||||||
hc snat add --out wan --from fdab:abab:abab::/48 --family ipv6 --snat-to 2a02:8013:7f3d::1`,
|
hc snat add --out wan --from fd00:1::/48 --family ipv6 --snat-to 2001:db8::1`,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
return change("POST", "/snats", model.SourceNAT{Match: mf.match(), To: to, Comment: comment})
|
return change("POST", "/snats", model.SourceNAT{Match: mf.match(), To: to, Comment: comment})
|
||||||
},
|
},
|
||||||
@@ -606,7 +756,7 @@ func redirectCmd() *cobra.Command {
|
|||||||
Long: `Sends matching traffic to a port on the firewall itself, so every client
|
Long: `Sends matching traffic to a port on the firewall itself, so every client
|
||||||
uses its DNS server, say:
|
uses its DNS server, say:
|
||||||
|
|
||||||
hc redirect add --in lan --service dns --not-to 10.0.10.5 --comment "force DNS"`,
|
hc redirect add --in lan --service dns --not-to 192.168.1.5 --comment "force DNS"`,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
return change("POST", "/redirects", model.Redirect{Match: mf.match(), ToPort: toPort, Comment: comment})
|
return change("POST", "/redirects", model.Redirect{Match: mf.match(), ToPort: toPort, Comment: comment})
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -175,6 +176,14 @@ func serveCmd() *cobra.Command {
|
|||||||
// housecarld stopped is rolled back first. An empty
|
// housecarld stopped is rolled back first. An empty
|
||||||
// configuration applies nothing: its default policies would
|
// configuration applies nothing: its default policies would
|
||||||
// close the machine before any rule lets anyone in.
|
// close the machine before any rule lets anyone in.
|
||||||
|
// What the quotas used before housecarld (or the machine)
|
||||||
|
// stopped comes back with the next apply.
|
||||||
|
if v, err := a.store.GetMeta(ctx, server.QuotaUsageKey); err == nil && v != "" {
|
||||||
|
var u nft.QuotaUsage
|
||||||
|
if json.Unmarshal([]byte(v), &u) == nil {
|
||||||
|
fw.RestoreQuotas(&u)
|
||||||
|
}
|
||||||
|
}
|
||||||
if !srv.ResumeProbation(ctx) {
|
if !srv.ResumeProbation(ctx) {
|
||||||
if eng.State().Empty() {
|
if eng.State().Empty() {
|
||||||
a.log.Info("the configuration is empty: no ruleset is applied until the first change (or 'hc import')")
|
a.log.Info("the configuration is empty: no ruleset is applied until the first change (or 'hc import')")
|
||||||
@@ -185,6 +194,7 @@ func serveCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
go nflog.Listen(ctx, render.LogGroup, a.log, srv.LogPacket)
|
go nflog.Listen(ctx, render.LogGroup, a.log, srv.LogPacket)
|
||||||
|
go srv.RunQuotas(ctx)
|
||||||
hup := make(chan os.Signal, 1)
|
hup := make(chan os.Signal, 1)
|
||||||
signal.Notify(hup, syscall.SIGHUP)
|
signal.Notify(hup, syscall.SIGHUP)
|
||||||
go func() {
|
go func() {
|
||||||
@@ -196,6 +206,7 @@ func serveCmd() *cobra.Command {
|
|||||||
}()
|
}()
|
||||||
err = srv.Serve(ctx, func() { sdNotify("READY=1") })
|
err = srv.Serve(ctx, func() { sdNotify("READY=1") })
|
||||||
sdNotify("STOPPING=1")
|
sdNotify("STOPPING=1")
|
||||||
|
srv.SaveQuotaUsage(context.Background())
|
||||||
a.log.Info("stopped")
|
a.log.Info("stopped")
|
||||||
return err
|
return err
|
||||||
},
|
},
|
||||||
|
|||||||
+53
-10
@@ -51,8 +51,16 @@ Calls that change the configuration answer:
|
|||||||
* `confirm_by`: the change is on probation, and is rolled back at that
|
* `confirm_by`: the change is on probation, and is rolled back at that
|
||||||
time unless `POST /confirm` comes first. Missing for changes made
|
time unless `POST /confirm` comes first. Missing for changes made
|
||||||
through the socket.
|
through the socket.
|
||||||
|
* `confirm_token`: the client that made the change can confirm it by
|
||||||
|
itself with `POST /confirm {"token"}`, from the same address, as the
|
||||||
|
same user and token, over a new connection (the answer closes this
|
||||||
|
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`.
|
would do, with `"applied": false, "dry_run": true`, and `diff`: the
|
||||||
|
ruleset's unified diff.
|
||||||
|
* `?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`.
|
||||||
* `before` is missing for a create and `after` for a delete.
|
* `before` is missing for a create and `after` for a delete.
|
||||||
* `warnings` are the problems the change brings; older ones are in
|
* `warnings` are the problems the change brings; older ones are in
|
||||||
`/status`.
|
`/status`.
|
||||||
@@ -71,6 +79,7 @@ Creating answers 201, other changes 200.
|
|||||||
| `GET /interfaces` | the machine's interfaces: `name`, `up`, `addresses`, `zone` |
|
| `GET /interfaces` | the machine's interfaces: `name`, `up`, `addresses`, `zone` |
|
||||||
| `GET /ruleset` | the nftables ruleset Housecarl builds, as text |
|
| `GET /ruleset` | the nftables ruleset Housecarl builds, as text |
|
||||||
| `GET /counters` | `{"since", "counters": {"r12": {"packets", "bytes"}, ...}}`, by mark ([design.md](design.md#its-own-table-only)); totals since housecarld started |
|
| `GET /counters` | `{"since", "counters": {"r12": {"packets", "bytes"}, ...}}`, by mark ([design.md](design.md#its-own-table-only)); totals since housecarld started |
|
||||||
|
| `GET /quotas` | each rule's quota: `rule_id`, `rule`, `quota`, `used` (bytes), `addresses` (`[{"address", "used"}]` for a per-address quota), `last_reset`, `next_reset` |
|
||||||
| `GET /log[?limit=&after=&q=]` | logged packets, newest first: `time`, `prefix`, `in`, `out`, `family`, `protocol`, `source`, `source_port`, `destination`, `destination_port`, `icmp_type`, `tcp_flags`, `length`, `mark` (the rule's). `after` (a time) for those since; `q` searches |
|
| `GET /log[?limit=&after=&q=]` | logged packets, newest first: `time`, `prefix`, `in`, `out`, `family`, `protocol`, `source`, `source_port`, `destination`, `destination_port`, `icmp_type`, `tcp_flags`, `length`, `mark` (the rule's). `after` (a time) for those since; `q` searches |
|
||||||
| `GET /catalog` | built-in services, chains, actions, roles, the log group |
|
| `GET /catalog` | built-in services, chains, actions, roles, the log group |
|
||||||
|
|
||||||
@@ -83,8 +92,8 @@ Creating answers 201, other changes 200.
|
|||||||
"log_policy_drops": false,
|
"log_policy_drops": false,
|
||||||
"builtins": {"loopback": true, "established": true, "drop_invalid": true, "icmpv6": true,
|
"builtins": {"loopback": true, "established": true, "drop_invalid": true, "icmpv6": true,
|
||||||
"ping_internal": true, "dhcpv6_client": true, "antispoof": true, "mss_clamp": false},
|
"ping_internal": true, "dhcpv6_client": true, "antispoof": true, "mss_clamp": false},
|
||||||
"own_prefixes": ["2001:db8:7f3d::/48"], "antispoof_v4": ["10.0.0.0/8", "..."], "antispoof_v6": ["fc00::/7", "..."],
|
"own_prefixes": ["2001:db8::/48"], "antispoof_v4": ["10.0.0.0/8", "..."], "antispoof_v6": ["fc00::/7", "..."],
|
||||||
"flowtable_devices": ["wan", "enp2s0"], "probation_seconds": 120}
|
"flowtable_devices": ["wan", "eth1"], "probation_seconds": 120, "manual_confirm": false}
|
||||||
```
|
```
|
||||||
|
|
||||||
Lists replace the old ones. `probation_seconds` 0 is the default (120).
|
Lists replace the old ones. `probation_seconds` 0 is the default (120).
|
||||||
@@ -102,24 +111,27 @@ share one pattern, `/{kind}` with kind `zones`, `sets`, `services`,
|
|||||||
| `GET /{kind}/{ref}` | by ID, or name for zones, sets, services and forwards |
|
| `GET /{kind}/{ref}` | by ID, or name for zones, sets, services and forwards |
|
||||||
| `PATCH /{kind}/{ref}` | change the fields given (a JSON merge: `null` clears) |
|
| `PATCH /{kind}/{ref}` | change the fields given (a JSON merge: `null` clears) |
|
||||||
| `DELETE /{kind}/{ref}` | refused while something uses it |
|
| `DELETE /{kind}/{ref}` | refused while something uses it |
|
||||||
|
| `POST /rules/{ref}/reset-quota` | start a rule's quota again now (`config:write`; audited, not staged) |
|
||||||
| `POST /{kind}/{ref}/move` | `{"before": ID}`, `{"after": ID}` or `{"to": "start"\|"end"}`; a rule can take `"chain"` too |
|
| `POST /{kind}/{ref}/move` | `{"before": ID}`, `{"after": ID}` or `{"to": "start"\|"end"}`; a rule can take `"chain"` too |
|
||||||
|
|
||||||
The objects:
|
The objects:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{"name": "lan", "interfaces": ["enp2s0", "enp2s0.*"], "external": false, "comment": ""}
|
{"name": "lan", "interfaces": ["eth1", "eth1.*"], "external": false, "comment": ""}
|
||||||
{"name": "trusted", "entries": ["10.0.10.0/24", "2001:db8:7f3d:10::/64"], "dynamic": false, "timeout": 0, "comment": ""}
|
{"name": "trusted", "entries": ["192.168.1.0/24", "2001:db8:1::/64"], "dynamic": false, "timeout": 0, "comment": ""}
|
||||||
{"name": "jellyfin", "entries": ["tcp/8096", "tcp/8920"], "comment": ""}
|
{"name": "web", "entries": ["tcp/8080", "tcp/8443"], "comment": ""}
|
||||||
{"chain": "input",
|
{"chain": "input",
|
||||||
"in_zones": ["wan"], "not_in": false, "out_zones": [], "not_out": false,
|
"in_zones": ["wan"], "not_in": false, "out_zones": [], "not_out": false,
|
||||||
"sources": ["trusted"], "not_sources": false, "destinations": [], "not_destinations": false,
|
"sources": ["trusted"], "not_sources": false, "destinations": [], "not_destinations": false,
|
||||||
"services": ["ssh"], "family": "",
|
"services": ["ssh"], "family": "",
|
||||||
"ct_state": ["new"], "ct_status": [], "days": [], "hour_from": "", "hour_to": "",
|
"ct_state": ["new"], "ct_status": [], "days": [], "hour_from": "", "hour_to": "",
|
||||||
"limit": {"rate": 3, "per": "minute", "burst": 5, "over": true, "per_source": true}, "conn_limit": 0,
|
"limit": {"rate": 3, "unit": "", "per": "minute", "burst": 5, "over": true, "per_source": true, "per_destination": false}, "conn_limit": 0,
|
||||||
|
"quota": {"bytes": 53687091200, "over": true, "per_source": true, "per_destination": false, "reset": "monthly", "day": 15},
|
||||||
|
"group_v4": 0, "group_v6": 0,
|
||||||
"action": "drop", "reject_with": "", "log": true, "log_prefix": "",
|
"action": "drop", "reject_with": "", "log": true, "log_prefix": "",
|
||||||
"raw": "", "comment": "", "disabled": false}
|
"raw": "", "comment": "", "disabled": false}
|
||||||
{"name": "jellyfin", "protocols": ["tcp"], "ports": "8096", "public_addresses": ["203.0.113.7"], "sources": [],
|
{"name": "web", "protocols": ["tcp"], "ports": "8443", "public_addresses": ["203.0.113.10"], "sources": [],
|
||||||
"to": "10.0.10.57", "to_port": "443", "to6": "2001:db8:7f3d:10::57", "hairpin": true, "hairpin_sources": [],
|
"to": "192.168.1.10", "to_port": "443", "to6": "2001:db8:1::10", "hairpin": true, "hairpin_sources": [],
|
||||||
"comment": "", "disabled": false}
|
"comment": "", "disabled": false}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -127,6 +139,37 @@ A source NAT rule is a match (as a rule's, without the rule's other
|
|||||||
fields) with `"to"` (empty: masquerade), a redirect a match with
|
fields) with `"to"` (empty: masquerade), a redirect a match with
|
||||||
`"to_port"`. Times are seconds (`timeout`).
|
`"to_port"`. Times are seconds (`timeout`).
|
||||||
|
|
||||||
|
### Changesets
|
||||||
|
|
||||||
|
Changes staged to be reviewed and applied together ([design.md](design.md#reviewing-changes-changesets)).
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| `GET /changesets[?status=open&mine=1&limit=]` | newest first |
|
||||||
|
| `POST /changesets` | `{"description"}`; answers the changeset (201) |
|
||||||
|
| `GET /changesets/{id}` | `{"id", "username", "via", "description", "status": "open"\|"applied"\|"discarded", "ops": [{"seq", "op", "kind", "ref", "id", "data", "summary", "added_at"}], "created_at", "updated_at", "history_id"}` |
|
||||||
|
| `PATCH /changesets/{id}` | `{"description"}` |
|
||||||
|
| `GET /changesets/{id}/preview` | `{"valid", "changes", "warnings", "error", "diff"}`: what applying it would do; `error.op` names an operation that no longer applies |
|
||||||
|
| `POST /changesets/{id}/apply` | optional `{"description"}`; answers like a change, with `confirm_by` over the network and `changeset` |
|
||||||
|
| `POST /changesets/{id}/discard` | |
|
||||||
|
| `DELETE /changesets/{id}/ops/{seq}` | take one operation out |
|
||||||
|
|
||||||
|
Staging (`?changeset=ID` on any change to objects or settings) checks
|
||||||
|
the changeset with the new operation, and nftables checks its ruleset;
|
||||||
|
an error refuses the operation. Only open changesets take operations, and
|
||||||
|
only their owner's (or an administrator's). An undo
|
||||||
|
(`POST /history/{id}/revert?changeset=ID`) is staged as one operation,
|
||||||
|
which checks when it is replayed that nothing it touches changed since;
|
||||||
|
applied, History links it to the entry it undoes. `/import/nft` and
|
||||||
|
`/takeover` can't be staged: with a changeset named they answer 400.
|
||||||
|
|
||||||
|
Reads of objects, settings and `/ruleset` with `?changeset=ID` show the
|
||||||
|
configuration as the changeset will make it; objects are marked
|
||||||
|
`"pending": "new"`, `"changed"` or `"deleted"` (deleted ones stay in the
|
||||||
|
list). A changeset that no longer applies shows the current
|
||||||
|
configuration, with the reason in the `X-Housecarl-Changeset-Error`
|
||||||
|
header.
|
||||||
|
|
||||||
### Dynamic sets' entries
|
### Dynamic sets' entries
|
||||||
|
|
||||||
| | |
|
| | |
|
||||||
@@ -144,7 +187,7 @@ history, but are audited.
|
|||||||
| | |
|
| | |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `GET /probation` | `{"pending": [history IDs], "takeovers": N, "deadline", "seconds_left"}` |
|
| `GET /probation` | `{"pending": [history IDs], "takeovers": N, "deadline", "seconds_left"}` |
|
||||||
| `POST /confirm` | keep them: `{"confirmed": N}`; the ruleset is saved for the boot |
|
| `POST /confirm` | keep them: `{"confirmed": N}`; the ruleset is saved for the boot. With `{"token"}`, the client confirming its own change (see `confirm_token`): 409 if a newer change is pending, or over the change's own connection; 403 from another user or address |
|
||||||
| `POST /rollback` | undo them now |
|
| `POST /rollback` | undo them now |
|
||||||
|
|
||||||
### History, import, takeover
|
### History, import, takeover
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ on the internet, and Jarl puts it in every view that has your zone.
|
|||||||
3. **Configure the certificate** in `/etc/housecarl/housecarl.yaml`:
|
3. **Configure the certificate** in `/etc/housecarl/housecarl.yaml`:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
listen: ["10.0.10.2:8070"] # an address your browser can reach
|
listen: ["192.168.1.2:8070"] # an address your browser can reach
|
||||||
tls:
|
tls:
|
||||||
acme:
|
acme:
|
||||||
domain: fw.adbraun.lol
|
domain: fw.adbraun.lol
|
||||||
|
|||||||
+76
-9
@@ -27,7 +27,9 @@ command and its flags.
|
|||||||
| `--config FILE` | `HOUSECARL_CLI_CONFIG` | the CLI's configuration file |
|
| `--config FILE` | `HOUSECARL_CLI_CONFIG` | the CLI's configuration file |
|
||||||
| | `HOUSECARL_SOCKET` | the local socket's path |
|
| | `HOUSECARL_SOCKET` | the local socket's path |
|
||||||
| `--insecure`, `--ca-cert FILE` | | TLS: skip the check, or trust another CA |
|
| `--insecure`, `--ca-cert FILE` | | TLS: skip the check, or trust another CA |
|
||||||
| `-n, --dry-run` | | check and show a change without making it |
|
| `-n, --dry-run` | | check and show a change without making it, with the ruleset's diff |
|
||||||
|
| `--no-confirm` | | leave a change on probation instead of confirming it once `hc` reaches Housecarl again |
|
||||||
|
| `-C, --changeset ID` | `HOUSECARL_CHANGESET` (checked, see [Changesets](#changesets)) | stage configuration changes in this changeset instead of making them |
|
||||||
| `-o, --output FORMAT` | | `table` (default), `json`, `yaml` or `id` |
|
| `-o, --output FORMAT` | | `table` (default), `json`, `yaml` or `id` |
|
||||||
| `-q, --quiet` | | print only errors (and the IDs of new objects) |
|
| `-q, --quiet` | | print only errors (and the IDs of new objects) |
|
||||||
|
|
||||||
@@ -40,14 +42,14 @@ server error.
|
|||||||
Every change is checked as a whole, then by nftables, before it is put
|
Every change is checked as a whole, then by nftables, before it is put
|
||||||
in force. A refused change says why, with the problem's code
|
in force. A refused change says why, with the problem's code
|
||||||
([validation.md](validation.md)). An applied one says its history entry
|
([validation.md](validation.md)). An applied one says its history entry
|
||||||
and any new warnings. Over the network, it is on probation:
|
and any new warnings. Over the network, it is on probation, and `hc`
|
||||||
|
confirms it by reaching Housecarl again over a new connection
|
||||||
|
([operations.md](operations.md#changes-on-probation)):
|
||||||
|
|
||||||
```
|
```
|
||||||
$ hc rule add input --from trusted --service ssh --comment "SSH from home"
|
$ hc rule add input --from trusted --service ssh --comment "SSH from home"
|
||||||
created rule input #31 (from trusted · ssh)
|
created rule input #31 (from trusted · ssh)
|
||||||
applied on probation (history 12): confirm with 'hc confirm' within 2m0s, or it is rolled back
|
applied and confirmed (history 12): this client reached Housecarl again over a new connection
|
||||||
$ hc confirm
|
|
||||||
confirmed 1 change; it is kept and loaded at boot
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Through the socket the change is kept at once:
|
Through the socket the change is kept at once:
|
||||||
@@ -56,8 +58,69 @@ Through the socket the change is kept at once:
|
|||||||
applied and saved (history 12; undo with 'hc history undo 12')
|
applied and saved (history 12; undo with 'hc history undo 12')
|
||||||
```
|
```
|
||||||
|
|
||||||
`-n` shows what would change without changing it; nftables checks it
|
`-n` shows what would change without changing it, with the diff of the
|
||||||
too.
|
ruleset; nftables checks it too.
|
||||||
|
|
||||||
|
## Changesets
|
||||||
|
|
||||||
|
To review several changes and apply them together, as one entry in the
|
||||||
|
history, stage them in a changeset:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
export HOUSECARL_CHANGESET=$(hc changeset create "guest network" -q)
|
||||||
|
hc zone add guest -i eth1.30
|
||||||
|
hc rule add forward --in guest --out wan
|
||||||
|
hc changeset diff # the operations, problems, nftables' check, the ruleset's diff
|
||||||
|
hc changeset remove 2 # take operation 2 out
|
||||||
|
hc changeset apply # one change; over the network on probation
|
||||||
|
unset HOUSECARL_CHANGESET
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
$ hc rule add forward --in guest --out wan
|
||||||
|
changeset 7 (guest network, 1 operation) from HOUSECARL_CHANGESET
|
||||||
|
staged: create rule forward #52
|
||||||
|
staged in changeset 7 as operation 2 (2 operations now); review with 'hc changeset diff 7', apply with 'hc changeset apply 7'
|
||||||
|
```
|
||||||
|
|
||||||
|
Each change is checked as it is staged: the changeset with it must stay
|
||||||
|
valid, and nftables must accept the ruleset, so make a set before the
|
||||||
|
rule that names it. Applying checks the whole changeset again, on the
|
||||||
|
configuration as it is then; an operation whose object changed since is
|
||||||
|
named (`operation 3 (update rule input #12) can't be applied: …`), and
|
||||||
|
`hc changeset remove` takes it out.
|
||||||
|
|
||||||
|
| Command | |
|
||||||
|
|---|---|
|
||||||
|
| `changeset create [DESCRIPTION]` | start one; `-q` prints only its ID |
|
||||||
|
| `changeset list [--status open\|applied\|discarded\|""] [--mine]` | open ones by default |
|
||||||
|
| `changeset show [ID]` | its operations |
|
||||||
|
| `changeset diff [ID]` | what applying it would do |
|
||||||
|
| `changeset apply [ID] [--description D]` | apply it |
|
||||||
|
| `changeset discard [ID]` | throw it away |
|
||||||
|
| `changeset remove [ID] OP` | take one operation out |
|
||||||
|
|
||||||
|
`show`, `diff`, `apply`, `discard` and `remove` work on the changeset you
|
||||||
|
name, else the one in `-C`, else the one in `HOUSECARL_CHANGESET`.
|
||||||
|
|
||||||
|
A changeset from `HOUSECARL_CHANGESET` is checked before anything uses
|
||||||
|
it, because an exported ID easily outlives its changeset:
|
||||||
|
|
||||||
|
- Staging into it, and `apply`, `discard` and `remove` on it, need it to
|
||||||
|
be open and yours. Otherwise the command stops and says why (`there is
|
||||||
|
no changeset 99`, `changeset 42 is applied`, `changeset 57 is sam's`).
|
||||||
|
Commands that only read, and `hc changeset create`, don't care.
|
||||||
|
- Each command says on stderr which changeset it uses: `changeset 42
|
||||||
|
(guest network, 2 operations) from HOUSECARL_CHANGESET` (not with `-q`).
|
||||||
|
- After `apply` or `discard`, a reminder to `unset HOUSECARL_CHANGESET`.
|
||||||
|
|
||||||
|
`-C ID` and an ID argument are used as given (housecarld still refuses a
|
||||||
|
changeset that isn't open, or another user's unless you are an
|
||||||
|
administrator). Only configuration changes are staged: dynamic set
|
||||||
|
entries, `confirm` and `rollback` are made at once; `history undo` is
|
||||||
|
staged like any change; `import` and `takeover` can't be staged, so they
|
||||||
|
are refused while a changeset is named. Changesets staged in the web UI show up here too,
|
||||||
|
and the other way round.
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
@@ -72,6 +135,7 @@ by name.
|
|||||||
| `interfaces` | the machine's interfaces, their addresses and zones |
|
| `interfaces` | the machine's interfaces, their addresses and zones |
|
||||||
| `ruleset` | the nftables ruleset Housecarl builds |
|
| `ruleset` | the nftables ruleset Housecarl builds |
|
||||||
| `counters` | the hits of every rule, NAT rule, built-in and policy, by mark (`r12`, `f3`, `b-established`, `p-input`) |
|
| `counters` | the hits of every rule, NAT rule, built-in and policy, by mark (`r12`, `f3`, `b-established`, `p-input`) |
|
||||||
|
| `quotas` | what each rule's quota used (each address's, for per-address quotas), and when it resets |
|
||||||
| `log [-f] [--search TEXT] [--limit N]` | the packets logged by rules and policies ([operations.md](operations.md#the-log)) |
|
| `log [-f] [--search TEXT] [--limit N]` | the packets logged by rules and policies ([operations.md](operations.md#the-log)) |
|
||||||
|
|
||||||
### zone, set, service
|
### zone, set, service
|
||||||
@@ -103,6 +167,7 @@ See [rules.md](rules.md).
|
|||||||
| `rule update ID [flags]` | only the flags given |
|
| `rule update ID [flags]` | only the flags given |
|
||||||
| `rule move ID --before ID\|--after ID\|--first\|--last\|--chain CHAIN` | |
|
| `rule move ID --before ID\|--after ID\|--first\|--last\|--chain CHAIN` | |
|
||||||
| `rule enable\|disable ID` | |
|
| `rule enable\|disable ID` | |
|
||||||
|
| `rule reset-quota ID` | start a rule's quota again now |
|
||||||
| `rule delete ID` | |
|
| `rule delete ID` | |
|
||||||
|
|
||||||
The match flags (`rule add`, `snat add`, `redirect add`):
|
The match flags (`rule add`, `snat add`, `redirect add`):
|
||||||
@@ -115,7 +180,9 @@ The match flags (`rule add`, `snat add`, `redirect add`):
|
|||||||
| `--family ipv4\|ipv6` | |
|
| `--family ipv4\|ipv6` | |
|
||||||
|
|
||||||
And for rules: `--state`, `--status`, `--days`, `--hours`, `--limit
|
And for rules: `--state`, `--status`, `--days`, `--hours`, `--limit
|
||||||
10/minute[,burst=N][,over][,per-source]`, `--conn-limit N`, `-a
|
10/minute|1mbytes/second[,burst=N][,over][,per-source|,per-destination]`,
|
||||||
|
`--quota 50gbytes[,over][,per-source|,per-destination][,daily|,weekly|,monthly=DAY]`,
|
||||||
|
`--group-v4 N`, `--group-v6 N`, `--conn-limit N`, `-a
|
||||||
accept|drop|reject|log`, `--reject-with tcp-reset`, `--log`,
|
accept|drop|reject|log`, `--reject-with tcp-reset`, `--log`,
|
||||||
`--log-prefix`, `--raw RULE`, `--comment`, `--disabled`
|
`--log-prefix`, `--raw RULE`, `--comment`, `--disabled`
|
||||||
([rules.md](rules.md#rules)).
|
([rules.md](rules.md#rules)).
|
||||||
@@ -137,7 +204,7 @@ See [nat.md](nat.md).
|
|||||||
| Command | |
|
| Command | |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `settings show` | |
|
| `settings show` | |
|
||||||
| `settings set [--input-policy\|--forward-policy\|--output-policy accept\|drop] [--log-drops on\|off] [--builtin NAME=on\|off]... [--own-prefix P]... [--antispoof-v4 P]... [--antispoof-v6 P]... [--flowtable DEV]... [--probation 2m]` | lists given replace the old ones |
|
| `settings set [--input-policy\|--forward-policy\|--output-policy accept\|drop] [--log-drops on\|off] [--builtin NAME=on\|off]... [--own-prefix P]... [--antispoof-v4 P]... [--antispoof-v6 P]... [--flowtable DEV]... [--probation 2m] [--auto-confirm on\|off]` | lists given replace the old ones |
|
||||||
|
|
||||||
### probation, history, import
|
### probation, history, import
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ Let it get its own certificate through Jarl ([certificates.md](certificates.md))
|
|||||||
or give it one:
|
or give it one:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
listen: ["10.0.10.1:8070"]
|
listen: ["192.168.1.1:8070"]
|
||||||
tls:
|
tls:
|
||||||
cert_file: /etc/housecarl/tls/fullchain.pem
|
cert_file: /etc/housecarl/tls/fullchain.pem
|
||||||
key_file: /etc/housecarl/tls/privkey.pem
|
key_file: /etc/housecarl/tls/privkey.pem
|
||||||
|
|||||||
+44
-1
@@ -50,6 +50,35 @@ Every change goes through one engine (`internal/engine`):
|
|||||||
Dry runs, history with undo, users, sessions and scoped API tokens come
|
Dry runs, history with undo, users, sessions and scoped API tokens come
|
||||||
from the same machinery as in Jarl and Steward.
|
from the same machinery as in Jarl and Steward.
|
||||||
|
|
||||||
|
## Reviewing changes: changesets
|
||||||
|
|
||||||
|
As in Jarl, a change can be made at once or staged in a **changeset**,
|
||||||
|
to be reviewed and applied together with others:
|
||||||
|
|
||||||
|
* Each change is an **operation** (create, update, delete or move an
|
||||||
|
object, or change the settings), kept as the API received it.
|
||||||
|
* Staged in a changeset, an operation is checked as it is added: the
|
||||||
|
changeset with it must still be valid, and nftables must accept the
|
||||||
|
ruleset it makes. Nothing is applied.
|
||||||
|
* The **review** shows the operations, the problems, and the **diff of
|
||||||
|
the ruleset**: the file that is loaded at boot, now and after.
|
||||||
|
* **Applying** replays the operations on the configuration as it is then,
|
||||||
|
as one change: one check, one transaction, one history entry, undone as
|
||||||
|
a whole. An operation that no longer applies (its object was changed or
|
||||||
|
deleted since) is named, and can be taken out.
|
||||||
|
* Then the applied changeset is **on probation** like any change made over
|
||||||
|
the network: unless it is confirmed, it is rolled back.
|
||||||
|
|
||||||
|
| Interface | Default | Why |
|
||||||
|
|---|---|---|
|
||||||
|
| Web UI | Review edits: they are collected in a draft changeset, pages show pending rows (green new, yellow changed, struck out deleted), and a bar leads to the Changes page, with the diff and Apply. The switch in the header turns on Live edits, applied at once. | Several related edits, seen together before they are in force. |
|
||||||
|
| CLI | Immediate: each command is a change of its own. Stage with `-C ID` or `HOUSECARL_CHANGESET`. | Scripts want a clear result per step. |
|
||||||
|
| API | Immediate unless a changeset is named (`?changeset=ID`, `X-Housecarl-Changeset`). | As the CLI. |
|
||||||
|
|
||||||
|
An undo is staged like any edit (in review mode, the web UI's Undo
|
||||||
|
collects it for review). Imports and takeovers can't be staged; with a
|
||||||
|
changeset named, they are refused rather than applied.
|
||||||
|
|
||||||
## Its own table only
|
## Its own table only
|
||||||
|
|
||||||
Housecarl writes one table, `inet housecarl`, and leaves every other
|
Housecarl writes one table, `inet housecarl`, and leaves every other
|
||||||
@@ -73,11 +102,25 @@ and log lines are traced back to their object through it.
|
|||||||
|
|
||||||
## Not locking yourself out
|
## Not locking yourself out
|
||||||
|
|
||||||
A change made over the network is applied **on probation**: unless it is
|
A change made over the network is applied **on probation** (a changeset
|
||||||
|
when it is applied, a live edit at once): unless it is
|
||||||
confirmed within two minutes (Settings; `hc confirm`, the banner in the
|
confirmed within two minutes (Settings; `hc confirm`, the banner in the
|
||||||
web UI, `POST /confirm`), it is rolled back by itself, and the rollback is
|
web UI, `POST /confirm`), it is rolled back by itself, and the rollback is
|
||||||
recorded in the history. A change that cuts you off undoes itself.
|
recorded in the history. A change that cuts you off undoes itself.
|
||||||
|
|
||||||
|
* **The client that made a change confirms it by itself** once it
|
||||||
|
reaches the API again: the change's answer carries a one-time token and
|
||||||
|
closes its connection (HTTP/2: a GOAWAY), and the web UI and `hc` send
|
||||||
|
the token straight back. That proves something only on a new
|
||||||
|
connection, since the built-in that accepts established connections
|
||||||
|
would let the old one through whatever the change did. So the token is
|
||||||
|
accepted only from the same user (and API token), the same address, a
|
||||||
|
different connection, and for the newest change. A change that cuts its
|
||||||
|
client off can't be confirmed by it, and rolls back. This proves the
|
||||||
|
API is still reachable, not that SSH or the LAN still work; with the
|
||||||
|
API, you can fix those. Settings → "a person confirms each change"
|
||||||
|
(`hc settings set --auto-confirm off`) goes back to confirming by hand;
|
||||||
|
`hc --no-confirm` leaves one change waiting.
|
||||||
* Changes made through the local socket (`sudo hc …` on the firewall)
|
* Changes made through the local socket (`sudo hc …` on the firewall)
|
||||||
need no confirmation: whoever makes them is already on the machine. One
|
need no confirmation: whoever makes them is already on the machine. One
|
||||||
made while others are pending joins them.
|
made while others are pending joins them.
|
||||||
|
|||||||
+26
-2
@@ -19,7 +19,7 @@ services, rules, port forwards and NAT, then **notes** on every guess it
|
|||||||
made, and the rules it **kept raw**.
|
made, and the rules it **kept raw**.
|
||||||
|
|
||||||
`--defines` reads your ruleset file's `define` lines, so sets get your
|
`--defines` reads your ruleset file's `define` lines, so sets get your
|
||||||
names (`define trusted_ips = { ... }` becomes the set `trusted_ips`).
|
names (`define admins = { ... }` becomes the set `admins`).
|
||||||
Without it, sets are named after what they hold. Interfaces named by a
|
Without it, sets are named after what they hold. Interfaces named by a
|
||||||
define name their zone too (`define wan_interface = "ppp0"` makes the
|
define name their zone too (`define wan_interface = "ppp0"` makes the
|
||||||
zone `wan`); otherwise a zone is named after its interface.
|
zone `wan`); otherwise a zone is named after its interface.
|
||||||
@@ -57,8 +57,24 @@ Import a ruleset.
|
|||||||
become part of the port forward, and a masquerade for inside sources
|
become part of the port forward, and a masquerade for inside sources
|
||||||
to the same host becomes its hairpin. An IPv6 accept to the same port
|
to the same host becomes its hairpin. An IPv6 accept to the same port
|
||||||
becomes the forward's IPv6 pinhole when there is only one candidate.
|
becomes the forward's IPv6 pinhole when there is only one candidate.
|
||||||
|
* **Maps are expanded**, one element at a time. A DNAT map
|
||||||
|
(`dnat ip to tcp dport map @forwards`, named or written in the rule)
|
||||||
|
becomes one port forward per element; forwards from a named map are
|
||||||
|
called after it (`forwards-443`). A verdict map
|
||||||
|
(`tcp dport vmap { 22 : accept, 23 : drop }`) becomes one rule per
|
||||||
|
element, in place, with `jump`s inlined as above. A lookup matches at
|
||||||
|
most one element, so the rules do the same. A map keyed by several
|
||||||
|
fields (`ip saddr . tcp dport`) can't be expanded: a rule using a named
|
||||||
|
one is left out with a note, to be added by hand.
|
||||||
* **Masquerade and SNAT** become source NAT; **redirect** becomes a
|
* **Masquerade and SNAT** become source NAT; **redirect** becomes a
|
||||||
redirect.
|
redirect.
|
||||||
|
* **Meters** (`update @m { ip saddr limit rate over 3/minute }`,
|
||||||
|
`add @m { ip saddr ct count over 20 }`, quotas in sets) become the
|
||||||
|
rule's per-source (or per-destination) limit, connection limit or quota;
|
||||||
|
a mask in the key (`ip saddr and 255.255.255.0`) becomes its grouping.
|
||||||
|
Byte rates and quotas are imported too; a quota imported resets only by
|
||||||
|
hand until you give it a period. A rule with a quota or a byte rate that
|
||||||
|
accepts stays a raw rule ([rules.md](rules.md#bandwidth-and-quotas)).
|
||||||
* **Policies** become the chains' policies.
|
* **Policies** become the chains' policies.
|
||||||
* **Anything else** becomes a raw rule with its original text, in place.
|
* **Anything else** becomes a raw rule with its original text, in place.
|
||||||
A raw rule naming a set of the old table needs the new set name:
|
A raw rule naming a set of the old table needs the new set name:
|
||||||
@@ -91,7 +107,10 @@ the old table:
|
|||||||
sudo hc takeover "inet main"
|
sudo hc takeover "inet main"
|
||||||
```
|
```
|
||||||
|
|
||||||
From now on only Housecarl's rules apply. Over the network, this is on
|
From now on only Housecarl's rules apply. Until you do, the dashboard
|
||||||
|
and `hc status` warn that the imported table still applies
|
||||||
|
(`takeover_pending`); they also warn while `nftables.service` is enabled
|
||||||
|
(`nftables_service`). Over the network, this is on
|
||||||
probation too: unless confirmed, the old table comes back.
|
probation too: unless confirmed, the old table comes back.
|
||||||
|
|
||||||
## 4. The boot
|
## 4. The boot
|
||||||
@@ -106,6 +125,11 @@ sudo systemctl disable nftables.service
|
|||||||
sudo mv /etc/nftables.conf /etc/nftables.conf.before-housecarl
|
sudo mv /etc/nftables.conf /etc/nftables.conf.before-housecarl
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Disable it, but **don't stop it** (no `systemctl stop nftables`, no
|
||||||
|
`disable --now`): Debian's unit stops by running `nft flush ruleset`,
|
||||||
|
which deletes every table, Housecarl's and Docker's included, at once.
|
||||||
|
Disabled, it simply doesn't run at the next boot.
|
||||||
|
|
||||||
`housecarl-ruleset.service` runs after `nftables.service` in any case,
|
`housecarl-ruleset.service` runs after `nftables.service` in any case,
|
||||||
so a leftover `flush ruleset` can't undo it. Docker, Incus and libvirt
|
so a leftover `flush ruleset` can't undo it. Docker, Incus and libvirt
|
||||||
keep adding their own tables as before.
|
keep adding their own tables as before.
|
||||||
|
|||||||
+3
-3
@@ -90,15 +90,15 @@ you are changing the firewall you are connected through.
|
|||||||
policies drop what no rule accepts, so let yourself in first:
|
policies drop what no rule accepts, so let yourself in first:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
sudo hc set add admins 10.0.10.0/24
|
sudo hc set add admins 192.168.1.0/24
|
||||||
sudo hc rule add input --from admins --service ssh
|
sudo hc rule add input --from admins --service ssh
|
||||||
sudo hc rule add input --from admins --service tcp/8070 --comment "Housecarl"
|
sudo hc rule add input --from admins --service tcp/8070 --comment "Housecarl"
|
||||||
sudo hc zone add wan --interface ppp0 --external
|
sudo hc zone add wan --interface ppp0 --external
|
||||||
sudo hc zone add lan --interface enp2s0
|
sudo hc zone add lan --interface eth1
|
||||||
sudo hc rule add input --in lan --service dns --service dhcp
|
sudo hc rule add input --in lan --service dns --service dhcp
|
||||||
sudo hc rule add forward --in lan --out wan
|
sudo hc rule add forward --in lan --out wan
|
||||||
sudo hc snat add --out wan
|
sudo hc snat add --out wan
|
||||||
sudo hc settings set --builtin mss_clamp=on --own-prefix 2001:db8:7f3d::/48
|
sudo hc settings set --builtin mss_clamp=on --own-prefix 2001:db8::/48
|
||||||
sudo hc status
|
sudo hc status
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+7
-7
@@ -8,9 +8,9 @@ One object does what takes three rules by hand (the DNAT, the forward
|
|||||||
accept, and the hairpin masquerade).
|
accept, and the hairpin masquerade).
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
hc forward add 8096 --to 10.0.10.57:443 --public 203.0.113.7 --hairpin --name jellyfin
|
hc forward add 8443 --to 192.168.1.10:443 --public 203.0.113.10 --hairpin --name web
|
||||||
hc forward add 7777-7780 --proto tcp,udp --to 10.0.10.76 --public 203.0.113.7 --hairpin --name terraria
|
hc forward add 27015-27030 --proto tcp,udp --to 192.168.1.20 --public 203.0.113.10 --hairpin --name game
|
||||||
hc forward add 25 --to 10.0.10.25 --from mail-relays --name smtp
|
hc forward add 25 --to 192.168.1.25 --from mail-relays --name smtp
|
||||||
hc forward list
|
hc forward list
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -44,10 +44,10 @@ IPv6 needs no translation: inside hosts have public addresses. `--to6`
|
|||||||
opens the same port (or the `--to` port, if given) to an IPv6 host:
|
opens the same port (or the `--to` port, if given) to an IPv6 host:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
hc forward add 8096 --to 10.0.10.57:443 --to6 2001:db8:7f3d:10::57 --public 203.0.113.7 --name jellyfin
|
hc forward add 8443 --to 192.168.1.10:443 --to6 2001:db8:1::10 --public 203.0.113.10 --name web
|
||||||
```
|
```
|
||||||
|
|
||||||
IPv6 clients connect to `[2001:db8:7f3d:10::57]:443` directly. A forward
|
IPv6 clients connect to `[2001:db8:1::10]:443` directly. A forward
|
||||||
can have only `--to6`, to open an IPv6 port alone.
|
can have only `--to6`, to open an IPv6 port alone.
|
||||||
|
|
||||||
## Source NAT
|
## Source NAT
|
||||||
@@ -60,7 +60,7 @@ service; the first that matches decides.
|
|||||||
```sh
|
```sh
|
||||||
hc snat add --out wan --from trusted --comment "the LAN to the internet"
|
hc snat add --out wan --from trusted --comment "the LAN to the internet"
|
||||||
hc snat add --out wg0 --not-out --from wg-clients
|
hc snat add --out wg0 --not-out --from wg-clients
|
||||||
hc snat add --out wan --from fd00:abab::/48 --family ipv6 --snat-to 2001:db8:7f3d::1
|
hc snat add --out wan --from fd00:1::/48 --family ipv6 --snat-to 2001:db8::1
|
||||||
```
|
```
|
||||||
|
|
||||||
Source NAT is IPv4 only unless `--family ipv6` says otherwise: with a
|
Source NAT is IPv4 only unless `--family ipv6` says otherwise: with a
|
||||||
@@ -73,7 +73,7 @@ A redirect sends matching traffic to a port on the firewall itself, for
|
|||||||
example to make every client use the firewall's DNS server:
|
example to make every client use the firewall's DNS server:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
hc redirect add --in lan --service dns --not-to 10.0.10.5 --comment "force DNS"
|
hc redirect add --in lan --service dns --not-to 192.168.1.5 --comment "force DNS"
|
||||||
hc redirect add --in lan --service http --to-port 3128
|
hc redirect add --in lan --service http --to-port 3128
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+53
-7
@@ -17,10 +17,36 @@ housecarld logs each change it applies, rollbacks, and what it couldn't
|
|||||||
apply. When it starts, it applies the configuration again (rolling back
|
apply. When it starts, it applies the configuration again (rolling back
|
||||||
first what was on probation when it stopped).
|
first what was on probation when it stopped).
|
||||||
|
|
||||||
|
## Reviewing before applying
|
||||||
|
|
||||||
|
In the web UI, edits are collected as pending changes (the bar under the
|
||||||
|
header), and Changes shows them with the diff of the ruleset; Apply puts
|
||||||
|
them in force together. The switch in the header (Review edits / Live
|
||||||
|
edits) applies each edit at once instead. With hc, see
|
||||||
|
[cli.md](cli.md#changesets).
|
||||||
|
|
||||||
## Changes on probation
|
## Changes on probation
|
||||||
|
|
||||||
Changes made over the network roll back unless confirmed
|
Changes made over the network roll back unless confirmed
|
||||||
([design.md](design.md#not-locking-yourself-out)):
|
([design.md](design.md#not-locking-yourself-out)). The web UI and `hc`
|
||||||
|
confirm their own changes once they reach Housecarl again over a new
|
||||||
|
connection, so normally nothing waits:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ hc rule add input --from admins --service ssh
|
||||||
|
created rule input #31
|
||||||
|
applied and confirmed (history 12): this client reached Housecarl again over a new connection
|
||||||
|
```
|
||||||
|
|
||||||
|
A change that cuts the client off can't be confirmed that way:
|
||||||
|
|
||||||
|
```
|
||||||
|
applied on probation (history 13), but couldn't confirm it: … context deadline exceeded
|
||||||
|
It is rolled back within 1m50s unless confirmed ('hc confirm')
|
||||||
|
```
|
||||||
|
|
||||||
|
By hand (with `--no-confirm`, or with Settings → "a person confirms each
|
||||||
|
change"):
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
hc status # Probation: 2 changes roll back in 97s unless confirmed
|
hc status # Probation: 2 changes roll back in 97s unless confirmed
|
||||||
@@ -54,10 +80,21 @@ sudo nft -c -f /var/lib/housecarl/ruleset.nft # check it
|
|||||||
|
|
||||||
Dynamic sets start empty after a reboot.
|
Dynamic sets start empty after a reboot.
|
||||||
|
|
||||||
Keep Debian's `nftables.service` disabled, or `/etc/nftables.conf`
|
Keep Debian's `nftables.service` disabled (`systemctl disable
|
||||||
without `flush ruleset`: otherwise it deletes Housecarl's table at
|
nftables`, without `--now`): at boot it loads `/etc/nftables.conf`, and
|
||||||
boot. `housecarl-ruleset.service` runs after it, which covers the
|
stopping or restarting it runs `nft flush ruleset`, which deletes every
|
||||||
ordinary case.
|
table, Housecarl's included. `housecarl-ruleset.service` runs after it
|
||||||
|
at boot, which covers the ordinary case, but not a restart of
|
||||||
|
`nftables.service` later. Housecarl never reads or writes
|
||||||
|
`/etc/nftables.conf`; its ruleset is saved in
|
||||||
|
`/var/lib/housecarl/ruleset.nft`. The dashboard and `hc status` warn
|
||||||
|
while `nftables.service` is enabled.
|
||||||
|
|
||||||
|
Other programs' tables are left alone: fail2ban's (`inet f2b-table`),
|
||||||
|
Docker's, Incus's. Housecarl replaces only its own table, so they keep
|
||||||
|
their rules through every Housecarl change and restart, and nothing
|
||||||
|
needs restarting in a particular order. A packet must get past every
|
||||||
|
table on its hook, so a drop in any of them wins.
|
||||||
|
|
||||||
## Hits
|
## Hits
|
||||||
|
|
||||||
@@ -66,6 +103,15 @@ Every rule, NAT rule, built-in and policy has a counter. `hc rule list`,
|
|||||||
all, with bytes. They count from housecarld's start, and carry across
|
all, with bytes. They count from housecarld's start, and carry across
|
||||||
changes to the ruleset.
|
changes to the ruleset.
|
||||||
|
|
||||||
|
## Quotas
|
||||||
|
|
||||||
|
`hc quotas` (and the rules page) show what each quota used and when it
|
||||||
|
resets; `hc rule reset-quota ID` starts one again. housecarld resets
|
||||||
|
quotas at midnight when their period ends (late, if it was stopped then),
|
||||||
|
and saves their usage every five minutes and when it stops; after a
|
||||||
|
reboot it puts it back. A reboot without a clean stop loses at most the
|
||||||
|
last five minutes' count.
|
||||||
|
|
||||||
## The log
|
## The log
|
||||||
|
|
||||||
Rules with `--log` (Log in the web UI) and, with `hc settings set
|
Rules with `--log` (Log in the web UI) and, with `hc settings set
|
||||||
@@ -78,8 +124,8 @@ hc log --search 198.51.100.7
|
|||||||
```
|
```
|
||||||
|
|
||||||
```
|
```
|
||||||
10:02:13 hc input #95 drop: wan - 198.51.100.7:53868 -> 203.0.113.7:22 tcp syn
|
10:02:13 hc input #95 drop: wan - 198.51.100.7:53868 -> 203.0.113.10:22 tcp syn
|
||||||
10:02:15 hc forward policy drop: wan enp2s0 198.51.100.9:443 -> 10.0.10.20:51432 tcp
|
10:02:15 hc forward policy drop: wan eth1 198.51.100.9:443 -> 192.168.1.20:51432 tcp
|
||||||
```
|
```
|
||||||
|
|
||||||
The packets go to netlink log group 4171, where housecarld reads them;
|
The packets go to netlink log group 4171, where housecarld reads them;
|
||||||
|
|||||||
+3
-2
@@ -7,7 +7,7 @@ else gets **grants**, `resource:access`:
|
|||||||
|
|
||||||
| Resource | read | write |
|
| Resource | read | write |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `config` | settings, zones, sets, services, rules, NAT, the ruleset, history, interfaces, probation (the web UI shows the forms locked) | change them, undo, import, confirm and roll back |
|
| `config` | settings, zones, sets, services, rules, NAT, the ruleset, history, interfaces, probation, changesets (the web UI shows the forms locked) | change them, stage and apply changesets (your own, or anyone's for administrators), undo, import, confirm and roll back |
|
||||||
| `sets` | dynamic sets' entries | add and remove entries |
|
| `sets` | dynamic sets' entries | add and remove entries |
|
||||||
| `log` | hit counters and the log | – |
|
| `log` | hit counters and the log | – |
|
||||||
| `audit` | the audit log | – |
|
| `audit` | the audit log | – |
|
||||||
@@ -81,6 +81,7 @@ on probation like any other.
|
|||||||
## The audit log
|
## The audit log
|
||||||
|
|
||||||
Logins (and failed ones), users, tokens, every configuration change (with
|
Logins (and failed ones), users, tokens, every configuration change (with
|
||||||
its history entry), confirmations and rollbacks, imports, takeovers and
|
its history entry), changesets created, applied and discarded,
|
||||||
|
confirmations and rollbacks, imports, takeovers and
|
||||||
dynamic set entries are recorded with who, from where and how: `session`,
|
dynamic set entries are recorded with who, from where and how: `session`,
|
||||||
`token` or `socket`.
|
`token` or `socket`.
|
||||||
+74
-15
@@ -3,12 +3,12 @@
|
|||||||
## Zones
|
## Zones
|
||||||
|
|
||||||
A zone names one or more interfaces, so rules can say "from the LAN"
|
A zone names one or more interfaces, so rules can say "from the LAN"
|
||||||
rather than `enp2s0`. An interface belongs to one zone at most. A trailing
|
rather than `eth1`. An interface belongs to one zone at most. A trailing
|
||||||
`*` matches many: `incusbr*`.
|
`*` matches many: `incusbr*`.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
hc zone add wan --interface wan --external
|
hc zone add wan --interface wan --external
|
||||||
hc zone add lan --interface enp2s0 --interface 'enp2s0.*'
|
hc zone add lan --interface eth1 --interface 'eth1.*'
|
||||||
hc zone add wg --interface wg0
|
hc zone add wg --interface wg0
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -27,23 +27,26 @@ A set is a named list of IPv4 and IPv6 addresses, prefixes and ranges,
|
|||||||
mixed:
|
mixed:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
hc set add trusted 10.0.10.0/24 10.20.30.0/24 2001:db8:7f3d:10::/64
|
hc set add trusted 192.168.1.0/24 192.168.2.0/24 2001:db8:1::/64
|
||||||
hc set add dns-servers 10.0.10.5 2001:db8:7f3d:10::5 10.0.10.250-10.0.10.254
|
hc set add dns-servers 192.168.1.5 2001:db8:1::5 192.168.1.250-192.168.1.254
|
||||||
```
|
```
|
||||||
|
|
||||||
In the ruleset each becomes two nftables sets, `trusted_v4` and
|
In the ruleset each becomes two nftables sets, `trusted_v4` and
|
||||||
`trusted_v6` (one if the set has only one family). A rule that names it
|
`trusted_v6` (one if the set has only one family). A rule that names it
|
||||||
becomes one nftables rule per family. Rules can also give addresses
|
becomes one nftables rule per family. Rules can also give addresses
|
||||||
directly (`--from 10.0.10.3`); a set is better when several rules share
|
directly (`--from 192.168.1.3`); a set is better when several rules share
|
||||||
them.
|
them.
|
||||||
|
|
||||||
A **dynamic** set's entries are added at run time, each with a timeout,
|
A **dynamic** set's entries are added at run time, each with a timeout,
|
||||||
and nftables removes them when it runs out, even with housecarld stopped:
|
and nftables removes them when it runs out, even with housecarld stopped.
|
||||||
|
It has no configured entries (validation refuses them); its timeout is
|
||||||
|
the default for entries added without one, and without a timeout they
|
||||||
|
stay until removed:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
hc set add guests --dynamic --timeout 24h
|
hc set add guests --dynamic --timeout 24h
|
||||||
hc set entries add guests 10.0.30.17 # for 24 hours
|
hc set entries add guests 10.0.30.17 # for 24 hours
|
||||||
hc set entries add guests 2001:db8:7f3d:30::17 --timeout 2h
|
hc set entries add guests 2001:db8:3::17 --timeout 2h
|
||||||
hc set entries guests # with the time left
|
hc set entries guests # with the time left
|
||||||
hc set entries remove guests 10.0.30.17
|
hc set entries remove guests 10.0.30.17
|
||||||
```
|
```
|
||||||
@@ -60,7 +63,7 @@ A service names ports of a protocol (`tcp`, `udp`, `sctp`) and ICMP
|
|||||||
types:
|
types:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
hc service add jellyfin tcp/8096 tcp/8920
|
hc service add web tcp/8080 tcp/8443
|
||||||
hc service add games udp/27015-27030 tcp/27015
|
hc service add games udp/27015-27030 tcp/27015
|
||||||
hc service add pmtu icmp/destination-unreachable icmpv6/packet-too-big
|
hc service add pmtu icmp/destination-unreachable icmpv6/packet-too-big
|
||||||
```
|
```
|
||||||
@@ -108,7 +111,8 @@ A rule matches on what it gives, and all of it:
|
|||||||
| `--state` | connection states: `new`, `established`, `related`, `invalid`, `untracked` |
|
| `--state` | connection states: `new`, `established`, `related`, `invalid`, `untracked` |
|
||||||
| `--status` | connection status: `dnat` (port forwarded), `snat` |
|
| `--status` | connection status: `dnat` (port forwarded), `snat` |
|
||||||
| `--days`, `--hours` | a time window: `--days Monday,Friday --hours 08:00-17:30`; `22:00-06:00` passes midnight. In the firewall's time zone, as the kernel knows it |
|
| `--days`, `--hours` | a time window: `--days Monday,Friday --hours 08:00-17:30`; `22:00-06:00` passes midnight. In the firewall's time zone, as the kernel knows it |
|
||||||
| `--limit` | a rate: `10/minute`, with `,burst=N`, `,over` (match beyond the rate, to drop a flood) and `,per-source` (a rate for each source address) |
|
| `--limit` | a rate: `10/minute`, or bytes: `1mbytes/second`; with `,burst=N`, `,over` (match beyond the rate) and `,per-source` or `,per-destination` ([Bandwidth and quotas](#bandwidth-and-quotas)) |
|
||||||
|
| `--quota` | an allowance of traffic: `50gbytes,over,per-source,monthly=15` ([Bandwidth and quotas](#bandwidth-and-quotas)) |
|
||||||
| `--conn-limit N` | a source with more than N connections |
|
| `--conn-limit N` | a source with more than N connections |
|
||||||
|
|
||||||
The action is `accept`, `drop`, `reject` (an ICMP error, or with
|
The action is `accept`, `drop`, `reject` (an ICMP error, or with
|
||||||
@@ -131,6 +135,58 @@ hc rule add forward --in wan --status dnat
|
|||||||
hc rule add forward --in wan -a drop
|
hc rule add forward --in wan -a drop
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Bandwidth and quotas
|
||||||
|
|
||||||
|
A **byte rate** limits bandwidth: `--limit 20mbytes/second,over,per-source
|
||||||
|
-a drop` drops what a device sends beyond 20 MB/s. Units are `bytes`,
|
||||||
|
`kbytes` and `mbytes` (`kb`, `mb` for short); the burst is in the same
|
||||||
|
unit. nftables can only hold byte rates per second (up to 4 GB/s) or per
|
||||||
|
minute (up to 293 MB/minute); validation says so for larger ones. This is
|
||||||
|
policing: traffic beyond the rate is dropped, not queued, so TCP slows to
|
||||||
|
fit, and UDP loses packets. Shaping (queues, fair shares) is `tc`'s work,
|
||||||
|
not a firewall's.
|
||||||
|
|
||||||
|
A **quota** is an allowance of traffic. `over` matches once it is used up,
|
||||||
|
to drop what comes after:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Each device on the LAN: 50 GB a month, from the 15th (a billing day)
|
||||||
|
hc rule add forward --in lan --out wan --quota 50gbytes,over,per-source,monthly=15 -a drop
|
||||||
|
# The whole guest network: 10 GB a day together
|
||||||
|
hc rule add forward --in guest --out wan --quota 10gbytes,over,daily -a drop
|
||||||
|
hc quotas # what each used, and the next reset
|
||||||
|
hc rule reset-quota 31 # start again now
|
||||||
|
```
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| `per-source`, `per-destination` | each address its own allowance; without, one allowance for everything the rule matches |
|
||||||
|
| `daily`, `weekly`, `monthly=DAY` | when it starts again: at midnight; weekly on Mondays; monthly on that day (the last day of a shorter month). Without one, only by hand |
|
||||||
|
|
||||||
|
nftables counts a quota but never resets it, and forgets it when its
|
||||||
|
table is replaced: Housecarl carries what each quota used across every
|
||||||
|
change and restart, saves it every five minutes (and when housecarld
|
||||||
|
stops) for after a reboot, and resets quotas when their period ends.
|
||||||
|
A per-address quota counts an address: a device that gets a new one
|
||||||
|
starts again. With Steward's reservations IPv4 addresses stay; IPv6 is
|
||||||
|
grouped by /64 (below).
|
||||||
|
|
||||||
|
**Rules with a quota or a byte rate count every packet.** The built-in
|
||||||
|
that accepts established connections comes before your rules, so a rule
|
||||||
|
there only sees each connection's first packet. Rules that count traffic
|
||||||
|
are checked before it instead, in their order (the web UI lists them
|
||||||
|
first, marked "every packet"). So they can drop, reject or log, but not
|
||||||
|
accept: an accept there would let packets past every later rule. Let the
|
||||||
|
traffic in with a rule of its own. The flowtable's fast path skips the
|
||||||
|
forward chain for established connections, so with it on, forward
|
||||||
|
quotas and byte rates only count first packets (a warning says so).
|
||||||
|
|
||||||
|
**Per-address limits group addresses**: each IPv4 address on its own, and
|
||||||
|
IPv6 addresses by /64, since one device uses several (privacy addresses
|
||||||
|
change). `--group-v4 24` counts each /24 together; `--group-v6 128` each
|
||||||
|
IPv6 address alone. This applies to per-address rates, connection limits
|
||||||
|
and quotas.
|
||||||
|
|
||||||
### Raw rules
|
### Raw rules
|
||||||
|
|
||||||
For what Housecarl can't say, a rule can be one nftables rule written by
|
For what Housecarl can't say, a rule can be one nftables rule written by
|
||||||
@@ -140,8 +196,11 @@ hand, placed like any other:
|
|||||||
hc rule add forward --raw 'iifname != "wan" ct state invalid ip daddr @trusted_v4 accept'
|
hc rule add forward --raw 'iifname != "wan" ct state invalid ip daddr @trusted_v4 accept'
|
||||||
```
|
```
|
||||||
|
|
||||||
It is checked by nftables with the rest of the ruleset, and can use the
|
It is used exactly as written: its text replaces the rule's zones,
|
||||||
sets' nftables names (`@trusted_v4`). The importer makes raw rules from
|
addresses, services, action, limits and log, so a raw rule with any of
|
||||||
|
those set is refused (`raw_conflict`); write them in the text. It is
|
||||||
|
checked by nftables with the rest of the ruleset, and can use the sets'
|
||||||
|
nftables names (`@trusted_v4`). The importer makes raw rules from
|
||||||
what it can't translate. It goes into the ruleset as written: add
|
what it can't translate. It goes into the ruleset as written: add
|
||||||
`counter` to it to count its packets.
|
`counter` to it to count its packets.
|
||||||
|
|
||||||
@@ -162,12 +221,12 @@ UI), and `hc ruleset` shows them:
|
|||||||
| `antispoof` | on | early | drop sources that can't come from the internet, on external zones: the antispoof lists (private, loopback, documentation and multicast ranges) and your own prefixes |
|
| `antispoof` | on | early | drop sources that can't come from the internet, on external zones: the antispoof lists (private, loopback, documentation and multicast ranges) and your own prefixes |
|
||||||
| `mss_clamp` | off | forward | clamp TCP's MSS to the route's MTU on external zones: needed with PPPoE |
|
| `mss_clamp` | off | forward | clamp TCP's MSS to the route's MTU on external zones: needed with PPPoE |
|
||||||
|
|
||||||
**Your prefixes** (`--own-prefix 2001:db8:7f3d::/48`) are your delegated
|
**Your prefixes** (`--own-prefix 2001:db8::/48`) are your delegated
|
||||||
IPv6 prefix and public addresses: nothing from outside should come from
|
IPv6 prefix and public addresses: nothing from outside should come from
|
||||||
them. The antispoof lists can be changed with `--antispoof-v4` and
|
them. The antispoof lists can be changed with `--antispoof-v4` and
|
||||||
`--antispoof-v6`.
|
`--antispoof-v6`.
|
||||||
|
|
||||||
The **flowtable** (`--flowtable wan --flowtable enp2s0`) lets established
|
The **flowtable** (`--flowtable wan --flowtable eth1`) lets established
|
||||||
TCP and UDP connections forwarded between those devices skip the rules,
|
TCP and UDP connections forwarded between those devices skip the rules,
|
||||||
which helps on a slow CPU. It needs exact device names. Counters of
|
which helps on a slow CPU. It needs exact device names. Counters of
|
||||||
forward rules then only count each connection's first packets.
|
forward rules then only count each connection's first packets.
|
||||||
@@ -179,8 +238,8 @@ show each rule's hits. The table looks like this (shortened):
|
|||||||
|
|
||||||
```
|
```
|
||||||
table inet housecarl {
|
table inet housecarl {
|
||||||
set trusted_v4 { type ipv4_addr; flags interval; elements = { 10.0.10.0/24, 10.20.30.0/24 } }
|
set trusted_v4 { type ipv4_addr; flags interval; elements = { 192.168.1.0/24, 192.168.2.0/24 } }
|
||||||
set trusted_v6 { type ipv6_addr; flags interval; elements = { 2001:db8:7f3d:10::/64 } }
|
set trusted_v6 { type ipv6_addr; flags interval; elements = { 2001:db8:1::/64 } }
|
||||||
chain early { type filter hook prerouting priority raw; policy accept; ... }
|
chain early { type filter hook prerouting priority raw; policy accept; ... }
|
||||||
chain input { type filter hook input priority filter; policy drop;
|
chain input { type filter hook input priority filter; policy drop;
|
||||||
iifname "lo" accept comment "hc:b-loopback"
|
iifname "lo" accept comment "hc:b-loopback"
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ are unique per kind, ignoring case.
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `set_entry` | error | an entry that isn't an address, prefix (without host bits) or range |
|
| `set_entry` | error | an entry that isn't an address, prefix (without host bits) or range |
|
||||||
| `set_empty` | warning | a set (not dynamic) with no entries: rules using it match nothing |
|
| `set_empty` | warning | a set (not dynamic) with no entries: rules using it match nothing |
|
||||||
|
| `set_dynamic_entries` | error | configured entries on a dynamic set: its entries come at run time |
|
||||||
| `set_timeout` | error | a timeout on a set that isn't dynamic, or under a second |
|
| `set_timeout` | error | a timeout on a set that isn't dynamic, or under a second |
|
||||||
| `service_empty` | error | a service with no entries |
|
| `service_empty` | error | a service with no entries |
|
||||||
| `service_entry` | error | an entry that isn't `PROTO/PORTS` (`tcp`, `udp`, `sctp`; a port or range) or `icmp/TYPE`, `icmpv6/TYPE` with a type nftables knows |
|
| `service_entry` | error | an entry that isn't `PROTO/PORTS` (`tcp`, `udp`, `sctp`; a port or range) or `icmp/TYPE`, `icmpv6/TYPE` with a type nftables knows |
|
||||||
@@ -77,12 +78,22 @@ These apply to rules, source NAT and redirects.
|
|||||||
| `rule_chain` | error | a chain that isn't `input`, `forward`, `output` or `early` |
|
| `rule_chain` | error | a chain that isn't `input`, `forward`, `output` or `early` |
|
||||||
| `rule_action` | error | an action that isn't `accept`, `drop`, `reject` or `log` |
|
| `rule_action` | error | an action that isn't `accept`, `drop`, `reject` or `log` |
|
||||||
| `raw_rule` | error | a raw rule over several lines, or with `;` |
|
| `raw_rule` | error | a raw rule over several lines, or with `;` |
|
||||||
|
| `raw_conflict` | error | a raw rule with other fields set (zones, addresses, services, limits, log…): it is used as written, so they would be ignored |
|
||||||
| `early_conntrack` | error | connection states, limits or a connection limit in the early chain, which runs before connection tracking |
|
| `early_conntrack` | error | connection states, limits or a connection limit in the early chain, which runs before connection tracking |
|
||||||
| `early_reject` | error | reject in the early chain: drop instead |
|
| `early_reject` | error | reject in the early chain: drop instead |
|
||||||
| `ct_state`, `ct_status` | error | an unknown connection state or status |
|
| `ct_state`, `ct_status` | error | an unknown connection state or status |
|
||||||
| `rule_day`, `rule_hours` | error | an unknown day, or hours that aren't `HH:MM`, or only one of them |
|
| `rule_day`, `rule_hours` | error | an unknown day, or hours that aren't `HH:MM`, or only one of them |
|
||||||
| `rule_limit` | error | a rate that isn't a number per second, minute, hour or day |
|
| `rule_limit` | error | a rate that isn't a number per second, minute, hour or day |
|
||||||
| `rule_conn_limit` | error | a connection limit under 1 |
|
| `rule_conn_limit` | error | a connection limit under 1 |
|
||||||
|
| `rule_limit_unit` | error | a rate's unit that isn't packets, bytes, kbytes or mbytes |
|
||||||
|
| `rule_limit_bytes` | error | a byte rate nftables can't hold: over 4 GB a second, 293 MB a minute, 4.9 MB an hour or 208 KB a day (the message gives it per second) |
|
||||||
|
| `rule_limit_key` | error | a limit both per source and per destination |
|
||||||
|
| `rule_quota` | error | a quota under 1 byte |
|
||||||
|
| `rule_quota_key` | error | a quota both per source and per destination |
|
||||||
|
| `rule_quota_reset` | error | a reset that isn't daily, weekly or monthly; a day with another reset than monthly; a day outside 1-31 |
|
||||||
|
| `rule_group` | error | a grouping prefix outside 1-32 (IPv4) or 1-128 (IPv6) |
|
||||||
|
| `rule_traffic_accept` | error | a rule with a quota or a byte rate that accepts: it is checked before established connections are accepted, so it can only drop, reject or log |
|
||||||
|
| `flowtable_traffic` | warning | a forward rule with a quota or a byte rate while the flowtable is on: it only counts each connection's first packets |
|
||||||
| `reject_with` | error | `tcp-reset` on a rule that matches more than TCP, or with an action other than reject |
|
| `reject_with` | error | `tcp-reset` on a rule that matches more than TCP, or with an action other than reject |
|
||||||
| `log_prefix` | error | a log prefix over 100 characters |
|
| `log_prefix` | error | a log prefix over 100 characters |
|
||||||
|
|
||||||
@@ -106,3 +117,13 @@ These apply to rules, source NAT and redirects.
|
|||||||
| `snat_everything` | warning | no out zones and no sources: everything leaving the firewall is translated |
|
| `snat_everything` | warning | no out zones and no sources: everything leaving the firewall is translated |
|
||||||
| `redirect_service` | error | no service, or one without TCP, UDP or SCTP ports |
|
| `redirect_service` | error | no service, or one without TCP, UDP or SCTP ports |
|
||||||
| `redirect_to_port` | error | the local port isn't one port |
|
| `redirect_to_port` | error | the local port isn't one port |
|
||||||
|
|
||||||
|
## The machine
|
||||||
|
|
||||||
|
These come from housecarld looking at the machine, not at the
|
||||||
|
configuration: the dashboard and `hc status` show them.
|
||||||
|
|
||||||
|
| Code | | When |
|
||||||
|
|---|---|---|
|
||||||
|
| `nftables_service` | warning, *host* | Debian's `nftables.service` is enabled: at boot it loads `/etc/nftables.conf`, and stopping or restarting it runs `nft flush ruleset`, deleting Housecarl's table too. Disable it with `systemctl disable nftables` (without `--now`) |
|
||||||
|
| `takeover_pending` | warning, *host* | the table you imported from is still in force beside Housecarl's, so a packet must get past both; `hc takeover TABLE` deletes it once Housecarl's rules are right |
|
||||||
@@ -3,12 +3,14 @@ module housecarl
|
|||||||
go 1.27.1
|
go 1.27.1
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/aymanbagabas/go-udiff v0.4.1
|
||||||
github.com/go-acme/lego/v4 v4.35.2
|
github.com/go-acme/lego/v4 v4.35.2
|
||||||
github.com/miekg/dns v1.1.73
|
github.com/miekg/dns v1.1.73
|
||||||
github.com/spf13/cobra v1.10.2
|
github.com/spf13/cobra v1.10.2
|
||||||
|
github.com/spf13/pflag v1.0.9
|
||||||
go.yaml.in/yaml/v3 v3.0.5
|
go.yaml.in/yaml/v3 v3.0.5
|
||||||
golang.org/x/crypto v0.57.0
|
golang.org/x/crypto v0.57.0
|
||||||
golang.org/x/net v0.58.0
|
golang.org/x/sys v0.48.0
|
||||||
golang.org/x/term v0.46.0
|
golang.org/x/term v0.46.0
|
||||||
modernc.org/sqlite v1.60.1
|
modernc.org/sqlite v1.60.1
|
||||||
)
|
)
|
||||||
@@ -22,8 +24,7 @@ require (
|
|||||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/spf13/pflag v1.0.9 // indirect
|
golang.org/x/net v0.58.0 // indirect
|
||||||
golang.org/x/sys v0.48.0 // indirect
|
|
||||||
golang.org/x/text v0.42.0 // indirect
|
golang.org/x/text v0.42.0 // indirect
|
||||||
modernc.org/libc v1.77.1 // indirect
|
modernc.org/libc v1.77.1 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
|
||||||
|
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
|
||||||
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||||
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ type ErrorInfo struct {
|
|||||||
Problems []validate.Problem `json:"problems,omitempty"`
|
Problems []validate.Problem `json:"problems,omitempty"`
|
||||||
// Result is the preview of a refused change.
|
// Result is the preview of a refused change.
|
||||||
Result json.RawMessage `json:"result,omitempty"`
|
Result json.RawMessage `json:"result,omitempty"`
|
||||||
|
// Op is the changeset operation (its seq) that couldn't be applied.
|
||||||
|
Op int `json:"op,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Whoami describes the caller.
|
// Whoami describes the caller.
|
||||||
@@ -64,6 +66,34 @@ type MutationResponse struct {
|
|||||||
// ConfirmBy is set when the change is on probation: unless confirmed
|
// ConfirmBy is set when the change is on probation: unless confirmed
|
||||||
// (POST /confirm) by then, it is rolled back.
|
// (POST /confirm) by then, it is rolled back.
|
||||||
ConfirmBy *time.Time `json:"confirm_by,omitempty"`
|
ConfirmBy *time.Time `json:"confirm_by,omitempty"`
|
||||||
|
// ConfirmToken lets the client that made the change confirm it by
|
||||||
|
// itself (POST /confirm {"token"}): from the same address, over a new
|
||||||
|
// connection, which proves it can still reach the API. The answer
|
||||||
|
// closes its connection, so the next request opens a new one.
|
||||||
|
ConfirmToken string `json:"confirm_token,omitempty"`
|
||||||
|
// Diff is the ruleset's unified diff, for a dry run.
|
||||||
|
Diff string `json:"diff,omitempty"`
|
||||||
|
// Staged: the change was added to a changeset (Changeset), not
|
||||||
|
// applied.
|
||||||
|
Staged bool `json:"staged,omitempty"`
|
||||||
|
Changeset *model.Changeset `json:"changeset,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChangesetInput creates a changeset, or changes its description.
|
||||||
|
type ChangesetInput struct {
|
||||||
|
Description string `json:"description"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChangesetPreview is what applying a changeset would do: its changes,
|
||||||
|
// the problems, nftables' check and the ruleset's diff.
|
||||||
|
type ChangesetPreview struct {
|
||||||
|
// Valid: it can be applied as it is.
|
||||||
|
Valid bool `json:"valid"`
|
||||||
|
Changes []model.Change `json:"changes"`
|
||||||
|
Warnings []validate.Problem `json:"warnings,omitempty"`
|
||||||
|
// Error says why it can't be applied (Op: which operation, if one).
|
||||||
|
Error *ErrorInfo `json:"error,omitempty"`
|
||||||
|
Diff string `json:"diff,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// HostInterface is one of the server's network interfaces, with the zone
|
// HostInterface is one of the server's network interfaces, with the zone
|
||||||
@@ -274,3 +304,29 @@ type CertificateResponse struct {
|
|||||||
Source string `json:"source"`
|
Source string `json:"source"`
|
||||||
Status *CertStatus `json:"status,omitempty"`
|
Status *CertStatus `json:"status,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// QuotaInfo is a rule's quota and what it used.
|
||||||
|
type QuotaInfo struct {
|
||||||
|
RuleID int64 `json:"rule_id"`
|
||||||
|
Rule string `json:"rule"`
|
||||||
|
Quota model.Quota `json:"quota"`
|
||||||
|
// Used is a total quota's bytes; Addresses a per-address quota's,
|
||||||
|
// each ("192.168.1.20", or "2001:db8:1::/64").
|
||||||
|
Used int64 `json:"used"`
|
||||||
|
Addresses []QuotaAddress `json:"addresses,omitempty"`
|
||||||
|
LastReset *time.Time `json:"last_reset,omitempty"`
|
||||||
|
NextReset *time.Time `json:"next_reset,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// QuotaAddress is one address's (or prefix's) use of a per-address quota.
|
||||||
|
type QuotaAddress struct {
|
||||||
|
Address string `json:"address"`
|
||||||
|
Used int64 `json:"used"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfirmInput confirms the changes on probation. With Token (a change's
|
||||||
|
// confirm_token), it is the client that made the change confirming it by
|
||||||
|
// itself; without, a person's confirmation.
|
||||||
|
type ConfirmInput struct {
|
||||||
|
Token string `json:"token,omitempty"`
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -28,6 +29,29 @@ type Client struct {
|
|||||||
HTTP *http.Client
|
HTTP *http.Client
|
||||||
// DryRun previews changes instead of making them.
|
// DryRun previews changes instead of making them.
|
||||||
DryRun bool
|
DryRun bool
|
||||||
|
// Changeset stages configuration changes in that changeset instead
|
||||||
|
// of applying them (X-Housecarl-Changeset).
|
||||||
|
Changeset int64
|
||||||
|
// BeforeStage, if set, runs once before the first change is staged,
|
||||||
|
// and can stop it (the CLI checks HOUSECARL_CHANGESET's changeset).
|
||||||
|
BeforeStage func() error
|
||||||
|
staged bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Staged reports whether a request is a configuration change that a
|
||||||
|
// changeset takes: objects, settings and undo, and the changes that can't
|
||||||
|
// be staged (import, takeover), which the server then refuses rather than
|
||||||
|
// apply them. Changesets' own calls and dynamic set entries aren't.
|
||||||
|
func Staged(method, path string) bool {
|
||||||
|
if method == http.MethodGet || strings.Contains(path, "/elements") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, p := range []string{"/zones", "/sets", "/services", "/rules", "/forwards", "/snats", "/redirects", "/settings", "/history/", "/import/", "/takeover"} {
|
||||||
|
if strings.HasPrefix(path, p) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Options configure New.
|
// Options configure New.
|
||||||
@@ -107,6 +131,12 @@ func (c *Client) Do(ctx context.Context, method, path string, query url.Values,
|
|||||||
}
|
}
|
||||||
query.Set("dry_run", "1")
|
query.Set("dry_run", "1")
|
||||||
}
|
}
|
||||||
|
stage := c.Changeset != 0 && Staged(method, path)
|
||||||
|
if stage && !c.staged && c.BeforeStage != nil {
|
||||||
|
if err := c.BeforeStage(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
u := c.BaseURL + api.Prefix + path
|
u := c.BaseURL + api.Prefix + path
|
||||||
if len(query) > 0 {
|
if len(query) > 0 {
|
||||||
u += "?" + query.Encode()
|
u += "?" + query.Encode()
|
||||||
@@ -131,6 +161,10 @@ func (c *Client) Do(ctx context.Context, method, path string, query url.Values,
|
|||||||
if c.Token != "" {
|
if c.Token != "" {
|
||||||
req.Header.Set("Authorization", "Bearer "+c.Token)
|
req.Header.Set("Authorization", "Bearer "+c.Token)
|
||||||
}
|
}
|
||||||
|
if stage {
|
||||||
|
req.Header.Set("X-Housecarl-Changeset", strconv.FormatInt(c.Changeset, 10))
|
||||||
|
c.staged = true
|
||||||
|
}
|
||||||
resp, err := c.HTTP.Do(req)
|
resp, err := c.HTTP.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if c.BaseURL == "http://housecarld" {
|
if c.BaseURL == "http://housecarld" {
|
||||||
|
|||||||
@@ -74,6 +74,9 @@ type Result struct {
|
|||||||
HistoryID int64 `json:"history_id,omitempty"`
|
HistoryID int64 `json:"history_id,omitempty"`
|
||||||
Changes []model.Change `json:"changes"`
|
Changes []model.Change `json:"changes"`
|
||||||
Warnings []validate.Problem `json:"warnings,omitempty"`
|
Warnings []validate.Problem `json:"warnings,omitempty"`
|
||||||
|
// Before and After are the configurations the change goes from and
|
||||||
|
// to (After also for a dry run), for diffs and answers.
|
||||||
|
Before, After *model.State `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Engine holds the current configuration.
|
// Engine holds the current configuration.
|
||||||
@@ -134,6 +137,9 @@ type Edit func(st *model.State, newID func() int64) error
|
|||||||
type Options struct {
|
type Options struct {
|
||||||
DryRun bool
|
DryRun bool
|
||||||
Via string // web, cli, api, import
|
Via string // web, cli, api, import
|
||||||
|
// RevertOf is the history entry the change undoes (a changeset with a
|
||||||
|
// staged undo), so History links the two.
|
||||||
|
RevertOf int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply makes a change: edit runs on a copy of the configuration, the
|
// Apply makes a change: edit runs on a copy of the configuration, the
|
||||||
@@ -158,7 +164,7 @@ func (e *Engine) Apply(ctx context.Context, p *auth.Principal, description strin
|
|||||||
if idErr != nil {
|
if idErr != nil {
|
||||||
return nil, idErr
|
return nil, idErr
|
||||||
}
|
}
|
||||||
return e.commit(ctx, p, description, opts, next, 0)
|
return e.commit(ctx, p, description, opts, next, opts.RevertOf)
|
||||||
}
|
}
|
||||||
|
|
||||||
// commit validates next and saves it as the new state.
|
// commit validates next and saves it as the new state.
|
||||||
@@ -166,7 +172,7 @@ func (e *Engine) commit(ctx context.Context, p *auth.Principal, description stri
|
|||||||
Normalize(next)
|
Normalize(next)
|
||||||
next.Sort()
|
next.Sort()
|
||||||
changes := Diff(e.state, next)
|
changes := Diff(e.state, next)
|
||||||
res := &Result{DryRun: opts.DryRun, Changes: changes}
|
res := &Result{DryRun: opts.DryRun, Changes: changes, Before: e.state.Clone(), After: next.Clone()}
|
||||||
if len(changes) == 0 {
|
if len(changes) == 0 {
|
||||||
return res, nil
|
return res, nil
|
||||||
}
|
}
|
||||||
@@ -261,8 +267,22 @@ func (e *Engine) Revert(ctx context.Context, p *auth.Principal, id int64, opts O
|
|||||||
}
|
}
|
||||||
e.mu.Lock()
|
e.mu.Lock()
|
||||||
defer e.mu.Unlock()
|
defer e.mu.Unlock()
|
||||||
cur := objects(e.state)
|
|
||||||
next := e.state.Clone()
|
next := e.state.Clone()
|
||||||
|
if err := Undo(next, h); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return e.commit(ctx, p, UndoDescription(h), opts, next, h.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UndoDescription is an undo's description: "undo 12: create rule …".
|
||||||
|
func UndoDescription(h *model.HistoryEntry) string {
|
||||||
|
return fmt.Sprintf("undo %d: %s", h.ID, h.Description)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Undo applies a history entry backwards to st, if every object it
|
||||||
|
// changed is still as it left it (a change made since would be lost).
|
||||||
|
func Undo(st *model.State, h *model.HistoryEntry) error {
|
||||||
|
cur := objects(st)
|
||||||
for i := len(h.Changes) - 1; i >= 0; i-- {
|
for i := len(h.Changes) - 1; i >= 0; i-- {
|
||||||
c := h.Changes[i]
|
c := h.Changes[i]
|
||||||
now := cur[key{c.Kind, c.ID}]
|
now := cur[key{c.Kind, c.ID}]
|
||||||
@@ -271,13 +291,13 @@ func (e *Engine) Revert(ctx context.Context, p *auth.Principal, id int64, opts O
|
|||||||
if what == "" {
|
if what == "" {
|
||||||
what = fmt.Sprintf("%s %d", c.Kind, c.ID)
|
what = fmt.Sprintf("%s %d", c.Kind, c.ID)
|
||||||
}
|
}
|
||||||
return nil, Errorf(KindConflict, "%s %s has changed since entry %d; undo the later changes first, or change it by hand", c.Kind, what, id)
|
return Errorf(KindConflict, "%s %s has changed since entry %d; undo the later changes first, or change it by hand", c.Kind, what, h.ID)
|
||||||
}
|
}
|
||||||
if err := put(next, c.Kind, c.ID, c.Before); err != nil {
|
if err := put(st, c.Kind, c.ID, c.Before); err != nil {
|
||||||
return nil, err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return e.commit(ctx, p, fmt.Sprintf("undo %d: %s", h.ID, h.Description), opts, next, h.ID)
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Replace makes the configuration next as a whole (imports).
|
// Replace makes the configuration next as a whole (imports).
|
||||||
@@ -370,7 +390,7 @@ func Diff(a, b *model.State) []model.Change {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// Describe sums up changes: "create zone lan (enp2s0)", or
|
// Describe sums up changes: "create zone lan (eth1)", or
|
||||||
// "3 changes" for many.
|
// "3 changes" for many.
|
||||||
func Describe(cs []model.Change) string {
|
func Describe(cs []model.Change) string {
|
||||||
if len(cs) == 0 {
|
if len(cs) == 0 {
|
||||||
|
|||||||
+411
-19
@@ -13,6 +13,7 @@ package importer
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/netip"
|
||||||
"regexp"
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -38,6 +39,8 @@ type Input struct {
|
|||||||
// Result is the imported configuration and what to know about it.
|
// Result is the imported configuration and what to know about it.
|
||||||
type Result struct {
|
type Result struct {
|
||||||
State *model.State
|
State *model.State
|
||||||
|
// Table is the table imported ("inet main").
|
||||||
|
Table string
|
||||||
Notes []string
|
Notes []string
|
||||||
// Raw lists the rules that became raw rules.
|
// Raw lists the rules that became raw rules.
|
||||||
Raw []string
|
Raw []string
|
||||||
@@ -81,6 +84,16 @@ type nftSet struct {
|
|||||||
Elem []json.RawMessage `json:"elem"`
|
Elem []json.RawMessage `json:"elem"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// nftMap is a named map: its elements are [key, value] pairs.
|
||||||
|
type nftMap struct {
|
||||||
|
Family string `json:"family"`
|
||||||
|
Table string `json:"table"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type json.RawMessage `json:"type"`
|
||||||
|
Map json.RawMessage `json:"map"`
|
||||||
|
Elem []json.RawMessage `json:"elem"`
|
||||||
|
}
|
||||||
|
|
||||||
type nftFlowtable struct {
|
type nftFlowtable struct {
|
||||||
Table string `json:"table"`
|
Table string `json:"table"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -93,6 +106,7 @@ type importer struct {
|
|||||||
chains map[string]nftChain
|
chains map[string]nftChain
|
||||||
rules map[string][]nftRule // by chain, in order
|
rules map[string][]nftRule // by chain, in order
|
||||||
sets map[string]nftSet
|
sets map[string]nftSet
|
||||||
|
maps map[string]nftMap
|
||||||
flow []nftFlowtable
|
flow []nftFlowtable
|
||||||
text map[int]string // rule text by handle
|
text map[int]string // rule text by handle
|
||||||
st *model.State
|
st *model.State
|
||||||
@@ -104,6 +118,7 @@ type importer struct {
|
|||||||
portNames map[string]string // canonical port list → define name
|
portNames map[string]string // canonical port list → define name
|
||||||
ifNames map[string]string // interface → define name
|
ifNames map[string]string // interface → define name
|
||||||
used map[string]bool // sets created
|
used map[string]bool // sets created
|
||||||
|
meters map[string]bool // sets used as meters
|
||||||
noted map[string]bool
|
noted map[string]bool
|
||||||
positions map[string]float64
|
positions map[string]float64
|
||||||
}
|
}
|
||||||
@@ -120,7 +135,7 @@ func Import(in Input) (*Result, error) {
|
|||||||
if err := json.Unmarshal(in.JSON, &doc); err != nil {
|
if err := json.Unmarshal(in.JSON, &doc); err != nil {
|
||||||
return nil, fmt.Errorf("the input isn't nft's JSON (nft -j list ruleset): %v", err)
|
return nil, fmt.Errorf("the input isn't nft's JSON (nft -j list ruleset): %v", err)
|
||||||
}
|
}
|
||||||
im := &importer{in: in, chains: map[string]nftChain{}, rules: map[string][]nftRule{}, sets: map[string]nftSet{},
|
im := &importer{in: in, chains: map[string]nftChain{}, rules: map[string][]nftRule{}, sets: map[string]nftSet{}, maps: map[string]nftMap{},
|
||||||
text: map[int]string{}, st: model.NewState(), used: map[string]bool{}, noted: map[string]bool{}, positions: map[string]float64{}}
|
text: map[int]string{}, st: model.NewState(), used: map[string]bool{}, noted: map[string]bool{}, positions: map[string]float64{}}
|
||||||
var tables []nftTable
|
var tables []nftTable
|
||||||
for _, item := range doc.Nftables {
|
for _, item := range doc.Nftables {
|
||||||
@@ -172,6 +187,12 @@ func Import(in Input) (*Result, error) {
|
|||||||
if ours(s.Family, s.Table) {
|
if ours(s.Family, s.Table) {
|
||||||
im.sets[s.Name] = s
|
im.sets[s.Name] = s
|
||||||
}
|
}
|
||||||
|
case item["map"] != nil:
|
||||||
|
var m nftMap
|
||||||
|
json.Unmarshal(item["map"], &m)
|
||||||
|
if ours(m.Family, m.Table) {
|
||||||
|
im.maps[m.Name] = m
|
||||||
|
}
|
||||||
case item["flowtable"] != nil:
|
case item["flowtable"] != nil:
|
||||||
var f nftFlowtable
|
var f nftFlowtable
|
||||||
json.Unmarshal(item["flowtable"], &f)
|
json.Unmarshal(item["flowtable"], &f)
|
||||||
@@ -182,11 +203,12 @@ func Import(in Input) (*Result, error) {
|
|||||||
}
|
}
|
||||||
im.readText()
|
im.readText()
|
||||||
im.readDefines()
|
im.readDefines()
|
||||||
|
im.findMeters()
|
||||||
im.importSets()
|
im.importSets()
|
||||||
im.run()
|
im.run()
|
||||||
im.nameForwards()
|
im.nameForwards()
|
||||||
im.st.Renumber()
|
im.st.Renumber()
|
||||||
return &Result{State: im.st, Notes: im.notes, Raw: im.raw}, nil
|
return &Result{State: im.st, Table: im.table.Family + " " + im.table.Name, Notes: im.notes, Raw: im.raw}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func tableList(ts []nftTable) string {
|
func tableList(ts []nftTable) string {
|
||||||
@@ -296,6 +318,27 @@ func validIface(s string) bool {
|
|||||||
// ---- sets
|
// ---- sets
|
||||||
|
|
||||||
// importSets turns the table's named address sets into sets.
|
// importSets turns the table's named address sets into sets.
|
||||||
|
// findMeters notes the sets rules use as meters (set statements: "update
|
||||||
|
// @m { ip saddr limit ... }"): they hold per-address state, not
|
||||||
|
// addresses, and become the rules' per-address limits and quotas.
|
||||||
|
func (im *importer) findMeters() {
|
||||||
|
im.meters = map[string]bool{}
|
||||||
|
for _, rules := range im.rules {
|
||||||
|
for _, r := range rules {
|
||||||
|
for _, e := range r.Expr {
|
||||||
|
var x struct {
|
||||||
|
Set *struct {
|
||||||
|
Set string `json:"set"`
|
||||||
|
} `json:"set"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(e, &x) == nil && x.Set != nil {
|
||||||
|
im.meters[strings.TrimPrefix(x.Set.Set, "@")] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (im *importer) importSets() {
|
func (im *importer) importSets() {
|
||||||
names := make([]string, 0, len(im.sets))
|
names := make([]string, 0, len(im.sets))
|
||||||
for n := range im.sets {
|
for n := range im.sets {
|
||||||
@@ -303,6 +346,9 @@ func (im *importer) importSets() {
|
|||||||
}
|
}
|
||||||
sort.Strings(names)
|
sort.Strings(names)
|
||||||
for _, n := range names {
|
for _, n := range names {
|
||||||
|
if im.meters[n] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
s := im.sets[n]
|
s := im.sets[n]
|
||||||
var typ string
|
var typ string
|
||||||
json.Unmarshal(s.Type, &typ)
|
json.Unmarshal(s.Type, &typ)
|
||||||
@@ -452,6 +498,11 @@ type parsed struct {
|
|||||||
hop255 bool
|
hop255 bool
|
||||||
tcpSyn bool
|
tcpSyn bool
|
||||||
limit *model.Limit
|
limit *model.Limit
|
||||||
|
quota *model.Quota
|
||||||
|
connLimit int
|
||||||
|
// group4, group6: a meter's key grouped by a prefix (ip saddr and
|
||||||
|
// 255.255.255.0); 0 for none.
|
||||||
|
group4, group6 int
|
||||||
log bool
|
log bool
|
||||||
logPrefix string
|
logPrefix string
|
||||||
verdict string // accept, drop, reject, jump, goto, return
|
verdict string // accept, drop, reject, jump, goto, return
|
||||||
@@ -465,6 +516,20 @@ type parsed struct {
|
|||||||
unsupported []string
|
unsupported []string
|
||||||
comment string
|
comment string
|
||||||
handle int
|
handle int
|
||||||
|
// mapUse: a dnat to a map, or a verdict map, still to be expanded
|
||||||
|
// into one rule per element (expandMaps).
|
||||||
|
mapUse *mapUse
|
||||||
|
// name: a port forward's name, when the rule's comment won't do
|
||||||
|
// (one of a map's elements).
|
||||||
|
name string
|
||||||
|
}
|
||||||
|
|
||||||
|
// mapUse is a lookup in a map: "dnat ip to tcp dport map @forwards" or
|
||||||
|
// "tcp dport vmap { 22 : accept }".
|
||||||
|
type mapUse struct {
|
||||||
|
kind string // dnat or vmap
|
||||||
|
key json.RawMessage // what is looked up: {"payload": ...}, {"meta": ...}
|
||||||
|
data json.RawMessage // "@name", or {"set": [[key, value], ...]}
|
||||||
}
|
}
|
||||||
|
|
||||||
type match struct {
|
type match struct {
|
||||||
@@ -516,19 +581,21 @@ func (p *parsed) expr(k string, v json.RawMessage) {
|
|||||||
json.Unmarshal(v, &x)
|
json.Unmarshal(v, &x)
|
||||||
p.log, p.logPrefix = true, x.Prefix
|
p.log, p.logPrefix = true, x.Prefix
|
||||||
case "limit":
|
case "limit":
|
||||||
var x struct {
|
l, ok := limitJSON(v)
|
||||||
Rate int `json:"rate"`
|
if !ok {
|
||||||
Per string `json:"per"`
|
p.bad("a limit Housecarl can't read")
|
||||||
Burst int `json:"burst"`
|
|
||||||
Inv bool `json:"inv"`
|
|
||||||
Unit string `json:"rate_unit"`
|
|
||||||
}
|
|
||||||
json.Unmarshal(v, &x)
|
|
||||||
if x.Unit != "" && x.Unit != "packets" {
|
|
||||||
p.bad("a byte rate limit")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
p.limit = &model.Limit{Rate: x.Rate, Per: x.Per, Burst: x.Burst, Over: x.Inv}
|
p.limit = l
|
||||||
|
case "quota":
|
||||||
|
q, ok := quotaJSON(v)
|
||||||
|
if !ok {
|
||||||
|
p.bad("a quota Housecarl can't read")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.quota = q
|
||||||
|
case "set":
|
||||||
|
p.meter(v)
|
||||||
case "dnat", "snat":
|
case "dnat", "snat":
|
||||||
var x struct {
|
var x struct {
|
||||||
Family string `json:"family"`
|
Family string `json:"family"`
|
||||||
@@ -536,6 +603,16 @@ func (p *parsed) expr(k string, v json.RawMessage) {
|
|||||||
Port json.RawMessage `json:"port"`
|
Port json.RawMessage `json:"port"`
|
||||||
}
|
}
|
||||||
json.Unmarshal(v, &x)
|
json.Unmarshal(v, &x)
|
||||||
|
var lookup struct {
|
||||||
|
Map *struct {
|
||||||
|
Key json.RawMessage `json:"key"`
|
||||||
|
Data json.RawMessage `json:"data"`
|
||||||
|
} `json:"map"`
|
||||||
|
}
|
||||||
|
if k == "dnat" && json.Unmarshal(x.Addr, &lookup) == nil && lookup.Map != nil && len(x.Port) == 0 {
|
||||||
|
p.mapUse = &mapUse{kind: "dnat", key: lookup.Map.Key, data: lookup.Map.Data}
|
||||||
|
return
|
||||||
|
}
|
||||||
addr, port := values(x.Addr), values(x.Port)
|
addr, port := values(x.Addr), values(x.Port)
|
||||||
if len(addr) != 1 || len(port) > 1 {
|
if len(addr) != 1 || len(port) > 1 {
|
||||||
p.bad(k + " to a range or map")
|
p.bad(k + " to a range or map")
|
||||||
@@ -572,11 +649,184 @@ func (p *parsed) expr(k string, v json.RawMessage) {
|
|||||||
}
|
}
|
||||||
case "flow":
|
case "flow":
|
||||||
p.flowAdd = true
|
p.flowAdd = true
|
||||||
|
case "vmap":
|
||||||
|
var x struct {
|
||||||
|
Key json.RawMessage `json:"key"`
|
||||||
|
Data json.RawMessage `json:"data"`
|
||||||
|
}
|
||||||
|
json.Unmarshal(v, &x)
|
||||||
|
p.mapUse = &mapUse{kind: "vmap", key: x.Key, data: x.Data}
|
||||||
default:
|
default:
|
||||||
p.bad(k)
|
p.bad(k)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// limitJSON reads a limit: packets, or bytes (rate_unit, burst_unit).
|
||||||
|
func limitJSON(v json.RawMessage) (*model.Limit, bool) {
|
||||||
|
var x struct {
|
||||||
|
Rate int64 `json:"rate"`
|
||||||
|
Per string `json:"per"`
|
||||||
|
Burst int64 `json:"burst"`
|
||||||
|
Inv bool `json:"inv"`
|
||||||
|
Unit string `json:"rate_unit"`
|
||||||
|
BurstUnit string `json:"burst_unit"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(v, &x) != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
l := &model.Limit{Rate: int(x.Rate), Per: x.Per, Burst: int(x.Burst), Over: x.Inv}
|
||||||
|
if x.Unit == "" || x.Unit == "packets" {
|
||||||
|
return l, true
|
||||||
|
}
|
||||||
|
// Bytes: both in the largest unit that holds them exactly.
|
||||||
|
rm, ok1 := model.ByteUnits[x.Unit]
|
||||||
|
bu := x.BurstUnit
|
||||||
|
if bu == "" {
|
||||||
|
bu = "bytes"
|
||||||
|
}
|
||||||
|
bm, ok2 := model.ByteUnits[bu]
|
||||||
|
if !ok1 || !ok2 {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
rate, burst := x.Rate*rm, x.Burst*bm
|
||||||
|
for _, u := range []string{"mbytes", "kbytes", "bytes"} {
|
||||||
|
m := model.ByteUnits[u]
|
||||||
|
if rate%m == 0 && burst%m == 0 {
|
||||||
|
l.Rate, l.Burst, l.Unit = int(rate/m), int(burst/m), u
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return l, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// quotaJSON reads a quota: {"val": 100, "val_unit": "mbytes", "inv": true}.
|
||||||
|
func quotaJSON(v json.RawMessage) (*model.Quota, bool) {
|
||||||
|
var x struct {
|
||||||
|
Val int64 `json:"val"`
|
||||||
|
Unit string `json:"val_unit"`
|
||||||
|
Inv bool `json:"inv"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(v, &x) != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
m, ok := model.ByteUnits[x.Unit]
|
||||||
|
if x.Unit == "" {
|
||||||
|
m, ok = 1, true
|
||||||
|
}
|
||||||
|
if !ok || x.Val < 1 {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return &model.Quota{Bytes: x.Val * m, Over: x.Inv}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// meter reads a set statement that keeps a limit, connection count or
|
||||||
|
// quota per address (a meter: "update @m { ip saddr limit rate over
|
||||||
|
// 3/minute }"), keyed by source or destination, perhaps grouped by a
|
||||||
|
// prefix.
|
||||||
|
func (p *parsed) meter(v json.RawMessage) {
|
||||||
|
var x struct {
|
||||||
|
Op string `json:"op"`
|
||||||
|
Elem json.RawMessage `json:"elem"`
|
||||||
|
Stmt []json.RawMessage `json:"stmt"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(v, &x) != nil || (x.Op != "add" && x.Op != "update") || len(x.Stmt) != 1 {
|
||||||
|
p.bad("a set statement")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// The key: ip saddr, or ip saddr and MASK.
|
||||||
|
var key map[string]json.RawMessage
|
||||||
|
json.Unmarshal(x.Elem, &key)
|
||||||
|
bits := 0
|
||||||
|
if and, ok := key["&"]; ok {
|
||||||
|
var pair []json.RawMessage
|
||||||
|
var mask string
|
||||||
|
if json.Unmarshal(and, &pair) != nil || len(pair) != 2 || json.Unmarshal(pair[1], &mask) != nil {
|
||||||
|
p.bad("a meter key")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a, err := netip.ParseAddr(mask)
|
||||||
|
if err != nil {
|
||||||
|
p.bad("a meter key")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pfx, ok := maskBits(a)
|
||||||
|
if !ok {
|
||||||
|
p.bad("a meter key with a mask that isn't a prefix")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bits = pfx
|
||||||
|
key = nil
|
||||||
|
json.Unmarshal(pair[0], &key)
|
||||||
|
}
|
||||||
|
var pl struct{ Protocol, Field string }
|
||||||
|
if raw, ok := key["payload"]; !ok || json.Unmarshal(raw, &pl) != nil || (pl.Protocol != "ip" && pl.Protocol != "ip6") || (pl.Field != "saddr" && pl.Field != "daddr") {
|
||||||
|
p.bad("a meter keyed by something other than an address")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if bits > 0 {
|
||||||
|
if pl.Protocol == "ip" {
|
||||||
|
p.group4 = bits
|
||||||
|
} else {
|
||||||
|
p.group6 = bits
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if p.family == "" {
|
||||||
|
p.family = map[string]string{"ip": "ipv4", "ip6": "ipv6"}[pl.Protocol]
|
||||||
|
}
|
||||||
|
var stmt map[string]json.RawMessage
|
||||||
|
json.Unmarshal(x.Stmt[0], &stmt)
|
||||||
|
switch {
|
||||||
|
case stmt["limit"] != nil:
|
||||||
|
l, ok := limitJSON(stmt["limit"])
|
||||||
|
if !ok {
|
||||||
|
p.bad("a meter's limit")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
l.PerSource, l.PerDestination = pl.Field == "saddr", pl.Field == "daddr"
|
||||||
|
p.limit = l
|
||||||
|
case stmt["ct count"] != nil:
|
||||||
|
var c struct {
|
||||||
|
Val int `json:"val"`
|
||||||
|
Inv bool `json:"inv"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(stmt["ct count"], &c) != nil || !c.Inv || pl.Field != "saddr" {
|
||||||
|
p.bad("a connection count other than over N per source")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.connLimit = c.Val
|
||||||
|
case stmt["quota"] != nil:
|
||||||
|
q, ok := quotaJSON(stmt["quota"])
|
||||||
|
if !ok {
|
||||||
|
p.bad("a meter's quota")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
q.PerSource, q.PerDestination = pl.Field == "saddr", pl.Field == "daddr"
|
||||||
|
p.quota = q
|
||||||
|
default:
|
||||||
|
p.bad("a set statement")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// maskBits is a mask's prefix length (255.255.255.0: 24), if it is one:
|
||||||
|
// ones, then only zeros.
|
||||||
|
func maskBits(a netip.Addr) (int, bool) {
|
||||||
|
n, zero := 0, false
|
||||||
|
for _, x := range a.AsSlice() {
|
||||||
|
for bit := 7; bit >= 0; bit-- {
|
||||||
|
one := x&(1<<bit) != 0
|
||||||
|
switch {
|
||||||
|
case one && zero:
|
||||||
|
return 0, false
|
||||||
|
case one:
|
||||||
|
n++
|
||||||
|
default:
|
||||||
|
zero = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n, true
|
||||||
|
}
|
||||||
|
|
||||||
func (p *parsed) match(m match) {
|
func (p *parsed) match(m match) {
|
||||||
var left map[string]json.RawMessage
|
var left map[string]json.RawMessage
|
||||||
json.Unmarshal(m.Left, &left)
|
json.Unmarshal(m.Left, &left)
|
||||||
@@ -740,8 +990,11 @@ func (im *importer) run() {
|
|||||||
// target chain gets the jump's own matches in front.
|
// target chain gets the jump's own matches in front.
|
||||||
func (im *importer) flatten(chain string, prefix *parsed, depth int) []*parsed {
|
func (im *importer) flatten(chain string, prefix *parsed, depth int) []*parsed {
|
||||||
var out []*parsed
|
var out []*parsed
|
||||||
|
var rules []*parsed
|
||||||
for _, r := range im.rules[chain] {
|
for _, r := range im.rules[chain] {
|
||||||
p := parse(r)
|
rules = append(rules, im.expandMaps(parse(r))...)
|
||||||
|
}
|
||||||
|
for _, p := range rules {
|
||||||
if prefix != nil {
|
if prefix != nil {
|
||||||
p = im.merge(prefix, p)
|
p = im.merge(prefix, p)
|
||||||
}
|
}
|
||||||
@@ -770,6 +1023,115 @@ func (im *importer) flatten(chain string, prefix *parsed, depth int) []*parsed {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// expandMaps turns a rule that looks something up in a map into one rule
|
||||||
|
// per element, each matching the element's key and doing what its value
|
||||||
|
// says: "tcp dport vmap { 22 : accept, 23 : drop }" becomes "tcp dport 22
|
||||||
|
// accept" and "tcp dport 23 drop"; "dnat ip to tcp dport map @forwards"
|
||||||
|
// one dnat (later a port forward) per element. A lookup finds at most one
|
||||||
|
// element, and their keys don't overlap, so the rules do the same in
|
||||||
|
// order. A map that can't be expanded leaves the rule unsupported.
|
||||||
|
func (im *importer) expandMaps(p *parsed) []*parsed {
|
||||||
|
u := p.mapUse
|
||||||
|
if u == nil {
|
||||||
|
return []*parsed{p}
|
||||||
|
}
|
||||||
|
p.mapUse = nil
|
||||||
|
fail := func(why string) []*parsed {
|
||||||
|
p.bad(why)
|
||||||
|
return []*parsed{p}
|
||||||
|
}
|
||||||
|
var keyObj map[string]json.RawMessage
|
||||||
|
json.Unmarshal(u.key, &keyObj)
|
||||||
|
if keyObj["concat"] != nil {
|
||||||
|
return fail("a map keyed by several fields (" + u.kind + ")")
|
||||||
|
}
|
||||||
|
// The elements: a named map's, or the anonymous one's.
|
||||||
|
var elems []json.RawMessage
|
||||||
|
mapName := ""
|
||||||
|
var ref string
|
||||||
|
if json.Unmarshal(u.data, &ref) == nil && strings.HasPrefix(ref, "@") {
|
||||||
|
m, ok := im.maps[ref[1:]]
|
||||||
|
if !ok {
|
||||||
|
return fail("the map " + ref + ", which isn't in the table")
|
||||||
|
}
|
||||||
|
mapName, elems = m.Name, m.Elem
|
||||||
|
} else {
|
||||||
|
var anon struct {
|
||||||
|
Set []json.RawMessage `json:"set"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(u.data, &anon) != nil || anon.Set == nil {
|
||||||
|
return fail("a " + u.kind + " map Housecarl can't read")
|
||||||
|
}
|
||||||
|
elems = anon.Set
|
||||||
|
}
|
||||||
|
if len(elems) == 0 {
|
||||||
|
im.note("rule handle %d looks up the empty map %s: left out", p.handle, map[bool]string{true: "@" + mapName, false: "{}"}[mapName != ""])
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var out []*parsed
|
||||||
|
for _, e := range elems {
|
||||||
|
var pair []json.RawMessage
|
||||||
|
if json.Unmarshal(e, &pair) != nil || len(pair) != 2 {
|
||||||
|
return fail("a map element Housecarl can't read")
|
||||||
|
}
|
||||||
|
q := *p
|
||||||
|
q.unsupported = append([]string{}, p.unsupported...)
|
||||||
|
q.match(match{Op: "==", Left: u.key, Right: pair[0]})
|
||||||
|
switch u.kind {
|
||||||
|
case "dnat":
|
||||||
|
addr, port := mapTarget(pair[1])
|
||||||
|
if addr == "" {
|
||||||
|
return fail("a dnat map whose values aren't addresses (or address . port)")
|
||||||
|
}
|
||||||
|
q.dnatAddr, q.dnatPort = addr, port
|
||||||
|
if mapName != "" {
|
||||||
|
if k := values(pair[0]); len(k) == 1 {
|
||||||
|
q.name = mapName + "-" + k[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "vmap":
|
||||||
|
var verdict map[string]json.RawMessage
|
||||||
|
if json.Unmarshal(pair[1], &verdict) != nil || len(verdict) != 1 {
|
||||||
|
return fail("a verdict map value Housecarl can't read")
|
||||||
|
}
|
||||||
|
for k, v := range verdict {
|
||||||
|
q.expr(k, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, &q)
|
||||||
|
}
|
||||||
|
if mapName != "" {
|
||||||
|
what := map[string]string{"dnat": "port forward", "vmap": "rule"}[u.kind]
|
||||||
|
if !im.noted["map "+mapName] {
|
||||||
|
im.noted["map "+mapName] = true
|
||||||
|
if len(elems) == 1 {
|
||||||
|
im.note("map %s: its element became a %s", mapName, what)
|
||||||
|
} else {
|
||||||
|
im.note("map %s: its %d elements became %d %ss, one each", mapName, len(elems), len(elems), what)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// mapTarget reads a dnat map's value: an address, or address . port.
|
||||||
|
func mapTarget(raw json.RawMessage) (addr, port string) {
|
||||||
|
var obj struct {
|
||||||
|
Concat []json.RawMessage `json:"concat"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(raw, &obj) == nil && len(obj.Concat) == 2 {
|
||||||
|
a, b := values(obj.Concat[0]), values(obj.Concat[1])
|
||||||
|
if len(a) == 1 && len(b) == 1 {
|
||||||
|
return a[0], b[0]
|
||||||
|
}
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
if a := values(raw); len(a) == 1 {
|
||||||
|
return a[0], ""
|
||||||
|
}
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
|
||||||
// hasMatch reports whether p matches more than the jump's own matches.
|
// hasMatch reports whether p matches more than the jump's own matches.
|
||||||
func hasMatch(p, prefix *parsed) bool {
|
func hasMatch(p, prefix *parsed) bool {
|
||||||
return len(p.dports) != len(prefix.dports) || len(p.src) != len(prefix.src) || len(p.dst) != len(prefix.dst) ||
|
return len(p.dports) != len(prefix.dports) || len(p.src) != len(prefix.src) || len(p.dst) != len(prefix.dst) ||
|
||||||
@@ -812,7 +1174,7 @@ var essentials6 = map[string]bool{"destination-unreachable": true, "packet-too-b
|
|||||||
var nd6 = map[string]bool{"nd-router-solicit": true, "nd-router-advert": true, "nd-neighbor-solicit": true, "nd-neighbor-advert": true}
|
var nd6 = map[string]bool{"nd-router-solicit": true, "nd-router-advert": true, "nd-neighbor-solicit": true, "nd-neighbor-advert": true}
|
||||||
|
|
||||||
func plain(p *parsed) bool {
|
func plain(p *parsed) bool {
|
||||||
return len(p.unsupported) == 0 && p.limit == nil && !p.log && len(p.src) == 0 && len(p.dst) == 0 && len(p.outIf) == 0
|
return len(p.unsupported) == 0 && p.limit == nil && p.quota == nil && p.connLimit == 0 && !p.log && len(p.src) == 0 && len(p.dst) == 0 && len(p.outIf) == 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// builtinOf names the built-in a rule is, or "".
|
// builtinOf names the built-in a rule is, or "".
|
||||||
@@ -1043,12 +1405,18 @@ func (im *importer) rule(chain string, p *parsed) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
r := &model.Rule{ID: im.newID(), Chain: chain, Position: im.nextPos(chain), Comment: p.comment, CtState: p.ctState, CtStatus: p.ctStatus,
|
r := &model.Rule{ID: im.newID(), Chain: chain, Position: im.nextPos(chain), Comment: p.comment, CtState: p.ctState, CtStatus: p.ctStatus,
|
||||||
Limit: p.limit, Log: p.log, LogPrefix: p.logPrefix, RejectWith: p.rejectWith}
|
Limit: p.limit, Quota: p.quota, ConnLimit: p.connLimit, GroupV4: p.group4, GroupV6: p.group6,
|
||||||
|
Log: p.log, LogPrefix: p.logPrefix, RejectWith: p.rejectWith}
|
||||||
switch p.verdict {
|
switch p.verdict {
|
||||||
case "accept", "drop", "reject":
|
case "accept", "drop", "reject":
|
||||||
r.Action = p.verdict
|
r.Action = p.verdict
|
||||||
case "":
|
case "":
|
||||||
if !p.log {
|
switch {
|
||||||
|
case len(p.unsupported) > 0:
|
||||||
|
// The verdict may be in what couldn't be read (a verdict map).
|
||||||
|
im.rawRule(r, p)
|
||||||
|
return
|
||||||
|
case !p.log:
|
||||||
im.note("a %s rule with no verdict (handle %d) was left out", chain, p.handle)
|
im.note("a %s rule with no verdict (handle %d) was left out", chain, p.handle)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1057,6 +1425,15 @@ func (im *importer) rule(chain string, p *parsed) {
|
|||||||
p.bad(p.verdict)
|
p.bad(p.verdict)
|
||||||
}
|
}
|
||||||
m, ok := im.toMatch(p)
|
m, ok := im.toMatch(p)
|
||||||
|
if r.CountsTraffic() && r.Action == model.ActionAccept {
|
||||||
|
// Housecarl checks these before established connections are
|
||||||
|
// accepted, where accepting would skip the later rules: as written.
|
||||||
|
p.bad("an accept with a quota or a byte rate")
|
||||||
|
ok = false
|
||||||
|
}
|
||||||
|
if ok && r.CountsTraffic() && chain != model.ChainEarly {
|
||||||
|
im.note("%s rule handle %d has a quota or a byte rate: Housecarl checks it before established connections are accepted, so it counts every packet of a connection", chain, p.handle)
|
||||||
|
}
|
||||||
if ok && !p.hop255 && !p.mss && !p.tcpSyn && !p.flowAdd && p.dnatAddr == "" && !p.masq {
|
if ok && !p.hop255 && !p.mss && !p.tcpSyn && !p.flowAdd && p.dnatAddr == "" && !p.masq {
|
||||||
r.Match = m
|
r.Match = m
|
||||||
im.st.Rules = append(im.st.Rules, r)
|
im.st.Rules = append(im.st.Rules, r)
|
||||||
@@ -1065,6 +1442,8 @@ func (im *importer) rule(chain string, p *parsed) {
|
|||||||
im.rawRule(r, p)
|
im.rawRule(r, p)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var mapRefRe = regexp.MustCompile(`@([A-Za-z0-9_]+)`)
|
||||||
|
|
||||||
func (im *importer) rawRule(r *model.Rule, p *parsed) {
|
func (im *importer) rawRule(r *model.Rule, p *parsed) {
|
||||||
text, ok := im.text[p.handle]
|
text, ok := im.text[p.handle]
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -1075,10 +1454,20 @@ func (im *importer) rawRule(r *model.Rule, p *parsed) {
|
|||||||
if i := strings.LastIndex(text, ` comment "`); i >= 0 {
|
if i := strings.LastIndex(text, ` comment "`); i >= 0 {
|
||||||
text = strings.TrimSpace(text[:i])
|
text = strings.TrimSpace(text[:i])
|
||||||
}
|
}
|
||||||
|
// A named map isn't carried over: a raw rule naming it would make
|
||||||
|
// nftables refuse the whole ruleset.
|
||||||
|
for _, ref := range mapRefRe.FindAllStringSubmatch(text, -1) {
|
||||||
|
if _, ok := im.maps[ref[1]]; ok {
|
||||||
|
im.note("rule handle %d of chain %s uses the map @%s, which couldn't be expanded (%s): left out, add it by hand: %s",
|
||||||
|
p.handle, r.Chain, ref[1], strings.Join(p.unsupported, ", "), text)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
if r.Action == "" {
|
if r.Action == "" {
|
||||||
r.Action = model.ActionAccept
|
r.Action = model.ActionAccept
|
||||||
}
|
}
|
||||||
r.Match, r.Raw = model.Match{}, text
|
// Only the text counts: what was parsed of it goes.
|
||||||
|
*r = model.Rule{ID: r.ID, Chain: r.Chain, Position: r.Position, Comment: r.Comment, Action: r.Action, Raw: text}
|
||||||
im.st.Rules = append(im.st.Rules, r)
|
im.st.Rules = append(im.st.Rules, r)
|
||||||
why := strings.Join(p.unsupported, ", ")
|
why := strings.Join(p.unsupported, ", ")
|
||||||
if why == "" {
|
if why == "" {
|
||||||
@@ -1100,6 +1489,9 @@ func (im *importer) dstnat(p *parsed) {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
f := &model.PortForward{Protocols: []string{p.proto}, Ports: ports, To: p.dnatAddr, ToPort: p.dnatPort, Comment: p.comment, Name: forwardName(p.comment)}
|
f := &model.PortForward{Protocols: []string{p.proto}, Ports: ports, To: p.dnatAddr, ToPort: p.dnatPort, Comment: p.comment, Name: forwardName(p.comment)}
|
||||||
|
if p.name != "" {
|
||||||
|
f.Name = strings.ToLower(nameRe.ReplaceAllString(p.name, "-"))
|
||||||
|
}
|
||||||
if f.ToPort == ports {
|
if f.ToPort == ports {
|
||||||
f.ToPort = ""
|
f.ToPort = ""
|
||||||
}
|
}
|
||||||
@@ -1136,7 +1528,7 @@ func forwardName(comment string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// nameForwards makes the forwards' names (from their comments) valid and
|
// nameForwards makes the forwards' names (from their comments) valid and
|
||||||
// unique: "8096 to jellyfin" becomes "fwd-8096-to-jellyfin", a second
|
// unique: "8080 to web" becomes "fwd-8080-to-web", a second
|
||||||
// "web" becomes "web-2".
|
// "web" becomes "web-2".
|
||||||
func (im *importer) nameForwards() {
|
func (im *importer) nameForwards() {
|
||||||
seen := map[string]bool{}
|
seen := map[string]bool{}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package importer
|
package importer
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -173,3 +174,130 @@ func TestImportedRulesetPassesNft(t *testing.T) {
|
|||||||
t.Errorf("nft -c: %v\n%s", err, out)
|
t.Errorf("nft -c: %v\n%s", err, out)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestImportMaps: dnat maps become port forwards and verdict maps rules,
|
||||||
|
// one per element (testdata/maps.nft); a map that can't be expanded is
|
||||||
|
// left out with a note, never kept as a raw rule naming it.
|
||||||
|
func TestImportMaps(t *testing.T) {
|
||||||
|
js, _ := os.ReadFile("testdata/maps.json")
|
||||||
|
txt, _ := os.ReadFile("testdata/maps.txt")
|
||||||
|
res, err := Import(Input{JSON: js, Text: string(txt)})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var fwds []string
|
||||||
|
for _, f := range res.State.Forwards {
|
||||||
|
fwds = append(fwds, fmt.Sprintf("%s %s/%s->%s:%s", f.Name, strings.Join(f.Protocols, "+"), f.Ports, f.To, f.ToPort))
|
||||||
|
}
|
||||||
|
var rules []string
|
||||||
|
for _, r := range res.State.Rules {
|
||||||
|
rules = append(rules, fmt.Sprintf("%s in=%s from=%s %s %s%s", r.Chain, strings.Join(r.InZones, ","), strings.Join(r.Sources, ","),
|
||||||
|
strings.Join(r.Services, ","), r.Action, r.Raw))
|
||||||
|
}
|
||||||
|
want := []string{
|
||||||
|
"portforwards-80 tcp/80->192.168.1.50:8080",
|
||||||
|
"portforwards-443 tcp/443->192.168.1.50:",
|
||||||
|
"hosts-2222 tcp/2222->192.168.1.60:",
|
||||||
|
" udp/5000->192.168.1.70:5001",
|
||||||
|
" udp/6000->192.168.1.71:",
|
||||||
|
}
|
||||||
|
if got := strings.Join(fwds, "\n"); got != strings.Join(want, "\n") {
|
||||||
|
t.Errorf("forwards:\n%s\nwant:\n%s", got, strings.Join(want, "\n"))
|
||||||
|
}
|
||||||
|
// In order; the jump in the verdict map is inlined.
|
||||||
|
want = []string{
|
||||||
|
"input in= from= accept",
|
||||||
|
"input in=lan from= tcp/22 accept",
|
||||||
|
"input in=lan from= tcp/23 drop",
|
||||||
|
"input in=lan from=192.168.1.0/24 tcp/80 accept",
|
||||||
|
"input in=wan from= udp/53 accept",
|
||||||
|
"input in=wan from= udp/123 drop",
|
||||||
|
"input in= from=10.0.0.0/8 accept",
|
||||||
|
"input in= from=192.0.2.1 drop",
|
||||||
|
}
|
||||||
|
if got := strings.Join(rules, "\n"); got != strings.Join(want, "\n") {
|
||||||
|
t.Errorf("rules:\n%s\nwant:\n%s", got, strings.Join(want, "\n"))
|
||||||
|
}
|
||||||
|
notes := strings.Join(res.Notes, "\n")
|
||||||
|
for _, n := range []string{"map portforwards: its 2 elements became 2 port forwards", "map hosts: its element became a port forward",
|
||||||
|
"uses the map @pairs, which couldn't be expanded"} {
|
||||||
|
if !strings.Contains(notes, n) {
|
||||||
|
t.Errorf("note %q missing:\n%s", n, notes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(res.Raw) > 0 {
|
||||||
|
t.Errorf("raw rules: %v", res.Raw)
|
||||||
|
}
|
||||||
|
if ps := validate.Errors(validate.Check(res.State, nil)); len(ps) > 0 {
|
||||||
|
t.Errorf("invalid: %+v", ps)
|
||||||
|
}
|
||||||
|
if cmd := strings.Fields(os.Getenv("HOUSECARL_NFT")); len(cmd) > 0 {
|
||||||
|
c := exec.Command(cmd[0], append(cmd[1:], "-c", "-f", "/dev/stdin")...)
|
||||||
|
c.Stdin = strings.NewReader(render.Render(res.State, render.Options{}).Text)
|
||||||
|
if out, err := c.CombinedOutput(); err != nil {
|
||||||
|
t.Errorf("nft -c: %v\n%s", err, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestImportMeters: meters (per-address limits, connection counts and
|
||||||
|
// quotas), byte rates and quotas become rules' limits and quotas
|
||||||
|
// (testdata/meters.nft); the meters' sets aren't imported as address sets.
|
||||||
|
func TestImportMeters(t *testing.T) {
|
||||||
|
js, _ := os.ReadFile("testdata/meters.json")
|
||||||
|
txt, _ := os.ReadFile("testdata/meters.txt")
|
||||||
|
res, err := Import(Input{JSON: js, Text: string(txt)})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var got []string
|
||||||
|
for _, r := range res.State.Rules {
|
||||||
|
s := r.Chain
|
||||||
|
if l := r.Limit; l != nil {
|
||||||
|
s += fmt.Sprintf(" limit %d %s/%s burst %d over=%v src=%v dst=%v", l.Rate, l.Unit, l.Per, l.Burst, l.Over, l.PerSource, l.PerDestination)
|
||||||
|
}
|
||||||
|
if r.ConnLimit > 0 {
|
||||||
|
s += fmt.Sprintf(" conn %d", r.ConnLimit)
|
||||||
|
}
|
||||||
|
if q := r.Quota; q != nil {
|
||||||
|
s += fmt.Sprintf(" quota %d over=%v src=%v", q.Bytes, q.Over, q.PerSource)
|
||||||
|
}
|
||||||
|
if r.GroupV4 != 0 || r.GroupV6 != 0 {
|
||||||
|
s += fmt.Sprintf(" group %d/%d", r.GroupV4, r.GroupV6)
|
||||||
|
}
|
||||||
|
s += " " + r.Action + r.Raw
|
||||||
|
got = append(got, s)
|
||||||
|
}
|
||||||
|
want := []string{
|
||||||
|
"input accept", // established: only in input here, so a rule
|
||||||
|
"input limit 3 /minute burst 5 over=true src=true dst=false drop",
|
||||||
|
"input limit 3 /minute burst 5 over=true src=true dst=false group 0/64 drop",
|
||||||
|
"input accept",
|
||||||
|
"forward conn 20 drop",
|
||||||
|
"forward limit 10240 kbytes/second burst 512 over=true src=false dst=false drop",
|
||||||
|
"forward quota 1048576000 over=true src=true group 24/0 drop",
|
||||||
|
"forward quota 104857600000 over=true src=false drop",
|
||||||
|
"forward accept",
|
||||||
|
}
|
||||||
|
if strings.Join(got, "\n") != strings.Join(want, "\n") {
|
||||||
|
t.Errorf("rules:\n%s\nwant:\n%s\nnotes: %v", strings.Join(got, "\n"), strings.Join(want, "\n"), res.Notes)
|
||||||
|
}
|
||||||
|
for _, s := range res.State.Sets {
|
||||||
|
if s.Name != "trusted" {
|
||||||
|
t.Errorf("a meter became a set: %s", s.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(res.Raw) > 0 {
|
||||||
|
t.Errorf("raw rules: %v", res.Raw)
|
||||||
|
}
|
||||||
|
if ps := validate.Errors(validate.Check(res.State, nil)); len(ps) > 0 {
|
||||||
|
t.Errorf("invalid: %+v", ps)
|
||||||
|
}
|
||||||
|
if cmd := strings.Fields(os.Getenv("HOUSECARL_NFT")); len(cmd) > 0 {
|
||||||
|
c := exec.Command(cmd[0], append(cmd[1:], "-c", "-f", "/dev/stdin")...)
|
||||||
|
c.Stdin = strings.NewReader(render.Render(res.State, render.Options{}).Text)
|
||||||
|
if out, err := c.CombinedOutput(); err != nil {
|
||||||
|
t.Errorf("nft -c: %v\n%s", err, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
{"nftables": [{"metainfo": {"version": "1.1.3", "release_name": "Commodore Bullmoose #4", "json_schema_version": 1}}, {"table": {"family": "inet", "name": "main", "handle": 3}}, {"chain": {"family": "inet", "table": "main", "name": "web", "handle": 1}}, {"chain": {"family": "inet", "table": "main", "name": "prerouting", "handle": 2, "type": "nat", "hook": "prerouting", "prio": -100, "policy": "accept"}}, {"chain": {"family": "inet", "table": "main", "name": "input", "handle": 3, "type": "filter", "hook": "input", "prio": 0, "policy": "drop"}}, {"map": {"family": "inet", "name": "portforwards", "table": "main", "type": "inet_service", "handle": 4, "map": ["ipv4_addr", "inet_service"], "elem": [[80, {"concat": ["192.168.1.50", 8080]}], [443, {"concat": ["192.168.1.50", 443]}]]}}, {"map": {"family": "inet", "name": "hosts", "table": "main", "type": "inet_service", "handle": 5, "map": "ipv4_addr", "elem": [[2222, "192.168.1.60"]]}}, {"map": {"family": "inet", "name": "lan_services", "table": "main", "type": "inet_service", "handle": 6, "map": "verdict", "elem": [[22, {"accept": null}], [23, {"drop": null}], [80, {"jump": {"target": "web"}}]]}}, {"map": {"family": "inet", "name": "pairs", "table": "main", "type": ["ipv4_addr", "inet_service"], "handle": 7, "map": "verdict", "elem": [[{"concat": ["192.168.1.9", 22]}, {"accept": null}]]}}, {"rule": {"family": "inet", "table": "main", "chain": "web", "handle": 8, "expr": [{"match": {"op": "==", "left": {"payload": {"protocol": "ip", "field": "saddr"}}, "right": {"prefix": {"addr": "192.168.1.0", "len": 24}}}}, {"accept": null}]}}, {"rule": {"family": "inet", "table": "main", "chain": "prerouting", "handle": 9, "comment": "Port forwards", "expr": [{"match": {"op": "==", "left": {"meta": {"key": "iifname"}}, "right": "wan"}}, {"dnat": {"family": "ip", "addr": {"map": {"key": {"payload": {"protocol": "tcp", "field": "dport"}}, "data": "@portforwards"}}}}]}}, {"rule": {"family": "inet", "table": "main", "chain": "prerouting", "handle": 10, "expr": [{"match": {"op": "==", "left": {"meta": {"key": "iifname"}}, "right": "wan"}}, {"dnat": {"family": "ip", "addr": {"map": {"key": {"payload": {"protocol": "tcp", "field": "dport"}}, "data": "@hosts"}}}}]}}, {"rule": {"family": "inet", "table": "main", "chain": "prerouting", "handle": 12, "expr": [{"match": {"op": "==", "left": {"meta": {"key": "iifname"}}, "right": "wan"}}, {"dnat": {"family": "ip", "addr": {"map": {"key": {"payload": {"protocol": "udp", "field": "dport"}}, "data": {"set": [[5000, {"concat": ["192.168.1.70", 5001]}], [6000, {"concat": ["192.168.1.71", 6000]}]]}}}}}]}}, {"rule": {"family": "inet", "table": "main", "chain": "input", "handle": 13, "expr": [{"match": {"op": "in", "left": {"ct": {"key": "state"}}, "right": ["established", "related"]}}, {"accept": null}]}}, {"rule": {"family": "inet", "table": "main", "chain": "input", "handle": 14, "expr": [{"match": {"op": "==", "left": {"meta": {"key": "iifname"}}, "right": "lan"}}, {"vmap": {"key": {"payload": {"protocol": "tcp", "field": "dport"}}, "data": "@lan_services"}}]}}, {"rule": {"family": "inet", "table": "main", "chain": "input", "handle": 16, "expr": [{"match": {"op": "==", "left": {"meta": {"key": "iifname"}}, "right": "wan"}}, {"vmap": {"key": {"payload": {"protocol": "udp", "field": "dport"}}, "data": {"set": [[53, {"accept": null}], [123, {"drop": null}]]}}}]}}, {"rule": {"family": "inet", "table": "main", "chain": "input", "handle": 18, "expr": [{"vmap": {"key": {"payload": {"protocol": "ip", "field": "saddr"}}, "data": {"set": [[{"prefix": {"addr": "10.0.0.0", "len": 8}}, {"accept": null}], ["192.0.2.1", {"drop": null}]]}}}]}}, {"rule": {"family": "inet", "table": "main", "chain": "input", "handle": 19, "expr": [{"vmap": {"key": {"concat": [{"payload": {"protocol": "ip", "field": "saddr"}}, {"payload": {"protocol": "tcp", "field": "dport"}}]}, "data": "@pairs"}}]}}]}
|
||||||
Vendored
+36
@@ -0,0 +1,36 @@
|
|||||||
|
# Maps and verdict maps, as found in hand-written rulesets (importer_test.go).
|
||||||
|
table inet main {
|
||||||
|
map portforwards {
|
||||||
|
type inet_service : ipv4_addr . inet_service
|
||||||
|
elements = { 443 : 192.168.1.50 . 443, 80 : 192.168.1.50 . 8080 }
|
||||||
|
}
|
||||||
|
map hosts {
|
||||||
|
type inet_service : ipv4_addr
|
||||||
|
elements = { 2222 : 192.168.1.60 }
|
||||||
|
}
|
||||||
|
map lan_services {
|
||||||
|
type inet_service : verdict
|
||||||
|
elements = { 22 : accept, 23 : drop, 80 : jump web }
|
||||||
|
}
|
||||||
|
map pairs {
|
||||||
|
type ipv4_addr . inet_service : verdict
|
||||||
|
elements = { 192.168.1.9 . 22 : accept }
|
||||||
|
}
|
||||||
|
chain web {
|
||||||
|
ip saddr 192.168.1.0/24 accept
|
||||||
|
}
|
||||||
|
chain prerouting {
|
||||||
|
type nat hook prerouting priority dstnat; policy accept;
|
||||||
|
iifname "wan" dnat ip to tcp dport map @portforwards comment "Port forwards"
|
||||||
|
iifname "wan" dnat ip to tcp dport map @hosts
|
||||||
|
iifname "wan" dnat ip to udp dport map { 5000 : 192.168.1.70 . 5001, 6000 : 192.168.1.71 . 6000 }
|
||||||
|
}
|
||||||
|
chain input {
|
||||||
|
type filter hook input priority filter; policy drop;
|
||||||
|
ct state established,related accept
|
||||||
|
iifname "lan" tcp dport vmap @lan_services
|
||||||
|
iifname "wan" udp dport vmap { 53 : accept, 123 : drop }
|
||||||
|
ip saddr vmap { 10.0.0.0/8 : accept, 192.0.2.1 : drop }
|
||||||
|
ip saddr . tcp dport vmap @pairs
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+44
@@ -0,0 +1,44 @@
|
|||||||
|
table inet main { # handle 3
|
||||||
|
map portforwards { # handle 4
|
||||||
|
type inet_service : ipv4_addr . inet_service
|
||||||
|
elements = { 80 : 192.168.1.50 . 8080,
|
||||||
|
443 : 192.168.1.50 . 443 }
|
||||||
|
}
|
||||||
|
|
||||||
|
map hosts { # handle 5
|
||||||
|
type inet_service : ipv4_addr
|
||||||
|
elements = { 2222 : 192.168.1.60 }
|
||||||
|
}
|
||||||
|
|
||||||
|
map lan_services { # handle 6
|
||||||
|
type inet_service : verdict
|
||||||
|
elements = { 22 : accept,
|
||||||
|
23 : drop,
|
||||||
|
80 : jump web }
|
||||||
|
}
|
||||||
|
|
||||||
|
map pairs { # handle 7
|
||||||
|
type ipv4_addr . inet_service : verdict
|
||||||
|
elements = { 192.168.1.9 . 22 : accept }
|
||||||
|
}
|
||||||
|
|
||||||
|
chain web { # handle 1
|
||||||
|
ip saddr 192.168.1.0/24 accept # handle 8
|
||||||
|
}
|
||||||
|
|
||||||
|
chain prerouting { # handle 2
|
||||||
|
type nat hook prerouting priority dstnat; policy accept;
|
||||||
|
iifname "wan" dnat ip to tcp dport map @portforwards comment "Port forwards" # handle 9
|
||||||
|
iifname "wan" dnat ip to tcp dport map @hosts # handle 10
|
||||||
|
iifname "wan" dnat ip to udp dport map { 5000 : 192.168.1.70 . 5001, 6000 : 192.168.1.71 . 6000 } # handle 12
|
||||||
|
}
|
||||||
|
|
||||||
|
chain input { # handle 3
|
||||||
|
type filter hook input priority filter; policy drop;
|
||||||
|
ct state established,related accept # handle 13
|
||||||
|
iifname "lan" tcp dport vmap @lan_services # handle 14
|
||||||
|
iifname "wan" udp dport vmap { 53 : accept, 123 : drop } # handle 16
|
||||||
|
ip saddr vmap { 10.0.0.0/8 : accept, 192.0.2.1 : drop } # handle 18
|
||||||
|
ip saddr . tcp dport vmap @pairs # handle 19
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"nftables": [{"metainfo": {"version": "1.1.3", "release_name": "Commodore Bullmoose #4", "json_schema_version": 1}}, {"table": {"family": "inet", "name": "main", "handle": 1}}, {"chain": {"family": "inet", "table": "main", "name": "input", "handle": 1, "type": "filter", "hook": "input", "prio": 0, "policy": "drop"}}, {"chain": {"family": "inet", "table": "main", "name": "forward", "handle": 2, "type": "filter", "hook": "forward", "prio": 0, "policy": "drop"}}, {"chain": {"family": "inet", "table": "main", "name": "output", "handle": 3, "type": "filter", "hook": "output", "prio": 0, "policy": "accept"}}, {"set": {"family": "inet", "name": "ssh_meter", "table": "main", "type": "ipv4_addr", "handle": 4, "size": 65535, "flags": ["timeout", "dynamic"], "timeout": 60}}, {"set": {"family": "inet", "name": "ssh6_meter", "table": "main", "type": "ipv6_addr", "handle": 5, "size": 65535, "flags": ["timeout", "dynamic"], "timeout": 60}}, {"set": {"family": "inet", "name": "my_connlimit", "table": "main", "type": "ipv4_addr", "handle": 6, "size": 65535, "flags": ["dynamic"]}}, {"set": {"family": "inet", "name": "guest_quota", "table": "main", "type": "ipv4_addr", "handle": 7, "size": 65535, "flags": ["dynamic"]}}, {"set": {"family": "inet", "name": "trusted", "table": "main", "type": "ipv4_addr", "handle": 8, "flags": ["interval"], "elem": [{"prefix": {"addr": "10.0.10.0", "len": 24}}]}}, {"rule": {"family": "inet", "table": "main", "chain": "input", "handle": 9, "expr": [{"match": {"op": "in", "left": {"ct": {"key": "state"}}, "right": ["established", "related"]}}, {"accept": null}]}}, {"rule": {"family": "inet", "table": "main", "chain": "input", "handle": 10, "expr": [{"match": {"op": "==", "left": {"payload": {"protocol": "tcp", "field": "dport"}}, "right": 22}}, {"match": {"op": "in", "left": {"ct": {"key": "state"}}, "right": "new"}}, {"set": {"op": "update", "elem": {"payload": {"protocol": "ip", "field": "saddr"}}, "set": "@ssh_meter", "stmt": [{"limit": {"rate": 3, "burst": 5, "per": "minute", "inv": true}}]}}, {"counter": {"packets": 0, "bytes": 0}}, {"drop": null}]}}, {"rule": {"family": "inet", "table": "main", "chain": "input", "handle": 11, "expr": [{"match": {"op": "==", "left": {"payload": {"protocol": "tcp", "field": "dport"}}, "right": 22}}, {"match": {"op": "in", "left": {"ct": {"key": "state"}}, "right": "new"}}, {"set": {"op": "update", "elem": {"&": [{"payload": {"protocol": "ip6", "field": "saddr"}}, "ffff:ffff:ffff:ffff::"]}, "set": "@ssh6_meter", "stmt": [{"limit": {"rate": 3, "burst": 5, "per": "minute", "inv": true}}]}}, {"counter": {"packets": 0, "bytes": 0}}, {"drop": null}]}}, {"rule": {"family": "inet", "table": "main", "chain": "input", "handle": 12, "expr": [{"match": {"op": "==", "left": {"payload": {"protocol": "ip", "field": "saddr"}}, "right": "@trusted"}}, {"match": {"op": "==", "left": {"payload": {"protocol": "tcp", "field": "dport"}}, "right": 22}}, {"accept": null}]}}, {"rule": {"family": "inet", "table": "main", "chain": "forward", "handle": 13, "expr": [{"match": {"op": "in", "left": {"ct": {"key": "state"}}, "right": "new"}}, {"set": {"op": "add", "elem": {"payload": {"protocol": "ip", "field": "saddr"}}, "set": "@my_connlimit", "stmt": [{"ct count": {"val": 20, "inv": true}}]}}, {"counter": {"packets": 0, "bytes": 0}}, {"drop": null}]}}, {"rule": {"family": "inet", "table": "main", "chain": "forward", "handle": 14, "expr": [{"match": {"op": "==", "left": {"meta": {"key": "iifname"}}, "right": "lan"}}, {"match": {"op": "==", "left": {"meta": {"key": "oifname"}}, "right": "wan"}}, {"limit": {"rate": 10, "burst": 512, "per": "second", "inv": true, "rate_unit": "mbytes", "burst_unit": "kbytes"}}, {"drop": null}]}}, {"rule": {"family": "inet", "table": "main", "chain": "forward", "handle": 15, "expr": [{"match": {"op": "==", "left": {"meta": {"key": "iifname"}}, "right": "guest0"}}, {"match": {"op": "==", "left": {"meta": {"key": "oifname"}}, "right": "wan"}}, {"set": {"op": "add", "elem": {"&": [{"payload": {"protocol": "ip", "field": "saddr"}}, "255.255.255.0"]}, "set": "@guest_quota", "stmt": [{"quota": {"val": 1000, "val_unit": "mbytes", "inv": true}}]}}, {"drop": null}]}}, {"rule": {"family": "inet", "table": "main", "chain": "forward", "handle": 16, "expr": [{"match": {"op": "==", "left": {"meta": {"key": "iifname"}}, "right": "lan"}}, {"match": {"op": "==", "left": {"meta": {"key": "oifname"}}, "right": "wan"}}, {"quota": {"val": 100000, "val_unit": "mbytes", "inv": true}}, {"drop": null}]}}, {"rule": {"family": "inet", "table": "main", "chain": "forward", "handle": 17, "expr": [{"match": {"op": "==", "left": {"meta": {"key": "iifname"}}, "right": "lan"}}, {"match": {"op": "==", "left": {"meta": {"key": "oifname"}}, "right": "wan"}}, {"accept": null}]}}]}
|
||||||
+46
@@ -0,0 +1,46 @@
|
|||||||
|
# Meters, byte rates and quotas, as hand-written rulesets use them (importer_test.go).
|
||||||
|
table inet main {
|
||||||
|
set ssh_meter {
|
||||||
|
type ipv4_addr
|
||||||
|
flags dynamic,timeout
|
||||||
|
timeout 1m
|
||||||
|
}
|
||||||
|
set ssh6_meter {
|
||||||
|
type ipv6_addr
|
||||||
|
flags dynamic,timeout
|
||||||
|
timeout 1m
|
||||||
|
}
|
||||||
|
set my_connlimit {
|
||||||
|
type ipv4_addr
|
||||||
|
size 65535
|
||||||
|
flags dynamic
|
||||||
|
}
|
||||||
|
set guest_quota {
|
||||||
|
type ipv4_addr
|
||||||
|
size 65535
|
||||||
|
flags dynamic
|
||||||
|
}
|
||||||
|
set trusted {
|
||||||
|
type ipv4_addr
|
||||||
|
flags interval
|
||||||
|
elements = { 10.0.10.0/24 }
|
||||||
|
}
|
||||||
|
chain input {
|
||||||
|
type filter hook input priority filter; policy drop;
|
||||||
|
ct state established,related accept
|
||||||
|
tcp dport 22 ct state new update @ssh_meter { ip saddr limit rate over 3/minute burst 5 packets } counter drop
|
||||||
|
tcp dport 22 ct state new update @ssh6_meter { ip6 saddr and ffff:ffff:ffff:ffff:: limit rate over 3/minute } counter drop
|
||||||
|
ip saddr @trusted tcp dport 22 accept
|
||||||
|
}
|
||||||
|
chain forward {
|
||||||
|
type filter hook forward priority filter; policy drop;
|
||||||
|
ct state new add @my_connlimit { ip saddr ct count over 20 } counter drop
|
||||||
|
iifname "lan" oifname "wan" limit rate over 10 mbytes/second burst 512 kbytes drop
|
||||||
|
iifname "guest0" oifname "wan" add @guest_quota { ip saddr and 255.255.255.0 quota over 1000 mbytes } drop
|
||||||
|
iifname "lan" oifname "wan" quota over 100000 mbytes drop
|
||||||
|
iifname "lan" oifname "wan" accept
|
||||||
|
}
|
||||||
|
chain output {
|
||||||
|
type filter hook output priority filter; policy accept;
|
||||||
|
}
|
||||||
|
}
|
||||||
+54
@@ -0,0 +1,54 @@
|
|||||||
|
table inet main { # handle 1
|
||||||
|
set ssh_meter { # handle 4
|
||||||
|
type ipv4_addr
|
||||||
|
size 65535
|
||||||
|
flags dynamic,timeout
|
||||||
|
timeout 1m
|
||||||
|
}
|
||||||
|
|
||||||
|
set ssh6_meter { # handle 5
|
||||||
|
type ipv6_addr
|
||||||
|
size 65535
|
||||||
|
flags dynamic,timeout
|
||||||
|
timeout 1m
|
||||||
|
}
|
||||||
|
|
||||||
|
set my_connlimit { # handle 6
|
||||||
|
type ipv4_addr
|
||||||
|
size 65535
|
||||||
|
flags dynamic
|
||||||
|
}
|
||||||
|
|
||||||
|
set guest_quota { # handle 7
|
||||||
|
type ipv4_addr
|
||||||
|
size 65535
|
||||||
|
flags dynamic
|
||||||
|
}
|
||||||
|
|
||||||
|
set trusted { # handle 8
|
||||||
|
type ipv4_addr
|
||||||
|
flags interval
|
||||||
|
elements = { 10.0.10.0/24 }
|
||||||
|
}
|
||||||
|
|
||||||
|
chain input { # handle 1
|
||||||
|
type filter hook input priority filter; policy drop;
|
||||||
|
ct state established,related accept # handle 9
|
||||||
|
tcp dport 22 ct state new update @ssh_meter { ip saddr limit rate over 3/minute burst 5 packets } counter packets 0 bytes 0 drop # handle 10
|
||||||
|
tcp dport 22 ct state new update @ssh6_meter { ip6 saddr & ffff:ffff:ffff:ffff:: limit rate over 3/minute burst 5 packets } counter packets 0 bytes 0 drop # handle 11
|
||||||
|
ip saddr @trusted tcp dport 22 accept # handle 12
|
||||||
|
}
|
||||||
|
|
||||||
|
chain forward { # handle 2
|
||||||
|
type filter hook forward priority filter; policy drop;
|
||||||
|
ct state new add @my_connlimit { ip saddr ct count over 20 } counter packets 0 bytes 0 drop # handle 13
|
||||||
|
iifname "lan" oifname "wan" limit rate over 10 mbytes/second burst 512 kbytes drop # handle 14
|
||||||
|
iifname "guest0" oifname "wan" add @guest_quota { ip saddr & 255.255.255.0 quota over 1000 mbytes } drop # handle 15
|
||||||
|
iifname "lan" oifname "wan" quota over 100000 mbytes drop # handle 16
|
||||||
|
iifname "lan" oifname "wan" accept # handle 17
|
||||||
|
}
|
||||||
|
|
||||||
|
chain output { # handle 3
|
||||||
|
type filter hook output priority filter; policy accept;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Changeset statuses.
|
||||||
|
const (
|
||||||
|
ChangesetOpen = "open"
|
||||||
|
ChangesetApplied = "applied"
|
||||||
|
ChangesetDiscarded = "discarded"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Changeset is a list of changes, staged to be reviewed and applied
|
||||||
|
// together (or discarded). Applied, it is one history entry.
|
||||||
|
type Changeset struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
UserID int64 `json:"user_id,omitempty"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Via string `json:"via"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Ops []Op `json:"ops"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
// HistoryID is the history entry an applied changeset became.
|
||||||
|
HistoryID int64 `json:"history_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Operations.
|
||||||
|
const (
|
||||||
|
OpCreate = "create"
|
||||||
|
OpUpdate = "update"
|
||||||
|
OpDelete = "delete"
|
||||||
|
OpMove = "move"
|
||||||
|
OpSettings = "settings"
|
||||||
|
// OpRevert undoes a history entry: Data is the entry (its changes),
|
||||||
|
// ID its number.
|
||||||
|
OpRevert = "revert"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Op is one change, as the API received it, so it can be stored and
|
||||||
|
// replayed on whatever the configuration is when it is applied.
|
||||||
|
type Op struct {
|
||||||
|
Seq int `json:"seq"`
|
||||||
|
Op string `json:"op"` // create, update, delete, move, settings, revert
|
||||||
|
Kind string `json:"kind,omitempty"`
|
||||||
|
// Ref names the object (ID, or name) for update, delete and move.
|
||||||
|
Ref string `json:"ref,omitempty"`
|
||||||
|
// ID is the object a create makes, given when it is staged so later
|
||||||
|
// operations can name it.
|
||||||
|
ID int64 `json:"id,omitempty"`
|
||||||
|
// Data is the object (create), the fields to change (update,
|
||||||
|
// settings) or where to move it (move).
|
||||||
|
Data json.RawMessage `json:"data,omitempty"`
|
||||||
|
Summary string `json:"summary"`
|
||||||
|
AddedAt time.Time `json:"added_at"`
|
||||||
|
}
|
||||||
@@ -133,8 +133,16 @@ type Rule struct {
|
|||||||
// LimitOver, beyond) a rate.
|
// LimitOver, beyond) a rate.
|
||||||
Limit *Limit `json:"limit,omitempty"`
|
Limit *Limit `json:"limit,omitempty"`
|
||||||
// ConnLimit matches when a source already has more than this many
|
// ConnLimit matches when a source already has more than this many
|
||||||
// connections (per source address).
|
// connections (per source address, grouped as GroupV4/GroupV6 say).
|
||||||
ConnLimit int `json:"conn_limit,omitempty"`
|
ConnLimit int `json:"conn_limit,omitempty"`
|
||||||
|
// Quota, if set, is an allowance of bytes: the rule matches once it
|
||||||
|
// is used up (Over), or while it lasts.
|
||||||
|
Quota *Quota `json:"quota,omitempty"`
|
||||||
|
// GroupV4 and GroupV6 group addresses for the per-address limits,
|
||||||
|
// connection limit and quota: by prefix length (0: the default, each
|
||||||
|
// IPv4 address alone, IPv6 by /64, since one device has several).
|
||||||
|
GroupV4 int `json:"group_v4,omitempty"`
|
||||||
|
GroupV6 int `json:"group_v6,omitempty"`
|
||||||
Action string `json:"action"`
|
Action string `json:"action"`
|
||||||
// RejectWith: "" (ICMP port/admin unreachable as fits) or tcp-reset.
|
// RejectWith: "" (ICMP port/admin unreachable as fits) or tcp-reset.
|
||||||
RejectWith string `json:"reject_with,omitempty"`
|
RejectWith string `json:"reject_with,omitempty"`
|
||||||
@@ -155,18 +163,105 @@ func (r *Rule) Label() string {
|
|||||||
return fmt.Sprintf("%s #%d", r.Chain, r.ID)
|
return fmt.Sprintf("%s #%d", r.Chain, r.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Limit is a rate: Rate packets per Per (second, minute, hour, day), with
|
// Limit is a rate: Rate packets (or bytes, with Unit) per Per (second,
|
||||||
// a burst.
|
// minute, hour, day), with a burst.
|
||||||
type Limit struct {
|
type Limit struct {
|
||||||
Rate int `json:"rate"`
|
Rate int `json:"rate"`
|
||||||
Per string `json:"per"`
|
Per string `json:"per"`
|
||||||
|
// Burst is in the rate's unit.
|
||||||
Burst int `json:"burst,omitempty"`
|
Burst int `json:"burst,omitempty"`
|
||||||
|
// Unit: "" for packets, or bytes, kbytes or mbytes for a byte rate
|
||||||
|
// (bandwidth).
|
||||||
|
Unit string `json:"unit,omitempty"`
|
||||||
// Over matches packets beyond the rate instead of within it (to drop
|
// Over matches packets beyond the rate instead of within it (to drop
|
||||||
// floods).
|
// floods).
|
||||||
Over bool `json:"over,omitempty"`
|
Over bool `json:"over,omitempty"`
|
||||||
// PerSource counts each source address on its own (a meter), instead
|
// PerSource counts each source address on its own (a meter), instead
|
||||||
// of all traffic the rule sees together.
|
// of all traffic the rule sees together; PerDestination each
|
||||||
|
// destination.
|
||||||
PerSource bool `json:"per_source,omitempty"`
|
PerSource bool `json:"per_source,omitempty"`
|
||||||
|
PerDestination bool `json:"per_destination,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Byte units, as nftables writes them.
|
||||||
|
var ByteUnits = map[string]int64{"bytes": 1, "kbytes": 1 << 10, "mbytes": 1 << 20, "gbytes": 1 << 30}
|
||||||
|
|
||||||
|
// Quota is an allowance of bytes. nftables counts it; Housecarl keeps the
|
||||||
|
// count across changes and restarts, and resets it each period.
|
||||||
|
type Quota struct {
|
||||||
|
Bytes int64 `json:"bytes"`
|
||||||
|
// Over matches once the allowance is used up (to drop); otherwise the
|
||||||
|
// rule matches while it lasts (to accept).
|
||||||
|
Over bool `json:"over,omitempty"`
|
||||||
|
// PerSource gives each source address (grouped as the rule's
|
||||||
|
// GroupV4/GroupV6 say) its own allowance; PerDestination each
|
||||||
|
// destination.
|
||||||
|
PerSource bool `json:"per_source,omitempty"`
|
||||||
|
PerDestination bool `json:"per_destination,omitempty"`
|
||||||
|
// Reset: "" (only by hand), daily, weekly (on Mondays) or monthly (on
|
||||||
|
// Day, 1-31: a short month's last day when it has no such day), at
|
||||||
|
// midnight.
|
||||||
|
Reset string `json:"reset,omitempty"`
|
||||||
|
Day int `json:"day,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quota resets.
|
||||||
|
var QuotaResets = []string{"daily", "weekly", "monthly"}
|
||||||
|
|
||||||
|
// PerAddress reports whether a quota (or limit) counts each address on its
|
||||||
|
// own, and which: "saddr", "daddr" or "".
|
||||||
|
func (q *Quota) PerAddress() string {
|
||||||
|
switch {
|
||||||
|
case q == nil:
|
||||||
|
return ""
|
||||||
|
case q.PerSource:
|
||||||
|
return "saddr"
|
||||||
|
case q.PerDestination:
|
||||||
|
return "daddr"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// PerAddress: as Quota's.
|
||||||
|
func (l *Limit) PerAddress() string {
|
||||||
|
switch {
|
||||||
|
case l == nil:
|
||||||
|
return ""
|
||||||
|
case l.PerSource:
|
||||||
|
return "saddr"
|
||||||
|
case l.PerDestination:
|
||||||
|
return "daddr"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountsTraffic reports whether a rule measures traffic (a quota or a byte
|
||||||
|
// rate): it must see every packet of a connection, so it is checked before
|
||||||
|
// the built-in that accepts established connections.
|
||||||
|
func (r *Rule) CountsTraffic() bool {
|
||||||
|
if r.Raw != "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if r.Quota != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if r.Limit == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, bytes := ByteUnits[r.Limit.Unit]
|
||||||
|
return bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
// Groups returns a rule's address grouping, defaults applied.
|
||||||
|
func (r *Rule) Groups() (v4, v6 int) {
|
||||||
|
v4, v6 = r.GroupV4, r.GroupV6
|
||||||
|
if v4 == 0 {
|
||||||
|
v4 = 32
|
||||||
|
}
|
||||||
|
if v6 == 0 {
|
||||||
|
v6 = 64
|
||||||
|
}
|
||||||
|
return v4, v6
|
||||||
}
|
}
|
||||||
|
|
||||||
// PortForward publishes a service on an inside host: incoming connections
|
// PortForward publishes a service on an inside host: incoming connections
|
||||||
|
|||||||
@@ -31,6 +31,11 @@ type Settings struct {
|
|||||||
// ProbationSeconds is how long a change made over the network has to
|
// ProbationSeconds is how long a change made over the network has to
|
||||||
// be confirmed before it is rolled back (0: the default).
|
// be confirmed before it is rolled back (0: the default).
|
||||||
ProbationSeconds int `json:"probation_seconds,omitempty"`
|
ProbationSeconds int `json:"probation_seconds,omitempty"`
|
||||||
|
// ManualConfirm: changes made over the network wait for a person to
|
||||||
|
// confirm them. Otherwise the client that made a change confirms it by
|
||||||
|
// itself once it reaches the API again over a new connection, from the
|
||||||
|
// same address: proof that it can still manage the firewall.
|
||||||
|
ManualConfirm bool `json:"manual_confirm,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Builtins are rules Housecarl adds itself, shown as "added by Housecarl".
|
// Builtins are rules Housecarl adds itself, shown as "added by Housecarl".
|
||||||
|
|||||||
+120
-1
@@ -196,6 +196,9 @@ type Table struct {
|
|||||||
Counters map[string]Counter
|
Counters map[string]Counter
|
||||||
// Elements by set name (both families together).
|
// Elements by set name (both families together).
|
||||||
Elements map[string][]render.Element
|
Elements map[string][]render.Element
|
||||||
|
// Quotas: the rules' quotas' used bytes (quota_r12), and the
|
||||||
|
// per-address quotas' entries by set (quota_r12_v4).
|
||||||
|
QuotaUsage
|
||||||
// Since is when the counters started (housecarld's first apply).
|
// Since is when the counters started (housecarld's first apply).
|
||||||
Since time.Time
|
Since time.Time
|
||||||
// Tables are all the machine's tables ("inet main"), when the whole
|
// Tables are all the machine's tables ("inet main"), when the whole
|
||||||
@@ -203,6 +206,48 @@ type Table struct {
|
|||||||
Tables []string
|
Tables []string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// QuotaUsage is what the quotas used: kept across reloads, and saved in
|
||||||
|
// the database so a reboot doesn't reset it.
|
||||||
|
type QuotaUsage struct {
|
||||||
|
Quotas map[string]int64 `json:"quotas,omitempty"`
|
||||||
|
QuotaSets map[string][]render.Element `json:"quota_sets,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// merge takes the larger usage of each quota and entry from o (what was
|
||||||
|
// saved before a reboot, when the kernel's count started again).
|
||||||
|
func (u *QuotaUsage) merge(o *QuotaUsage) {
|
||||||
|
if o == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if u.Quotas == nil {
|
||||||
|
u.Quotas = map[string]int64{}
|
||||||
|
}
|
||||||
|
if u.QuotaSets == nil {
|
||||||
|
u.QuotaSets = map[string][]render.Element{}
|
||||||
|
}
|
||||||
|
for k, v := range o.Quotas {
|
||||||
|
if v > u.Quotas[k] {
|
||||||
|
u.Quotas[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for set, es := range o.QuotaSets {
|
||||||
|
for _, e := range es {
|
||||||
|
found := false
|
||||||
|
for i := range u.QuotaSets[set] {
|
||||||
|
if u.QuotaSets[set][i].Addr == e.Addr {
|
||||||
|
found = true
|
||||||
|
if e.Used > u.QuotaSets[set][i].Used {
|
||||||
|
u.QuotaSets[set][i].Used = e.Used
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
u.QuotaSets[set] = append(u.QuotaSets[set], e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Counter is packets and bytes.
|
// Counter is packets and bytes.
|
||||||
type Counter struct {
|
type Counter struct {
|
||||||
Packets uint64 `json:"packets"`
|
Packets uint64 `json:"packets"`
|
||||||
@@ -223,9 +268,25 @@ type jsonSet struct {
|
|||||||
Elem []json.RawMessage `json:"elem"`
|
Elem []json.RawMessage `json:"elem"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// jsonQuota is a quota's state: a named quota ({"bytes", "used"}, in
|
||||||
|
// bytes) or one in an element ({"val", "val_unit", "used", "used_unit"}).
|
||||||
|
type jsonQuota struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Used int64 `json:"used"`
|
||||||
|
UsedUnit string `json:"used_unit"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q jsonQuota) bytes() int64 {
|
||||||
|
if m, ok := model.ByteUnits[q.UsedUnit]; ok {
|
||||||
|
return q.Used * m
|
||||||
|
}
|
||||||
|
return q.Used
|
||||||
|
}
|
||||||
|
|
||||||
// ParseTable reads `nft -j list table inet housecarl`.
|
// ParseTable reads `nft -j list table inet housecarl`.
|
||||||
func ParseTable(data []byte) (*Table, error) {
|
func ParseTable(data []byte) (*Table, error) {
|
||||||
t := &Table{Counters: map[string]Counter{}, Elements: map[string][]render.Element{}}
|
t := &Table{Counters: map[string]Counter{}, Elements: map[string][]render.Element{},
|
||||||
|
QuotaUsage: QuotaUsage{Quotas: map[string]int64{}, QuotaSets: map[string][]render.Element{}}}
|
||||||
if len(data) == 0 {
|
if len(data) == 0 {
|
||||||
return t, nil
|
return t, nil
|
||||||
}
|
}
|
||||||
@@ -252,11 +313,31 @@ func ParseTable(data []byte) (*Table, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if raw, ok := item["quota"]; ok {
|
||||||
|
var q jsonQuota
|
||||||
|
if json.Unmarshal(raw, &q) == nil && strings.HasPrefix(q.Name, "quota_r") {
|
||||||
|
t.Quotas[q.Name] = q.bytes()
|
||||||
|
}
|
||||||
|
}
|
||||||
if raw, ok := item["set"]; ok {
|
if raw, ok := item["set"]; ok {
|
||||||
var s jsonSet
|
var s jsonSet
|
||||||
if json.Unmarshal(raw, &s) != nil {
|
if json.Unmarshal(raw, &s) != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if strings.HasPrefix(s.Name, "quota_r") {
|
||||||
|
for _, e := range s.Elem {
|
||||||
|
var w struct {
|
||||||
|
Elem struct {
|
||||||
|
Val string `json:"val"`
|
||||||
|
Quota *jsonQuota `json:"quota"`
|
||||||
|
} `json:"elem"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(e, &w) == nil && w.Elem.Quota != nil {
|
||||||
|
t.QuotaSets[s.Name] = append(t.QuotaSets[s.Name], render.Element{Addr: w.Elem.Val, Used: w.Elem.Quota.bytes()})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
name := strings.TrimSuffix(strings.TrimSuffix(s.Name, "_v4"), "_v6")
|
name := strings.TrimSuffix(strings.TrimSuffix(s.Name, "_v4"), "_v6")
|
||||||
if name == s.Name || strings.HasPrefix(s.Name, "meter_") || strings.HasPrefix(s.Name, "conn_") {
|
if name == s.Name || strings.HasPrefix(s.Name, "meter_") || strings.HasPrefix(s.Name, "conn_") {
|
||||||
continue
|
continue
|
||||||
@@ -307,6 +388,37 @@ type Firewall struct {
|
|||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
base map[string]Counter
|
base map[string]Counter
|
||||||
since time.Time
|
since time.Time
|
||||||
|
// restore is the quota usage saved before housecarld (or the machine)
|
||||||
|
// stopped, put back by the next apply.
|
||||||
|
restore *QuotaUsage
|
||||||
|
}
|
||||||
|
|
||||||
|
// RestoreQuotas has the next apply put this saved quota usage back where
|
||||||
|
// the kernel's count is lower (after a reboot, the boot ruleset starts
|
||||||
|
// every quota at zero).
|
||||||
|
func (f *Firewall) RestoreQuotas(u *QuotaUsage) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
f.restore = u
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResetQuota starts a rule's quota again: the named quota, or the
|
||||||
|
// per-address quota sets (both families).
|
||||||
|
func (f *Firewall) ResetQuota(ctx context.Context, ruleID int64, perAddress bool) error {
|
||||||
|
name := render.QuotaName(ruleID)
|
||||||
|
if !perAddress {
|
||||||
|
_, err := f.Runner.run(ctx, "", "reset", "quota", "inet", render.Table, name)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, fam := range []string{"_v4", "_v6"} {
|
||||||
|
if _, err := f.Runner.run(ctx, "", "flush", "set", "inet", render.Table, name+fam); err != nil {
|
||||||
|
if e, ok := err.(*Error); ok && strings.Contains(e.Output, "No such file or directory") {
|
||||||
|
continue // the rule has no such family
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *Firewall) options() render.Options {
|
func (f *Firewall) options() render.Options {
|
||||||
@@ -337,9 +449,16 @@ func (f *Firewall) Apply(ctx context.Context, st *model.State) error {
|
|||||||
old = t
|
old = t
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
usage := QuotaUsage{}
|
||||||
|
if old != nil {
|
||||||
|
usage = old.QuotaUsage
|
||||||
|
}
|
||||||
|
usage.merge(f.restore)
|
||||||
|
opt.Quotas, opt.QuotaSets = usage.Quotas, usage.QuotaSets
|
||||||
if err := f.Runner.Apply(ctx, render.Render(st, opt)); err != nil {
|
if err := f.Runner.Apply(ctx, render.Render(st, opt)); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
f.restore = nil
|
||||||
if f.base == nil {
|
if f.base == nil {
|
||||||
f.base, f.since = map[string]Counter{}, time.Now()
|
f.base, f.since = map[string]Counter{}, time.Now()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,3 +125,86 @@ func TestFirewallInContainer(t *testing.T) {
|
|||||||
t.Errorf("another table was touched: %s", out)
|
t.Errorf("another table was touched: %s", out)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestQuotasInContainer: quotas keep what they used across a reload,
|
||||||
|
// reset by hand, and come back after a reboot from what was saved.
|
||||||
|
func TestQuotasInContainer(t *testing.T) {
|
||||||
|
image := os.Getenv("HOUSECARL_NFT_IMAGE")
|
||||||
|
if image == "" {
|
||||||
|
t.Skip("HOUSECARL_NFT_IMAGE not set")
|
||||||
|
}
|
||||||
|
name := "housecarl-nft-quota-test"
|
||||||
|
exec.Command("docker", "rm", "-f", name).Run()
|
||||||
|
if out, err := exec.Command("docker", "run", "-d", "--name", name, "--cap-add", "NET_ADMIN", image, "sleep", "300").CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("starting the container: %v %s", err, out)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { exec.Command("docker", "rm", "-f", name).Run() })
|
||||||
|
fw := &Firewall{Runner: &Runner{Command: []string{"docker", "exec", "-i", name, "nft"}}}
|
||||||
|
ctx := context.Background()
|
||||||
|
st := model.NewState()
|
||||||
|
// A total quota on pings to 127.0.0.2, and one per destination on
|
||||||
|
// pings to 127.0.0.3.
|
||||||
|
st.Rules = []*model.Rule{
|
||||||
|
{ID: 1, Chain: model.ChainOutput, Position: 1, Action: model.ActionDrop, Match: model.Match{Destinations: []string{"127.0.0.2"}},
|
||||||
|
Quota: &model.Quota{Bytes: 10 << 20, Over: true}},
|
||||||
|
{ID: 2, Chain: model.ChainOutput, Position: 2, Action: model.ActionDrop, Match: model.Match{Destinations: []string{"127.0.0.3"}},
|
||||||
|
Quota: &model.Quota{Bytes: 10 << 20, Over: true, PerDestination: true}},
|
||||||
|
}
|
||||||
|
if err := fw.Apply(ctx, st); err != nil {
|
||||||
|
t.Fatalf("apply: %v", err)
|
||||||
|
}
|
||||||
|
ping := func(addr string) {
|
||||||
|
exec.Command("docker", "exec", name, "ping", "-c", "3", "-s", "1000", "-W", "1", addr).Run()
|
||||||
|
}
|
||||||
|
ping("127.0.0.2")
|
||||||
|
ping("127.0.0.3")
|
||||||
|
used := func() (int64, int64) {
|
||||||
|
tb, err := fw.Table(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var dev int64
|
||||||
|
for _, e := range tb.QuotaSets["quota_r2_v4"] {
|
||||||
|
if e.Addr == "127.0.0.3" {
|
||||||
|
dev = e.Used
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tb.Quotas["quota_r1"], dev
|
||||||
|
}
|
||||||
|
total, dev := used()
|
||||||
|
if total < 3000 || dev < 3000 {
|
||||||
|
t.Fatalf("used: total %d, per destination %d", total, dev)
|
||||||
|
}
|
||||||
|
// A reload (a change) keeps what was used.
|
||||||
|
st.Rules[0].Comment = "changed"
|
||||||
|
if err := fw.Apply(ctx, st); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if t2, d2 := used(); t2 < total || d2 < dev {
|
||||||
|
t.Errorf("after a reload: total %d (was %d), per destination %d (was %d)", t2, total, d2, dev)
|
||||||
|
}
|
||||||
|
// Reset by hand.
|
||||||
|
if err := fw.ResetQuota(ctx, 1, false); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := fw.ResetQuota(ctx, 2, true); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if t3, d3 := used(); t3 != 0 || d3 != 0 {
|
||||||
|
t.Errorf("after a reset: total %d, per destination %d", t3, d3)
|
||||||
|
}
|
||||||
|
// A reboot: the table is gone (the boot ruleset has no usage); the
|
||||||
|
// saved usage comes back.
|
||||||
|
ping("127.0.0.2")
|
||||||
|
ping("127.0.0.3")
|
||||||
|
tb, _ := fw.Table(ctx)
|
||||||
|
saved := tb.QuotaUsage
|
||||||
|
exec.Command("docker", "exec", name, "nft", "delete", "table", "inet", "housecarl").Run()
|
||||||
|
fw.RestoreQuotas(&saved)
|
||||||
|
if err := fw.Apply(ctx, st); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if t4, d4 := used(); t4 < saved.Quotas["quota_r1"] || t4 == 0 || d4 == 0 {
|
||||||
|
t.Errorf("after a reboot: total %d, per destination %d (saved %+v)", t4, d4, saved)
|
||||||
|
}
|
||||||
|
}
|
||||||
+131
-18
@@ -12,6 +12,7 @@ package render
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/netip"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -33,14 +34,25 @@ type Options struct {
|
|||||||
// Elements are dynamic sets' current entries, re-added in the same
|
// Elements are dynamic sets' current entries, re-added in the same
|
||||||
// transaction so a reload keeps them: set name → entries.
|
// transaction so a reload keeps them: set name → entries.
|
||||||
Elements map[string][]Element
|
Elements map[string][]Element
|
||||||
|
// Quotas are the rules' quotas' used bytes (quota name, quota_r12),
|
||||||
|
// and QuotaSets the per-address quotas' entries (set name,
|
||||||
|
// quota_r12_v4), so a reload keeps what was used.
|
||||||
|
Quotas map[string]int64
|
||||||
|
QuotaSets map[string][]Element
|
||||||
}
|
}
|
||||||
|
|
||||||
// Element is a dynamic set's entry with its remaining lifetime.
|
// Element is a dynamic set's entry with its remaining lifetime, or a
|
||||||
|
// per-address quota's entry with what it used.
|
||||||
type Element struct {
|
type Element struct {
|
||||||
Addr string `json:"address"`
|
Addr string `json:"address"`
|
||||||
Timeout int `json:"timeout,omitempty"` // seconds left (0: none)
|
Timeout int `json:"timeout,omitempty"` // seconds left (0: none)
|
||||||
|
Used int64 `json:"used,omitempty"` // bytes
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// QuotaName is the name of a rule's quota: the named quota of a total
|
||||||
|
// quota, and (with _v4, _v6) the sets of a per-address one.
|
||||||
|
func QuotaName(ruleID int64) string { return fmt.Sprintf("quota_r%d", ruleID) }
|
||||||
|
|
||||||
// Ruleset is the rendered nftables script.
|
// Ruleset is the rendered nftables script.
|
||||||
type Ruleset struct {
|
type Ruleset struct {
|
||||||
Text string
|
Text string
|
||||||
@@ -86,7 +98,8 @@ type renderer struct {
|
|||||||
st *model.State
|
st *model.State
|
||||||
opt Options
|
opt Options
|
||||||
w *writer
|
w *writer
|
||||||
// meters are the per-source limit and connection sets the rules need.
|
// meters are the per-address limit, connection and quota sets, and
|
||||||
|
// the named quotas, the rules need.
|
||||||
meters []string
|
meters []string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,7 +251,8 @@ func (r *renderer) early() {
|
|||||||
r.builtin("antispoof6", "iifname %s ip6 saddr { %s } counter drop", ifaces(ext), strings.Join(v6, ", "))
|
r.builtin("antispoof6", "iifname %s ip6 saddr { %s } counter drop", ifaces(ext), strings.Join(v6, ", "))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
r.rules(model.ChainEarly)
|
r.rules(model.ChainEarly, true)
|
||||||
|
r.rules(model.ChainEarly, false)
|
||||||
r.end()
|
r.end()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,6 +279,9 @@ func (r *renderer) input() {
|
|||||||
if b.Loopback {
|
if b.Loopback {
|
||||||
r.builtin("loopback", "iifname \"lo\" accept")
|
r.builtin("loopback", "iifname \"lo\" accept")
|
||||||
}
|
}
|
||||||
|
// Rules that count traffic see every packet: before the established
|
||||||
|
// connections are accepted.
|
||||||
|
r.rules(model.ChainInput, true)
|
||||||
r.connBuiltins()
|
r.connBuiltins()
|
||||||
ext := st.ExternalInterfaces()
|
ext := st.ExternalInterfaces()
|
||||||
if b.ICMPv6 {
|
if b.ICMPv6 {
|
||||||
@@ -282,7 +299,7 @@ func (r *renderer) input() {
|
|||||||
if b.DHCPv6Client && len(ext) > 0 {
|
if b.DHCPv6Client && len(ext) > 0 {
|
||||||
r.builtin("dhcpv6-client", "iifname %s ip6 saddr fe80::/10 udp dport 546 counter accept", ifaces(ext))
|
r.builtin("dhcpv6-client", "iifname %s ip6 saddr fe80::/10 udp dport 546 counter accept", ifaces(ext))
|
||||||
}
|
}
|
||||||
r.rules(model.ChainInput)
|
r.rules(model.ChainInput, false)
|
||||||
r.policyTail("input", st.Settings.InputPolicy)
|
r.policyTail("input", st.Settings.InputPolicy)
|
||||||
r.end()
|
r.end()
|
||||||
}
|
}
|
||||||
@@ -295,6 +312,7 @@ func (r *renderer) forward(flowtable bool) {
|
|||||||
if b.MSSClamp && len(ext) > 0 {
|
if b.MSSClamp && len(ext) > 0 {
|
||||||
r.builtin("mss-clamp", "oifname %s tcp flags syn tcp option maxseg size set rt mtu", ifaces(ext))
|
r.builtin("mss-clamp", "oifname %s tcp flags syn tcp option maxseg size set rt mtu", ifaces(ext))
|
||||||
}
|
}
|
||||||
|
r.rules(model.ChainForward, true)
|
||||||
if flowtable {
|
if flowtable {
|
||||||
r.builtin("flowtable", "meta l4proto { tcp, udp } flow add @ft")
|
r.builtin("flowtable", "meta l4proto { tcp, udp } flow add @ft")
|
||||||
}
|
}
|
||||||
@@ -303,7 +321,7 @@ func (r *renderer) forward(flowtable bool) {
|
|||||||
r.builtin("icmpv6", "icmpv6 type { %s } counter accept", icmpv6Errors)
|
r.builtin("icmpv6", "icmpv6 type { %s } counter accept", icmpv6Errors)
|
||||||
}
|
}
|
||||||
r.forwardAccepts()
|
r.forwardAccepts()
|
||||||
r.rules(model.ChainForward)
|
r.rules(model.ChainForward, false)
|
||||||
r.policyTail("forward", st.Settings.ForwardPolicy)
|
r.policyTail("forward", st.Settings.ForwardPolicy)
|
||||||
r.end()
|
r.end()
|
||||||
}
|
}
|
||||||
@@ -311,6 +329,7 @@ func (r *renderer) forward(flowtable bool) {
|
|||||||
func (r *renderer) output() {
|
func (r *renderer) output() {
|
||||||
st := r.st
|
st := r.st
|
||||||
r.chain("output", "output", "filter", st.Settings.OutputPolicy)
|
r.chain("output", "output", "filter", st.Settings.OutputPolicy)
|
||||||
|
r.rules(model.ChainOutput, true)
|
||||||
if st.Settings.OutputPolicy == model.PolicyDrop {
|
if st.Settings.OutputPolicy == model.PolicyDrop {
|
||||||
if st.Settings.Builtins.Loopback {
|
if st.Settings.Builtins.Loopback {
|
||||||
r.builtin("loopback", "oifname \"lo\" accept")
|
r.builtin("loopback", "oifname \"lo\" accept")
|
||||||
@@ -322,7 +341,7 @@ func (r *renderer) output() {
|
|||||||
r.builtin("icmpv6", "icmpv6 type { %s, echo-request, echo-reply, %s, %s } counter accept", icmpv6Errors, icmpv6ND, icmpv6MLD)
|
r.builtin("icmpv6", "icmpv6 type { %s, echo-request, echo-reply, %s, %s } counter accept", icmpv6Errors, icmpv6ND, icmpv6MLD)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
r.rules(model.ChainOutput)
|
r.rules(model.ChainOutput, false)
|
||||||
r.policyTail("output", st.Settings.OutputPolicy)
|
r.policyTail("output", st.Settings.OutputPolicy)
|
||||||
r.end()
|
r.end()
|
||||||
}
|
}
|
||||||
@@ -341,9 +360,11 @@ func (r *renderer) policyTail(chain, policy string) {
|
|||||||
|
|
||||||
// ---- rules
|
// ---- rules
|
||||||
|
|
||||||
func (r *renderer) rules(chain string) {
|
// rules writes a chain's rules: those that count traffic (traffic true),
|
||||||
|
// or the others.
|
||||||
|
func (r *renderer) rules(chain string, traffic bool) {
|
||||||
for _, rule := range r.st.Rules {
|
for _, rule := range r.st.Rules {
|
||||||
if rule.Chain != chain || rule.Disabled {
|
if rule.Chain != chain || rule.Disabled || rule.CountsTraffic() != traffic {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
obj := rule.Label()
|
obj := rule.Label()
|
||||||
@@ -370,8 +391,8 @@ func (r *renderer) rules(chain string) {
|
|||||||
tail = append(tail, hours(rule.HourFrom, rule.HourTo))
|
tail = append(tail, hours(rule.HourFrom, rule.HourTo))
|
||||||
}
|
}
|
||||||
verdict := r.verdict(rule, mark)
|
verdict := r.verdict(rule, mark)
|
||||||
perSource := rule.ConnLimit > 0 || rule.Limit != nil && rule.Limit.PerSource
|
perAddress := rule.ConnLimit > 0 || rule.Limit.PerAddress() != "" || rule.Quota.PerAddress() != ""
|
||||||
for _, line := range r.expand(rule.Match, perSource) {
|
for _, line := range r.expand(rule.Match, perAddress) {
|
||||||
parts := append([]string{}, line.parts...)
|
parts := append([]string{}, line.parts...)
|
||||||
parts = append(parts, tail...)
|
parts = append(parts, tail...)
|
||||||
if l := rule.Limit; l != nil {
|
if l := rule.Limit; l != nil {
|
||||||
@@ -380,6 +401,9 @@ func (r *renderer) rules(chain string) {
|
|||||||
if rule.ConnLimit > 0 {
|
if rule.ConnLimit > 0 {
|
||||||
parts = append(parts, r.connLimit(rule, line))
|
parts = append(parts, r.connLimit(rule, line))
|
||||||
}
|
}
|
||||||
|
if rule.Quota != nil {
|
||||||
|
parts = append(parts, r.quota(rule, line))
|
||||||
|
}
|
||||||
parts = append(parts, verdict)
|
parts = append(parts, verdict)
|
||||||
r.w.emit(obj, "\t\t%s", strings.Join(nonEmpty(parts), " "))
|
r.w.emit(obj, "\t\t%s", strings.Join(nonEmpty(parts), " "))
|
||||||
}
|
}
|
||||||
@@ -410,20 +434,51 @@ func (r *renderer) verdict(rule *model.Rule, mark string) string {
|
|||||||
return strings.Join(v, " ")
|
return strings.Join(v, " ")
|
||||||
}
|
}
|
||||||
|
|
||||||
// limit renders a rate limit, per source with a meter set per family.
|
// limit renders a rate limit: of packets or bytes; per source or
|
||||||
|
// destination with a meter set per family, whose entries are refreshed
|
||||||
|
// by each packet (update) and forgotten once a source is quiet.
|
||||||
func (r *renderer) limit(rule *model.Rule, line expanded, l *model.Limit) string {
|
func (r *renderer) limit(rule *model.Rule, line expanded, l *model.Limit) string {
|
||||||
rate := fmt.Sprintf("limit rate %s%d/%s", map[bool]string{true: "over ", false: ""}[l.Over], l.Rate, l.Per)
|
unit := ""
|
||||||
if l.Burst > 0 {
|
if _, ok := model.ByteUnits[l.Unit]; ok {
|
||||||
rate += fmt.Sprintf(" burst %d packets", l.Burst)
|
unit = " " + l.Unit
|
||||||
}
|
}
|
||||||
if !l.PerSource {
|
rate := fmt.Sprintf("limit rate %s%d%s/%s", map[bool]string{true: "over ", false: ""}[l.Over], l.Rate, unit, l.Per)
|
||||||
|
if l.Burst > 0 {
|
||||||
|
burstUnit := " packets"
|
||||||
|
if unit != "" {
|
||||||
|
burstUnit = unit
|
||||||
|
}
|
||||||
|
rate += fmt.Sprintf(" burst %d%s", l.Burst, burstUnit)
|
||||||
|
}
|
||||||
|
addr := l.PerAddress()
|
||||||
|
if addr == "" {
|
||||||
return rate
|
return rate
|
||||||
}
|
}
|
||||||
name := fmt.Sprintf("meter_r%d_%s", rule.ID, line.fam)
|
name := fmt.Sprintf("meter_r%d_%s", rule.ID, line.fam)
|
||||||
if !r.hasMeter(name) {
|
if !r.hasMeter(name) {
|
||||||
r.meters = append(r.meters, fmt.Sprintf("\tset %s { type %s; flags dynamic,timeout; timeout 1%s; }", name, addrType(line.fam == "v6"), map[string]string{"second": "m", "minute": "m", "hour": "h", "day": "d"}[l.Per]))
|
r.meters = append(r.meters, fmt.Sprintf("\tset %s { type %s; flags dynamic,timeout; timeout 1%s; }", name, addrType(line.fam == "v6"), map[string]string{"second": "m", "minute": "m", "hour": "h", "day": "d"}[l.Per]))
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("add @%s { %s saddr %s }", name, ipKeyword(line.fam), rate)
|
return fmt.Sprintf("update @%s { %s %s }", name, r.addrKey(rule, line.fam, addr), rate)
|
||||||
|
}
|
||||||
|
|
||||||
|
// addrKey is what a per-address meter counts by: "ip saddr", or grouped
|
||||||
|
// by a prefix, "ip6 saddr and ffff:ffff:ffff:ffff::".
|
||||||
|
func (r *renderer) addrKey(rule *model.Rule, fam, addr string) string {
|
||||||
|
v4, v6 := rule.Groups()
|
||||||
|
bits, size := v4, 32
|
||||||
|
if fam == "v6" {
|
||||||
|
bits, size = v6, 128
|
||||||
|
}
|
||||||
|
key := ipKeyword(fam) + " " + addr
|
||||||
|
if bits >= size {
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
mask := make([]byte, size/8)
|
||||||
|
for i := 0; i < bits; i++ {
|
||||||
|
mask[i/8] |= 0x80 >> (i % 8)
|
||||||
|
}
|
||||||
|
m, _ := netip.AddrFromSlice(mask)
|
||||||
|
return key + " and " + m.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *renderer) connLimit(rule *model.Rule, line expanded) string {
|
func (r *renderer) connLimit(rule *model.Rule, line expanded) string {
|
||||||
@@ -431,12 +486,70 @@ func (r *renderer) connLimit(rule *model.Rule, line expanded) string {
|
|||||||
if !r.hasMeter(name) {
|
if !r.hasMeter(name) {
|
||||||
r.meters = append(r.meters, fmt.Sprintf("\tset %s { type %s; flags dynamic; size 65535; }", name, addrType(line.fam == "v6")))
|
r.meters = append(r.meters, fmt.Sprintf("\tset %s { type %s; flags dynamic; size 65535; }", name, addrType(line.fam == "v6")))
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("add @%s { %s saddr ct count over %d }", name, ipKeyword(line.fam), rule.ConnLimit)
|
return fmt.Sprintf("add @%s { %s ct count over %d }", name, r.addrKey(rule, line.fam, "saddr"), rule.ConnLimit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// quota renders a rule's quota: a named quota shared by the rule's lines
|
||||||
|
// (both families count against one allowance), or per address a set of
|
||||||
|
// quotas per family. What was used comes back from Options, so a reload
|
||||||
|
// doesn't reset it.
|
||||||
|
func (r *renderer) quota(rule *model.Rule, line expanded) string {
|
||||||
|
q := rule.Quota
|
||||||
|
amount := quotaAmount(q)
|
||||||
|
name := QuotaName(rule.ID)
|
||||||
|
addr := q.PerAddress()
|
||||||
|
if addr == "" {
|
||||||
|
if !r.hasMeter(name) {
|
||||||
|
used := ""
|
||||||
|
if u := r.opt.Quotas[name]; u > 0 {
|
||||||
|
used = fmt.Sprintf(" used %d bytes", u)
|
||||||
|
}
|
||||||
|
r.meters = append(r.meters, fmt.Sprintf("\tquota %s { %s%s }", name, amount, used))
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("quota name %s", quote(name))
|
||||||
|
}
|
||||||
|
set := name + "_" + line.fam
|
||||||
|
if !r.hasMeter(set) {
|
||||||
|
var elems []string
|
||||||
|
for _, e := range r.opt.QuotaSets[set] {
|
||||||
|
a, err := netip.ParseAddr(e.Addr)
|
||||||
|
if err != nil || a.Is6() != (line.fam == "v6") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
used := ""
|
||||||
|
if e.Used > 0 {
|
||||||
|
used = fmt.Sprintf(" used %d bytes", e.Used)
|
||||||
|
}
|
||||||
|
elems = append(elems, fmt.Sprintf("%s quota %s%s", a, amount, used))
|
||||||
|
}
|
||||||
|
list := ""
|
||||||
|
if len(elems) > 0 {
|
||||||
|
list = " elements = { " + strings.Join(elems, ", ") + " }"
|
||||||
|
}
|
||||||
|
r.meters = append(r.meters, fmt.Sprintf("\tset %s { type %s; flags dynamic; size 65535;%s }", set, addrType(line.fam == "v6"), list))
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("add @%s { %s quota %s }", set, r.addrKey(rule, line.fam, addr), amount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// quotaAmount writes a quota: "over 5000 mbytes" (nftables 1.1.3 has no
|
||||||
|
// gbytes).
|
||||||
|
func quotaAmount(q *model.Quota) string {
|
||||||
|
n, unit := q.Bytes, "bytes"
|
||||||
|
for _, u := range []string{"mbytes", "kbytes"} {
|
||||||
|
if n >= model.ByteUnits[u] && n%model.ByteUnits[u] == 0 {
|
||||||
|
n, unit = n/model.ByteUnits[u], u
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if q.Over {
|
||||||
|
return fmt.Sprintf("over %d %s", n, unit)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("until %d %s", n, unit)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *renderer) hasMeter(name string) bool {
|
func (r *renderer) hasMeter(name string) bool {
|
||||||
for _, m := range r.meters {
|
for _, m := range r.meters {
|
||||||
if strings.Contains(m, "set "+name+" ") {
|
if strings.Contains(m, "set "+name+" ") || strings.Contains(m, "quota "+name+" ") {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,6 +66,16 @@ func router() *model.State {
|
|||||||
rule(model.ChainForward, model.Rule{Match: model.Match{Destinations: []string{"wg_net"}}, Action: model.ActionDrop})
|
rule(model.ChainForward, model.Rule{Match: model.Match{Destinations: []string{"wg_net"}}, Action: model.ActionDrop})
|
||||||
rule(model.ChainForward, model.Rule{Match: model.Match{Services: []string{"ping"}}})
|
rule(model.ChainForward, model.Rule{Match: model.Match{Services: []string{"ping"}}})
|
||||||
rule(model.ChainForward, model.Rule{Match: model.Match{Sources: []string{"wg_net", "trusted", "guests"}}})
|
rule(model.ChainForward, model.Rule{Match: model.Match{Sources: []string{"wg_net", "trusted", "guests"}}})
|
||||||
|
// Bandwidth: a byte rate, per destination; per /24; quotas, total and
|
||||||
|
// per device.
|
||||||
|
rule(model.ChainForward, model.Rule{Match: model.Match{OutZones: []string{"lan"}}, Action: model.ActionDrop,
|
||||||
|
Limit: &model.Limit{Rate: 5, Unit: "mbytes", Per: "second", Burst: 1, Over: true, PerDestination: true}, Comment: "5 MB/s each"})
|
||||||
|
rule(model.ChainForward, model.Rule{Match: model.Match{InZones: []string{"wan"}}, Action: model.ActionDrop, GroupV4: 24,
|
||||||
|
Limit: &model.Limit{Rate: 100, Unit: "kbytes", Per: "minute", Over: true, PerSource: true}, Comment: "per /24"})
|
||||||
|
rule(model.ChainForward, model.Rule{Match: model.Match{Sources: []string{"guests"}, OutZones: []string{"wan"}}, Action: model.ActionDrop,
|
||||||
|
Quota: &model.Quota{Bytes: 50000 << 20, Over: true, Reset: "monthly", Day: 15}, Comment: "guest quota"})
|
||||||
|
rule(model.ChainForward, model.Rule{Match: model.Match{InZones: []string{"lan"}, OutZones: []string{"wan"}}, Action: model.ActionDrop,
|
||||||
|
Quota: &model.Quota{Bytes: 5 << 30, Over: true, PerSource: true, Reset: "daily"}, Comment: "device quota"})
|
||||||
rule(model.ChainEarly, model.Rule{Match: model.Match{InZones: []string{"wan"}, Sources: []string{"198.51.100.0/24"}}, Action: model.ActionDrop})
|
rule(model.ChainEarly, model.Rule{Match: model.Match{InZones: []string{"wan"}, Sources: []string{"198.51.100.0/24"}}, Action: model.ActionDrop})
|
||||||
rule(model.ChainOutput, model.Rule{Raw: "meta skuid 0 counter accept", Comment: "raw"})
|
rule(model.ChainOutput, model.Rule{Raw: "meta skuid 0 counter accept", Comment: "raw"})
|
||||||
st.Forwards = []*model.PortForward{
|
st.Forwards = []*model.PortForward{
|
||||||
@@ -87,8 +97,26 @@ func router() *model.State {
|
|||||||
return st
|
return st
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// quotaOptions carries quota usage, as a reload does: the guest quota's,
|
||||||
|
// and two devices' of the per-device quota.
|
||||||
|
func quotaOptions(st *model.State) (map[string]int64, map[string][]Element) {
|
||||||
|
q, sets := map[string]int64{}, map[string][]Element{}
|
||||||
|
for _, r := range st.Rules {
|
||||||
|
switch r.Comment {
|
||||||
|
case "guest quota":
|
||||||
|
q[QuotaName(r.ID)] = 123456
|
||||||
|
case "device quota":
|
||||||
|
sets[QuotaName(r.ID)+"_v4"] = []Element{{Addr: "10.0.10.20", Used: 1 << 20}}
|
||||||
|
sets[QuotaName(r.ID)+"_v6"] = []Element{{Addr: "2a02:8013:7f3d:10::", Used: 2048}}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return q, sets
|
||||||
|
}
|
||||||
|
|
||||||
func TestRenderRouter(t *testing.T) {
|
func TestRenderRouter(t *testing.T) {
|
||||||
rs := Render(router(), Options{Elements: map[string][]Element{"guests": {{Addr: "10.0.30.5", Timeout: 1700}}}})
|
st := router()
|
||||||
|
q, qs := quotaOptions(st)
|
||||||
|
rs := Render(st, Options{Elements: map[string][]Element{"guests": {{Addr: "10.0.30.5", Timeout: 1700}}}, Quotas: q, QuotaSets: qs})
|
||||||
text := rs.Text
|
text := rs.Text
|
||||||
for _, want := range []string{
|
for _, want := range []string{
|
||||||
"table inet housecarl {}\ndelete table inet housecarl\ntable inet housecarl {",
|
"table inet housecarl {}\ndelete table inet housecarl\ntable inet housecarl {",
|
||||||
@@ -108,7 +136,16 @@ func TestRenderRouter(t *testing.T) {
|
|||||||
`ip saddr @trusted_v4 tcp dport { 22, 8053, 8067 } counter accept`,
|
`ip saddr @trusted_v4 tcp dport { 22, 8053, 8067 } counter accept`,
|
||||||
`ip6 saddr @trusted_v6 tcp dport { 22, 8053, 8067 } counter accept`,
|
`ip6 saddr @trusted_v6 tcp dport { 22, 8053, 8067 } counter accept`,
|
||||||
`iifname "enp2s0" ip6 saddr fe80::/10 udp dport 547 counter accept`,
|
`iifname "enp2s0" ip6 saddr fe80::/10 udp dport 547 counter accept`,
|
||||||
`iifname "wan" meta nfproto ipv4 tcp dport 22 add @meter_r`,
|
`iifname "wan" meta nfproto ipv4 tcp dport 22 update @meter_r`,
|
||||||
|
`ip6 saddr and ffff:ffff:ffff:ffff:: limit rate over 3/minute burst 5 packets }`,
|
||||||
|
`oifname "enp2s0" meta nfproto ipv4 update @meter_r`,
|
||||||
|
`{ ip daddr limit rate over 5 mbytes/second burst 1 mbytes } counter drop comment "hc:r`,
|
||||||
|
`{ ip saddr and 255.255.255.0 limit rate over 100 kbytes/minute } counter drop`,
|
||||||
|
`quota name "quota_r`,
|
||||||
|
`{ over 50000 mbytes used 123456 bytes }`,
|
||||||
|
`flags dynamic; size 65535; elements = { 10.0.10.20 quota over 5120 mbytes used 1048576 bytes } }`,
|
||||||
|
`elements = { 2a02:8013:7f3d:10:: quota over 5120 mbytes used 2048 bytes } }`,
|
||||||
|
`{ ip6 saddr and ffff:ffff:ffff:ffff:: quota over 5120 mbytes } counter drop comment "hc:r`,
|
||||||
`ip saddr limit rate over 3/minute burst 5 packets } counter log prefix "hc input #`,
|
`ip saddr limit rate over 3/minute burst 5 packets } counter log prefix "hc input #`,
|
||||||
`tcp dport 22 add @conn_r`,
|
`tcp dport 22 add @conn_r`,
|
||||||
`counter reject with tcp reset comment`,
|
`counter reject with tcp reset comment`,
|
||||||
@@ -139,6 +176,16 @@ func TestRenderRouter(t *testing.T) {
|
|||||||
t.Errorf("missing %q", want)
|
t.Errorf("missing %q", want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Rules that count traffic see every packet: in forward, after the
|
||||||
|
// MSS clamp and before established connections are accepted.
|
||||||
|
fwd := text[strings.Index(text, "chain forward {"):]
|
||||||
|
quota, est, mss := strings.Index(fwd, `quota name "quota_r`), strings.Index(fwd, "hc:b-established"), strings.Index(fwd, "hc:b-mss-clamp")
|
||||||
|
if quota < 0 || !(mss < quota && quota < est) {
|
||||||
|
t.Errorf("the quota rule isn't between the MSS clamp and established (%d, %d, %d)", mss, quota, est)
|
||||||
|
}
|
||||||
|
if bw := strings.Index(fwd, "limit rate over 5 mbytes/second"); bw > est {
|
||||||
|
t.Error("the byte rate rule comes after established")
|
||||||
|
}
|
||||||
// The trusted masquerade has no IPv6 part: masquerading the IPv6 LAN
|
// The trusted masquerade has no IPv6 part: masquerading the IPv6 LAN
|
||||||
// isn't wanted (its sources are global).
|
// isn't wanted (its sources are global).
|
||||||
if strings.Contains(text, "ip6 saddr @trusted_v6 counter masquerade") {
|
if strings.Contains(text, "ip6 saddr @trusted_v6 counter masquerade") {
|
||||||
@@ -167,7 +214,8 @@ func TestRenderedRulesetPassesNft(t *testing.T) {
|
|||||||
t.Skip("HOUSECARL_NFT not set")
|
t.Skip("HOUSECARL_NFT not set")
|
||||||
}
|
}
|
||||||
for name, st := range map[string]*model.State{"router": router(), "empty": model.NewState()} {
|
for name, st := range map[string]*model.State{"router": router(), "empty": model.NewState()} {
|
||||||
rs := Render(st, Options{Elements: map[string][]Element{"guests": {{Addr: "10.0.30.5", Timeout: 1700}, {Addr: "2a02:8013:7f3d:30::5"}}}})
|
q, qs := quotaOptions(st)
|
||||||
|
rs := Render(st, Options{Elements: map[string][]Element{"guests": {{Addr: "10.0.30.5", Timeout: 1700}, {Addr: "2a02:8013:7f3d:30::5"}}}, Quotas: q, QuotaSets: qs})
|
||||||
c := exec.CommandContext(context.Background(), cmd[0], append(cmd[1:], "-c", "-f", "/dev/stdin")...)
|
c := exec.CommandContext(context.Background(), cmd[0], append(cmd[1:], "-c", "-f", "/dev/stdin")...)
|
||||||
c.Stdin = strings.NewReader(rs.Text)
|
c.Stdin = strings.NewReader(rs.Text)
|
||||||
if out, err := c.CombinedOutput(); err != nil {
|
if out, err := c.CombinedOutput(); err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,484 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"housecarl/internal/api"
|
||||||
|
"housecarl/internal/auth"
|
||||||
|
"housecarl/internal/engine"
|
||||||
|
"housecarl/internal/model"
|
||||||
|
"housecarl/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Changesets: changes staged to be reviewed (validation, nftables' check,
|
||||||
|
// the ruleset's diff) and applied together, as one history entry, or
|
||||||
|
// discarded. The web UI stages its edits by default; the CLI and the API
|
||||||
|
// stage when a changeset is named (?changeset=ID, X-Housecarl-Changeset).
|
||||||
|
|
||||||
|
// changesetParam is the changeset a request names (0: none).
|
||||||
|
func changesetParam(r *http.Request) (int64, error) {
|
||||||
|
v := r.URL.Query().Get("changeset")
|
||||||
|
if v == "" {
|
||||||
|
v = r.Header.Get("X-Housecarl-Changeset")
|
||||||
|
}
|
||||||
|
if v == "" {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
id, err := strconv.ParseInt(v, 10, 64)
|
||||||
|
if err != nil || id <= 0 {
|
||||||
|
return 0, badRequest("invalid changeset %q", v)
|
||||||
|
}
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// notStaged refuses a changeset on a change that can't be staged (an
|
||||||
|
// import, a takeover): it would otherwise be applied at once, unexpectedly.
|
||||||
|
func notStaged(r *http.Request, what string) error {
|
||||||
|
if id, err := changesetParam(r); err != nil || id == 0 {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return badRequest("%s can't be staged in a changeset; leave out --changeset (or unset HOUSECARL_CHANGESET) to do it now", what)
|
||||||
|
}
|
||||||
|
|
||||||
|
// openChangeset loads a changeset to change: it must be open, and the
|
||||||
|
// caller's own unless they are an administrator.
|
||||||
|
func (s *Server) openChangeset(r *http.Request, p *auth.Principal, id int64) (*model.Changeset, error) {
|
||||||
|
cs, err := s.store.GetChangeset(r.Context(), id)
|
||||||
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
|
return nil, notFound("there is no changeset %d", id)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if cs.Status != model.ChangesetOpen {
|
||||||
|
return nil, conflict("changeset %d is %s", id, cs.Status)
|
||||||
|
}
|
||||||
|
if !ownsChangeset(p, cs) && !p.IsAdmin() {
|
||||||
|
return nil, forbidden("changeset %d is %s's, not yours", id, cs.Username)
|
||||||
|
}
|
||||||
|
return cs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ownsChangeset(p *auth.Principal, cs *model.Changeset) bool {
|
||||||
|
if p == nil || p.User == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if p.User.ID != 0 {
|
||||||
|
return cs.UserID == p.User.ID
|
||||||
|
}
|
||||||
|
return cs.UserID == 0 && cs.Username == p.User.Username
|
||||||
|
}
|
||||||
|
|
||||||
|
// stage adds an operation to a changeset. The changeset with it must
|
||||||
|
// still be valid and pass nftables' check; nothing is applied.
|
||||||
|
func (s *Server) stage(w http.ResponseWriter, r *http.Request, p *auth.Principal, id int64, op model.Op) error {
|
||||||
|
s.csMu.Lock()
|
||||||
|
defer s.csMu.Unlock()
|
||||||
|
cs, err := s.openChangeset(r, p, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// The operation on its own, after the changeset so far: what it
|
||||||
|
// changes, for its summary.
|
||||||
|
before := s.eng.State()
|
||||||
|
if err := replay(before, cs.Ops); err != nil {
|
||||||
|
return conflict("changeset %d can't be applied as it is (%v); review it first", id, err)
|
||||||
|
}
|
||||||
|
after := before.Clone()
|
||||||
|
objID, err := applyOp(after, &op)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
engine.Normalize(after)
|
||||||
|
after.Sort()
|
||||||
|
changes := engine.Diff(before, after)
|
||||||
|
if len(changes) == 0 {
|
||||||
|
writeJSON(w, http.StatusOK, api.MutationResponse{Changes: []model.Change{}, Changeset: cs})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
op.Summary = engine.Describe(changes)
|
||||||
|
if op.Op == model.OpRevert {
|
||||||
|
var h model.HistoryEntry
|
||||||
|
json.Unmarshal(op.Data, &h)
|
||||||
|
op.Summary = engine.UndoDescription(&h)
|
||||||
|
}
|
||||||
|
op.AddedAt = s.nowFn()
|
||||||
|
for _, o := range cs.Ops {
|
||||||
|
if o.Seq >= op.Seq {
|
||||||
|
op.Seq = o.Seq + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if op.Seq == 0 {
|
||||||
|
op.Seq = 1
|
||||||
|
}
|
||||||
|
ops := append(append([]model.Op{}, cs.Ops...), op)
|
||||||
|
dry := queryBool(r, "dry_run")
|
||||||
|
// The whole changeset, checked as it would be applied.
|
||||||
|
res, err := s.eng.Apply(r.Context(), p, "", engine.Options{DryRun: true, Via: via(r, p)}, func(st *model.State, _ func() int64) error {
|
||||||
|
return replay(st, ops)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
s.writeErr(w, r, err, res)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !dry {
|
||||||
|
cs.Ops = ops
|
||||||
|
if err := s.store.UpdateChangeset(r.Context(), cs); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := api.MutationResponse{Staged: !dry, DryRun: dry, Changes: changes, Warnings: res.Warnings, Changeset: cs}
|
||||||
|
if object := objectOf(&op, objID); object != nil {
|
||||||
|
if v := object(after); v != nil {
|
||||||
|
out.Object, _ = json.Marshal(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if dry {
|
||||||
|
out.Diff = s.rulesetDiff(res.Before, res.After)
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) createChangeset(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
if err := require(p, auth.ResConfig, true); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var in api.ChangesetInput
|
||||||
|
if r.ContentLength != 0 {
|
||||||
|
if err := decodeJSON(r, &in); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cs := &model.Changeset{Username: p.Name(), Via: via(r, p), Description: strings.TrimSpace(in.Description), Status: model.ChangesetOpen}
|
||||||
|
if p.User != nil {
|
||||||
|
cs.UserID = p.User.ID
|
||||||
|
}
|
||||||
|
if err := s.store.CreateChangeset(r.Context(), cs); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.eng.Audit(r.Context(), p, "changeset.create", fmt.Sprintf("changeset %d", cs.ID), "success", cs.Description)
|
||||||
|
writeJSON(w, http.StatusCreated, cs)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) listChangesets(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
if err := require(p, auth.ResConfig, false); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var uid int64
|
||||||
|
if queryBool(r, "mine") {
|
||||||
|
uid = -1
|
||||||
|
if p.User != nil && p.User.ID != 0 {
|
||||||
|
uid = p.User.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
list, err := s.store.ListChangesets(r.Context(), r.URL.Query().Get("status"), max(uid, 0), queryInt(r, "limit", 100))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if uid == -1 { // the socket's users have no user ID: by name
|
||||||
|
mine := []*model.Changeset{}
|
||||||
|
for _, cs := range list {
|
||||||
|
if ownsChangeset(p, cs) {
|
||||||
|
mine = append(mine, cs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
list = mine
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, list)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) getChangeset(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
|
||||||
|
}
|
||||||
|
cs, err := s.store.GetChangeset(r.Context(), id)
|
||||||
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
|
return notFound("there is no changeset %d", id)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, cs)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) updateChangeset(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
if err := require(p, auth.ResConfig, true); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
id, err := pathID(r, "id")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var in api.ChangesetInput
|
||||||
|
if err := decodeJSON(r, &in); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.csMu.Lock()
|
||||||
|
defer s.csMu.Unlock()
|
||||||
|
cs, err := s.openChangeset(r, p, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cs.Description = strings.TrimSpace(in.Description)
|
||||||
|
if err := s.store.UpdateChangeset(r.Context(), cs); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, cs)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeOp takes one operation out of a changeset. Later ones that
|
||||||
|
// needed it show up in the preview.
|
||||||
|
func (s *Server) removeOp(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
if err := require(p, auth.ResConfig, true); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
id, err := pathID(r, "id")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
seq, err := strconv.Atoi(r.PathValue("seq"))
|
||||||
|
if err != nil {
|
||||||
|
return badRequest("invalid operation %q", r.PathValue("seq"))
|
||||||
|
}
|
||||||
|
s.csMu.Lock()
|
||||||
|
defer s.csMu.Unlock()
|
||||||
|
cs, err := s.openChangeset(r, p, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
kept := []model.Op{}
|
||||||
|
for _, o := range cs.Ops {
|
||||||
|
if o.Seq != seq {
|
||||||
|
kept = append(kept, o)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(kept) == len(cs.Ops) {
|
||||||
|
return notFound("changeset %d has no operation %d", id, seq)
|
||||||
|
}
|
||||||
|
cs.Ops = kept
|
||||||
|
if err := s.store.UpdateChangeset(r.Context(), cs); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, cs)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// previewChangeset says what applying a changeset would do, without
|
||||||
|
// doing it: its changes, problems, nftables' check, the ruleset's diff.
|
||||||
|
func (s *Server) previewChangeset(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
|
||||||
|
}
|
||||||
|
cs, err := s.store.GetChangeset(r.Context(), id)
|
||||||
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
|
return notFound("there is no changeset %d", id)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
res, err := s.eng.Apply(r.Context(), p, "", engine.Options{DryRun: true, Via: via(r, p)}, func(st *model.State, _ func() int64) error {
|
||||||
|
return replay(st, cs.Ops)
|
||||||
|
})
|
||||||
|
out := api.ChangesetPreview{Valid: err == nil && res != nil && len(res.Changes) > 0, Changes: []model.Change{}}
|
||||||
|
if res != nil {
|
||||||
|
if res.Changes != nil {
|
||||||
|
out.Changes = res.Changes
|
||||||
|
}
|
||||||
|
out.Warnings = res.Warnings
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
out.Error = errorInfo(err)
|
||||||
|
} else if res != nil {
|
||||||
|
out.Diff = s.rulesetDiff(res.Before, res.After)
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// errorInfo is an error as the API reports it.
|
||||||
|
func errorInfo(err error) *api.ErrorInfo {
|
||||||
|
var oe *opError
|
||||||
|
if errors.As(err, &oe) {
|
||||||
|
info, _ := oe.info()
|
||||||
|
return &info
|
||||||
|
}
|
||||||
|
var he *httpError
|
||||||
|
if errors.As(err, &he) {
|
||||||
|
return &api.ErrorInfo{Code: he.code, Message: he.msg}
|
||||||
|
}
|
||||||
|
if ee, ok := engine.AsError(err); ok {
|
||||||
|
return &api.ErrorInfo{Code: ee.Kind, Message: ee.Message, Problems: ee.Problems}
|
||||||
|
}
|
||||||
|
return &api.ErrorInfo{Code: "internal", Message: err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyChangeset applies a changeset's operations as one change: one
|
||||||
|
// history entry, and (over the network) on probation like any change.
|
||||||
|
func (s *Server) applyChangeset(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
if err := require(p, auth.ResConfig, true); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
id, err := pathID(r, "id")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var in api.ChangesetInput
|
||||||
|
if r.ContentLength != 0 {
|
||||||
|
if err := decodeJSON(r, &in); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.csMu.Lock()
|
||||||
|
defer s.csMu.Unlock()
|
||||||
|
cs, err := s.openChangeset(r, p, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if d := strings.TrimSpace(in.Description); d != "" {
|
||||||
|
cs.Description = d
|
||||||
|
}
|
||||||
|
if len(cs.Ops) == 0 {
|
||||||
|
return badRequest("changeset %d has no changes to apply", id)
|
||||||
|
}
|
||||||
|
opts := engine.Options{Via: via(r, p)}
|
||||||
|
desc := cs.Description
|
||||||
|
for _, o := range cs.Ops {
|
||||||
|
if o.Op == model.OpRevert {
|
||||||
|
opts.RevertOf = o.ID // History links the undo
|
||||||
|
if desc == "" && len(cs.Ops) == 1 {
|
||||||
|
desc = o.Summary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res, err := s.eng.Apply(r.Context(), p, desc, opts, func(st *model.State, _ func() int64) error {
|
||||||
|
return replay(st, cs.Ops)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
s.eng.Audit(r.Context(), p, "changeset.apply", fmt.Sprintf("changeset %d", id), "failure", err.Error())
|
||||||
|
s.writeErr(w, r, err, res)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cs.Status, cs.HistoryID = model.ChangesetApplied, res.HistoryID
|
||||||
|
if err := s.store.UpdateChangeset(r.Context(), cs); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.eng.Audit(r.Context(), p, "changeset.apply", fmt.Sprintf("changeset %d", id), "success", fmt.Sprintf("history %d", res.HistoryID))
|
||||||
|
s.afterChange(r.Context(), p, res)
|
||||||
|
out := s.mutation(res, nil)
|
||||||
|
out.ConfirmToken = s.issueCheck(w, r, p, res)
|
||||||
|
out.Changeset = cs
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) discardChangeset(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
if err := require(p, auth.ResConfig, true); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
id, err := pathID(r, "id")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.csMu.Lock()
|
||||||
|
defer s.csMu.Unlock()
|
||||||
|
cs, err := s.openChangeset(r, p, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cs.Status = model.ChangesetDiscarded
|
||||||
|
if err := s.store.UpdateChangeset(r.Context(), cs); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.eng.Audit(r.Context(), p, "changeset.discard", fmt.Sprintf("changeset %d", id), "success", fmt.Sprintf("%d operations", len(cs.Ops)))
|
||||||
|
writeJSON(w, http.StatusOK, cs)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- reading the configuration as a changeset will make it
|
||||||
|
|
||||||
|
// viewState is the configuration a read shows: the current one, or with
|
||||||
|
// a changeset named, as it will be once that is applied (draft true).
|
||||||
|
// A changeset that no longer applies shows the current configuration,
|
||||||
|
// with the reason in the X-Housecarl-Changeset-Error header.
|
||||||
|
func (s *Server) viewState(w http.ResponseWriter, r *http.Request) (cur, view *model.State, draft bool, err error) {
|
||||||
|
cur = s.eng.State()
|
||||||
|
id, err := changesetParam(r)
|
||||||
|
if err != nil || id == 0 {
|
||||||
|
return cur, cur, false, err
|
||||||
|
}
|
||||||
|
cs, err := s.store.GetChangeset(r.Context(), id)
|
||||||
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
|
return nil, nil, false, notFound("there is no changeset %d", id)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, false, err
|
||||||
|
}
|
||||||
|
if cs.Status != model.ChangesetOpen {
|
||||||
|
return cur, cur, false, nil
|
||||||
|
}
|
||||||
|
next := cur.Clone()
|
||||||
|
if err := replay(next, cs.Ops); err != nil {
|
||||||
|
w.Header().Set("X-Housecarl-Changeset-Error", err.Error())
|
||||||
|
return cur, cur, false, nil
|
||||||
|
}
|
||||||
|
engine.Normalize(next)
|
||||||
|
next.Sort()
|
||||||
|
return cur, next, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// withPending lists a kind's objects as a changeset will leave them, each
|
||||||
|
// marked "pending": "new", "changed" or "deleted" (deleted ones stay in
|
||||||
|
// the list, in their place).
|
||||||
|
func withPending(cur, next *model.State, kind string) []any {
|
||||||
|
was := map[int64]string{}
|
||||||
|
for _, o := range cur.Objects(kind) {
|
||||||
|
b, _ := json.Marshal(o)
|
||||||
|
was[o.GetID()] = string(b)
|
||||||
|
}
|
||||||
|
// The deleted ones go back in, so they sort into their places.
|
||||||
|
merged := next.Clone()
|
||||||
|
deleted := map[int64]bool{}
|
||||||
|
for _, o := range cur.Objects(kind) {
|
||||||
|
if merged.ByID(kind, o.GetID()) == nil {
|
||||||
|
b, _ := json.Marshal(o)
|
||||||
|
merged.Put(kind, o.GetID(), b)
|
||||||
|
deleted[o.GetID()] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
merged.Sort()
|
||||||
|
out := []any{}
|
||||||
|
for _, o := range merged.Objects(kind) {
|
||||||
|
var m map[string]any
|
||||||
|
b, _ := json.Marshal(o)
|
||||||
|
json.Unmarshal(b, &m)
|
||||||
|
old, existed := was[o.GetID()]
|
||||||
|
switch {
|
||||||
|
case deleted[o.GetID()]:
|
||||||
|
m["pending"] = "deleted"
|
||||||
|
case !existed:
|
||||||
|
m["pending"] = "new"
|
||||||
|
case old != string(b):
|
||||||
|
m["pending"] = "changed"
|
||||||
|
}
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
+140
-182
@@ -7,6 +7,8 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
udiff "github.com/aymanbagabas/go-udiff"
|
||||||
|
|
||||||
"housecarl/internal/api"
|
"housecarl/internal/api"
|
||||||
"housecarl/internal/auth"
|
"housecarl/internal/auth"
|
||||||
"housecarl/internal/engine"
|
"housecarl/internal/engine"
|
||||||
@@ -14,26 +16,48 @@ import (
|
|||||||
"housecarl/internal/store"
|
"housecarl/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// change applies an edit through the engine and answers it; object picks
|
// change makes an operation: at once through the engine, or, with a
|
||||||
// what to return from the new state. A network change starts (or joins)
|
// changeset named (?changeset=ID or X-Housecarl-Changeset), staged in it.
|
||||||
// the probation.
|
// A network change starts (or joins) the probation.
|
||||||
func (s *Server) change(w http.ResponseWriter, r *http.Request, p *auth.Principal, description string, edit engine.Edit, object func(st *model.State) any) error {
|
func (s *Server) change(w http.ResponseWriter, r *http.Request, p *auth.Principal, op model.Op) error {
|
||||||
if err := require(p, auth.ResConfig, true); err != nil {
|
if err := require(p, auth.ResConfig, true); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
res, err := s.eng.Apply(r.Context(), p, description, engine.Options{DryRun: queryBool(r, "dry_run"), Via: via(r, p)}, edit)
|
if op.Op == model.OpCreate && op.ID == 0 {
|
||||||
|
ids, err := s.store.AllocateIDs(r.Context(), 1)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
op.ID = ids[0]
|
||||||
|
}
|
||||||
|
csID, err := changesetParam(r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if csID != 0 {
|
||||||
|
return s.stage(w, r, p, csID, op)
|
||||||
|
}
|
||||||
|
var id int64
|
||||||
|
res, err := s.eng.Apply(r.Context(), p, "", engine.Options{DryRun: queryBool(r, "dry_run"), Via: via(r, p)}, func(st *model.State, _ func() int64) error {
|
||||||
|
var err error
|
||||||
|
id, err = applyOp(st, &op)
|
||||||
|
return err
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.writeErr(w, r, err, res)
|
s.writeErr(w, r, err, res)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
s.afterChange(r.Context(), p, res)
|
s.afterChange(r.Context(), p, res)
|
||||||
// POST to a collection creates; POST to an object (move) changes it.
|
out := s.mutation(res, objectOf(&op, id))
|
||||||
s.respondChange(w, r, res, object, r.Method == http.MethodPost && r.PathValue("ref") == "")
|
out.ConfirmToken = s.issueCheck(w, r, p, res)
|
||||||
|
writeJSON(w, s.mutationStatus(res, op.Op == model.OpCreate), out)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// respondChange answers a change: 201 when it created something.
|
// mutation is the answer to a change: what changed, warnings, the object
|
||||||
func (s *Server) respondChange(w http.ResponseWriter, r *http.Request, res *engine.Result, object func(st *model.State) any, creates bool) {
|
// (as it is after the change), the probation, and for a dry run the
|
||||||
|
// ruleset's diff.
|
||||||
|
func (s *Server) mutation(res *engine.Result, object func(st *model.State) any) api.MutationResponse {
|
||||||
out := api.MutationResponse{Applied: res.Applied, DryRun: res.DryRun, HistoryID: res.HistoryID, Changes: res.Changes, Warnings: res.Warnings}
|
out := api.MutationResponse{Applied: res.Applied, DryRun: res.DryRun, HistoryID: res.HistoryID, Changes: res.Changes, Warnings: res.Warnings}
|
||||||
if out.Changes == nil {
|
if out.Changes == nil {
|
||||||
out.Changes = []model.Change{}
|
out.Changes = []model.Change{}
|
||||||
@@ -41,16 +65,39 @@ func (s *Server) respondChange(w http.ResponseWriter, r *http.Request, res *engi
|
|||||||
if pr := s.probationStatus(); res.Applied && (len(pr.Pending) > 0 || pr.Takeovers > 0) {
|
if pr := s.probationStatus(); res.Applied && (len(pr.Pending) > 0 || pr.Takeovers > 0) {
|
||||||
out.ConfirmBy = &pr.Deadline
|
out.ConfirmBy = &pr.Deadline
|
||||||
}
|
}
|
||||||
if object != nil && (res.Applied || res.DryRun) {
|
if object != nil && res.After != nil && (res.Applied || res.DryRun) {
|
||||||
if v := object(s.eng.State()); v != nil {
|
if v := object(res.After); v != nil {
|
||||||
out.Object, _ = json.Marshal(v)
|
out.Object, _ = json.Marshal(v)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
status := http.StatusOK
|
if res.DryRun && len(res.Changes) > 0 {
|
||||||
if res.Applied && creates {
|
out.Diff = s.rulesetDiff(res.Before, res.After)
|
||||||
status = http.StatusCreated
|
|
||||||
}
|
}
|
||||||
writeJSON(w, status, out)
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// mutationStatus is 201 for an applied create, else 200.
|
||||||
|
func (s *Server) mutationStatus(res *engine.Result, creates bool) int {
|
||||||
|
if res.Applied && creates {
|
||||||
|
return http.StatusCreated
|
||||||
|
}
|
||||||
|
return http.StatusOK
|
||||||
|
}
|
||||||
|
|
||||||
|
// respondChange answers a change made without an operation (undo).
|
||||||
|
func (s *Server) respondChange(w http.ResponseWriter, r *http.Request, p *auth.Principal, res *engine.Result, object func(st *model.State) any, creates bool) {
|
||||||
|
out := s.mutation(res, object)
|
||||||
|
out.ConfirmToken = s.issueCheck(w, r, p, res)
|
||||||
|
writeJSON(w, s.mutationStatus(res, creates), out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// rulesetDiff is the unified diff of the rulesets two configurations
|
||||||
|
// make: the file housecarl-ruleset.service loads, now and after.
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// kindOf maps the API's plural path names to kinds.
|
// kindOf maps the API's plural path names to kinds.
|
||||||
@@ -84,17 +131,30 @@ func (s *Server) listObjects(w http.ResponseWriter, r *http.Request, p *auth.Pri
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
st := s.eng.State()
|
cur, st, draft, err := s.viewState(w, r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
out := []any{}
|
out := []any{}
|
||||||
q := r.URL.Query()
|
c := r.URL.Query().Get("chain")
|
||||||
|
if draft {
|
||||||
|
// Marked new, changed or deleted.
|
||||||
|
for _, o := range withPending(cur, st, kind) {
|
||||||
|
if c != "" && kind == model.KindRule && o.(map[string]any)["chain"] != c {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, o)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
for _, o := range st.Objects(kind) {
|
for _, o := range st.Objects(kind) {
|
||||||
if c := q.Get("chain"); c != "" {
|
if c != "" {
|
||||||
if rule, ok := o.(*model.Rule); ok && rule.Chain != c {
|
if rule, ok := o.(*model.Rule); ok && rule.Chain != c {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
out = append(out, o)
|
out = append(out, o)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
// Services: the built-in ones too, unless overridden.
|
// Services: the built-in ones too, unless overridden.
|
||||||
if kind == model.KindService && !queryBool(r, "own") {
|
if kind == model.KindService && !queryBool(r, "own") {
|
||||||
for _, b := range model.BuiltinServices() {
|
for _, b := range model.BuiltinServices() {
|
||||||
@@ -102,9 +162,14 @@ func (s *Server) listObjects(w http.ResponseWriter, r *http.Request, p *auth.Pri
|
|||||||
out = append(out, b)
|
out = append(out, b)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
sort.SliceStable(out, func(i, j int) bool {
|
name := func(x any) string {
|
||||||
return out[i].(*model.Service).Name < out[j].(*model.Service).Name
|
if m, ok := x.(map[string]any); ok {
|
||||||
})
|
n, _ := m["name"].(string)
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
return x.(*model.Service).Name
|
||||||
|
}
|
||||||
|
sort.SliceStable(out, func(i, j int) bool { return name(out[i]) < name(out[j]) })
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, out)
|
writeJSON(w, http.StatusOK, out)
|
||||||
return nil
|
return nil
|
||||||
@@ -118,7 +183,10 @@ func (s *Server) getObject(w http.ResponseWriter, r *http.Request, p *auth.Princ
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
st := s.eng.State()
|
_, st, _, err := s.viewState(w, r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
o, err := findObject(st, kind, r.PathValue("ref"))
|
o, err := findObject(st, kind, r.PathValue("ref"))
|
||||||
if err != nil && kind == model.KindService {
|
if err != nil && kind == model.KindService {
|
||||||
if b := model.BuiltinService(r.PathValue("ref")); b != nil {
|
if b := model.BuiltinService(r.PathValue("ref")); b != nil {
|
||||||
@@ -235,29 +303,11 @@ func (s *Server) createObject(w http.ResponseWriter, r *http.Request, p *auth.Pr
|
|||||||
if err := decodeJSON(r, &raw); err != nil {
|
if err := decodeJSON(r, &raw); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
o, err := decodeObject(kind, raw)
|
// Refuse a bad object now, not when it is applied.
|
||||||
if err != nil {
|
if _, err := decodeObject(kind, raw); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if svc, ok := o.(*model.Service); ok {
|
return s.change(w, r, p, model.Op{Op: model.OpCreate, Kind: kind, Data: raw})
|
||||||
svc.Builtin = false
|
|
||||||
}
|
|
||||||
if rule, ok := o.(*model.Rule); ok && rule.Action == "" {
|
|
||||||
rule.Action = model.ActionAccept
|
|
||||||
}
|
|
||||||
if f, ok := o.(*model.PortForward); ok && len(f.Protocols) == 0 {
|
|
||||||
f.Protocols = []string{"tcp"}
|
|
||||||
}
|
|
||||||
var id int64
|
|
||||||
return s.change(w, r, p, "", func(st *model.State, newID func() int64) error {
|
|
||||||
id = newID()
|
|
||||||
setID(o, id)
|
|
||||||
if pos := position(o); pos != nil && *pos == 0 {
|
|
||||||
*pos = lastPosition(st, kind, chainOf(o))
|
|
||||||
}
|
|
||||||
data, _ := json.Marshal(o)
|
|
||||||
return st.Put(kind, id, data)
|
|
||||||
}, func(st *model.State) any { return st.ByID(kind, id) })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) updateObject(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
func (s *Server) updateObject(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
@@ -269,34 +319,8 @@ func (s *Server) updateObject(w http.ResponseWriter, r *http.Request, p *auth.Pr
|
|||||||
if err := decodeJSON(r, &patch); err != nil {
|
if err := decodeJSON(r, &patch); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
delete(patch, "id")
|
data, _ := json.Marshal(patch)
|
||||||
var id int64
|
return s.change(w, r, p, model.Op{Op: model.OpUpdate, Kind: kind, Ref: r.PathValue("ref"), Data: data})
|
||||||
return s.change(w, r, p, "", func(st *model.State, _ func() int64) error {
|
|
||||||
o, err := findObject(st, kind, r.PathValue("ref"))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
id = o.GetID()
|
|
||||||
// The patch's fields over the object's.
|
|
||||||
var cur map[string]json.RawMessage
|
|
||||||
b, _ := json.Marshal(o)
|
|
||||||
json.Unmarshal(b, &cur)
|
|
||||||
for k, v := range patch {
|
|
||||||
if string(v) == "null" {
|
|
||||||
delete(cur, k)
|
|
||||||
} else {
|
|
||||||
cur[k] = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
merged, _ := json.Marshal(cur)
|
|
||||||
next, err := decodeObject(kind, merged)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
setID(next, id)
|
|
||||||
data, _ := json.Marshal(next)
|
|
||||||
return st.Put(kind, id, data)
|
|
||||||
}, func(st *model.State) any { return st.ByID(kind, id) })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) deleteObject(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
func (s *Server) deleteObject(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
@@ -304,16 +328,7 @@ func (s *Server) deleteObject(w http.ResponseWriter, r *http.Request, p *auth.Pr
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return s.change(w, r, p, "", func(st *model.State, _ func() int64) error {
|
return s.change(w, r, p, model.Op{Op: model.OpDelete, Kind: kind, Ref: r.PathValue("ref")})
|
||||||
o, err := findObject(st, kind, r.PathValue("ref"))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if used := usedBy(st, kind, o); used != "" {
|
|
||||||
return conflict("%s %s is used by %s; change that first", kind, o.Label(), used)
|
|
||||||
}
|
|
||||||
return st.Put(kind, o.GetID(), nil)
|
|
||||||
}, nil)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// usedBy names an object that refers to o (zones, sets and services are
|
// usedBy names an object that refers to o (zones, sets and services are
|
||||||
@@ -364,8 +379,7 @@ func usedBy(st *model.State, kind string, o model.Object) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// moveObject reorders a rule or NAT rule: before or after another, or to
|
// moveObject reorders a rule or NAT rule (moveOp).
|
||||||
// the start or end; a rule can change chain at the same time.
|
|
||||||
func (s *Server) moveObject(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
func (s *Server) moveObject(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
kind, err := pathKind(r)
|
kind, err := pathKind(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -375,72 +389,8 @@ func (s *Server) moveObject(w http.ResponseWriter, r *http.Request, p *auth.Prin
|
|||||||
if err := decodeJSON(r, &in); err != nil {
|
if err := decodeJSON(r, &in); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
var id int64
|
data, _ := json.Marshal(in)
|
||||||
return s.change(w, r, p, "", func(st *model.State, _ func() int64) error {
|
return s.change(w, r, p, model.Op{Op: model.OpMove, Kind: kind, Ref: r.PathValue("ref"), Data: data})
|
||||||
o, err := findObject(st, kind, r.PathValue("ref"))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
id = o.GetID()
|
|
||||||
pos := position(o)
|
|
||||||
if pos == nil {
|
|
||||||
return badRequest("only rules, port forwards, source NAT rules and redirects have an order")
|
|
||||||
}
|
|
||||||
if rule, ok := o.(*model.Rule); ok && in.Chain != "" {
|
|
||||||
rule.Chain = in.Chain
|
|
||||||
}
|
|
||||||
chain := chainOf(o)
|
|
||||||
// The others in order.
|
|
||||||
var others []model.Object
|
|
||||||
for _, x := range st.Objects(kind) {
|
|
||||||
if x.GetID() != id && chainOf(x) == chain {
|
|
||||||
others = append(others, x)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort.SliceStable(others, func(i, j int) bool { return *position(others[i]) < *position(others[j]) })
|
|
||||||
place := func(i int) { // before others[i] (i == len: at the end)
|
|
||||||
switch {
|
|
||||||
case len(others) == 0:
|
|
||||||
*pos = 1
|
|
||||||
case i == 0:
|
|
||||||
*pos = *position(others[0]) - 1
|
|
||||||
case i == len(others):
|
|
||||||
*pos = *position(others[len(others)-1]) + 1
|
|
||||||
default:
|
|
||||||
*pos = (*position(others[i-1]) + *position(others[i])) / 2
|
|
||||||
}
|
|
||||||
}
|
|
||||||
index := func(ref int64) (int, error) {
|
|
||||||
for i, x := range others {
|
|
||||||
if x.GetID() == ref {
|
|
||||||
return i, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0, badRequest("%d isn't another %s of the same chain", ref, kind)
|
|
||||||
}
|
|
||||||
switch {
|
|
||||||
case in.Before != 0:
|
|
||||||
i, err := index(in.Before)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
place(i)
|
|
||||||
case in.After != 0:
|
|
||||||
i, err := index(in.After)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
place(i + 1)
|
|
||||||
case in.To == "start":
|
|
||||||
place(0)
|
|
||||||
case in.To == "end" || in.Chain != "":
|
|
||||||
place(len(others))
|
|
||||||
default:
|
|
||||||
return badRequest("say where: before, after, or to start or end")
|
|
||||||
}
|
|
||||||
data, _ := json.Marshal(o)
|
|
||||||
return st.Put(kind, id, data)
|
|
||||||
}, func(st *model.State) any { return st.ByID(kind, id) })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- settings
|
// ---- settings
|
||||||
@@ -449,7 +399,11 @@ func (s *Server) getSettings(w http.ResponseWriter, r *http.Request, p *auth.Pri
|
|||||||
if err := require(p, auth.ResConfig, false); err != nil {
|
if err := require(p, auth.ResConfig, false); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, s.eng.State().Settings)
|
_, st, _, err := s.viewState(w, r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, st.Settings)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -458,35 +412,8 @@ func (s *Server) updateSettings(w http.ResponseWriter, r *http.Request, p *auth.
|
|||||||
if err := decodeJSON(r, &patch); err != nil {
|
if err := decodeJSON(r, &patch); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return s.change(w, r, p, "", func(st *model.State, _ func() int64) error {
|
data, _ := json.Marshal(patch)
|
||||||
var cur map[string]json.RawMessage
|
return s.change(w, r, p, model.Op{Op: model.OpSettings, Data: data})
|
||||||
b, _ := json.Marshal(st.Settings)
|
|
||||||
json.Unmarshal(b, &cur)
|
|
||||||
for k, v := range patch {
|
|
||||||
// Built-ins merge one by one.
|
|
||||||
if k == "builtins" {
|
|
||||||
var cb, pb map[string]json.RawMessage
|
|
||||||
json.Unmarshal(cur[k], &cb)
|
|
||||||
if err := json.Unmarshal(v, &pb); err != nil {
|
|
||||||
return badRequest("builtins: %v", err)
|
|
||||||
}
|
|
||||||
for bk, bv := range pb {
|
|
||||||
cb[bk] = bv
|
|
||||||
}
|
|
||||||
v, _ = json.Marshal(cb)
|
|
||||||
}
|
|
||||||
cur[k] = v
|
|
||||||
}
|
|
||||||
merged, _ := json.Marshal(cur)
|
|
||||||
var next model.Settings
|
|
||||||
dec := json.NewDecoder(strings.NewReader(string(merged)))
|
|
||||||
dec.DisallowUnknownFields()
|
|
||||||
if err := dec.Decode(&next); err != nil {
|
|
||||||
return badRequest("settings: %v", err)
|
|
||||||
}
|
|
||||||
st.Settings = next
|
|
||||||
return nil
|
|
||||||
}, func(st *model.State) any { return st.Settings })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- the ruleset
|
// ---- the ruleset
|
||||||
@@ -495,7 +422,11 @@ func (s *Server) ruleset(w http.ResponseWriter, r *http.Request, p *auth.Princip
|
|||||||
if err := require(p, auth.ResConfig, false); err != nil {
|
if err := require(p, auth.ResConfig, false); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
rs := s.fw.Render(s.eng.State())
|
_, st, _, err := s.viewState(w, r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rs := s.fw.Render(st)
|
||||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||||
w.Write([]byte(rs.Text))
|
w.Write([]byte(rs.Text))
|
||||||
return nil
|
return nil
|
||||||
@@ -542,13 +473,30 @@ func (s *Server) revertHistory(w http.ResponseWriter, r *http.Request, p *auth.P
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
// Staged, an undo is an operation of its own, reviewed like any.
|
||||||
|
if csID, err := changesetParam(r); err != nil {
|
||||||
|
return err
|
||||||
|
} else if csID != 0 {
|
||||||
|
h, err := s.store.GetHistory(r.Context(), id)
|
||||||
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
|
return notFound("history entry %d not found", id)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if h.RevertedBy != 0 {
|
||||||
|
return conflict("history entry %d was already undone by entry %d", id, h.RevertedBy)
|
||||||
|
}
|
||||||
|
data, _ := json.Marshal(h)
|
||||||
|
return s.stage(w, r, p, csID, model.Op{Op: model.OpRevert, ID: id, Data: data})
|
||||||
|
}
|
||||||
res, err := s.eng.Revert(r.Context(), p, id, engine.Options{DryRun: queryBool(r, "dry_run"), Via: via(r, p)})
|
res, err := s.eng.Revert(r.Context(), p, id, engine.Options{DryRun: queryBool(r, "dry_run"), Via: via(r, p)})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.writeErr(w, r, err, res)
|
s.writeErr(w, r, err, res)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
s.afterChange(r.Context(), p, res)
|
s.afterChange(r.Context(), p, res)
|
||||||
s.respondChange(w, r, res, nil, false)
|
s.respondChange(w, r, p, res, nil, false)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -566,7 +514,17 @@ func (s *Server) confirmChanges(w http.ResponseWriter, r *http.Request, p *auth.
|
|||||||
if err := require(p, auth.ResConfig, true); err != nil {
|
if err := require(p, auth.ResConfig, true); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
n, err := s.confirm(r.Context(), p)
|
var in api.ConfirmInput
|
||||||
|
if r.ContentLength != 0 {
|
||||||
|
if err := decodeJSON(r, &in); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
confirm := func() (int, error) { return s.confirm(r.Context(), p) }
|
||||||
|
if in.Token != "" {
|
||||||
|
confirm = func() (int, error) { return s.confirmToken(r, p, in.Token) }
|
||||||
|
}
|
||||||
|
n, err := confirm()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ func (s *Server) importNft(w http.ResponseWriter, r *http.Request, p *auth.Princ
|
|||||||
if err := require(p, auth.ResConfig, true); err != nil {
|
if err := require(p, auth.ResConfig, true); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := notStaged(r, "an import"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
var in api.ImportInput
|
var in api.ImportInput
|
||||||
if err := decodeJSON(r, &in); err != nil {
|
if err := decodeJSON(r, &in); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -48,6 +51,11 @@ func (s *Server) importNft(w http.ResponseWriter, r *http.Request, p *auth.Princ
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
s.afterChange(r.Context(), p, er)
|
s.afterChange(r.Context(), p, er)
|
||||||
|
if er.Applied {
|
||||||
|
// Until it is taken over, the old table still applies too: the
|
||||||
|
// status warns (takeover_pending).
|
||||||
|
s.store.SetMeta(r.Context(), importedTableKey, res.Table)
|
||||||
|
}
|
||||||
out.MutationResponse = api.MutationResponse{Applied: er.Applied, DryRun: er.DryRun, HistoryID: er.HistoryID, Changes: er.Changes, Warnings: er.Warnings}
|
out.MutationResponse = api.MutationResponse{Applied: er.Applied, DryRun: er.DryRun, HistoryID: er.HistoryID, Changes: er.Changes, Warnings: er.Warnings}
|
||||||
if out.Changes == nil {
|
if out.Changes == nil {
|
||||||
out.Changes = []model.Change{}
|
out.Changes = []model.Change{}
|
||||||
@@ -55,6 +63,7 @@ func (s *Server) importNft(w http.ResponseWriter, r *http.Request, p *auth.Princ
|
|||||||
if pr := s.probationStatus(); er.Applied && len(pr.Pending) > 0 {
|
if pr := s.probationStatus(); er.Applied && len(pr.Pending) > 0 {
|
||||||
out.ConfirmBy = &pr.Deadline
|
out.ConfirmBy = &pr.Deadline
|
||||||
}
|
}
|
||||||
|
out.ConfirmToken = s.issueCheck(w, r, p, er)
|
||||||
writeJSON(w, http.StatusOK, out)
|
writeJSON(w, http.StatusOK, out)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -105,6 +114,9 @@ func (s *Server) takeover(w http.ResponseWriter, r *http.Request, p *auth.Princi
|
|||||||
if err := requireAdmin(p); err != nil {
|
if err := requireAdmin(p); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := notStaged(r, "a takeover"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
var in api.TakeoverInput
|
var in api.TakeoverInput
|
||||||
if err := decodeJSON(r, &in); err != nil {
|
if err := decodeJSON(r, &in); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -142,6 +154,12 @@ func (s *Server) takeover(w http.ResponseWriter, r *http.Request, p *auth.Princi
|
|||||||
} else if pr := s.probationStatus(); pr.Takeovers > 0 {
|
} else if pr := s.probationStatus(); pr.Takeovers > 0 {
|
||||||
msg += fmt.Sprintf("; confirm with 'hc confirm' within %ds, or they come back", pr.SecondsLeft)
|
msg += fmt.Sprintf("; confirm with 'hc confirm' within %ds, or they come back", pr.SecondsLeft)
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"deleted": done, "message": msg})
|
answer := map[string]any{"deleted": done, "message": msg}
|
||||||
|
if !queryBool(r, "dry_run") && len(done) > 0 {
|
||||||
|
if tok := s.issueCheck(w, r, p, nil); tok != "" {
|
||||||
|
answer["confirm_token"] = tok
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, answer)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -44,10 +47,43 @@ func (s *Server) status(w http.ResponseWriter, r *http.Request, p *auth.Principa
|
|||||||
if data, err := s.fw.ListRuleset(r.Context()); err == nil {
|
if data, err := s.fw.ListRuleset(r.Context()); err == nil {
|
||||||
out.OtherTables = otherTables(data)
|
out.OtherTables = otherTables(data)
|
||||||
}
|
}
|
||||||
|
out.Problems = append(out.Problems, s.hostProblems(r.Context(), out.OtherTables)...)
|
||||||
writeJSON(w, http.StatusOK, out)
|
writeJSON(w, http.StatusOK, out)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const importedTableKey = "imported_table"
|
||||||
|
|
||||||
|
// nftablesUnitLinks are where an enabled nftables.service shows (Debian
|
||||||
|
// enables it for sysinit.target).
|
||||||
|
var nftablesUnitLinks = []string{
|
||||||
|
"/etc/systemd/system/sysinit.target.wants/nftables.service",
|
||||||
|
"/etc/systemd/system/multi-user.target.wants/nftables.service",
|
||||||
|
}
|
||||||
|
|
||||||
|
// hostProblems are warnings about the machine rather than the
|
||||||
|
// configuration: another ruleset that still loads at boot, or the table
|
||||||
|
// an import came from, still in force beside Housecarl's.
|
||||||
|
func (s *Server) hostProblems(ctx context.Context, others []string) []validate.Problem {
|
||||||
|
var out []validate.Problem
|
||||||
|
for _, link := range s.unitLinks {
|
||||||
|
if _, err := os.Lstat(link); err == nil {
|
||||||
|
out = append(out, validate.Problem{Severity: validate.Warning, Code: "nftables_service", Object: "nftables.service",
|
||||||
|
Message: "nftables.service is enabled: at boot it loads /etc/nftables.conf, and stopping or restarting it flushes every table, Housecarl's included; disable it (systemctl disable nftables, without --now)"})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if t, err := s.store.GetMeta(ctx, importedTableKey); err == nil && t != "" {
|
||||||
|
for _, o := range others {
|
||||||
|
if o == t {
|
||||||
|
out = append(out, validate.Problem{Severity: validate.Warning, Code: "takeover_pending", Object: "table " + t,
|
||||||
|
Message: fmt.Sprintf("%s, the table you imported, still applies beside Housecarl's: a packet must get past both; once Housecarl's rules are right, delete it with 'hc takeover %q'", t, t)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// hostInterfaces lists the machine's interfaces with their zones.
|
// hostInterfaces lists the machine's interfaces with their zones.
|
||||||
func (s *Server) hostInterfaces(st *model.State) []api.HostInterface {
|
func (s *Server) hostInterfaces(st *model.State) []api.HostInterface {
|
||||||
out := []api.HostInterface{}
|
out := []api.HostInterface{}
|
||||||
|
|||||||
@@ -0,0 +1,271 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"housecarl/internal/api"
|
||||||
|
"housecarl/internal/engine"
|
||||||
|
"housecarl/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Every configuration change is an operation (model.Op): applied at once,
|
||||||
|
// or staged in a changeset and replayed when the changeset is applied.
|
||||||
|
// applyOp is the one place an operation becomes an edit.
|
||||||
|
|
||||||
|
// applyOp makes an operation's change to st. It returns the ID of the
|
||||||
|
// object it made or changed (0 for deletes and settings).
|
||||||
|
func applyOp(st *model.State, op *model.Op) (int64, error) {
|
||||||
|
switch op.Op {
|
||||||
|
case model.OpCreate:
|
||||||
|
return createOp(st, op)
|
||||||
|
case model.OpUpdate:
|
||||||
|
return updateOp(st, op)
|
||||||
|
case model.OpDelete:
|
||||||
|
return 0, deleteOp(st, op)
|
||||||
|
case model.OpMove:
|
||||||
|
return moveOp(st, op)
|
||||||
|
case model.OpSettings:
|
||||||
|
return 0, settingsOp(st, op)
|
||||||
|
case model.OpRevert:
|
||||||
|
var h model.HistoryEntry
|
||||||
|
if err := json.Unmarshal(op.Data, &h); err != nil {
|
||||||
|
return 0, badRequest("revert: %v", err)
|
||||||
|
}
|
||||||
|
return 0, engine.Undo(st, &h)
|
||||||
|
}
|
||||||
|
return 0, badRequest("unknown operation %q", op.Op)
|
||||||
|
}
|
||||||
|
|
||||||
|
func createOp(st *model.State, op *model.Op) (int64, error) {
|
||||||
|
o, err := decodeObject(op.Kind, op.Data)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if svc, ok := o.(*model.Service); ok {
|
||||||
|
svc.Builtin = false
|
||||||
|
}
|
||||||
|
if rule, ok := o.(*model.Rule); ok && rule.Action == "" {
|
||||||
|
rule.Action = model.ActionAccept
|
||||||
|
}
|
||||||
|
if f, ok := o.(*model.PortForward); ok && len(f.Protocols) == 0 {
|
||||||
|
f.Protocols = []string{"tcp"}
|
||||||
|
}
|
||||||
|
if st.ByID(op.Kind, op.ID) != nil {
|
||||||
|
return 0, conflict("%s %d exists already", op.Kind, op.ID)
|
||||||
|
}
|
||||||
|
setID(o, op.ID)
|
||||||
|
if pos := position(o); pos != nil && *pos == 0 {
|
||||||
|
*pos = lastPosition(st, op.Kind, chainOf(o))
|
||||||
|
}
|
||||||
|
data, _ := json.Marshal(o)
|
||||||
|
return op.ID, st.Put(op.Kind, op.ID, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateOp(st *model.State, op *model.Op) (int64, error) {
|
||||||
|
var patch map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(op.Data, &patch); err != nil {
|
||||||
|
return 0, badRequest("%s: %v", op.Kind, err)
|
||||||
|
}
|
||||||
|
delete(patch, "id")
|
||||||
|
o, err := findObject(st, op.Kind, op.Ref)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
id := o.GetID()
|
||||||
|
// The patch's fields over the object's.
|
||||||
|
var cur map[string]json.RawMessage
|
||||||
|
b, _ := json.Marshal(o)
|
||||||
|
json.Unmarshal(b, &cur)
|
||||||
|
for k, v := range patch {
|
||||||
|
if string(v) == "null" {
|
||||||
|
delete(cur, k)
|
||||||
|
} else {
|
||||||
|
cur[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
merged, _ := json.Marshal(cur)
|
||||||
|
next, err := decodeObject(op.Kind, merged)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
setID(next, id)
|
||||||
|
data, _ := json.Marshal(next)
|
||||||
|
return id, st.Put(op.Kind, id, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteOp(st *model.State, op *model.Op) error {
|
||||||
|
o, err := findObject(st, op.Kind, op.Ref)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if used := usedBy(st, op.Kind, o); used != "" {
|
||||||
|
return conflict("%s %s is used by %s; change that first", op.Kind, o.Label(), used)
|
||||||
|
}
|
||||||
|
return st.Put(op.Kind, o.GetID(), nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// moveOp reorders a rule or NAT rule: before or after another, or to the
|
||||||
|
// start or end; a rule can change chain at the same time.
|
||||||
|
func moveOp(st *model.State, op *model.Op) (int64, error) {
|
||||||
|
var in api.MoveInput
|
||||||
|
if err := json.Unmarshal(op.Data, &in); err != nil {
|
||||||
|
return 0, badRequest("move: %v", err)
|
||||||
|
}
|
||||||
|
o, err := findObject(st, op.Kind, op.Ref)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
id := o.GetID()
|
||||||
|
pos := position(o)
|
||||||
|
if pos == nil {
|
||||||
|
return 0, badRequest("only rules, port forwards, source NAT rules and redirects have an order")
|
||||||
|
}
|
||||||
|
if rule, ok := o.(*model.Rule); ok && in.Chain != "" {
|
||||||
|
rule.Chain = in.Chain
|
||||||
|
}
|
||||||
|
chain := chainOf(o)
|
||||||
|
// The others in order.
|
||||||
|
var others []model.Object
|
||||||
|
for _, x := range st.Objects(op.Kind) {
|
||||||
|
if x.GetID() != id && chainOf(x) == chain {
|
||||||
|
others = append(others, x)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.SliceStable(others, func(i, j int) bool { return *position(others[i]) < *position(others[j]) })
|
||||||
|
place := func(i int) { // before others[i] (i == len: at the end)
|
||||||
|
switch {
|
||||||
|
case len(others) == 0:
|
||||||
|
*pos = 1
|
||||||
|
case i == 0:
|
||||||
|
*pos = *position(others[0]) - 1
|
||||||
|
case i == len(others):
|
||||||
|
*pos = *position(others[len(others)-1]) + 1
|
||||||
|
default:
|
||||||
|
*pos = (*position(others[i-1]) + *position(others[i])) / 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
index := func(ref int64) (int, error) {
|
||||||
|
for i, x := range others {
|
||||||
|
if x.GetID() == ref {
|
||||||
|
return i, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, badRequest("%d isn't another %s of the same chain", ref, op.Kind)
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case in.Before != 0:
|
||||||
|
i, err := index(in.Before)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
place(i)
|
||||||
|
case in.After != 0:
|
||||||
|
i, err := index(in.After)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
place(i + 1)
|
||||||
|
case in.To == "start":
|
||||||
|
place(0)
|
||||||
|
case in.To == "end" || in.Chain != "":
|
||||||
|
place(len(others))
|
||||||
|
default:
|
||||||
|
return 0, badRequest("say where: before, after, or to start or end")
|
||||||
|
}
|
||||||
|
data, _ := json.Marshal(o)
|
||||||
|
return id, st.Put(op.Kind, id, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func settingsOp(st *model.State, op *model.Op) error {
|
||||||
|
var patch map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(op.Data, &patch); err != nil {
|
||||||
|
return badRequest("settings: %v", err)
|
||||||
|
}
|
||||||
|
var cur map[string]json.RawMessage
|
||||||
|
b, _ := json.Marshal(st.Settings)
|
||||||
|
json.Unmarshal(b, &cur)
|
||||||
|
for k, v := range patch {
|
||||||
|
// Built-ins merge one by one.
|
||||||
|
if k == "builtins" {
|
||||||
|
var cb, pb map[string]json.RawMessage
|
||||||
|
json.Unmarshal(cur[k], &cb)
|
||||||
|
if err := json.Unmarshal(v, &pb); err != nil {
|
||||||
|
return badRequest("builtins: %v", err)
|
||||||
|
}
|
||||||
|
for bk, bv := range pb {
|
||||||
|
cb[bk] = bv
|
||||||
|
}
|
||||||
|
v, _ = json.Marshal(cb)
|
||||||
|
}
|
||||||
|
cur[k] = v
|
||||||
|
}
|
||||||
|
merged, _ := json.Marshal(cur)
|
||||||
|
var next model.Settings
|
||||||
|
dec := json.NewDecoder(strings.NewReader(string(merged)))
|
||||||
|
dec.DisallowUnknownFields()
|
||||||
|
if err := dec.Decode(&next); err != nil {
|
||||||
|
return badRequest("settings: %v", err)
|
||||||
|
}
|
||||||
|
st.Settings = next
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// replay applies a changeset's operations to st, in order. An operation
|
||||||
|
// that fails is named in the error (opError).
|
||||||
|
func replay(st *model.State, ops []model.Op) error {
|
||||||
|
for i := range ops {
|
||||||
|
if _, err := applyOp(st, &ops[i]); err != nil {
|
||||||
|
return &opError{seq: ops[i].Seq, summary: ops[i].Summary, err: err}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// opError: an operation of a changeset that can't be applied (the
|
||||||
|
// objects it names changed since it was staged, say).
|
||||||
|
type opError struct {
|
||||||
|
seq int
|
||||||
|
summary string
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *opError) Error() string {
|
||||||
|
return fmt.Sprintf("operation %d (%s) can't be applied: %v", e.seq, e.summary, e.err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *opError) Unwrap() error { return e.err }
|
||||||
|
|
||||||
|
// info is the error for the API, with the inner error's status.
|
||||||
|
func (e *opError) info() (api.ErrorInfo, int) {
|
||||||
|
info := api.ErrorInfo{Code: "conflict", Message: e.Error(), Op: e.seq}
|
||||||
|
status := http.StatusConflict
|
||||||
|
var he *httpError
|
||||||
|
if errors.As(e.err, &he) {
|
||||||
|
info.Code, status = he.code, he.status
|
||||||
|
}
|
||||||
|
if ee, ok := engine.AsError(e.err); ok {
|
||||||
|
info.Code, info.Problems, status = ee.Kind, ee.Problems, engineStatus(ee.Kind)
|
||||||
|
}
|
||||||
|
return info, status
|
||||||
|
}
|
||||||
|
|
||||||
|
// objectOf picks what an operation's answer returns from a state.
|
||||||
|
func objectOf(op *model.Op, id int64) func(st *model.State) any {
|
||||||
|
switch op.Op {
|
||||||
|
case model.OpDelete:
|
||||||
|
return nil
|
||||||
|
case model.OpSettings:
|
||||||
|
return func(st *model.State) any { return st.Settings }
|
||||||
|
}
|
||||||
|
return func(st *model.State) any {
|
||||||
|
if o := st.ByID(op.Kind, id); o != nil {
|
||||||
|
return o
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,8 +2,10 @@ package server
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/subtle"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/http"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -31,6 +33,21 @@ type probation struct {
|
|||||||
timer *time.Timer
|
timer *time.Timer
|
||||||
// restores are nft scripts that bring back tables a takeover deleted.
|
// restores are nft scripts that bring back tables a takeover deleted.
|
||||||
restores []string
|
restores []string
|
||||||
|
// check is the latest change's confirmation token (reachCheck): only
|
||||||
|
// it can confirm, since the ruleset in force is the one it tests.
|
||||||
|
check *reachCheck
|
||||||
|
}
|
||||||
|
|
||||||
|
// reachCheck is what a client must show to confirm its change by itself:
|
||||||
|
// the token, from the same user (and API token), the same address, and a
|
||||||
|
// new connection (another remote address and port than the change's).
|
||||||
|
type reachCheck struct {
|
||||||
|
token string
|
||||||
|
userID int64
|
||||||
|
username string
|
||||||
|
tokenID int64
|
||||||
|
ip string
|
||||||
|
remote string
|
||||||
}
|
}
|
||||||
|
|
||||||
// system is the principal of rollbacks.
|
// system is the principal of rollbacks.
|
||||||
@@ -88,6 +105,60 @@ func (s *Server) startProbation(ctx context.Context, p *auth.Principal, historyI
|
|||||||
s.saveProbation(ctx)
|
s.saveProbation(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// issueCheck gives the client that made a network change on probation a
|
||||||
|
// token to confirm it by itself, unless the settings want a person to
|
||||||
|
// confirm. The answer then closes its connection (HTTP/2: a GOAWAY), so
|
||||||
|
// the confirmation has to open a new one through the new ruleset; an
|
||||||
|
// established connection would get through whatever the change did.
|
||||||
|
func (s *Server) issueCheck(w http.ResponseWriter, r *http.Request, p *auth.Principal, res *engine.Result) string {
|
||||||
|
if p == nil || p.Method == "socket" || (res != nil && !res.Applied) || s.eng.State().Settings.ManualConfirm {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
pr := &s.probation
|
||||||
|
pr.mu.Lock()
|
||||||
|
defer pr.mu.Unlock()
|
||||||
|
if len(pr.pending) == 0 && len(pr.restores) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
c := &reachCheck{token: auth.RandomString(24), username: p.Name(), ip: s.clientIP(r), remote: r.RemoteAddr}
|
||||||
|
if p.User != nil {
|
||||||
|
c.userID = p.User.ID
|
||||||
|
}
|
||||||
|
if p.Token != nil {
|
||||||
|
c.tokenID = p.Token.ID
|
||||||
|
}
|
||||||
|
pr.check = c
|
||||||
|
w.Header().Set("Connection", "close")
|
||||||
|
return c.token
|
||||||
|
}
|
||||||
|
|
||||||
|
// confirmToken confirms with a change's token: the proof that the client
|
||||||
|
// that made it still reaches the API, from where it made it.
|
||||||
|
func (s *Server) confirmToken(r *http.Request, p *auth.Principal, token string) (int, error) {
|
||||||
|
pr := &s.probation
|
||||||
|
pr.mu.Lock()
|
||||||
|
c := pr.check
|
||||||
|
pending := len(pr.pending) + len(pr.restores)
|
||||||
|
pr.mu.Unlock()
|
||||||
|
var tokenID int64
|
||||||
|
if p.Token != nil {
|
||||||
|
tokenID = p.Token.ID
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case pending == 0:
|
||||||
|
return 0, nil
|
||||||
|
case c == nil || subtle.ConstantTimeCompare([]byte(c.token), []byte(token)) != 1:
|
||||||
|
return 0, conflict("this confirmation is for an earlier change: a newer one is on probation, and only its own client (or a person) can confirm it")
|
||||||
|
case p.User == nil || p.User.ID != c.userID || p.Name() != c.username || tokenID != c.tokenID:
|
||||||
|
return 0, forbidden("only the client that made the change can confirm it this way")
|
||||||
|
case s.clientIP(r) != c.ip:
|
||||||
|
return 0, forbidden("the confirmation comes from %s, the change from %s: it must come from the same place", s.clientIP(r), c.ip)
|
||||||
|
case r.RemoteAddr == c.remote:
|
||||||
|
return 0, conflict("the confirmation came over the change's own connection, which proves nothing; it must open a new one")
|
||||||
|
}
|
||||||
|
return s.confirmWith(r.Context(), p, fmt.Sprintf("confirmed automatically: %s reached the API again over a new connection", c.ip))
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) saveBoot() {
|
func (s *Server) saveBoot() {
|
||||||
if err := s.fw.SaveBoot(s.eng.State()); err != nil {
|
if err := s.fw.SaveBoot(s.eng.State()); err != nil {
|
||||||
s.log.Error("saving the ruleset for the next boot failed", "err", err)
|
s.log.Error("saving the ruleset for the next boot failed", "err", err)
|
||||||
@@ -110,8 +181,12 @@ func (s *Server) probationStatus() api.Probation {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// confirm makes the pending changes permanent.
|
// confirm makes the pending changes permanent (a person's confirmation).
|
||||||
func (s *Server) confirm(ctx context.Context, p *auth.Principal) (int, error) {
|
func (s *Server) confirm(ctx context.Context, p *auth.Principal) (int, error) {
|
||||||
|
return s.confirmWith(ctx, p, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) confirmWith(ctx context.Context, p *auth.Principal, how string) (int, error) {
|
||||||
pr := &s.probation
|
pr := &s.probation
|
||||||
pr.mu.Lock()
|
pr.mu.Lock()
|
||||||
defer pr.mu.Unlock()
|
defer pr.mu.Unlock()
|
||||||
@@ -122,10 +197,14 @@ func (s *Server) confirm(ctx context.Context, p *auth.Principal) (int, error) {
|
|||||||
if pr.timer != nil {
|
if pr.timer != nil {
|
||||||
pr.timer.Stop()
|
pr.timer.Stop()
|
||||||
}
|
}
|
||||||
pr.pending, pr.restores, pr.deadline, pr.timer = nil, nil, time.Time{}, nil
|
pr.pending, pr.restores, pr.deadline, pr.timer, pr.check = nil, nil, time.Time{}, nil, nil
|
||||||
s.saveProbation(ctx)
|
s.saveProbation(ctx)
|
||||||
s.saveBoot()
|
s.saveBoot()
|
||||||
s.eng.Audit(ctx, p, "config.confirm", "", "success", fmt.Sprintf("%d changes confirmed", n))
|
detail := fmt.Sprintf("%d changes confirmed", n)
|
||||||
|
if how != "" {
|
||||||
|
detail += "; " + how
|
||||||
|
}
|
||||||
|
s.eng.Audit(ctx, p, "config.confirm", "", "success", detail)
|
||||||
return n, nil
|
return n, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,7 +215,7 @@ func (s *Server) rollback(why string) {
|
|||||||
pr.mu.Lock()
|
pr.mu.Lock()
|
||||||
defer pr.mu.Unlock()
|
defer pr.mu.Unlock()
|
||||||
pending, restores := pr.pending, pr.restores
|
pending, restores := pr.pending, pr.restores
|
||||||
pr.pending, pr.restores, pr.deadline, pr.timer = nil, nil, time.Time{}, nil
|
pr.pending, pr.restores, pr.deadline, pr.timer, pr.check = nil, nil, time.Time{}, nil, nil
|
||||||
for i := len(restores) - 1; i >= 0; i-- {
|
for i := len(restores) - 1; i >= 0; i-- {
|
||||||
if err := s.fw.ApplyScript(ctx, restores[i]); err != nil {
|
if err := s.fw.ApplyScript(ctx, restores[i]); err != nil {
|
||||||
s.log.Error("bringing back a table a takeover deleted failed", "err", err)
|
s.log.Error("bringing back a table a takeover deleted failed", "err", err)
|
||||||
|
|||||||
@@ -0,0 +1,263 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"housecarl/internal/api"
|
||||||
|
"housecarl/internal/auth"
|
||||||
|
"housecarl/internal/model"
|
||||||
|
"housecarl/internal/nft"
|
||||||
|
"housecarl/internal/render"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Quotas: nftables counts them, and never resets them. housecarld resets
|
||||||
|
// each at the start of its period (daily, weekly, monthly), or when asked,
|
||||||
|
// and saves what they used every few minutes and when it stops, so a
|
||||||
|
// reboot (whose boot ruleset starts them at zero) doesn't lose it.
|
||||||
|
|
||||||
|
const (
|
||||||
|
quotaResetsKey = "quota_resets" // rule mark → when its quota was last reset
|
||||||
|
QuotaUsageKey = "quota_usage" // nft.QuotaUsage, saved
|
||||||
|
)
|
||||||
|
|
||||||
|
// periodStart is when the current period of a reset began (zero for a
|
||||||
|
// quota reset only by hand), in local time.
|
||||||
|
func periodStart(now time.Time, reset string, day int) time.Time {
|
||||||
|
y, m, d := now.Date()
|
||||||
|
midnight := time.Date(y, m, d, 0, 0, 0, 0, now.Location())
|
||||||
|
switch reset {
|
||||||
|
case "daily":
|
||||||
|
return midnight
|
||||||
|
case "weekly":
|
||||||
|
back := (int(now.Weekday()) + 6) % 7 // Monday
|
||||||
|
return midnight.AddDate(0, 0, -back)
|
||||||
|
case "monthly":
|
||||||
|
if day < 1 {
|
||||||
|
day = 1
|
||||||
|
}
|
||||||
|
start := monthDay(y, m, day, now.Location())
|
||||||
|
if start.After(now) {
|
||||||
|
py, pm, _ := time.Date(y, m, 1, 0, 0, 0, 0, now.Location()).AddDate(0, -1, 0).Date()
|
||||||
|
start = monthDay(py, pm, day, now.Location())
|
||||||
|
}
|
||||||
|
return start
|
||||||
|
}
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// monthDay is midnight on a day of a month, or on its last day when the
|
||||||
|
// month is shorter.
|
||||||
|
func monthDay(y int, m time.Month, day int, loc *time.Location) time.Time {
|
||||||
|
last := time.Date(y, m+1, 0, 0, 0, 0, 0, loc).Day()
|
||||||
|
if day > last {
|
||||||
|
day = last
|
||||||
|
}
|
||||||
|
return time.Date(y, m, day, 0, 0, 0, 0, loc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// nextReset is when the period after the current one starts.
|
||||||
|
func nextReset(now time.Time, reset string, day int) time.Time {
|
||||||
|
start := periodStart(now, reset, day)
|
||||||
|
switch reset {
|
||||||
|
case "daily":
|
||||||
|
return start.AddDate(0, 0, 1)
|
||||||
|
case "weekly":
|
||||||
|
return start.AddDate(0, 0, 7)
|
||||||
|
case "monthly":
|
||||||
|
y, m, _ := start.Date()
|
||||||
|
ny, nm, _ := time.Date(y, m, 1, 0, 0, 0, 0, now.Location()).AddDate(0, 1, 0).Date()
|
||||||
|
if day < 1 {
|
||||||
|
day = 1
|
||||||
|
}
|
||||||
|
return monthDay(ny, nm, day, now.Location())
|
||||||
|
}
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) quotaResets(ctx context.Context) map[string]time.Time {
|
||||||
|
out := map[string]time.Time{}
|
||||||
|
if v, err := s.store.GetMeta(ctx, quotaResetsKey); err == nil && v != "" {
|
||||||
|
json.Unmarshal([]byte(v), &out)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) saveQuotaResets(ctx context.Context, m map[string]time.Time) {
|
||||||
|
b, _ := json.Marshal(m)
|
||||||
|
if err := s.store.SetMeta(ctx, quotaResetsKey, string(b)); err != nil {
|
||||||
|
s.log.Error("saving the quotas' reset times failed", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunQuotas resets quotas when their period ends and saves their usage,
|
||||||
|
// until ctx ends (then it saves once more).
|
||||||
|
func (s *Server) RunQuotas(ctx context.Context) {
|
||||||
|
s.checkQuotas(ctx)
|
||||||
|
tick := time.NewTicker(time.Minute)
|
||||||
|
defer tick.Stop()
|
||||||
|
n := 0
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
s.SaveQuotaUsage(context.Background())
|
||||||
|
return
|
||||||
|
case <-tick.C:
|
||||||
|
s.checkQuotas(ctx)
|
||||||
|
if n++; n%5 == 0 {
|
||||||
|
s.SaveQuotaUsage(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkQuotas resets the quotas whose period has ended since their last
|
||||||
|
// reset. A quota seen for the first time starts its period now.
|
||||||
|
func (s *Server) checkQuotas(ctx context.Context) {
|
||||||
|
st := s.eng.State()
|
||||||
|
resets := s.quotaResets(ctx)
|
||||||
|
now := s.nowFn()
|
||||||
|
changed := false
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, r := range st.Rules {
|
||||||
|
if r.Quota == nil || r.Disabled || r.Raw != "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := fmt.Sprintf("r%d", r.ID)
|
||||||
|
seen[key] = true
|
||||||
|
last, ok := resets[key]
|
||||||
|
if !ok {
|
||||||
|
resets[key], changed = now, true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
start := periodStart(now, r.Quota.Reset, r.Quota.Day)
|
||||||
|
if start.IsZero() || !last.Before(start) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := s.fw.ResetQuota(ctx, r.ID, r.Quota.PerAddress() != ""); err != nil {
|
||||||
|
s.log.Error("resetting a quota failed", "rule", r.Label(), "err", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s.log.Info("quota reset for its new period", "rule", r.Label(), "period", r.Quota.Reset)
|
||||||
|
resets[key], changed = now, true
|
||||||
|
s.forgetQuotaUsage(ctx, r.ID)
|
||||||
|
}
|
||||||
|
for key := range resets {
|
||||||
|
if !seen[key] {
|
||||||
|
delete(resets, key)
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if changed {
|
||||||
|
s.saveQuotaResets(ctx, resets)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveQuotaUsage saves what the quotas used, for after a reboot.
|
||||||
|
func (s *Server) SaveQuotaUsage(ctx context.Context) {
|
||||||
|
t, err := s.fw.Table(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(t.QuotaUsage)
|
||||||
|
if err := s.store.SetMeta(ctx, QuotaUsageKey, string(b)); err != nil {
|
||||||
|
s.log.Error("saving the quotas' usage failed", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// forgetQuotaUsage drops a reset quota from the saved usage, so a restart
|
||||||
|
// doesn't bring back what it used before.
|
||||||
|
func (s *Server) forgetQuotaUsage(ctx context.Context, ruleID int64) {
|
||||||
|
v, err := s.store.GetMeta(ctx, QuotaUsageKey)
|
||||||
|
if err != nil || v == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var u nft.QuotaUsage
|
||||||
|
if json.Unmarshal([]byte(v), &u) != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name := render.QuotaName(ruleID)
|
||||||
|
delete(u.Quotas, name)
|
||||||
|
delete(u.QuotaSets, name+"_v4")
|
||||||
|
delete(u.QuotaSets, name+"_v6")
|
||||||
|
b, _ := json.Marshal(u)
|
||||||
|
s.store.SetMeta(ctx, QuotaUsageKey, string(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
// listQuotas answers what each rule's quota used.
|
||||||
|
func (s *Server) listQuotas(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
if err := require(p, auth.ResLog, false); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
st := s.eng.State()
|
||||||
|
var usage nft.QuotaUsage
|
||||||
|
if t, err := s.fw.Table(r.Context()); err == nil {
|
||||||
|
usage = t.QuotaUsage
|
||||||
|
}
|
||||||
|
resets := s.quotaResets(r.Context())
|
||||||
|
now := s.nowFn()
|
||||||
|
out := []api.QuotaInfo{}
|
||||||
|
for _, rule := range st.Rules {
|
||||||
|
if rule.Quota == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
q := api.QuotaInfo{RuleID: rule.ID, Rule: rule.Label(), Quota: *rule.Quota}
|
||||||
|
name := render.QuotaName(rule.ID)
|
||||||
|
if rule.Quota.PerAddress() == "" {
|
||||||
|
q.Used = usage.Quotas[name]
|
||||||
|
} else {
|
||||||
|
v4, v6 := rule.Groups()
|
||||||
|
for _, fam := range []string{"_v4", "_v6"} {
|
||||||
|
for _, e := range usage.QuotaSets[name+fam] {
|
||||||
|
addr := e.Addr
|
||||||
|
if fam == "_v4" && v4 < 32 {
|
||||||
|
addr = fmt.Sprintf("%s/%d", addr, v4)
|
||||||
|
} else if fam == "_v6" && v6 < 128 {
|
||||||
|
addr = fmt.Sprintf("%s/%d", addr, v6)
|
||||||
|
}
|
||||||
|
q.Addresses = append(q.Addresses, api.QuotaAddress{Address: addr, Used: e.Used})
|
||||||
|
q.Used += e.Used
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(q.Addresses, func(i, j int) bool { return q.Addresses[i].Used > q.Addresses[j].Used })
|
||||||
|
}
|
||||||
|
if last, ok := resets[fmt.Sprintf("r%d", rule.ID)]; ok {
|
||||||
|
q.LastReset = &last
|
||||||
|
}
|
||||||
|
if next := nextReset(now, rule.Quota.Reset, rule.Quota.Day); !next.IsZero() {
|
||||||
|
q.NextReset = &next
|
||||||
|
}
|
||||||
|
out = append(out, q)
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resetQuota starts a rule's quota again now.
|
||||||
|
func (s *Server) resetQuota(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
if err := require(p, auth.ResConfig, true); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
o, err := findObject(s.eng.State(), model.KindRule, r.PathValue("ref"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rule := o.(*model.Rule)
|
||||||
|
if rule.Quota == nil {
|
||||||
|
return badRequest("%s has no quota", rule.Label())
|
||||||
|
}
|
||||||
|
if err := s.fw.ResetQuota(r.Context(), rule.ID, rule.Quota.PerAddress() != ""); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resets := s.quotaResets(r.Context())
|
||||||
|
resets[fmt.Sprintf("r%d", rule.ID)] = s.nowFn()
|
||||||
|
s.saveQuotaResets(r.Context(), resets)
|
||||||
|
s.forgetQuotaUsage(r.Context(), rule.ID)
|
||||||
|
s.eng.Audit(r.Context(), p, "quota.reset", rule.Label(), "success", "")
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"reset": rule.Label()})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -70,6 +70,12 @@ func engineStatus(kind string) int {
|
|||||||
// writeErr writes an error response. result, if given, is the preview of
|
// writeErr writes an error response. result, if given, is the preview of
|
||||||
// a refused change.
|
// a refused change.
|
||||||
func (s *Server) writeErr(w http.ResponseWriter, r *http.Request, err error, result any) {
|
func (s *Server) writeErr(w http.ResponseWriter, r *http.Request, err error, result any) {
|
||||||
|
var oe *opError
|
||||||
|
if errors.As(err, &oe) {
|
||||||
|
info, status := oe.info()
|
||||||
|
writeJSON(w, status, api.ErrorBody{Error: info})
|
||||||
|
return
|
||||||
|
}
|
||||||
var he *httpError
|
var he *httpError
|
||||||
if errors.As(err, &he) {
|
if errors.As(err, &he) {
|
||||||
writeError(w, he.status, he.code, he.msg)
|
writeError(w, he.status, he.code, he.msg)
|
||||||
|
|||||||
@@ -41,6 +41,16 @@ func (s *Server) routes() {
|
|||||||
m.HandleFunc("GET "+p+"/sets/{ref}/elements", s.authed(s.listElements))
|
m.HandleFunc("GET "+p+"/sets/{ref}/elements", s.authed(s.listElements))
|
||||||
m.HandleFunc("POST "+p+"/sets/{ref}/elements", s.authed(s.addElement))
|
m.HandleFunc("POST "+p+"/sets/{ref}/elements", s.authed(s.addElement))
|
||||||
m.HandleFunc("DELETE "+p+"/sets/{ref}/elements/{addr}", s.authed(s.deleteElement))
|
m.HandleFunc("DELETE "+p+"/sets/{ref}/elements/{addr}", s.authed(s.deleteElement))
|
||||||
|
m.HandleFunc("GET "+p+"/quotas", s.authed(s.listQuotas))
|
||||||
|
m.HandleFunc("POST "+p+"/rules/{ref}/reset-quota", s.authed(s.resetQuota))
|
||||||
|
m.HandleFunc("GET "+p+"/changesets", s.authed(s.listChangesets))
|
||||||
|
m.HandleFunc("POST "+p+"/changesets", s.authed(s.createChangeset))
|
||||||
|
m.HandleFunc("GET "+p+"/changesets/{id}", s.authed(s.getChangeset))
|
||||||
|
m.HandleFunc("PATCH "+p+"/changesets/{id}", s.authed(s.updateChangeset))
|
||||||
|
m.HandleFunc("GET "+p+"/changesets/{id}/preview", s.authed(s.previewChangeset))
|
||||||
|
m.HandleFunc("POST "+p+"/changesets/{id}/apply", s.authed(s.applyChangeset))
|
||||||
|
m.HandleFunc("POST "+p+"/changesets/{id}/discard", s.authed(s.discardChangeset))
|
||||||
|
m.HandleFunc("DELETE "+p+"/changesets/{id}/ops/{seq}", s.authed(s.removeOp))
|
||||||
m.HandleFunc("GET "+p+"/{kind}", s.authed(s.listObjects))
|
m.HandleFunc("GET "+p+"/{kind}", s.authed(s.listObjects))
|
||||||
m.HandleFunc("POST "+p+"/{kind}", s.authed(s.createObject))
|
m.HandleFunc("POST "+p+"/{kind}", s.authed(s.createObject))
|
||||||
m.HandleFunc("GET "+p+"/{kind}/{ref}", s.authed(s.getObject))
|
m.HandleFunc("GET "+p+"/{kind}/{ref}", s.authed(s.getObject))
|
||||||
|
|||||||
@@ -65,6 +65,8 @@ type Firewall interface {
|
|||||||
TableText(ctx context.Context, family, name string) (string, error)
|
TableText(ctx context.Context, family, name string) (string, error)
|
||||||
ApplyScript(ctx context.Context, script string) error
|
ApplyScript(ctx context.Context, script string) error
|
||||||
SaveBoot(st *model.State) error
|
SaveBoot(st *model.State) error
|
||||||
|
// ResetQuota starts a rule's quota again (perAddress: its sets).
|
||||||
|
ResetQuota(ctx context.Context, ruleID int64, perAddress bool) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server is the HTTP server.
|
// Server is the HTTP server.
|
||||||
@@ -86,6 +88,11 @@ type Server struct {
|
|||||||
|
|
||||||
probation probation
|
probation probation
|
||||||
logs *logRing
|
logs *logRing
|
||||||
|
// unitLinks are where an enabled nftables.service shows (tests change
|
||||||
|
// them).
|
||||||
|
unitLinks []string
|
||||||
|
// csMu orders changes to changesets (staging, applying, discarding).
|
||||||
|
csMu sync.Mutex
|
||||||
nowFn func() time.Time
|
nowFn func() time.Time
|
||||||
// probationFor overrides the settings' probation (tests).
|
// probationFor overrides the settings' probation (tests).
|
||||||
probationFor time.Duration
|
probationFor time.Duration
|
||||||
@@ -109,7 +116,7 @@ func New(cfg Config, eng *engine.Engine, fw Firewall, ifaces Interfaces, log *sl
|
|||||||
log = slog.Default()
|
log = slog.Default()
|
||||||
}
|
}
|
||||||
s := &Server{cfg: cfg, eng: eng, fw: fw, ifaces: ifaces, store: eng.Store(), log: log, mux: http.NewServeMux(), limiter: newRateLimiter(),
|
s := &Server{cfg: cfg, eng: eng, fw: fw, ifaces: ifaces, store: eng.Store(), log: log, mux: http.NewServeMux(), limiter: newRateLimiter(),
|
||||||
web: web, started: time.Now(), logs: newLogRing(2000), nowFn: time.Now}
|
web: web, started: time.Now(), logs: newLogRing(2000), nowFn: time.Now, unitLinks: nftablesUnitLinks}
|
||||||
s.routes()
|
s.routes()
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/cookiejar"
|
"net/http/cookiejar"
|
||||||
@@ -35,6 +36,7 @@ type fakeFW struct {
|
|||||||
applyErr error
|
applyErr error
|
||||||
deleted []string
|
deleted []string
|
||||||
restored []string
|
restored []string
|
||||||
|
resets []string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeFW) Check(_ context.Context, st *model.State) error { return nil }
|
func (f *fakeFW) Check(_ context.Context, st *model.State) error { return nil }
|
||||||
@@ -88,6 +90,12 @@ func (f *fakeFW) DeleteTable(_ context.Context, fam, name string) error {
|
|||||||
f.deleted = append(f.deleted, fam+" "+name)
|
f.deleted = append(f.deleted, fam+" "+name)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
func (f *fakeFW) ResetQuota(_ context.Context, id int64, perAddress bool) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
f.resets = append(f.resets, fmt.Sprintf("r%d:%v", id, perAddress))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
func (f *fakeFW) TableText(_ context.Context, fam, name string) (string, error) {
|
func (f *fakeFW) TableText(_ context.Context, fam, name string) (string, error) {
|
||||||
return "table " + fam + " " + name + " {}", nil
|
return "table " + fam + " " + name + " {}", nil
|
||||||
}
|
}
|
||||||
@@ -538,3 +546,322 @@ func TestImportAndTakeover(t *testing.T) {
|
|||||||
t.Errorf("taking over Housecarl's own table: %d", code)
|
t.Errorf("taking over Housecarl's own table: %d", code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestChangesets: changes staged in a changeset are checked as they are
|
||||||
|
// added, shown in reads that name the changeset, previewed with the
|
||||||
|
// ruleset's diff, and applied together as one history entry, on
|
||||||
|
// probation; another user can't use the changeset.
|
||||||
|
func TestChangesets(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
e.srv.probationFor = 5 * time.Second
|
||||||
|
e.call(e.token, "POST", "/zones", model.Zone{Name: "lan", Interfaces: []string{"eth1"}}, nil)
|
||||||
|
e.call(e.token, "POST", "/confirm", nil, nil)
|
||||||
|
applied := len(e.fw.applied)
|
||||||
|
var cs model.Changeset
|
||||||
|
if code := e.call(e.token, "POST", "/changesets", api.ChangesetInput{Description: "trusted ssh"}, &cs); code != 201 || cs.Status != "open" {
|
||||||
|
t.Fatalf("create: %d %+v", code, cs)
|
||||||
|
}
|
||||||
|
q := "?changeset=" + itoa(cs.ID)
|
||||||
|
// A rule naming a set the changeset hasn't made yet is refused.
|
||||||
|
var eb api.ErrorBody
|
||||||
|
rule := model.Rule{Chain: "input", Match: model.Match{Sources: []string{"trusted"}, Services: []string{"ssh"}}, Action: "accept"}
|
||||||
|
if code := e.call(e.token, "POST", "/rules"+q, rule, &eb); code != 422 || eb.Error.Code != "validation" {
|
||||||
|
t.Errorf("staging an invalid rule: %d %+v", code, eb)
|
||||||
|
}
|
||||||
|
var mr api.MutationResponse
|
||||||
|
if code := e.call(e.token, "POST", "/sets"+q, model.AddressSet{Name: "trusted", Entries: []string{"10.0.10.0/24"}}, &mr); code != 200 || !mr.Staged || mr.Applied {
|
||||||
|
t.Fatalf("stage set: %d %+v", code, mr)
|
||||||
|
}
|
||||||
|
if code := e.call(e.token, "POST", "/rules"+q, rule, &mr); code != 200 || !mr.Staged || len(mr.Changeset.Ops) != 2 {
|
||||||
|
t.Fatalf("stage rule: %d %+v", code, mr)
|
||||||
|
}
|
||||||
|
var created model.Rule
|
||||||
|
json.Unmarshal(mr.Object, &created)
|
||||||
|
// Later operations can name what the changeset made.
|
||||||
|
if code := e.call(e.token, "PATCH", "/rules/"+itoa(created.ID)+q, map[string]any{"comment": "SSH from home"}, &mr); code != 200 || len(mr.Changeset.Ops) != 3 {
|
||||||
|
t.Fatalf("stage update: %d %+v", code, mr)
|
||||||
|
}
|
||||||
|
if code := e.call(e.token, "DELETE", "/zones/lan"+q, nil, &mr); code != 200 {
|
||||||
|
t.Fatalf("stage delete: %d %+v", code, mr)
|
||||||
|
}
|
||||||
|
// Nothing applied yet.
|
||||||
|
if len(e.fw.applied) != applied || len(e.eng.State().Sets) != 0 {
|
||||||
|
t.Fatalf("staging applied something")
|
||||||
|
}
|
||||||
|
// Reads naming the changeset show it, marked.
|
||||||
|
var zones []map[string]any
|
||||||
|
e.call(e.token, "GET", "/zones"+q, nil, &zones)
|
||||||
|
if len(zones) != 1 || zones[0]["pending"] != "deleted" {
|
||||||
|
t.Errorf("zones in the changeset: %+v", zones)
|
||||||
|
}
|
||||||
|
var rules []map[string]any
|
||||||
|
e.call(e.token, "GET", "/rules"+q+"&chain=input", nil, &rules)
|
||||||
|
if len(rules) != 1 || rules[0]["pending"] != "new" || rules[0]["comment"] != "SSH from home" {
|
||||||
|
t.Errorf("rules in the changeset: %+v", rules)
|
||||||
|
}
|
||||||
|
var plain []map[string]any
|
||||||
|
e.call(e.token, "GET", "/zones", nil, &plain)
|
||||||
|
if len(plain) != 1 || plain[0]["pending"] != nil {
|
||||||
|
t.Errorf("zones without the changeset: %+v", plain)
|
||||||
|
}
|
||||||
|
// The preview: valid, with the ruleset's diff.
|
||||||
|
var pv api.ChangesetPreview
|
||||||
|
e.call(e.token, "GET", "/changesets/"+itoa(cs.ID)+"/preview", nil, &pv)
|
||||||
|
if !pv.Valid || !strings.Contains(pv.Diff, "+\t\tip saddr @trusted_v4 tcp dport 22 counter accept") || !strings.Contains(pv.Diff, "--- ruleset.nft (now)") {
|
||||||
|
t.Errorf("preview: %+v", pv)
|
||||||
|
}
|
||||||
|
// Another user can't stage into it, apply or discard it.
|
||||||
|
ctx := context.Background()
|
||||||
|
other := &model.User{Username: "sam", Grants: auth.Roles["operator"]}
|
||||||
|
e.srv.store.CreateUser(ctx, other)
|
||||||
|
otok, pid, sh := auth.NewToken()
|
||||||
|
e.srv.store.CreateToken(ctx, &model.Token{UserID: other.ID, Name: "t", PublicID: pid, SecretHash: sh})
|
||||||
|
if code := e.call(otok, "POST", "/changesets/"+itoa(cs.ID)+"/apply", nil, &eb); code != 403 || !strings.Contains(eb.Error.Message, "admin's") {
|
||||||
|
t.Errorf("another user's apply: %d %+v", code, eb)
|
||||||
|
}
|
||||||
|
// Removing an operation; then the changeset is applied as one change.
|
||||||
|
var after model.Changeset
|
||||||
|
if code := e.call(e.token, "DELETE", "/changesets/"+itoa(cs.ID)+"/ops/4", nil, &after); code != 200 || len(after.Ops) != 3 {
|
||||||
|
t.Fatalf("remove op: %d %+v", code, after)
|
||||||
|
}
|
||||||
|
if code := e.call(e.token, "POST", "/changesets/"+itoa(cs.ID)+"/apply", nil, &mr); code != 200 || !mr.Applied || mr.ConfirmBy == nil || mr.Changeset.Status != "applied" {
|
||||||
|
t.Fatalf("apply: %d %+v", code, mr)
|
||||||
|
}
|
||||||
|
if len(e.fw.applied) != applied+1 || len(e.eng.State().Rules) != 1 || len(e.eng.State().Zones) != 1 {
|
||||||
|
t.Errorf("applied: %d rulesets, state %+v", len(e.fw.applied)-applied, e.eng.State())
|
||||||
|
}
|
||||||
|
h, _ := e.srv.store.GetHistory(ctx, mr.HistoryID)
|
||||||
|
if h.Description != "trusted ssh" || len(h.Changes) != 2 {
|
||||||
|
t.Errorf("history: %+v", h)
|
||||||
|
}
|
||||||
|
// An applied changeset takes no more.
|
||||||
|
if code := e.call(e.token, "POST", "/sets"+q, model.AddressSet{Name: "x", Entries: []string{"10.0.0.1"}}, &eb); code != 409 || !strings.Contains(eb.Error.Message, "is applied") {
|
||||||
|
t.Errorf("staging into an applied changeset: %d %+v", code, eb)
|
||||||
|
}
|
||||||
|
// A changeset that no longer applies names the operation.
|
||||||
|
var cs2 model.Changeset
|
||||||
|
e.call(e.token, "POST", "/changesets", nil, &cs2)
|
||||||
|
e.call(e.token, "PATCH", "/rules/"+itoa(created.ID)+"?changeset="+itoa(cs2.ID), map[string]any{"comment": "x"}, nil)
|
||||||
|
e.call(e.token, "DELETE", "/rules/"+itoa(created.ID), nil, nil)
|
||||||
|
e.call(e.token, "GET", "/changesets/"+itoa(cs2.ID)+"/preview", nil, &pv)
|
||||||
|
if pv.Valid || pv.Error == nil || pv.Error.Op != 1 || !strings.Contains(pv.Error.Message, "operation 1") {
|
||||||
|
t.Errorf("stale preview: %+v", pv)
|
||||||
|
}
|
||||||
|
if code := e.call(e.token, "POST", "/changesets/"+itoa(cs2.ID)+"/discard", nil, &after); code != 200 || after.Status != "discarded" {
|
||||||
|
t.Errorf("discard: %d %+v", code, after)
|
||||||
|
}
|
||||||
|
// A dry run shows the ruleset's diff.
|
||||||
|
if code := e.call(e.token, "POST", "/sets?dry_run=1", model.AddressSet{Name: "y", Entries: []string{"10.0.0.2"}}, &mr); code != 200 || !strings.Contains(mr.Diff, "+\tset y_v4") {
|
||||||
|
t.Errorf("dry run diff: %d %q", code, mr.Diff)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQuotaPeriods(t *testing.T) {
|
||||||
|
at := func(s string) time.Time { v, _ := time.ParseInLocation("2006-01-02 15:04", s, time.Local); return v }
|
||||||
|
for _, c := range []struct {
|
||||||
|
now, reset string
|
||||||
|
day int
|
||||||
|
start string
|
||||||
|
next string
|
||||||
|
}{
|
||||||
|
{"2026-10-19 10:00", "daily", 0, "2026-10-19 00:00", "2026-10-20 00:00"},
|
||||||
|
{"2026-10-19 10:00", "weekly", 0, "2026-10-19 00:00", "2026-10-26 00:00"}, // a Monday
|
||||||
|
{"2026-10-25 23:59", "weekly", 0, "2026-10-19 00:00", "2026-10-26 00:00"}, // a Sunday
|
||||||
|
{"2026-10-19 10:00", "monthly", 15, "2026-10-15 00:00", "2026-11-15 00:00"},
|
||||||
|
{"2026-10-10 10:00", "monthly", 15, "2026-09-15 00:00", "2026-10-15 00:00"},
|
||||||
|
{"2026-02-28 10:00", "monthly", 31, "2026-02-28 00:00", "2026-03-31 00:00"}, // February has no 31st
|
||||||
|
{"2026-03-15 10:00", "monthly", 31, "2026-02-28 00:00", "2026-03-31 00:00"},
|
||||||
|
{"2026-01-01 00:00", "monthly", 0, "2026-01-01 00:00", "2026-02-01 00:00"},
|
||||||
|
} {
|
||||||
|
now := at(c.now)
|
||||||
|
if got := periodStart(now, c.reset, c.day); !got.Equal(at(c.start)) {
|
||||||
|
t.Errorf("%s %s %d: period from %s, want %s", c.now, c.reset, c.day, got.Format("2006-01-02 15:04"), c.start)
|
||||||
|
}
|
||||||
|
if got := nextReset(now, c.reset, c.day); !got.Equal(at(c.next)) {
|
||||||
|
t.Errorf("%s %s %d: next reset %s, want %s", c.now, c.reset, c.day, got.Format("2006-01-02 15:04"), c.next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !periodStart(time.Now(), "", 0).IsZero() {
|
||||||
|
t.Error("a quota reset by hand has no period")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestQuotaResets: a quota starts its period when first seen, is reset
|
||||||
|
// when the next one starts, and by hand.
|
||||||
|
func TestQuotaResets(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
now := time.Date(2026, 10, 19, 23, 50, 0, 0, time.Local)
|
||||||
|
e.srv.nowFn = func() time.Time { return now }
|
||||||
|
var mr api.MutationResponse
|
||||||
|
rule := model.Rule{Chain: "forward", Action: "drop", Quota: &model.Quota{Bytes: 5 << 30, Over: true, PerSource: true, Reset: "daily"}}
|
||||||
|
if code := e.call(e.token, "POST", "/rules", rule, &mr); code != 201 {
|
||||||
|
t.Fatalf("rule: %d %+v", code, mr)
|
||||||
|
}
|
||||||
|
var created model.Rule
|
||||||
|
json.Unmarshal(mr.Object, &created)
|
||||||
|
e.srv.checkQuotas(ctx)
|
||||||
|
if len(e.fw.resets) != 0 {
|
||||||
|
t.Fatalf("reset on first sight: %v", e.fw.resets)
|
||||||
|
}
|
||||||
|
now = now.Add(5 * time.Minute) // the same day
|
||||||
|
e.srv.checkQuotas(ctx)
|
||||||
|
now = now.Add(10 * time.Minute) // past midnight
|
||||||
|
e.srv.checkQuotas(ctx)
|
||||||
|
e.srv.checkQuotas(ctx)
|
||||||
|
want := fmt.Sprintf("r%d:true", created.ID)
|
||||||
|
if strings.Join(e.fw.resets, ",") != want {
|
||||||
|
t.Errorf("resets %v, want one %s", e.fw.resets, want)
|
||||||
|
}
|
||||||
|
var qs []api.QuotaInfo
|
||||||
|
e.call(e.token, "GET", "/quotas", nil, &qs)
|
||||||
|
if len(qs) != 1 || qs[0].NextReset == nil || qs[0].NextReset.Day() != 21 {
|
||||||
|
t.Errorf("quotas %+v", qs)
|
||||||
|
}
|
||||||
|
if code := e.call(e.token, "POST", "/rules/"+itoa(created.ID)+"/reset-quota", nil, nil); code != 200 || len(e.fw.resets) != 2 {
|
||||||
|
t.Errorf("reset by hand: %d %v", code, e.fw.resets)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAutoConfirm: the client that made a change confirms it by itself:
|
||||||
|
// with its token, from the same address, over a new connection. Nobody
|
||||||
|
// else can, an older token can't, and with manual_confirm there is none.
|
||||||
|
func TestAutoConfirm(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
e.srv.probationFor = 5 * time.Second
|
||||||
|
post := func(token, path string, body any) (*http.Response, []byte) {
|
||||||
|
b, _ := json.Marshal(body)
|
||||||
|
req, _ := http.NewRequest("POST", e.ts.URL+api.Prefix+path, bytes.NewReader(b))
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
out, _ := io.ReadAll(resp.Body)
|
||||||
|
return resp, out
|
||||||
|
}
|
||||||
|
resp, body := post(e.token, "/zones", model.Zone{Name: "lan", Interfaces: []string{"eth1"}})
|
||||||
|
var mr api.MutationResponse
|
||||||
|
json.Unmarshal(body, &mr)
|
||||||
|
if mr.ConfirmToken == "" || !resp.Close {
|
||||||
|
t.Fatalf("no token, or the connection stays open: %+v close=%v", mr, resp.Close)
|
||||||
|
}
|
||||||
|
first := mr.ConfirmToken
|
||||||
|
// A newer change: only its token confirms now.
|
||||||
|
_, body = post(e.token, "/zones", model.Zone{Name: "dmz", Interfaces: []string{"eth2"}})
|
||||||
|
json.Unmarshal(body, &mr)
|
||||||
|
if resp, _ := post(e.token, "/confirm", api.ConfirmInput{Token: first}); resp.StatusCode != 409 {
|
||||||
|
t.Errorf("an older token: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
// Another user can't use it.
|
||||||
|
ctx := context.Background()
|
||||||
|
other := &model.User{Username: "sam", Grants: auth.Roles["operator"]}
|
||||||
|
e.srv.store.CreateUser(ctx, other)
|
||||||
|
otok, pid, sh := auth.NewToken()
|
||||||
|
e.srv.store.CreateToken(ctx, &model.Token{UserID: other.ID, Name: "t", PublicID: pid, SecretHash: sh})
|
||||||
|
if resp, _ := post(otok, "/confirm", api.ConfirmInput{Token: mr.ConfirmToken}); resp.StatusCode != 403 {
|
||||||
|
t.Errorf("another user's confirmation: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
// Not over the change's own connection, nor from elsewhere.
|
||||||
|
c := e.srv.probation.check
|
||||||
|
p := &auth.Principal{User: &model.User{ID: c.userID, Username: c.username}, Method: "token", Token: &model.Token{ID: c.tokenID}}
|
||||||
|
same := httptest.NewRequest("POST", "/", nil)
|
||||||
|
same.RemoteAddr = c.remote
|
||||||
|
if _, err := e.srv.confirmToken(same, p, c.token); err == nil || !strings.Contains(err.Error(), "new one") {
|
||||||
|
t.Errorf("over the same connection: %v", err)
|
||||||
|
}
|
||||||
|
elsewhere := httptest.NewRequest("POST", "/", nil)
|
||||||
|
elsewhere.RemoteAddr = "192.0.2.9:4444"
|
||||||
|
if _, err := e.srv.confirmToken(elsewhere, p, c.token); err == nil || !strings.Contains(err.Error(), "same place") {
|
||||||
|
t.Errorf("from elsewhere: %v", err)
|
||||||
|
}
|
||||||
|
// The client itself, over a new connection: confirmed.
|
||||||
|
resp, body = post(e.token, "/confirm", api.ConfirmInput{Token: mr.ConfirmToken})
|
||||||
|
if resp.StatusCode != 200 || !strings.Contains(string(body), `"confirmed":2`) {
|
||||||
|
t.Fatalf("auto-confirm: %d %s", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
if pr := e.srv.probationStatus(); len(pr.Pending) != 0 {
|
||||||
|
t.Errorf("still pending: %+v", pr)
|
||||||
|
}
|
||||||
|
// manual_confirm: no token; a person confirms.
|
||||||
|
e.call(e.token, "PATCH", "/settings", map[string]any{"manual_confirm": true}, nil)
|
||||||
|
e.call(e.token, "POST", "/confirm", nil, nil)
|
||||||
|
_, body = post(e.token, "/zones", model.Zone{Name: "guest", Interfaces: []string{"eth3"}})
|
||||||
|
mr = api.MutationResponse{}
|
||||||
|
json.Unmarshal(body, &mr)
|
||||||
|
if mr.ConfirmToken != "" || mr.ConfirmBy == nil {
|
||||||
|
t.Errorf("manual confirmation: %+v", mr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStagedUndo: an undo staged in a changeset changes nothing until the
|
||||||
|
// changeset is applied; then History links the two entries.
|
||||||
|
func TestStagedUndo(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
e.srv.probationFor = 5 * time.Second
|
||||||
|
var mr api.MutationResponse
|
||||||
|
e.call(e.token, "POST", "/zones", model.Zone{Name: "lan", Interfaces: []string{"eth1"}}, &mr)
|
||||||
|
e.call(e.token, "POST", "/confirm", nil, nil)
|
||||||
|
made := mr.HistoryID
|
||||||
|
var cs model.Changeset
|
||||||
|
e.call(e.token, "POST", "/changesets", nil, &cs)
|
||||||
|
q := "?changeset=" + itoa(cs.ID)
|
||||||
|
if code := e.call(e.token, "POST", "/history/"+itoa(made)+"/revert"+q, nil, &mr); code != 200 || !mr.Staged {
|
||||||
|
t.Fatalf("staging the undo: %d %+v", code, mr)
|
||||||
|
}
|
||||||
|
if op := mr.Changeset.Ops[0]; op.Op != model.OpRevert || !strings.HasPrefix(op.Summary, "undo "+itoa(made)+":") {
|
||||||
|
t.Errorf("the operation: %+v", op)
|
||||||
|
}
|
||||||
|
if len(e.eng.State().Zones) != 1 {
|
||||||
|
t.Fatal("a staged undo was applied")
|
||||||
|
}
|
||||||
|
// Staging it again can't work: the changeset already undoes it.
|
||||||
|
var eb api.ErrorBody
|
||||||
|
if code := e.call(e.token, "POST", "/history/"+itoa(made)+"/revert"+q, nil, &eb); code != 409 {
|
||||||
|
t.Errorf("a second undo: %d %+v", code, eb)
|
||||||
|
}
|
||||||
|
if code := e.call(e.token, "POST", "/changesets/"+itoa(cs.ID)+"/apply", nil, &mr); code != 200 || !mr.Applied {
|
||||||
|
t.Fatalf("apply: %d %+v", code, mr)
|
||||||
|
}
|
||||||
|
if len(e.eng.State().Zones) != 0 {
|
||||||
|
t.Error("the undo wasn't applied")
|
||||||
|
}
|
||||||
|
h, _ := e.srv.store.GetHistory(context.Background(), mr.HistoryID)
|
||||||
|
orig, _ := e.srv.store.GetHistory(context.Background(), made)
|
||||||
|
if h.RevertOf != made || orig.RevertedBy != h.ID || !strings.HasPrefix(h.Description, "undo ") {
|
||||||
|
t.Errorf("history: %+v, original %+v", h, orig)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHostProblems: an enabled nftables.service, and an imported table
|
||||||
|
// still in force, are warned about.
|
||||||
|
func TestHostProblems(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
dir := t.TempDir()
|
||||||
|
link := dir + "/nftables.service"
|
||||||
|
e.srv.unitLinks = []string{link}
|
||||||
|
codes := func() string {
|
||||||
|
var st api.Status
|
||||||
|
e.call(e.token, "GET", "/status", nil, &st)
|
||||||
|
var out []string
|
||||||
|
for _, p := range st.Problems {
|
||||||
|
out = append(out, p.Code)
|
||||||
|
}
|
||||||
|
return strings.Join(out, " ")
|
||||||
|
}
|
||||||
|
if c := codes(); strings.Contains(c, "nftables_service") || strings.Contains(c, "takeover_pending") {
|
||||||
|
t.Errorf("warned without cause: %s", c)
|
||||||
|
}
|
||||||
|
os.Symlink("/usr/lib/systemd/system/nftables.service", link) // dangling is fine: Lstat
|
||||||
|
// The fake ruleset lists inet main: imported from it, it still applies.
|
||||||
|
e.srv.store.SetMeta(context.Background(), importedTableKey, "inet main")
|
||||||
|
if c := codes(); !strings.Contains(c, "nftables_service") || !strings.Contains(c, "takeover_pending") {
|
||||||
|
t.Errorf("not warned: %s", c)
|
||||||
|
}
|
||||||
|
e.srv.store.SetMeta(context.Background(), importedTableKey, "inet gone")
|
||||||
|
if c := codes(); strings.Contains(c, "takeover_pending") {
|
||||||
|
t.Errorf("warned about a table that is gone: %s", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"housecarl/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
const changesetCols = `id, user_id, username, via, description, status, ops, created_at, updated_at, history_id`
|
||||||
|
|
||||||
|
func scanChangeset(sc interface{ Scan(...any) error }) (*model.Changeset, error) {
|
||||||
|
cs := &model.Changeset{}
|
||||||
|
var uid, hid sql.NullInt64
|
||||||
|
var ops, created, updated string
|
||||||
|
if err := sc.Scan(&cs.ID, &uid, &cs.Username, &cs.Via, &cs.Description, &cs.Status, &ops, &created, &updated, &hid); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cs.UserID, cs.HistoryID = uid.Int64, hid.Int64
|
||||||
|
cs.CreatedAt, cs.UpdatedAt = parseTS(created), parseTS(updated)
|
||||||
|
if err := json.Unmarshal([]byte(ops), &cs.Ops); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if cs.Ops == nil {
|
||||||
|
cs.Ops = []model.Op{}
|
||||||
|
}
|
||||||
|
return cs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateChangeset saves a new changeset and sets its ID.
|
||||||
|
func (s *Store) CreateChangeset(ctx context.Context, cs *model.Changeset) error {
|
||||||
|
now := time.Now()
|
||||||
|
cs.CreatedAt, cs.UpdatedAt = now, now
|
||||||
|
if cs.Ops == nil {
|
||||||
|
cs.Ops = []model.Op{}
|
||||||
|
}
|
||||||
|
ops, _ := json.Marshal(cs.Ops)
|
||||||
|
res, err := s.db.ExecContext(ctx, `INSERT INTO changesets (user_id, username, via, description, status, ops, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, nullID(cs.UserID), cs.Username, cs.Via, cs.Description, cs.Status, string(ops), ts(now), ts(now))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cs.ID, err = res.LastInsertId()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetChangeset loads one changeset.
|
||||||
|
func (s *Store) GetChangeset(ctx context.Context, id int64) (*model.Changeset, error) {
|
||||||
|
cs, err := scanChangeset(s.db.QueryRowContext(ctx, `SELECT `+changesetCols+` FROM changesets WHERE id = ?`, id))
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
return cs, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListChangesets lists changesets, newest first: of a status ("" any),
|
||||||
|
// and of a user (0 anyone's).
|
||||||
|
func (s *Store) ListChangesets(ctx context.Context, status string, userID int64, limit int) ([]*model.Changeset, error) {
|
||||||
|
if limit <= 0 || limit > 500 {
|
||||||
|
limit = 100
|
||||||
|
}
|
||||||
|
q := `SELECT ` + changesetCols + ` FROM changesets WHERE 1=1`
|
||||||
|
var args []any
|
||||||
|
if status != "" {
|
||||||
|
q += ` AND status = ?`
|
||||||
|
args = append(args, status)
|
||||||
|
}
|
||||||
|
if userID != 0 {
|
||||||
|
q += ` AND user_id = ?`
|
||||||
|
args = append(args, userID)
|
||||||
|
}
|
||||||
|
q += ` ORDER BY id DESC LIMIT ?`
|
||||||
|
args = append(args, limit)
|
||||||
|
rows, err := s.db.QueryContext(ctx, q, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := []*model.Changeset{}
|
||||||
|
for rows.Next() {
|
||||||
|
cs, err := scanChangeset(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, cs)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateChangeset saves a changeset's description, status, operations and
|
||||||
|
// history entry.
|
||||||
|
func (s *Store) UpdateChangeset(ctx context.Context, cs *model.Changeset) error {
|
||||||
|
cs.UpdatedAt = time.Now()
|
||||||
|
ops, _ := json.Marshal(cs.Ops)
|
||||||
|
res, err := s.db.ExecContext(ctx, `UPDATE changesets SET description = ?, status = ?, ops = ?, updated_at = ?, history_id = ? WHERE id = ?`,
|
||||||
|
cs.Description, cs.Status, string(ops), ts(cs.UpdatedAt), nullID(cs.HistoryID), cs.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func nullID(id int64) any {
|
||||||
|
if id == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
@@ -163,6 +163,22 @@ CREATE TABLE audit (
|
|||||||
detail TEXT NOT NULL DEFAULT ''
|
detail TEXT NOT NULL DEFAULT ''
|
||||||
);
|
);
|
||||||
CREATE INDEX audit_time ON audit (time);
|
CREATE INDEX audit_time ON audit (time);
|
||||||
|
`,
|
||||||
|
// 2: changesets, staged changes to review and apply together
|
||||||
|
`
|
||||||
|
CREATE TABLE changesets (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
via TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
ops TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
history_id INTEGER
|
||||||
|
);
|
||||||
|
CREATE INDEX changesets_status ON changesets (status);
|
||||||
`,
|
`,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -85,6 +85,52 @@ func Check(st *model.State, env *Env) []Problem {
|
|||||||
return c.out
|
return c.out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// rawIgnores names what a raw rule has besides its text, which the
|
||||||
|
// ruleset wouldn't use (the action too is the text's; it isn't checked).
|
||||||
|
func rawIgnores(r *model.Rule) []string {
|
||||||
|
m := r.Match
|
||||||
|
var out []string
|
||||||
|
for _, x := range []struct {
|
||||||
|
name string
|
||||||
|
set bool
|
||||||
|
}{
|
||||||
|
{"zones", len(m.InZones) > 0 || len(m.OutZones) > 0},
|
||||||
|
{"addresses", len(m.Sources) > 0 || len(m.Destinations) > 0},
|
||||||
|
{"services", len(m.Services) > 0},
|
||||||
|
{"family", m.Family != ""},
|
||||||
|
{"connection state", len(r.CtState) > 0 || len(r.CtStatus) > 0},
|
||||||
|
{"time window", len(r.Days) > 0 || r.HourFrom != "" || r.HourTo != ""},
|
||||||
|
{"rate limit", r.Limit != nil},
|
||||||
|
{"connection limit", r.ConnLimit != 0},
|
||||||
|
{"quota", r.Quota != nil},
|
||||||
|
{"address grouping", r.GroupV4 != 0 || r.GroupV6 != 0},
|
||||||
|
{"log", r.Log || r.LogPrefix != ""},
|
||||||
|
{"reject with", r.RejectWith != ""},
|
||||||
|
} {
|
||||||
|
if x.set {
|
||||||
|
out = append(out, x.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxByteRate is the largest byte rate per period nftables (1.1.3) can
|
||||||
|
// hold: the kernel's token bucket multiplies the rate by the period.
|
||||||
|
var maxByteRate = map[string]int64{"second": 4294967294, "minute": 307445734, "hour": 5124095, "day": 213503}
|
||||||
|
|
||||||
|
// byteSize writes bytes the way nftables reads them: "1 mbytes".
|
||||||
|
func byteSize(n int64) string {
|
||||||
|
for _, u := range []string{"mbytes", "kbytes"} {
|
||||||
|
if n >= model.ByteUnits[u] && n%model.ByteUnits[u] == 0 {
|
||||||
|
return fmt.Sprintf("%d %s", n/model.ByteUnits[u], u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if n >= 10*model.ByteUnits["kbytes"] {
|
||||||
|
return fmt.Sprintf("%d kbytes", n/model.ByteUnits["kbytes"])
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d bytes", n)
|
||||||
|
}
|
||||||
|
|
||||||
var nameRe = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_-]{0,47}$`)
|
var nameRe = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_-]{0,47}$`)
|
||||||
|
|
||||||
// ifaceRe: Linux interface names (15 bytes), with an optional trailing *
|
// ifaceRe: Linux interface names (15 bytes), with an optional trailing *
|
||||||
@@ -185,6 +231,9 @@ func (c *checker) sets() {
|
|||||||
c.add(Error, "set_entry", obj, "%v", err)
|
c.add(Error, "set_entry", obj, "%v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if len(s.Entries) > 0 && s.Dynamic {
|
||||||
|
c.add(Error, "set_dynamic_entries", obj, "a dynamic set gets its entries at run time (hc set entries add, the Entries button), each with its timeout; leave out the configured ones, or make it a set that isn't dynamic")
|
||||||
|
}
|
||||||
if len(s.Entries) == 0 && !s.Dynamic {
|
if len(s.Entries) == 0 && !s.Dynamic {
|
||||||
c.add(Warning, "set_empty", obj, "the set is empty: rules using it match nothing")
|
c.add(Warning, "set_empty", obj, "the set is empty: rules using it match nothing")
|
||||||
}
|
}
|
||||||
@@ -314,6 +363,9 @@ func (c *checker) rule(r *model.Rule) {
|
|||||||
if strings.ContainsAny(r.Raw, ";\n\r") {
|
if strings.ContainsAny(r.Raw, ";\n\r") {
|
||||||
c.add(Error, "raw_rule", obj, "a raw rule is one nftables rule on one line, without ;")
|
c.add(Error, "raw_rule", obj, "a raw rule is one nftables rule on one line, without ;")
|
||||||
}
|
}
|
||||||
|
if ignored := rawIgnores(r); len(ignored) > 0 {
|
||||||
|
c.add(Error, "raw_conflict", obj, "a raw rule is used as written, so its %s would be ignored: write them in the nftables rule, or leave them out", strings.Join(ignored, ", "))
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
in := r.Chain != model.ChainOutput
|
in := r.Chain != model.ChainOutput
|
||||||
@@ -348,13 +400,49 @@ func (c *checker) rule(r *model.Rule) {
|
|||||||
c.add(Error, "rule_hours", obj, "write hours as HH:MM (08:00, 17:30)")
|
c.add(Error, "rule_hours", obj, "write hours as HH:MM (08:00, 17:30)")
|
||||||
}
|
}
|
||||||
if l := r.Limit; l != nil {
|
if l := r.Limit; l != nil {
|
||||||
if l.Rate < 1 || !limitPer[l.Per] || l.Burst < 0 {
|
_, byteUnit := model.ByteUnits[l.Unit]
|
||||||
c.add(Error, "rule_limit", obj, "a rate is a number of packets per second, minute, hour or day, with an optional burst")
|
switch {
|
||||||
|
case l.Rate < 1 || !limitPer[l.Per] || l.Burst < 0:
|
||||||
|
c.add(Error, "rule_limit", obj, "a rate is a number of packets (or bytes, kbytes, mbytes) per second, minute, hour or day, with an optional burst")
|
||||||
|
case l.Unit != "" && l.Unit != "packets" && (!byteUnit || l.Unit == "gbytes"):
|
||||||
|
c.add(Error, "rule_limit_unit", obj, "the rate's unit is %q; use packets, bytes, kbytes or mbytes", l.Unit)
|
||||||
|
case byteUnit && int64(l.Rate)*model.ByteUnits[l.Unit] > maxByteRate[l.Per]:
|
||||||
|
perSecond := int64(l.Rate) * model.ByteUnits[l.Unit] / map[string]int64{"second": 1, "minute": 60, "hour": 3600, "day": 86400}[l.Per]
|
||||||
|
c.add(Error, "rule_limit_bytes", obj, "nftables can't hold a byte rate of %d %s per %s (at most %s per %s); give it per second: %s/second",
|
||||||
|
l.Rate, l.Unit, l.Per, byteSize(maxByteRate[l.Per]), l.Per, byteSize(perSecond))
|
||||||
|
}
|
||||||
|
if l.PerSource && l.PerDestination {
|
||||||
|
c.add(Error, "rule_limit_key", obj, "a limit is per source or per destination, not both")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if r.ConnLimit < 0 {
|
if r.ConnLimit < 0 {
|
||||||
c.add(Error, "rule_conn_limit", obj, "the connection limit must be 1 or more")
|
c.add(Error, "rule_conn_limit", obj, "the connection limit must be 1 or more")
|
||||||
}
|
}
|
||||||
|
if q := r.Quota; q != nil {
|
||||||
|
if q.Bytes < 1 {
|
||||||
|
c.add(Error, "rule_quota", obj, "a quota is an amount of bytes, 1 or more")
|
||||||
|
}
|
||||||
|
if q.PerSource && q.PerDestination {
|
||||||
|
c.add(Error, "rule_quota_key", obj, "a quota is per source or per destination, not both")
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case q.Reset != "" && q.Reset != "daily" && q.Reset != "weekly" && q.Reset != "monthly":
|
||||||
|
c.add(Error, "rule_quota_reset", obj, "the quota resets %q; use daily, weekly, monthly, or nothing (only by hand)", q.Reset)
|
||||||
|
case q.Day != 0 && q.Reset != "monthly":
|
||||||
|
c.add(Error, "rule_quota_reset", obj, "a day of the month is only for a monthly reset")
|
||||||
|
case q.Day < 0 || q.Day > 31:
|
||||||
|
c.add(Error, "rule_quota_reset", obj, "the day of the month is 1 to 31 (a later day than a month has means its last day)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if r.CountsTraffic() && r.Action == model.ActionAccept {
|
||||||
|
c.add(Error, "rule_traffic_accept", obj, "a rule with a quota or a byte rate counts every packet, before established connections are accepted, so it can only drop, reject or log; let the traffic in with a rule of its own")
|
||||||
|
}
|
||||||
|
if r.CountsTraffic() && r.Chain == model.ChainForward && len(c.st.Settings.FlowtableDevices) > 0 && !r.Disabled {
|
||||||
|
c.add(Warning, "flowtable_traffic", obj, "the flowtable's fast path skips the forward chain once a connection is established, so this quota or byte rate only counts each connection's first packets; turn the flowtable off (settings) to count all of it")
|
||||||
|
}
|
||||||
|
if r.GroupV4 < 0 || r.GroupV4 > 32 || r.GroupV6 < 0 || r.GroupV6 > 128 {
|
||||||
|
c.add(Error, "rule_group", obj, "group addresses by a prefix length: 1 to 32 for IPv4, 1 to 128 for IPv6")
|
||||||
|
}
|
||||||
if r.RejectWith != "" {
|
if r.RejectWith != "" {
|
||||||
if r.RejectWith != "tcp-reset" {
|
if r.RejectWith != "tcp-reset" {
|
||||||
c.add(Error, "reject_with", obj, "reject with is %q; use tcp-reset, or nothing for an ICMP error", r.RejectWith)
|
c.add(Error, "reject_with", obj, "reject with is %q; use tcp-reset, or nothing for an ICMP error", r.RejectWith)
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ func TestCodes(t *testing.T) {
|
|||||||
{"set_entry", func(st *model.State) { st.Sets[0].Entries = []string{"10.0.10.5/24"} }},
|
{"set_entry", func(st *model.State) { st.Sets[0].Entries = []string{"10.0.10.5/24"} }},
|
||||||
{"set_empty", func(st *model.State) { st.Sets[0].Entries = nil }},
|
{"set_empty", func(st *model.State) { st.Sets[0].Entries = nil }},
|
||||||
{"set_timeout", func(st *model.State) { st.Sets[0].Timeout = 60 }},
|
{"set_timeout", func(st *model.State) { st.Sets[0].Timeout = 60 }},
|
||||||
|
{"set_dynamic_entries", func(st *model.State) { st.Sets[0].Dynamic = true }},
|
||||||
{"service_empty", func(st *model.State) { st.Services[0].Entries = nil }},
|
{"service_empty", func(st *model.State) { st.Services[0].Entries = nil }},
|
||||||
{"service_entry", func(st *model.State) { st.Services[0].Entries = []string{"tcp/99999"} }},
|
{"service_entry", func(st *model.State) { st.Services[0].Entries = []string{"tcp/99999"} }},
|
||||||
{"unknown_zone", func(st *model.State) { st.Rules[1].InZones = []string{"dmz"} }},
|
{"unknown_zone", func(st *model.State) { st.Rules[1].InZones = []string{"dmz"} }},
|
||||||
@@ -83,6 +84,7 @@ func TestCodes(t *testing.T) {
|
|||||||
{"rule_chain", func(st *model.State) { rule(st).Chain = "prerouting" }},
|
{"rule_chain", func(st *model.State) { rule(st).Chain = "prerouting" }},
|
||||||
{"rule_action", func(st *model.State) { rule(st).Action = "allow" }},
|
{"rule_action", func(st *model.State) { rule(st).Action = "allow" }},
|
||||||
{"raw_rule", func(st *model.State) { rule(st).Raw = "tcp dport 22 accept; udp dport 53 accept" }},
|
{"raw_rule", func(st *model.State) { rule(st).Raw = "tcp dport 22 accept; udp dport 53 accept" }},
|
||||||
|
{"raw_conflict", func(st *model.State) { rule(st).Raw = "tcp dport 22 accept" }}, // it still has sources and services
|
||||||
{"early_conntrack", func(st *model.State) { rule(st).Chain = model.ChainEarly; rule(st).CtState = []string{"new"} }},
|
{"early_conntrack", func(st *model.State) { rule(st).Chain = model.ChainEarly; rule(st).CtState = []string{"new"} }},
|
||||||
{"early_reject", func(st *model.State) { rule(st).Chain = model.ChainEarly; rule(st).Action = model.ActionReject }},
|
{"early_reject", func(st *model.State) { rule(st).Chain = model.ChainEarly; rule(st).Action = model.ActionReject }},
|
||||||
{"ct_state", func(st *model.State) { rule(st).CtState = []string{"fresh"} }},
|
{"ct_state", func(st *model.State) { rule(st).CtState = []string{"fresh"} }},
|
||||||
@@ -91,6 +93,21 @@ func TestCodes(t *testing.T) {
|
|||||||
{"rule_hours", func(st *model.State) { rule(st).HourFrom = "08:00" }},
|
{"rule_hours", func(st *model.State) { rule(st).HourFrom = "08:00" }},
|
||||||
{"rule_limit", func(st *model.State) { rule(st).Limit = &model.Limit{Rate: 10, Per: "week"} }},
|
{"rule_limit", func(st *model.State) { rule(st).Limit = &model.Limit{Rate: 10, Per: "week"} }},
|
||||||
{"rule_conn_limit", func(st *model.State) { rule(st).ConnLimit = -1 }},
|
{"rule_conn_limit", func(st *model.State) { rule(st).ConnLimit = -1 }},
|
||||||
|
{"rule_limit_unit", func(st *model.State) { rule(st).Limit = &model.Limit{Rate: 1, Per: "second", Unit: "gbytes"} }},
|
||||||
|
{"rule_limit_bytes", func(st *model.State) { rule(st).Limit = &model.Limit{Rate: 100, Per: "hour", Unit: "mbytes"} }},
|
||||||
|
{"rule_limit_key", func(st *model.State) {
|
||||||
|
rule(st).Limit = &model.Limit{Rate: 1, Per: "second", PerSource: true, PerDestination: true}
|
||||||
|
}},
|
||||||
|
{"rule_quota", func(st *model.State) { rule(st).Quota = &model.Quota{} }},
|
||||||
|
{"rule_quota_key", func(st *model.State) { rule(st).Quota = &model.Quota{Bytes: 1, PerSource: true, PerDestination: true} }},
|
||||||
|
{"rule_quota_reset", func(st *model.State) { rule(st).Quota = &model.Quota{Bytes: 1, Reset: "yearly"} }},
|
||||||
|
{"rule_quota_reset", func(st *model.State) { rule(st).Quota = &model.Quota{Bytes: 1, Reset: "daily", Day: 3} }},
|
||||||
|
{"rule_group", func(st *model.State) { rule(st).GroupV6 = 129 }},
|
||||||
|
{"rule_traffic_accept", func(st *model.State) { rule(st).Quota = &model.Quota{Bytes: 1 << 20} }},
|
||||||
|
{"flowtable_traffic", func(st *model.State) {
|
||||||
|
st.Settings.FlowtableDevices = []string{"wan"}
|
||||||
|
st.Rules[1].Action, st.Rules[1].Quota = model.ActionDrop, &model.Quota{Bytes: 1 << 20, Over: true}
|
||||||
|
}},
|
||||||
{"reject_with", func(st *model.State) { rule(st).RejectWith = "tcp-reset" }},
|
{"reject_with", func(st *model.State) { rule(st).RejectWith = "tcp-reset" }},
|
||||||
{"log_prefix", func(st *model.State) { rule(st).Log = true; rule(st).LogPrefix = strings.Repeat("x", 101) }},
|
{"log_prefix", func(st *model.State) { rule(st).Log = true; rule(st).LogPrefix = strings.Repeat("x", 101) }},
|
||||||
{"forward_protocol", func(st *model.State) { st.Forwards[0].Protocols = []string{"icmp"} }},
|
{"forward_protocol", func(st *model.State) { st.Forwards[0].Protocols = []string{"icmp"} }},
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ export const state = {
|
|||||||
me: null, // whoami response
|
me: null, // whoami response
|
||||||
csrf: "",
|
csrf: "",
|
||||||
catalog: null,
|
catalog: null,
|
||||||
|
draft: null, // the open changeset edits are staged into
|
||||||
|
immediate: false, // apply edits immediately instead of staging them
|
||||||
};
|
};
|
||||||
|
|
||||||
export class APIError extends Error {
|
export class APIError extends Error {
|
||||||
@@ -20,13 +22,16 @@ export class APIError extends Error {
|
|||||||
let onUnauthorized = () => {};
|
let onUnauthorized = () => {};
|
||||||
export function setUnauthorizedHandler(fn) { onUnauthorized = fn; }
|
export function setUnauthorizedHandler(fn) { onUnauthorized = fn; }
|
||||||
|
|
||||||
// onChange is told about every applied change (the probation banner).
|
// onChange is told about every applied change (the probation banner), with
|
||||||
|
// the answer (its confirm_token, to confirm it by itself).
|
||||||
let onChange = () => {};
|
let onChange = () => {};
|
||||||
export function setChangeHandler(fn) { onChange = fn; }
|
export function setChangeHandler(fn) { onChange = fn; }
|
||||||
|
|
||||||
// api calls the REST API. opts: {body, query}.
|
// api calls the REST API. opts: {body, query, stage (a changeset ID),
|
||||||
|
// timeout (ms)}.
|
||||||
export async function api(method, path, opts = {}) {
|
export async function api(method, path, opts = {}) {
|
||||||
const headers = { "Accept": "application/json" };
|
const headers = { "Accept": "application/json" };
|
||||||
|
if (opts.stage) headers["X-Housecarl-Changeset"] = String(opts.stage);
|
||||||
let body;
|
let body;
|
||||||
if (opts.body !== undefined) {
|
if (opts.body !== undefined) {
|
||||||
headers["Content-Type"] = "application/json";
|
headers["Content-Type"] = "application/json";
|
||||||
@@ -40,7 +45,13 @@ export async function api(method, path, opts = {}) {
|
|||||||
const s = q.toString();
|
const s = q.toString();
|
||||||
if (s) url += (url.includes("?") ? "&" : "?") + s;
|
if (s) url += (url.includes("?") ? "&" : "?") + s;
|
||||||
}
|
}
|
||||||
const resp = await fetch(url, { method, headers, body, credentials: "same-origin" });
|
let signal;
|
||||||
|
if (opts.timeout) {
|
||||||
|
const ac = new AbortController();
|
||||||
|
setTimeout(() => ac.abort(), opts.timeout);
|
||||||
|
signal = ac.signal;
|
||||||
|
}
|
||||||
|
const resp = await fetch(url, { method, headers, body, credentials: "same-origin", signal });
|
||||||
if (resp.status === 204) return null;
|
if (resp.status === 204) return null;
|
||||||
const ct = resp.headers.get("Content-Type") || "";
|
const ct = resp.headers.get("Content-Type") || "";
|
||||||
const data = ct.includes("application/json") ? await resp.json() : await resp.text();
|
const data = ct.includes("application/json") ? await resp.json() : await resp.text();
|
||||||
@@ -51,25 +62,131 @@ export async function api(method, path, opts = {}) {
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const get = (path, query) => api("GET", path, { query });
|
// get reads; with pending changes, the configuration as they will make it
|
||||||
|
// (objects marked "pending": new, changed, deleted).
|
||||||
|
export const get = (path, query) => api("GET", path, { query: draftQuery(query) });
|
||||||
|
|
||||||
// change sends a configuration change and reports it: a toast with the
|
// draftQuery names the draft changeset in a query, unless edits are
|
||||||
// history entry (to undo it from History) and one per new warning. It
|
// applied immediately.
|
||||||
// returns the response.
|
export function draftQuery(query = {}) {
|
||||||
|
if (state.draft && !state.immediate) return { ...query, changeset: state.draft.id };
|
||||||
|
return query;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the draft changeset (review mode, as in Jarl)
|
||||||
|
|
||||||
|
const draftKey = () => "housecarl.draft." + (state.me ? state.me.user.username : "");
|
||||||
|
const immediateKey = () => "housecarl.immediate." + (state.me ? state.me.user.username : "");
|
||||||
|
|
||||||
|
const listeners = new Set();
|
||||||
|
export function onDraftChange(fn) { listeners.add(fn); return () => listeners.delete(fn); }
|
||||||
|
function notify() { for (const fn of listeners) fn(); }
|
||||||
|
|
||||||
|
function remember(key, value) {
|
||||||
|
try { if (value === null) localStorage.removeItem(key); else localStorage.setItem(key, value); } catch { /* private window */ }
|
||||||
|
}
|
||||||
|
function recall(key) {
|
||||||
|
try { return localStorage.getItem(key); } catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadDraft restores the user's draft changeset if it is still open, or
|
||||||
|
// picks up their newest open one made in the web UI.
|
||||||
|
export async function loadDraft() {
|
||||||
|
state.immediate = recall(immediateKey()) === "1";
|
||||||
|
state.draft = null;
|
||||||
|
const id = recall(draftKey());
|
||||||
|
if (id) {
|
||||||
|
try {
|
||||||
|
const cs = await api("GET", "/changesets/" + id);
|
||||||
|
if (cs.status === "open") state.draft = cs;
|
||||||
|
} catch { /* gone */ }
|
||||||
|
}
|
||||||
|
if (!state.draft) {
|
||||||
|
try {
|
||||||
|
const list = await api("GET", "/changesets", { query: { status: "open", mine: 1, limit: 20 } });
|
||||||
|
state.draft = list.find((c) => c.via === "web") || null;
|
||||||
|
} catch { /* no config access */ }
|
||||||
|
}
|
||||||
|
remember(draftKey(), state.draft ? String(state.draft.id) : null);
|
||||||
|
notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setImmediate(on) {
|
||||||
|
state.immediate = on;
|
||||||
|
remember(immediateKey(), on ? "1" : "0");
|
||||||
|
notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshDraft() {
|
||||||
|
if (!state.draft) return;
|
||||||
|
try {
|
||||||
|
const cs = await api("GET", "/changesets/" + state.draft.id);
|
||||||
|
state.draft = cs.status === "open" ? cs : null;
|
||||||
|
} catch { state.draft = null; }
|
||||||
|
if (!state.draft) remember(draftKey(), null);
|
||||||
|
notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDraft(cs) {
|
||||||
|
state.draft = cs;
|
||||||
|
remember(draftKey(), cs ? String(cs.id) : null);
|
||||||
|
notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureDraft() {
|
||||||
|
if (state.draft) return state.draft;
|
||||||
|
const cs = await api("POST", "/changesets", { body: { description: "" } });
|
||||||
|
cs.fresh = true;
|
||||||
|
useDraft(cs);
|
||||||
|
return cs;
|
||||||
|
}
|
||||||
|
|
||||||
|
// dropFreshDraft discards a draft made for an edit that was then refused
|
||||||
|
// (or only checked), so no empty changeset is left open.
|
||||||
|
async function dropFreshDraft(cs) {
|
||||||
|
if (!cs.fresh || (state.draft && state.draft.id !== cs.id) || (state.draft && (state.draft.ops || []).length)) return;
|
||||||
|
try { await api("POST", "/changesets/" + cs.id + "/discard"); } catch { /* leave it */ }
|
||||||
|
useDraft(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// change sends a configuration change: staged in the draft changeset, or
|
||||||
|
// applied at once in immediate mode (or with opts.immediate). It reports
|
||||||
|
// it (toasts) and returns the response.
|
||||||
export async function change(method, path, body, opts = {}) {
|
export async function change(method, path, body, opts = {}) {
|
||||||
const r = await api(method, path, { body, query: opts.query });
|
let r;
|
||||||
|
if (state.immediate || opts.immediate) {
|
||||||
|
r = await api(method, path, { body, query: opts.query });
|
||||||
|
} else {
|
||||||
|
const cs = await ensureDraft();
|
||||||
|
try {
|
||||||
|
r = await api(method, path, { body, query: opts.query, stage: cs.id });
|
||||||
|
} catch (e) {
|
||||||
|
if (e.status === 409 && /is (applied|discarded)/.test(e.message)) {
|
||||||
|
useDraft(null);
|
||||||
|
return change(method, path, body, opts);
|
||||||
|
}
|
||||||
|
await dropFreshDraft(cs);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
if (r && r.staged && r.changeset) useDraft(r.changeset);
|
||||||
|
else await dropFreshDraft(cs);
|
||||||
|
}
|
||||||
if (!opts.quiet) report(r);
|
if (!opts.quiet) report(r);
|
||||||
return r;
|
return r;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function report(r) {
|
export function report(r) {
|
||||||
if (!r) return;
|
if (!r) return;
|
||||||
if (r.dry_run) toast("Checked by Housecarl and nftables: nothing was changed");
|
if (r.staged) {
|
||||||
else if (r.applied && r.confirm_by) toast("Applied on probation (history #" + r.history_id + "): confirm it in the banner", "warn", 6000);
|
const n = ((r.changeset || {}).ops || []).length;
|
||||||
|
toast("Staged: " + ((r.changeset.ops || [])[n - 1] || {}).summary + " — " + (n === 1 ? "1 pending change" : n + " pending changes") + " to review and apply");
|
||||||
|
} else if (r.dry_run) toast("Checked by Housecarl and nftables: nothing was changed");
|
||||||
|
else if (r.applied && r.confirm_by && !r.confirm_token) toast("Applied on probation (history #" + r.history_id + "): confirm it in the banner", "warn", 6000);
|
||||||
|
else if (r.applied && r.confirm_by) toast("Applied (history #" + r.history_id + ")");
|
||||||
else if (r.applied) toast("Applied and saved (history #" + r.history_id + ")");
|
else if (r.applied) toast("Applied and saved (history #" + r.history_id + ")");
|
||||||
else if (!(r.changes || []).length) toast("Nothing to change");
|
else if (!(r.changes || []).length) toast("Nothing to change");
|
||||||
for (const w of r.warnings || []) toast(w.message + (w.object ? " (" + w.object + ")" : ""), "warn", 10000);
|
for (const w of r.warnings || []) toast(w.message + (w.object ? " (" + w.object + ")" : ""), "warn", 10000);
|
||||||
if (r.applied) onChange();
|
if (r.applied) onChange(r);
|
||||||
}
|
}
|
||||||
|
|
||||||
// can reports whether the user may read (or write) a resource.
|
// can reports whether the user may read (or write) a resource.
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
// Housecarl web UI: router, layout, login and the probation banner.
|
// Housecarl web UI: router, layout, login and the probation banner.
|
||||||
import { h, mount, toast, errorBox } from "./ui.js";
|
import { h, mount, toast, errorBox, modal } from "./ui.js";
|
||||||
import { wordmark, logo } from "./brand.js";
|
import { wordmark, logo } from "./brand.js";
|
||||||
import { api, get, state, can, setUnauthorizedHandler, setChangeHandler } from "./api.js";
|
import { api, get, state, can, setUnauthorizedHandler, setChangeHandler, loadDraft, onDraftChange, setImmediate, useDraft } from "./api.js";
|
||||||
|
import * as changes from "./pages/changes.js";
|
||||||
import * as dashboard from "./pages/dashboard.js";
|
import * as dashboard from "./pages/dashboard.js";
|
||||||
import * as rules from "./pages/rules.js";
|
import * as rules from "./pages/rules.js";
|
||||||
import * as nat from "./pages/nat.js";
|
import * as nat from "./pages/nat.js";
|
||||||
@@ -27,6 +28,7 @@ const routes = [
|
|||||||
{ pattern: /^log$/, page: log, nav: "log" },
|
{ pattern: /^log$/, page: log, nav: "log" },
|
||||||
{ pattern: /^settings$/, page: settings, nav: "settings" },
|
{ pattern: /^settings$/, page: settings, nav: "settings" },
|
||||||
{ pattern: /^import$/, page: importPage, nav: "import" },
|
{ pattern: /^import$/, page: importPage, nav: "import" },
|
||||||
|
{ pattern: /^changes$/, page: changes, nav: "changes" },
|
||||||
{ pattern: /^history$/, page: history, nav: "history" },
|
{ pattern: /^history$/, page: history, nav: "history" },
|
||||||
{ pattern: /^history\/(\d+)$/, page: history, nav: "history", params: ["id"] },
|
{ pattern: /^history\/(\d+)$/, page: history, nav: "history", params: ["id"] },
|
||||||
{ pattern: /^audit$/, page: audit, nav: "audit" },
|
{ pattern: /^audit$/, page: audit, nav: "audit" },
|
||||||
@@ -46,6 +48,7 @@ const navItems = [
|
|||||||
["services", "#/services", "Services", "config"],
|
["services", "#/services", "Services", "config"],
|
||||||
] },
|
] },
|
||||||
["log", "#/log", "Log", "log"],
|
["log", "#/log", "Log", "log"],
|
||||||
|
["changes", "#/changes", "Changes", "config"],
|
||||||
["history", "#/history", "History", "config"],
|
["history", "#/history", "History", "config"],
|
||||||
{ menu: "Server", items: [
|
{ menu: "Server", items: [
|
||||||
["settings", "#/settings", "Settings and built-ins", "config"],
|
["settings", "#/settings", "Settings and built-ins", "config"],
|
||||||
@@ -56,7 +59,7 @@ const navItems = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const app = document.getElementById("app");
|
const app = document.getElementById("app");
|
||||||
let mainEl, navEl, bannerEl, current = { key: "" }, routeGen = 0;
|
let mainEl, navEl, bannerEl, barEl, modeHolder, current = { key: "" }, routeGen = 0;
|
||||||
|
|
||||||
function parseHash() {
|
function parseHash() {
|
||||||
const raw = location.hash.replace(/^#\/?/, "");
|
const raw = location.hash.replace(/^#\/?/, "");
|
||||||
@@ -92,6 +95,8 @@ async function loggedIn(me) {
|
|||||||
state.me = me;
|
state.me = me;
|
||||||
state.csrf = me.csrf_token || "";
|
state.csrf = me.csrf_token || "";
|
||||||
layout();
|
layout();
|
||||||
|
onDraftChange(renderBar);
|
||||||
|
if (can("config", true)) await loadDraft();
|
||||||
window.addEventListener("hashchange", route);
|
window.addEventListener("hashchange", route);
|
||||||
await route();
|
await route();
|
||||||
}
|
}
|
||||||
@@ -100,6 +105,8 @@ function layout() {
|
|||||||
navEl = h("nav", { class: "main" });
|
navEl = h("nav", { class: "main" });
|
||||||
mainEl = h("main");
|
mainEl = h("main");
|
||||||
bannerEl = h("div", { class: "probation-banner hidden", role: "alert" });
|
bannerEl = h("div", { class: "probation-banner hidden", role: "alert" });
|
||||||
|
barEl = h("div");
|
||||||
|
modeHolder = can("config", true) ? h("span") : null;
|
||||||
const me = state.me;
|
const me = state.me;
|
||||||
const header = h("header", { class: "top" },
|
const header = h("header", { class: "top" },
|
||||||
h("a", { class: "brand", href: "#/", title: "Dashboard" },
|
h("a", { class: "brand", href: "#/", title: "Dashboard" },
|
||||||
@@ -107,19 +114,88 @@ function layout() {
|
|||||||
wordmark({ rule: false, height: 17, cls: "brand-name" })),
|
wordmark({ rule: false, height: 17, cls: "brand-name" })),
|
||||||
navEl,
|
navEl,
|
||||||
h("div", { class: "header-right" },
|
h("div", { class: "header-right" },
|
||||||
|
modeHolder,
|
||||||
menu(h("span", { class: "user", title: (me.permissions || []).join("\n") }, me.user.username), [
|
menu(h("span", { class: "user", title: (me.permissions || []).join("\n") }, me.user.username), [
|
||||||
h("a", { href: "#/account" }, "Account"),
|
h("a", { href: "#/account" }, "Account"),
|
||||||
me.admin ? h("a", { href: "#/users" }, "Users") : null,
|
me.admin ? h("a", { href: "#/users" }, "Users") : null,
|
||||||
h("button", { type: "button", class: "menu-item", onclick: logout }, "Log out"),
|
h("button", { type: "button", class: "menu-item", onclick: logout }, "Log out"),
|
||||||
], { right: true, active: ["account", "users"].includes(current.nav), label: "Account menu" })));
|
], { right: true, active: ["account", "users"].includes(current.nav), label: "Account menu" })));
|
||||||
mount(app, h("div", { class: "shell" }, header, bannerEl, mainEl, footer(me.build)));
|
mount(app, h("div", { class: "shell" }, header, barEl, bannerEl, mainEl, footer(me.build)));
|
||||||
renderNav();
|
renderNav();
|
||||||
refreshProbation();
|
refreshProbation();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- pending changes: edits are staged for review (as in Jarl)
|
||||||
|
|
||||||
|
// renderBar shows the pending-changes bar (while there are pending
|
||||||
|
// changes) and the Review/Live switch in the header.
|
||||||
|
function renderBar() {
|
||||||
|
if (!barEl) return;
|
||||||
|
const d = state.draft;
|
||||||
|
const n = d ? (d.ops || []).length : 0;
|
||||||
|
if (modeHolder) {
|
||||||
|
const live = state.immediate;
|
||||||
|
mount(modeHolder, h("button", {
|
||||||
|
type: "button", class: "mode-toggle" + (live ? " live" : ""), "aria-pressed": live ? "true" : "false",
|
||||||
|
title: live ? "Edits are applied right away (each on probation over the network). Click to collect them for review first."
|
||||||
|
: "Edits are collected as pending changes, reviewed with the ruleset's diff and applied together under Changes. Click to apply edits right away.",
|
||||||
|
onclick: () => { setImmediate(!live); rerender(); },
|
||||||
|
}, h("span", { class: "dot" }), live ? "Live edits" : "Review edits"));
|
||||||
|
}
|
||||||
|
mount(barEl, n > 0 && !state.immediate ? h("div", { class: "pending-bar" },
|
||||||
|
h("strong", null, n === 1 ? "1 pending change" : n + " pending changes"),
|
||||||
|
h("span", { class: "muted" }, "Collected in changeset #" + d.id + "; not in force yet."),
|
||||||
|
h("a", { class: "button small", href: "#/changes" }, "Review"),
|
||||||
|
h("button", { class: "primary small apply-now", title: "Apply the pending changes now, without reviewing them first", onclick: (ev) => applyDraft(ev.target, d) }, "Apply now")) : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyDraft applies the draft changeset from the bar and stays on the
|
||||||
|
// page. If it is refused, a dialog says why and leads to Changes.
|
||||||
|
export async function applyDraft(btn, d, description) {
|
||||||
|
btn.disabled = true;
|
||||||
|
const label = btn.textContent;
|
||||||
|
btn.textContent = "Applying…";
|
||||||
|
try {
|
||||||
|
const r = await api("POST", "/changesets/" + d.id + "/apply", { body: { description: description || "" } });
|
||||||
|
useDraft(null);
|
||||||
|
if (r.confirm_by && !r.confirm_token) toast("Applied changeset #" + d.id + " on probation (history #" + r.history_id + "): confirm it in the banner", "warn", 8000);
|
||||||
|
else toast("Applied changeset #" + d.id + " (history #" + r.history_id + ")");
|
||||||
|
for (const w of r.warnings || []) toast(w.message + (w.object ? " (" + w.object + ")" : ""), "warn", 10000);
|
||||||
|
if (r.confirm_token) await autoConfirm(r.confirm_token);
|
||||||
|
else await refreshProbation();
|
||||||
|
return r;
|
||||||
|
} catch (e) {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = label;
|
||||||
|
const review = await modal("The changes were not applied", errorBox(e), [{ label: "Close", value: false }, { label: "Review changes", kind: "primary", value: true }], { wide: true });
|
||||||
|
if (review) location.hash = "#/changes";
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
rerender();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- probation: changes made here roll back unless confirmed
|
// ---- probation: changes made here roll back unless confirmed
|
||||||
|
|
||||||
let probationTimer = null, deadline = 0, pending = 0;
|
let probationTimer = null, deadline = 0, pending = 0, checking = false;
|
||||||
|
|
||||||
|
// autoConfirm confirms a change with its token. The server closed the
|
||||||
|
// change's connection, so this request opens a new one through the new
|
||||||
|
// ruleset: it only gets through if this browser can still reach
|
||||||
|
// Housecarl. If it can't, the change rolls back by itself.
|
||||||
|
export async function autoConfirm(token) {
|
||||||
|
checking = true;
|
||||||
|
await refreshProbation();
|
||||||
|
try {
|
||||||
|
await api("POST", "/confirm", { body: { token }, timeout: 10000 });
|
||||||
|
checking = false;
|
||||||
|
toast("Confirmed automatically: this browser reached Housecarl again over a new connection");
|
||||||
|
} catch (e) {
|
||||||
|
checking = false;
|
||||||
|
toast("Couldn't confirm the change (" + (e.name === "AbortError" ? "no answer over a new connection" : e.message) + "): unless it is confirmed, it rolls back by itself", "warn", 10000);
|
||||||
|
}
|
||||||
|
await refreshProbation();
|
||||||
|
}
|
||||||
|
|
||||||
// refreshProbation asks whether changes wait for confirmation, and shows
|
// refreshProbation asks whether changes wait for confirmation, and shows
|
||||||
// the banner with a countdown while they do.
|
// the banner with a countdown while they do.
|
||||||
@@ -147,6 +223,10 @@ function drawBanner() {
|
|||||||
}
|
}
|
||||||
bannerEl.classList.remove("hidden");
|
bannerEl.classList.remove("hidden");
|
||||||
const write = can("config", true);
|
const write = can("config", true);
|
||||||
|
if (checking) {
|
||||||
|
mount(bannerEl, h("strong", null, "Checking you can still reach Housecarl…"), " The change is confirmed once this browser gets through over a new connection.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
mount(bannerEl,
|
mount(bannerEl,
|
||||||
h("strong", null, pending === 1 ? "1 change is on probation" : pending + " changes are on probation"),
|
h("strong", null, pending === 1 ? "1 change is on probation" : pending + " changes are on probation"),
|
||||||
" — rolled back in ", h("span", { class: "mono" }, left + "s"), " unless you confirm them. If this page stops answering, wait: the firewall puts itself back.",
|
" — rolled back in ", h("span", { class: "mono" }, left + "s"), " unless you confirm them. If this page stops answering, wait: the firewall puts itself back.",
|
||||||
@@ -246,7 +326,7 @@ export function rerender() {
|
|||||||
if (current.page) route();
|
if (current.page) route();
|
||||||
}
|
}
|
||||||
|
|
||||||
setChangeHandler(() => refreshProbation());
|
setChangeHandler((r) => r && r.confirm_token ? autoConfirm(r.confirm_token) : refreshProbation());
|
||||||
|
|
||||||
setUnauthorizedHandler(() => {
|
setUnauthorizedHandler(() => {
|
||||||
if (state.me) {
|
if (state.me) {
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
// Changes: the pending changeset — its operations, the check, and the
|
||||||
|
// ruleset's diff — to apply together or discard. Other open changesets
|
||||||
|
// (from hc or the API) can be taken over here.
|
||||||
|
import { h, mount, table, input, toast, errorBox, confirmDialog, diffView, problemsList, time, plural, badge } from "../ui.js";
|
||||||
|
import { api, state, useDraft, refreshDraft, can } from "../api.js";
|
||||||
|
import { rerender, applyDraft } from "../app.js";
|
||||||
|
|
||||||
|
export async function render(el) {
|
||||||
|
await refreshDraft();
|
||||||
|
const open = await api("GET", "/changesets", { query: { status: "open", limit: 50 } });
|
||||||
|
const d = state.draft;
|
||||||
|
const parts = [h("div", { class: "page-head" },
|
||||||
|
h("div", { class: "grow" }, h("h1", null, "Pending changes"),
|
||||||
|
h("div", { class: "muted" }, "Edits are collected in a changeset. Nothing reaches the firewall until you apply it; then the whole changeset is checked by Housecarl and nftables and put in force in one step, as one entry in the history. Over the network it is on probation: confirm it, or it rolls back.")))];
|
||||||
|
if (state.immediate) {
|
||||||
|
parts.push(h("div", { class: "msg info" }, "Live edits are on (the switch at the top): edits are applied right away, not collected here."));
|
||||||
|
}
|
||||||
|
if (!d || !(d.ops || []).length) {
|
||||||
|
parts.push(h("div", { class: "panel" }, h("div", { class: "empty" }, "No pending changes. Edit rules, NAT, objects or settings and they show up here.")));
|
||||||
|
} else {
|
||||||
|
parts.push(draftPanel(d));
|
||||||
|
}
|
||||||
|
const others = open.filter((c) => !d || c.id !== d.id);
|
||||||
|
if (others.length) {
|
||||||
|
parts.push(h("div", { class: "panel" }, h("h2", null, "Other open changesets"),
|
||||||
|
h("p", { class: "muted small" }, "Made with hc or the API, by others, or earlier here. Switch to one to review and apply it."),
|
||||||
|
table([
|
||||||
|
{ label: "#", render: (c) => String(c.id) },
|
||||||
|
{ label: "Description", render: (c) => c.description || h("span", { class: "muted" }, "(none)") },
|
||||||
|
{ label: "Operations", render: (c) => String((c.ops || []).length) },
|
||||||
|
{ label: "By", render: (c) => [c.username, h("span", { class: "muted small" }, " via " + c.via)] },
|
||||||
|
{ label: "Updated", render: (c) => time(c.updated_at) },
|
||||||
|
can("config", true) ? { label: "", tdClass: "actions-cell", render: (c) => [
|
||||||
|
h("button", { class: "small", onclick: () => { useDraft(c); rerender(); } }, "Switch to"), " ",
|
||||||
|
h("button", { class: "small danger", onclick: async () => {
|
||||||
|
if (!(await confirmDialog("Discard", "Discard changeset #" + c.id + "?", "Discard", "danger"))) return;
|
||||||
|
try { await api("POST", "/changesets/" + c.id + "/discard"); } catch (e) { toast(e.message, "err"); }
|
||||||
|
rerender();
|
||||||
|
} }, "Discard")] } : null,
|
||||||
|
].filter(Boolean), others)));
|
||||||
|
}
|
||||||
|
mount(el, parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
function draftPanel(d) {
|
||||||
|
const ops = d.ops || [];
|
||||||
|
const desc = input({ value: d.description || "", placeholder: "Describe this change (it becomes the history entry's description)", class: "wide" });
|
||||||
|
const preview = h("div", { class: "muted" }, "Checking…");
|
||||||
|
const result = h("div");
|
||||||
|
const applyBtn = h("button", { class: "primary", disabled: true }, "Apply changes");
|
||||||
|
const removeOp = async (seq) => {
|
||||||
|
try {
|
||||||
|
useDraft(await api("DELETE", "/changesets/" + d.id + "/ops/" + seq));
|
||||||
|
rerender();
|
||||||
|
} catch (e) { toast(e.message, "err", 8000); }
|
||||||
|
};
|
||||||
|
const opsTable = table([
|
||||||
|
{ label: "#", render: (o) => String(o.seq) },
|
||||||
|
{ label: "Change", render: (o) => o.summary || o.op },
|
||||||
|
{ label: "Added", render: (o) => time(o.added_at) },
|
||||||
|
{ label: "", tdClass: "actions-cell", render: (o) => h("button", { class: "small", title: "Take this operation out of the changeset", onclick: () => removeOp(o.seq) }, "Remove") },
|
||||||
|
], ops);
|
||||||
|
// failedOp marks the operation that no longer applies, and offers to
|
||||||
|
// take it out.
|
||||||
|
const failedOp = (err) => {
|
||||||
|
const i = ops.findIndex((o) => o.seq === err.op);
|
||||||
|
const rows = opsTable.querySelectorAll("tbody tr");
|
||||||
|
if (i < 0 || !rows[i]) return errorBox({ message: err.message, details: err });
|
||||||
|
rows[i].classList.add("op-failed");
|
||||||
|
return h("div", { class: "msg err" },
|
||||||
|
h("div", null, err.message),
|
||||||
|
h("div", { class: "form-actions" },
|
||||||
|
h("button", { class: "small", onclick: () => removeOp(err.op) }, "Remove operation " + err.op),
|
||||||
|
" ", h("span", { class: "muted small" }, "It is marked in the list above. Something it names changed since it was staged.")));
|
||||||
|
};
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const p = await api("GET", "/changesets/" + d.id + "/preview");
|
||||||
|
applyBtn.disabled = !p.valid;
|
||||||
|
mount(preview,
|
||||||
|
p.error ? (p.error.op ? failedOp(p.error) : errorBox({ message: p.error.message, details: p.error })) : null,
|
||||||
|
p.valid ? h("div", { class: "msg ok" }, "Valid: Housecarl's checks pass and nftables accepts the new ruleset.") : null,
|
||||||
|
!p.error && !p.valid ? h("div", { class: "msg info" }, "Together these operations change nothing.") : null,
|
||||||
|
problemsList(p.warnings),
|
||||||
|
h("h3", null, "The ruleset (/var/lib/housecarl/ruleset.nft)"),
|
||||||
|
p.valid ? diffView(p.diff) : h("div", { class: "muted" }, "No diff until the changeset can be applied."));
|
||||||
|
} catch (e) { mount(preview, errorBox(e)); }
|
||||||
|
})();
|
||||||
|
applyBtn.addEventListener("click", async () => {
|
||||||
|
mount(result);
|
||||||
|
if (desc.value !== (d.description || "")) {
|
||||||
|
try { await api("PATCH", "/changesets/" + d.id, { body: { description: desc.value } }); } catch { /* the apply says it */ }
|
||||||
|
}
|
||||||
|
const r = await applyDraft(applyBtn, d, desc.value);
|
||||||
|
if (r && r.history_id) location.hash = "#/history/" + r.history_id;
|
||||||
|
});
|
||||||
|
const discard = h("button", { class: "danger", onclick: async () => {
|
||||||
|
if (!(await confirmDialog("Discard changes", "Throw away all " + plural(ops.length, "pending change") + "?", "Discard", "danger"))) return;
|
||||||
|
try { await api("POST", "/changesets/" + d.id + "/discard"); } catch (e) { toast(e.message, "err"); }
|
||||||
|
useDraft(null);
|
||||||
|
rerender();
|
||||||
|
} }, "Discard all");
|
||||||
|
return h("div", null,
|
||||||
|
h("div", { class: "panel" },
|
||||||
|
h("h2", null, "Changeset #" + d.id + " ", badge(plural(ops.length, "operation"), "warn")),
|
||||||
|
desc,
|
||||||
|
h("div", { class: "form-actions" }, opsTable),
|
||||||
|
h("div", { class: "actions form-actions" }, applyBtn, discard),
|
||||||
|
result),
|
||||||
|
h("div", { class: "panel" }, h("h2", null, "Review"), preview));
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
// 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 } from "../ui.js";
|
||||||
import { get, change, can } from "../api.js";
|
import { get, change, can, state } from "../api.js";
|
||||||
import { rerender } from "../app.js";
|
import { rerender } from "../app.js";
|
||||||
|
|
||||||
export async function render(el, params) {
|
export async function render(el, params) {
|
||||||
@@ -20,8 +20,9 @@ export async function render(el, params) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function undo(e) {
|
async function undo(e) {
|
||||||
if (!await confirmDialog("Undo change #" + e.id, "Undo “" + e.description + "”? This is recorded as a new change, which can be undone too.", "Undo")) return;
|
if (!await confirmDialog("Undo change #" + e.id, "Undo “" + e.description + "”? This is recorded as a new change, which can be undone too." + (!state.immediate ? " It is added to your pending changes, to review and apply." : ""), "Undo")) return;
|
||||||
try {
|
try {
|
||||||
|
// Like any edit: staged for review, or applied at once in live mode.
|
||||||
await change("POST", "/history/" + e.id + "/revert");
|
await change("POST", "/history/" + e.id + "/revert");
|
||||||
rerender();
|
rerender();
|
||||||
} catch (err) { modal("Can't undo", errorBox(err)); }
|
} catch (err) { modal("Can't undo", errorBox(err)); }
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Import: an existing nftables ruleset as Housecarl's configuration.
|
// Import: an existing nftables ruleset as Housecarl's configuration.
|
||||||
import { h, mount, field, textarea, input, checkbox, errorBox, copyBox } from "../ui.js";
|
import { h, mount, field, textarea, input, checkbox, errorBox, copyBox } from "../ui.js";
|
||||||
import { api } from "../api.js";
|
import { api } from "../api.js";
|
||||||
import { refreshProbation } from "../app.js";
|
import { refreshProbation, autoConfirm } from "../app.js";
|
||||||
|
|
||||||
export async function render(el) {
|
export async function render(el) {
|
||||||
const json = textarea("", { rows: 6, class: "mono", placeholder: "the output of: sudo nft -j list ruleset" });
|
const json = textarea("", { rows: 6, class: "mono", placeholder: "the output of: sudo nft -j list ruleset" });
|
||||||
@@ -21,12 +21,13 @@ export async function render(el) {
|
|||||||
mount(out, h("div", { class: "msg " + (dry ? "info" : "ok") },
|
mount(out, h("div", { class: "msg " + (dry ? "info" : "ok") },
|
||||||
dry ? "Would import: " : "Imported: ",
|
dry ? "Would import: " : "Imported: ",
|
||||||
`${st.zones.length} zones, ${st.sets.length} sets, ${st.services.length} services, ${st.rules.length} rules, ${st.forwards.length} port forwards, ${st.snats.length} source NAT rules, ${st.redirects.length} redirects.`,
|
`${st.zones.length} zones, ${st.sets.length} sets, ${st.services.length} services, ${st.rules.length} rules, ${st.forwards.length} port forwards, ${st.snats.length} source NAT rules, ${st.redirects.length} redirects.`,
|
||||||
r.confirm_by ? " It is on probation: confirm it in the banner." : ""),
|
r.confirm_by && !r.confirm_token ? " It is on probation: confirm it in the banner." : ""),
|
||||||
r.notes.length ? h("div", { class: "panel" }, h("h2", null, "Notes"), h("ul", null, r.notes.map((n) => h("li", null, n)))) : null,
|
r.notes.length ? h("div", { class: "panel" }, h("h2", null, "Notes"), h("ul", null, r.notes.map((n) => h("li", null, n)))) : null,
|
||||||
r.raw.length ? h("div", { class: "panel" }, h("h2", null, "Kept as raw rules"), h("ul", { class: "mono small" }, r.raw.map((n) => h("li", null, n)))) : null,
|
r.raw.length ? h("div", { class: "panel" }, h("h2", null, "Kept as raw rules"), h("ul", { class: "mono small" }, r.raw.map((n) => h("li", null, n)))) : null,
|
||||||
!dry ? h("p", null, "Check the ", h("a", { href: "#/rules" }, "rules"), " and ", h("a", { href: "#/nat" }, "NAT"),
|
!dry ? h("p", null, "Check the ", h("a", { href: "#/rules" }, "rules"), " and ", h("a", { href: "#/nat" }, "NAT"),
|
||||||
"; both rulesets apply until the old table goes: ", copyBox("sudo hc takeover \"inet main\"")) : null);
|
"; both rulesets apply until the old table goes: ", copyBox("sudo hc takeover \"inet main\"")) : null);
|
||||||
if (!dry) refreshProbation();
|
if (!dry && r.confirm_token) autoConfirm(r.confirm_token);
|
||||||
|
else if (!dry) refreshProbation();
|
||||||
} catch (e) { mount(out, errorBox(e)); }
|
} catch (e) { mount(out, errorBox(e)); }
|
||||||
};
|
};
|
||||||
mount(el,
|
mount(el,
|
||||||
|
|||||||
@@ -3,12 +3,12 @@
|
|||||||
import { h, mount, table, modal, errorBox, field, input, checkbox, confirmDialog, badge } from "../ui.js";
|
import { h, mount, table, modal, errorBox, field, input, checkbox, confirmDialog, badge } from "../ui.js";
|
||||||
import { get, change, can } from "../api.js";
|
import { get, change, can } from "../api.js";
|
||||||
import { rerender } from "../app.js";
|
import { rerender } from "../app.js";
|
||||||
import { names, matchEditor, describe, hits, counters, listEditor } from "../widgets.js";
|
import { names, matchEditor, describe, hits, counters, listEditor, rowClass } from "../widgets.js";
|
||||||
|
|
||||||
export async function render(el) {
|
export async function render(el) {
|
||||||
const [fwds, snats, redirects, cs] = await Promise.all([get("/forwards"), get("/snats"), get("/redirects"), counters()]);
|
const [fwds, snats, redirects, cs] = await Promise.all([get("/forwards"), get("/snats"), get("/redirects"), counters()]);
|
||||||
const write = can("config", true);
|
const write = can("config", true);
|
||||||
const actions = (kind, x, edit) => write ? [
|
const actions = (kind, x, edit) => x.pending === "deleted" ? badge("deleted", "err") : write ? [
|
||||||
h("button", { class: "small reveal", onclick: () => edit(x) }, "Edit"), " ",
|
h("button", { class: "small reveal", onclick: () => edit(x) }, "Edit"), " ",
|
||||||
h("button", { class: "small reveal", onclick: () => toggle(kind, x) }, x.disabled ? "Enable" : "Disable"), " ",
|
h("button", { class: "small reveal", onclick: () => toggle(kind, x) }, x.disabled ? "Enable" : "Disable"), " ",
|
||||||
h("button", { class: "small reveal", onclick: () => del(kind, x) }, "Delete")] : null;
|
h("button", { class: "small reveal", onclick: () => del(kind, x) }, "Delete")] : null;
|
||||||
@@ -25,7 +25,7 @@ export async function render(el) {
|
|||||||
{ label: "Hairpin", render: (f) => f.hairpin ? badge((f.hairpin_sources || []).length ? "from " + f.hairpin_sources.join(", ") : "yes", "ok") : "" },
|
{ label: "Hairpin", render: (f) => f.hairpin ? badge((f.hairpin_sources || []).length ? "from " + f.hairpin_sources.join(", ") : "yes", "ok") : "" },
|
||||||
{ label: "Hits", render: (f) => hits(cs, "f" + f.id, "f" + f.id + "-6") },
|
{ label: "Hits", render: (f) => hits(cs, "f" + f.id, "f" + f.id + "-6") },
|
||||||
{ label: "", tdClass: "actions-cell", render: (f) => actions("forwards", f, forwardDialog) },
|
{ label: "", tdClass: "actions-cell", render: (f) => actions("forwards", f, forwardDialog) },
|
||||||
], fwds, { empty: "No port forwards.", rowClass: (x) => x.disabled ? "disabled" : "" })),
|
], fwds, { empty: "No port forwards.", rowClass })),
|
||||||
h("div", { class: "panel" },
|
h("div", { class: "panel" },
|
||||||
h("div", { class: "panel-head" }, h("h2", null, "Source NAT"), write ? h("button", { class: "primary small", onclick: () => snatDialog(null) }, "Add source NAT") : null),
|
h("div", { class: "panel-head" }, h("h2", null, "Source NAT"), write ? h("button", { class: "primary small", onclick: () => snatDialog(null) }, "Add source NAT") : null),
|
||||||
table([
|
table([
|
||||||
@@ -35,7 +35,7 @@ export async function render(el) {
|
|||||||
{ label: "Hits", render: (x) => hits(cs, "s" + x.id) },
|
{ label: "Hits", render: (x) => hits(cs, "s" + x.id) },
|
||||||
{ label: "Comment", render: (x) => x.comment },
|
{ label: "Comment", render: (x) => x.comment },
|
||||||
{ label: "", tdClass: "actions-cell", render: (x) => actions("snats", x, snatDialog) },
|
{ label: "", tdClass: "actions-cell", render: (x) => actions("snats", x, snatDialog) },
|
||||||
], snats, { empty: "No source NAT.", rowClass: (x) => x.disabled ? "disabled" : "" })),
|
], snats, { empty: "No source NAT.", rowClass })),
|
||||||
h("div", { class: "panel" },
|
h("div", { class: "panel" },
|
||||||
h("div", { class: "panel-head" }, h("h2", null, "Redirects"), write ? h("button", { class: "primary small", onclick: () => redirectDialog(null) }, "Add redirect") : null),
|
h("div", { class: "panel-head" }, h("h2", null, "Redirects"), write ? h("button", { class: "primary small", onclick: () => redirectDialog(null) }, "Add redirect") : null),
|
||||||
table([
|
table([
|
||||||
@@ -44,7 +44,7 @@ export async function render(el) {
|
|||||||
{ label: "Hits", render: (x) => hits(cs, "d" + x.id) },
|
{ label: "Hits", render: (x) => hits(cs, "d" + x.id) },
|
||||||
{ label: "Comment", render: (x) => x.comment },
|
{ label: "Comment", render: (x) => x.comment },
|
||||||
{ label: "", tdClass: "actions-cell", render: (x) => actions("redirects", x, redirectDialog) },
|
{ label: "", tdClass: "actions-cell", render: (x) => actions("redirects", x, redirectDialog) },
|
||||||
], redirects, { empty: "No redirects.", rowClass: (x) => x.disabled ? "disabled" : "" })));
|
], redirects, { empty: "No redirects.", rowClass })));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggle(kind, x) {
|
async function toggle(kind, x) {
|
||||||
@@ -78,13 +78,13 @@ async function dialog(title, kind, x, fields, value) {
|
|||||||
async function forwardDialog(f) {
|
async function forwardDialog(f) {
|
||||||
const n = await names();
|
const n = await names();
|
||||||
f = f || { protocols: ["tcp"], hairpin: true };
|
f = f || { protocols: ["tcp"], hairpin: true };
|
||||||
const name = input({ value: f.name || "", placeholder: "jellyfin" });
|
const name = input({ value: f.name || "", placeholder: "web" });
|
||||||
const tcp = checkbox("TCP", f.protocols.includes("tcp")), udp = checkbox("UDP", f.protocols.includes("udp"));
|
const tcp = checkbox("TCP", f.protocols.includes("tcp")), udp = checkbox("UDP", f.protocols.includes("udp"));
|
||||||
const ports = input({ value: f.ports || "", placeholder: "8096 or 7777-7780", class: "mono" });
|
const ports = input({ value: f.ports || "", placeholder: "8080, or a range: 27015-27030", class: "mono" });
|
||||||
const pub = listEditor(f.public_addresses, { suggestions: n.sets, placeholder: "your public IPv4 address, or a set", addLabel: "Add address" });
|
const pub = listEditor(f.public_addresses, { suggestions: n.sets, placeholder: "your public IPv4 address, or a set", addLabel: "Add address" });
|
||||||
const to = input({ value: f.to || "", placeholder: "10.0.10.57", class: "mono" });
|
const to = input({ value: f.to || "", placeholder: "192.168.1.10", class: "mono" });
|
||||||
const toPort = input({ value: f.to_port || "", placeholder: "the same", class: "mono short" });
|
const toPort = input({ value: f.to_port || "", placeholder: "the same", class: "mono short" });
|
||||||
const to6 = input({ value: f.to6 || "", placeholder: "2001:db8:10::57 (optional)", class: "mono" });
|
const to6 = input({ value: f.to6 || "", placeholder: "2001:db8:1::10 (optional)", class: "mono" });
|
||||||
const src = listEditor(f.sources, { suggestions: n.sets, placeholder: "anywhere", addLabel: "Add source" });
|
const src = listEditor(f.sources, { suggestions: n.sets, placeholder: "anywhere", addLabel: "Add source" });
|
||||||
const hairpin = checkbox("the public address also works from inside (hairpin NAT)", f.hairpin);
|
const hairpin = checkbox("the public address also works from inside (hairpin NAT)", f.hairpin);
|
||||||
const hairSrc = listEditor(f.hairpin_sources, { suggestions: n.sets, placeholder: "all inside sources", addLabel: "Add source" });
|
const hairSrc = listEditor(f.hairpin_sources, { suggestions: n.sets, placeholder: "all inside sources", addLabel: "Add source" });
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
// Rules: each chain's rules in order, with their hits; add, edit, move.
|
// Rules: each chain's rules in order, with their hits; add, edit, move.
|
||||||
import { h, mount, table, modal, errorBox, field, input, select, checkbox, confirmDialog, badge, textarea } from "../ui.js";
|
import { h, mount, table, modal, errorBox, field, input, select, checkbox, confirmDialog, badge, textarea, toast } from "../ui.js";
|
||||||
import { get, change, can } from "../api.js";
|
import { get, api, change, can } from "../api.js";
|
||||||
import { rerender } from "../app.js";
|
import { rerender } from "../app.js";
|
||||||
import { names, matchEditor, describe, hits, counters, listEditor } from "../widgets.js";
|
import { names, matchEditor, describe, hits, counters, listEditor, rowClass, live, fmtBytes, quotas } from "../widgets.js";
|
||||||
|
|
||||||
const chains = [
|
const chains = [
|
||||||
["input", "Input", "Traffic to the firewall itself."],
|
["input", "Input", "Traffic to the firewall itself."],
|
||||||
@@ -25,49 +25,165 @@ const builtins = {
|
|||||||
|
|
||||||
export async function render(el, params, query) {
|
export async function render(el, params, query) {
|
||||||
const chain = query.get("chain") || "input";
|
const chain = query.get("chain") || "input";
|
||||||
const [rules, settings, cs] = await Promise.all([get("/rules", { chain }), get("/settings"), counters()]);
|
const [all, settings, cs, qs] = await Promise.all([get("/rules", { chain }), get("/settings"), counters(), quotas()]);
|
||||||
|
// As nftables checks them: the rules that count traffic first.
|
||||||
|
const rules = [...all.filter(countsTraffic), ...all.filter((r) => !countsTraffic(r))];
|
||||||
const write = can("config", true);
|
const write = can("config", true);
|
||||||
const info = chains.find((c) => c[0] === chain) || chains[0];
|
const info = chains.find((c) => c[0] === chain) || chains[0];
|
||||||
const policy = settings[chain + "_policy"];
|
const policy = settings[chain + "_policy"];
|
||||||
const bi = (builtins[chain] || []).filter(([flag]) => flag === "" || settings.builtins[flag]);
|
const actionBadge = (r) => r.raw ? badge("as written", "") : badge(r.action + (r.log ? " + log" : ""), { accept: "ok", drop: "err", reject: "warn", log: "" }[r.action]);
|
||||||
const actionBadge = (r) => badge(r.action + (r.log ? " + log" : ""), { accept: "ok", drop: "err", reject: "warn", log: "" }[r.action]);
|
|
||||||
mount(el,
|
mount(el,
|
||||||
h("div", { class: "page-head" },
|
h("div", { class: "page-head" },
|
||||||
h("div", { class: "grow" }, h("h1", null, "Rules"), h("div", { class: "muted" }, "Checked top to bottom; the first rule that decides wins. ", info[2])),
|
h("div", { class: "grow" }, h("h1", null, "Rules"), h("div", { class: "muted" }, "Checked top to bottom; the first rule that decides wins. ", info[2])),
|
||||||
write ? h("div", { class: "actions" }, h("button", { class: "primary", onclick: () => ruleDialog(null, chain) }, "Add rule")) : null),
|
write ? h("div", { class: "actions" }, h("button", { class: "primary", onclick: () => ruleDialog(null, chain) }, "Add rule")) : null),
|
||||||
h("div", { class: "tabs" }, chains.map(([k, label]) => h("a", { href: "#/rules?chain=" + k, class: k === chain ? "active" : "" }, label))),
|
h("div", { class: "tabs" }, chains.map(([k, label]) => h("a", { href: "#/rules?chain=" + k, class: k === chain ? "active" : "" }, label))),
|
||||||
h("div", { class: "panel" },
|
h("div", { class: "panel" },
|
||||||
bi.length ? h("div", { class: "builtins muted small" }, h("strong", null, "Added by Housecarl first: "),
|
builtinsRow(chain, settings, cs),
|
||||||
bi.map(([, mark, text], i) => [i ? " · " : "", text, mark ? [" (", hits(cs, "b-" + mark), ")"] : ""]), " — ",
|
|
||||||
h("a", { href: "#/settings" }, "settings")) : null,
|
|
||||||
table([
|
table([
|
||||||
{ label: "#", render: (r, i) => String(i + 1) },
|
{ label: "#", render: (r) => r.pending === "deleted" ? "" : String(live(rules).indexOf(r) + 1) },
|
||||||
{ label: "Match", render: (r) => r.raw ? [badge("raw", ""), " ", h("span", { class: "mono small" }, r.raw)]
|
{ label: "Match", render: (r) => r.raw ? [badge("raw", ""), " ", h("span", { class: "mono small" }, r.raw)]
|
||||||
: [describe(r), extras(r)] },
|
: [countsTraffic(r) ? [badge("every packet", "accent"), " "] : null, describe(r), extras(r, qs[r.id])] },
|
||||||
{ label: "Action", render: actionBadge },
|
{ label: "Action", render: actionBadge },
|
||||||
{ label: "Hits", render: (r) => hits(cs, "r" + r.id) },
|
{ label: "Hits", render: (r) => hits(cs, "r" + r.id) },
|
||||||
{ label: "Comment", render: (r) => r.comment },
|
{ label: "Comment", render: (r) => r.comment },
|
||||||
write ? { label: "", tdClass: "actions-cell", render: (r, i) => [
|
write ? { label: "", tdClass: "actions-cell", render: (r) => {
|
||||||
h("button", { class: "small reveal", title: "Move up", disabled: i === 0, onclick: () => move(r, { before: rules[i - 1].id }) }, "↑"), " ",
|
if (r.pending === "deleted") return badge("deleted", "err");
|
||||||
h("button", { class: "small reveal", title: "Move down", disabled: i === rules.length - 1, onclick: () => move(r, { after: rules[i + 1].id }) }, "↓"), " ",
|
// Moves stay among the rules of the same kind (counting traffic,
|
||||||
|
// or not): nftables checks those first.
|
||||||
|
const rs = live(rules).filter((x) => countsTraffic(x) === countsTraffic(r)), i = rs.indexOf(r);
|
||||||
|
return [
|
||||||
|
h("button", { class: "small reveal", title: "Move up", disabled: i === 0, onclick: () => move(r, { before: rs[i - 1].id }) }, "↑"), " ",
|
||||||
|
h("button", { class: "small reveal", title: "Move down", disabled: i === rs.length - 1, onclick: () => move(r, { after: rs[i + 1].id }) }, "↓"), " ",
|
||||||
h("button", { class: "small reveal", onclick: () => ruleDialog(r, chain) }, "Edit"), " ",
|
h("button", { class: "small reveal", onclick: () => ruleDialog(r, chain) }, "Edit"), " ",
|
||||||
h("button", { class: "small reveal", onclick: () => toggle(r) }, r.disabled ? "Enable" : "Disable"), " ",
|
h("button", { class: "small reveal", onclick: () => toggle(r) }, r.disabled ? "Enable" : "Disable"), " ",
|
||||||
h("button", { class: "small reveal", onclick: () => del(r) }, "Delete"),
|
h("button", { class: "small reveal", onclick: () => del(r) }, "Delete"),
|
||||||
] } : null,
|
];
|
||||||
].filter(Boolean), rules, { empty: "No rules in this chain yet.", rowClass: (r) => r.disabled ? "disabled" : "" }),
|
} } : null,
|
||||||
|
].filter(Boolean), rules, { empty: "No rules in this chain yet.", rowClass }),
|
||||||
|
rules.some(countsTraffic) ? h("div", { class: "muted small" }, "Rules marked “every packet” have a quota or a byte rate: they are checked before established connections are accepted, so they count all of a connection's traffic, not only its first packet. They can drop, reject or log, not accept.") : null,
|
||||||
h("div", { class: "policy small" }, "Then the policy: ", badge(policy, policy === "drop" ? "err" : "ok"),
|
h("div", { class: "policy small" }, "Then the policy: ", badge(policy, policy === "drop" ? "err" : "ok"),
|
||||||
policy === "drop" ? [" — ", hits(cs, "p-" + chain), " packets reached it"] : null)));
|
policy === "drop" ? [" — ", hits(cs, "p-" + chain), " packets reached it"] : null)));
|
||||||
}
|
}
|
||||||
|
|
||||||
function extras(r) {
|
// countsTraffic: a quota or a byte rate (as model.Rule.CountsTraffic).
|
||||||
|
function countsTraffic(r) {
|
||||||
|
return !r.raw && (!!r.quota || !!(r.limit && ["bytes", "kbytes", "mbytes"].includes(r.limit.unit)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// What each built-in does (a switch covers all its rules).
|
||||||
|
const builtinText = {
|
||||||
|
loopback: "Accept everything on lo (the firewall talking to itself)",
|
||||||
|
established: "Accept established and related connections: replies, and the rest of connections a rule let in",
|
||||||
|
drop_invalid: "Drop invalid packets: what connection tracking can't place",
|
||||||
|
icmpv6: "Accept the ICMPv6 that IPv6 needs: errors, echo, neighbour discovery, MLD (RFC 4890)",
|
||||||
|
ping_internal: "Accept ping to the firewall from internal zones",
|
||||||
|
dhcpv6_client: "Accept DHCPv6 replies on external zones (your ISP's prefix delegation)",
|
||||||
|
antispoof: "Drop sources that can't come from outside, on external zones (Settings: the lists and your prefixes)",
|
||||||
|
mss_clamp: "Clamp TCP's MSS to the route's MTU on external zones (needed with PPPoE)",
|
||||||
|
};
|
||||||
|
|
||||||
|
// builtinsRow sums up the chain's built-ins in one line, with a button to
|
||||||
|
// see and switch them.
|
||||||
|
function builtinsRow(chain, settings, cs) {
|
||||||
|
const list = builtins[chain] || [];
|
||||||
|
const on = list.filter(([flag]) => flag === "" || settings.builtins[flag]);
|
||||||
|
if (!list.length) return null;
|
||||||
|
return h("div", { class: "builtins muted small" },
|
||||||
|
on.length ? (on.length === 1 ? "Housecarl adds 1 rule before yours. " : "Housecarl adds " + on.length + " rules before yours. ") : "Housecarl adds no rules before yours here. ",
|
||||||
|
h("button", { class: "small", onclick: () => builtinsDialog(chain, settings, cs) }, "Built-ins…"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// builtinsDialog lists the chain's built-ins: what each does, its hits,
|
||||||
|
// and a switch (a setting, so it covers every chain the built-in is in).
|
||||||
|
async function builtinsDialog(chain, settings, cs) {
|
||||||
|
const list = builtins[chain] || [];
|
||||||
|
const flags = [...new Set(list.map(([flag]) => flag))];
|
||||||
|
const write = can("config", true);
|
||||||
|
const boxes = {};
|
||||||
|
const rows = flags.map((flag) => {
|
||||||
|
const marks = list.filter(([f]) => f === flag).map(([, mark]) => mark).filter(Boolean);
|
||||||
|
const box = flag ? h("input", { type: "checkbox", checked: settings.builtins[flag], disabled: !write }) : null;
|
||||||
|
if (box) boxes[flag] = box;
|
||||||
|
return { flag, marks, box, text: flag ? builtinText[flag] : list.find(([f]) => f === flag)[2] };
|
||||||
|
});
|
||||||
|
const err = h("div");
|
||||||
|
const body = h("div", null,
|
||||||
|
h("p", { class: "muted" }, "Checked in this order, before your rules (rules with a quota or a byte rate come before the established built-in, so they see every packet). Each is a setting: switching one changes every chain it is in."),
|
||||||
|
table([
|
||||||
|
{ label: "On", render: (x) => x.box || h("span", { class: "muted small" }, "always") },
|
||||||
|
{ label: "Built-in", render: (x) => x.flag ? x.text : [x.text, h("div", { class: "muted small" }, "Part of each port forward.")] },
|
||||||
|
{ label: "Hits", render: (x) => x.marks.length ? hits(cs, ...x.marks.map((m) => "b-" + m)) : h("span", { class: "muted" }, "–") },
|
||||||
|
], rows),
|
||||||
|
err);
|
||||||
|
await modal("Built-ins of the " + chain + " chain", body, write ? [
|
||||||
|
{ label: "Cancel", value: null },
|
||||||
|
{ label: "Save", kind: "primary", onclick: async () => {
|
||||||
|
mount(err);
|
||||||
|
const b = {};
|
||||||
|
for (const [flag, box] of Object.entries(boxes)) if (box.checked !== settings.builtins[flag]) b[flag] = box.checked;
|
||||||
|
if (!Object.keys(b).length) return true;
|
||||||
|
try { await change("PATCH", "/settings", { builtins: b }); rerender(); return true; } catch (e) { mount(err, errorBox(e)); return false; }
|
||||||
|
} },
|
||||||
|
] : [{ label: "Close", value: null }], { wide: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function extras(r, q) {
|
||||||
const x = [];
|
const x = [];
|
||||||
if ((r.ct_state || []).length) x.push("state " + r.ct_state.join(","));
|
if ((r.ct_state || []).length) x.push("state " + r.ct_state.join(","));
|
||||||
if ((r.ct_status || []).length) x.push("status " + r.ct_status.join(","));
|
if ((r.ct_status || []).length) x.push("status " + r.ct_status.join(","));
|
||||||
if ((r.days || []).length) x.push(r.days.join(","));
|
if ((r.days || []).length) x.push(r.days.join(","));
|
||||||
if (r.hour_from) x.push(r.hour_from + "–" + r.hour_to);
|
if (r.hour_from) x.push(r.hour_from + "–" + r.hour_to);
|
||||||
if (r.limit) x.push((r.limit.over ? "over " : "") + r.limit.rate + "/" + r.limit.per + (r.limit.per_source ? " per source" : ""));
|
if (r.limit) {
|
||||||
|
const l = r.limit;
|
||||||
|
x.push((l.over ? "over " : "") + l.rate + (l.unit && l.unit !== "packets" ? " " + l.unit : "") + "/" + l.per +
|
||||||
|
(l.per_source ? " per source" : l.per_destination ? " per destination" : ""));
|
||||||
|
}
|
||||||
if (r.conn_limit) x.push("over " + r.conn_limit + " connections");
|
if (r.conn_limit) x.push("over " + r.conn_limit + " connections");
|
||||||
return x.length ? h("div", { class: "muted small" }, x.join(" · ")) : null;
|
const out = x.length ? [h("div", { class: "muted small" }, x.join(" · "))] : [];
|
||||||
|
if (r.quota) out.push(quotaLine(r, q));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// quotaLine shows a quota, and what it used: in total, or per address
|
||||||
|
// (the busiest, and the rest in a dialog).
|
||||||
|
function quotaLine(r, q) {
|
||||||
|
const qt = r.quota;
|
||||||
|
const each = qt.per_source ? " each source" : qt.per_destination ? " each destination" : "";
|
||||||
|
const resets = qt.reset === "monthly" ? "monthly on day " + (qt.day || 1) : qt.reset || "by hand";
|
||||||
|
let used = null;
|
||||||
|
if (q && !qt.per_source && !qt.per_destination) {
|
||||||
|
used = [h("strong", null, fmtBytes(q.used)), " of " + fmtBytes(qt.bytes) + " used"];
|
||||||
|
} else if (q) {
|
||||||
|
const n = (q.addresses || []).length;
|
||||||
|
used = [n === 1 ? "1 address" : n + " addresses", n ? [", busiest ", h("strong", null, fmtBytes(q.addresses[0].used))] : null, " ",
|
||||||
|
n ? h("button", { class: "small", onclick: () => usageDialog(r, q) }, "Usage…") : null];
|
||||||
|
}
|
||||||
|
return h("div", { class: "muted small" }, (qt.over ? "quota over " : "quota ") + fmtBytes(qt.bytes) + each + ", resets " + resets,
|
||||||
|
used ? [" · ", used] : null,
|
||||||
|
q && q.next_reset ? " · next reset " + new Date(q.next_reset).toLocaleString() : null,
|
||||||
|
can("config", true) && q && r.pending !== "new" ? [" ", h("button", { class: "small", onclick: () => resetQuota(r) }, "Reset")] : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function usageDialog(r, q) {
|
||||||
|
await modal("Quota usage of " + (r.comment || "rule #" + r.id), h("div", null,
|
||||||
|
h("p", { class: "muted" }, "Each " + (r.quota.per_source ? "source" : "destination") + " has " + fmtBytes(r.quota.bytes) + "."),
|
||||||
|
table([
|
||||||
|
{ label: "Address", render: (a) => h("span", { class: "mono" }, a.address) },
|
||||||
|
{ label: "Used", render: (a) => fmtBytes(a.used) },
|
||||||
|
{ label: "", render: (a) => h("div", { class: "usage" }, h("div", { class: "usage-bar" + (a.used >= r.quota.bytes ? " full" : "") }, bar(a.used / r.quota.bytes))) },
|
||||||
|
], q.addresses)), [{ label: "Close", value: null }], { wide: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function bar(f) {
|
||||||
|
const seg = h("div", { class: "seg leased" });
|
||||||
|
seg.style.width = Math.min(100, f * 100) + "%";
|
||||||
|
return seg;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetQuota(r) {
|
||||||
|
if (!await confirmDialog("Reset the quota", "Start the quota of " + (r.comment || "rule #" + r.id) + " again now" +
|
||||||
|
(r.quota.per_source || r.quota.per_destination ? ", for every address" : "") + "? This is done at once, not staged.", "Reset")) return;
|
||||||
|
try { await api("POST", "/rules/" + r.id + "/reset-quota"); toast("Quota reset"); rerender(); } catch (e) { modal("Not reset", errorBox(e)); }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function move(r, where) {
|
async function move(r, where) {
|
||||||
@@ -87,7 +203,7 @@ async function del(r) {
|
|||||||
export async function ruleDialog(r, chain) {
|
export async function ruleDialog(r, chain) {
|
||||||
const n = await names();
|
const n = await names();
|
||||||
r = r || { chain, action: "accept" };
|
r = r || { chain, action: "accept" };
|
||||||
const ch = select([["input", "input (to the firewall)"], ["forward", "forward (through it)"], ["output", "output (from it)"], ["early", "early (before conntrack)"]], r.chain || chain);
|
const ch = select(["input", "forward", "output", "early"], r.chain || chain);
|
||||||
const match = matchEditor(r, n);
|
const match = matchEditor(r, n);
|
||||||
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 || "");
|
||||||
@@ -101,33 +217,121 @@ export async function ruleDialog(r, chain) {
|
|||||||
const hourTo = input({ value: r.hour_to || "", placeholder: "17:30", class: "short" });
|
const hourTo = input({ value: r.hour_to || "", placeholder: "17:30", class: "short" });
|
||||||
const l = r.limit || {};
|
const l = r.limit || {};
|
||||||
const rate = input({ value: l.rate || "", placeholder: "10", class: "short", type: "number", min: 1 });
|
const rate = input({ value: l.rate || "", placeholder: "10", class: "short", type: "number", min: 1 });
|
||||||
const per = select(["second", "minute", "hour", "day"], l.per || "minute");
|
const unit = select([["", "packets"], ["bytes", "bytes"], ["kbytes", "KB"], ["mbytes", "MB"]], l.unit || "", { class: "inline" });
|
||||||
|
const per = select(["second", "minute", "hour", "day"], l.per || "minute", { class: "inline" });
|
||||||
const burst = input({ value: l.burst || "", placeholder: "burst", class: "short", type: "number", min: 0 });
|
const burst = input({ value: l.burst || "", placeholder: "burst", class: "short", type: "number", min: 0 });
|
||||||
const over = checkbox("match beyond the rate (to drop floods)", l.over);
|
const over = checkbox("match beyond the rate (to drop floods, or traffic over a bandwidth)", l.over);
|
||||||
const perSource = checkbox("per source address", l.per_source);
|
const keyOf = (x) => x.per_source ? "source" : x.per_destination ? "destination" : "";
|
||||||
|
const keys = [["", "for everything the rule matches"], ["source", "for each source address"], ["destination", "for each destination address"]];
|
||||||
|
const limitKey = select(keys, keyOf(l), { class: "inline" });
|
||||||
|
const qt = r.quota || {};
|
||||||
|
const gb = qt.bytes && qt.bytes % (1 << 30) === 0;
|
||||||
|
const quotaAmount = input({ value: qt.bytes ? qt.bytes / (gb ? 1 << 30 : 1 << 20) : "", placeholder: "50", class: "short", type: "number", min: 1 });
|
||||||
|
const quotaUnit = select([["mb", "MB"], ["gb", "GB"]], gb || !qt.bytes ? "gb" : "mb", { class: "inline" });
|
||||||
|
const quotaOver = checkbox("match once it is used up (to drop); otherwise while it lasts (to accept)", qt.over === undefined ? true : qt.over);
|
||||||
|
const quotaKey = select(keys, keyOf(qt), { class: "inline" });
|
||||||
|
const quotaReset = select([["", "only by hand"], ["daily", "daily, at midnight"], ["weekly", "weekly, on Mondays"], ["monthly", "monthly, on day"]], qt.reset || "", { class: "inline" });
|
||||||
|
const quotaDay = input({ value: qt.day || "", placeholder: "1", class: "short", type: "number", min: 1, max: 31 });
|
||||||
|
const showDay = () => quotaDay.classList.toggle("hidden", quotaReset.value !== "monthly");
|
||||||
|
quotaReset.addEventListener("change", showDay);
|
||||||
|
showDay();
|
||||||
|
const group4 = input({ value: r.group_v4 || "", placeholder: "32", class: "short", type: "number", min: 1, max: 32 });
|
||||||
|
const group6 = input({ value: r.group_v6 || "", placeholder: "64", class: "short", type: "number", min: 1, max: 128 });
|
||||||
const conn = input({ value: r.conn_limit || "", placeholder: "e.g. 20", class: "short", type: "number", min: 1 });
|
const conn = input({ value: r.conn_limit || "", placeholder: "e.g. 20", class: "short", type: "number", min: 1 });
|
||||||
const log = checkbox("log matching packets (Log page)", r.log);
|
const log = checkbox("log matching packets (Log page)", r.log);
|
||||||
const logPrefix = input({ value: r.log_prefix || "", placeholder: "default: hc CHAIN #ID ACTION:" });
|
const logPrefix = input({ value: r.log_prefix || "", placeholder: "default: hc CHAIN #ID ACTION:" });
|
||||||
const raw = textarea(r.raw || "", { rows: 2, class: "mono", placeholder: "iifname != \"wan\" ct state invalid ip daddr @trusted_v4 accept" });
|
const raw = textarea(r.raw || "", { rows: 2, class: "mono", placeholder: "ip saddr @admins_v4 tcp dport 22 ct state new accept" });
|
||||||
const comment = input({ value: r.comment || "" });
|
const comment = input({ value: r.comment || "" });
|
||||||
const disabled = checkbox("disabled: keep the rule but don't use it", r.disabled);
|
const disabled = checkbox("disabled: keep the rule but don't use it", r.disabled);
|
||||||
const err = h("div");
|
const err = h("div");
|
||||||
|
|
||||||
|
// The parts most rules don't need are behind "+" buttons: open when the
|
||||||
|
// rule uses them, closed (and unset) with ✕.
|
||||||
|
const adders = h("div", { class: "adders" });
|
||||||
|
const sections = {};
|
||||||
|
const section = (key, label, open, fields) => {
|
||||||
|
const nodes = fields.flat().filter(Boolean);
|
||||||
|
const sec = { open };
|
||||||
|
const add = h("button", { type: "button", class: "small", onclick: () => show(true) }, "+ " + label);
|
||||||
|
const close = h("button", { type: "button", class: "small close-section", title: "Remove " + label.toLowerCase(), onclick: () => show(false) }, "✕");
|
||||||
|
nodes[0].append(" ", close);
|
||||||
|
const show = (on) => {
|
||||||
|
sec.open = on;
|
||||||
|
for (const n of nodes) n.classList.toggle("hidden", !on);
|
||||||
|
add.classList.toggle("hidden", on);
|
||||||
|
if (sec.onShow) sec.onShow(on);
|
||||||
|
if (on) { const f = nodes.find((n) => n.querySelector && n.querySelector("input, select, textarea")); if (f) f.querySelector("input, select, textarea").focus(); }
|
||||||
|
updateGroup();
|
||||||
|
};
|
||||||
|
adders.append(add, " ");
|
||||||
|
sections[key] = sec;
|
||||||
|
sec.nodes = nodes;
|
||||||
|
sec.init = () => show(open);
|
||||||
|
return nodes;
|
||||||
|
};
|
||||||
|
const stateFields = section("state", "Connection state", (r.ct_state || []).length > 0 || (r.ct_status || []).length > 0, [
|
||||||
|
field("Connection state", states, "new: a connection's first packet; established: its later ones; related: packets that belong to another connection (ICMP errors, FTP data); invalid: what connection tracking can't place. Empty: any."),
|
||||||
|
field("Connection status", statuses, "dnat: the connection was port forwarded; snat: its source was translated. Empty: any."),
|
||||||
|
]);
|
||||||
|
const timeFields = section("time", "Time window", (r.days || []).length > 0 || !!r.hour_from, [
|
||||||
|
field("Days", days),
|
||||||
|
field("Hours", h("div", null, hourFrom, " to ", hourTo), "22:00 to 06:00 passes midnight. In the firewall's local time."),
|
||||||
|
]);
|
||||||
|
const rateFields = section("rate", "Rate limit", !!r.limit, [
|
||||||
|
field("Rate limit", h("div", null, rate, " ", unit, " per ", per, " ", burst, " ", limitKey, over),
|
||||||
|
"In bytes, it is a bandwidth (KB or MB per second or minute); traffic beyond it is dropped, not queued. The burst is in the same unit."),
|
||||||
|
]);
|
||||||
|
const quotaFields = section("quota", "Quota", !!r.quota, [
|
||||||
|
field("Quota", h("div", null, quotaAmount, " ", quotaUnit, " ", quotaKey, " resets ", quotaReset, " ", quotaDay, quotaOver),
|
||||||
|
"An allowance of traffic; Housecarl keeps the count across changes and restarts."),
|
||||||
|
]);
|
||||||
|
const connFields = section("conn", "Connection limit", !!r.conn_limit, [
|
||||||
|
field("Connection limit", conn, "Match a source that already has more than this many connections."),
|
||||||
|
]);
|
||||||
|
const rawFields = section("raw", "Write it in nftables", !!r.raw, [
|
||||||
|
field("Raw nftables", raw, "One nftables rule, for what Housecarl can't say, used as written: it replaces the zones, addresses, services, action, limits and log (write them in it). Checked by nftables."),
|
||||||
|
]);
|
||||||
|
// Grouping only means something with a per-address limit, quota or
|
||||||
|
// connection limit.
|
||||||
|
const groupFields = field("Group addresses", h("div", null, "IPv4 /", group4, " IPv6 /", group6),
|
||||||
|
"Per-address limits, connection limits and quotas count addresses by these prefixes. Default: each IPv4 address, IPv6 by /64 (one device has several addresses).").filter(Boolean);
|
||||||
|
function updateGroup() {
|
||||||
|
if (!sections.raw) return; // still building
|
||||||
|
const on = (sections.rate.open && limitKey.value !== "") || (sections.quota.open && quotaKey.value !== "") || sections.conn.open;
|
||||||
|
for (const n of groupFields) n.classList.toggle("hidden", !on);
|
||||||
|
}
|
||||||
|
limitKey.addEventListener("change", updateGroup);
|
||||||
|
quotaKey.addEventListener("change", updateGroup);
|
||||||
|
|
||||||
|
// Written in nftables, a rule is its text: the fields it replaces go
|
||||||
|
// (and the other parts close).
|
||||||
|
const matchNodes = match.flat().filter(Boolean);
|
||||||
|
const actionField = field("Action", h("div", null, action, " ", rejectWith)).filter(Boolean);
|
||||||
|
const logField = field("Log", h("div", null, log, logPrefix)).filter(Boolean);
|
||||||
|
const addersLabel = h("label");
|
||||||
|
sections.raw.onShow = (on) => {
|
||||||
|
for (const n of [...matchNodes, ...actionField, ...logField]) n.classList.toggle("hidden", on);
|
||||||
|
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");
|
||||||
|
if (on) sec.open = false;
|
||||||
|
}
|
||||||
|
for (const b of adders.children) if (!b.textContent.includes("nftables")) b.classList.toggle("hidden", on);
|
||||||
|
updateGroup();
|
||||||
|
};
|
||||||
const body = h("div", null,
|
const body = h("div", null,
|
||||||
h("div", { class: "form-grid" },
|
h("div", { class: "form-grid" },
|
||||||
field("Chain", ch),
|
field("Chain", ch),
|
||||||
...match.flat(),
|
...matchNodes,
|
||||||
field("Action", h("div", null, action, " ", rejectWith)),
|
...actionField,
|
||||||
field("Connection state", states),
|
...stateFields, ...timeFields, ...rateFields, ...quotaFields, ...connFields, ...groupFields, ...rawFields,
|
||||||
field("Connection status", statuses, "dnat: the connection was port forwarded."),
|
addersLabel, adders,
|
||||||
field("Days", days),
|
...logField,
|
||||||
field("Hours", h("div", null, hourFrom, " to ", hourTo), "22:00 to 06:00 passes midnight."),
|
|
||||||
field("Rate limit", h("div", null, rate, " packets per ", per, " ", burst, over, perSource), "Empty: no limit."),
|
|
||||||
field("Connection limit", conn, "Match a source that already has more than this many connections."),
|
|
||||||
field("Log", h("div", null, log, logPrefix)),
|
|
||||||
field("Raw nftables", raw, "Instead of all the above: one nftables rule, for what Housecarl can't say. Checked by nftables."),
|
|
||||||
field("Comment", comment),
|
field("Comment", comment),
|
||||||
r.id ? [h("label"), disabled] : null),
|
r.id ? [h("label"), disabled] : null),
|
||||||
err);
|
err);
|
||||||
|
for (const sec of Object.values(sections)) sec.init();
|
||||||
|
updateGroup();
|
||||||
await modal(r.id ? "Edit rule" : "Add rule", body, [
|
await modal(r.id ? "Edit rule" : "Add rule", body, [
|
||||||
{ label: "Cancel", value: null },
|
{ label: "Cancel", value: null },
|
||||||
{ label: "Check", onclick: () => save(true) },
|
{ label: "Check", onclick: () => save(true) },
|
||||||
@@ -136,10 +340,20 @@ export async function ruleDialog(r, chain) {
|
|||||||
|
|
||||||
async function save(dry) {
|
async function save(dry) {
|
||||||
mount(err);
|
mount(err);
|
||||||
const v = { chain: ch.value, ...match.value(), action: action.value, reject_with: action.value === "reject" ? rejectWith.value : "",
|
const on = (k) => sections[k].open;
|
||||||
ct_state: states.value(), ct_status: statuses.value(), days: days.value(), hour_from: hourFrom.value.trim(), hour_to: hourTo.value.trim(),
|
const isRaw = on("raw");
|
||||||
limit: rate.value ? { rate: parseInt(rate.value, 10), per: per.value, burst: parseInt(burst.value || "0", 10), over: over.input.checked, per_source: perSource.input.checked } : null,
|
const m = isRaw ? { in_zones: [], not_in: false, out_zones: [], not_out: false, sources: [], not_sources: false, destinations: [], not_destinations: false, services: [], family: "" } : match.value();
|
||||||
conn_limit: parseInt(conn.value || "0", 10), log: log.input.checked, log_prefix: logPrefix.value.trim(), raw: raw.value.trim(), comment: comment.value.trim() };
|
const v = { chain: ch.value, ...m, action: isRaw ? "accept" : action.value, reject_with: !isRaw && action.value === "reject" ? rejectWith.value : "",
|
||||||
|
ct_state: on("state") ? states.value() : [], ct_status: on("state") ? statuses.value() : [],
|
||||||
|
days: on("time") ? days.value() : [], hour_from: on("time") ? hourFrom.value.trim() : "", hour_to: on("time") ? hourTo.value.trim() : "",
|
||||||
|
limit: on("rate") && rate.value ? { rate: parseInt(rate.value, 10), unit: unit.value, per: per.value, burst: parseInt(burst.value || "0", 10), over: over.input.checked,
|
||||||
|
per_source: limitKey.value === "source", per_destination: limitKey.value === "destination" } : null,
|
||||||
|
quota: on("quota") && quotaAmount.value ? { bytes: parseInt(quotaAmount.value, 10) * (quotaUnit.value === "gb" ? 1 << 30 : 1 << 20), over: quotaOver.input.checked,
|
||||||
|
per_source: quotaKey.value === "source", per_destination: quotaKey.value === "destination",
|
||||||
|
reset: quotaReset.value, day: quotaReset.value === "monthly" ? parseInt(quotaDay.value || "1", 10) : 0 } : null,
|
||||||
|
group_v4: isRaw ? 0 : parseInt(group4.value || "0", 10), group_v6: isRaw ? 0 : parseInt(group6.value || "0", 10),
|
||||||
|
conn_limit: on("conn") ? parseInt(conn.value || "0", 10) : 0, log: !isRaw && log.input.checked, log_prefix: isRaw ? "" : logPrefix.value.trim(),
|
||||||
|
raw: on("raw") ? raw.value.trim() : "", comment: comment.value.trim() };
|
||||||
if (r.id) v.disabled = disabled.input.checked;
|
if (r.id) v.disabled = disabled.input.checked;
|
||||||
try {
|
try {
|
||||||
await change(r.id ? "PATCH" : "POST", r.id ? "/rules/" + r.id : "/rules", v, { query: dry ? { dry_run: 1 } : undefined });
|
await change(r.id ? "PATCH" : "POST", r.id ? "/rules/" + r.id : "/rules", v, { query: dry ? { dry_run: 1 } : undefined });
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { h, mount, table, modal, errorBox, field, input, confirmDialog, badge } from "../ui.js";
|
import { h, mount, table, modal, errorBox, field, input, confirmDialog, badge } from "../ui.js";
|
||||||
import { get, change, can } from "../api.js";
|
import { get, change, can } from "../api.js";
|
||||||
import { rerender } from "../app.js";
|
import { rerender } from "../app.js";
|
||||||
import { listEditor } from "../widgets.js";
|
import { listEditor, rowClass } from "../widgets.js";
|
||||||
|
|
||||||
export async function render(el) {
|
export async function render(el) {
|
||||||
const services = await get("/services");
|
const services = await get("/services");
|
||||||
@@ -16,11 +16,11 @@ export async function render(el) {
|
|||||||
{ label: "Entries", render: (s) => h("span", { class: "mono small" }, s.entries.join(", ")) },
|
{ label: "Entries", render: (s) => h("span", { class: "mono small" }, s.entries.join(", ")) },
|
||||||
{ label: "", render: (s) => s.builtin ? badge("built in", "") : badge("yours", "accent") },
|
{ label: "", render: (s) => s.builtin ? badge("built in", "") : badge("yours", "accent") },
|
||||||
{ label: "Comment", render: (s) => s.comment },
|
{ label: "Comment", render: (s) => s.comment },
|
||||||
write ? { label: "", tdClass: "actions-cell", render: (s) => s.builtin
|
write ? { label: "", tdClass: "actions-cell", render: (s) => s.pending === "deleted" ? badge("deleted", "err") : s.builtin
|
||||||
? h("button", { class: "small reveal", onclick: () => serviceDialog({ name: s.name, entries: s.entries }) }, "Change")
|
? h("button", { class: "small reveal", onclick: () => serviceDialog({ name: s.name, entries: s.entries }) }, "Change")
|
||||||
: [h("button", { class: "small reveal", onclick: () => serviceDialog(s) }, "Edit"), " ",
|
: [h("button", { class: "small reveal", onclick: () => serviceDialog(s) }, "Edit"), " ",
|
||||||
h("button", { class: "small reveal", onclick: () => del(s) }, "Delete")] } : null,
|
h("button", { class: "small reveal", onclick: () => del(s) }, "Delete")] } : null,
|
||||||
].filter(Boolean), services)));
|
].filter(Boolean), services, { rowClass })));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function del(s) {
|
async function del(s) {
|
||||||
@@ -30,8 +30,8 @@ async function del(s) {
|
|||||||
|
|
||||||
async function serviceDialog(s) {
|
async function serviceDialog(s) {
|
||||||
s = s || { entries: [] };
|
s = s || { entries: [] };
|
||||||
const name = input({ value: s.name || "", placeholder: "jellyfin" });
|
const name = input({ value: s.name || "", placeholder: "web" });
|
||||||
const list = listEditor(s.entries, { suggestions: ["tcp/", "udp/", "icmp/echo-request", "icmpv6/echo-request"], placeholder: "tcp/8096, udp/27015-27030, icmp/echo-request", addLabel: "Add entry" });
|
const list = listEditor(s.entries, { suggestions: ["tcp/", "udp/", "icmp/echo-request", "icmpv6/echo-request"], placeholder: "tcp/8080, udp/27015-27030, icmp/echo-request", addLabel: "Add entry" });
|
||||||
const comment = input({ value: s.comment || "" });
|
const comment = input({ value: s.comment || "" });
|
||||||
const err = h("div");
|
const err = h("div");
|
||||||
await modal(s.id ? "Edit service " + s.name : "Add service", h("div", null, h("div", { class: "form-grid" },
|
await modal(s.id ? "Edit service " + s.name : "Add service", h("div", null, h("div", { class: "form-grid" },
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { h, mount, table, modal, errorBox, field, input, checkbox, confirmDialog, badge, toast } from "../ui.js";
|
import { h, mount, table, modal, errorBox, field, input, checkbox, confirmDialog, badge, toast } from "../ui.js";
|
||||||
import { get, api, change, can } from "../api.js";
|
import { get, api, change, can } from "../api.js";
|
||||||
import { rerender } from "../app.js";
|
import { rerender } from "../app.js";
|
||||||
import { listEditor, fmtSeconds, parseSeconds } from "../widgets.js";
|
import { listEditor, fmtSeconds, parseSeconds, rowClass } from "../widgets.js";
|
||||||
|
|
||||||
export async function render(el) {
|
export async function render(el) {
|
||||||
const sets = await get("/sets");
|
const sets = await get("/sets");
|
||||||
@@ -14,12 +14,12 @@ export async function render(el) {
|
|||||||
h("div", { class: "panel" }, table([
|
h("div", { class: "panel" }, table([
|
||||||
{ label: "Name", render: (s) => h("strong", null, s.name) },
|
{ label: "Name", render: (s) => h("strong", null, s.name) },
|
||||||
{ label: "Entries", render: (s) => s.dynamic ? [badge("dynamic", "accent"), s.timeout ? " timeout " + fmtSeconds(s.timeout) : "", " ",
|
{ label: "Entries", render: (s) => s.dynamic ? [badge("dynamic", "accent"), s.timeout ? " timeout " + fmtSeconds(s.timeout) : "", " ",
|
||||||
h("button", { class: "small", onclick: () => entries(s) }, "Entries…")] : h("span", { class: "mono small" }, s.entries.join(", ")) },
|
s.pending ? null : h("button", { class: "small", onclick: () => entries(s) }, "Entries…")] : h("span", { class: "mono small" }, s.entries.join(", ")) },
|
||||||
{ label: "Comment", render: (s) => s.comment },
|
{ label: "Comment", render: (s) => s.comment },
|
||||||
write ? { label: "", tdClass: "actions-cell", render: (s) => [
|
write ? { label: "", tdClass: "actions-cell", render: (s) => s.pending === "deleted" ? badge("deleted", "err") : [
|
||||||
h("button", { class: "small reveal", onclick: () => setDialog(s) }, "Edit"), " ",
|
h("button", { class: "small reveal", onclick: () => setDialog(s) }, "Edit"), " ",
|
||||||
h("button", { class: "small reveal", onclick: () => del(s) }, "Delete")] } : null,
|
h("button", { class: "small reveal", onclick: () => del(s) }, "Delete")] } : null,
|
||||||
].filter(Boolean), sets, { empty: "No sets yet." })));
|
].filter(Boolean), sets, { empty: "No sets yet.", rowClass })));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function del(s) {
|
async function del(s) {
|
||||||
@@ -30,20 +30,30 @@ async function del(s) {
|
|||||||
async function setDialog(s) {
|
async function setDialog(s) {
|
||||||
s = s || { entries: [] };
|
s = s || { entries: [] };
|
||||||
const name = input({ value: s.name || "", placeholder: "trusted" });
|
const name = input({ value: s.name || "", placeholder: "trusted" });
|
||||||
const list = listEditor(s.entries, { placeholder: "10.0.10.0/24, 2001:db8:10::/64, 10.0.0.1-10.0.0.9", addLabel: "Add entry" });
|
const list = listEditor(s.entries, { placeholder: "192.168.1.0/24, 2001:db8:1::/64, 192.168.1.100-192.168.1.199", addLabel: "Add entry" });
|
||||||
const dynamic = checkbox("dynamic: entries are added at run time", s.dynamic);
|
const dynamic = checkbox("dynamic: entries are added at run time (the Entries button, hc set entries, a program with a token), each for a time", s.dynamic);
|
||||||
|
// A dynamic set has no configured entries; only it has a timeout.
|
||||||
|
const entryFields = field("Entries", list).filter(Boolean);
|
||||||
|
const timeoutFields = field("Entry timeout", timeout, "How long an entry stays unless it is added with its own. Empty: until removed (or a reboot).").filter(Boolean);
|
||||||
|
const showKind = () => {
|
||||||
|
for (const n of entryFields) n.classList.toggle("hidden", dynamic.input.checked);
|
||||||
|
for (const n of timeoutFields) n.classList.toggle("hidden", !dynamic.input.checked);
|
||||||
|
};
|
||||||
|
dynamic.input.addEventListener("change", showKind);
|
||||||
|
showKind();
|
||||||
const timeout = input({ value: s.timeout ? fmtSeconds(s.timeout) : "", placeholder: "1h, 1d (none: until removed)", class: "short" });
|
const timeout = input({ value: s.timeout ? fmtSeconds(s.timeout) : "", placeholder: "1h, 1d (none: until removed)", class: "short" });
|
||||||
const comment = input({ value: s.comment || "" });
|
const comment = input({ value: s.comment || "" });
|
||||||
const err = h("div");
|
const err = h("div");
|
||||||
await modal(s.id ? "Edit set " + s.name : "Add set", h("div", null, h("div", { class: "form-grid" },
|
await modal(s.id ? "Edit set " + s.name : "Add set", h("div", null, h("div", { class: "form-grid" },
|
||||||
field("Name", name), field("Entries", list), h("label"), dynamic, field("Entry timeout", timeout, "A dynamic set's default."), field("Comment", comment)), err), [
|
field("Name", name), ...entryFields, h("label"), dynamic, ...timeoutFields, field("Comment", comment)), err), [
|
||||||
{ label: "Cancel", value: null },
|
{ label: "Cancel", value: null },
|
||||||
{ label: s.id ? "Save" : "Add set", kind: "primary", onclick: async () => {
|
{ label: s.id ? "Save" : "Add set", kind: "primary", onclick: async () => {
|
||||||
mount(err);
|
mount(err);
|
||||||
const t = parseSeconds(timeout.value);
|
const t = parseSeconds(timeout.value);
|
||||||
if (t === null) { mount(err, h("div", { class: "msg err" }, "Write the timeout as 1h, 30m or 1d.")); return false; }
|
if (t === null) { mount(err, h("div", { class: "msg err" }, "Write the timeout as 1h, 30m or 1d.")); return false; }
|
||||||
try {
|
try {
|
||||||
await change(s.id ? "PATCH" : "POST", s.id ? "/sets/" + s.id : "/sets", { name: name.value.trim(), entries: list.value(), dynamic: dynamic.input.checked, timeout: t, comment: comment.value.trim() });
|
await change(s.id ? "PATCH" : "POST", s.id ? "/sets/" + s.id : "/sets", { name: name.value.trim(), entries: dynamic.input.checked ? [] : list.value(), dynamic: dynamic.input.checked,
|
||||||
|
timeout: dynamic.input.checked ? t : 0, comment: comment.value.trim() });
|
||||||
rerender();
|
rerender();
|
||||||
return true;
|
return true;
|
||||||
} catch (e) { mount(err, errorBox(e)); return false; }
|
} catch (e) { mount(err, errorBox(e)); return false; }
|
||||||
@@ -57,7 +67,7 @@ async function entries(s) {
|
|||||||
const write = can("sets", true);
|
const write = can("sets", true);
|
||||||
const draw = async () => {
|
const draw = async () => {
|
||||||
const es = await get("/sets/" + encodeURIComponent(s.name) + "/elements");
|
const es = await get("/sets/" + encodeURIComponent(s.name) + "/elements");
|
||||||
const addr = input({ placeholder: "10.0.30.5 or 2001:db8::5", class: "mono" });
|
const addr = input({ placeholder: "192.168.1.50 or 2001:db8:1::50", class: "mono" });
|
||||||
const t = input({ placeholder: s.timeout ? fmtSeconds(s.timeout) : "none", class: "short" });
|
const t = input({ placeholder: s.timeout ? fmtSeconds(s.timeout) : "none", class: "short" });
|
||||||
mount(body,
|
mount(body,
|
||||||
table([
|
table([
|
||||||
|
|||||||
@@ -22,11 +22,12 @@ export async function render(el) {
|
|||||||
const input_ = pol(s.input_policy), forward = pol(s.forward_policy), output = pol(s.output_policy);
|
const input_ = pol(s.input_policy), forward = pol(s.forward_policy), output = pol(s.output_policy);
|
||||||
const logDrops = checkbox("log what the input and forward policies drop (rate-limited; the Log page)", s.log_policy_drops);
|
const logDrops = checkbox("log what the input and forward policies drop (rate-limited; the Log page)", s.log_policy_drops);
|
||||||
const bis = builtinText.map(([k, text]) => [k, checkbox(text, s.builtins[k])]);
|
const bis = builtinText.map(([k, text]) => [k, checkbox(text, s.builtins[k])]);
|
||||||
const own = listEditor(s.own_prefixes, { placeholder: "2001:db8:7f3d::/48", addLabel: "Add prefix" });
|
const own = listEditor(s.own_prefixes, { placeholder: "2001:db8::/48", addLabel: "Add prefix" });
|
||||||
const v4 = listEditor(s.antispoof_v4, { addLabel: "Add" });
|
const v4 = listEditor(s.antispoof_v4, { addLabel: "Add" });
|
||||||
const v6 = listEditor(s.antispoof_v6, { addLabel: "Add" });
|
const v6 = listEditor(s.antispoof_v6, { addLabel: "Add" });
|
||||||
const flow = listEditor(s.flowtable_devices, { suggestions: ifs.map((i) => i.name), placeholder: "wan", addLabel: "Add device" });
|
const flow = listEditor(s.flowtable_devices, { suggestions: ifs.map((i) => i.name), placeholder: "wan", addLabel: "Add device" });
|
||||||
const probation = input({ value: fmtSeconds(s.probation_seconds || 120), class: "short" });
|
const probation = input({ value: fmtSeconds(s.probation_seconds || 120), class: "short" });
|
||||||
|
const manual = checkbox("a person confirms each change (otherwise the browser or hc that made it confirms it by itself, once it reaches Housecarl again over a new connection)", s.manual_confirm);
|
||||||
const err = h("div");
|
const err = h("div");
|
||||||
const save = async (dry) => {
|
const save = async (dry) => {
|
||||||
mount(err);
|
mount(err);
|
||||||
@@ -37,8 +38,8 @@ export async function render(el) {
|
|||||||
try {
|
try {
|
||||||
const r = await change("PATCH", "/settings", { input_policy: input_.value, forward_policy: forward.value, output_policy: output.value,
|
const r = await change("PATCH", "/settings", { input_policy: input_.value, forward_policy: forward.value, output_policy: output.value,
|
||||||
log_policy_drops: logDrops.input.checked, builtins, own_prefixes: own.value(), antispoof_v4: v4.value(), antispoof_v6: v6.value(),
|
log_policy_drops: logDrops.input.checked, builtins, own_prefixes: own.value(), antispoof_v4: v4.value(), antispoof_v6: v6.value(),
|
||||||
flowtable_devices: flow.value(), probation_seconds: p === 120 ? 0 : p }, { query: dry ? { dry_run: 1 } : undefined });
|
flowtable_devices: flow.value(), probation_seconds: p === 120 ? 0 : p, manual_confirm: manual.input.checked }, { query: dry ? { dry_run: 1 } : undefined });
|
||||||
if (!dry && r.applied) rerender();
|
if (!dry && (r.applied || r.staged)) rerender();
|
||||||
} catch (e) { mount(err, errorBox(e)); }
|
} catch (e) { mount(err, errorBox(e)); }
|
||||||
};
|
};
|
||||||
mount(el,
|
mount(el,
|
||||||
@@ -54,7 +55,8 @@ export async function render(el) {
|
|||||||
field("Antispoof IPv4", v4),
|
field("Antispoof IPv4", v4),
|
||||||
field("Antispoof IPv6", v6),
|
field("Antispoof IPv6", v6),
|
||||||
field("Flowtable", flow, "Forwarded TCP and UDP between these devices skip the rules once established (fast path). Exact names; none: off."),
|
field("Flowtable", flow, "Forwarded TCP and UDP between these devices skip the rules once established (fast path). Exact names; none: off."),
|
||||||
field("Probation", probation, "How long a change made here has to be confirmed before it is rolled back.")),
|
field("Probation", probation, "How long a change made over the network has to be confirmed before it is rolled back."),
|
||||||
|
h("label"), manual),
|
||||||
err,
|
err,
|
||||||
write ? h("div", { class: "actions form-actions" },
|
write ? h("div", { class: "actions form-actions" },
|
||||||
h("button", { class: "primary", onclick: () => save(false) }, "Save"),
|
h("button", { class: "primary", onclick: () => save(false) }, "Save"),
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { h, mount, table, modal, errorBox, field, input, checkbox, confirmDialog, badge } from "../ui.js";
|
import { h, mount, table, modal, errorBox, field, input, checkbox, confirmDialog, badge } from "../ui.js";
|
||||||
import { get, change, can } from "../api.js";
|
import { get, change, can } from "../api.js";
|
||||||
import { rerender } from "../app.js";
|
import { rerender } from "../app.js";
|
||||||
import { listEditor } from "../widgets.js";
|
import { listEditor, rowClass } from "../widgets.js";
|
||||||
|
|
||||||
export async function render(el) {
|
export async function render(el) {
|
||||||
const [zones, ifs] = await Promise.all([get("/zones"), get("/interfaces")]);
|
const [zones, ifs] = await Promise.all([get("/zones"), get("/interfaces")]);
|
||||||
@@ -16,10 +16,10 @@ export async function render(el) {
|
|||||||
{ label: "Interfaces", render: (z) => h("span", { class: "mono" }, z.interfaces.join(", ")) },
|
{ label: "Interfaces", render: (z) => h("span", { class: "mono" }, z.interfaces.join(", ")) },
|
||||||
{ label: "", render: (z) => z.external ? badge("external", "warn") : badge("internal", "") },
|
{ label: "", render: (z) => z.external ? badge("external", "warn") : badge("internal", "") },
|
||||||
{ label: "Comment", render: (z) => z.comment },
|
{ label: "Comment", render: (z) => z.comment },
|
||||||
write ? { label: "", tdClass: "actions-cell", render: (z) => [
|
write ? { label: "", tdClass: "actions-cell", render: (z) => z.pending === "deleted" ? badge("deleted", "err") : [
|
||||||
h("button", { class: "small reveal", onclick: () => zoneDialog(z, ifs) }, "Edit"), " ",
|
h("button", { class: "small reveal", onclick: () => zoneDialog(z, ifs) }, "Edit"), " ",
|
||||||
h("button", { class: "small reveal", onclick: () => del(z) }, "Delete")] } : null,
|
h("button", { class: "small reveal", onclick: () => del(z) }, "Delete")] } : null,
|
||||||
].filter(Boolean), zones, { empty: "No zones yet: add one per network (wan, lan, guest…)." })),
|
].filter(Boolean), zones, { empty: "No zones yet: add one per network (wan, lan, guest…).", rowClass })),
|
||||||
h("div", { class: "panel" }, h("h2", null, "This machine's interfaces"), table([
|
h("div", { class: "panel" }, h("h2", null, "This machine's interfaces"), table([
|
||||||
{ label: "Interface", render: (i) => h("span", { class: "mono" }, i.name) },
|
{ label: "Interface", render: (i) => h("span", { class: "mono" }, i.name) },
|
||||||
{ label: "State", render: (i) => badge(i.up ? "up" : "down", i.up ? "ok" : "") },
|
{ label: "State", render: (i) => badge(i.up ? "up" : "down", i.up ? "ok" : "") },
|
||||||
|
|||||||
@@ -383,3 +383,32 @@ input.short { width: 7em; }
|
|||||||
.checks { display: flex; flex-direction: column; gap: 4px; }
|
.checks { display: flex; flex-direction: column; gap: 4px; }
|
||||||
.form-actions { margin-top: 14px; }
|
.form-actions { margin-top: 14px; }
|
||||||
tr.disabled td:not(.actions-cell) { opacity: 0.55; }
|
tr.disabled td:not(.actions-cell) { opacity: 0.55; }
|
||||||
|
|
||||||
|
/* Pending changes (as in Jarl): the bar, the Review/Live switch, rows. */
|
||||||
|
.pending-bar {
|
||||||
|
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
|
||||||
|
padding: 8px 24px; background: var(--warn-soft); border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.pending-bar strong { color: var(--warn); }
|
||||||
|
.pending-bar .apply-now { margin-left: auto; }
|
||||||
|
.mode-toggle { display: inline-flex; align-items: center; gap: 6px; padding: 3px 10px; margin-right: 10px; border-radius: 999px; font-size: 12px; font-weight: 500; background: transparent; color: var(--muted); border: 1px solid var(--border); }
|
||||||
|
.mode-toggle:hover { color: var(--text); border-color: var(--accent); }
|
||||||
|
.mode-toggle .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--muted); }
|
||||||
|
.mode-toggle.live { color: var(--warn); border-color: var(--warn); background: var(--warn-soft); }
|
||||||
|
.mode-toggle.live .dot { background: var(--warn); }
|
||||||
|
tr.pending-new td { background: var(--add); }
|
||||||
|
tr.pending-changed td { background: var(--warn-soft); }
|
||||||
|
tr.pending-deleted td { background: var(--del); color: var(--muted); }
|
||||||
|
tr.pending-deleted td:not(.actions-cell) { text-decoration: line-through; }
|
||||||
|
tr.op-failed td { background: var(--err-soft); }
|
||||||
|
input.wide { width: 100%; }
|
||||||
|
.diff { background: var(--code-bg); border-radius: var(--radius); overflow: auto; font-family: var(--mono); font-size: 12.5px; line-height: 1.4; }
|
||||||
|
.diff div { white-space: pre; padding: 0 10px; }
|
||||||
|
.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 .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. */
|
||||||
|
.adders { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||||
|
button.close-section { padding: 0 6px; margin-left: 4px; font-size: 11px; line-height: 1.5; }
|
||||||
@@ -191,6 +191,21 @@ export function problemsList(problems) {
|
|||||||
h("li", { class: p.severity }, h("strong", null, p.severity + ": "), p.message, p.object ? h("span", { class: "where" }, p.object) : null)));
|
h("li", { class: p.severity }, h("strong", null, p.severity + ": "), p.message, p.object ? h("span", { class: "where" }, p.object) : null)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// diffView shows a unified diff, lines colored.
|
||||||
|
export function diffView(text) {
|
||||||
|
if (!text) return h("div", { class: "muted" }, "The ruleset doesn't change.");
|
||||||
|
const box = h("div", { class: "diff" });
|
||||||
|
for (const line of text.replace(/\n$/, "").split("\n")) {
|
||||||
|
let cls = "";
|
||||||
|
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";
|
||||||
|
box.append(h("div", { class: cls }, line || " "));
|
||||||
|
}
|
||||||
|
return box;
|
||||||
|
}
|
||||||
|
|
||||||
// errorBox shows an API error with its problems.
|
// errorBox shows an API error with its problems.
|
||||||
export function errorBox(err) {
|
export function errorBox(err) {
|
||||||
if (!err) return null;
|
if (!err) return null;
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ export function listEditor(values, opts = {}) {
|
|||||||
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, 10.0.10.0/24, 2001:db8::/64, 10.0.0.1-10.0.0.9", 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 = zones(m.in_zones), outZ = zones(m.out_zones);
|
||||||
const notIn = checkbox("any zone but these", m.not_in), notOut = checkbox("any zone but these", m.not_out);
|
const notIn = checkbox("any zone but these", m.not_in), notOut = checkbox("any zone but these", m.not_out);
|
||||||
const src = addrs(m.sources), dst = addrs(m.destinations);
|
const src = addrs(m.sources), dst = addrs(m.destinations);
|
||||||
@@ -132,3 +132,36 @@ export function hits(counters, ...marks) {
|
|||||||
export async function counters() {
|
export async function counters() {
|
||||||
try { return (await get("/counters")).counters; } catch { return {}; }
|
try { return (await get("/counters")).counters; } catch { return {}; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- pending changes
|
||||||
|
|
||||||
|
// rowClass marks a table row: pending (new, changed, deleted, while
|
||||||
|
// changes are collected for review) and disabled.
|
||||||
|
export function rowClass(x) {
|
||||||
|
return [x.pending ? "pending-" + x.pending : "", x.disabled ? "disabled" : ""].filter(Boolean).join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
// live drops what the pending changes delete (for picking neighbours).
|
||||||
|
export function live(list) {
|
||||||
|
return list.filter((x) => x.pending !== "deleted");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- bytes
|
||||||
|
|
||||||
|
// fmtBytes writes bytes for people: "1.8 GB".
|
||||||
|
export function fmtBytes(n) {
|
||||||
|
if (n >= 1 << 30) return (n / (1 << 30)).toFixed(1) + " GB";
|
||||||
|
if (n >= 1 << 20) return (n / (1 << 20)).toFixed(1) + " MB";
|
||||||
|
if (n >= 1 << 10) return (n / (1 << 10)).toFixed(1) + " KB";
|
||||||
|
return n + " bytes";
|
||||||
|
}
|
||||||
|
|
||||||
|
// quotas loads what the rules' quotas used, by rule ID ({} without
|
||||||
|
// log:read).
|
||||||
|
export async function quotas() {
|
||||||
|
try {
|
||||||
|
const out = {};
|
||||||
|
for (const q of await get("/quotas")) out[q.rule_id] = q;
|
||||||
|
return out;
|
||||||
|
} catch { return {}; }
|
||||||
|
}
|
||||||
+88
-2
@@ -172,11 +172,11 @@ run "probation of 10 seconds" 0 H settings set --probation 10s
|
|||||||
TOKEN=$(R /w/housecarld -c /w/housecarl.yaml user token admin e2e 2>/dev/null | tail -1)
|
TOKEN=$(R /w/housecarld -c /w/housecarl.yaml user token admin e2e 2>/dev/null | tail -1)
|
||||||
C() { docker exec ${P}-client /w/hc --config /w/cli.yaml --server http://$LAN4:8070 --token "$TOKEN" "$@"; }
|
C() { docker exec ${P}-client /w/hc --config /w/cli.yaml --server http://$LAN4:8070 --token "$TOKEN" "$@"; }
|
||||||
run "the API from the LAN" 0 C whoami
|
run "the API from the LAN" 0 C whoami
|
||||||
run "a change over the network is on probation" 0 C zone add guests --interface guest0
|
run "a change over the network is on probation" 0 C --no-confirm zone add guests --interface guest0
|
||||||
contains "applied on probation"
|
contains "applied on probation"
|
||||||
run "confirmed" 0 C confirm
|
run "confirmed" 0 C confirm
|
||||||
contains "confirmed 1 change;"
|
contains "confirmed 1 change;"
|
||||||
run "a change that cuts the client off" 0 C rule disable "$APIRULE"
|
run "a change that cuts the client off" 0 C --no-confirm rule disable "$APIRULE"
|
||||||
contains "on probation"
|
contains "on probation"
|
||||||
run "the client is cut off (a timeout)" 124 docker exec ${P}-client timeout 3 /w/hc --config /w/cli.yaml --server http://$LAN4:8070 --token "$TOKEN" whoami
|
run "the client is cut off (a timeout)" 124 docker exec ${P}-client timeout 3 /w/hc --config /w/cli.yaml --server http://$LAN4:8070 --token "$TOKEN" whoami
|
||||||
sleep 12
|
sleep 12
|
||||||
@@ -185,6 +185,92 @@ run "the rule is enabled again" 0 H rule show "$APIRULE"
|
|||||||
not_contains "disabled"
|
not_contains "disabled"
|
||||||
run "the rollback is in the history" 0 H history list
|
run "the rollback is in the history" 0 H history list
|
||||||
contains "rollback"
|
contains "rollback"
|
||||||
|
# Confirmed by the client itself: it reached the API over a new connection.
|
||||||
|
run "a change confirmed by its client" 0 C zone add guests2 --interface guest2
|
||||||
|
contains "applied and confirmed"
|
||||||
|
run "nothing waits" 0 C status
|
||||||
|
not_contains "Probation:"
|
||||||
|
run "the confirmation is audited" 0 H audit --action config.confirm
|
||||||
|
contains "confirmed automatically"
|
||||||
|
# A change that cuts its client off can't be confirmed by it: rolled back.
|
||||||
|
run "a lockout change, not confirmed" 0 C rule disable "$APIRULE"
|
||||||
|
contains "couldn't confirm it"
|
||||||
|
sleep 3
|
||||||
|
run "rolled back: the client is back" 0 C whoami
|
||||||
|
run "the rule is enabled again" 0 H rule show "$APIRULE"
|
||||||
|
not_contains "disabled"
|
||||||
|
run "a person must confirm, by setting" 0 H settings set --auto-confirm off
|
||||||
|
run "then a change waits" 0 C zone add guests3 --interface guest3
|
||||||
|
contains "applied on probation"
|
||||||
|
run "confirmed by a person" 0 C confirm
|
||||||
|
run "back to automatic" 0 H settings set --auto-confirm on
|
||||||
|
|
||||||
|
# ---- changesets: staged, reviewed, applied together
|
||||||
|
|
||||||
|
CS=$(C changeset create "e2e batch" -q)
|
||||||
|
run "a changeset" 0 test -n "$CS"
|
||||||
|
CE() { docker exec -e HOUSECARL_CHANGESET=$CS ${P}-client /w/hc --config /w/cli.yaml --server http://$LAN4:8070 --token "$TOKEN" "$@"; }
|
||||||
|
run "staged from HOUSECARL_CHANGESET" 0 CE set add e2e_block 198.51.100.7
|
||||||
|
contains "staged in changeset $CS as operation 1"
|
||||||
|
contains "from HOUSECARL_CHANGESET"
|
||||||
|
run "a rule naming the staged set" 0 CE rule add input --from e2e_block --service ssh -a drop
|
||||||
|
contains "operation 2"
|
||||||
|
run "disabling the jellyfin forward, staged" 0 CE forward update jellyfin --disabled
|
||||||
|
contains "operation 3"
|
||||||
|
run "nothing is live yet: the forward still works" 0 connect net $WAN4 8096
|
||||||
|
contains "^jellyfin$"
|
||||||
|
run "the set isn't there yet" 4 H set show e2e_block
|
||||||
|
run "the changeset's diff" 0 CE changeset diff
|
||||||
|
contains "^+.*ip saddr @e2e_block_v4 tcp dport 22 counter drop"
|
||||||
|
contains "^-.*dnat ip to 10.88.10.57:443"
|
||||||
|
contains "valid: nftables accepts"
|
||||||
|
run "remove the forward operation" 0 CE changeset remove 3
|
||||||
|
contains "has 2 operations"
|
||||||
|
run "the diff without it" 0 CE changeset diff
|
||||||
|
not_contains "dnat ip to 10.88.10.57"
|
||||||
|
run "applied together, and confirmed by this client" 0 CE changeset apply
|
||||||
|
contains "applied and confirmed"
|
||||||
|
contains "HOUSECARL_CHANGESET still names changeset $CS"
|
||||||
|
run "one history entry" 0 H history list
|
||||||
|
contains "e2e batch"
|
||||||
|
run "confirmed" 0 C confirm
|
||||||
|
run "the set is there now" 0 H set show e2e_block
|
||||||
|
run "the forward still works" 0 connect net $WAN4 8096
|
||||||
|
contains "^jellyfin$"
|
||||||
|
run "a stale HOUSECARL_CHANGESET is refused" 1 CE zone add stale -i stale0
|
||||||
|
contains "is applied"
|
||||||
|
CS2=$(H changeset create -q)
|
||||||
|
UNDO=$(H zone add undome -i undo0 | sed -n 's/.*history \([0-9]*\);.*/\1/p')
|
||||||
|
run "an undo is staged" 0 H -C "$CS2" history undo "$UNDO"
|
||||||
|
contains "staged in changeset $CS2"
|
||||||
|
run "not applied yet: the zone is there" 0 H zone show undome
|
||||||
|
run "an import can't be staged" 6 sh -c "echo '{\"nftables\":[]}' | docker exec -i ${P}-router /w/hc -C $CS2 import -"
|
||||||
|
contains "can't be staged"
|
||||||
|
run "discarded" 0 H changeset discard "$CS2"
|
||||||
|
run "the zone stays" 0 H zone show undome
|
||||||
|
run "a dry run shows the ruleset's diff" 0 H -n set add e2e_dry 10.1.2.3
|
||||||
|
contains "^+.*set e2e_dry_v4"
|
||||||
|
|
||||||
|
# ---- quotas: an allowance per device, kept across a restart, reset
|
||||||
|
|
||||||
|
QR=$(H -o id rule add forward --from $CLIENT4 --service tcp/9000 --quota 1mbytes,over,per-source -a drop --first --comment "e2e quota")
|
||||||
|
run "a per-device quota" 0 test -n "$QR"
|
||||||
|
send() { docker exec ${P}-client sh -c "head -c $1 /dev/zero | nc -N -w 3 $NET4 9000"; }
|
||||||
|
run "within the quota" 0 send 300000
|
||||||
|
send 2000000 >/dev/null 2>&1
|
||||||
|
run "used up: the device is cut off" 1 send 1000
|
||||||
|
run "the usage" 0 H quotas
|
||||||
|
contains "e2e quota.*1.0 MB each.*by hand"
|
||||||
|
contains "$CLIENT4 *1\.[0-9] MB"
|
||||||
|
docker exec ${P}-router sh -c 'kill $(pidof housecarld)'; sleep 1; start
|
||||||
|
run "after a restart, still used up" 1 send 1000
|
||||||
|
run "the usage after a restart" 0 H quotas
|
||||||
|
contains "$CLIENT4 *1\.[0-9] MB"
|
||||||
|
run "reset by hand" 0 H rule reset-quota "$QR"
|
||||||
|
run "the device is back" 0 send 1000
|
||||||
|
run "the reset is audited" 0 H audit --action quota.
|
||||||
|
contains "quota.reset"
|
||||||
|
run "the quota rule goes" 0 H rule delete -y "$QR"
|
||||||
|
|
||||||
# ---- the drop log
|
# ---- the drop log
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user