Files
housecarl/cmd/hc/rules.go
T
abrown38andClaude Opus 5.5 9a165cf70b Staged undo, built-ins window, machine warnings; raw and dynamic set fixes
- Undo is staged like any edit: in review mode the web UI's Undo, and hc
  history undo with -C or HOUSECARL_CHANGESET, add a revert operation
  that checks, when replayed, that nothing it touches changed since;
  applied, History links the entries. Imports and takeovers still can't
  be staged.
- The rules page sums up a chain's built-ins in one line; a window lists
  them with what they do and their hits, and switches them.
- Warnings about the machine (dashboard, hc status): nftables.service
  enabled (it loads /etc/nftables.conf at boot and flushes every table
  when stopped), and the imported table still in force beside
  Housecarl's. The docs say to disable nftables.service but not stop it.
- A raw rule with other fields set is refused (raw_conflict) instead of
  silently ignoring them; the rule dialog's nftables section hides what
  it replaces, and the importer's raw rules carry only their text.
- A dynamic set with configured entries is refused
  (set_dynamic_entries): they were silently ignored. The set dialog
  shows entries or the entry timeout, as fits.
- The rule dialog's chain choices are just the chains' names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:52:55 +01:00

772 lines
26 KiB
Go

package main
import (
"fmt"
"io"
"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 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")
}
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, 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
}
var resp api.MutationResponse
if err := c.Do(ctx(), "POST", "/rules", nil, r, &resp); err != nil {
return err
}
// Then place it.
if resp.Applied && (rf.first || rf.before != 0 || rf.after != 0) {
var created model.Rule
if err := jsonUnmarshal(resp.Object, &created); err == nil {
mv := api.MoveInput{Before: rf.before, After: rf.after}
if rf.first {
mv.To = "start"
}
var moved api.MutationResponse
if err := c.Do(ctx(), "POST", objPath("rules", fmt.Sprint(created.ID))+"/move", nil, mv, &moved); err != nil {
return err
}
resp.Changes = append(resp.Changes, moved.Changes...)
resp.ConfirmBy = moved.ConfirmBy
}
}
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},
} {
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)
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 {
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 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
}