POST /rules/{ref}/reset-quota takes an optional address (one entry of a
per-address quota starts again) and give (bytes taken off what was used,
one address's or everyone's, until the next reset). The firewall keeps
the adjustment for its next apply, which writes the new usage into the
ruleset; housecarld applies at once and saves the usage. hc rule
reset-quota --address/--give; Reset and Give back in the web UI, per
address in the usage dialog.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
808 lines
28 KiB
Go
808 lines
28 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"net/url"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/spf13/cobra"
|
|
"github.com/spf13/pflag"
|
|
|
|
"housecarl/internal/api"
|
|
"housecarl/internal/model"
|
|
)
|
|
|
|
// matchFlags are the flags of what a rule, source NAT or redirect
|
|
// matches.
|
|
type matchFlags struct {
|
|
in, out, from, to, services []string
|
|
notIn, notOut, notFrom, notTo bool
|
|
family string
|
|
}
|
|
|
|
func (m *matchFlags) register(f *pflag.FlagSet, in, out bool) {
|
|
if in {
|
|
f.StringArrayVar(&m.in, "in", nil, "coming in on a zone (repeat)")
|
|
f.BoolVar(&m.notIn, "not-in", false, "on any zone but the --in ones")
|
|
}
|
|
if out {
|
|
f.StringArrayVar(&m.out, "out", nil, "going out on a zone (repeat)")
|
|
f.BoolVar(&m.notOut, "not-out", false, "on any zone but the --out ones")
|
|
}
|
|
f.StringArrayVar(&m.from, "from", nil, "source: a set, address, prefix or range (repeat)")
|
|
f.BoolVar(&m.notFrom, "not-from", false, "any source but the --from ones")
|
|
f.StringArrayVar(&m.to, "to", nil, "destination: a set, address, prefix or range (repeat)")
|
|
f.BoolVar(&m.notTo, "not-to", false, "any destination but the --to ones")
|
|
f.StringArrayVar(&m.services, "service", nil, "a service (ssh, https, yours) or entry (tcp/8080, udp/1000-2000, icmp/echo-request); repeat")
|
|
f.StringVar(&m.family, "family", "", "ipv4 or ipv6 only")
|
|
}
|
|
|
|
func (m *matchFlags) match() model.Match {
|
|
return model.Match{InZones: m.in, NotIn: m.notIn, OutZones: m.out, NotOut: m.notOut, Sources: m.from, NotSources: m.notFrom,
|
|
Destinations: m.to, NotDests: m.notTo, Services: m.services, Family: m.family}
|
|
}
|
|
|
|
// patch adds the given match flags to an update.
|
|
func (m *matchFlags) patch(fl *pflag.FlagSet, p patch) {
|
|
set := func(flag, field string, v any) {
|
|
if fl.Lookup(flag) != nil && fl.Changed(flag) {
|
|
p[field] = v
|
|
}
|
|
}
|
|
set("in", "in_zones", m.in)
|
|
set("not-in", "not_in", m.notIn)
|
|
set("out", "out_zones", m.out)
|
|
set("not-out", "not_out", m.notOut)
|
|
set("from", "sources", m.from)
|
|
set("not-from", "not_sources", m.notFrom)
|
|
set("to", "destinations", m.to)
|
|
set("not-to", "not_destinations", m.notTo)
|
|
set("service", "services", m.services)
|
|
set("family", "family", m.family)
|
|
}
|
|
|
|
// describe sums up a match: "in lan from trusted ssh, dns".
|
|
func describe(m model.Match) string {
|
|
var parts []string
|
|
side := func(word string, not bool, list []string) {
|
|
if len(list) == 0 {
|
|
return
|
|
}
|
|
if not {
|
|
word += " not"
|
|
}
|
|
parts = append(parts, word+" "+strings.Join(list, ","))
|
|
}
|
|
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 {
|
|
parts = append(parts, strings.Join(m.Services, ","))
|
|
}
|
|
if m.Family != "" {
|
|
parts = append(parts, m.Family+" only")
|
|
}
|
|
if len(parts) == 0 {
|
|
return "everything"
|
|
}
|
|
return strings.Join(parts, " ")
|
|
}
|
|
|
|
// ---- rules
|
|
|
|
type ruleFlags struct {
|
|
matchFlags
|
|
action, rejectWith, comment, logPrefix, raw string
|
|
states, statuses, days []string
|
|
hours, limit, quota string
|
|
connLimit, groupV4, groupV6 int
|
|
log, disabled, everyPacket bool
|
|
before, after int64
|
|
first bool
|
|
}
|
|
|
|
func (r *ruleFlags) register(f *pflag.FlagSet) {
|
|
r.matchFlags.register(f, true, true)
|
|
f.StringVarP(&r.action, "action", "a", "accept", "accept, drop, reject, or log (log and go on)")
|
|
f.StringVar(&r.rejectWith, "reject-with", "", "tcp-reset (default: an ICMP error)")
|
|
f.StringSliceVar(&r.states, "state", nil, "connection states: new, established, related, invalid, untracked")
|
|
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, 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) {
|
|
if s == "" {
|
|
return nil, nil
|
|
}
|
|
parts := strings.Split(s, ",")
|
|
rate, per, ok := strings.Cut(strings.TrimSpace(parts[0]), "/")
|
|
m := amountRe.FindStringSubmatch(strings.TrimSpace(rate))
|
|
unit, unitOK := "", false
|
|
if m != nil {
|
|
unit, unitOK = byteUnit(m[2])
|
|
}
|
|
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":
|
|
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, 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, 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 {
|
|
return rule, fmt.Errorf("--hours: write it as 08:00-17:30")
|
|
}
|
|
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
|
|
}
|
|
|
|
func ruleCmd() *cobra.Command {
|
|
cmd := &cobra.Command{Use: "rule", Aliases: []string{"rules"}, Short: "Rules: what to accept, drop or reject, in order"}
|
|
ls := &cobra.Command{
|
|
Use: "list [CHAIN]", Short: "List the rules, in order, with their hits", Args: cobra.MaximumNArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
query := q()
|
|
if len(args) == 1 {
|
|
query = q("chain", args[0])
|
|
}
|
|
rs, err := get[[]model.Rule]("/rules", query)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
cs := counters()
|
|
return output(rs, func(w io.Writer) {
|
|
fmt.Fprintln(w, "CHAIN\tID\tMATCH\tACTION\tHITS\tCOMMENT")
|
|
for _, r := range rs {
|
|
what := describe(r.Match)
|
|
if r.Raw != "" {
|
|
what = "raw: " + r.Raw
|
|
}
|
|
extra := ruleExtras(r)
|
|
action := r.Action
|
|
if r.Raw != "" {
|
|
action = "-" // the text's own
|
|
}
|
|
if r.Log {
|
|
action += "+log"
|
|
}
|
|
if r.Disabled {
|
|
action += " (disabled)"
|
|
}
|
|
fmt.Fprintf(w, "%s\t%d\t%s%s\t%s\t%s\t%s\n", r.Chain, r.ID, what, extra, action, hits(cs, fmt.Sprintf("r%d", r.ID)), r.Comment)
|
|
}
|
|
})
|
|
},
|
|
}
|
|
cmd.AddCommand(ls)
|
|
cmd.AddCommand(showCmd("rules", "rule", func(w io.Writer, r model.Rule) {
|
|
if r.Raw == "" {
|
|
fmt.Fprintf(w, "ID:\t%d\nChain:\t%s\nMatch:\t%s%s\nAction:\t%s\n", r.ID, r.Chain, describe(r.Match), ruleExtras(r), r.Action)
|
|
} 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)
|
|
}
|
|
if r.Log {
|
|
fmt.Fprintf(w, "Log:\tyes %s\n", r.LogPrefix)
|
|
}
|
|
if r.Comment != "" {
|
|
fmt.Fprintf(w, "Comment:\t%s\n", r.Comment)
|
|
}
|
|
cs := counters()
|
|
fmt.Fprintf(w, "Hits:\t%s\n", hits(cs, fmt.Sprintf("r%d", r.ID)))
|
|
}))
|
|
var rf ruleFlags
|
|
add := &cobra.Command{
|
|
Use: "add CHAIN [flags]", Short: "Add a rule (at the end of its chain unless --first, --before or --after)", Args: cobra.ExactArgs(1),
|
|
Long: `Adds a rule to a chain: input (to the firewall), forward (through it),
|
|
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 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'`,
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
r, err := rf.rule(args[0])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
c, err := newClient()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// Placed in the same change: one history entry, and never in
|
|
// force at the end of the chain.
|
|
q := url.Values{}
|
|
switch {
|
|
case rf.first:
|
|
q.Set("first", "1")
|
|
case rf.before != 0:
|
|
q.Set("before", fmt.Sprint(rf.before))
|
|
case rf.after != 0:
|
|
q.Set("after", fmt.Sprint(rf.after))
|
|
}
|
|
var resp api.MutationResponse
|
|
if err := c.Do(ctx(), "POST", "/rules", q, r, &resp); err != nil {
|
|
return err
|
|
}
|
|
return report(&resp)
|
|
},
|
|
}
|
|
rf.register(add.Flags())
|
|
add.Flags().BoolVar(&rf.first, "first", false, "put it first in its chain")
|
|
add.Flags().Int64Var(&rf.before, "before", 0, "put it before rule ID")
|
|
add.Flags().Int64Var(&rf.after, "after", 0, "put it after rule ID")
|
|
cmd.AddCommand(add)
|
|
var uf ruleFlags
|
|
update := &cobra.Command{
|
|
Use: "update ID [flags]", Short: "Change a rule (only the flags given)", Args: cobra.ExactArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
fl := cmd.Flags()
|
|
p := patch{}
|
|
uf.matchFlags.patch(fl, p)
|
|
r, err := uf.rule("")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for flag, field := range map[string][2]any{
|
|
"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}, "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]
|
|
}
|
|
}
|
|
if fl.Changed("hours") {
|
|
p["hour_from"], p["hour_to"] = r.HourFrom, r.HourTo
|
|
}
|
|
if fl.Changed("limit") {
|
|
p["limit"] = r.Limit
|
|
}
|
|
if fl.Changed("quota") {
|
|
p["quota"] = r.Quota
|
|
}
|
|
return change("PATCH", objPath("rules", args[0]), p)
|
|
},
|
|
}
|
|
uf.register(update.Flags())
|
|
cmd.AddCommand(update)
|
|
var before, after int64
|
|
var first, last bool
|
|
var chain string
|
|
move := &cobra.Command{
|
|
Use: "move ID (--before ID | --after ID | --first | --last | --chain CHAIN)", Short: "Move a rule", Args: cobra.ExactArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
mv := api.MoveInput{Before: before, After: after, Chain: chain}
|
|
switch {
|
|
case first:
|
|
mv.To = "start"
|
|
case last:
|
|
mv.To = "end"
|
|
}
|
|
return change("POST", objPath("rules", args[0])+"/move", mv)
|
|
},
|
|
}
|
|
move.Flags().Int64Var(&before, "before", 0, "before rule ID")
|
|
move.Flags().Int64Var(&after, "after", 0, "after rule ID")
|
|
move.Flags().BoolVar(&first, "first", false, "first in its chain")
|
|
move.Flags().BoolVar(&last, "last", false, "last in its chain")
|
|
move.Flags().StringVar(&chain, "chain", "", "to the end of another chain")
|
|
cmd.AddCommand(move)
|
|
var resetAddr, give string
|
|
resetCmd := &cobra.Command{
|
|
Use: "reset-quota ID [--address ADDR] [--give AMOUNT]", Short: "Start a rule's quota again now, or give some back until its next reset", Args: cobra.ExactArgs(1),
|
|
Long: `Starts a rule's quota again now: all of it (every address's, for a
|
|
per-address quota), or with --address one address's. --give takes an
|
|
amount off what was used instead: extra allowance until the quota's next
|
|
reset, without changing the rule. Done at once, not staged or on
|
|
probation.
|
|
|
|
hc rule reset-quota 31
|
|
hc rule reset-quota 31 --address 192.168.1.23
|
|
hc rule reset-quota 31 --give 5gbytes --address 192.168.1.23`,
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
in := api.QuotaReset{Address: resetAddr}
|
|
if give != "" {
|
|
bad := fmt.Errorf("--give %q: write it as 500mbytes or 5gbytes", give)
|
|
m := amountRe.FindStringSubmatch(strings.TrimSpace(give))
|
|
if m == nil {
|
|
return bad
|
|
}
|
|
unit, ok := byteUnit(m[2])
|
|
n, _ := strconv.ParseInt(m[1], 10, 64)
|
|
if !ok || unit == "" || n < 1 {
|
|
return bad
|
|
}
|
|
in.Give = n * model.ByteUnits[unit]
|
|
}
|
|
c, err := newClient()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := c.Do(ctx(), "POST", objPath("rules", args[0])+"/reset-quota", nil, in, nil); err != nil {
|
|
return err
|
|
}
|
|
if !flagQuiet {
|
|
who := ""
|
|
if resetAddr != "" {
|
|
who = " for " + resetAddr
|
|
}
|
|
if in.Give > 0 {
|
|
fmt.Printf("gave back %s of the quota of rule %s%s, until its next reset\n", humanBytes(in.Give), args[0], who)
|
|
} else {
|
|
fmt.Printf("reset the quota of rule %s%s\n", args[0], who)
|
|
}
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
resetCmd.Flags().StringVar(&resetAddr, "address", "", "only this address's (a per-address quota; an IPv6 address counts for its /64)")
|
|
resetCmd.Flags().StringVar(&give, "give", "", "take this much off what was used instead (5gbytes, 500mbytes)")
|
|
cmd.AddCommand(resetCmd)
|
|
for _, on := range []bool{true, false} {
|
|
verb, short := "disable", "Keep a rule but don't use it"
|
|
if on {
|
|
verb, short = "enable", "Use a disabled rule again"
|
|
}
|
|
cmd.AddCommand(&cobra.Command{Use: verb + " ID", Short: short, Args: cobra.ExactArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
return change("PATCH", objPath("rules", args[0]), patch{"disabled": !on})
|
|
}})
|
|
}
|
|
cmd.AddCommand(deleteCmd("rules", "rule"))
|
|
return cmd
|
|
}
|
|
|
|
func ruleExtras(r model.Rule) string {
|
|
var x []string
|
|
if len(r.CtState) > 0 {
|
|
x = append(x, "state "+strings.Join(r.CtState, ","))
|
|
}
|
|
if len(r.CtStatus) > 0 {
|
|
x = append(x, "status "+strings.Join(r.CtStatus, ","))
|
|
}
|
|
if len(r.Days) > 0 {
|
|
x = append(x, strings.Join(r.Days, ","))
|
|
}
|
|
if r.HourFrom != "" {
|
|
x = append(x, r.HourFrom+"-"+r.HourTo)
|
|
}
|
|
if l := r.Limit; l != nil {
|
|
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 ""
|
|
}
|
|
return " " + strings.Join(x, " ")
|
|
}
|
|
|
|
// ---- port forwards
|
|
|
|
func forwardCmd() *cobra.Command {
|
|
cmd := &cobra.Command{Use: "forward", Aliases: []string{"forwards", "port-forward"}, Short: "Port forwards: publish an inside host's service"}
|
|
cmd.AddCommand(&cobra.Command{
|
|
Use: "list", Short: "List the port forwards, with their hits", Args: cobra.NoArgs,
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
fs, err := get[[]model.PortForward]("/forwards", nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
cs := counters()
|
|
return output(fs, func(w io.Writer) {
|
|
fmt.Fprintln(w, "ID\tNAME\tPUBLIC\tTO\tTO6\tFROM\tHAIRPIN\tHITS")
|
|
for _, f := range fs {
|
|
pub := list(f.PublicAddresses)
|
|
if len(f.PublicAddresses) == 0 {
|
|
pub = "(external zones)"
|
|
}
|
|
to := orDash(f.To)
|
|
if f.ToPort != "" {
|
|
to += ":" + f.ToPort
|
|
}
|
|
name := orDash(f.Name)
|
|
if f.Disabled {
|
|
name += " (disabled)"
|
|
}
|
|
fmt.Fprintf(w, "%d\t%s\t%s %s/%s\t%s\t%s\t%s\t%s\t%s\n", f.ID, name, pub, strings.Join(f.Protocols, ","), f.Ports, to,
|
|
orDash(f.To6), list(f.Sources), yesNo(f.Hairpin), hits(cs, fmt.Sprintf("f%d", f.ID), fmt.Sprintf("f%d-6", f.ID)))
|
|
}
|
|
})
|
|
},
|
|
})
|
|
cmd.AddCommand(showCmd("forwards", "forward", func(w io.Writer, f model.PortForward) {
|
|
fmt.Fprintf(w, "ID:\t%d\nName:\t%s\nProtocols:\t%s\nPorts:\t%s\nPublic:\t%s\nTo:\t%s\n", f.ID, orDash(f.Name), list(f.Protocols), f.Ports, list(f.PublicAddresses), orDash(f.To))
|
|
if f.ToPort != "" {
|
|
fmt.Fprintf(w, "To port:\t%s\n", f.ToPort)
|
|
}
|
|
fmt.Fprintf(w, "To (IPv6):\t%s\nFrom:\t%s\nHairpin:\t%s %s\n", orDash(f.To6), list(f.Sources), yesNo(f.Hairpin), list(f.HairpinSources))
|
|
}))
|
|
var pf model.PortForward
|
|
var to string
|
|
add := &cobra.Command{
|
|
Use: "add PORTS --to HOST[:PORT] [flags]", Short: "Add a port forward", Args: cobra.ExactArgs(1),
|
|
Long: `Forwards a public port (or range) to an inside host, lets the connections
|
|
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 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 != "" {
|
|
host, port, ok := strings.Cut(to, ":")
|
|
if ok && strings.Count(to, ":") == 1 {
|
|
pf.To, pf.ToPort = host, port
|
|
} else {
|
|
pf.To = to
|
|
}
|
|
}
|
|
return change("POST", "/forwards", pf)
|
|
},
|
|
}
|
|
f := add.Flags()
|
|
f.StringVar(&pf.Name, "name", "", "a name")
|
|
f.StringSliceVar(&pf.Protocols, "proto", []string{"tcp"}, "tcp, udp or both")
|
|
f.StringArrayVar(&pf.PublicAddresses, "public", nil, "a public IPv4 address or set to match (repeat; default: any address on the external zones)")
|
|
f.StringArrayVar(&pf.Sources, "from", nil, "only from these sources (repeat)")
|
|
f.StringVar(&to, "to", "", "the inside IPv4 host, with :PORT if it differs")
|
|
f.StringVar(&pf.To6, "to6", "", "an inside IPv6 host to open the port to")
|
|
f.BoolVar(&pf.Hairpin, "hairpin", false, "make the public address work from inside")
|
|
f.StringArrayVar(&pf.HairpinSources, "hairpin-from", nil, "only for these inside sources (repeat; default: all inside)")
|
|
f.StringVar(&pf.Comment, "comment", "", "a comment")
|
|
cmd.AddCommand(add)
|
|
cmd.AddCommand(objectUpdate("forwards", "forward", map[string]string{"name": "name", "proto": "protocols", "public": "public_addresses",
|
|
"from": "sources", "to6": "to6", "hairpin": "hairpin", "hairpin-from": "hairpin_sources", "comment": "comment", "disabled": "disabled",
|
|
"ports": "ports", "to": "to", "to-port": "to_port"}, func(f *pflag.FlagSet) {
|
|
f.String("name", "", "a name")
|
|
f.StringSlice("proto", nil, "tcp, udp or both")
|
|
f.StringArray("public", nil, "public addresses (replace)")
|
|
f.StringArray("from", nil, "sources (replace)")
|
|
f.String("ports", "", "the public port or range")
|
|
f.String("to", "", "the inside IPv4 host")
|
|
f.String("to-port", "", "the inside port (\"\" keeps the public one)")
|
|
f.String("to6", "", "the inside IPv6 host (\"\": none)")
|
|
f.Bool("hairpin", false, "make the public address work from inside")
|
|
f.StringArray("hairpin-from", nil, "hairpin sources (replace)")
|
|
f.String("comment", "", "a comment")
|
|
f.Bool("disabled", false, "keep it but don't use it")
|
|
}))
|
|
cmd.AddCommand(deleteCmd("forwards", "forward"))
|
|
return cmd
|
|
}
|
|
|
|
// objectUpdate makes an update command whose flags map one to one onto
|
|
// fields.
|
|
func objectUpdate(kind, what string, fields map[string]string, register func(*pflag.FlagSet)) *cobra.Command {
|
|
cmd := &cobra.Command{
|
|
Use: "update " + strings.ToUpper(what) + " [flags]", Short: "Change a " + what + " (only the flags given)", Args: cobra.ExactArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
p := patch{}
|
|
var err error
|
|
cmd.Flags().Visit(func(fl *pflag.Flag) {
|
|
field, ok := fields[fl.Name]
|
|
if !ok {
|
|
return
|
|
}
|
|
switch fl.Value.Type() {
|
|
case "bool":
|
|
p[field] = fl.Value.String() == "true"
|
|
case "stringSlice":
|
|
v, _ := cmd.Flags().GetStringSlice(fl.Name)
|
|
p[field] = v
|
|
case "stringArray":
|
|
v, _ := cmd.Flags().GetStringArray(fl.Name)
|
|
p[field] = v
|
|
default:
|
|
p[field] = fl.Value.String()
|
|
}
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return change("PATCH", objPath(kind, args[0]), p)
|
|
},
|
|
}
|
|
register(cmd.Flags())
|
|
return cmd
|
|
}
|
|
|
|
// ---- source NAT
|
|
|
|
func snatCmd() *cobra.Command {
|
|
cmd := &cobra.Command{Use: "snat", Aliases: []string{"masquerade"}, Short: "Source NAT: masquerade or rewrite outgoing sources"}
|
|
cmd.AddCommand(&cobra.Command{
|
|
Use: "list", Short: "List the source NAT rules, in order", Args: cobra.NoArgs,
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
xs, err := get[[]model.SourceNAT]("/snats", nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
cs := counters()
|
|
return output(xs, func(w io.Writer) {
|
|
fmt.Fprintln(w, "ID\tMATCH\tTO\tHITS\tCOMMENT")
|
|
for _, x := range xs {
|
|
to := x.To
|
|
if to == "" {
|
|
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)
|
|
}
|
|
})
|
|
},
|
|
})
|
|
var mf matchFlags
|
|
var to, comment string
|
|
add := &cobra.Command{
|
|
Use: "add [flags]", Short: "Add a source NAT rule", Args: cobra.NoArgs,
|
|
Long: `Masquerades (or with --snat-to, rewrites to an address) the source of
|
|
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 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})
|
|
},
|
|
}
|
|
mf.register(add.Flags(), true, true)
|
|
add.Flags().StringVar(&to, "snat-to", "", "rewrite to this address instead of masquerading")
|
|
add.Flags().StringVar(&comment, "comment", "", "a comment")
|
|
cmd.AddCommand(add)
|
|
cmd.AddCommand(natUpdate("snats", "snat", true))
|
|
cmd.AddCommand(deleteCmd("snats", "snat"))
|
|
return cmd
|
|
}
|
|
|
|
// natUpdate changes a source NAT rule or redirect.
|
|
func natUpdate(kind, what string, out bool) *cobra.Command {
|
|
var mf matchFlags
|
|
var to, comment string
|
|
var disabled bool
|
|
cmd := &cobra.Command{
|
|
Use: "update ID [flags]", Short: "Change a " + what + " (only the flags given)", Args: cobra.ExactArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
fl := cmd.Flags()
|
|
p := patch{}
|
|
mf.patch(fl, p)
|
|
if fl.Changed("snat-to") {
|
|
p["to"] = to
|
|
}
|
|
if fl.Changed("to-port") {
|
|
p["to_port"] = to
|
|
}
|
|
if fl.Changed("comment") {
|
|
p["comment"] = comment
|
|
}
|
|
if fl.Changed("disabled") {
|
|
p["disabled"] = disabled
|
|
}
|
|
return change("PATCH", objPath(kind, args[0]), p)
|
|
},
|
|
}
|
|
mf.register(cmd.Flags(), true, out)
|
|
if out {
|
|
cmd.Flags().StringVar(&to, "snat-to", "", "rewrite to this address (\"\": masquerade)")
|
|
} else {
|
|
cmd.Flags().StringVar(&to, "to-port", "", "the local port")
|
|
}
|
|
cmd.Flags().StringVar(&comment, "comment", "", "a comment")
|
|
cmd.Flags().BoolVar(&disabled, "disabled", false, "keep it but don't use it")
|
|
return cmd
|
|
}
|
|
|
|
// ---- redirects
|
|
|
|
func redirectCmd() *cobra.Command {
|
|
cmd := &cobra.Command{Use: "redirect", Aliases: []string{"redirects"}, Short: "Redirects: send traffic to a port on the firewall itself"}
|
|
cmd.AddCommand(&cobra.Command{
|
|
Use: "list", Short: "List the redirects", Args: cobra.NoArgs,
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
xs, err := get[[]model.Redirect]("/redirects", nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
cs := counters()
|
|
return output(xs, func(w io.Writer) {
|
|
fmt.Fprintln(w, "ID\tMATCH\tTO PORT\tHITS\tCOMMENT")
|
|
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)
|
|
}
|
|
})
|
|
},
|
|
})
|
|
var mf matchFlags
|
|
var toPort, comment string
|
|
add := &cobra.Command{
|
|
Use: "add [flags]", Short: "Add a redirect", Args: cobra.NoArgs,
|
|
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 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})
|
|
},
|
|
}
|
|
mf.register(add.Flags(), true, false)
|
|
add.Flags().StringVar(&toPort, "to-port", "", "the local port (default: the service's)")
|
|
add.Flags().StringVar(&comment, "comment", "", "a comment")
|
|
cmd.AddCommand(add)
|
|
cmd.AddCommand(natUpdate("redirects", "redirect", false))
|
|
cmd.AddCommand(deleteCmd("redirects", "redirect"))
|
|
return cmd
|
|
}
|