Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46c319eced | ||
|
|
24dbc0a869 | ||
|
|
44b90bd770 | ||
|
|
ff03e76925 |
No files matched your search
+13
-3
@@ -12,9 +12,10 @@ All notable changes to Housecarl are documented here. The format follows
|
|||||||
reset-quota ID --address ADDR` starts only that device's allowance
|
reset-quota ID --address ADDR` starts only that device's allowance
|
||||||
again; `--give 5gbytes` takes that much off what was used instead (one
|
again; `--give 5gbytes` takes that much off what was used instead (one
|
||||||
address's, or everyone's): extra allowance until the next reset,
|
address's, or everyone's): extra allowance until the next reset,
|
||||||
without changing the rule. In the web UI: **Give back…** beside a
|
without changing the rule. In the web UI, a rule's quota is one
|
||||||
quota's Reset, and Reset and Give back for each address in its usage
|
line (what it used, how it resets) that opens a dialog with the rest:
|
||||||
dialog. `POST /rules/{ref}/reset-quota` takes `address` and `give`.
|
usage per address, last and next reset, and Reset and Give back for
|
||||||
|
everyone or one address. `POST /rules/{ref}/reset-quota` takes `address` and `give`.
|
||||||
- **Names beside inside addresses.** Connections, the log, quota usage,
|
- **Names beside inside addresses.** Connections, the log, quota usage,
|
||||||
the dashboard's quotas and the statistics' devices and blocked
|
the dashboard's quotas and the statistics' devices and blocked
|
||||||
sources show each inside address's name, found by a reverse lookup:
|
sources show each inside address's name, found by a reverse lookup:
|
||||||
@@ -75,6 +76,15 @@ All notable changes to Housecarl are documented here. The format follows
|
|||||||
- Import: sets with `flags timeout` become dynamic sets; a DNAT of
|
- Import: sets with `flags timeout` become dynamic sets; a DNAT of
|
||||||
several ports becomes one port forward per port.
|
several ports becomes one port forward per port.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Rules read as a direction.** A match's zones are written `lan → wan`,
|
||||||
|
`any but wan → any`, or with the firewall itself as the other end
|
||||||
|
(`lan → firewall` in input, `firewall → wan` in output), instead of
|
||||||
|
`in lan · out wan` and `in not wan`; exclusions read `from any but X`.
|
||||||
|
On the Rules and NAT pages and in `hc`; the flags and JSON stay as
|
||||||
|
they were.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- `hc stats top devices` listed each device twice (once for what it
|
- `hc stats top devices` listed each device twice (once for what it
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ The web UI is on `http://127.0.0.1:8070/` (see `listen` in
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Housecarl 0.1.1 is in testing (0.1.1-rc.8): zones, address sets and
|
Housecarl 0.1.1 is in testing (0.1.1-rc.10): zones, address sets and
|
||||||
services with ordered rules, IPv4 and IPv6, port forwards with hairpin NAT
|
services with ordered rules, IPv4 and IPv6, port forwards with hairpin NAT
|
||||||
and IPv6 pinholes, source NAT and redirects, built-ins, probation for
|
and IPv6 pinholes, source NAT and redirects, built-ins, probation for
|
||||||
network changes (confirmed by the client that made them), the ruleset
|
network changes (confirmed by the client that made them), the ruleset
|
||||||
|
|||||||
+17
-3
@@ -35,9 +35,23 @@ func TestLimitAndDescribe(t *testing.T) {
|
|||||||
if _, err := parseLimit("fast"); err == nil {
|
if _, err := parseLimit("fast"); err == nil {
|
||||||
t.Error("a bad limit accepted")
|
t.Error("a bad limit accepted")
|
||||||
}
|
}
|
||||||
m := model.Match{InZones: []string{"wan"}, NotSources: true, Sources: []string{"trusted"}, Services: []string{"ssh", "tcp/8080"}}
|
for _, c := range []struct {
|
||||||
if got := describe(m); got != "in wan from not trusted ssh,tcp/8080" {
|
m model.Match
|
||||||
t.Errorf("describe: %q", got)
|
chain string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{model.Match{InZones: []string{"wan"}, NotSources: true, Sources: []string{"trusted"}, Services: []string{"ssh", "tcp/8080"}}, "input",
|
||||||
|
"wan → firewall from any but trusted ssh,tcp/8080"},
|
||||||
|
{model.Match{InZones: []string{"wan"}, NotIn: true, Destinations: []string{"trusted_ips"}}, "forward", "any but wan → any to trusted_ips"},
|
||||||
|
{model.Match{InZones: []string{"lan"}, OutZones: []string{"wan"}}, "forward", "lan → wan"},
|
||||||
|
{model.Match{OutZones: []string{"wan"}}, "output", "firewall → wan"},
|
||||||
|
{model.Match{OutZones: []string{"wan"}, Sources: []string{"10.0.0.0/8"}}, "", "any → wan from 10.0.0.0/8"},
|
||||||
|
{model.Match{Sources: []string{"trusted"}}, "input", "from trusted"},
|
||||||
|
{model.Match{}, "forward", "everything"},
|
||||||
|
} {
|
||||||
|
if got := describe(c.m, c.chain); got != c.want {
|
||||||
|
t.Errorf("describe(%+v, %s) = %q, want %q", c.m, c.chain, got, c.want)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if got := editList([]string{"a", "b", "c"}, []string{"d"}, []string{"b"}); len(got) != 3 || got[1] != "c" || got[2] != "d" {
|
if got := editList([]string{"a", "b", "c"}, []string{"d"}, []string{"b"}); len(got) != 3 || got[1] != "c" || got[2] != "d" {
|
||||||
t.Errorf("editList: %v", got)
|
t.Errorf("editList: %v", got)
|
||||||
|
|||||||
+34
-16
@@ -64,22 +64,40 @@ func (m *matchFlags) patch(fl *pflag.FlagSet, p patch) {
|
|||||||
set("family", "family", m.family)
|
set("family", "family", m.family)
|
||||||
}
|
}
|
||||||
|
|
||||||
// describe sums up a match: "in lan from trusted ssh, dns".
|
// describe sums up a match in a chain ("" for NAT): "lan → firewall
|
||||||
func describe(m model.Match) string {
|
// from trusted ssh,dns". Zones are a direction; the firewall itself is
|
||||||
|
// the other end of the input and output chains.
|
||||||
|
func describe(m model.Match, chain string) string {
|
||||||
var parts []string
|
var parts []string
|
||||||
side := func(word string, not bool, list []string) {
|
list := func(not bool, xs []string) string {
|
||||||
if len(list) == 0 {
|
s := strings.Join(xs, ",")
|
||||||
return
|
|
||||||
}
|
|
||||||
if not {
|
if not {
|
||||||
word += " not"
|
return "any but " + s
|
||||||
}
|
}
|
||||||
parts = append(parts, word+" "+strings.Join(list, ","))
|
return s
|
||||||
|
}
|
||||||
|
if len(m.InZones) > 0 || len(m.OutZones) > 0 {
|
||||||
|
in, out := "any", "any"
|
||||||
|
if len(m.InZones) > 0 {
|
||||||
|
in = list(m.NotIn, m.InZones)
|
||||||
|
}
|
||||||
|
if len(m.OutZones) > 0 {
|
||||||
|
out = list(m.NotOut, m.OutZones)
|
||||||
|
}
|
||||||
|
switch chain {
|
||||||
|
case model.ChainInput:
|
||||||
|
out = "firewall"
|
||||||
|
case model.ChainOutput:
|
||||||
|
in = "firewall"
|
||||||
|
}
|
||||||
|
parts = append(parts, in+" → "+out)
|
||||||
|
}
|
||||||
|
if len(m.Sources) > 0 {
|
||||||
|
parts = append(parts, "from "+list(m.NotSources, m.Sources))
|
||||||
|
}
|
||||||
|
if len(m.Destinations) > 0 {
|
||||||
|
parts = append(parts, "to "+list(m.NotDests, m.Destinations))
|
||||||
}
|
}
|
||||||
side("in", m.NotIn, m.InZones)
|
|
||||||
side("out", m.NotOut, m.OutZones)
|
|
||||||
side("from", m.NotSources, m.Sources)
|
|
||||||
side("to", m.NotDests, m.Destinations)
|
|
||||||
if len(m.Services) > 0 {
|
if len(m.Services) > 0 {
|
||||||
parts = append(parts, strings.Join(m.Services, ","))
|
parts = append(parts, strings.Join(m.Services, ","))
|
||||||
}
|
}
|
||||||
@@ -281,7 +299,7 @@ func ruleCmd() *cobra.Command {
|
|||||||
return output(rs, func(w io.Writer) {
|
return output(rs, func(w io.Writer) {
|
||||||
fmt.Fprintln(w, "CHAIN\tID\tMATCH\tACTION\tHITS\tCOMMENT")
|
fmt.Fprintln(w, "CHAIN\tID\tMATCH\tACTION\tHITS\tCOMMENT")
|
||||||
for _, r := range rs {
|
for _, r := range rs {
|
||||||
what := describe(r.Match)
|
what := describe(r.Match, r.Chain)
|
||||||
if r.Raw != "" {
|
if r.Raw != "" {
|
||||||
what = "raw: " + r.Raw
|
what = "raw: " + r.Raw
|
||||||
}
|
}
|
||||||
@@ -304,7 +322,7 @@ func ruleCmd() *cobra.Command {
|
|||||||
cmd.AddCommand(ls)
|
cmd.AddCommand(ls)
|
||||||
cmd.AddCommand(showCmd("rules", "rule", func(w io.Writer, r model.Rule) {
|
cmd.AddCommand(showCmd("rules", "rule", func(w io.Writer, r model.Rule) {
|
||||||
if r.Raw == "" {
|
if r.Raw == "" {
|
||||||
fmt.Fprintf(w, "ID:\t%d\nChain:\t%s\nMatch:\t%s%s\nAction:\t%s\n", r.ID, r.Chain, describe(r.Match), ruleExtras(r), r.Action)
|
fmt.Fprintf(w, "ID:\t%d\nChain:\t%s\nMatch:\t%s%s\nAction:\t%s\n", r.ID, r.Chain, describe(r.Match, r.Chain), ruleExtras(r), r.Action)
|
||||||
} else {
|
} else {
|
||||||
fmt.Fprintf(w, "ID:\t%d\nChain:\t%s\n", r.ID, r.Chain)
|
fmt.Fprintf(w, "ID:\t%d\nChain:\t%s\n", r.ID, r.Chain)
|
||||||
}
|
}
|
||||||
@@ -700,7 +718,7 @@ func snatCmd() *cobra.Command {
|
|||||||
if to == "" {
|
if to == "" {
|
||||||
to = "masquerade"
|
to = "masquerade"
|
||||||
}
|
}
|
||||||
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\n", x.ID, describe(x.Match), to, hits(cs, fmt.Sprintf("s%d", x.ID)), x.Comment)
|
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\n", x.ID, describe(x.Match, ""), to, hits(cs, fmt.Sprintf("s%d", x.ID)), x.Comment)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@@ -780,7 +798,7 @@ func redirectCmd() *cobra.Command {
|
|||||||
return output(xs, func(w io.Writer) {
|
return output(xs, func(w io.Writer) {
|
||||||
fmt.Fprintln(w, "ID\tMATCH\tTO PORT\tHITS\tCOMMENT")
|
fmt.Fprintln(w, "ID\tMATCH\tTO PORT\tHITS\tCOMMENT")
|
||||||
for _, x := range xs {
|
for _, x := range xs {
|
||||||
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\n", x.ID, describe(x.Match), orDash(x.ToPort), hits(cs, fmt.Sprintf("d%d", x.ID)), x.Comment)
|
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\n", x.ID, describe(x.Match, ""), orDash(x.ToPort), hits(cs, fmt.Sprintf("d%d", x.ID)), x.Comment)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|||||||
+3
-2
@@ -192,8 +192,9 @@ hc rule reset-quota 31 --give 5gbytes --address 192.168.1.23
|
|||||||
|
|
||||||
`--give` is a top-up: it takes that much off what was used (never below
|
`--give` is a top-up: it takes that much off what was used (never below
|
||||||
zero), so the device gets 5 GB more until the quota's next reset, and the
|
zero), so the device gets 5 GB more until the quota's next reset, and the
|
||||||
rule stays as it is. The web UI has **Reset** and **Give back…** on the
|
rule stays as it is. In the web UI, the quota's line under the rule
|
||||||
rule, and for each address in its usage dialog. Like a reset, these are
|
opens its dialog: usage (each address's), resets, and **Reset** and
|
||||||
|
**Give back…** for everyone or one address. Like a reset, these are
|
||||||
done at once (audited, not staged or on probation); Housecarl puts the
|
done at once (audited, not staged or on probation); Housecarl puts the
|
||||||
ruleset in force again with the new figure.
|
ruleset in force again with the new figure.
|
||||||
|
|
||||||
|
|||||||
@@ -158,42 +158,53 @@ function extras(r, q) {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
// quotaLine shows a quota, and what it used: in total, or per address
|
// quotaLine sums up a quota on the rule's row: what it used (in total,
|
||||||
// (the busiest, and the rest in a dialog).
|
// or how many addresses and the busiest); the rest is in its dialog.
|
||||||
function quotaLine(r, q) {
|
function quotaLine(r, q) {
|
||||||
const qt = r.quota;
|
const qt = r.quota;
|
||||||
const each = qt.per_source ? " each source" : qt.per_destination ? " each destination" : "";
|
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";
|
const resets = qt.reset === "monthly" ? "monthly" : qt.reset || "reset by hand";
|
||||||
let used = null;
|
let used = fmtBytes(qt.bytes) + each;
|
||||||
if (q && !qt.per_source && !qt.per_destination) {
|
if (q && !each) used = fmtBytes(q.used) + " of " + fmtBytes(qt.bytes) + " used";
|
||||||
used = [h("strong", null, fmtBytes(q.used)), " of " + fmtBytes(qt.bytes) + " used"];
|
else if (q) {
|
||||||
} else if (q) {
|
|
||||||
const n = (q.addresses || []).length;
|
const n = (q.addresses || []).length;
|
||||||
used = [n === 1 ? "1 address" : n + " addresses", n ? [", busiest ", h("strong", null, fmtBytes(q.addresses[0].used))] : null, " ",
|
used += " · " + (n === 1 ? "1 address" : n + " addresses") + (n ? ", busiest " + fmtBytes(q.addresses[0].used) : "");
|
||||||
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,
|
const text = "quota " + used + " · " + resets;
|
||||||
used ? [" · ", used] : null,
|
return h("div", { class: "muted small" }, q && r.pending !== "new"
|
||||||
q && q.next_reset ? " · next reset " + new Date(q.next_reset).toLocaleString() : null,
|
? h("button", { class: "link small", title: "Usage, reset and give back", onclick: () => quotaDialog(r, q) }, text) : text);
|
||||||
can("config", true) && q && r.pending !== "new" ? [" ", h("button", { class: "small", onclick: () => resetQuota(r) }, "Reset"),
|
|
||||||
" ", h("button", { class: "small", onclick: () => giveBack(r) }, "Give back…")] : null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function usageDialog(r, q) {
|
// quotaDialog shows a quota in full: what it used (each address's, for a
|
||||||
const names = await lookupNames(q.addresses.map((a) => a.address));
|
// per-address one), its resets, and resetting or giving back.
|
||||||
// A row's action closes this dialog first: it opens one of its own.
|
async function quotaDialog(r, q) {
|
||||||
const then = (f) => (e) => { e.target.closest(".modal").querySelector(".buttons button").click(); f(); };
|
const qt = r.quota;
|
||||||
|
const perAddress = qt.per_source || qt.per_destination;
|
||||||
const write = can("config", true);
|
const write = can("config", true);
|
||||||
await modal("Quota usage of " + (r.comment || "rule #" + r.id), h("div", null,
|
const resets = qt.reset === "monthly" ? "monthly on day " + (qt.day || 1) : qt.reset || "only by hand";
|
||||||
h("p", { class: "muted" }, "Each " + (r.quota.per_source ? "source" : "destination") + " has " + fmtBytes(r.quota.bytes) + "."),
|
const when = (t) => t ? new Date(t).toLocaleString() : "–";
|
||||||
table([
|
const names = perAddress ? await lookupNames((q.addresses || []).map((a) => a.address)) : {};
|
||||||
|
// A row's action closes this dialog first: it opens one of its own.
|
||||||
|
const then = (f) => (e) => { e.target.closest(".modal").querySelector(".buttons button:last-child").click(); f(); }; // Close
|
||||||
|
const v = await modal("Quota of " + (r.comment || "rule #" + r.id), h("div", null,
|
||||||
|
h("p", null, fmtBytes(qt.bytes) + (perAddress ? (qt.per_source ? " for each source" : " for each destination") : " in all") + "; the rule matches " +
|
||||||
|
(qt.over ? "once it is used up" : "while it lasts") + ". Resets " + resets + "."),
|
||||||
|
h("p", { class: "muted small" }, "Last reset " + when(q.last_reset) + " · next reset " + when(q.next_reset)),
|
||||||
|
perAddress ? table([
|
||||||
{ label: "Address", render: (a) => h("span", { class: "mono" }, named(names, a.address)) },
|
{ label: "Address", render: (a) => h("span", { class: "mono" }, named(names, a.address)) },
|
||||||
{ label: "Used", render: (a) => fmtBytes(a.used) },
|
{ label: "Used", render: (a) => fmtBytes(a.used) },
|
||||||
{ label: "", render: (a) => usageBar(a.used, r.quota.bytes) },
|
{ label: "", render: (a) => usageBar(a.used, qt.bytes) },
|
||||||
write ? { label: "", render: (a) => h("span", { class: "nowrap" },
|
write ? { label: "", render: (a) => h("span", { class: "nowrap" },
|
||||||
h("button", { class: "small", onclick: then(() => resetQuota(r, a.address)) }, "Reset"), " ",
|
h("button", { class: "small", onclick: then(() => resetQuota(r, a.address)) }, "Reset"), " ",
|
||||||
h("button", { class: "small", onclick: then(() => giveBack(r, a.address)) }, "Give back…")) } : null,
|
h("button", { class: "small", onclick: then(() => giveBack(r, a.address)) }, "Give back…")) } : null,
|
||||||
].filter(Boolean), q.addresses)), [{ label: "Close", value: null }], { wide: true });
|
].filter(Boolean), q.addresses || [], { empty: "No address has used it since the last reset." })
|
||||||
|
: h("div", null, h("strong", null, fmtBytes(q.used)), " of " + fmtBytes(qt.bytes) + " used", usageBar(q.used, qt.bytes))),
|
||||||
|
[
|
||||||
|
...(write ? [{ label: perAddress ? "Reset all" : "Reset", value: "reset" }, { label: perAddress ? "Give back to all…" : "Give back…", value: "give" }] : []),
|
||||||
|
{ label: "Close", value: null },
|
||||||
|
], { wide: perAddress });
|
||||||
|
if (v === "reset") resetQuota(r);
|
||||||
|
if (v === "give") giveBack(r);
|
||||||
}
|
}
|
||||||
|
|
||||||
// giveBack takes an amount off what a quota used (one address's, or
|
// giveBack takes an amount off what a quota used (one address's, or
|
||||||
|
|||||||
@@ -98,11 +98,16 @@ export function matchEditor(m, n, opts = {}) {
|
|||||||
// describe sums up a match: "in lan · from trusted · ssh, dns".
|
// describe sums up a match: "in lan · from trusted · ssh, dns".
|
||||||
export function describe(m) {
|
export function describe(m) {
|
||||||
const parts = [];
|
const parts = [];
|
||||||
const side = (word, not, list) => { if (list && list.length) parts.push(word + (not ? " not " : " ") + list.join(", ")); };
|
const list = (not, xs) => (not ? "any but " : "") + xs.join(", ");
|
||||||
side("in", m.not_in, m.in_zones);
|
// Zones are a direction, "lan → wan"; the firewall itself is the other
|
||||||
side("out", m.not_out, m.out_zones);
|
// end of the input and output chains.
|
||||||
side("from", m.not_sources, m.sources);
|
const ins = m.in_zones || [], outs = m.out_zones || [];
|
||||||
side("to", m.not_destinations, m.destinations);
|
if (ins.length || outs.length) {
|
||||||
|
parts.push((m.chain === "output" ? "firewall" : ins.length ? list(m.not_in, ins) : "any") + " → " +
|
||||||
|
(m.chain === "input" ? "firewall" : outs.length ? list(m.not_out, outs) : "any"));
|
||||||
|
}
|
||||||
|
if (m.sources && m.sources.length) parts.push("from " + list(m.not_sources, m.sources));
|
||||||
|
if (m.destinations && m.destinations.length) parts.push("to " + list(m.not_destinations, m.destinations));
|
||||||
if (m.services && m.services.length) parts.push(m.services.join(", "));
|
if (m.services && m.services.length) parts.push(m.services.join(", "));
|
||||||
if (m.family) parts.push(m.family + " only");
|
if (m.family) parts.push(m.family + " only");
|
||||||
return parts.length ? parts.join(" · ") : "everything";
|
return parts.length ? parts.join(" · ") : "everything";
|
||||||
|
|||||||
Reference in new issue
Block a user