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 }