- Connections: the kernel's tracked connections, read and closed over ctnetlink (internal/conntrack, raw netlink like the drop log), with their NAT and TCP state; closing one makes the rules check its next packet again (GET /connections, POST /connections/close; hc connections; Traffic → Connections). - Rules can check every packet (every_packet, --every-packet): before established connections are accepted, so a drop or reject also stops connections already open. They can't accept. - A packet tracer (internal/trace): a table of its own, inet housecarl-trace, marks the packets a filter picks, `nft monitor trace` reports every rule and policy they meet in every table, and the tracer names what accepted or dropped them, Housecarl's rules by name (POST, GET, DELETE /trace; hc trace; Traffic → Trace). One trace at a time, at most 200 packets and 20 a second; the table goes when it ends. e2e: 157 checks, among them an upload a normal block doesn't stop and an every-packet one does, and a trace naming the blocking rule. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
777 lines
27 KiB
Go
777 lines
27 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, 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
|
|
}
|
|
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},
|
|
"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)
|
|
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 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
|
|
}
|