Build Housecarl 0.1.0: the nftables firewall manager

Zones, address sets (IPv4 and IPv6 mixed, dynamic sets with timeouts) and
services, ordered rules in the early, input, forward and output chains,
port forwards with hairpin NAT and IPv6 pinholes, source NAT, redirects,
and switchable built-ins (loopback, conntrack, ICMPv6, antispoofing, MSS
clamping, DHCPv6 client, flowtable). Every change is validated, checked
with nft -c and applied in one transaction to Housecarl's own table,
inet housecarl; changes made over the network roll back unless
confirmed, and the confirmed ruleset is loaded at boot by
housecarl-ruleset.service. An empty configuration applies nothing.

Also: hit counters and the drop log (nflog), 'hc import' of an existing
nftables ruleset and 'hc takeover', the hc CLI, the REST API and the web
UI, users, scoped tokens (sets:write:NAME), the audit log, the web UI's
certificate through Jarl, Debian packages, docs, unit tests with real
nft 1.1.3, and an e2e test with real traffic (71 checks).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
abrown38andClaude Opus 5.5 committed 2026-10-19 07:57:14 +01:00
1 parent 54beb0bba3
commit 34292eeebb
118 files changed
+22165 -114

No files matched your search

+621
View File
@@ -0,0 +1,621 @@
package main
import (
"fmt"
"io"
"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 string
connLimit 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, with ,burst=N ,over (beyond the rate) ,per-source")
f.IntVar(&r.connLimit, "conn-limit", 0, "match a source with more than this many connections")
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")
}
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]), "/")
n, err := strconv.Atoi(rate)
if !ok || err != nil {
return nil, fmt.Errorf("--limit %q: write it as 10/minute, with ,burst=N ,over ,per-source", s)
}
l := &model.Limit{Rate: n, Per: strings.TrimSuffix(per, "s")}
for _, p := range parts[1:] {
k, v, _ := strings.Cut(strings.TrimSpace(p), "=")
switch k {
case "burst":
if l.Burst, err = strconv.Atoi(v); err != nil {
return nil, fmt.Errorf("--limit: burst=%q isn't a number", v)
}
case "over":
l.Over = true
case "per-source":
l.PerSource = true
default:
return nil, fmt.Errorf("--limit: unknown %q (burst=N, over, per-source)", k)
}
}
return l, nil
}
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)
rule.Limit = l
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.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) {
fmt.Fprintf(w, "ID:\t%d\nChain:\t%s\nMatch:\t%s%s\nAction:\t%s\n", r.ID, r.Chain, describe(r.Match), ruleExtras(r), r.Action)
if r.Raw != "" {
fmt.Fprintf(w, "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 2a02:8013:7f3d:10::/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},
} {
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
}
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)
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 {
s := fmt.Sprintf("limit %d/%s", l.Rate, l.Per)
if l.Over {
s = "over " + s
}
if l.PerSource {
s += " per source"
}
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 8096 --to 10.0.10.57:443 --to6 2a02:8013:7f3d:10::57 --public 217.155.8.194 --hairpin --name jellyfin
hc forward add 7777-7780 --proto tcp,udp --to 10.0.10.76 --public 217.155.8.194 --hairpin --name terraria
hc forward add 25 --to 10.0.10.25 --from trusted`,
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 fdab:abab:abab::/48 --family ipv6 --snat-to 2a02:8013:7f3d::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 10.0.10.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
}