Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9cbb3e4e9 | ||
|
|
a45c893830 | ||
|
|
98e11a8bec | ||
|
|
9a165cf70b | ||
|
|
d25e12995e | ||
|
|
e4efdf9c78 | ||
|
|
fc390eb57c | ||
|
|
09504e5555 | ||
|
|
2880f7ddc9 | ||
|
|
ebec2cfcca |
No files matched your search
+40
-3
@@ -30,19 +30,52 @@ the way Jarl and Steward are.
|
||||
invalid packets, ICMPv6 (RFC 4890), ping from internal zones, DHCPv6
|
||||
client replies, antispoofing with your own prefixes, MSS clamping, and
|
||||
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
|
||||
whole configuration with stable problem codes (docs/validation.md),
|
||||
then `nft -c`, then one `nft -f` transaction that replaces Housecarl's
|
||||
own table, `inet housecarl`, and nothing else. Dynamic sets' entries
|
||||
and the counters carry across.
|
||||
- **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
|
||||
back what was pending. No rule is forced to keep SSH or the web UI
|
||||
open.
|
||||
- **At boot:** the confirmed ruleset is loaded by
|
||||
`housecarl-ruleset.service` before the network comes up, without
|
||||
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.
|
||||
- **Connections and tracing:** the connections the kernel tracks, with
|
||||
their NAT, and closing them so the rules check them again (Traffic →
|
||||
Connections, `hc connections`); a rule option, "every packet", for
|
||||
blocks that stop connections already open; and a packet tracer
|
||||
(Traffic → Trace, `hc trace`) that follows packets through every
|
||||
table's rules and names what accepted or dropped them.
|
||||
- **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
|
||||
netlink log group 4171 (`hc log -f`, the Log page).
|
||||
@@ -52,10 +85,14 @@ the way Jarl and Steward are.
|
||||
- **Import** of an existing nftables ruleset (`hc import`): rules keep
|
||||
their order, defines name the sets, DNAT becomes port forwards, what
|
||||
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
|
||||
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
|
||||
Let's Encrypt through Jarl, as in Steward.
|
||||
- **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`,
|
||||
`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
|
||||
zone, address, service, connection state, time of day, rate per
|
||||
source, or connection count. Anything else can be written as an
|
||||
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
|
||||
nftables rules each family needs.
|
||||
* **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
|
||||
DHCPv6 client, and a flowtable fast path.
|
||||
* **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,
|
||||
it rolls itself back, so a rule that cuts you off undoes itself.
|
||||
* **Your existing ruleset, imported.** `hc import` turns an nftables
|
||||
@@ -44,7 +49,9 @@ table of its own.
|
||||
rules' order, and keeps what it can't translate as raw rules
|
||||
([docs/import.md](docs/import.md)).
|
||||
* **Easy to watch.** Hits per rule, a live log of what was dropped and
|
||||
why, and the history of every change, field by field, with undo.
|
||||
why, the connections the firewall tracks (and closing them), a packet
|
||||
tracer that names the rule that dropped something, in any table, and
|
||||
the history of every change, field by field, with undo.
|
||||
* **Its own table only.** Docker, Incus and libvirt keep theirs. The
|
||||
confirmed ruleset is loaded at boot, before the network comes up, even
|
||||
if housecarld doesn't start.
|
||||
@@ -75,10 +82,10 @@ sudo hc takeover "inet main"
|
||||
sudo systemctl disable nftables.service
|
||||
|
||||
# ...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 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 snat add --out wan
|
||||
sudo hc status
|
||||
@@ -106,11 +113,13 @@ The web UI is on `http://127.0.0.1:8070/` (see `listen` in
|
||||
|
||||
## 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.5): zones, address sets and
|
||||
services with ordered rules, IPv4 and IPv6, port forwards with hairpin NAT
|
||||
and IPv6 pinholes, source NAT and redirects, built-ins, probation for
|
||||
network changes, the ruleset at boot, hits and the drop log, nftables
|
||||
import, a web UI, REST API and CLI.
|
||||
network changes (confirmed by the client that made them), the ruleset
|
||||
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
|
||||
|
||||
|
||||
@@ -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, "Flowtable:\t%s\n", list(s.FlowtableDevices))
|
||||
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
|
||||
set := &cobra.Command{
|
||||
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,
|
||||
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 --flowtable wan --flowtable enp2s0`,
|
||||
hc settings set --flowtable wan --flowtable eth1`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
fl := cmd.Flags()
|
||||
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
|
||||
}
|
||||
}
|
||||
if fl.Changed("auto-confirm") {
|
||||
on, err := onOff("auto-confirm", autoConf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p["manual_confirm"] = !on
|
||||
}
|
||||
if fl.Changed("probation") {
|
||||
n, err := parseSeconds(probation)
|
||||
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(&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(&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)
|
||||
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 {
|
||||
var follow bool
|
||||
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'
|
||||
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
|
||||
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.
|
||||
Once it looks right, import without -n, check it with 'hc ruleset', and
|
||||
'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
|
||||
}
|
||||
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
|
||||
},
|
||||
}
|
||||
|
||||
+63
-17
@@ -37,14 +37,18 @@ func main() {
|
||||
|
||||
// Global flags.
|
||||
var (
|
||||
flagServer string
|
||||
flagToken string
|
||||
flagConfig string
|
||||
flagDryRun bool
|
||||
flagOutput string
|
||||
flagInsecure bool
|
||||
flagCACert string
|
||||
flagQuiet bool
|
||||
flagServer string
|
||||
flagToken string
|
||||
flagConfig string
|
||||
flagDryRun bool
|
||||
flagOutput string
|
||||
flagInsecure bool
|
||||
flagCACert string
|
||||
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.
|
||||
@@ -132,6 +136,10 @@ func newClient() (*client.Client, error) {
|
||||
return nil, err
|
||||
}
|
||||
c.DryRun = flagDryRun
|
||||
if err := setupChangeset(c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lastClient = c
|
||||
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
|
||||
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
|
||||
confirm' (within 2 minutes by default) or it is rolled back by itself, so a
|
||||
change that cuts you off undoes itself. On the firewall itself, root uses
|
||||
A change made over the network is on probation: hc confirms it by itself
|
||||
once it reaches Housecarl again over a new connection, and a change that
|
||||
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
|
||||
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.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.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:"})
|
||||
add := func(group string, cmds ...*cobra.Command) {
|
||||
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(),
|
||||
rulesetCmd(), countersCmd(), logCmd(), interfaceCmd())
|
||||
add("changes", confirmCmd(), rollbackCmd(), historyCmd(), importCmd(), takeoverCmd())
|
||||
rulesetCmd(), countersCmd(), quotasCmd(), logCmd(), connectionsCmd(), traceCmd(), interfaceCmd())
|
||||
add("changes", changesetCmd(), confirmCmd(), rollbackCmd(), historyCmd(), importCmd(), takeoverCmd())
|
||||
add("admin", whoamiCmd(), userCmd(), tokenCmd(), passwordCmd(), certCmd(), auditCmd(), configCmd())
|
||||
return root
|
||||
}
|
||||
@@ -237,8 +251,6 @@ func outputIDs(v any) error {
|
||||
if m, ok := it.(map[string]any); ok {
|
||||
if id, ok := m["id"]; ok {
|
||||
fmt.Println(id)
|
||||
} else if ip, ok := m["ip"]; ok {
|
||||
fmt.Println(ip)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -294,6 +306,19 @@ func exitCode(err error) int {
|
||||
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.
|
||||
func report(resp *api.MutationResponse) error {
|
||||
switch flagOutput {
|
||||
@@ -310,6 +335,9 @@ func report(resp *api.MutationResponse) error {
|
||||
if resp.DryRun {
|
||||
return "would " + c.Action()
|
||||
}
|
||||
if resp.Staged {
|
||||
return "staged: " + c.Action()
|
||||
}
|
||||
return c.Action() + "d"
|
||||
}
|
||||
if !flagQuiet || resp.DryRun {
|
||||
@@ -322,8 +350,26 @@ func report(resp *api.MutationResponse) error {
|
||||
}
|
||||
}
|
||||
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:
|
||||
if resp.Diff != "" && !flagQuiet {
|
||||
fmt.Fprintln(w)
|
||||
printDiff(w, resp.Diff)
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
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:
|
||||
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))
|
||||
@@ -332,7 +378,7 @@ func report(resp *api.MutationResponse) error {
|
||||
case !resp.Applied && len(resp.Changes) == 0 && !flagQuiet:
|
||||
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)
|
||||
}
|
||||
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.
|
||||
|
||||
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 {
|
||||
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),
|
||||
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
|
||||
|
||||
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),
|
||||
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 pmtu icmp/destination-unreachable icmpv6/packet-too-big`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
|
||||
+176
-21
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -96,9 +97,9 @@ type ruleFlags struct {
|
||||
matchFlags
|
||||
action, rejectWith, comment, logPrefix, raw string
|
||||
states, statuses, days []string
|
||||
hours, limit string
|
||||
connLimit int
|
||||
log, disabled bool
|
||||
hours, limit, quota string
|
||||
connLimit, groupV4, groupV6 int
|
||||
log, disabled, everyPacket bool
|
||||
before, after int64
|
||||
first bool
|
||||
}
|
||||
@@ -111,13 +112,37 @@ func (r *ruleFlags) register(f *pflag.FlagSet) {
|
||||
f.StringSliceVar(&r.statuses, "status", nil, "connection status: dnat, snat")
|
||||
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.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.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.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.comment, "comment", "", "a comment")
|
||||
f.BoolVar(&r.disabled, "disabled", false, "keep the rule but don't use it")
|
||||
f.BoolVar(&r.everyPacket, "every-packet", false, "check every packet, so a drop or reject also stops connections already open")
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -126,32 +151,101 @@ func parseLimit(s string) (*model.Limit, error) {
|
||||
}
|
||||
parts := strings.Split(s, ",")
|
||||
rate, per, ok := strings.Cut(strings.TrimSpace(parts[0]), "/")
|
||||
n, err := strconv.Atoi(rate)
|
||||
if !ok || err != nil {
|
||||
return nil, fmt.Errorf("--limit %q: write it as 10/minute, with ,burst=N ,over ,per-source", s)
|
||||
m := amountRe.FindStringSubmatch(strings.TrimSpace(rate))
|
||||
unit, unitOK := "", false
|
||||
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:] {
|
||||
k, v, _ := strings.Cut(strings.TrimSpace(p), "=")
|
||||
switch k {
|
||||
case "burst":
|
||||
if l.Burst, err = strconv.Atoi(v); err != nil {
|
||||
return nil, fmt.Errorf("--limit: burst=%q isn't a number", v)
|
||||
b := amountRe.FindStringSubmatch(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":
|
||||
l.Over = true
|
||||
case "per-source":
|
||||
l.PerSource = true
|
||||
case "per-destination":
|
||||
l.PerDestination = true
|
||||
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
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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, EveryPacket: r.everyPacket, Log: r.log, LogPrefix: r.logPrefix, Raw: r.raw, Comment: r.comment, Disabled: r.disabled}
|
||||
if r.hours != "" {
|
||||
from, to, ok := strings.Cut(r.hours, "-")
|
||||
if !ok {
|
||||
@@ -160,7 +254,12 @@ func (r *ruleFlags) rule(chain string) (model.Rule, error) {
|
||||
rule.HourFrom, rule.HourTo = from, to
|
||||
}
|
||||
l, err := parseLimit(r.limit)
|
||||
if err != nil {
|
||||
return rule, err
|
||||
}
|
||||
rule.Limit = l
|
||||
rule.Quota, err = parseQuota(r.quota)
|
||||
rule.GroupV4, rule.GroupV6 = r.groupV4, r.groupV6
|
||||
return rule, err
|
||||
}
|
||||
|
||||
@@ -187,6 +286,9 @@ func ruleCmd() *cobra.Command {
|
||||
}
|
||||
extra := ruleExtras(r)
|
||||
action := r.Action
|
||||
if r.Raw != "" {
|
||||
action = "-" // the text's own
|
||||
}
|
||||
if r.Log {
|
||||
action += "+log"
|
||||
}
|
||||
@@ -200,7 +302,11 @@ func ruleCmd() *cobra.Command {
|
||||
}
|
||||
cmd.AddCommand(ls)
|
||||
cmd.AddCommand(showCmd("rules", "rule", func(w io.Writer, r model.Rule) {
|
||||
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)
|
||||
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)
|
||||
} else {
|
||||
fmt.Fprintf(w, "ID:\t%d\nChain:\t%s\n", r.ID, r.Chain)
|
||||
}
|
||||
if r.Raw != "" {
|
||||
fmt.Fprintf(w, "Raw:\t%s\n", r.Raw)
|
||||
}
|
||||
@@ -221,7 +327,7 @@ output (from it) or early (before connection tracking: cheap drops).
|
||||
|
||||
hc rule add input --from trusted --service ssh
|
||||
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 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'`,
|
||||
@@ -277,7 +383,8 @@ 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},
|
||||
"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},
|
||||
"disabled": {"disabled", r.Disabled},
|
||||
"disabled": {"disabled", r.Disabled}, "group-v4": {"group_v4", r.GroupV4}, "group-v6": {"group_v6", r.GroupV6},
|
||||
"every-packet": {"every_packet", r.EveryPacket},
|
||||
} {
|
||||
if fl.Changed(flag) {
|
||||
p[field[0].(string)] = field[1]
|
||||
@@ -289,6 +396,9 @@ output (from it) or early (before connection tracking: cheap drops).
|
||||
if fl.Changed("limit") {
|
||||
p["limit"] = r.Limit
|
||||
}
|
||||
if fl.Changed("quota") {
|
||||
p["quota"] = r.Quota
|
||||
}
|
||||
return change("PATCH", objPath("rules", args[0]), p)
|
||||
},
|
||||
}
|
||||
@@ -316,6 +426,22 @@ output (from it) or early (before connection tracking: cheap drops).
|
||||
move.Flags().BoolVar(&last, "last", false, "last in its chain")
|
||||
move.Flags().StringVar(&chain, "chain", "", "to the end of another chain")
|
||||
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} {
|
||||
verb, short := "disable", "Keep a rule but don't use it"
|
||||
if on {
|
||||
@@ -345,18 +471,47 @@ func ruleExtras(r model.Rule) string {
|
||||
x = append(x, r.HourFrom+"-"+r.HourTo)
|
||||
}
|
||||
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 {
|
||||
s = "over " + s
|
||||
}
|
||||
if l.PerSource {
|
||||
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)
|
||||
}
|
||||
if r.ConnLimit > 0 {
|
||||
x = append(x, fmt.Sprintf("over %d connections", r.ConnLimit))
|
||||
}
|
||||
if r.EveryPacket {
|
||||
x = append(x, "every packet")
|
||||
}
|
||||
if len(x) == 0 {
|
||||
return ""
|
||||
}
|
||||
@@ -412,9 +567,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
|
||||
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 7777-7780 --proto tcp,udp --to 10.0.10.76 --public 217.155.8.194 --hairpin --name terraria
|
||||
hc forward add 25 --to 10.0.10.25 --from trusted`,
|
||||
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 27015-27030 --proto tcp,udp --to 192.168.1.20 --public 203.0.113.10 --hairpin --name game
|
||||
hc forward add 25 --to 192.168.1.25 --from trusted`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
pf.Ports = args[0]
|
||||
if to != "" {
|
||||
@@ -528,7 +683,7 @@ traffic leaving the firewall. IPv4 only, unless --family ipv6 (NAT66).
|
||||
|
||||
hc snat add --out wan --from trusted
|
||||
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 {
|
||||
return change("POST", "/snats", model.SourceNAT{Match: mf.match(), To: to, Comment: comment})
|
||||
},
|
||||
@@ -606,7 +761,7 @@ func redirectCmd() *cobra.Command {
|
||||
Long: `Sends matching traffic to a port on the firewall itself, so every client
|
||||
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 {
|
||||
return change("POST", "/redirects", model.Redirect{Match: mf.match(), ToPort: toPort, Comment: comment})
|
||||
},
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"housecarl/internal/api"
|
||||
)
|
||||
|
||||
// Traffic as it happens: connections the kernel tracks, and tracing
|
||||
// packets through the ruleset.
|
||||
|
||||
func connectionsCmd() *cobra.Command {
|
||||
var search string
|
||||
var limit int
|
||||
cmd := &cobra.Command{
|
||||
Use: "connections", Aliases: []string{"conns"}, Short: "The connections the firewall tracks, and closing them",
|
||||
Long: `Lists the connections the kernel tracks: what the "established" built-in
|
||||
lets through without checking the rules again. A rule added later doesn't
|
||||
stop them by itself; close them, and their next packet is checked by the
|
||||
rules as it stands.
|
||||
|
||||
hc connections --search 192.168.1.50
|
||||
hc connections --search ":443 tcp"
|
||||
hc connections close --search 192.168.1.50`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cs, err := get[[]api.Connection]("/connections", q("q", search, "limit", strconv.Itoa(limit)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return output(cs, func(w io.Writer) {
|
||||
fmt.Fprintln(w, "PROTO\tFROM\tTO\tNAT\tSTATE\tEXPIRES")
|
||||
for _, c := range cs {
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", c.Protocol, hostPort(c.Source, c.SourcePort), hostPort(c.Destination, c.DestinationPort), natText(c), c.State, fmtSeconds(c.Timeout))
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&search, "search", "", "only those with all these words: addresses, :port, protocol, state")
|
||||
cmd.Flags().IntVar(&limit, "limit", 500, "at most this many")
|
||||
var csearch string
|
||||
var yes bool
|
||||
closeCmd := &cobra.Command{
|
||||
Use: "close --search TEXT", Short: "Close connections: the rules check them again with their next packet", Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if strings.TrimSpace(csearch) == "" {
|
||||
return fmt.Errorf("say which: --search 192.168.1.50 (see 'hc connections --search' first)")
|
||||
}
|
||||
if !confirm("Close every connection matching "+strconvQuote(csearch)+"?", yes) {
|
||||
return fmt.Errorf("nothing closed")
|
||||
}
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var out map[string]int
|
||||
if err := c.Do(ctx(), "POST", "/connections/close", nil, api.CloseConnections{Search: csearch}, &out); err != nil {
|
||||
return err
|
||||
}
|
||||
if !flagQuiet {
|
||||
fmt.Printf("closed %s\n", plural(out["closed"], "connection"))
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
closeCmd.Flags().StringVar(&csearch, "search", "", "the connections to close, as for 'hc connections --search'")
|
||||
closeCmd.Flags().BoolVarP(&yes, "yes", "y", false, "don't ask")
|
||||
cmd.AddCommand(closeCmd)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func strconvQuote(s string) string { return strconv.Quote(s) }
|
||||
|
||||
func hostPort(addr string, port int) string {
|
||||
if port == 0 {
|
||||
return addr
|
||||
}
|
||||
if strings.Contains(addr, ":") {
|
||||
return fmt.Sprintf("[%s]:%d", addr, port)
|
||||
}
|
||||
return fmt.Sprintf("%s:%d", addr, port)
|
||||
}
|
||||
|
||||
// natText: "to 192.168.1.10:443" (a port forward), "as 203.0.113.10"
|
||||
// (masqueraded).
|
||||
func natText(c api.Connection) string {
|
||||
var parts []string
|
||||
if c.DNATTo != "" {
|
||||
port := c.DNATPort
|
||||
if port == 0 {
|
||||
port = c.DestinationPort
|
||||
}
|
||||
parts = append(parts, "to "+hostPort(c.DNATTo, port))
|
||||
}
|
||||
if c.SNATTo != "" {
|
||||
parts = append(parts, "as "+c.SNATTo)
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
func traceCmd() *cobra.Command {
|
||||
var in api.TraceInput
|
||||
cmd := &cobra.Command{
|
||||
Use: "trace", Short: "Follow packets through every table's rules: what each met, and what decided",
|
||||
Long: `Traces packets from or to an address, on a port (either way), through the
|
||||
whole ruleset, Housecarl's and every other table's, for a while: each
|
||||
rule and policy they meet, and what accepted or dropped them. Ctrl-C
|
||||
stops it.
|
||||
|
||||
hc trace --address 192.168.1.50 --port 443
|
||||
hc trace --port 22 --seconds 120
|
||||
hc trace --address 2001:db8:1::/64 --protocol icmpv6`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var st api.TraceStatus
|
||||
if err := c.Do(ctx(), "POST", "/trace", nil, in, &st); err != nil {
|
||||
return err
|
||||
}
|
||||
if !flagQuiet {
|
||||
fmt.Fprintf(os.Stderr, "tracing until %s (Ctrl-C stops)\n", st.Ends.Local().Format("15:04:05"))
|
||||
}
|
||||
sig, stop := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||
defer stop()
|
||||
shown := 0
|
||||
for {
|
||||
select {
|
||||
case <-sig.Done():
|
||||
c.Do(ctx(), "DELETE", "/trace", nil, nil, &st)
|
||||
case <-time.After(time.Second):
|
||||
if err := c.Do(ctx(), "GET", "/trace", nil, nil, &st); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for ; shown < len(st.Packets); shown++ {
|
||||
printPacket(os.Stdout, st.Packets[shown])
|
||||
}
|
||||
if !st.Running || sig.Err() != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if st.Error != "" {
|
||||
return fmt.Errorf("the trace failed: %s", st.Error)
|
||||
}
|
||||
if !flagQuiet {
|
||||
fmt.Fprintf(os.Stderr, "%d packets traced\n", len(st.Packets))
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
f := cmd.Flags()
|
||||
f.StringVar(&in.Address, "address", "", "packets from or to this address or prefix")
|
||||
f.IntVar(&in.Port, "port", 0, "packets from or to this port")
|
||||
f.StringVar(&in.Protocol, "protocol", "", "tcp, udp, sctp, icmp or icmpv6 (default any)")
|
||||
f.IntVar(&in.Seconds, "seconds", 60, "how long (5 to 600)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
// printPacket writes a traced packet: its headers, each step, its fate.
|
||||
func printPacket(w io.Writer, p api.TracePacket) {
|
||||
what := p.Protocol
|
||||
if p.TCPFlags != "" {
|
||||
what += " " + p.TCPFlags
|
||||
}
|
||||
if p.ICMPType != "" {
|
||||
what += " " + p.ICMPType
|
||||
}
|
||||
where := ""
|
||||
if p.In != "" {
|
||||
where += " in " + p.In
|
||||
}
|
||||
if p.Out != "" {
|
||||
where += " out " + p.Out
|
||||
}
|
||||
fmt.Fprintf(w, "%s %s -> %s %s%s\n", p.Time.Local().Format("15:04:05.000"), hostPort(p.Source, p.SourcePort), hostPort(p.Destination, p.DestinationPort), what, where)
|
||||
for _, s := range p.Steps {
|
||||
name := s.Label
|
||||
if name == "" {
|
||||
if s.Kind == "policy" {
|
||||
name = s.Chain + " policy"
|
||||
} else {
|
||||
name = s.Rule
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(w, " %s %s: %s -> %s\n", s.Table, s.Chain, name, s.Verdict)
|
||||
}
|
||||
decided := p.DecidedBy.Label
|
||||
if decided == "" {
|
||||
decided = strings.TrimSpace(p.DecidedBy.Table + " " + p.DecidedBy.Chain + " " + p.DecidedBy.Kind)
|
||||
}
|
||||
if decided == "" {
|
||||
fmt.Fprintf(w, " => %s\n", strings.ToUpper(p.Verdict))
|
||||
} else {
|
||||
fmt.Fprintf(w, " => %s by %s\n", strings.ToUpper(p.Verdict), decided)
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ package main
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -25,6 +26,7 @@ import (
|
||||
"housecarl/internal/auth"
|
||||
"housecarl/internal/certs"
|
||||
"housecarl/internal/config"
|
||||
"housecarl/internal/conntrack"
|
||||
"housecarl/internal/engine"
|
||||
"housecarl/internal/hostif"
|
||||
"housecarl/internal/jarl"
|
||||
@@ -34,6 +36,7 @@ import (
|
||||
"housecarl/internal/render"
|
||||
"housecarl/internal/server"
|
||||
"housecarl/internal/store"
|
||||
"housecarl/internal/trace"
|
||||
"housecarl/internal/validate"
|
||||
"housecarl/internal/version"
|
||||
"housecarl/internal/web"
|
||||
@@ -175,6 +178,14 @@ func serveCmd() *cobra.Command {
|
||||
// housecarld stopped is rolled back first. An empty
|
||||
// configuration applies nothing: its default policies would
|
||||
// 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 eng.State().Empty() {
|
||||
a.log.Info("the configuration is empty: no ruleset is applied until the first change (or 'hc import')")
|
||||
@@ -185,6 +196,10 @@ func serveCmd() *cobra.Command {
|
||||
}
|
||||
}
|
||||
go nflog.Listen(ctx, render.LogGroup, a.log, srv.LogPacket)
|
||||
go srv.RunQuotas(ctx)
|
||||
srv.SetConntrack(conntrack.Client{})
|
||||
// A trace left behind by a crash.
|
||||
fw.DeleteTable(ctx, "inet", trace.Table)
|
||||
hup := make(chan os.Signal, 1)
|
||||
signal.Notify(hup, syscall.SIGHUP)
|
||||
go func() {
|
||||
@@ -196,6 +211,7 @@ func serveCmd() *cobra.Command {
|
||||
}()
|
||||
err = srv.Serve(ctx, func() { sdNotify("READY=1") })
|
||||
sdNotify("STOPPING=1")
|
||||
srv.SaveQuotaUsage(context.Background())
|
||||
a.log.Info("stopped")
|
||||
return err
|
||||
},
|
||||
|
||||
+58
-10
@@ -51,8 +51,16 @@ Calls that change the configuration answer:
|
||||
* `confirm_by`: the change is on probation, and is rolled back at that
|
||||
time unless `POST /confirm` comes first. Missing for changes made
|
||||
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
|
||||
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.
|
||||
* `warnings` are the problems the change brings; older ones are in
|
||||
`/status`.
|
||||
@@ -71,6 +79,12 @@ Creating answers 201, other changes 200.
|
||||
| `GET /interfaces` | the machine's interfaces: `name`, `up`, `addresses`, `zone` |
|
||||
| `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 /quotas` | each rule's quota: `rule_id`, `rule`, `quota`, `used` (bytes), `addresses` (`[{"address", "used"}]` for a per-address quota), `last_reset`, `next_reset` |
|
||||
| `GET /connections[?q=&limit=]` | tracked connections: `family`, `protocol`, `source`, `source_port`, `destination`, `destination_port` (or `icmp_id`, `icmp_type`, `icmp_code`), `dnat_to`, `dnat_port`, `snat_to`, `state`, `assured`, `timeout` (seconds left), `packets` and `bytes` when the kernel counts them. `q`: all of its words in the addresses, `:port`, protocol, state |
|
||||
| `POST /connections/close` | `{"connections": [...]}` (as listed) or `{"search": "192.168.1.50"}`: delete their tracking entries (`config:write`, audited); answers `{"closed": N}` |
|
||||
| `POST /trace` | `{"address", "port", "protocol", "seconds"}` (5-600, default 60): start tracing (`config:write`, audited); 409 while one runs |
|
||||
| `GET /trace` | the trace running, or the last: `running`, `input`, `started`, `ends`, `error`, `packets`: each with `time`, `in`, `out`, `protocol`, addresses and ports, `tcp_flags`, `icmp_type`, `steps` (`table`, `chain`, `kind` rule or policy, `rule`, `mark`, `label`, `verdict`), `verdict` (accepted, dropped) and `decided_by` |
|
||||
| `DELETE /trace` | stop it |
|
||||
| `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 |
|
||||
|
||||
@@ -83,8 +97,8 @@ Creating answers 201, other changes 200.
|
||||
"log_policy_drops": false,
|
||||
"builtins": {"loopback": true, "established": true, "drop_invalid": true, "icmpv6": true,
|
||||
"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", "..."],
|
||||
"flowtable_devices": ["wan", "enp2s0"], "probation_seconds": 120}
|
||||
"own_prefixes": ["2001:db8::/48"], "antispoof_v4": ["10.0.0.0/8", "..."], "antispoof_v6": ["fc00::/7", "..."],
|
||||
"flowtable_devices": ["wan", "eth1"], "probation_seconds": 120, "manual_confirm": false}
|
||||
```
|
||||
|
||||
Lists replace the old ones. `probation_seconds` 0 is the default (120).
|
||||
@@ -102,24 +116,27 @@ share one pattern, `/{kind}` with kind `zones`, `sets`, `services`,
|
||||
| `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) |
|
||||
| `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 |
|
||||
|
||||
The objects:
|
||||
|
||||
```json
|
||||
{"name": "lan", "interfaces": ["enp2s0", "enp2s0.*"], "external": false, "comment": ""}
|
||||
{"name": "trusted", "entries": ["10.0.10.0/24", "2001:db8:7f3d:10::/64"], "dynamic": false, "timeout": 0, "comment": ""}
|
||||
{"name": "jellyfin", "entries": ["tcp/8096", "tcp/8920"], "comment": ""}
|
||||
{"name": "lan", "interfaces": ["eth1", "eth1.*"], "external": false, "comment": ""}
|
||||
{"name": "trusted", "entries": ["192.168.1.0/24", "2001:db8:1::/64"], "dynamic": false, "timeout": 0, "comment": ""}
|
||||
{"name": "web", "entries": ["tcp/8080", "tcp/8443"], "comment": ""}
|
||||
{"chain": "input",
|
||||
"in_zones": ["wan"], "not_in": false, "out_zones": [], "not_out": false,
|
||||
"sources": ["trusted"], "not_sources": false, "destinations": [], "not_destinations": false,
|
||||
"services": ["ssh"], "family": "",
|
||||
"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": "",
|
||||
"raw": "", "comment": "", "disabled": false}
|
||||
{"name": "jellyfin", "protocols": ["tcp"], "ports": "8096", "public_addresses": ["203.0.113.7"], "sources": [],
|
||||
"to": "10.0.10.57", "to_port": "443", "to6": "2001:db8:7f3d:10::57", "hairpin": true, "hairpin_sources": [],
|
||||
{"name": "web", "protocols": ["tcp"], "ports": "8443", "public_addresses": ["203.0.113.10"], "sources": [],
|
||||
"to": "192.168.1.10", "to_port": "443", "to6": "2001:db8:1::10", "hairpin": true, "hairpin_sources": [],
|
||||
"comment": "", "disabled": false}
|
||||
```
|
||||
|
||||
@@ -127,6 +144,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
|
||||
`"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
|
||||
|
||||
| | |
|
||||
@@ -144,7 +192,7 @@ history, but are audited.
|
||||
| | |
|
||||
|---|---|
|
||||
| `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 |
|
||||
|
||||
### 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`:
|
||||
|
||||
```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:
|
||||
acme:
|
||||
domain: fw.adbraun.lol
|
||||
|
||||
+78
-9
@@ -27,7 +27,9 @@ command and its flags.
|
||||
| `--config FILE` | `HOUSECARL_CLI_CONFIG` | the CLI's configuration file |
|
||||
| | `HOUSECARL_SOCKET` | the local socket's path |
|
||||
| `--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` |
|
||||
| `-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
|
||||
in force. A refused change says why, with the problem's code
|
||||
([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"
|
||||
created rule input #31 (from trusted · ssh)
|
||||
applied on probation (history 12): confirm with 'hc confirm' within 2m0s, or it is rolled back
|
||||
$ hc confirm
|
||||
confirmed 1 change; it is kept and loaded at boot
|
||||
applied and confirmed (history 12): this client reached Housecarl again over a new connection
|
||||
```
|
||||
|
||||
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')
|
||||
```
|
||||
|
||||
`-n` shows what would change without changing it; nftables checks it
|
||||
too.
|
||||
`-n` shows what would change without changing it, with the diff of the
|
||||
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
|
||||
|
||||
@@ -72,7 +135,10 @@ by name.
|
||||
| `interfaces` | the machine's interfaces, their addresses and zones |
|
||||
| `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`) |
|
||||
| `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)) |
|
||||
| `connections [--search TEXT] [--limit N]`, `connections close --search TEXT [-y]` | tracked connections; closing makes the rules check them again ([operations.md](operations.md#connections)) |
|
||||
| `trace [--address A] [--port N] [--protocol P] [--seconds S]` | follow packets through every table's rules ([operations.md](operations.md#tracing)) |
|
||||
|
||||
### zone, set, service
|
||||
|
||||
@@ -103,6 +169,7 @@ See [rules.md](rules.md).
|
||||
| `rule update ID [flags]` | only the flags given |
|
||||
| `rule move ID --before ID\|--after ID\|--first\|--last\|--chain CHAIN` | |
|
||||
| `rule enable\|disable ID` | |
|
||||
| `rule reset-quota ID` | start a rule's quota again now |
|
||||
| `rule delete ID` | |
|
||||
|
||||
The match flags (`rule add`, `snat add`, `redirect add`):
|
||||
@@ -115,7 +182,9 @@ The match flags (`rule add`, `snat add`, `redirect add`):
|
||||
| `--family ipv4\|ipv6` | |
|
||||
|
||||
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`, `--every-packet`, `-a
|
||||
accept|drop|reject|log`, `--reject-with tcp-reset`, `--log`,
|
||||
`--log-prefix`, `--raw RULE`, `--comment`, `--disabled`
|
||||
([rules.md](rules.md#rules)).
|
||||
@@ -137,7 +206,7 @@ See [nat.md](nat.md).
|
||||
| Command | |
|
||||
|---|---|
|
||||
| `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
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ Let it get its own certificate through Jarl ([certificates.md](certificates.md))
|
||||
or give it one:
|
||||
|
||||
```yaml
|
||||
listen: ["10.0.10.1:8070"]
|
||||
listen: ["192.168.1.1:8070"]
|
||||
tls:
|
||||
cert_file: /etc/housecarl/tls/fullchain.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
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
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.
|
||||
|
||||
* **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)
|
||||
need no confirmation: whoever makes them is already on the machine. One
|
||||
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**.
|
||||
|
||||
`--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
|
||||
define name their zone too (`define wan_interface = "ppp0"` makes the
|
||||
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
|
||||
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.
|
||||
* **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
|
||||
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.
|
||||
* **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:
|
||||
@@ -91,7 +107,10 @@ the old table:
|
||||
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.
|
||||
|
||||
## 4. The boot
|
||||
@@ -106,6 +125,11 @@ sudo systemctl disable nftables.service
|
||||
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,
|
||||
so a leftover `flush ruleset` can't undo it. Docker, Incus and libvirt
|
||||
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:
|
||||
|
||||
```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 tcp/8070 --comment "Housecarl"
|
||||
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 forward --in lan --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
|
||||
```
|
||||
|
||||
|
||||
+7
-7
@@ -8,9 +8,9 @@ One object does what takes three rules by hand (the DNAT, the forward
|
||||
accept, and the hairpin masquerade).
|
||||
|
||||
```sh
|
||||
hc forward add 8096 --to 10.0.10.57:443 --public 203.0.113.7 --hairpin --name jellyfin
|
||||
hc forward add 7777-7780 --proto tcp,udp --to 10.0.10.76 --public 203.0.113.7 --hairpin --name terraria
|
||||
hc forward add 25 --to 10.0.10.25 --from mail-relays --name smtp
|
||||
hc forward add 8443 --to 192.168.1.10:443 --public 203.0.113.10 --hairpin --name web
|
||||
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 192.168.1.25 --from mail-relays --name smtp
|
||||
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:
|
||||
|
||||
```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.
|
||||
|
||||
## Source NAT
|
||||
@@ -60,7 +60,7 @@ service; the first that matches decides.
|
||||
```sh
|
||||
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 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
|
||||
@@ -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:
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
|
||||
+104
-12
@@ -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
|
||||
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 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
|
||||
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.
|
||||
|
||||
Keep Debian's `nftables.service` disabled, or `/etc/nftables.conf`
|
||||
without `flush ruleset`: otherwise it deletes Housecarl's table at
|
||||
boot. `housecarl-ruleset.service` runs after it, which covers the
|
||||
ordinary case.
|
||||
Keep Debian's `nftables.service` disabled (`systemctl disable
|
||||
nftables`, without `--now`): at boot it loads `/etc/nftables.conf`, and
|
||||
stopping or restarting it runs `nft flush ruleset`, which deletes every
|
||||
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
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
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:15 hc forward policy drop: wan enp2s0 198.51.100.9:443 -> 10.0.10.20:51432 tcp
|
||||
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 eth1 198.51.100.9:443 -> 192.168.1.20:51432 tcp
|
||||
```
|
||||
|
||||
The packets go to netlink log group 4171, where housecarld reads them;
|
||||
@@ -87,16 +133,62 @@ the last 2000 are kept in memory. They aren't written to the kernel log
|
||||
or the journal. A rule's log isn't rate-limited: on a busy rule, give it
|
||||
a limit, or log only new connections (`--state new`).
|
||||
|
||||
## Connections
|
||||
|
||||
The built-in that accepts established connections lets their packets
|
||||
through without checking the rules again: a rule added later doesn't
|
||||
stop a connection already open. Traffic → Connections (`hc connections`)
|
||||
lists what the kernel tracks, with NAT (`to 192.168.1.10:443`: a port
|
||||
forward; `as 203.0.113.10`: masqueraded) and the time left:
|
||||
|
||||
```sh
|
||||
hc connections --search 192.168.1.50
|
||||
hc connections --search ":443 tcp"
|
||||
hc connections close --search 192.168.1.50
|
||||
```
|
||||
|
||||
Closing a connection deletes its tracking entry: its next packet is
|
||||
checked by the rules as they are now. One they block is stopped; one they
|
||||
allow carries on (TCP connections are picked up again mid-stream).
|
||||
|
||||
To make a block stop open connections by itself, tick "every packet" on
|
||||
the rule (`--every-packet`): it is checked before established connections
|
||||
are accepted, like rules with a quota ([rules.md](rules.md#rules)).
|
||||
|
||||
## Tracing
|
||||
|
||||
Traffic → Trace (`hc trace`) follows packets through the whole ruleset:
|
||||
Housecarl's rules and every other table (Docker's, fail2ban's, an old
|
||||
`inet main`), each rule and policy they meet, and what accepted or
|
||||
dropped them:
|
||||
|
||||
```
|
||||
$ hc trace --address 192.168.1.50 --port 443 --seconds 60
|
||||
14:02:13.120 192.168.1.50:51432 -> 198.51.100.9:443 tcp syn in eth1 out wan
|
||||
inet housecarl forward: built-in: mss-clamp -> continue
|
||||
inet housecarl forward: rule forward #12 (kids at night) -> drop
|
||||
=> DROPPED by rule forward #12 (kids at night)
|
||||
```
|
||||
|
||||
It traces packets from or to the address, on the port (either way), for
|
||||
5 to 600 seconds, at most 200 packets and 20 a second. It works with
|
||||
nftables' own tracing: a table of its own (`inet housecarl-trace`,
|
||||
hooked before everything else) marks the packets, and `nft monitor
|
||||
trace` reports them; the table goes when the trace ends. One trace runs
|
||||
at a time.
|
||||
|
||||
## When something doesn't get through
|
||||
|
||||
1. `hc log -f` with `--log-drops on`: is it dropped by a policy (no rule
|
||||
1. Trace it (above): it names the rule or policy that dropped it, in
|
||||
whichever table.
|
||||
2. `hc log -f` with `--log-drops on`: is it dropped by a policy (no rule
|
||||
accepted it), or by a rule (its number is in the line)?
|
||||
2. `hc rule list forward` (or `input`): hits on the rule you expect?
|
||||
3. `hc ruleset`: the rule as nftables sees it, with the zones' interfaces
|
||||
3. `hc rule list forward` (or `input`): hits on the rule you expect?
|
||||
4. `hc ruleset`: the rule as nftables sees it, with the zones' interfaces
|
||||
and the sets' families.
|
||||
4. `hc status`: other tables. A Docker or Incus table, or an old one,
|
||||
5. `hc status`: other tables. A Docker or Incus table, or an old one,
|
||||
can drop what Housecarl accepts.
|
||||
5. Port forwards: `hc forward list` hits, and the inside host's own
|
||||
6. Port forwards: `hc forward list` hits, and the inside host's own
|
||||
firewall and default route (answers must go back through Housecarl).
|
||||
|
||||
## Backups
|
||||
|
||||
+4
-3
@@ -7,9 +7,9 @@ else gets **grants**, `resource:access`:
|
||||
|
||||
| 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; close connections; start and stop traces |
|
||||
| `sets` | dynamic sets' entries | add and remove entries |
|
||||
| `log` | hit counters and the log | – |
|
||||
| `log` | hit counters, the log, connections, traces | – |
|
||||
| `audit` | the audit log | – |
|
||||
| `*` | all of the above | |
|
||||
|
||||
@@ -81,6 +81,7 @@ on probation like any other.
|
||||
## The audit log
|
||||
|
||||
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`,
|
||||
`token` or `socket`.
|
||||
+78
-15
@@ -3,12 +3,12 @@
|
||||
## Zones
|
||||
|
||||
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*`.
|
||||
|
||||
```sh
|
||||
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
|
||||
```
|
||||
|
||||
@@ -27,23 +27,26 @@ A set is a named list of IPv4 and IPv6 addresses, prefixes and ranges,
|
||||
mixed:
|
||||
|
||||
```sh
|
||||
hc set add trusted 10.0.10.0/24 10.20.30.0/24 2001:db8:7f3d:10::/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 trusted 192.168.1.0/24 192.168.2.0/24 2001:db8:1::/64
|
||||
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
|
||||
`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
|
||||
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.
|
||||
|
||||
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
|
||||
hc set add guests --dynamic --timeout 24h
|
||||
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 remove guests 10.0.30.17
|
||||
```
|
||||
@@ -60,7 +63,7 @@ A service names ports of a protocol (`tcp`, `udp`, `sctp`) and ICMP
|
||||
types:
|
||||
|
||||
```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 pmtu icmp/destination-unreachable icmpv6/packet-too-big
|
||||
```
|
||||
@@ -108,13 +111,18 @@ A rule matches on what it gives, and all of it:
|
||||
| `--state` | connection states: `new`, `established`, `related`, `invalid`, `untracked` |
|
||||
| `--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 |
|
||||
| `--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 |
|
||||
|
||||
The action is `accept`, `drop`, `reject` (an ICMP error, or with
|
||||
`--reject-with tcp-reset` a TCP reset) or `log` (log and go on).
|
||||
`--log` logs the packets of any rule ([operations.md](operations.md#the-log)).
|
||||
`--disabled` (or `hc rule disable ID`) keeps a rule without using it.
|
||||
`--every-packet` ("every packet" in the web UI) checks a drop or reject
|
||||
against every packet, before established connections are accepted, so it
|
||||
also stops connections already open, not just new ones; it can't accept
|
||||
([operations.md](operations.md#connections)).
|
||||
|
||||
Examples:
|
||||
|
||||
@@ -131,6 +139,58 @@ hc rule add forward --in wan --status dnat
|
||||
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
|
||||
|
||||
For what Housecarl can't say, a rule can be one nftables rule written by
|
||||
@@ -140,8 +200,11 @@ hand, placed like any other:
|
||||
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
|
||||
sets' nftables names (`@trusted_v4`). The importer makes raw rules from
|
||||
It is used exactly as written: its text replaces the rule's zones,
|
||||
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
|
||||
`counter` to it to count its packets.
|
||||
|
||||
@@ -162,12 +225,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 |
|
||||
| `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
|
||||
them. The antispoof lists can be changed with `--antispoof-v4` and
|
||||
`--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,
|
||||
which helps on a slow CPU. It needs exact device names. Counters of
|
||||
forward rules then only count each connection's first packets.
|
||||
@@ -179,8 +242,8 @@ show each rule's hits. The table looks like this (shortened):
|
||||
|
||||
```
|
||||
table inet housecarl {
|
||||
set trusted_v4 { type ipv4_addr; flags interval; elements = { 10.0.10.0/24, 10.20.30.0/24 } }
|
||||
set trusted_v6 { type ipv6_addr; flags interval; elements = { 2001:db8:7f3d:10::/64 } }
|
||||
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:1::/64 } }
|
||||
chain early { type filter hook prerouting priority raw; policy accept; ... }
|
||||
chain input { type filter hook input priority filter; policy drop;
|
||||
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_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 |
|
||||
| `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 |
|
||||
@@ -77,12 +78,23 @@ These apply to rules, source NAT and redirects.
|
||||
| `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` |
|
||||
| `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_reject` | error | reject in the early chain: drop instead |
|
||||
| `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_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_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 |
|
||||
| `rule_every_packet_accept` | error | a rule checked against every packet (`every_packet`) that accepts: it comes before established connections are accepted, so it can only drop, reject or log |
|
||||
| `flowtable_traffic` | warning | a forward rule checked against every packet (a quota, a byte rate, `every_packet`) while the flowtable is on: it only sees 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 |
|
||||
| `log_prefix` | error | a log prefix over 100 characters |
|
||||
|
||||
@@ -106,3 +118,13 @@ These apply to rules, source NAT and redirects.
|
||||
| `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_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
|
||||
|
||||
require (
|
||||
github.com/aymanbagabas/go-udiff v0.4.1
|
||||
github.com/go-acme/lego/v4 v4.35.2
|
||||
github.com/miekg/dns v1.1.73
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/spf13/pflag v1.0.9
|
||||
go.yaml.in/yaml/v3 v3.0.5
|
||||
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
|
||||
modernc.org/sqlite v1.60.1
|
||||
)
|
||||
@@ -22,8 +24,7 @@ require (
|
||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/spf13/pflag v1.0.9 // indirect
|
||||
golang.org/x/sys v0.48.0 // indirect
|
||||
golang.org/x/net v0.58.0 // indirect
|
||||
golang.org/x/text v0.42.0 // indirect
|
||||
modernc.org/libc v1.77.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/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||
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"`
|
||||
// Result is the preview of a refused change.
|
||||
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.
|
||||
@@ -64,6 +66,34 @@ type MutationResponse struct {
|
||||
// ConfirmBy is set when the change is on probation: unless confirmed
|
||||
// (POST /confirm) by then, it is rolled back.
|
||||
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
|
||||
@@ -274,3 +304,121 @@ type CertificateResponse struct {
|
||||
Source string `json:"source"`
|
||||
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"`
|
||||
}
|
||||
|
||||
// Connection is a connection the kernel tracks (conntrack), as it was
|
||||
// first seen: Source:SourcePort → Destination:DestinationPort. NAT shows
|
||||
// as DNATTo (where it was really sent: a port forward) and SNATTo (the
|
||||
// source it went out with: masquerading).
|
||||
type Connection struct {
|
||||
ID uint32 `json:"id,omitempty"`
|
||||
Family string `json:"family"`
|
||||
Protocol string `json:"protocol"`
|
||||
Source string `json:"source"`
|
||||
SourcePort int `json:"source_port,omitempty"`
|
||||
Destination string `json:"destination"`
|
||||
DestinationPort int `json:"destination_port,omitempty"`
|
||||
ICMPID int `json:"icmp_id,omitempty"`
|
||||
ICMPType int `json:"icmp_type,omitempty"`
|
||||
ICMPCode int `json:"icmp_code,omitempty"`
|
||||
DNATTo string `json:"dnat_to,omitempty"`
|
||||
DNATPort int `json:"dnat_port,omitempty"`
|
||||
SNATTo string `json:"snat_to,omitempty"`
|
||||
// State is TCP's (established, time-wait, …); Assured: traffic went
|
||||
// both ways.
|
||||
State string `json:"state,omitempty"`
|
||||
Assured bool `json:"assured,omitempty"`
|
||||
// Timeout is the seconds left until it is forgotten unless more
|
||||
// traffic comes.
|
||||
Timeout int `json:"timeout"`
|
||||
Mark uint32 `json:"mark,omitempty"`
|
||||
// Packets and Bytes, both ways, when the kernel counts them
|
||||
// (net.netfilter.nf_conntrack_acct).
|
||||
Packets uint64 `json:"packets,omitempty"`
|
||||
Bytes uint64 `json:"bytes,omitempty"`
|
||||
}
|
||||
|
||||
// CloseConnections closes connections: these, or every one Search finds
|
||||
// (as GET /connections?q= does).
|
||||
type CloseConnections struct {
|
||||
Connections []Connection `json:"connections,omitempty"`
|
||||
Search string `json:"search,omitempty"`
|
||||
}
|
||||
|
||||
// TraceInput says what to trace: packets from or to Address (an address
|
||||
// or prefix), on Port (either way), of Protocol, for Seconds (0: 60).
|
||||
type TraceInput struct {
|
||||
Address string `json:"address,omitempty"`
|
||||
Port int `json:"port,omitempty"`
|
||||
Protocol string `json:"protocol,omitempty"`
|
||||
Seconds int `json:"seconds,omitempty"`
|
||||
}
|
||||
|
||||
// TraceStep is one rule or policy a traced packet met, in any table.
|
||||
type TraceStep struct {
|
||||
Table string `json:"table"` // "inet housecarl", "ip nat"…
|
||||
Chain string `json:"chain"`
|
||||
Kind string `json:"kind"` // rule or policy
|
||||
// Rule is the rule as nftables writes it; Mark its Housecarl mark
|
||||
// ("r12", "b-established"), if it is one of Housecarl's.
|
||||
Rule string `json:"rule,omitempty"`
|
||||
Mark string `json:"mark,omitempty"`
|
||||
Verdict string `json:"verdict"` // accept, drop, continue, jump X…
|
||||
// Label names Housecarl's object ("rule input #12 (ssh)").
|
||||
Label string `json:"label,omitempty"`
|
||||
}
|
||||
|
||||
// TracePacket is one traced packet: its headers as it arrived, every
|
||||
// step, and its fate.
|
||||
type TracePacket struct {
|
||||
ID string `json:"id"`
|
||||
Time time.Time `json:"time"`
|
||||
In string `json:"in,omitempty"`
|
||||
Out string `json:"out,omitempty"`
|
||||
Protocol string `json:"protocol,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
SourcePort int `json:"source_port,omitempty"`
|
||||
Destination string `json:"destination,omitempty"`
|
||||
DestinationPort int `json:"destination_port,omitempty"`
|
||||
TCPFlags string `json:"tcp_flags,omitempty"`
|
||||
ICMPType string `json:"icmp_type,omitempty"`
|
||||
Steps []TraceStep `json:"steps"`
|
||||
// Verdict is accepted or dropped; DecidedBy the step that decided.
|
||||
Verdict string `json:"verdict"`
|
||||
DecidedBy TraceStep `json:"decided_by"`
|
||||
}
|
||||
|
||||
// TraceStatus is the trace running (or the last one).
|
||||
type TraceStatus struct {
|
||||
Running bool `json:"running"`
|
||||
Input TraceInput `json:"input"`
|
||||
Started time.Time `json:"started"`
|
||||
Ends time.Time `json:"ends"`
|
||||
Packets []TracePacket `json:"packets"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -28,6 +29,29 @@ type Client struct {
|
||||
HTTP *http.Client
|
||||
// DryRun previews changes instead of making them.
|
||||
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.
|
||||
@@ -107,6 +131,12 @@ func (c *Client) Do(ctx context.Context, method, path string, query url.Values,
|
||||
}
|
||||
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
|
||||
if len(query) > 0 {
|
||||
u += "?" + query.Encode()
|
||||
@@ -131,6 +161,10 @@ func (c *Client) Do(ctx context.Context, method, path string, query url.Values,
|
||||
if 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)
|
||||
if err != nil {
|
||||
if c.BaseURL == "http://housecarld" {
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
// Package conntrack lists and closes the kernel's tracked connections,
|
||||
// through ctnetlink (the netfilter netlink subsystem for connection
|
||||
// tracking): what the "established" built-in lets through, and what a
|
||||
// change of rules doesn't stop by itself.
|
||||
//
|
||||
// Closing a connection deletes its tracking entry. Its next packet is
|
||||
// then checked by the rules again, as a new connection would be: one the
|
||||
// rules now block is stopped, one they allow carries on (TCP connections
|
||||
// are picked up again mid-stream).
|
||||
package conntrack
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
|
||||
"housecarl/internal/api"
|
||||
)
|
||||
|
||||
const (
|
||||
subsysCtnetlink = 1
|
||||
msgNew = 0
|
||||
msgGet = 1
|
||||
msgDelete = 2
|
||||
|
||||
ctaTupleOrig = 1
|
||||
ctaTupleReply = 2
|
||||
ctaStatus = 3
|
||||
ctaProtoinfo = 4
|
||||
ctaTimeout = 7
|
||||
ctaMark = 8
|
||||
ctaCountersOrig = 9
|
||||
ctaCountersReply = 10
|
||||
ctaID = 12
|
||||
|
||||
ctaTupleIP = 1
|
||||
ctaTupleProto = 2
|
||||
|
||||
ctaIPv4Src = 1
|
||||
ctaIPv4Dst = 2
|
||||
ctaIPv6Src = 3
|
||||
ctaIPv6Dst = 4
|
||||
|
||||
ctaProtoNum = 1
|
||||
ctaProtoSrcPort = 2
|
||||
ctaProtoDstPort = 3
|
||||
ctaProtoICMPID = 4
|
||||
ctaProtoICMPType = 5
|
||||
ctaProtoICMPCode = 6
|
||||
ctaProtoICMP6ID = 7
|
||||
ctaProtoICMP6Typ = 8
|
||||
ctaProtoICMP6Cod = 9
|
||||
|
||||
ctaProtoinfoTCP = 1
|
||||
ctaProtoinfoTCPState = 1
|
||||
|
||||
ctaCountersPackets = 1
|
||||
ctaCountersBytes = 2
|
||||
|
||||
statusAssured = 1 << 2
|
||||
statusSrcNAT = 1 << 4
|
||||
statusDstNAT = 1 << 5
|
||||
|
||||
nlaNested = 0x8000
|
||||
nlaTypeMask = 0x3fff
|
||||
)
|
||||
|
||||
var tcpStates = []string{"none", "syn-sent", "syn-recv", "established", "fin-wait", "close-wait", "last-ack", "time-wait", "close", "syn-sent2"}
|
||||
|
||||
var protoNames = map[uint8]string{1: "icmp", 6: "tcp", 17: "udp", 58: "icmpv6", 132: "sctp", 136: "udplite", 33: "dccp", 47: "gre"}
|
||||
|
||||
// Client talks to ctnetlink. The zero value works.
|
||||
type Client struct{}
|
||||
|
||||
var seq atomic.Uint32
|
||||
|
||||
// List returns every tracked connection, IPv4 and IPv6.
|
||||
func (Client) List(ctx context.Context) ([]api.Connection, error) {
|
||||
msgs, err := request(ctx, msgGet, unix.NLM_F_REQUEST|unix.NLM_F_DUMP, unix.AF_UNSPEC, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := []api.Connection{}
|
||||
for _, m := range msgs {
|
||||
if c, ok := parse(m); ok {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Close deletes a connection's tracking entry, found by its original
|
||||
// direction (addresses, protocol, ports or ICMP id). Gone already is fine.
|
||||
func (Client) Close(ctx context.Context, c api.Connection) error {
|
||||
src, err1 := netip.ParseAddr(c.Source)
|
||||
dst, err2 := netip.ParseAddr(c.Destination)
|
||||
if err1 != nil || err2 != nil || src.Is4() != dst.Is4() {
|
||||
return fmt.Errorf("connection %s -> %s: not a pair of addresses", c.Source, c.Destination)
|
||||
}
|
||||
num := protoNumber(c.Protocol)
|
||||
if num == 0 {
|
||||
return fmt.Errorf("connection %s -> %s: unknown protocol %q", c.Source, c.Destination, c.Protocol)
|
||||
}
|
||||
family, ipSrc, ipDst := uint8(unix.AF_INET), uint16(ctaIPv4Src), uint16(ctaIPv4Dst)
|
||||
if src.Is6() {
|
||||
family, ipSrc, ipDst = unix.AF_INET6, ctaIPv6Src, ctaIPv6Dst
|
||||
}
|
||||
ip := attr(ipSrc, src.AsSlice())
|
||||
ip = append(ip, attr(ipDst, dst.AsSlice())...)
|
||||
proto := attr(ctaProtoNum, []byte{num})
|
||||
switch num {
|
||||
case 1:
|
||||
proto = append(proto, attr(ctaProtoICMPID, be16(uint16(c.ICMPID)))...)
|
||||
proto = append(proto, attr(ctaProtoICMPType, []byte{uint8(c.ICMPType)})...)
|
||||
proto = append(proto, attr(ctaProtoICMPCode, []byte{uint8(c.ICMPCode)})...)
|
||||
case 58:
|
||||
proto = append(proto, attr(ctaProtoICMP6ID, be16(uint16(c.ICMPID)))...)
|
||||
proto = append(proto, attr(ctaProtoICMP6Typ, []byte{uint8(c.ICMPType)})...)
|
||||
proto = append(proto, attr(ctaProtoICMP6Cod, []byte{uint8(c.ICMPCode)})...)
|
||||
default:
|
||||
proto = append(proto, attr(ctaProtoSrcPort, be16(uint16(c.SourcePort)))...)
|
||||
proto = append(proto, attr(ctaProtoDstPort, be16(uint16(c.DestinationPort)))...)
|
||||
}
|
||||
tuple := attr(ctaTupleIP|nlaNested, ip)
|
||||
tuple = append(tuple, attr(ctaTupleProto|nlaNested, proto)...)
|
||||
_, err := request(ctx, msgDelete, unix.NLM_F_REQUEST|unix.NLM_F_ACK, family, attr(ctaTupleOrig|nlaNested, tuple))
|
||||
if errors.Is(err, unix.ENOENT) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func protoNumber(name string) uint8 {
|
||||
for n, s := range protoNames {
|
||||
if s == name {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// ---- netlink
|
||||
|
||||
func request(ctx context.Context, msg uint16, flags uint16, family uint8, payload []byte) ([][]byte, error) {
|
||||
fd, err := unix.Socket(unix.AF_NETLINK, unix.SOCK_RAW|unix.SOCK_CLOEXEC, unix.NETLINK_NETFILTER)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer unix.Close(fd)
|
||||
if err := unix.Bind(fd, &unix.SockaddrNetlink{Family: unix.AF_NETLINK}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
if d, ok := ctx.Deadline(); ok && d.Before(deadline) {
|
||||
deadline = d
|
||||
}
|
||||
tv := unix.NsecToTimeval(time.Until(deadline).Nanoseconds())
|
||||
unix.SetsockoptTimeval(fd, unix.SOL_SOCKET, unix.SO_RCVTIMEO, &tv)
|
||||
unix.SetsockoptInt(fd, unix.SOL_SOCKET, unix.SO_RCVBUF, 1<<22)
|
||||
|
||||
s := seq.Add(1)
|
||||
b := make([]byte, unix.SizeofNlMsghdr+4, unix.SizeofNlMsghdr+4+len(payload))
|
||||
binary.NativeEndian.PutUint16(b[4:], subsysCtnetlink<<8|msg)
|
||||
binary.NativeEndian.PutUint16(b[6:], flags)
|
||||
binary.NativeEndian.PutUint32(b[8:], s)
|
||||
b[16] = family // nfgenmsg: family, version 0, res_id 0
|
||||
b = append(b, payload...)
|
||||
binary.NativeEndian.PutUint32(b[0:], uint32(len(b)))
|
||||
if err := unix.Sendto(fd, b, 0, &unix.SockaddrNetlink{Family: unix.AF_NETLINK}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out [][]byte
|
||||
buf := make([]byte, 1<<16)
|
||||
for {
|
||||
n, _, err := unix.Recvfrom(fd, buf, 0)
|
||||
if err != nil {
|
||||
if errors.Is(err, unix.EAGAIN) {
|
||||
return nil, errors.New("no answer from connection tracking (is nf_conntrack loaded?)")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
for off := 0; off+unix.SizeofNlMsghdr <= n; {
|
||||
l := int(binary.NativeEndian.Uint32(buf[off:]))
|
||||
typ := binary.NativeEndian.Uint16(buf[off+4:])
|
||||
if l < unix.SizeofNlMsghdr || off+l > n {
|
||||
return nil, errors.New("connection tracking: a malformed answer")
|
||||
}
|
||||
body := buf[off+unix.SizeofNlMsghdr : off+l]
|
||||
switch typ {
|
||||
case unix.NLMSG_DONE:
|
||||
return out, nil
|
||||
case unix.NLMSG_ERROR:
|
||||
if len(body) >= 4 {
|
||||
if code := int32(binary.NativeEndian.Uint32(body)); code != 0 {
|
||||
return nil, syscall.Errno(-code)
|
||||
}
|
||||
}
|
||||
return out, nil // the acknowledgement
|
||||
default:
|
||||
out = append(out, append([]byte(nil), body...))
|
||||
}
|
||||
off += (l + 3) &^ 3
|
||||
}
|
||||
if flags&unix.NLM_F_DUMP == 0 && flags&unix.NLM_F_ACK == 0 {
|
||||
return out, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func attr(typ uint16, data []byte) []byte {
|
||||
l := 4 + len(data)
|
||||
b := make([]byte, (l+3)&^3)
|
||||
binary.NativeEndian.PutUint16(b[0:], uint16(l))
|
||||
binary.NativeEndian.PutUint16(b[2:], typ)
|
||||
copy(b[4:], data)
|
||||
return b
|
||||
}
|
||||
|
||||
func be16(v uint16) []byte {
|
||||
b := make([]byte, 2)
|
||||
binary.BigEndian.PutUint16(b, v)
|
||||
return b
|
||||
}
|
||||
|
||||
// attrs splits netlink attributes: type (without the nested flag) → data.
|
||||
func attrs(b []byte) map[uint16][]byte {
|
||||
m := map[uint16][]byte{}
|
||||
for len(b) >= 4 {
|
||||
l := int(binary.NativeEndian.Uint16(b[0:]))
|
||||
typ := binary.NativeEndian.Uint16(b[2:]) & nlaTypeMask
|
||||
if l < 4 || l > len(b) {
|
||||
break
|
||||
}
|
||||
m[typ] = b[4:l]
|
||||
b = b[min((l+3)&^3, len(b)):]
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// ---- parsing
|
||||
|
||||
type tuple struct {
|
||||
src, dst netip.Addr
|
||||
proto uint8
|
||||
sport, dport uint16
|
||||
icmpID uint16
|
||||
icmpType, icmpCd uint8
|
||||
}
|
||||
|
||||
func parseTuple(b []byte) (tuple, bool) {
|
||||
var t tuple
|
||||
a := attrs(b)
|
||||
ip := attrs(a[ctaTupleIP])
|
||||
if v, ok := ip[ctaIPv4Src]; ok {
|
||||
t.src, _ = netip.AddrFromSlice(v)
|
||||
t.dst, _ = netip.AddrFromSlice(ip[ctaIPv4Dst])
|
||||
} else if v, ok := ip[ctaIPv6Src]; ok {
|
||||
t.src, _ = netip.AddrFromSlice(v)
|
||||
t.dst, _ = netip.AddrFromSlice(ip[ctaIPv6Dst])
|
||||
}
|
||||
p := attrs(a[ctaTupleProto])
|
||||
if v := p[ctaProtoNum]; len(v) == 1 {
|
||||
t.proto = v[0]
|
||||
}
|
||||
if v := p[ctaProtoSrcPort]; len(v) == 2 {
|
||||
t.sport = binary.BigEndian.Uint16(v)
|
||||
}
|
||||
if v := p[ctaProtoDstPort]; len(v) == 2 {
|
||||
t.dport = binary.BigEndian.Uint16(v)
|
||||
}
|
||||
for _, k := range [][3]uint16{{ctaProtoICMPID, ctaProtoICMPType, ctaProtoICMPCode}, {ctaProtoICMP6ID, ctaProtoICMP6Typ, ctaProtoICMP6Cod}} {
|
||||
if v := p[k[0]]; len(v) == 2 {
|
||||
t.icmpID = binary.BigEndian.Uint16(v)
|
||||
}
|
||||
if v := p[k[1]]; len(v) == 1 {
|
||||
t.icmpType = v[0]
|
||||
}
|
||||
if v := p[k[2]]; len(v) == 1 {
|
||||
t.icmpCd = v[0]
|
||||
}
|
||||
}
|
||||
return t, t.src.IsValid() && t.dst.IsValid()
|
||||
}
|
||||
|
||||
// parse reads one connection (an nfgenmsg and its attributes).
|
||||
func parse(m []byte) (api.Connection, bool) {
|
||||
if len(m) < 4 {
|
||||
return api.Connection{}, false
|
||||
}
|
||||
a := attrs(m[4:])
|
||||
orig, ok1 := parseTuple(a[ctaTupleOrig])
|
||||
reply, ok2 := parseTuple(a[ctaTupleReply])
|
||||
if !ok1 || !ok2 {
|
||||
return api.Connection{}, false
|
||||
}
|
||||
c := api.Connection{
|
||||
Family: "ipv4", Protocol: protoNames[orig.proto],
|
||||
Source: orig.src.String(), SourcePort: int(orig.sport), Destination: orig.dst.String(), DestinationPort: int(orig.dport),
|
||||
ICMPID: int(orig.icmpID), ICMPType: int(orig.icmpType), ICMPCode: int(orig.icmpCd),
|
||||
}
|
||||
if orig.src.Is6() {
|
||||
c.Family = "ipv6"
|
||||
}
|
||||
if c.Protocol == "" {
|
||||
c.Protocol = fmt.Sprint(orig.proto)
|
||||
}
|
||||
// NAT shows in the reply direction: it comes from where the packets
|
||||
// really went, to where they really came from.
|
||||
if reply.src != orig.dst || reply.sport != orig.dport {
|
||||
c.DNATTo = reply.src.String()
|
||||
if reply.sport != orig.dport {
|
||||
c.DNATPort = int(reply.sport)
|
||||
}
|
||||
}
|
||||
if reply.dst != orig.src || reply.dport != orig.sport {
|
||||
c.SNATTo = reply.dst.String()
|
||||
}
|
||||
if v := a[ctaStatus]; len(v) == 4 {
|
||||
st := binary.BigEndian.Uint32(v)
|
||||
c.Assured = st&statusAssured != 0
|
||||
}
|
||||
if v := a[ctaTimeout]; len(v) == 4 {
|
||||
c.Timeout = int(binary.BigEndian.Uint32(v))
|
||||
}
|
||||
if v := a[ctaMark]; len(v) == 4 {
|
||||
c.Mark = binary.BigEndian.Uint32(v)
|
||||
}
|
||||
if v := a[ctaID]; len(v) == 4 {
|
||||
c.ID = binary.BigEndian.Uint32(v)
|
||||
}
|
||||
if v := attrs(attrs(a[ctaProtoinfo])[ctaProtoinfoTCP])[ctaProtoinfoTCPState]; len(v) == 1 && int(v[0]) < len(tcpStates) {
|
||||
c.State = tcpStates[v[0]]
|
||||
}
|
||||
for _, k := range []uint16{ctaCountersOrig, ctaCountersReply} {
|
||||
ct := attrs(a[k])
|
||||
if v := ct[ctaCountersPackets]; len(v) == 8 {
|
||||
c.Packets += binary.BigEndian.Uint64(v)
|
||||
}
|
||||
if v := ct[ctaCountersBytes]; len(v) == 8 {
|
||||
c.Bytes += binary.BigEndian.Uint64(v)
|
||||
}
|
||||
}
|
||||
return c, true
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package conntrack
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"net/netip"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func nested(typ uint16, parts ...[]byte) []byte {
|
||||
var b []byte
|
||||
for _, p := range parts {
|
||||
b = append(b, p...)
|
||||
}
|
||||
return attr(typ|nlaNested, b)
|
||||
}
|
||||
|
||||
func tupleAttr(typ uint16, src, dst string, proto uint8, sport, dport uint16) []byte {
|
||||
s, d := netip.MustParseAddr(src), netip.MustParseAddr(dst)
|
||||
return nested(typ,
|
||||
nested(ctaTupleIP, attr(ctaIPv4Src, s.AsSlice()), attr(ctaIPv4Dst, d.AsSlice())),
|
||||
nested(ctaTupleProto, attr(ctaProtoNum, []byte{proto}), attr(ctaProtoSrcPort, be16(sport)), attr(ctaProtoDstPort, be16(dport))))
|
||||
}
|
||||
|
||||
// A port-forwarded TCP connection from the internet, as ctnetlink
|
||||
// describes it: the reply comes from the inside host.
|
||||
func TestParse(t *testing.T) {
|
||||
status, timeout := make([]byte, 4), make([]byte, 4)
|
||||
binary.BigEndian.PutUint32(status, statusAssured|statusDstNAT)
|
||||
binary.BigEndian.PutUint32(timeout, 431999)
|
||||
msg := []byte{2, 0, 0, 0} // nfgenmsg: AF_INET
|
||||
msg = append(msg, tupleAttr(ctaTupleOrig, "198.51.100.7", "203.0.113.10", 6, 51432, 8443)...)
|
||||
msg = append(msg, tupleAttr(ctaTupleReply, "192.168.1.10", "198.51.100.7", 6, 443, 51432)...)
|
||||
msg = append(msg, attr(ctaStatus, status)...)
|
||||
msg = append(msg, attr(ctaTimeout, timeout)...)
|
||||
msg = append(msg, nested(ctaProtoinfo, nested(ctaProtoinfoTCP, attr(ctaProtoinfoTCPState, []byte{3})))...)
|
||||
c, ok := parse(msg)
|
||||
if !ok {
|
||||
t.Fatal("not parsed")
|
||||
}
|
||||
if c.Protocol != "tcp" || c.Source != "198.51.100.7" || c.SourcePort != 51432 || c.Destination != "203.0.113.10" || c.DestinationPort != 8443 ||
|
||||
c.DNATTo != "192.168.1.10" || c.DNATPort != 443 || c.SNATTo != "" || c.State != "established" || !c.Assured || c.Timeout != 431999 {
|
||||
t.Errorf("parsed %+v", c)
|
||||
}
|
||||
// Masqueraded on the way out: the reply comes back to the WAN address.
|
||||
msg = []byte{2, 0, 0, 0}
|
||||
msg = append(msg, tupleAttr(ctaTupleOrig, "192.168.1.20", "198.51.100.9", 17, 5353, 53)...)
|
||||
msg = append(msg, tupleAttr(ctaTupleReply, "198.51.100.9", "203.0.113.10", 17, 53, 5353)...)
|
||||
if c, _ := parse(msg); c.SNATTo != "203.0.113.10" || c.DNATTo != "" || c.Protocol != "udp" {
|
||||
t.Errorf("masqueraded: %+v", c)
|
||||
}
|
||||
}
|
||||
@@ -74,6 +74,9 @@ type Result struct {
|
||||
HistoryID int64 `json:"history_id,omitempty"`
|
||||
Changes []model.Change `json:"changes"`
|
||||
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.
|
||||
@@ -134,6 +137,9 @@ type Edit func(st *model.State, newID func() int64) error
|
||||
type Options struct {
|
||||
DryRun bool
|
||||
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
|
||||
@@ -158,7 +164,7 @@ func (e *Engine) Apply(ctx context.Context, p *auth.Principal, description strin
|
||||
if idErr != nil {
|
||||
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.
|
||||
@@ -166,7 +172,7 @@ func (e *Engine) commit(ctx context.Context, p *auth.Principal, description stri
|
||||
Normalize(next)
|
||||
next.Sort()
|
||||
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 {
|
||||
return res, nil
|
||||
}
|
||||
@@ -261,8 +267,22 @@ func (e *Engine) Revert(ctx context.Context, p *auth.Principal, id int64, opts O
|
||||
}
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
cur := objects(e.state)
|
||||
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-- {
|
||||
c := h.Changes[i]
|
||||
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 == "" {
|
||||
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 {
|
||||
return nil, err
|
||||
if err := put(st, c.Kind, c.ID, c.Before); err != nil {
|
||||
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).
|
||||
@@ -370,7 +390,7 @@ func Diff(a, b *model.State) []model.Change {
|
||||
return out
|
||||
}
|
||||
|
||||
// Describe sums up changes: "create zone lan (enp2s0)", or
|
||||
// Describe sums up changes: "create zone lan (eth1)", or
|
||||
// "3 changes" for many.
|
||||
func Describe(cs []model.Change) string {
|
||||
if len(cs) == 0 {
|
||||
|
||||
+424
-32
@@ -13,6 +13,7 @@ package importer
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
@@ -38,6 +39,8 @@ type Input struct {
|
||||
// Result is the imported configuration and what to know about it.
|
||||
type Result struct {
|
||||
State *model.State
|
||||
// Table is the table imported ("inet main").
|
||||
Table string
|
||||
Notes []string
|
||||
// Raw lists the rules that became raw rules.
|
||||
Raw []string
|
||||
@@ -81,6 +84,16 @@ type nftSet struct {
|
||||
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 {
|
||||
Table string `json:"table"`
|
||||
Name string `json:"name"`
|
||||
@@ -93,6 +106,7 @@ type importer struct {
|
||||
chains map[string]nftChain
|
||||
rules map[string][]nftRule // by chain, in order
|
||||
sets map[string]nftSet
|
||||
maps map[string]nftMap
|
||||
flow []nftFlowtable
|
||||
text map[int]string // rule text by handle
|
||||
st *model.State
|
||||
@@ -104,6 +118,7 @@ type importer struct {
|
||||
portNames map[string]string // canonical port list → define name
|
||||
ifNames map[string]string // interface → define name
|
||||
used map[string]bool // sets created
|
||||
meters map[string]bool // sets used as meters
|
||||
noted map[string]bool
|
||||
positions map[string]float64
|
||||
}
|
||||
@@ -120,7 +135,7 @@ func Import(in Input) (*Result, error) {
|
||||
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)
|
||||
}
|
||||
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{}}
|
||||
var tables []nftTable
|
||||
for _, item := range doc.Nftables {
|
||||
@@ -172,6 +187,12 @@ func Import(in Input) (*Result, error) {
|
||||
if ours(s.Family, s.Table) {
|
||||
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:
|
||||
var f nftFlowtable
|
||||
json.Unmarshal(item["flowtable"], &f)
|
||||
@@ -182,11 +203,12 @@ func Import(in Input) (*Result, error) {
|
||||
}
|
||||
im.readText()
|
||||
im.readDefines()
|
||||
im.findMeters()
|
||||
im.importSets()
|
||||
im.run()
|
||||
im.nameForwards()
|
||||
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 {
|
||||
@@ -296,6 +318,27 @@ func validIface(s string) bool {
|
||||
// ---- 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() {
|
||||
names := make([]string, 0, len(im.sets))
|
||||
for n := range im.sets {
|
||||
@@ -303,6 +346,9 @@ func (im *importer) importSets() {
|
||||
}
|
||||
sort.Strings(names)
|
||||
for _, n := range names {
|
||||
if im.meters[n] {
|
||||
continue
|
||||
}
|
||||
s := im.sets[n]
|
||||
var typ string
|
||||
json.Unmarshal(s.Type, &typ)
|
||||
@@ -439,19 +485,24 @@ func values(raw json.RawMessage) []string {
|
||||
|
||||
// parsed is one rule, read.
|
||||
type parsed struct {
|
||||
inIf, outIf []string
|
||||
notIn, notOut bool
|
||||
src, dst []string // entries, or "@set"
|
||||
notSrc, notDst bool
|
||||
family string
|
||||
proto string
|
||||
dports []string
|
||||
icmp []string
|
||||
icmpProto string
|
||||
ctState, ctStatus []string
|
||||
hop255 bool
|
||||
tcpSyn bool
|
||||
limit *model.Limit
|
||||
inIf, outIf []string
|
||||
notIn, notOut bool
|
||||
src, dst []string // entries, or "@set"
|
||||
notSrc, notDst bool
|
||||
family string
|
||||
proto string
|
||||
dports []string
|
||||
icmp []string
|
||||
icmpProto string
|
||||
ctState, ctStatus []string
|
||||
hop255 bool
|
||||
tcpSyn bool
|
||||
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
|
||||
logPrefix string
|
||||
verdict string // accept, drop, reject, jump, goto, return
|
||||
@@ -465,6 +516,20 @@ type parsed struct {
|
||||
unsupported []string
|
||||
comment string
|
||||
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 {
|
||||
@@ -516,19 +581,21 @@ func (p *parsed) expr(k string, v json.RawMessage) {
|
||||
json.Unmarshal(v, &x)
|
||||
p.log, p.logPrefix = true, x.Prefix
|
||||
case "limit":
|
||||
var x struct {
|
||||
Rate int `json:"rate"`
|
||||
Per string `json:"per"`
|
||||
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")
|
||||
l, ok := limitJSON(v)
|
||||
if !ok {
|
||||
p.bad("a limit Housecarl can't read")
|
||||
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":
|
||||
var x struct {
|
||||
Family string `json:"family"`
|
||||
@@ -536,6 +603,16 @@ func (p *parsed) expr(k string, v json.RawMessage) {
|
||||
Port json.RawMessage `json:"port"`
|
||||
}
|
||||
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)
|
||||
if len(addr) != 1 || len(port) > 1 {
|
||||
p.bad(k + " to a range or map")
|
||||
@@ -572,11 +649,184 @@ func (p *parsed) expr(k string, v json.RawMessage) {
|
||||
}
|
||||
case "flow":
|
||||
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:
|
||||
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) {
|
||||
var left map[string]json.RawMessage
|
||||
json.Unmarshal(m.Left, &left)
|
||||
@@ -740,8 +990,11 @@ func (im *importer) run() {
|
||||
// target chain gets the jump's own matches in front.
|
||||
func (im *importer) flatten(chain string, prefix *parsed, depth int) []*parsed {
|
||||
var out []*parsed
|
||||
var rules []*parsed
|
||||
for _, r := range im.rules[chain] {
|
||||
p := parse(r)
|
||||
rules = append(rules, im.expandMaps(parse(r))...)
|
||||
}
|
||||
for _, p := range rules {
|
||||
if prefix != nil {
|
||||
p = im.merge(prefix, p)
|
||||
}
|
||||
@@ -770,6 +1023,115 @@ func (im *importer) flatten(chain string, prefix *parsed, depth int) []*parsed {
|
||||
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.
|
||||
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) ||
|
||||
@@ -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}
|
||||
|
||||
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 "".
|
||||
@@ -1043,12 +1405,18 @@ func (im *importer) rule(chain string, p *parsed) {
|
||||
return
|
||||
}
|
||||
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 {
|
||||
case "accept", "drop", "reject":
|
||||
r.Action = p.verdict
|
||||
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)
|
||||
return
|
||||
}
|
||||
@@ -1057,6 +1425,15 @@ func (im *importer) rule(chain string, p *parsed) {
|
||||
p.bad(p.verdict)
|
||||
}
|
||||
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 {
|
||||
r.Match = m
|
||||
im.st.Rules = append(im.st.Rules, r)
|
||||
@@ -1065,6 +1442,8 @@ func (im *importer) rule(chain string, p *parsed) {
|
||||
im.rawRule(r, p)
|
||||
}
|
||||
|
||||
var mapRefRe = regexp.MustCompile(`@([A-Za-z0-9_]+)`)
|
||||
|
||||
func (im *importer) rawRule(r *model.Rule, p *parsed) {
|
||||
text, ok := im.text[p.handle]
|
||||
if !ok {
|
||||
@@ -1075,10 +1454,20 @@ func (im *importer) rawRule(r *model.Rule, p *parsed) {
|
||||
if i := strings.LastIndex(text, ` comment "`); i >= 0 {
|
||||
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 == "" {
|
||||
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)
|
||||
why := strings.Join(p.unsupported, ", ")
|
||||
if why == "" {
|
||||
@@ -1100,6 +1489,9 @@ func (im *importer) dstnat(p *parsed) {
|
||||
break
|
||||
}
|
||||
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 {
|
||||
f.ToPort = ""
|
||||
}
|
||||
@@ -1136,7 +1528,7 @@ func forwardName(comment string) string {
|
||||
}
|
||||
|
||||
// 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".
|
||||
func (im *importer) nameForwards() {
|
||||
seen := map[string]bool{}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package importer
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
@@ -173,3 +174,130 @@ func TestImportedRulesetPassesNft(t *testing.T) {
|
||||
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"`
|
||||
}
|
||||
+115
-10
@@ -133,9 +133,21 @@ type Rule struct {
|
||||
// LimitOver, beyond) a rate.
|
||||
Limit *Limit `json:"limit,omitempty"`
|
||||
// ConnLimit matches when a source already has more than this many
|
||||
// connections (per source address).
|
||||
ConnLimit int `json:"conn_limit,omitempty"`
|
||||
Action string `json:"action"`
|
||||
// connections (per source address, grouped as GroupV4/GroupV6 say).
|
||||
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"`
|
||||
// EveryPacket checks the rule against every packet, before established
|
||||
// connections are accepted, so a drop or reject also stops connections
|
||||
// already open, not just new ones.
|
||||
EveryPacket bool `json:"every_packet,omitempty"`
|
||||
Action string `json:"action"`
|
||||
// RejectWith: "" (ICMP port/admin unreachable as fits) or tcp-reset.
|
||||
RejectWith string `json:"reject_with,omitempty"`
|
||||
Log bool `json:"log,omitempty"`
|
||||
@@ -155,18 +167,111 @@ func (r *Rule) Label() string {
|
||||
return fmt.Sprintf("%s #%d", r.Chain, r.ID)
|
||||
}
|
||||
|
||||
// Limit is a rate: Rate packets per Per (second, minute, hour, day), with
|
||||
// a burst.
|
||||
// Limit is a rate: Rate packets (or bytes, with Unit) per Per (second,
|
||||
// minute, hour, day), with a burst.
|
||||
type Limit struct {
|
||||
Rate int `json:"rate"`
|
||||
Per string `json:"per"`
|
||||
Burst int `json:"burst,omitempty"`
|
||||
Rate int `json:"rate"`
|
||||
Per string `json:"per"`
|
||||
// Burst is in the rate's unit.
|
||||
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
|
||||
// floods).
|
||||
Over bool `json:"over,omitempty"`
|
||||
// PerSource counts each source address on its own (a meter), instead
|
||||
// of all traffic the rule sees together.
|
||||
PerSource bool `json:"per_source,omitempty"`
|
||||
// of all traffic the rule sees together; PerDestination each
|
||||
// destination.
|
||||
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
|
||||
}
|
||||
|
||||
// ChecksEveryPacket reports whether a rule is checked before the built-in
|
||||
// that accepts established connections: it counts traffic, or asks to.
|
||||
func (r *Rule) ChecksEveryPacket() bool {
|
||||
return r.Raw == "" && (r.EveryPacket || r.CountsTraffic())
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -31,6 +31,11 @@ type Settings struct {
|
||||
// ProbationSeconds is how long a change made over the network has to
|
||||
// be confirmed before it is rolled back (0: the default).
|
||||
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".
|
||||
|
||||
+157
-1
@@ -8,6 +8,7 @@
|
||||
package nft
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
@@ -24,6 +25,7 @@ import (
|
||||
|
||||
"housecarl/internal/model"
|
||||
"housecarl/internal/render"
|
||||
"housecarl/internal/trace"
|
||||
)
|
||||
|
||||
// Runner runs nft.
|
||||
@@ -196,6 +198,9 @@ type Table struct {
|
||||
Counters map[string]Counter
|
||||
// Elements by set name (both families together).
|
||||
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 time.Time
|
||||
// Tables are all the machine's tables ("inet main"), when the whole
|
||||
@@ -203,6 +208,48 @@ type Table struct {
|
||||
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.
|
||||
type Counter struct {
|
||||
Packets uint64 `json:"packets"`
|
||||
@@ -223,9 +270,25 @@ type jsonSet struct {
|
||||
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`.
|
||||
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 {
|
||||
return t, nil
|
||||
}
|
||||
@@ -252,11 +315,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 {
|
||||
var s jsonSet
|
||||
if json.Unmarshal(raw, &s) != nil {
|
||||
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")
|
||||
if name == s.Name || strings.HasPrefix(s.Name, "meter_") || strings.HasPrefix(s.Name, "conn_") {
|
||||
continue
|
||||
@@ -307,6 +390,37 @@ type Firewall struct {
|
||||
mu sync.Mutex
|
||||
base map[string]Counter
|
||||
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 {
|
||||
@@ -337,9 +451,16 @@ func (f *Firewall) Apply(ctx context.Context, st *model.State) error {
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
f.restore = nil
|
||||
if f.base == nil {
|
||||
f.base, f.since = map[string]Counter{}, time.Now()
|
||||
}
|
||||
@@ -422,6 +543,41 @@ func (f *Firewall) ApplyScript(ctx context.Context, script string) error {
|
||||
return f.Runner.ApplyScript(ctx, script)
|
||||
}
|
||||
|
||||
// Trace follows packets: it starts `nft monitor trace`, puts the tracing
|
||||
// table (script) in, and passes each line to line until ctx ends; then
|
||||
// it takes the table out again, whatever happened.
|
||||
func (f *Firewall) Trace(ctx context.Context, script string, line func(string)) error {
|
||||
c := f.Runner.cmd(ctx, "monitor", "trace")
|
||||
out, err := c.StdoutPipe()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var errb bytes.Buffer
|
||||
c.Stderr = &errb
|
||||
if err := c.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Runner.run(context.Background(), "", "delete", "table", "inet", trace.Table)
|
||||
if err := f.Runner.ApplyScript(ctx, script); err != nil {
|
||||
c.Process.Kill()
|
||||
c.Wait()
|
||||
return fmt.Errorf("starting the trace: %w", err)
|
||||
}
|
||||
sc := bufio.NewScanner(out)
|
||||
sc.Buffer(make([]byte, 64<<10), 1<<20)
|
||||
for sc.Scan() {
|
||||
line(sc.Text())
|
||||
}
|
||||
err = c.Wait()
|
||||
if ctx.Err() != nil {
|
||||
return nil // stopped, or its time was up
|
||||
}
|
||||
if err != nil && errb.Len() > 0 {
|
||||
return fmt.Errorf("nft monitor trace: %s", strings.TrimSpace(errb.String()))
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteTable deletes another table (takeover).
|
||||
func (f *Firewall) DeleteTable(ctx context.Context, family, name string) error {
|
||||
if name == render.Table && family == "inet" {
|
||||
|
||||
@@ -125,3 +125,86 @@ func TestFirewallInContainer(t *testing.T) {
|
||||
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 (
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
@@ -33,14 +34,25 @@ type Options struct {
|
||||
// Elements are dynamic sets' current entries, re-added in the same
|
||||
// transaction so a reload keeps them: set name → entries.
|
||||
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 {
|
||||
Addr string `json:"address"`
|
||||
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.
|
||||
type Ruleset struct {
|
||||
Text string
|
||||
@@ -86,7 +98,8 @@ type renderer struct {
|
||||
st *model.State
|
||||
opt Options
|
||||
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
|
||||
}
|
||||
|
||||
@@ -238,7 +251,8 @@ func (r *renderer) early() {
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -265,6 +279,9 @@ func (r *renderer) input() {
|
||||
if b.Loopback {
|
||||
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()
|
||||
ext := st.ExternalInterfaces()
|
||||
if b.ICMPv6 {
|
||||
@@ -282,7 +299,7 @@ func (r *renderer) input() {
|
||||
if b.DHCPv6Client && len(ext) > 0 {
|
||||
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.end()
|
||||
}
|
||||
@@ -295,6 +312,7 @@ func (r *renderer) forward(flowtable bool) {
|
||||
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.rules(model.ChainForward, true)
|
||||
if flowtable {
|
||||
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.forwardAccepts()
|
||||
r.rules(model.ChainForward)
|
||||
r.rules(model.ChainForward, false)
|
||||
r.policyTail("forward", st.Settings.ForwardPolicy)
|
||||
r.end()
|
||||
}
|
||||
@@ -311,6 +329,7 @@ func (r *renderer) forward(flowtable bool) {
|
||||
func (r *renderer) output() {
|
||||
st := r.st
|
||||
r.chain("output", "output", "filter", st.Settings.OutputPolicy)
|
||||
r.rules(model.ChainOutput, true)
|
||||
if st.Settings.OutputPolicy == model.PolicyDrop {
|
||||
if st.Settings.Builtins.Loopback {
|
||||
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.rules(model.ChainOutput)
|
||||
r.rules(model.ChainOutput, false)
|
||||
r.policyTail("output", st.Settings.OutputPolicy)
|
||||
r.end()
|
||||
}
|
||||
@@ -341,9 +360,11 @@ func (r *renderer) policyTail(chain, policy string) {
|
||||
|
||||
// ---- 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 {
|
||||
if rule.Chain != chain || rule.Disabled {
|
||||
if rule.Chain != chain || rule.Disabled || rule.ChecksEveryPacket() != traffic {
|
||||
continue
|
||||
}
|
||||
obj := rule.Label()
|
||||
@@ -370,8 +391,8 @@ func (r *renderer) rules(chain string) {
|
||||
tail = append(tail, hours(rule.HourFrom, rule.HourTo))
|
||||
}
|
||||
verdict := r.verdict(rule, mark)
|
||||
perSource := rule.ConnLimit > 0 || rule.Limit != nil && rule.Limit.PerSource
|
||||
for _, line := range r.expand(rule.Match, perSource) {
|
||||
perAddress := rule.ConnLimit > 0 || rule.Limit.PerAddress() != "" || rule.Quota.PerAddress() != ""
|
||||
for _, line := range r.expand(rule.Match, perAddress) {
|
||||
parts := append([]string{}, line.parts...)
|
||||
parts = append(parts, tail...)
|
||||
if l := rule.Limit; l != nil {
|
||||
@@ -380,6 +401,9 @@ func (r *renderer) rules(chain string) {
|
||||
if rule.ConnLimit > 0 {
|
||||
parts = append(parts, r.connLimit(rule, line))
|
||||
}
|
||||
if rule.Quota != nil {
|
||||
parts = append(parts, r.quota(rule, line))
|
||||
}
|
||||
parts = append(parts, verdict)
|
||||
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, " ")
|
||||
}
|
||||
|
||||
// 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 {
|
||||
rate := fmt.Sprintf("limit rate %s%d/%s", map[bool]string{true: "over ", false: ""}[l.Over], l.Rate, l.Per)
|
||||
if l.Burst > 0 {
|
||||
rate += fmt.Sprintf(" burst %d packets", l.Burst)
|
||||
unit := ""
|
||||
if _, ok := model.ByteUnits[l.Unit]; ok {
|
||||
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
|
||||
}
|
||||
name := fmt.Sprintf("meter_r%d_%s", rule.ID, line.fam)
|
||||
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]))
|
||||
}
|
||||
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 {
|
||||
@@ -431,12 +486,70 @@ func (r *renderer) connLimit(rule *model.Rule, line expanded) string {
|
||||
if !r.hasMeter(name) {
|
||||
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 {
|
||||
for _, m := range r.meters {
|
||||
if strings.Contains(m, "set "+name+" ") {
|
||||
if strings.Contains(m, "set "+name+" ") || strings.Contains(m, "quota "+name+" ") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +66,17 @@ 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{Services: []string{"ping"}}})
|
||||
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.ChainForward, model.Rule{Match: model.Match{Sources: []string{"10.0.10.66"}}, Action: model.ActionReject, EveryPacket: true, Comment: "blocked now"})
|
||||
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"})
|
||||
st.Forwards = []*model.PortForward{
|
||||
@@ -87,8 +98,26 @@ func router() *model.State {
|
||||
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) {
|
||||
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
|
||||
for _, want := range []string{
|
||||
"table inet housecarl {}\ndelete table inet housecarl\ntable inet housecarl {",
|
||||
@@ -108,7 +137,16 @@ func TestRenderRouter(t *testing.T) {
|
||||
`ip saddr @trusted_v4 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 "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 #`,
|
||||
`tcp dport 22 add @conn_r`,
|
||||
`counter reject with tcp reset comment`,
|
||||
@@ -139,6 +177,19 @@ func TestRenderRouter(t *testing.T) {
|
||||
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")
|
||||
}
|
||||
if now := strings.Index(fwd, "blocked now"); now < 0 || now > est {
|
||||
t.Error("the every-packet rule comes after established")
|
||||
}
|
||||
// The trusted masquerade has no IPv6 part: masquerading the IPv6 LAN
|
||||
// isn't wanted (its sources are global).
|
||||
if strings.Contains(text, "ip6 saddr @trusted_v6 counter masquerade") {
|
||||
@@ -167,7 +218,8 @@ func TestRenderedRulesetPassesNft(t *testing.T) {
|
||||
t.Skip("HOUSECARL_NFT not set")
|
||||
}
|
||||
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.Stdin = strings.NewReader(rs.Text)
|
||||
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
|
||||
}
|
||||
+143
-185
@@ -7,6 +7,8 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
udiff "github.com/aymanbagabas/go-udiff"
|
||||
|
||||
"housecarl/internal/api"
|
||||
"housecarl/internal/auth"
|
||||
"housecarl/internal/engine"
|
||||
@@ -14,26 +16,48 @@ import (
|
||||
"housecarl/internal/store"
|
||||
)
|
||||
|
||||
// change applies an edit through the engine and answers it; object picks
|
||||
// what to return from the new state. 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 {
|
||||
// change makes an operation: at once through the engine, or, with a
|
||||
// changeset named (?changeset=ID or X-Housecarl-Changeset), staged in it.
|
||||
// A network change starts (or joins) the probation.
|
||||
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 {
|
||||
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 {
|
||||
s.writeErr(w, r, err, res)
|
||||
return nil
|
||||
}
|
||||
s.afterChange(r.Context(), p, res)
|
||||
// POST to a collection creates; POST to an object (move) changes it.
|
||||
s.respondChange(w, r, res, object, r.Method == http.MethodPost && r.PathValue("ref") == "")
|
||||
out := s.mutation(res, objectOf(&op, id))
|
||||
out.ConfirmToken = s.issueCheck(w, r, p, res)
|
||||
writeJSON(w, s.mutationStatus(res, op.Op == model.OpCreate), out)
|
||||
return nil
|
||||
}
|
||||
|
||||
// respondChange answers a change: 201 when it created something.
|
||||
func (s *Server) respondChange(w http.ResponseWriter, r *http.Request, res *engine.Result, object func(st *model.State) any, creates bool) {
|
||||
// mutation is the answer to a change: what changed, warnings, the object
|
||||
// (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}
|
||||
if out.Changes == nil {
|
||||
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) {
|
||||
out.ConfirmBy = &pr.Deadline
|
||||
}
|
||||
if object != nil && (res.Applied || res.DryRun) {
|
||||
if v := object(s.eng.State()); v != nil {
|
||||
if object != nil && res.After != nil && (res.Applied || res.DryRun) {
|
||||
if v := object(res.After); v != nil {
|
||||
out.Object, _ = json.Marshal(v)
|
||||
}
|
||||
}
|
||||
status := http.StatusOK
|
||||
if res.Applied && creates {
|
||||
status = http.StatusCreated
|
||||
if res.DryRun && len(res.Changes) > 0 {
|
||||
out.Diff = s.rulesetDiff(res.Before, res.After)
|
||||
}
|
||||
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.
|
||||
@@ -84,16 +131,29 @@ func (s *Server) listObjects(w http.ResponseWriter, r *http.Request, p *auth.Pri
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
st := s.eng.State()
|
||||
cur, st, draft, err := s.viewState(w, r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out := []any{}
|
||||
q := r.URL.Query()
|
||||
for _, o := range st.Objects(kind) {
|
||||
if c := q.Get("chain"); c != "" {
|
||||
if rule, ok := o.(*model.Rule); ok && rule.Chain != c {
|
||||
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) {
|
||||
if c != "" {
|
||||
if rule, ok := o.(*model.Rule); ok && rule.Chain != c {
|
||||
continue
|
||||
}
|
||||
}
|
||||
out = append(out, o)
|
||||
}
|
||||
out = append(out, o)
|
||||
}
|
||||
// Services: the built-in ones too, unless overridden.
|
||||
if kind == model.KindService && !queryBool(r, "own") {
|
||||
@@ -102,9 +162,14 @@ func (s *Server) listObjects(w http.ResponseWriter, r *http.Request, p *auth.Pri
|
||||
out = append(out, b)
|
||||
}
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
return out[i].(*model.Service).Name < out[j].(*model.Service).Name
|
||||
})
|
||||
name := func(x any) string {
|
||||
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)
|
||||
return nil
|
||||
@@ -118,7 +183,10 @@ func (s *Server) getObject(w http.ResponseWriter, r *http.Request, p *auth.Princ
|
||||
if err != nil {
|
||||
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"))
|
||||
if err != nil && kind == model.KindService {
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
o, err := decodeObject(kind, raw)
|
||||
if err != nil {
|
||||
// Refuse a bad object now, not when it is applied.
|
||||
if _, err := decodeObject(kind, raw); err != nil {
|
||||
return 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"}
|
||||
}
|
||||
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) })
|
||||
return s.change(w, r, p, model.Op{Op: model.OpCreate, Kind: kind, Data: raw})
|
||||
}
|
||||
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
delete(patch, "id")
|
||||
var id int64
|
||||
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) })
|
||||
data, _ := json.Marshal(patch)
|
||||
return s.change(w, r, p, model.Op{Op: model.OpUpdate, Kind: kind, Ref: r.PathValue("ref"), Data: data})
|
||||
}
|
||||
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
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
|
||||
}
|
||||
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)
|
||||
return s.change(w, r, p, model.Op{Op: model.OpDelete, Kind: kind, Ref: r.PathValue("ref")})
|
||||
}
|
||||
|
||||
// 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 ""
|
||||
}
|
||||
|
||||
// moveObject 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.
|
||||
// moveObject reorders a rule or NAT rule (moveOp).
|
||||
func (s *Server) moveObject(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
kind, err := pathKind(r)
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
var id int64
|
||||
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()
|
||||
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) })
|
||||
data, _ := json.Marshal(in)
|
||||
return s.change(w, r, p, model.Op{Op: model.OpMove, Kind: kind, Ref: r.PathValue("ref"), Data: data})
|
||||
}
|
||||
|
||||
// ---- 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 {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -458,35 +412,8 @@ func (s *Server) updateSettings(w http.ResponseWriter, r *http.Request, p *auth.
|
||||
if err := decodeJSON(r, &patch); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.change(w, r, p, "", func(st *model.State, _ func() int64) error {
|
||||
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
|
||||
}, func(st *model.State) any { return st.Settings })
|
||||
data, _ := json.Marshal(patch)
|
||||
return s.change(w, r, p, model.Op{Op: model.OpSettings, Data: data})
|
||||
}
|
||||
|
||||
// ---- 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 {
|
||||
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.Write([]byte(rs.Text))
|
||||
return nil
|
||||
@@ -542,13 +473,30 @@ func (s *Server) revertHistory(w http.ResponseWriter, r *http.Request, p *auth.P
|
||||
if err != nil {
|
||||
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)})
|
||||
if err != nil {
|
||||
s.writeErr(w, r, err, res)
|
||||
return nil
|
||||
}
|
||||
s.afterChange(r.Context(), p, res)
|
||||
s.respondChange(w, r, res, nil, false)
|
||||
s.respondChange(w, r, p, res, nil, false)
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
if err := notStaged(r, "an import"); err != nil {
|
||||
return err
|
||||
}
|
||||
var in api.ImportInput
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
return err
|
||||
@@ -48,6 +51,11 @@ func (s *Server) importNft(w http.ResponseWriter, r *http.Request, p *auth.Princ
|
||||
return nil
|
||||
}
|
||||
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}
|
||||
if out.Changes == nil {
|
||||
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 {
|
||||
out.ConfirmBy = &pr.Deadline
|
||||
}
|
||||
out.ConfirmToken = s.issueCheck(w, r, p, er)
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
if err := notStaged(r, "a takeover"); err != nil {
|
||||
return err
|
||||
}
|
||||
var in api.TakeoverInput
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
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 {
|
||||
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
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"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 {
|
||||
out.OtherTables = otherTables(data)
|
||||
}
|
||||
out.Problems = append(out.Problems, s.hostProblems(r.Context(), out.OtherTables)...)
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
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.
|
||||
func (s *Server) hostInterfaces(st *model.State) []api.HostInterface {
|
||||
out := []api.HostInterface{}
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"housecarl/internal/api"
|
||||
"housecarl/internal/auth"
|
||||
"housecarl/internal/model"
|
||||
"housecarl/internal/trace"
|
||||
)
|
||||
|
||||
// Traffic as it happens: the connections the kernel tracks (and closing
|
||||
// them, so the rules check them again), and tracing packets through the
|
||||
// whole ruleset.
|
||||
|
||||
// Conntrack lists and closes tracked connections (internal/conntrack).
|
||||
type Conntrack interface {
|
||||
List(ctx context.Context) ([]api.Connection, error)
|
||||
Close(ctx context.Context, c api.Connection) error
|
||||
}
|
||||
|
||||
// SetConntrack gives the server the kernel's connection tracking.
|
||||
func (s *Server) SetConntrack(c Conntrack) { s.conns = c }
|
||||
|
||||
func (s *Server) conntrack() (Conntrack, error) {
|
||||
if s.conns == nil {
|
||||
return nil, &httpError{http.StatusServiceUnavailable, "unavailable", "connection tracking isn't available here"}
|
||||
}
|
||||
return s.conns, nil
|
||||
}
|
||||
|
||||
// connMatches: a search in a connection's addresses, ports (":443"),
|
||||
// protocol, state and NAT addresses.
|
||||
func connMatches(c api.Connection, q string) bool {
|
||||
if q == "" {
|
||||
return true
|
||||
}
|
||||
hay := strings.ToLower(strings.Join([]string{c.Protocol, c.Source, ":" + strconv.Itoa(c.SourcePort), c.Destination, ":" + strconv.Itoa(c.DestinationPort),
|
||||
c.DNATTo, c.SNATTo, c.State}, " ") + " ")
|
||||
for _, w := range strings.Fields(strings.ToLower(q)) {
|
||||
if strings.HasPrefix(w, ":") {
|
||||
w += " " // a whole port
|
||||
}
|
||||
if !strings.Contains(hay, w) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Server) listConnections(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := require(p, auth.ResLog, false); err != nil {
|
||||
return err
|
||||
}
|
||||
ct, err := s.conntrack()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
all, err := ct.List(r.Context())
|
||||
if err != nil {
|
||||
return fmt.Errorf("listing connections: %w", err)
|
||||
}
|
||||
q := r.URL.Query().Get("q")
|
||||
out := []api.Connection{}
|
||||
for _, c := range all {
|
||||
if connMatches(c, q) {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].Source != out[j].Source {
|
||||
return out[i].Source < out[j].Source
|
||||
}
|
||||
return out[i].Destination < out[j].Destination
|
||||
})
|
||||
if n := queryInt(r, "limit", 1000); n > 0 && len(out) > n {
|
||||
out = out[:n]
|
||||
}
|
||||
w.Header().Set("X-Total-Count", strconv.Itoa(len(all)))
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
return nil
|
||||
}
|
||||
|
||||
// closeConnections deletes tracking entries: the rules check those
|
||||
// connections again with their next packet.
|
||||
func (s *Server) closeConnections(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := require(p, auth.ResConfig, true); err != nil {
|
||||
return err
|
||||
}
|
||||
ct, err := s.conntrack()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var in api.CloseConnections
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
return err
|
||||
}
|
||||
list := in.Connections
|
||||
if strings.TrimSpace(in.Search) != "" {
|
||||
all, err := ct.List(r.Context())
|
||||
if err != nil {
|
||||
return fmt.Errorf("listing connections: %w", err)
|
||||
}
|
||||
for _, c := range all {
|
||||
if connMatches(c, in.Search) {
|
||||
list = append(list, c)
|
||||
}
|
||||
}
|
||||
} else if len(list) == 0 {
|
||||
return badRequest("say which connections: a list, or a search")
|
||||
}
|
||||
closed := 0
|
||||
for _, c := range list {
|
||||
if err := ct.Close(r.Context(), c); err != nil {
|
||||
return fmt.Errorf("closing %s %s -> %s: %w", c.Protocol, c.Source, c.Destination, err)
|
||||
}
|
||||
closed++
|
||||
}
|
||||
what := in.Search
|
||||
if what == "" && len(list) == 1 {
|
||||
what = fmt.Sprintf("%s %s:%d -> %s:%d", list[0].Protocol, list[0].Source, list[0].SourcePort, list[0].Destination, list[0].DestinationPort)
|
||||
}
|
||||
s.eng.Audit(r.Context(), p, "connections.close", what, "success", fmt.Sprintf("%d closed", closed))
|
||||
writeJSON(w, http.StatusOK, map[string]int{"closed": closed})
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- tracing
|
||||
|
||||
type tracing struct {
|
||||
mu sync.Mutex
|
||||
status api.TraceStatus
|
||||
tracer *trace.Tracer
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
func (s *Server) startTrace(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := require(p, auth.ResConfig, true); err != nil {
|
||||
return err
|
||||
}
|
||||
var in api.TraceInput
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
return err
|
||||
}
|
||||
if in.Seconds == 0 {
|
||||
in.Seconds = 60
|
||||
}
|
||||
if in.Seconds < 5 || in.Seconds > 600 {
|
||||
return badRequest("trace for 5 to 600 seconds")
|
||||
}
|
||||
script, err := trace.Script(in)
|
||||
if err != nil {
|
||||
return badRequest("%v", err)
|
||||
}
|
||||
t := &s.tracing
|
||||
t.mu.Lock()
|
||||
if t.status.Running {
|
||||
t.mu.Unlock()
|
||||
return conflict("a trace is running already (started %s); stop it first", t.status.Started.Format("15:04:05"))
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(in.Seconds)*time.Second)
|
||||
now := s.nowFn()
|
||||
t.status = api.TraceStatus{Running: true, Input: in, Started: now, Ends: now.Add(time.Duration(in.Seconds) * time.Second), Packets: []api.TracePacket{}}
|
||||
t.tracer = &trace.Tracer{Now: s.nowFn}
|
||||
t.cancel = cancel
|
||||
t.mu.Unlock()
|
||||
s.eng.Audit(r.Context(), p, "trace.start", traceWhat(in), "success", fmt.Sprintf("%d seconds", in.Seconds))
|
||||
go func() {
|
||||
defer cancel()
|
||||
err := s.fw.Trace(ctx, script, func(line string) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
t.tracer.Line(line)
|
||||
if t.tracer.Full() {
|
||||
cancel()
|
||||
}
|
||||
})
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
t.status.Running = false
|
||||
if err != nil {
|
||||
t.status.Error = err.Error()
|
||||
s.log.Warn("tracing failed", "err", err)
|
||||
}
|
||||
}()
|
||||
writeJSON(w, http.StatusOK, s.traceStatus())
|
||||
return nil
|
||||
}
|
||||
|
||||
func traceWhat(in api.TraceInput) string {
|
||||
var parts []string
|
||||
if in.Address != "" {
|
||||
parts = append(parts, in.Address)
|
||||
}
|
||||
if in.Protocol != "" {
|
||||
parts = append(parts, in.Protocol)
|
||||
}
|
||||
if in.Port != 0 {
|
||||
parts = append(parts, "port "+strconv.Itoa(in.Port))
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
// traceStatus is the trace with each step named after Housecarl's object.
|
||||
func (s *Server) traceStatus() api.TraceStatus {
|
||||
t := &s.tracing
|
||||
t.mu.Lock()
|
||||
out := t.status
|
||||
if t.tracer != nil {
|
||||
out.Packets = t.tracer.Packets()
|
||||
}
|
||||
t.mu.Unlock()
|
||||
if out.Packets == nil {
|
||||
out.Packets = []api.TracePacket{}
|
||||
}
|
||||
st := s.eng.State()
|
||||
for i := range out.Packets {
|
||||
p := &out.Packets[i]
|
||||
for j := range p.Steps {
|
||||
p.Steps[j].Label = markLabel(st, p.Steps[j])
|
||||
}
|
||||
p.DecidedBy.Label = markLabel(st, p.DecidedBy)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// markLabel names the Housecarl object behind a step: "rule input #12
|
||||
// (ssh)", "built-in: established", "port forward web", "input policy".
|
||||
func markLabel(st *model.State, step api.TraceStep) string {
|
||||
if step.Kind == "policy" {
|
||||
if step.Table == "inet housecarl" {
|
||||
return step.Chain + " policy"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
m := step.Mark
|
||||
kind, id := "", int64(0)
|
||||
switch {
|
||||
case m == "":
|
||||
return ""
|
||||
case strings.HasPrefix(m, "b-"):
|
||||
return "built-in: " + strings.TrimPrefix(m, "b-")
|
||||
case strings.HasPrefix(m, "r"):
|
||||
kind = model.KindRule
|
||||
case strings.HasPrefix(m, "f"):
|
||||
kind = model.KindForward
|
||||
case strings.HasPrefix(m, "s"):
|
||||
kind = model.KindSNAT
|
||||
case strings.HasPrefix(m, "d"):
|
||||
kind = model.KindRedirect
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
num := strings.TrimLeft(m[1:], "0123456789")
|
||||
id, _ = strconv.ParseInt(strings.TrimSuffix(m[1:], num), 10, 64)
|
||||
if o := st.ByID(kind, id); o != nil {
|
||||
label := o.Label() // "source NAT #3 (…)", "redirect #4", a forward's name…
|
||||
switch {
|
||||
case kind == model.KindRule:
|
||||
label = "rule " + label
|
||||
case kind == model.KindForward && !strings.HasPrefix(label, "port forward"):
|
||||
label = "port forward " + label
|
||||
}
|
||||
if num != "" {
|
||||
label += " (" + strings.TrimPrefix(num, "-") + ")"
|
||||
}
|
||||
return label
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *Server) getTrace(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := require(p, auth.ResLog, false); err != nil {
|
||||
return err
|
||||
}
|
||||
writeJSON(w, http.StatusOK, s.traceStatus())
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) stopTrace(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := require(p, auth.ResConfig, true); err != nil {
|
||||
return err
|
||||
}
|
||||
t := &s.tracing
|
||||
t.mu.Lock()
|
||||
if t.cancel != nil && t.status.Running {
|
||||
t.cancel()
|
||||
}
|
||||
t.mu.Unlock()
|
||||
// Let the trace end, so the answer shows it stopped.
|
||||
for i := 0; i < 50; i++ {
|
||||
t.mu.Lock()
|
||||
running := t.status.Running
|
||||
t.mu.Unlock()
|
||||
if !running {
|
||||
break
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, s.traceStatus())
|
||||
return nil
|
||||
}
|
||||
@@ -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 (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -31,6 +33,21 @@ type probation struct {
|
||||
timer *time.Timer
|
||||
// restores are nft scripts that bring back tables a takeover deleted.
|
||||
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.
|
||||
@@ -88,6 +105,60 @@ func (s *Server) startProbation(ctx context.Context, p *auth.Principal, historyI
|
||||
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() {
|
||||
if err := s.fw.SaveBoot(s.eng.State()); err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
// 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) {
|
||||
return s.confirmWith(ctx, p, "")
|
||||
}
|
||||
|
||||
func (s *Server) confirmWith(ctx context.Context, p *auth.Principal, how string) (int, error) {
|
||||
pr := &s.probation
|
||||
pr.mu.Lock()
|
||||
defer pr.mu.Unlock()
|
||||
@@ -122,10 +197,14 @@ func (s *Server) confirm(ctx context.Context, p *auth.Principal) (int, error) {
|
||||
if pr.timer != nil {
|
||||
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.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
|
||||
}
|
||||
|
||||
@@ -136,7 +215,7 @@ func (s *Server) rollback(why string) {
|
||||
pr.mu.Lock()
|
||||
defer pr.mu.Unlock()
|
||||
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-- {
|
||||
if err := s.fw.ApplyScript(ctx, restores[i]); err != nil {
|
||||
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
|
||||
// a refused change.
|
||||
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
|
||||
if errors.As(err, &he) {
|
||||
writeError(w, he.status, he.code, he.msg)
|
||||
|
||||
@@ -41,6 +41,21 @@ func (s *Server) routes() {
|
||||
m.HandleFunc("GET "+p+"/sets/{ref}/elements", s.authed(s.listElements))
|
||||
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("GET "+p+"/quotas", s.authed(s.listQuotas))
|
||||
m.HandleFunc("GET "+p+"/connections", s.authed(s.listConnections))
|
||||
m.HandleFunc("POST "+p+"/connections/close", s.authed(s.closeConnections))
|
||||
m.HandleFunc("GET "+p+"/trace", s.authed(s.getTrace))
|
||||
m.HandleFunc("POST "+p+"/trace", s.authed(s.startTrace))
|
||||
m.HandleFunc("DELETE "+p+"/trace", s.authed(s.stopTrace))
|
||||
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("POST "+p+"/{kind}", s.authed(s.createObject))
|
||||
m.HandleFunc("GET "+p+"/{kind}/{ref}", s.authed(s.getObject))
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package server
|
||||
|
||||
import "encoding/json"
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"housecarl/internal/trace"
|
||||
)
|
||||
|
||||
// otherTables lists the tables in an `nft -j list ruleset` other than
|
||||
// Housecarl's ("inet main").
|
||||
@@ -18,7 +22,7 @@ func otherTables(data []byte) []string {
|
||||
continue
|
||||
}
|
||||
var t struct{ Family, Name string }
|
||||
if json.Unmarshal(raw, &t) == nil && !(t.Family == "inet" && t.Name == "housecarl") {
|
||||
if json.Unmarshal(raw, &t) == nil && !(t.Family == "inet" && (t.Name == "housecarl" || t.Name == trace.Table)) {
|
||||
out = append(out, t.Family+" "+t.Name)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,10 @@ type Firewall interface {
|
||||
TableText(ctx context.Context, family, name string) (string, error)
|
||||
ApplyScript(ctx context.Context, script string) 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
|
||||
// Trace follows packets (internal/trace) until ctx ends.
|
||||
Trace(ctx context.Context, script string, line func(string)) error
|
||||
}
|
||||
|
||||
// Server is the HTTP server.
|
||||
@@ -86,7 +90,16 @@ type Server struct {
|
||||
|
||||
probation probation
|
||||
logs *logRing
|
||||
nowFn func() time.Time
|
||||
// unitLinks are where an enabled nftables.service shows (tests change
|
||||
// them).
|
||||
unitLinks []string
|
||||
// conns is the kernel's connection tracking (nil: unavailable).
|
||||
conns Conntrack
|
||||
// tracing is the packet trace running (or the last one).
|
||||
tracing tracing
|
||||
// csMu orders changes to changesets (staging, applying, discarding).
|
||||
csMu sync.Mutex
|
||||
nowFn func() time.Time
|
||||
// probationFor overrides the settings' probation (tests).
|
||||
probationFor time.Duration
|
||||
}
|
||||
@@ -109,7 +122,7 @@ func New(cfg Config, eng *engine.Engine, fw Firewall, ifaces Interfaces, log *sl
|
||||
log = slog.Default()
|
||||
}
|
||||
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()
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
@@ -28,13 +29,16 @@ import (
|
||||
// fakeFW stands in for nftables: it records the applied states and keeps
|
||||
// dynamic sets' entries.
|
||||
type fakeFW struct {
|
||||
mu sync.Mutex
|
||||
applied []*model.State
|
||||
boot *model.State
|
||||
elements map[string][]render.Element
|
||||
applyErr error
|
||||
deleted []string
|
||||
restored []string
|
||||
mu sync.Mutex
|
||||
applied []*model.State
|
||||
boot *model.State
|
||||
elements map[string][]render.Element
|
||||
applyErr error
|
||||
deleted []string
|
||||
restored []string
|
||||
resets []string
|
||||
traced string
|
||||
traceLines []string
|
||||
}
|
||||
|
||||
func (f *fakeFW) Check(_ context.Context, st *model.State) error { return nil }
|
||||
@@ -88,6 +92,23 @@ func (f *fakeFW) DeleteTable(_ context.Context, fam, name string) error {
|
||||
f.deleted = append(f.deleted, fam+" "+name)
|
||||
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) Trace(ctx context.Context, script string, line func(string)) error {
|
||||
f.mu.Lock()
|
||||
f.traced = script
|
||||
lines := f.traceLines
|
||||
f.mu.Unlock()
|
||||
for _, l := range lines {
|
||||
line(l)
|
||||
}
|
||||
<-ctx.Done()
|
||||
return nil
|
||||
}
|
||||
func (f *fakeFW) TableText(_ context.Context, fam, name string) (string, error) {
|
||||
return "table " + fam + " " + name + " {}", nil
|
||||
}
|
||||
@@ -538,3 +559,410 @@ func TestImportAndTakeover(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeConns struct {
|
||||
list []api.Connection
|
||||
closed []api.Connection
|
||||
}
|
||||
|
||||
func (f *fakeConns) List(context.Context) ([]api.Connection, error) { return f.list, nil }
|
||||
func (f *fakeConns) Close(_ context.Context, c api.Connection) error {
|
||||
f.closed = append(f.closed, c)
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestConnections: connections listed and searched; closed one by one, or
|
||||
// all a search finds; closing needs config:write, and is audited.
|
||||
func TestConnections(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
var code int
|
||||
if code = e.call(e.token, "GET", "/connections", nil, nil); code != 503 {
|
||||
t.Errorf("without conntrack: %d", code)
|
||||
}
|
||||
fc := &fakeConns{list: []api.Connection{
|
||||
{Family: "ipv4", Protocol: "tcp", Source: "192.168.1.50", SourcePort: 51432, Destination: "198.51.100.9", DestinationPort: 443, SNATTo: "203.0.113.10", State: "established"},
|
||||
{Family: "ipv4", Protocol: "udp", Source: "192.168.1.51", SourcePort: 5353, Destination: "192.168.1.1", DestinationPort: 53},
|
||||
{Family: "ipv6", Protocol: "tcp", Source: "2001:db8:1::50", SourcePort: 40000, Destination: "2001:db8:2::9", DestinationPort: 4430},
|
||||
}}
|
||||
e.srv.SetConntrack(fc)
|
||||
var cs []api.Connection
|
||||
e.call(e.token, "GET", "/connections?q=192.168.1.50", nil, &cs)
|
||||
if len(cs) != 1 || cs[0].DestinationPort != 443 {
|
||||
t.Errorf("search by address: %+v", cs)
|
||||
}
|
||||
e.call(e.token, "GET", "/connections?q=:443", nil, &cs)
|
||||
if len(cs) != 1 || cs[0].Source != "192.168.1.50" {
|
||||
t.Errorf("search by port (not :4430): %+v", cs)
|
||||
}
|
||||
var out map[string]int
|
||||
if code := e.call(e.token, "POST", "/connections/close", api.CloseConnections{Search: "tcp"}, &out); code != 200 || out["closed"] != 2 || len(fc.closed) != 2 {
|
||||
t.Errorf("close a search: %d %+v %+v", code, out, fc.closed)
|
||||
}
|
||||
if code := e.call(e.token, "POST", "/connections/close", api.CloseConnections{}, nil); code != 400 {
|
||||
t.Errorf("close nothing said: %d", code)
|
||||
}
|
||||
var tok api.Token
|
||||
e.call(e.token, "POST", "/me/tokens", api.TokenInput{Name: "monitor", Role: "log"}, &tok)
|
||||
if code := e.call(tok.Secret, "GET", "/connections", nil, nil); code != 200 {
|
||||
t.Errorf("listing with log:read: %d", code)
|
||||
}
|
||||
if code := e.call(tok.Secret, "POST", "/connections/close", api.CloseConnections{Search: "udp"}, nil); code != 403 {
|
||||
t.Errorf("closing with log:read: %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTrace: a trace runs with the filter's table, collects packets named
|
||||
// after Housecarl's objects, and stops when asked; one at a time.
|
||||
func TestTrace(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
var mr api.MutationResponse
|
||||
e.call(e.token, "POST", "/rules", model.Rule{Chain: "input", Match: model.Match{Services: []string{"tcp/9999"}}, Action: "drop", Comment: "block 9999"}, &mr)
|
||||
var rule model.Rule
|
||||
json.Unmarshal(mr.Object, &rule)
|
||||
e.fw.traceLines = []string{
|
||||
`trace id 1 inet housecarl input packet: iif "wan" ip saddr 198.51.100.7 ip daddr 203.0.113.10 tcp sport 4000 tcp dport 9999 tcp flags == syn`,
|
||||
`trace id 1 inet housecarl input rule ct state established,related accept comment "hc:b-established" (verdict continue)`,
|
||||
fmt.Sprintf(`trace id 1 inet housecarl input rule tcp dport 9999 counter drop comment "hc:r%d block 9999" (verdict drop)`, rule.ID),
|
||||
}
|
||||
var st api.TraceStatus
|
||||
if code := e.call(e.token, "POST", "/trace", api.TraceInput{Port: 9999, Seconds: 30}, &st); code != 200 || !st.Running {
|
||||
t.Fatalf("start: %d %+v", code, st)
|
||||
}
|
||||
if code := e.call(e.token, "POST", "/trace", api.TraceInput{Port: 22}, nil); code != 409 {
|
||||
t.Errorf("a second trace: %d", code)
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
e.call(e.token, "GET", "/trace", nil, &st)
|
||||
if len(st.Packets) != 1 || st.Packets[0].Verdict != "dropped" || st.Packets[0].DecidedBy.Label != "rule input #"+itoa(rule.ID)+" (block 9999)" ||
|
||||
st.Packets[0].Steps[0].Label != "built-in: established" {
|
||||
t.Errorf("packets: %+v", st.Packets)
|
||||
}
|
||||
if !strings.Contains(e.fw.traced, "th dport 9999") {
|
||||
t.Errorf("the trace table: %s", e.fw.traced)
|
||||
}
|
||||
if code := e.call(e.token, "DELETE", "/trace", nil, &st); code != 200 || st.Running || len(st.Packets) != 1 {
|
||||
t.Errorf("stop: %d %+v", code, st)
|
||||
}
|
||||
if code := e.call(e.token, "POST", "/trace", api.TraceInput{}, nil); code != 400 {
|
||||
t.Errorf("tracing everything: %d", code)
|
||||
}
|
||||
}
|
||||
@@ -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 ''
|
||||
);
|
||||
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);
|
||||
`,
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
// Package trace follows packets through the whole ruleset, every table's
|
||||
// chains, with nftables' own tracing: a table of its own (housecarl-trace,
|
||||
// hooked before everything else) marks the packets a filter picks
|
||||
// (meta nftrace set 1), and `nft monitor trace` reports each chain and
|
||||
// rule they pass, and what decided their fate. Packets are counted per
|
||||
// trace id: one packet through the hooks it crosses.
|
||||
package trace
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"housecarl/internal/api"
|
||||
)
|
||||
|
||||
// Table is the tracing table's name (family inet).
|
||||
const Table = "housecarl-trace"
|
||||
|
||||
// Script builds the tracing table for a filter: packets from or to the
|
||||
// address, on the port (either way), of the protocol. A limit keeps a
|
||||
// flood from swamping the trace.
|
||||
func Script(in api.TraceInput) (string, error) {
|
||||
var addr netip.Prefix
|
||||
if in.Address != "" {
|
||||
p, err := netip.ParsePrefix(in.Address)
|
||||
if err != nil {
|
||||
a, err2 := netip.ParseAddr(in.Address)
|
||||
if err2 != nil {
|
||||
return "", fmt.Errorf("address %q: give an address or a prefix (192.168.1.50, 2001:db8::/64)", in.Address)
|
||||
}
|
||||
p = netip.PrefixFrom(a, a.BitLen())
|
||||
}
|
||||
addr = p.Masked()
|
||||
}
|
||||
if in.Port < 0 || in.Port > 65535 {
|
||||
return "", fmt.Errorf("port %d: 1 to 65535", in.Port)
|
||||
}
|
||||
proto := strings.ToLower(in.Protocol)
|
||||
switch proto {
|
||||
case "", "tcp", "udp", "sctp", "icmp", "icmpv6":
|
||||
default:
|
||||
return "", fmt.Errorf("protocol %q: tcp, udp, sctp, icmp, icmpv6, or nothing for any", in.Protocol)
|
||||
}
|
||||
if !addr.IsValid() && in.Port == 0 {
|
||||
return "", fmt.Errorf("say what to trace: an address, a port, or both")
|
||||
}
|
||||
if in.Port != 0 && (proto == "icmp" || proto == "icmpv6") {
|
||||
return "", fmt.Errorf("ICMP has no ports")
|
||||
}
|
||||
var l4 string
|
||||
switch {
|
||||
case proto != "":
|
||||
l4 = "meta l4proto " + proto
|
||||
case in.Port != 0:
|
||||
l4 = "meta l4proto { tcp, udp, sctp }"
|
||||
}
|
||||
addrs := []string{""}
|
||||
if addr.IsValid() {
|
||||
ip := "ip"
|
||||
if addr.Addr().Is6() {
|
||||
ip = "ip6"
|
||||
}
|
||||
addrs = []string{ip + " saddr " + addr.String(), ip + " daddr " + addr.String()}
|
||||
}
|
||||
ports := []string{""}
|
||||
if in.Port != 0 {
|
||||
ports = []string{fmt.Sprintf("th dport %d", in.Port), fmt.Sprintf("th sport %d", in.Port)}
|
||||
}
|
||||
var rules []string
|
||||
for _, a := range addrs {
|
||||
for _, p := range ports {
|
||||
parts := []string{}
|
||||
for _, x := range []string{a, l4, p, "limit rate 20/second", "meta nftrace set 1"} {
|
||||
if x != "" {
|
||||
parts = append(parts, x)
|
||||
}
|
||||
}
|
||||
rules = append(rules, "\t\t"+strings.Join(parts, " "))
|
||||
}
|
||||
}
|
||||
body := strings.Join(rules, "\n")
|
||||
return fmt.Sprintf(`table inet %[1]s {}
|
||||
delete table inet %[1]s
|
||||
table inet %[1]s {
|
||||
chain pre {
|
||||
type filter hook prerouting priority -301; policy accept;
|
||||
%[2]s
|
||||
}
|
||||
chain out {
|
||||
type filter hook output priority -301; policy accept;
|
||||
%[2]s
|
||||
}
|
||||
}
|
||||
`, Table, body), nil
|
||||
}
|
||||
|
||||
// ---- reading `nft monitor trace`
|
||||
|
||||
// "trace id 38ff3662 inet housecarl input rule … (verdict accept)"
|
||||
var lineRe = regexp.MustCompile(`^trace id (\S+) (\S+) (\S+) (\S+) (packet: (.*)|rule (.*) \(verdict ([^)]*)\)|policy (\S+)|(.*))$`)
|
||||
|
||||
var (
|
||||
fieldRe = regexp.MustCompile(`\b(iif|oif) "([^"]*)"`)
|
||||
addrRe = regexp.MustCompile(`\b(ip6?) (saddr|daddr) (\S+)`)
|
||||
portRe = regexp.MustCompile(`\b(tcp|udp|sctp) (sport|dport) (\d+)`)
|
||||
flagsRe = regexp.MustCompile(`\btcp flags == (\S+)`)
|
||||
icmpRe = regexp.MustCompile(`\b(icmp|icmpv6) type (\S+)`)
|
||||
protoRe = regexp.MustCompile(`\bmeta l4proto (\S+)|\bip protocol (\S+)|\bip6 nexthdr (\S+)`)
|
||||
markRe = regexp.MustCompile(`comment "hc:(\S+)`)
|
||||
)
|
||||
|
||||
// Tracer collects packets from trace lines.
|
||||
type Tracer struct {
|
||||
// Max is how many packets it keeps (0: 200).
|
||||
Max int
|
||||
Now func() time.Time
|
||||
packets []*api.TracePacket
|
||||
byID map[string]*api.TracePacket
|
||||
// seen are the chains a packet entered: the kernel reuses trace ids
|
||||
// (they come from the packet's buffer), so entering one again means
|
||||
// another packet.
|
||||
seen map[*api.TracePacket]map[string]bool
|
||||
}
|
||||
|
||||
// Packets returns what was traced so far, oldest first.
|
||||
func (t *Tracer) Packets() []api.TracePacket {
|
||||
out := make([]api.TracePacket, 0, len(t.packets))
|
||||
for _, p := range t.packets {
|
||||
out = append(out, *p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Full reports whether it keeps no more packets.
|
||||
func (t *Tracer) Full() bool {
|
||||
max := t.Max
|
||||
if max == 0 {
|
||||
max = 200
|
||||
}
|
||||
return len(t.packets) >= max
|
||||
}
|
||||
|
||||
// Line reads one line of `nft monitor trace`. The tracing table's own
|
||||
// steps are left out.
|
||||
func (t *Tracer) Line(line string) {
|
||||
m := lineRe.FindStringSubmatch(strings.TrimSpace(line))
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
id, table, chain := m[1], m[2]+" "+m[3], m[4]
|
||||
if m[3] == Table {
|
||||
return
|
||||
}
|
||||
if t.byID == nil {
|
||||
t.byID, t.seen = map[string]*api.TracePacket{}, map[*api.TracePacket]map[string]bool{}
|
||||
}
|
||||
p := t.byID[id]
|
||||
isPacket := m[6] != "" || strings.HasPrefix(m[5], "packet:")
|
||||
if p != nil && isPacket && t.seen[p][table+" "+chain] {
|
||||
p = nil // the same id, another packet
|
||||
}
|
||||
if p == nil {
|
||||
if t.Full() {
|
||||
return
|
||||
}
|
||||
now := time.Now
|
||||
if t.Now != nil {
|
||||
now = t.Now
|
||||
}
|
||||
p = &api.TracePacket{ID: id, Time: now(), Verdict: "accepted"}
|
||||
t.byID[id] = p
|
||||
t.seen[p] = map[string]bool{}
|
||||
t.packets = append(t.packets, p)
|
||||
}
|
||||
switch {
|
||||
case isPacket:
|
||||
t.seen[p][table+" "+chain] = true
|
||||
readPacket(p, m[6])
|
||||
case m[7] != "":
|
||||
step := api.TraceStep{Table: table, Chain: chain, Kind: "rule", Rule: m[7], Verdict: m[8]}
|
||||
if mk := markRe.FindStringSubmatch(m[7]); mk != nil {
|
||||
step.Mark = strings.TrimSuffix(mk[1], `"`)
|
||||
}
|
||||
p.Steps = append(p.Steps, step)
|
||||
decide(p, step)
|
||||
case m[9] != "":
|
||||
step := api.TraceStep{Table: table, Chain: chain, Kind: "policy", Verdict: m[9]}
|
||||
p.Steps = append(p.Steps, step)
|
||||
decide(p, step)
|
||||
}
|
||||
}
|
||||
|
||||
// decide notes a packet's fate: the first drop (or reject) ends it.
|
||||
func decide(p *api.TracePacket, s api.TraceStep) {
|
||||
if p.Verdict == "dropped" {
|
||||
return
|
||||
}
|
||||
switch strings.Fields(s.Verdict + " ")[0] {
|
||||
case "drop", "reject":
|
||||
p.Verdict, p.DecidedBy = "dropped", s
|
||||
case "accept":
|
||||
// What let it through is a filter chain's accept: Housecarl's
|
||||
// input, forward or output (NAT chains accept everything they
|
||||
// see). Another table's accept counts when Housecarl's says
|
||||
// nothing.
|
||||
filter := s.Table == "inet housecarl" && (s.Chain == "input" || s.Chain == "forward" || s.Chain == "output")
|
||||
if filter || p.DecidedBy.Verdict == "" && s.Table != "inet housecarl" {
|
||||
p.DecidedBy = s
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// readPacket fills in a packet's headers from its "packet:" line (once:
|
||||
// the first hook's view, before any NAT).
|
||||
func readPacket(p *api.TracePacket, f string) {
|
||||
for _, m := range fieldRe.FindAllStringSubmatch(f, -1) {
|
||||
if m[1] == "iif" && p.In == "" {
|
||||
p.In = m[2]
|
||||
}
|
||||
if m[1] == "oif" && p.Out == "" {
|
||||
p.Out = m[2]
|
||||
}
|
||||
}
|
||||
if p.Source != "" {
|
||||
return
|
||||
}
|
||||
for _, m := range addrRe.FindAllStringSubmatch(f, -1) {
|
||||
if m[2] == "saddr" {
|
||||
p.Source = m[3]
|
||||
} else {
|
||||
p.Destination = m[3]
|
||||
}
|
||||
}
|
||||
for _, m := range portRe.FindAllStringSubmatch(f, -1) {
|
||||
p.Protocol = m[1]
|
||||
n, _ := strconv.Atoi(m[3])
|
||||
if m[2] == "sport" {
|
||||
p.SourcePort = n
|
||||
} else {
|
||||
p.DestinationPort = n
|
||||
}
|
||||
}
|
||||
if m := flagsRe.FindStringSubmatch(f); m != nil {
|
||||
p.TCPFlags = m[1]
|
||||
}
|
||||
if m := icmpRe.FindStringSubmatch(f); m != nil {
|
||||
p.Protocol, p.ICMPType = m[1], m[2]
|
||||
}
|
||||
if p.Protocol == "" {
|
||||
if m := protoRe.FindStringSubmatch(f); m != nil {
|
||||
p.Protocol = strings.TrimSpace(m[1] + m[2] + m[3])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package trace
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"housecarl/internal/api"
|
||||
)
|
||||
|
||||
// Lines nft 1.1.3's `nft monitor trace` printed for a connection to a
|
||||
// blocked port, and for one that loopback accepted.
|
||||
const sample = `trace id 38ff3662 inet housecarl-trace pre packet: iif "wan" ip saddr 198.51.100.7 ip daddr 203.0.113.10 ip dscp cs0 ip ecn not-ect ip ttl 64 ip id 44978 ip protocol 6 ip length 60 tcp sport 43168 tcp dport 9999 tcp flags == syn tcp window 65495
|
||||
trace id 38ff3662 inet housecarl-trace pre rule ip saddr 198.51.100.7 meta nftrace set 1 (verdict continue)
|
||||
trace id 38ff3662 inet housecarl-trace pre policy accept
|
||||
trace id 38ff3662 inet housecarl input packet: iif "wan" ip saddr 198.51.100.7 ip daddr 203.0.113.10 ip dscp cs0 ip ecn not-ect ip ttl 64 ip id 44978 ip protocol 6 ip length 60 tcp sport 43168 tcp dport 9999 tcp flags == syn tcp window 65495
|
||||
trace id 38ff3662 inet housecarl input rule ct state established,related counter packets 0 bytes 0 accept comment "hc:b-established" (verdict continue)
|
||||
trace id 38ff3662 inet housecarl input rule tcp dport 9999 counter packets 1 bytes 60 drop comment "hc:r12 block 9999" (verdict drop)
|
||||
trace id a232d9a0 inet housecarl output packet: oif "lo" ip saddr 127.0.0.1 ip daddr 127.0.0.1 ip protocol 6 tcp sport 43168 tcp dport 22 tcp flags == syn
|
||||
trace id a232d9a0 inet housecarl output policy accept
|
||||
trace id a232d9a0 ip other input rule ip saddr 127.0.0.1 accept (verdict accept)
|
||||
trace id 77 inet housecarl forward packet: iif "lan" oif "wan" ip saddr 192.168.1.20 ip daddr 198.51.100.10 tcp sport 3500 tcp dport 7000 tcp flags == syn
|
||||
trace id 77 inet housecarl forward rule ip saddr @trusted_v4 counter accept comment "hc:r40 trusted" (verdict accept)
|
||||
trace id 77 inet housecarl srcnat rule oifname "wan" counter masquerade comment "hc:s91" (verdict accept)`
|
||||
|
||||
func TestTracer(t *testing.T) {
|
||||
var tr Tracer
|
||||
for _, l := range strings.Split(sample, "\n") {
|
||||
tr.Line(l)
|
||||
}
|
||||
ps := tr.Packets()
|
||||
if len(ps) != 3 {
|
||||
t.Fatalf("packets: %+v", ps)
|
||||
}
|
||||
p := ps[0]
|
||||
if p.In != "wan" || p.Source != "198.51.100.7" || p.DestinationPort != 9999 || p.Protocol != "tcp" || p.TCPFlags != "syn" {
|
||||
t.Errorf("headers: %+v", p)
|
||||
}
|
||||
if len(p.Steps) != 2 || p.Verdict != "dropped" || p.DecidedBy.Mark != "r12" || p.DecidedBy.Chain != "input" {
|
||||
t.Errorf("steps: %+v", p)
|
||||
}
|
||||
if q := ps[1]; q.Verdict != "accepted" || q.Out != "lo" || len(q.Steps) != 2 || q.DecidedBy.Kind != "policy" {
|
||||
t.Errorf("second: %+v", q)
|
||||
}
|
||||
// Accepted by the forward rule, not by source NAT, which came after.
|
||||
if q := ps[2]; q.Verdict != "accepted" || q.DecidedBy.Mark != "r40" {
|
||||
t.Errorf("third: decided by %+v", q.DecidedBy)
|
||||
}
|
||||
}
|
||||
|
||||
// The kernel reuses trace ids: a connection's next packet can come with
|
||||
// the same one. Entering a chain again starts another packet.
|
||||
func TestReusedID(t *testing.T) {
|
||||
var tr Tracer
|
||||
for _, l := range strings.Split(`trace id 5 inet housecarl output packet: oif "lo" ip saddr 127.0.0.1 ip daddr 127.0.0.1 tcp sport 49078 tcp dport 8070 tcp flags == syn
|
||||
trace id 5 inet housecarl output policy accept
|
||||
trace id 5 inet housecarl srcnat packet: oif "lo" ip saddr 127.0.0.1 ip daddr 127.0.0.1 tcp sport 49078 tcp dport 8070 tcp flags == syn
|
||||
trace id 5 inet housecarl srcnat policy accept
|
||||
trace id 5 inet housecarl output packet: oif "lo" ip saddr 127.0.0.1 ip daddr 127.0.0.1 tcp sport 49078 tcp dport 8070 tcp flags == ack
|
||||
trace id 5 inet housecarl output policy accept`, "\n") {
|
||||
tr.Line(l)
|
||||
}
|
||||
ps := tr.Packets()
|
||||
if len(ps) != 2 || len(ps[0].Steps) != 2 || len(ps[1].Steps) != 1 || ps[1].TCPFlags != "ack" {
|
||||
t.Errorf("packets: %+v", ps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScript(t *testing.T) {
|
||||
s, err := Script(api.TraceInput{Address: "192.168.1.50", Port: 443})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"table inet housecarl-trace {}\ndelete table inet housecarl-trace\n",
|
||||
"type filter hook prerouting priority -301; policy accept;",
|
||||
"ip saddr 192.168.1.50/32 meta l4proto { tcp, udp, sctp } th dport 443 limit rate 20/second meta nftrace set 1",
|
||||
"ip daddr 192.168.1.50/32 meta l4proto { tcp, udp, sctp } th sport 443 limit rate 20/second meta nftrace set 1",
|
||||
} {
|
||||
if !strings.Contains(s, want) {
|
||||
t.Errorf("missing %q in\n%s", want, s)
|
||||
}
|
||||
}
|
||||
if _, err := Script(api.TraceInput{}); err == nil {
|
||||
t.Error("tracing everything")
|
||||
}
|
||||
if _, err := Script(api.TraceInput{Port: 22, Protocol: "icmp"}); err == nil {
|
||||
t.Error("ICMP with a port")
|
||||
}
|
||||
}
|
||||
@@ -85,6 +85,53 @@ func Check(st *model.State, env *Env) []Problem {
|
||||
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 != ""},
|
||||
{"every packet", r.EveryPacket},
|
||||
} {
|
||||
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}$`)
|
||||
|
||||
// ifaceRe: Linux interface names (15 bytes), with an optional trailing *
|
||||
@@ -185,6 +232,9 @@ func (c *checker) sets() {
|
||||
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 {
|
||||
c.add(Warning, "set_empty", obj, "the set is empty: rules using it match nothing")
|
||||
}
|
||||
@@ -314,6 +364,9 @@ func (c *checker) rule(r *model.Rule) {
|
||||
if strings.ContainsAny(r.Raw, ";\n\r") {
|
||||
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
|
||||
}
|
||||
in := r.Chain != model.ChainOutput
|
||||
@@ -348,13 +401,52 @@ func (c *checker) rule(r *model.Rule) {
|
||||
c.add(Error, "rule_hours", obj, "write hours as HH:MM (08:00, 17:30)")
|
||||
}
|
||||
if l := r.Limit; l != nil {
|
||||
if l.Rate < 1 || !limitPer[l.Per] || l.Burst < 0 {
|
||||
c.add(Error, "rule_limit", obj, "a rate is a number of packets per second, minute, hour or day, with an optional burst")
|
||||
_, byteUnit := model.ByteUnits[l.Unit]
|
||||
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 {
|
||||
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)")
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case 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")
|
||||
case r.ChecksEveryPacket() && r.Action == model.ActionAccept:
|
||||
c.add(Error, "rule_every_packet_accept", obj, "a rule checked against every packet comes before established connections are accepted, so accepting there would skip every later rule; it can drop, reject or log")
|
||||
}
|
||||
if r.ChecksEveryPacket() && 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 rule only sees each connection's first packets; turn the flowtable off (settings) for it to see all of them")
|
||||
}
|
||||
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 != "tcp-reset" {
|
||||
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_empty", func(st *model.State) { st.Sets[0].Entries = nil }},
|
||||
{"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_entry", func(st *model.State) { st.Services[0].Entries = []string{"tcp/99999"} }},
|
||||
{"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_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_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_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"} }},
|
||||
@@ -91,6 +93,22 @@ func TestCodes(t *testing.T) {
|
||||
{"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_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} }},
|
||||
{"rule_every_packet_accept", func(st *model.State) { rule(st).EveryPacket = true }},
|
||||
{"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" }},
|
||||
{"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"} }},
|
||||
|
||||
@@ -5,6 +5,8 @@ export const state = {
|
||||
me: null, // whoami response
|
||||
csrf: "",
|
||||
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 {
|
||||
@@ -20,13 +22,16 @@ export class APIError extends Error {
|
||||
let onUnauthorized = () => {};
|
||||
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 = () => {};
|
||||
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 = {}) {
|
||||
const headers = { "Accept": "application/json" };
|
||||
if (opts.stage) headers["X-Housecarl-Changeset"] = String(opts.stage);
|
||||
let body;
|
||||
if (opts.body !== undefined) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
@@ -40,7 +45,13 @@ export async function api(method, path, opts = {}) {
|
||||
const s = q.toString();
|
||||
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;
|
||||
const ct = resp.headers.get("Content-Type") || "";
|
||||
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;
|
||||
}
|
||||
|
||||
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
|
||||
// history entry (to undo it from History) and one per new warning. It
|
||||
// returns the response.
|
||||
// draftQuery names the draft changeset in a query, unless edits are
|
||||
// applied immediately.
|
||||
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 = {}) {
|
||||
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);
|
||||
return r;
|
||||
}
|
||||
|
||||
export function report(r) {
|
||||
if (!r) return;
|
||||
if (r.dry_run) toast("Checked by Housecarl and nftables: nothing was changed");
|
||||
else if (r.applied && r.confirm_by) toast("Applied on probation (history #" + r.history_id + "): confirm it in the banner", "warn", 6000);
|
||||
if (r.staged) {
|
||||
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.changes || []).length) toast("Nothing to change");
|
||||
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.
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
// 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 { 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 connections from "./pages/connections.js";
|
||||
import * as tracePage from "./pages/trace.js";
|
||||
import * as dashboard from "./pages/dashboard.js";
|
||||
import * as rules from "./pages/rules.js";
|
||||
import * as nat from "./pages/nat.js";
|
||||
@@ -25,8 +28,11 @@ const routes = [
|
||||
{ pattern: /^sets$/, page: sets, nav: "sets" },
|
||||
{ pattern: /^services$/, page: services, nav: "services" },
|
||||
{ pattern: /^log$/, page: log, nav: "log" },
|
||||
{ pattern: /^connections$/, page: connections, nav: "connections" },
|
||||
{ pattern: /^trace$/, page: tracePage, nav: "trace" },
|
||||
{ pattern: /^settings$/, page: settings, nav: "settings" },
|
||||
{ pattern: /^import$/, page: importPage, nav: "import" },
|
||||
{ pattern: /^changes$/, page: changes, nav: "changes" },
|
||||
{ pattern: /^history$/, page: history, nav: "history" },
|
||||
{ pattern: /^history\/(\d+)$/, page: history, nav: "history", params: ["id"] },
|
||||
{ pattern: /^audit$/, page: audit, nav: "audit" },
|
||||
@@ -45,7 +51,12 @@ const navItems = [
|
||||
["sets", "#/sets", "Address sets", "config"],
|
||||
["services", "#/services", "Services", "config"],
|
||||
] },
|
||||
["log", "#/log", "Log", "log"],
|
||||
{ menu: "Traffic", items: [
|
||||
["log", "#/log", "Log", "log"],
|
||||
["connections", "#/connections", "Connections", "log"],
|
||||
["trace", "#/trace", "Trace", "log"],
|
||||
] },
|
||||
["changes", "#/changes", "Changes", "config"],
|
||||
["history", "#/history", "History", "config"],
|
||||
{ menu: "Server", items: [
|
||||
["settings", "#/settings", "Settings and built-ins", "config"],
|
||||
@@ -56,7 +67,7 @@ const navItems = [
|
||||
];
|
||||
|
||||
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() {
|
||||
const raw = location.hash.replace(/^#\/?/, "");
|
||||
@@ -92,6 +103,8 @@ async function loggedIn(me) {
|
||||
state.me = me;
|
||||
state.csrf = me.csrf_token || "";
|
||||
layout();
|
||||
onDraftChange(renderBar);
|
||||
if (can("config", true)) await loadDraft();
|
||||
window.addEventListener("hashchange", route);
|
||||
await route();
|
||||
}
|
||||
@@ -100,6 +113,8 @@ function layout() {
|
||||
navEl = h("nav", { class: "main" });
|
||||
mainEl = h("main");
|
||||
bannerEl = h("div", { class: "probation-banner hidden", role: "alert" });
|
||||
barEl = h("div");
|
||||
modeHolder = can("config", true) ? h("span") : null;
|
||||
const me = state.me;
|
||||
const header = h("header", { class: "top" },
|
||||
h("a", { class: "brand", href: "#/", title: "Dashboard" },
|
||||
@@ -107,19 +122,88 @@ function layout() {
|
||||
wordmark({ rule: false, height: 17, cls: "brand-name" })),
|
||||
navEl,
|
||||
h("div", { class: "header-right" },
|
||||
modeHolder,
|
||||
menu(h("span", { class: "user", title: (me.permissions || []).join("\n") }, me.user.username), [
|
||||
h("a", { href: "#/account" }, "Account"),
|
||||
me.admin ? h("a", { href: "#/users" }, "Users") : null,
|
||||
h("button", { type: "button", class: "menu-item", onclick: logout }, "Log out"),
|
||||
], { 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();
|
||||
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
|
||||
|
||||
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
|
||||
// the banner with a countdown while they do.
|
||||
@@ -147,6 +231,10 @@ function drawBanner() {
|
||||
}
|
||||
bannerEl.classList.remove("hidden");
|
||||
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,
|
||||
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.",
|
||||
@@ -246,7 +334,7 @@ export function rerender() {
|
||||
if (current.page) route();
|
||||
}
|
||||
|
||||
setChangeHandler(() => refreshProbation());
|
||||
setChangeHandler((r) => r && r.confirm_token ? autoConfirm(r.confirm_token) : refreshProbation());
|
||||
|
||||
setUnauthorizedHandler(() => {
|
||||
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));
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// Connections: what the kernel tracks, and closing them so the rules
|
||||
// check them again.
|
||||
import { h, mount, input, debounce, toast, confirmDialog, badge } from "../ui.js";
|
||||
import { get, api, can } from "../api.js";
|
||||
import { onLeave } from "../app.js";
|
||||
import { fmtSeconds } from "../widgets.js";
|
||||
|
||||
export async function render(el, params, query) {
|
||||
const search = input({ type: "search", placeholder: "address, :port, protocol, state…", value: query.get("q") || "" });
|
||||
const out = h("div");
|
||||
const count = h("span", { class: "muted small" });
|
||||
const write = can("config", true);
|
||||
let shown = [];
|
||||
const hostPort = (a, p) => !p ? a : a.includes(":") ? "[" + a + "]:" + p : a + ":" + p;
|
||||
const close = async (list, what) => {
|
||||
if (!await confirmDialog("Close connections", "Close " + what + "? Their next packet is checked by the rules as they are now: what they block is stopped, what they allow carries on.", "Close", "danger")) return;
|
||||
try {
|
||||
const r = await api("POST", "/connections/close", { body: list ? { connections: list } : { search: search.value.trim() } });
|
||||
toast("Closed " + r.closed + (r.closed === 1 ? " connection" : " connections"));
|
||||
draw();
|
||||
} catch (e) { toast(e.message, "err"); }
|
||||
};
|
||||
const draw = async () => {
|
||||
shown = await get("/connections", { q: search.value.trim(), limit: 500 });
|
||||
count.textContent = shown.length === 1 ? "1 connection" : shown.length + " connections";
|
||||
mount(out, shown.length ? h("div", { class: "table-wrap" }, h("table", null,
|
||||
h("thead", null, h("tr", null, ["Protocol", "From", "To", "NAT", "State", "Expires", ""].map((t) => h("th", null, t)))),
|
||||
h("tbody", null, shown.map((c) => h("tr", null,
|
||||
h("td", null, c.protocol),
|
||||
h("td", { class: "mono" }, hostPort(c.source, c.source_port)),
|
||||
h("td", { class: "mono" }, hostPort(c.destination, c.destination_port)),
|
||||
h("td", { class: "mono small" }, c.dnat_to ? "to " + hostPort(c.dnat_to, c.dnat_port || c.destination_port) : "", c.snat_to ? (c.dnat_to ? ", " : "") + "as " + c.snat_to : ""),
|
||||
h("td", null, c.state || "", c.assured ? null : [" ", badge("one way", "")]),
|
||||
h("td", { class: "small" }, fmtSeconds(c.timeout)),
|
||||
h("td", { class: "actions-cell" }, write ? h("button", { class: "small reveal", onclick: () => close([c], "this connection") }, "Close") : null)))))) :
|
||||
h("div", { class: "empty" }, search.value.trim() ? "No connection matches." : "No connections tracked."));
|
||||
};
|
||||
search.addEventListener("input", debounce(draw, 300));
|
||||
mount(el,
|
||||
h("div", { class: "page-head" },
|
||||
h("div", { class: "grow" }, h("h1", null, "Connections"),
|
||||
h("div", { class: "muted" }, "The connections the firewall tracks. Their packets are let through by the built-in for established connections, without the rules being checked again, so a rule added later doesn't stop them. Close them, and their next packet is checked by the rules as they are now.")),
|
||||
write ? h("div", { class: "actions" }, h("button", { class: "danger", onclick: () => close(null, search.value.trim() ? "every connection matching “" + search.value.trim() + "”" : "every connection") }, "Close all shown")) : null),
|
||||
h("div", { class: "filter-row" }, search, count),
|
||||
h("div", { class: "panel" }, out));
|
||||
await draw();
|
||||
const t = setInterval(() => { if (!document.hidden && document.activeElement !== search) draw().catch(() => {}); }, 5000);
|
||||
onLeave(() => clearInterval(t));
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// History: every configuration change, with undo.
|
||||
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";
|
||||
|
||||
export async function render(el, params) {
|
||||
@@ -20,8 +20,9 @@ export async function render(el, params) {
|
||||
}
|
||||
|
||||
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 {
|
||||
// Like any edit: staged for review, or applied at once in live mode.
|
||||
await change("POST", "/history/" + e.id + "/revert");
|
||||
rerender();
|
||||
} catch (err) { modal("Can't undo", errorBox(err)); }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Import: an existing nftables ruleset as Housecarl's configuration.
|
||||
import { h, mount, field, textarea, input, checkbox, errorBox, copyBox } from "../ui.js";
|
||||
import { api } from "../api.js";
|
||||
import { refreshProbation } from "../app.js";
|
||||
import { refreshProbation, autoConfirm } from "../app.js";
|
||||
|
||||
export async function render(el) {
|
||||
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") },
|
||||
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.`,
|
||||
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.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"),
|
||||
"; 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)); }
|
||||
};
|
||||
mount(el,
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
import { h, mount, table, modal, errorBox, field, input, checkbox, confirmDialog, badge } from "../ui.js";
|
||||
import { get, change, can } from "../api.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) {
|
||||
const [fwds, snats, redirects, cs] = await Promise.all([get("/forwards"), get("/snats"), get("/redirects"), counters()]);
|
||||
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: () => toggle(kind, x) }, x.disabled ? "Enable" : "Disable"), " ",
|
||||
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: "Hits", render: (f) => hits(cs, "f" + f.id, "f" + f.id + "-6") },
|
||||
{ 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-head" }, h("h2", null, "Source NAT"), write ? h("button", { class: "primary small", onclick: () => snatDialog(null) }, "Add source NAT") : null),
|
||||
table([
|
||||
@@ -35,7 +35,7 @@ export async function render(el) {
|
||||
{ label: "Hits", render: (x) => hits(cs, "s" + x.id) },
|
||||
{ label: "Comment", render: (x) => x.comment },
|
||||
{ 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-head" }, h("h2", null, "Redirects"), write ? h("button", { class: "primary small", onclick: () => redirectDialog(null) }, "Add redirect") : null),
|
||||
table([
|
||||
@@ -44,7 +44,7 @@ export async function render(el) {
|
||||
{ label: "Hits", render: (x) => hits(cs, "d" + x.id) },
|
||||
{ label: "Comment", render: (x) => x.comment },
|
||||
{ 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) {
|
||||
@@ -78,13 +78,13 @@ async function dialog(title, kind, x, fields, value) {
|
||||
async function forwardDialog(f) {
|
||||
const n = await names();
|
||||
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 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 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 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 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" });
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// 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 { get, change, can } from "../api.js";
|
||||
import { h, mount, table, modal, errorBox, field, input, select, checkbox, confirmDialog, badge, textarea, toast } from "../ui.js";
|
||||
import { get, api, change, can } from "../api.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 = [
|
||||
["input", "Input", "Traffic to the firewall itself."],
|
||||
@@ -25,49 +25,166 @@ const builtins = {
|
||||
|
||||
export async function render(el, params, query) {
|
||||
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(everyPacket), ...all.filter((r) => !everyPacket(r))];
|
||||
const write = can("config", true);
|
||||
const info = chains.find((c) => c[0] === chain) || chains[0];
|
||||
const policy = settings[chain + "_policy"];
|
||||
const bi = (builtins[chain] || []).filter(([flag]) => flag === "" || settings.builtins[flag]);
|
||||
const actionBadge = (r) => badge(r.action + (r.log ? " + log" : ""), { accept: "ok", drop: "err", reject: "warn", log: "" }[r.action]);
|
||||
const actionBadge = (r) => r.raw ? badge("as written", "") : badge(r.action + (r.log ? " + log" : ""), { accept: "ok", drop: "err", reject: "warn", log: "" }[r.action]);
|
||||
mount(el,
|
||||
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])),
|
||||
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: "panel" },
|
||||
bi.length ? h("div", { class: "builtins muted small" }, h("strong", null, "Added by Housecarl first: "),
|
||||
bi.map(([, mark, text], i) => [i ? " · " : "", text, mark ? [" (", hits(cs, "b-" + mark), ")"] : ""]), " — ",
|
||||
h("a", { href: "#/settings" }, "settings")) : null,
|
||||
builtinsRow(chain, settings, cs),
|
||||
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)]
|
||||
: [describe(r), extras(r)] },
|
||||
: [everyPacket(r) ? [badge("every packet", "accent"), " "] : null, describe(r), extras(r, qs[r.id])] },
|
||||
{ label: "Action", render: actionBadge },
|
||||
{ label: "Hits", render: (r) => hits(cs, "r" + r.id) },
|
||||
{ label: "Comment", render: (r) => r.comment },
|
||||
write ? { label: "", tdClass: "actions-cell", render: (r, i) => [
|
||||
h("button", { class: "small reveal", title: "Move up", disabled: i === 0, onclick: () => move(r, { before: rules[i - 1].id }) }, "↑"), " ",
|
||||
h("button", { class: "small reveal", title: "Move down", disabled: i === rules.length - 1, onclick: () => move(r, { after: rules[i + 1].id }) }, "↓"), " ",
|
||||
write ? { label: "", tdClass: "actions-cell", render: (r) => {
|
||||
if (r.pending === "deleted") return badge("deleted", "err");
|
||||
// Moves stay among the rules of the same kind (counting traffic,
|
||||
// or not): nftables checks those first.
|
||||
const rs = live(rules).filter((x) => everyPacket(x) === everyPacket(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: () => toggle(r) }, r.disabled ? "Enable" : "Disable"), " ",
|
||||
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(everyPacket) ? h("div", { class: "muted small" }, "Rules marked “every packet” are checked before established connections are accepted: they see all of a connection's traffic, not only its first packet, so a quota counts all of it and a block stops connections already open. They can drop, reject or log, not accept.") : null,
|
||||
h("div", { class: "policy small" }, "Then the policy: ", badge(policy, policy === "drop" ? "err" : "ok"),
|
||||
policy === "drop" ? [" — ", hits(cs, "p-" + chain), " packets reached it"] : null)));
|
||||
}
|
||||
|
||||
function extras(r) {
|
||||
// everyPacket: checked before established connections are accepted (as
|
||||
// model.Rule.ChecksEveryPacket): asked to, or a quota or a byte rate.
|
||||
function everyPacket(r) {
|
||||
return !r.raw && (!!r.every_packet || !!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 = [];
|
||||
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.days || []).length) x.push(r.days.join(","));
|
||||
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");
|
||||
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) {
|
||||
@@ -87,11 +204,15 @@ async function del(r) {
|
||||
export async function ruleDialog(r, chain) {
|
||||
const n = await names();
|
||||
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 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 showReject = () => rejectWith.classList.toggle("hidden", action.value !== "reject");
|
||||
const every = checkbox("every packet: also stop connections already open, not just new ones", r.every_packet);
|
||||
const showReject = () => {
|
||||
rejectWith.classList.toggle("hidden", action.value !== "reject");
|
||||
every.classList.toggle("hidden", action.value === "accept");
|
||||
};
|
||||
action.addEventListener("change", showReject);
|
||||
showReject();
|
||||
const states = listEditor(r.ct_state, { suggestions: ["new", "established", "related", "invalid", "untracked"], placeholder: "new", mono: false, addLabel: "Add state" });
|
||||
@@ -101,33 +222,122 @@ export async function ruleDialog(r, chain) {
|
||||
const hourTo = input({ value: r.hour_to || "", placeholder: "17:30", class: "short" });
|
||||
const l = r.limit || {};
|
||||
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 over = checkbox("match beyond the rate (to drop floods)", l.over);
|
||||
const perSource = checkbox("per source address", l.per_source);
|
||||
const over = checkbox("match beyond the rate (to drop floods, or traffic over a bandwidth)", l.over);
|
||||
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 log = checkbox("log matching packets (Log page)", r.log);
|
||||
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 disabled = checkbox("disabled: keep the rule but don't use it", r.disabled);
|
||||
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, every),
|
||||
"Every packet: checked before established connections are accepted, so blocking takes effect on what is already connected.").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,
|
||||
h("div", { class: "form-grid" },
|
||||
field("Chain", ch),
|
||||
...match.flat(),
|
||||
field("Action", h("div", null, action, " ", rejectWith)),
|
||||
field("Connection state", states),
|
||||
field("Connection status", statuses, "dnat: the connection was port forwarded."),
|
||||
field("Days", days),
|
||||
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."),
|
||||
...matchNodes,
|
||||
...actionField,
|
||||
...stateFields, ...timeFields, ...rateFields, ...quotaFields, ...connFields, ...groupFields, ...rawFields,
|
||||
addersLabel, adders,
|
||||
...logField,
|
||||
field("Comment", comment),
|
||||
r.id ? [h("label"), disabled] : null),
|
||||
err);
|
||||
for (const sec of Object.values(sections)) sec.init();
|
||||
updateGroup();
|
||||
await modal(r.id ? "Edit rule" : "Add rule", body, [
|
||||
{ label: "Cancel", value: null },
|
||||
{ label: "Check", onclick: () => save(true) },
|
||||
@@ -136,10 +346,21 @@ export async function ruleDialog(r, chain) {
|
||||
|
||||
async function save(dry) {
|
||||
mount(err);
|
||||
const v = { chain: ch.value, ...match.value(), action: action.value, reject_with: action.value === "reject" ? rejectWith.value : "",
|
||||
ct_state: states.value(), ct_status: statuses.value(), days: days.value(), hour_from: hourFrom.value.trim(), hour_to: hourTo.value.trim(),
|
||||
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,
|
||||
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 on = (k) => sections[k].open;
|
||||
const isRaw = on("raw");
|
||||
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();
|
||||
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,
|
||||
every_packet: !isRaw && action.value !== "accept" && every.input.checked, 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;
|
||||
try {
|
||||
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 { get, change, can } from "../api.js";
|
||||
import { rerender } from "../app.js";
|
||||
import { listEditor } from "../widgets.js";
|
||||
import { listEditor, rowClass } from "../widgets.js";
|
||||
|
||||
export async function render(el) {
|
||||
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: "", render: (s) => s.builtin ? badge("built in", "") : badge("yours", "accent") },
|
||||
{ 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(s) }, "Edit"), " ",
|
||||
h("button", { class: "small reveal", onclick: () => del(s) }, "Delete")] } : null,
|
||||
].filter(Boolean), services)));
|
||||
].filter(Boolean), services, { rowClass })));
|
||||
}
|
||||
|
||||
async function del(s) {
|
||||
@@ -30,8 +30,8 @@ async function del(s) {
|
||||
|
||||
async function serviceDialog(s) {
|
||||
s = s || { entries: [] };
|
||||
const name = input({ value: s.name || "", placeholder: "jellyfin" });
|
||||
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 name = input({ value: s.name || "", placeholder: "web" });
|
||||
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 err = h("div");
|
||||
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 { get, api, change, can } from "../api.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) {
|
||||
const sets = await get("/sets");
|
||||
@@ -14,12 +14,12 @@ export async function render(el) {
|
||||
h("div", { class: "panel" }, table([
|
||||
{ label: "Name", render: (s) => h("strong", null, s.name) },
|
||||
{ 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 },
|
||||
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: () => del(s) }, "Delete")] } : null,
|
||||
].filter(Boolean), sets, { empty: "No sets yet." })));
|
||||
].filter(Boolean), sets, { empty: "No sets yet.", rowClass })));
|
||||
}
|
||||
|
||||
async function del(s) {
|
||||
@@ -30,20 +30,30 @@ async function del(s) {
|
||||
async function setDialog(s) {
|
||||
s = s || { entries: [] };
|
||||
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 dynamic = checkbox("dynamic: entries are added at run time", s.dynamic);
|
||||
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 (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 comment = input({ value: s.comment || "" });
|
||||
const err = h("div");
|
||||
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: s.id ? "Save" : "Add set", kind: "primary", onclick: async () => {
|
||||
mount(err);
|
||||
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; }
|
||||
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();
|
||||
return true;
|
||||
} catch (e) { mount(err, errorBox(e)); return false; }
|
||||
@@ -57,7 +67,7 @@ async function entries(s) {
|
||||
const write = can("sets", true);
|
||||
const draw = async () => {
|
||||
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" });
|
||||
mount(body,
|
||||
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 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 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 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 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 save = async (dry) => {
|
||||
mount(err);
|
||||
@@ -37,8 +38,8 @@ export async function render(el) {
|
||||
try {
|
||||
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(),
|
||||
flowtable_devices: flow.value(), probation_seconds: p === 120 ? 0 : p }, { query: dry ? { dry_run: 1 } : undefined });
|
||||
if (!dry && r.applied) rerender();
|
||||
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 || r.staged)) rerender();
|
||||
} catch (e) { mount(err, errorBox(e)); }
|
||||
};
|
||||
mount(el,
|
||||
@@ -54,7 +55,8 @@ export async function render(el) {
|
||||
field("Antispoof IPv4", v4),
|
||||
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("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,
|
||||
write ? h("div", { class: "actions form-actions" },
|
||||
h("button", { class: "primary", onclick: () => save(false) }, "Save"),
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
// Trace: follow packets through every table's rules, and see what
|
||||
// decided their fate.
|
||||
import { h, mount, input, select, field, errorBox, badge } from "../ui.js";
|
||||
import { get, api, can } from "../api.js";
|
||||
import { onLeave } from "../app.js";
|
||||
|
||||
export async function render(el) {
|
||||
const address = input({ placeholder: "192.168.1.50, or 2001:db8:1::/64", class: "mono" });
|
||||
const port = input({ placeholder: "443", class: "short", type: "number", min: 1, max: 65535 });
|
||||
const protocol = select([["", "any"], "tcp", "udp", "sctp", "icmp", "icmpv6"], "", { class: "inline" });
|
||||
const seconds = input({ value: "60", class: "short", type: "number", min: 5, max: 600 });
|
||||
const err = h("div");
|
||||
const status = h("div");
|
||||
const packets = h("div");
|
||||
const write = can("config", true);
|
||||
const startBtn = h("button", { class: "primary", onclick: start }, "Start tracing");
|
||||
const stopBtn = h("button", { class: "hidden", onclick: stop }, "Stop");
|
||||
let timer = null;
|
||||
|
||||
async function start() {
|
||||
mount(err);
|
||||
try {
|
||||
await api("POST", "/trace", { body: { address: address.value.trim(), port: parseInt(port.value || "0", 10), protocol: protocol.value, seconds: parseInt(seconds.value || "60", 10) } });
|
||||
poll();
|
||||
} catch (e) { mount(err, errorBox(e)); }
|
||||
}
|
||||
async function stop() {
|
||||
try { show(await api("DELETE", "/trace")); } catch (e) { mount(err, errorBox(e)); }
|
||||
}
|
||||
async function poll() {
|
||||
clearTimeout(timer);
|
||||
let st;
|
||||
try { st = await get("/trace"); } catch (e) { mount(err, errorBox(e)); return; }
|
||||
show(st);
|
||||
if (st.running) timer = setTimeout(poll, 1000);
|
||||
}
|
||||
function show(st) {
|
||||
startBtn.classList.toggle("hidden", st.running || !write);
|
||||
stopBtn.classList.toggle("hidden", !st.running || !write);
|
||||
const what = [st.input.address, st.input.protocol, st.input.port ? "port " + st.input.port : ""].filter(Boolean).join(" ");
|
||||
mount(status, st.started && st.started !== "0001-01-01T00:00:00Z" ? h("div", { class: "msg " + (st.error ? "err" : st.running ? "info" : "ok") },
|
||||
st.running ? ["Tracing ", h("strong", null, what), " until " + new Date(st.ends).toLocaleTimeString() + ". Make the traffic happen now."]
|
||||
: ["Traced ", h("strong", null, what), ": " + (st.packets.length === 1 ? "1 packet" : st.packets.length + " packets") + "."],
|
||||
st.error ? h("div", null, st.error) : null) : null);
|
||||
mount(packets, st.packets.length ? st.packets.slice().reverse().map(packet)
|
||||
: st.running ? h("div", { class: "empty" }, "No packets yet.") : null);
|
||||
}
|
||||
const hostPort = (a, p) => !a ? "" : !p ? a : a.includes(":") ? "[" + a + "]:" + p : a + ":" + p;
|
||||
function packet(p) {
|
||||
const head = [p.protocol, p.tcp_flags, p.icmp_type].filter(Boolean).join(" ");
|
||||
const decided = p.decided_by.label || [p.decided_by.table, p.decided_by.chain, p.decided_by.kind].filter(Boolean).join(" ");
|
||||
return h("div", { class: "panel trace-packet" },
|
||||
h("div", { class: "trace-head" },
|
||||
badge(p.verdict, p.verdict === "dropped" ? "err" : "ok"), " ",
|
||||
h("span", { class: "mono" }, hostPort(p.source, p.source_port), " → ", hostPort(p.destination, p.destination_port)), " ",
|
||||
h("span", { class: "muted" }, head, p.in ? " · in " + p.in : "", p.out ? " · out " + p.out : ""),
|
||||
h("span", { class: "muted small trace-time" }, new Date(p.time).toLocaleTimeString())),
|
||||
decided ? h("div", { class: "small" }, p.verdict === "dropped" ? "Dropped by " : "Accepted by ", h("strong", null, decided)) : null,
|
||||
h("table", { class: "trace-steps" }, h("tbody", null, p.steps.map((s) => h("tr", { class: /^(drop|reject)/.test(s.verdict) ? "step-drop" : s.verdict === "accept" ? "step-accept" : "" },
|
||||
h("td", { class: "muted small" }, s.table + " " + s.chain),
|
||||
h("td", null, s.label ? (s.mark && s.mark.startsWith("r") ? h("a", { href: "#/rules?chain=" + s.chain }, s.label) : s.label)
|
||||
: s.kind === "policy" ? s.chain + " policy" : h("span", { class: "mono small" }, s.rule)),
|
||||
h("td", { class: "small" }, s.verdict))))));
|
||||
}
|
||||
|
||||
mount(el,
|
||||
h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, "Trace"),
|
||||
h("div", { class: "muted" }, "Follows packets through the whole ruleset, Housecarl's rules and every other table (Docker, fail2ban…): each rule and policy they meet, and what accepted or dropped them. For “why doesn't this get through?”"))),
|
||||
write ? h("div", { class: "panel" }, h("div", { class: "form-grid" },
|
||||
field("Address", address, "Packets from or to it (either way). An address or a prefix."),
|
||||
field("Port", h("div", null, port, " ", protocol), "Packets from or to it. Empty: any."),
|
||||
field("For", h("div", null, seconds, " seconds"), "At most 600; it stops by itself after 200 packets.")),
|
||||
h("div", { class: "actions form-actions" }, startBtn, stopBtn), err) : null,
|
||||
status, packets);
|
||||
await poll();
|
||||
onLeave(() => clearTimeout(timer));
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
import { h, mount, table, modal, errorBox, field, input, checkbox, confirmDialog, badge } from "../ui.js";
|
||||
import { get, change, can } from "../api.js";
|
||||
import { rerender } from "../app.js";
|
||||
import { listEditor } from "../widgets.js";
|
||||
import { listEditor, rowClass } from "../widgets.js";
|
||||
|
||||
export async function render(el) {
|
||||
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: "", render: (z) => z.external ? badge("external", "warn") : badge("internal", "") },
|
||||
{ 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: () => 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([
|
||||
{ label: "Interface", render: (i) => h("span", { class: "mono" }, i.name) },
|
||||
{ label: "State", render: (i) => badge(i.up ? "up" : "down", i.up ? "ok" : "") },
|
||||
|
||||
@@ -383,3 +383,39 @@ input.short { width: 7em; }
|
||||
.checks { display: flex; flex-direction: column; gap: 4px; }
|
||||
.form-actions { margin-top: 14px; }
|
||||
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; }
|
||||
/* Trace: one panel per packet, its steps below. */
|
||||
.trace-head { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-bottom: 4px; }
|
||||
.trace-time { margin-left: auto; }
|
||||
table.trace-steps { width: 100%; margin-top: 6px; }
|
||||
table.trace-steps td { padding: 3px 8px; border-top: 1px solid var(--border); }
|
||||
tr.step-drop td { background: var(--del); }
|
||||
tr.step-accept td { background: var(--add); }
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
// 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.
|
||||
export function errorBox(err) {
|
||||
if (!err) return null;
|
||||
|
||||
@@ -70,7 +70,7 @@ export function listEditor(values, opts = {}) {
|
||||
export function matchEditor(m, n, opts = {}) {
|
||||
m = m || {};
|
||||
const zones = (list) => listEditor(list, { suggestions: n.zones, placeholder: "zone", mono: false, addLabel: "Add zone" });
|
||||
const addrs = (list) => listEditor(list, { suggestions: n.sets, placeholder: "a set, 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 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);
|
||||
@@ -132,3 +132,36 @@ export function hits(counters, ...marks) {
|
||||
export async function counters() {
|
||||
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 {}; }
|
||||
}
|
||||
+123
-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)
|
||||
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 "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"
|
||||
run "confirmed" 0 C confirm
|
||||
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"
|
||||
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
|
||||
@@ -185,6 +185,127 @@ run "the rule is enabled again" 0 H rule show "$APIRULE"
|
||||
not_contains "disabled"
|
||||
run "the rollback is in the history" 0 H history list
|
||||
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"
|
||||
|
||||
# ---- connections and tracing
|
||||
|
||||
# A connection that stays open, masqueraded on its way out.
|
||||
docker exec -d ${P}-client sh -c "sleep 30 | nc $NET4 9000"
|
||||
sleep 1
|
||||
run "the connection is tracked" 0 H connections --search "$CLIENT4 :9000"
|
||||
contains "$CLIENT4:[0-9]* *$NET4:9000 *as $WAN4"
|
||||
run "closed" 0 H connections close --search "$CLIENT4 :9000" -y
|
||||
contains "closed [1-9][0-9]* connection"
|
||||
run "gone" 0 H connections --search "$CLIENT4 :9000"
|
||||
not_contains ":9000"
|
||||
# A block added while traffic flows: it stops new connections, not this
|
||||
# one, unless it checks every packet.
|
||||
docker exec -d ${P}-client sh -c "rm -f /tmp/ncdone; yes | nc $NET4 9000; touch /tmp/ncdone"
|
||||
sleep 1
|
||||
BR=$(H -o id rule add forward --from $CLIENT4 --service tcp/9000 -a reject --reject-with tcp-reset --comment "e2e block" --first)
|
||||
run "a block for new connections" 0 test -n "$BR"
|
||||
sleep 2
|
||||
run "the open upload carries on" 1 docker exec ${P}-client test -f /tmp/ncdone
|
||||
run "the block checks every packet now" 0 H rule update "$BR" --every-packet
|
||||
sleep 2
|
||||
run "the upload is cut" 0 docker exec ${P}-client test -f /tmp/ncdone
|
||||
# Tracing a new connection: dropped, by that rule.
|
||||
docker exec -d ${P}-router sh -c "/w/hc trace --address $CLIENT4 --port 9000 --seconds 6 > /w/trace.out 2>&1"
|
||||
sleep 2
|
||||
connect client $NET4 9000 >/dev/null 2>&1
|
||||
sleep 6
|
||||
run "the trace" 0 cat "$WORK/trace.out"
|
||||
contains "$CLIENT4:[0-9]* -> $NET4:9000 tcp syn"
|
||||
contains "inet housecarl forward: rule forward #$BR (e2e block) -> drop"
|
||||
contains "=> DROPPED by rule forward #$BR (e2e block)"
|
||||
run "no trace table is left" 0 R nft list tables
|
||||
not_contains "housecarl-trace"
|
||||
run "the block goes" 0 H rule delete -y "$BR"
|
||||
|
||||
# ---- the drop log
|
||||
|
||||
|
||||
Reference in new issue
Block a user