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

+630
View File
@@ -0,0 +1,630 @@
package main
import (
"encoding/json"
"fmt"
"io"
"os"
"sort"
"strconv"
"strings"
"github.com/spf13/cobra"
"golang.org/x/term"
"housecarl/internal/api"
"housecarl/internal/model"
)
func historyCmd() *cobra.Command {
cmd := &cobra.Command{Use: "history", Short: "The history of configuration changes, and undo"}
var limit int
list := &cobra.Command{
Use: "list", Short: "List recent changes", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var hs []model.HistoryEntry
if err := c.Get(ctx(), "/history", q("limit", strconv.Itoa(limit)), &hs); err != nil {
return err
}
return output(hs, func(w io.Writer) {
fmt.Fprintln(w, "ID\tWHEN\tBY\tVIA\tCHANGES\tDESCRIPTION")
for _, h := range hs {
desc := h.Description
if h.RevertedBy != 0 {
desc += fmt.Sprintf(" (undone by %d)", h.RevertedBy)
}
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%d\t%s\n", h.ID, h.Time.Local().Format("2006-01-02 15:04"), h.Username, h.Via, len(h.Changes), desc)
}
})
},
}
list.Flags().IntVar(&limit, "limit", 30, "how many")
cmd.AddCommand(list)
cmd.AddCommand(&cobra.Command{
Use: "show ID", Short: "Show a change: each object before and after", Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var h model.HistoryEntry
if err := c.Get(ctx(), "/history/"+args[0], nil, &h); err != nil {
return err
}
return output(h, func(w io.Writer) {
fmt.Fprintf(w, "%d\t%s by %s (%s): %s\n", h.ID, h.Time.Local().Format("2006-01-02 15:04:05"), h.Username, h.Via, h.Description)
for _, ch := range h.Changes {
fmt.Fprintf(w, "\n%s %s %s\n", ch.Action(), ch.Kind, ch.Label)
for _, line := range diffJSON(ch.Before, ch.After) {
fmt.Fprintln(w, " "+line)
}
}
})
},
})
cmd.AddCommand(&cobra.Command{
Use: "undo ID",
Short: "Undo a change",
Long: `Undoes a change by applying it backwards. It is refused when an object the
change touched has changed since; undo the later changes first.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var resp api.MutationResponse
if err := c.Do(ctx(), "POST", "/history/"+args[0]+"/revert", nil, nil, &resp); err != nil {
return err
}
return report(&resp)
},
})
return cmd
}
// diffJSON lists the fields that differ between two versions of an
// object: "name: office -> lab".
func diffJSON(before, after json.RawMessage) []string {
var a, b map[string]any
json.Unmarshal(before, &a)
json.Unmarshal(after, &b)
keys := map[string]bool{}
for k := range a {
keys[k] = true
}
for k := range b {
keys[k] = true
}
var names []string
for k := range keys {
names = append(names, k)
}
sort.Strings(names)
var out []string
show := func(v any, ok bool) string {
if !ok {
return "-"
}
x, _ := json.Marshal(v)
return string(x)
}
for _, k := range names {
va, oka := a[k]
vb, okb := b[k]
sa, sb := show(va, oka), show(vb, okb)
if sa == sb {
continue
}
switch {
case before == nil:
out = append(out, k+": "+sb)
case after == nil:
out = append(out, k+": "+sa)
default:
out = append(out, k+": "+sa+" -> "+sb)
}
}
return out
}
func whoamiCmd() *cobra.Command {
return &cobra.Command{
Use: "whoami", Short: "Show who you are to the server", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var me api.Whoami
if err := c.Get(ctx(), "/auth/whoami", nil, &me); err != nil {
return err
}
return output(me, func(w io.Writer) {
fmt.Fprintf(w, "User:\t%s (%s)\n", me.User.Username, me.Method)
fmt.Fprintf(w, "Administrator:\t%v\n", me.Admin)
fmt.Fprintf(w, "May:\t%s\n", strings.Join(me.Permissions, ", "))
fmt.Fprintf(w, "Server:\tHousecarl %s\n", me.Build.String())
})
},
}
}
func readSecret(prompt string) (string, error) {
fmt.Fprint(os.Stderr, prompt)
if term.IsTerminal(int(os.Stdin.Fd())) {
b, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Fprintln(os.Stderr)
return strings.TrimSpace(string(b)), err
}
var s string
_, err := fmt.Fscanln(os.Stdin, &s)
return strings.TrimSpace(s), err
}
func userID(name string) (int64, error) {
c, err := newClient()
if err != nil {
return 0, err
}
var us []api.User
if err := c.Get(ctx(), "/users", nil, &us); err != nil {
return 0, err
}
for _, u := range us {
if strings.EqualFold(u.Username, name) || strconv.FormatInt(u.ID, 10) == name {
return u.ID, nil
}
}
return 0, fmt.Errorf("no user %s", name)
}
func userCmd() *cobra.Command {
cmd := &cobra.Command{Use: "user", Aliases: []string{"users"}, Short: "Users (administrators only)"}
cmd.AddCommand(&cobra.Command{
Use: "list", Short: "List users", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var us []api.User
if err := c.Get(ctx(), "/users", nil, &us); err != nil {
return err
}
return output(us, func(w io.Writer) {
fmt.Fprintln(w, "ID\tUSER\tADMIN\tGRANTS\tPASSWORD\tDISABLED\tLAST LOGIN")
for _, u := range us {
login := "-"
if u.LastLoginAt != nil {
login = fmtWhen(*u.LastLoginAt)
}
fmt.Fprintf(w, "%d\t%s\t%v\t%s\t%v\t%v\t%s\n", u.ID, u.Username, u.Admin, orDash(strings.Join(u.GrantStrings, " ")), u.HasPassword, u.Disabled, login)
}
})
},
})
var admin, noPassword bool
var role, comment string
var grants []string
add := &cobra.Command{
Use: "add USER",
Short: "Add a user (asks for the password)",
Long: `Adds a user. Without --admin, give what they may do: a role (viewer,
operator, log) or grants such as --grant config:read --grant sets:write:guests.
--no-password makes a service account that only uses API tokens.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
in := api.UserInput{Username: &args[0], Admin: &admin}
if !noPassword {
pw, err := readSecret("Password for " + args[0] + ": ")
if err != nil {
return err
}
in.Password = &pw
}
if role != "" {
in.Role = &role
}
if grants != nil {
in.Grants = &grants
}
if comment != "" {
in.Comment = &comment
}
var u api.User
if err := c.Do(ctx(), "POST", "/users", nil, in, &u); err != nil {
return err
}
if !flagQuiet {
fmt.Printf("added user %s (%d)\n", u.Username, u.ID)
}
return nil
},
}
f := add.Flags()
f.BoolVar(&admin, "admin", false, "an administrator (may do everything)")
f.StringVar(&role, "role", "", "a role: viewer, operator or log")
f.StringArrayVar(&grants, "grant", nil, "a grant, resource:access (config, sets, log, audit; read or write; sets:write:SET,SET)")
f.BoolVar(&noPassword, "no-password", false, "no password: API tokens only")
f.StringVar(&comment, "comment", "", "a comment")
cmd.AddCommand(add)
var setPassword, disable, enable bool
update := &cobra.Command{
Use: "update USER", Short: "Change a user", Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
id, err := userID(args[0])
if err != nil {
return err
}
c, _ := newClient()
in := api.UserInput{}
fl := cmd.Flags()
if fl.Changed("admin") {
in.Admin = &admin
}
if fl.Changed("role") {
in.Role = &role
}
if fl.Changed("grant") {
in.Grants = &grants
}
if fl.Changed("comment") {
in.Comment = &comment
}
if disable || enable {
in.Disabled = &disable
}
if setPassword {
pw, err := readSecret("New password for " + args[0] + ": ")
if err != nil {
return err
}
in.Password = &pw
}
var u api.User
if err := c.Do(ctx(), "PATCH", "/users/"+strconv.FormatInt(id, 10), nil, in, &u); err != nil {
return err
}
if !flagQuiet {
fmt.Printf("updated user %s\n", u.Username)
}
return nil
},
}
f = update.Flags()
f.BoolVar(&admin, "admin", false, "make (or with --admin=false, unmake) an administrator")
f.StringVar(&role, "role", "", "replace the grants with a role's")
f.StringArrayVar(&grants, "grant", nil, "replace the grants (repeat for several)")
f.BoolVar(&setPassword, "password", false, "set a new password (asks for it)")
f.BoolVar(&disable, "disable", false, "disable the user (logs them out)")
f.BoolVar(&enable, "enable", false, "enable the user again")
f.StringVar(&comment, "comment", "", "a comment")
cmd.AddCommand(update)
cmd.AddCommand(&cobra.Command{
Use: "delete USER", Short: "Delete a user and their tokens", Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
id, err := userID(args[0])
if err != nil {
return err
}
c, _ := newClient()
if err := c.Do(ctx(), "DELETE", "/users/"+strconv.FormatInt(id, 10), nil, nil, nil); err != nil {
return err
}
if !flagQuiet {
fmt.Println("deleted user", args[0])
}
return nil
},
})
return cmd
}
func tokenCmd() *cobra.Command {
cmd := &cobra.Command{Use: "token", Aliases: []string{"tokens"}, Short: "API tokens"}
var all bool
var user string
list := &cobra.Command{
Use: "list", Short: "List your tokens (--all: everyone's, for administrators)", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
path := "/me/tokens"
if all {
path = "/tokens"
}
var ts []api.Token
if err := c.Get(ctx(), path, nil, &ts); err != nil {
return err
}
return output(ts, func(w io.Writer) {
fmt.Fprintln(w, "ID\tUSER\tNAME\tGRANTS\tEXPIRES\tLAST USED")
for _, t := range ts {
exp, used := "never", "never"
if t.ExpiresAt != nil {
exp = fmtWhen(*t.ExpiresAt)
}
if t.LastUsedAt != nil {
used = fmtWhen(*t.LastUsedAt) + " from " + t.LastUsedIP
}
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\t%s\n", t.ID, t.Username, t.Name, strings.Join(t.GrantStrings, " "), exp, used)
}
})
},
}
list.Flags().BoolVar(&all, "all", false, "everyone's tokens")
cmd.AddCommand(list)
var role, expires string
var grants []string
var save bool
create := &cobra.Command{
Use: "create NAME",
Short: "Make an API token (the secret is shown once)",
Long: `Makes an API token. It has all of its user's permissions unless --role or
--grant narrows it. On the server as root, --user names whose token it is.
--save puts it in the CLI's configuration.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
path := "/me/tokens"
if user != "" {
id, err := userID(user)
if err != nil {
return err
}
path = "/users/" + strconv.FormatInt(id, 10) + "/tokens"
}
in := api.TokenInput{Name: args[0], Role: role, Grants: grants, ExpiresIn: expires}
var t api.Token
if err := c.Do(ctx(), "POST", path, nil, in, &t); err != nil {
return err
}
if save {
cfg, err := loadCLIConfig()
if err != nil {
return err
}
cfg.Token = t.Secret
if err := saveCLIConfig(cfg); err != nil {
return err
}
fmt.Fprintln(os.Stderr, "saved in", flagConfig)
}
if flagOutput == "json" || flagOutput == "yaml" {
return output(t, nil)
}
fmt.Println(t.Secret)
if !flagQuiet {
fmt.Fprintln(os.Stderr, "this is the only time the token is shown; keep it safe")
}
return nil
},
}
f := create.Flags()
f.StringVar(&role, "role", "", "narrow it to a role: viewer, operator or log")
f.StringArrayVar(&grants, "grant", nil, "narrow it to grants (resource:access)")
f.StringVar(&expires, "expires", "", "expire after this long (90d, 720h)")
f.StringVar(&user, "user", "", "the user it belongs to (administrators)")
f.BoolVar(&save, "save", false, "save it as this CLI's token")
cmd.AddCommand(create)
var grace, rexp string
var current, rsave bool
rotate := &cobra.Command{
Use: "rotate [ID]",
Short: "Give a token a new secret (shown once)",
Long: `Gives a token a new secret. It keeps its ID, name and permissions, so only
the programs that use it need the new secret. The old secret stops at once,
or after --grace (up to 7d) so you can update them without an outage. A
token that expires gets its lifetime again, from now, unless --expires says
otherwise (90d, or never).
hc token rotate --current --save # this CLI's own token`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
id := ""
switch {
case current && len(args) == 0:
var me api.Whoami
if err := c.Get(ctx(), "/auth/whoami", nil, &me); err != nil {
return err
}
if me.TokenID == 0 {
return fmt.Errorf("this CLI isn't using a token (%s); give the token's ID", me.Method)
}
id = strconv.FormatInt(me.TokenID, 10)
case len(args) == 1 && !current:
id = args[0]
default:
return fmt.Errorf("give the token's ID, or --current")
}
body := api.TokenRotation{Grace: grace}
switch rexp {
case "":
case "never":
body.NoExpiry = true
default:
body.ExpiresIn = rexp
}
var t api.Token
if err := c.Do(ctx(), "POST", "/tokens/"+id+"/rotate", nil, body, &t); err != nil {
return err
}
if rsave {
cfg, err := loadCLIConfig()
if err != nil {
return err
}
cfg.Token = t.Secret
if err := saveCLIConfig(cfg); err != nil {
return err
}
fmt.Fprintln(os.Stderr, "saved in", flagConfig)
}
if flagOutput == "json" || flagOutput == "yaml" {
return output(t, nil)
}
fmt.Println(t.Secret)
if !flagQuiet {
if t.PrevExpiresAt != nil {
fmt.Fprintf(os.Stderr, "the old secret works until %s\n", t.PrevExpiresAt.Local().Format("2006-01-02 15:04"))
} else {
fmt.Fprintln(os.Stderr, "the old secret no longer works")
}
}
return nil
},
}
f = rotate.Flags()
f.StringVar(&grace, "grace", "", "keep the old secret working this long (30m, 24h, up to 7d)")
f.StringVar(&rexp, "expires", "", "a new lifetime from now (90d), or never")
f.BoolVar(&current, "current", false, "rotate the token this CLI uses")
f.BoolVar(&rsave, "save", false, "save the new secret as this CLI's token")
cmd.AddCommand(rotate)
cmd.AddCommand(&cobra.Command{
Use: "revoke ID", Short: "Revoke a token", 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(), "DELETE", "/tokens/"+args[0], nil, nil, nil); err != nil {
return err
}
if !flagQuiet {
fmt.Println("revoked token", args[0])
}
return nil
},
})
return cmd
}
func passwordCmd() *cobra.Command {
return &cobra.Command{
Use: "password", Short: "Change your own password", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
cur, err := readSecret("Current password: ")
if err != nil {
return err
}
pw, err := readSecret("New password: ")
if err != nil {
return err
}
if err := c.Do(ctx(), "POST", "/me/password", nil, api.PasswordChange{Current: cur, Password: pw}, nil); err != nil {
return err
}
fmt.Println("password changed")
return nil
},
}
}
func auditCmd() *cobra.Command {
var user, action, search string
var limit int
cmd := &cobra.Command{
Use: "audit", Short: "Show the audit log: logins, users, tokens, changes, confirmations, set entries", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var evs []model.AuditEvent
if err := c.Get(ctx(), "/audit", q("user", user, "action", action, "q", search, "limit", strconv.Itoa(limit)), &evs); err != nil {
return err
}
return output(evs, func(w io.Writer) {
fmt.Fprintln(w, "WHEN\tUSER\tFROM\tACTION\tOBJECT\tRESULT\tDETAIL")
for _, e := range evs {
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", e.Time.Local().Format("2006-01-02 15:04:05"), orDash(e.Username), orDash(e.IP), e.Action, orDash(e.Object), e.Result, e.Detail)
}
})
},
}
f := cmd.Flags()
f.StringVar(&user, "user", "", "only this user's")
f.StringVar(&action, "action", "", "only actions starting with this (auth., config., set.)")
f.StringVar(&search, "search", "", "only events whose object or detail contains this")
f.IntVar(&limit, "limit", 100, "how many")
return cmd
}
func configCmd() *cobra.Command {
cmd := &cobra.Command{Use: "config", Short: "This CLI's settings: the server and token"}
cmd.AddCommand(&cobra.Command{
Use: "show", Short: "Show the CLI's configuration", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := loadCLIConfig()
if err != nil {
return err
}
tok := "-"
if cfg.Token != "" {
tok = cfg.Token[:min(len(cfg.Token), 24)] + "…"
}
fmt.Printf("file: %s\nserver: %s\ntoken: %s\ninsecure: %v\nca_cert: %s\n", flagConfig, orDash(cfg.Server)+" (empty: the local socket)", tok, cfg.Insecure, orDash(cfg.CACert))
return nil
},
})
cmd.AddCommand(&cobra.Command{
Use: "set KEY [VALUE]",
Short: "Set server, token (asks when no value), insecure or ca_cert",
Args: cobra.RangeArgs(1, 2),
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := loadCLIConfig()
if err != nil {
return err
}
val := ""
if len(args) == 2 {
val = args[1]
} else if args[0] == "token" {
if val, err = readSecret("API token: "); err != nil {
return err
}
}
switch args[0] {
case "server":
cfg.Server = val
case "token":
cfg.Token = val
case "insecure":
cfg.Insecure = val == "true" || val == "yes" || val == "on"
case "ca_cert", "ca-cert":
cfg.CACert = val
default:
return fmt.Errorf("unknown setting %q (use server, token, insecure or ca_cert)", args[0])
}
if err := saveCLIConfig(cfg); err != nil {
return err
}
fmt.Fprintln(os.Stderr, "saved in", flagConfig)
return nil
},
})
return cmd
}
+158
View File
@@ -0,0 +1,158 @@
package main
import (
"fmt"
"io"
"os"
"strings"
"time"
"github.com/spf13/cobra"
"housecarl/internal/api"
)
func certCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "cert",
Aliases: []string{"certificate"},
Short: "The web UI's certificate, obtained through Jarl (administrators)",
Long: `Shows the certificate housecarld obtains itself for its web UI (tls.acme in
housecarl.yaml, with Jarl publishing the DNS-01 challenge; docs/certificates.md).
'check' tests the whole path without asking the CA; 'renew' gets a new
certificate now.`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error { return certStatus() },
}
cmd.AddCommand(&cobra.Command{Use: "status", Short: "Show the certificate", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error { return certStatus() }})
cmd.AddCommand(&cobra.Command{
Use: "check",
Short: "Check that the challenge can be published and seen, without the CA",
Long: `Runs the check housecarld runs before every certificate order: Jarl answers
and knows the token, a zone holds _acme-challenge.DOMAIN, the token may
write it, a test record is written, the zone's name servers and the public
resolvers serve it, and it is removed again. It can take a minute or two.`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
c.HTTP.Timeout = 5 * time.Minute
if !flagQuiet && flagOutput == "table" {
fmt.Fprintln(os.Stderr, "checking (this can take a minute or two)…")
}
var ch api.CertCheck
if err := c.Do(ctx(), "POST", "/certificate/check", nil, nil, &ch); err != nil {
return err
}
if err := output(ch, func(w io.Writer) { printCheck(w, &ch) }); err != nil {
return err
}
if !ch.OK {
return fmt.Errorf("the check failed")
}
return nil
},
})
var wait bool
renew := &cobra.Command{
Use: "renew",
Short: "Get a new certificate now (the check runs first)",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var r api.CertificateResponse
if err := c.Do(ctx(), "POST", "/certificate/renew", nil, nil, &r); err != nil {
return err
}
if !wait {
fmt.Println("renewing in the background; follow it with 'hc cert'")
return nil
}
start := time.Now()
for {
time.Sleep(3 * time.Second)
if err := c.Get(ctx(), "/certificate", nil, &r); err != nil {
return err
}
st := r.Status
if st.State != "working" && st.LastAttempt != nil && !st.LastAttempt.Before(start.Add(-time.Second)) {
break
}
}
if err := certStatus(); err != nil {
return err
}
if r.Status.LastError != "" {
return fmt.Errorf("renewing failed")
}
return nil
},
}
renew.Flags().BoolVarP(&wait, "wait", "w", false, "wait until it is done")
cmd.AddCommand(renew)
return cmd
}
func certStatus() error {
c, err := newClient()
if err != nil {
return err
}
var r api.CertificateResponse
if err := c.Get(ctx(), "/certificate", nil, &r); err != nil {
return err
}
return output(r, func(w io.Writer) {
switch r.Source {
case "file":
fmt.Fprintln(w, "Source:\ttls.cert_file (renewed by you)")
return
case "none":
fmt.Fprintln(w, "Source:\tnone: the web UI is plain HTTP (set tls.acme or tls.cert_file in housecarl.yaml; docs/certificates.md)")
return
}
st := r.Status
fmt.Fprintf(w, "Domain:\t%s\n", st.Domain)
ca := st.Directory
if st.Staging {
ca += " (staging: browsers don't trust it)"
}
fmt.Fprintf(w, "CA:\t%s\n", ca)
fmt.Fprintf(w, "State:\t%s\n", st.State)
if st.NotAfter != nil {
fmt.Fprintf(w, "Valid:\t%s to %s, from %s\n", st.NotBefore.Local().Format("2006-01-02"), st.NotAfter.Local().Format("2006-01-02 15:04"), st.Issuer)
fmt.Fprintf(w, "Renews:\t%s\n", st.RenewAt.Local().Format("2006-01-02"))
}
if st.LastAttempt != nil {
fmt.Fprintf(w, "Last attempt:\t%s\n", st.LastAttempt.Local().Format("2006-01-02 15:04"))
}
if st.NextAttempt != nil && st.State != "working" {
fmt.Fprintf(w, "Next check:\t%s\n", st.NextAttempt.Local().Format("2006-01-02 15:04"))
}
if st.LastError != "" {
fmt.Fprintf(w, "Error:\t%s\n", st.LastError)
}
if st.Check != nil {
fmt.Fprintf(w, "\nLast check, %s:\n", st.Check.Time.Local().Format("2006-01-02 15:04"))
printCheck(w, st.Check)
}
})
}
func printCheck(w io.Writer, c *api.CertCheck) {
for _, s := range c.Steps {
mark := map[string]string{"ok": "ok ", "fail": "FAIL", "warn": "warn", "skip": "skip"}[s.Status]
fmt.Fprintf(w, " %s\t%s\t%s\n", mark, s.Name, s.Detail)
if s.Status == "fail" && len(s.Queries) > 0 {
for _, q := range s.Queries {
fmt.Fprintf(w, " \t\t%s\n", strings.TrimSpace(q))
}
}
}
}
+483
View File
@@ -0,0 +1,483 @@
package main
import (
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"sort"
"strings"
"time"
"github.com/spf13/cobra"
"housecarl/internal/api"
"housecarl/internal/model"
)
func jsonUnmarshal(b []byte, v any) error { return json.Unmarshal(b, v) }
// ---- status
func statusCmd() *cobra.Command {
return &cobra.Command{
Use: "status", Short: "Show the firewall's state: ruleset, probation, problems, interfaces", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
st, err := get[api.Status]("/status", nil)
if err != nil {
return err
}
return output(st, func(w io.Writer) {
fmt.Fprintf(w, "Housecarl:\t%s, up since %s\n", st.Build.Label, st.Started.Local().Format("2006-01-02 15:04"))
if st.Applied {
fmt.Fprintln(w, "Ruleset:\tin force (table inet housecarl)")
} else if st.Unconfigured {
fmt.Fprintln(w, "Ruleset:\tnone yet: the configuration is empty (add rules, or 'hc import')")
} else {
fmt.Fprintf(w, "Ruleset:\tNOT in force: %s\n", st.ApplyError)
}
if len(st.Probation.Pending) > 0 {
n := len(st.Probation.Pending)
what := map[bool]string{true: "1 change rolls", false: fmt.Sprintf("%d changes roll", n)}[n == 1]
fmt.Fprintf(w, "Probation:\t%s back in %ds unless confirmed ('hc confirm')\n", what, st.Probation.SecondsLeft)
}
if len(st.OtherTables) > 0 {
fmt.Fprintf(w, "Other tables:\t%s (not Housecarl's; 'hc takeover' deletes them once replaced)\n", strings.Join(st.OtherTables, ", "))
}
var counts []string
for _, k := range model.Kinds {
counts = append(counts, fmt.Sprintf("%d %ss", st.Counts[k], k))
}
fmt.Fprintf(w, "Configuration:\t%s\n", strings.Join(counts, ", "))
fmt.Fprintln(w, "Interfaces:")
for _, i := range st.Interfaces {
state := "down"
if i.Up {
state = "up"
}
fmt.Fprintf(w, " %s\t%s\tzone %s\t%s\n", i.Name, state, orDash(i.Zone), strings.Join(i.Addresses, ", "))
}
if len(st.Problems) > 0 {
fmt.Fprintln(w, "Problems:")
for _, p := range st.Problems {
fmt.Fprintf(w, " %s:\t%s: %s [%s]\n", p.Severity, p.Object, p.Message, p.Code)
}
}
})
},
}
}
func interfaceCmd() *cobra.Command {
return &cobra.Command{
Use: "interfaces", Aliases: []string{"interface"}, Short: "List the machine's interfaces and their zones", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
ifs, err := get[[]api.HostInterface]("/interfaces", nil)
if err != nil {
return err
}
return output(ifs, func(w io.Writer) {
fmt.Fprintln(w, "INTERFACE\tSTATE\tZONE\tADDRESSES")
for _, i := range ifs {
state := "down"
if i.Up {
state = "up"
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", i.Name, state, orDash(i.Zone), orDash(strings.Join(i.Addresses, ", ")))
}
})
},
}
}
// ---- settings
func settingsCmd() *cobra.Command {
cmd := &cobra.Command{Use: "settings", Short: "Policies, built-in rules and other firewall-wide settings"}
cmd.AddCommand(&cobra.Command{
Use: "show", Short: "Show the settings", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
s, err := get[model.Settings]("/settings", nil)
if err != nil {
return err
}
return output(s, func(w io.Writer) {
fmt.Fprintf(w, "Policies:\tinput %s, forward %s, output %s\n", s.InputPolicy, s.ForwardPolicy, s.OutputPolicy)
fmt.Fprintf(w, "Log policy drops:\t%s\n", yesNo(s.LogPolicyDrops))
b, _ := json.Marshal(s.Builtins)
var m map[string]bool
json.Unmarshal(b, &m)
var names []string
for n := range m {
names = append(names, n)
}
sort.Strings(names)
for i, n := range names {
label := ""
if i == 0 {
label = "Built-ins:"
}
fmt.Fprintf(w, "%s\t%s %s\n", label, n, map[bool]string{true: "on", false: "off"}[m[n]])
}
fmt.Fprintf(w, "Own prefixes:\t%s\n", list(s.OwnPrefixes))
fmt.Fprintf(w, "Antispoof IPv4:\t%s\n", list(s.AntispoofV4))
fmt.Fprintf(w, "Antispoof IPv6:\t%s\n", list(s.AntispoofV6))
fmt.Fprintf(w, "Flowtable:\t%s\n", list(s.FlowtableDevices))
fmt.Fprintf(w, "Probation:\t%s\n", fmtSeconds(s.Probation()))
})
},
})
var input, forward, output, logDrops, probation string
var builtins, own, flowtable, spoof4, spoof6 []string
set := &cobra.Command{
Use: "set [flags]", Short: "Change settings (only the flags given)", Args: cobra.NoArgs,
Long: `Changes the settings. Built-ins are rules Housecarl adds itself; each can be
turned off: loopback, established, drop_invalid, icmpv6, ping_internal,
dhcpv6_client, antispoof, mss_clamp.
hc settings set --builtin mss_clamp=on --own-prefix 2a02:8013:7f3d::/48
hc settings set --forward-policy drop --log-drops on
hc settings set --flowtable wan --flowtable enp2s0`,
RunE: func(cmd *cobra.Command, args []string) error {
fl := cmd.Flags()
p := patch{}
for flag, field := range map[string]string{"input-policy": "input_policy", "forward-policy": "forward_policy", "output-policy": "output_policy"} {
if fl.Changed(flag) {
v, _ := fl.GetString(flag)
p[field] = v
}
}
if fl.Changed("log-drops") {
on, err := onOff("log-drops", logDrops)
if err != nil {
return err
}
p["log_policy_drops"] = on
}
if len(builtins) > 0 {
b := map[string]bool{}
for _, x := range builtins {
k, v, ok := strings.Cut(x, "=")
on, err := onOff("builtin "+k, v)
if !ok || err != nil {
return fmt.Errorf("--builtin %q: write it as NAME=on or NAME=off", x)
}
b[strings.ReplaceAll(k, "-", "_")] = on
}
p["builtins"] = b
}
for flag, v := range map[string][]string{"own-prefix": own, "flowtable": flowtable, "antispoof-v4": spoof4, "antispoof-v6": spoof6} {
if fl.Changed(flag) {
p[map[string]string{"own-prefix": "own_prefixes", "flowtable": "flowtable_devices", "antispoof-v4": "antispoof_v4", "antispoof-v6": "antispoof_v6"}[flag]] = v
}
}
if fl.Changed("probation") {
n, err := parseSeconds(probation)
if err != nil {
return err
}
p["probation_seconds"] = n
}
return change("PATCH", "/settings", p)
},
}
f := set.Flags()
f.StringVar(&input, "input-policy", "", "accept or drop what no rule decided, to the firewall")
f.StringVar(&forward, "forward-policy", "", "… through it")
f.StringVar(&output, "output-policy", "", "… from it")
f.StringVar(&logDrops, "log-drops", "", "log what the input and forward policies drop: on or off")
f.StringArrayVar(&builtins, "builtin", nil, "turn a built-in on or off: NAME=on|off (repeat)")
f.StringArrayVar(&own, "own-prefix", nil, "your public prefixes, for antispoofing (repeat; replaces)")
f.StringArrayVar(&flowtable, "flowtable", nil, "fast-path devices (repeat; replaces; none: off)")
f.StringArrayVar(&spoof4, "antispoof-v4", nil, "IPv4 sources dropped from the internet (replaces)")
f.StringArrayVar(&spoof6, "antispoof-v6", nil, "IPv6 sources dropped from the internet (replaces)")
f.StringVar(&probation, "probation", "", "how long a network change has to be confirmed (2m)")
cmd.AddCommand(set)
return cmd
}
func onOff(name, v string) (bool, error) {
switch strings.ToLower(v) {
case "on", "yes", "true":
return true, nil
case "off", "no", "false":
return false, nil
}
return false, fmt.Errorf("--%s: use on or off", name)
}
// ---- the ruleset, counters, the log
func rulesetCmd() *cobra.Command {
return &cobra.Command{
Use: "ruleset", Short: "Print the nftables ruleset Housecarl builds", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var text string
if err := c.Do(ctx(), "GET", "/ruleset", nil, nil, &text); err != nil {
return err
}
fmt.Print(text)
return nil
},
}
}
func countersCmd() *cobra.Command {
return &cobra.Command{
Use: "counters", Short: "Hits of every rule, NAT rule and built-in", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
cs, err := get[api.Counters]("/counters", nil)
if err != nil {
return err
}
return output(cs, func(w io.Writer) {
fmt.Fprintf(w, "Since %s\n", cs.Since.Local().Format("2006-01-02 15:04"))
fmt.Fprintln(w, "MARK\tPACKETS\tBYTES")
var marks []string
for m := range cs.Counters {
marks = append(marks, m)
}
sort.Strings(marks)
for _, m := range marks {
c := cs.Counters[m]
fmt.Fprintf(w, "%s\t%d\t%d\n", m, c.Packets, c.Bytes)
}
})
},
}
}
func logCmd() *cobra.Command {
var follow bool
var limit int
var search string
cmd := &cobra.Command{
Use: "log", Short: "Packets the ruleset logged (dropped by a rule or a policy)", Args: cobra.NoArgs,
Long: `Shows the packets rules with --log and the policies (settings
--log-drops) logged, newest last. -f keeps watching.`,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var last time.Time
print := func(es []api.LogEntry) {
sort.Slice(es, func(i, j int) bool { return es[i].Time.Before(es[j].Time) })
for _, e := range es {
last = e.Time
src, dst := e.Source, e.Dest
if e.SrcPort != 0 {
src = joinPort(src, e.SrcPort)
}
if e.DestPort != 0 {
dst = joinPort(dst, e.DestPort)
}
what := e.Protocol
if e.ICMPType != "" {
what += " " + e.ICMPType
}
if e.TCPFlags != "" {
what += " " + e.TCPFlags
}
fmt.Printf("%s %-28s %-6s %-6s %s -> %s %s\n", e.Time.Local().Format("15:04:05"), strings.TrimSpace(e.Prefix), orDash(e.In), orDash(e.Out), src, dst, what)
}
}
for {
var es []api.LogEntry
query := q("limit", fmt.Sprint(limit), "q", search)
if !last.IsZero() {
query.Set("after", last.Format(time.RFC3339Nano))
}
if err := c.Get(ctx(), "/log", query, &es); err != nil {
return err
}
if flagOutput == "json" || flagOutput == "yaml" {
return output(es, nil)
}
print(es)
if !follow {
if len(es) == 0 {
fmt.Fprintln(os.Stderr, "nothing logged yet (log with 'hc rule update ID --log' or 'hc settings set --log-drops on')")
}
return nil
}
time.Sleep(2 * time.Second)
}
},
}
cmd.Flags().BoolVarP(&follow, "follow", "f", false, "keep watching")
cmd.Flags().IntVar(&limit, "limit", 50, "at most this many")
cmd.Flags().StringVar(&search, "search", "", "only entries containing this (address, interface, prefix)")
return cmd
}
func joinPort(addr string, port int) string {
if strings.Contains(addr, ":") {
return fmt.Sprintf("[%s]:%d", addr, port)
}
return fmt.Sprintf("%s:%d", addr, port)
}
// ---- probation
func confirmCmd() *cobra.Command {
return &cobra.Command{
Use: "confirm", Short: "Keep the changes on probation (otherwise they roll back)", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var out map[string]int
if err := c.Do(ctx(), "POST", "/confirm", nil, nil, &out); err != nil {
return err
}
if out["confirmed"] == 0 {
fmt.Println("nothing was waiting to be confirmed")
} else if out["confirmed"] == 1 {
fmt.Println("confirmed 1 change; it is kept and loaded at boot")
} else {
fmt.Printf("confirmed %d changes; they are kept and loaded at boot\n", out["confirmed"])
}
return nil
},
}
}
func rollbackCmd() *cobra.Command {
return &cobra.Command{
Use: "rollback", Short: "Undo the changes on probation now", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var out map[string]int
if err := c.Do(ctx(), "POST", "/rollback", nil, nil, &out); err != nil {
return err
}
fmt.Printf("rolled back %d changes\n", out["rolled_back"])
return nil
},
}
}
// ---- import and takeover
func importCmd() *cobra.Command {
var replace bool
var table, defines, textFile string
cmd := &cobra.Command{
Use: "import [FILE|-]",
Short: "Import an nftables ruleset (nft -j JSON) as Housecarl's configuration",
Long: `Turns an existing nftables ruleset into zones, sets, services, rules, port
forwards and NAT. The input is nft's JSON; on the firewall:
sudo hc import --defines /etc/nftables.conf -n
Without a FILE, hc runs 'nft -j list ruleset' and 'nft -a list ruleset'
itself (root on the firewall); from elsewhere, give the JSON as FILE and
the text with --text. --defines reads the ruleset file's define lines, to
name the sets as you did (trusted_ips, wg0_net, …). -n shows the result without
saving; what can't be expressed becomes raw rules, listed with notes.
Once it looks right, import without -n, check it with 'hc ruleset', and
'hc takeover inet main' deletes the old table.`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
var data, text []byte
var err error
switch {
case len(args) == 0:
data, err = exec.Command("nft", "-j", "list", "ruleset").Output()
if err != nil {
return fmt.Errorf("running 'nft -j list ruleset': %v (give a FILE, or run as root on the firewall)", err)
}
text, _ = exec.Command("nft", "-a", "list", "ruleset").Output()
case args[0] == "-":
data, err = io.ReadAll(os.Stdin)
default:
data, err = os.ReadFile(args[0])
}
if err != nil {
return err
}
if textFile != "" {
if text, err = os.ReadFile(textFile); err != nil {
return err
}
}
in := api.ImportInput{JSON: data, Text: string(text), Table: table, Replace: replace}
if defines != "" {
b, err := os.ReadFile(defines)
if err != nil {
return err
}
in.Defines = string(b)
}
c, err := newClient()
if err != nil {
return err
}
var res api.ImportResult
if err := c.Do(ctx(), "POST", "/import/nft", nil, in, &res); err != nil {
return err
}
if flagOutput == "json" || flagOutput == "yaml" {
return output(res, nil)
}
st := res.State
fmt.Printf("%d zones, %d sets, %d services, %d rules, %d port forwards, %d source NAT rules, %d redirects\n",
len(st.Zones), len(st.Sets), len(st.Services), len(st.Rules), len(st.Forwards), len(st.SNATs), len(st.Redirects))
for _, n := range res.Notes {
fmt.Println("note:", n)
}
for _, r := range res.Raw {
fmt.Println("raw rule:", r)
}
return report(&res.MutationResponse)
},
}
cmd.Flags().BoolVar(&replace, "replace", false, "replace Housecarl's configuration (needed when it has one)")
cmd.Flags().StringVar(&table, "table", "", "import only this table (\"inet main\"; default: all but Housecarl's)")
cmd.Flags().StringVar(&defines, "defines", "", "the ruleset file, whose defines name the sets")
cmd.Flags().StringVar(&textFile, "text", "", "'nft -a list ruleset' output, to keep what can't be expressed as raw rules")
return cmd
}
func takeoverCmd() *cobra.Command {
var yes bool
cmd := &cobra.Command{
Use: "takeover TABLE...",
Short: "Delete the tables Housecarl replaces (\"inet main\"), on probation",
Long: `Deletes other nftables tables once Housecarl's ruleset replaces them (after
'hc import'). Until then both apply: a packet must get past both. Over the
network this is on probation like a change: unless confirmed, the tables
come back. Don't forget the boot: disable nftables.service, or empty
/etc/nftables.conf, or it brings the old ruleset back.
hc takeover "inet main"`,
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if !confirm("Delete "+strings.Join(args, ", ")+"?", yes) {
return fmt.Errorf("nothing deleted")
}
c, err := newClient()
if err != nil {
return err
}
var out map[string]any
if err := c.Do(ctx(), "POST", "/takeover", nil, api.TakeoverInput{Tables: args}, &out); err != nil {
return err
}
fmt.Println(out["message"])
return nil
},
}
cmd.Flags().BoolVarP(&yes, "yes", "y", false, "don't ask")
return cmd
}
+421 -14
View File
@@ -1,42 +1,449 @@
// Command hc is Housecarl's command line client: it will manage the
// firewall through housecarld's API, as jarl and steward do for theirs.
// For now it only knows its version.
// Command hc is the command line client for Housecarl. It talks to
// housecarld's REST API: on the server itself through the local socket, or
// anywhere with the server's URL and an API token.
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"text/tabwriter"
"time"
"github.com/spf13/cobra"
"go.yaml.in/yaml/v3"
"housecarl/internal/api"
"housecarl/internal/client"
"housecarl/internal/model"
"housecarl/internal/validate"
"housecarl/internal/version"
)
func main() {
if err := rootCmd().Execute(); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
printError(err)
os.Exit(exitCode(err))
}
}
// Global flags.
var (
flagServer string
flagToken string
flagConfig string
flagDryRun bool
flagOutput string
flagInsecure bool
flagCACert string
flagQuiet bool
)
// DefaultSocket is housecarld's local socket.
const DefaultSocket = "/run/housecarl/housecarl.sock"
// cliConfig is the CLI's configuration file.
type cliConfig struct {
Server string `yaml:"server,omitempty"`
Token string `yaml:"token,omitempty"`
Insecure bool `yaml:"insecure,omitempty"`
CACert string `yaml:"ca_cert,omitempty"`
}
func defaultConfigPath() string {
if p := os.Getenv("HOUSECARL_CLI_CONFIG"); p != "" {
return p
}
dir, err := os.UserConfigDir()
if err != nil {
dir = filepath.Join(os.Getenv("HOME"), ".config")
}
return filepath.Join(dir, "housecarl", "config.yaml")
}
func loadCLIConfig() (*cliConfig, error) {
c := &cliConfig{}
b, err := os.ReadFile(flagConfig)
if errors.Is(err, os.ErrNotExist) {
return c, nil
}
if err != nil {
return nil, err
}
return c, yaml.Unmarshal(b, c)
}
func saveCLIConfig(c *cliConfig) error {
if err := os.MkdirAll(filepath.Dir(flagConfig), 0o700); err != nil {
return err
}
b, err := yaml.Marshal(c)
if err != nil {
return err
}
return os.WriteFile(flagConfig, b, 0o600)
}
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if v != "" {
return v
}
}
return ""
}
// newClient connects to the server: --server, HOUSECARL_SERVER, the config
// file, or else the local socket.
func newClient() (*client.Client, error) {
cfg, err := loadCLIConfig()
if err != nil {
return nil, fmt.Errorf("reading %s: %w", flagConfig, err)
}
server := firstNonEmpty(flagServer, os.Getenv("HOUSECARL_SERVER"), cfg.Server)
token := firstNonEmpty(flagToken, os.Getenv("HOUSECARL_TOKEN"), cfg.Token)
if server == "" || server == "local" {
sock := firstNonEmpty(os.Getenv("HOUSECARL_SOCKET"), DefaultSocket)
if _, err := os.Stat(sock); err != nil {
return nil, fmt.Errorf("no server configured, and there is no local socket %s: use --server URL, HOUSECARL_SERVER or 'hc config set server https://...'; on the server itself, check that housecarld runs", sock)
}
server = "unix://" + sock
}
if strings.HasPrefix(token, "@") {
b, err := os.ReadFile(token[1:])
if err != nil {
return nil, fmt.Errorf("reading the token file: %w", err)
}
token = strings.TrimSpace(string(b))
}
if _, isSocket := client.SocketPath(server); !isSocket && token == "" {
return nil, errors.New("no API token: use --token, HOUSECARL_TOKEN or 'hc config set token'")
}
c, err := client.New(client.Options{Server: server, Token: token, Insecure: flagInsecure || cfg.Insecure, CACert: firstNonEmpty(flagCACert, cfg.CACert)})
if err != nil {
return nil, err
}
c.DryRun = flagDryRun
return c, nil
}
func rootCmd() *cobra.Command {
root := &cobra.Command{
Use: "hc",
Short: "Manage a Housecarl firewall",
Long: `hc will manage a Housecarl firewall through housecarld's API: its zones,
address sets, rules and port forwards. It doesn't yet: Housecarl is at its
start (docs/design.md).`,
Short: "Command line client for Housecarl, the firewall",
Long: `hc manages a Housecarl firewall through its API.
Every change is checked as a whole, then by nftables, before it is put in
force, and is recorded in the history, where 'hc history undo ID' takes it
back. --dry-run shows what a change would do.
A change made over the network is on probation: confirm it with 'hc
confirm' (within 2 minutes by default) or it is rolled back by itself, so a
change that cuts you off undoes itself. On the firewall itself, root uses
housecarld's local socket: no token, no probation. From elsewhere, set the
server and an API token once:
hc config set server https://firewall.example.com:8070
hc config set token (paste the token at the prompt)
Exit codes: 0 ok, 1 error, 3 not authenticated or not allowed, 4 not found,
5 conflict, 6 invalid or rejected by validation, 7 server error.`,
SilenceUsage: true,
SilenceErrors: true,
Version: version.String(),
}
root.AddCommand(versionCmd())
pf := root.PersistentFlags()
pf.StringVarP(&flagServer, "server", "s", "", "server URL, or \"local\" for the local socket (env HOUSECARL_SERVER)")
pf.StringVar(&flagToken, "token", "", "API token, or @FILE to read it from a file (env HOUSECARL_TOKEN)")
pf.StringVar(&flagConfig, "config", defaultConfigPath(), "CLI configuration file")
pf.BoolVarP(&flagDryRun, "dry-run", "n", false, "check and show a change without making it")
pf.StringVarP(&flagOutput, "output", "o", "table", "output format: table, json, yaml or id")
pf.BoolVar(&flagInsecure, "insecure", false, "don't verify the server's TLS certificate")
pf.StringVar(&flagCACert, "ca-cert", "", "PEM file with a CA to trust for the server's certificate")
pf.BoolVarP(&flagQuiet, "quiet", "q", false, "print only errors (and IDs of new objects)")
root.AddGroup(&cobra.Group{ID: "fw", Title: "Firewall:"}, &cobra.Group{ID: "changes", Title: "Changes:"}, &cobra.Group{ID: "admin", Title: "Administration:"})
add := func(group string, cmds ...*cobra.Command) {
for _, c := range cmds {
c.GroupID = group
root.AddCommand(c)
}
}
add("fw", statusCmd(), zoneCmd(), setCmd(), serviceCmd(), ruleCmd(), forwardCmd(), snatCmd(), redirectCmd(), settingsCmd(),
rulesetCmd(), countersCmd(), logCmd(), interfaceCmd())
add("changes", confirmCmd(), rollbackCmd(), historyCmd(), importCmd(), takeoverCmd())
add("admin", whoamiCmd(), userCmd(), tokenCmd(), passwordCmd(), certCmd(), auditCmd(), configCmd())
return root
}
func versionCmd() *cobra.Command {
return &cobra.Command{
Use: "version", Short: "Print the version", Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) { fmt.Println("hc", version.String()) },
func ctx() context.Context { return context.Background() }
// ---- output
func output(v any, table func(w io.Writer)) error {
switch flagOutput {
case "json":
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(v)
case "yaml":
b, err := json.Marshal(v)
if err != nil {
return err
}
var x any
if err := json.Unmarshal(b, &x); err != nil {
return err
}
return yaml.NewEncoder(os.Stdout).Encode(x)
case "id":
return outputIDs(v)
case "table", "":
if table == nil {
flagOutput = "yaml"
return output(v, nil)
}
tw := tabwriter.NewWriter(os.Stdout, 2, 4, 2, ' ', 0)
table(tw)
return tw.Flush()
}
return fmt.Errorf("unknown output format %q (use table, json, yaml or id)", flagOutput)
}
// outputIDs prints the id of each object.
func outputIDs(v any) error {
b, err := json.Marshal(v)
if err != nil {
return err
}
dec := json.NewDecoder(bytes.NewReader(b))
dec.UseNumber()
var x any
if err := dec.Decode(&x); err != nil {
return err
}
list, ok := x.([]any)
if !ok {
list = []any{x}
}
for _, it := range list {
if m, ok := it.(map[string]any); ok {
if id, ok := m["id"]; ok {
fmt.Println(id)
} else if ip, ok := m["ip"]; ok {
fmt.Println(ip)
}
}
}
return nil
}
func printProblems(w io.Writer, ps []validate.Problem) {
for _, p := range ps {
obj := ""
if p.Object != "" {
obj = p.Object + ": "
}
fmt.Fprintf(w, "%s: %s%s [%s]\n", p.Severity, obj, p.Message, p.Code)
}
}
func printError(err error) {
var ce *client.Error
if errors.As(err, &ce) {
ps := ce.Info.Problems
if len(ps) == 1 {
fmt.Fprintf(os.Stderr, "error: %s [%s]\n", ce.Info.Message, ps[0].Code)
return
}
fmt.Fprintln(os.Stderr, "error:", ce.Info.Message)
if len(ps) > 1 {
printProblems(os.Stderr, ps)
}
return
}
fmt.Fprintln(os.Stderr, "error:", err)
}
// exitCode maps errors to exit codes: 1 other errors, 3 not authenticated
// or not allowed, 4 not found, 5 conflict, 6 invalid request or rejected
// by validation, 7 server error.
func exitCode(err error) int {
var ce *client.Error
if errors.As(err, &ce) {
switch {
case ce.Status == 401 || ce.Status == 403:
return 3
case ce.Status == 404:
return 4
case ce.Status == 409:
return 5
case ce.Status == 400 || ce.Status == 422:
return 6
case ce.Status >= 500:
return 7
}
}
return 1
}
// report prints the outcome of a change.
func report(resp *api.MutationResponse) error {
switch flagOutput {
case "json", "yaml":
return output(resp, nil)
case "id":
if len(resp.Object) > 0 {
return outputIDs(resp.Object)
}
return nil
}
w := os.Stdout
verb := func(c model.Change) string {
if resp.DryRun {
return "would " + c.Action()
}
return c.Action() + "d"
}
if !flagQuiet || resp.DryRun {
for _, c := range resp.Changes {
if c.Kind == model.KindSettings {
fmt.Fprintf(w, "%s settings\n", verb(c))
continue
}
fmt.Fprintf(w, "%s %s %s\n", verb(c), c.Kind, c.Label)
}
}
switch {
case resp.DryRun:
fmt.Fprintln(w, "dry run: nothing was changed")
case resp.Applied && resp.ConfirmBy != nil:
fmt.Fprintf(w, "applied on probation (history %d): confirm with 'hc confirm' within %s, or it is rolled back\n",
resp.HistoryID, time.Until(*resp.ConfirmBy).Round(time.Second))
case resp.Applied && !flagQuiet:
fmt.Fprintf(w, "applied and saved (history %d; undo with 'hc history undo %d')\n", resp.HistoryID, resp.HistoryID)
case !resp.Applied && len(resp.Changes) == 0 && !flagQuiet:
fmt.Fprintln(w, "nothing to change")
}
if flagQuiet && len(resp.Object) > 0 && resp.Applied {
outputIDs(resp.Object)
}
printProblems(os.Stderr, resp.Warnings)
return nil
}
func q(kv ...string) url.Values {
v := url.Values{}
for i := 0; i+1 < len(kv); i += 2 {
if kv[i+1] != "" {
v.Set(kv[i], kv[i+1])
}
}
return v
}
func confirm(prompt string, yes bool) bool {
if yes || flagDryRun {
return true
}
fmt.Fprintf(os.Stderr, "%s [y/N] ", prompt)
var answer string
fmt.Scanln(&answer)
return strings.EqualFold(answer, "y") || strings.EqualFold(answer, "yes")
}
// ---- durations
// parseSeconds reads a duration: "12h", "30m", "7d", "1d12h" or plain
// seconds.
func parseSeconds(s string) (int, error) {
s = strings.TrimSpace(s)
if n, err := strconv.Atoi(s); err == nil {
return n, nil
}
days := 0
if i := strings.Index(s, "d"); i > 0 {
n, err := strconv.Atoi(s[:i])
if err != nil {
return 0, fmt.Errorf("invalid duration %q (use e.g. 12h, 30m or 7d)", s)
}
days, s = n, s[i+1:]
}
var d time.Duration
if s != "" {
var err error
if d, err = time.ParseDuration(s); err != nil {
return 0, fmt.Errorf("invalid duration %q (use e.g. 12h, 30m or 7d)", s)
}
}
return days*86400 + int(d/time.Second), nil
}
// fmtSeconds writes seconds the short way: 43200 is "12h", 90 is "1m30s".
func fmtSeconds(n int) string {
if n <= 0 {
return "-"
}
var parts []string
for _, u := range []struct {
s int
name string
}{{86400, "d"}, {3600, "h"}, {60, "m"}, {1, "s"}} {
if n >= u.s {
parts = append(parts, strconv.Itoa(n/u.s)+u.name)
n %= u.s
}
}
return strings.Join(parts, "")
}
// fmtWhen writes a time relative to now: "in 3h", "2d ago".
func fmtWhen(t time.Time) string {
if t.IsZero() {
return "-"
}
d := time.Until(t)
ago := d < 0
if ago {
d = -d
}
var s string
switch {
case d < time.Minute:
return "now"
case d < time.Hour:
s = fmt.Sprintf("%dm", int(d.Minutes()))
case d < 48*time.Hour:
s = fmt.Sprintf("%dh%02dm", int(d.Hours()), int(d.Minutes())%60)
default:
s = fmt.Sprintf("%dd", int(d.Hours()/24))
}
if ago {
return s + " ago"
}
return "in " + s
}
// fmtSeen is a last-seen time, or "-" for a client never seen.
func fmtSeen(t *time.Time) string {
if t == nil {
return "-"
}
return fmtWhen(*t)
}
func orDash(s string) string {
if s == "" {
return "-"
}
return s
}
// optionsArg turns --option NAME=VALUE flags into option inputs.
+42
View File
@@ -0,0 +1,42 @@
package main
import (
"testing"
"housecarl/internal/model"
)
func TestDurations(t *testing.T) {
for in, want := range map[string]int{"3600": 3600, "12h": 43200, "30m": 1800, "1d": 86400, "1d12h": 129600, "1h30m": 5400} {
if got, err := parseSeconds(in); err != nil || got != want {
t.Errorf("parseSeconds(%q) = %d, %v; want %d", in, got, err, want)
}
}
for _, bad := range []string{"12x", "d", "1d2x"} {
if _, err := parseSeconds(bad); err == nil {
t.Errorf("parseSeconds(%q) accepted", bad)
}
}
for n, want := range map[int]string{43200: "12h", 5400: "1h30m", 90061: "1d1h1m1s", 0: "-"} {
if got := fmtSeconds(n); got != want {
t.Errorf("fmtSeconds(%d) = %q, want %q", n, got, want)
}
}
}
func TestLimitAndDescribe(t *testing.T) {
l, err := parseLimit("3/minutes,burst=5,over,per-source")
if err != nil || l.Rate != 3 || l.Per != "minute" || l.Burst != 5 || !l.Over || !l.PerSource {
t.Errorf("parseLimit: %+v %v", l, err)
}
if _, err := parseLimit("fast"); err == nil {
t.Error("a bad limit accepted")
}
m := model.Match{InZones: []string{"wan"}, NotSources: true, Sources: []string{"trusted"}, Services: []string{"ssh", "tcp/8080"}}
if got := describe(m); got != "in wan from not trusted ssh,tcp/8080" {
t.Errorf("describe: %q", got)
}
if got := editList([]string{"a", "b", "c"}, []string{"d"}, []string{"b"}); len(got) != 3 || got[1] != "c" || got[2] != "d" {
t.Errorf("editList: %v", got)
}
}
+461
View File
@@ -0,0 +1,461 @@
package main
import (
"fmt"
"io"
"net/url"
"strings"
"github.com/spf13/cobra"
"housecarl/internal/api"
"housecarl/internal/model"
)
// ---- helpers shared by the object commands
// change sends a change and reports it.
func change(method, path string, body any) error {
c, err := newClient()
if err != nil {
return err
}
var resp api.MutationResponse
if err := c.Do(ctx(), method, path, nil, body, &resp); err != nil {
return err
}
return report(&resp)
}
func objPath(kind, ref string) string { return "/" + kind + "/" + url.PathEscape(ref) }
func get[T any](path string, query url.Values) (T, error) {
var out T
c, err := newClient()
if err != nil {
return out, err
}
err = c.Get(ctx(), path, query, &out)
return out, err
}
// counters returns the hit counters, or none when they can't be read
// (no log:read permission).
func counters() map[string]api.Counter {
cs, err := get[api.Counters]("/counters", nil)
if err != nil {
return map[string]api.Counter{}
}
return cs.Counters
}
func hits(c map[string]api.Counter, marks ...string) string {
var p uint64
found := false
for _, m := range marks {
if x, ok := c[m]; ok {
p += x.Packets
found = true
}
}
if !found {
return "-"
}
return humanCount(p)
}
func humanCount(n uint64) string {
switch {
case n >= 10_000_000_000:
return fmt.Sprintf("%dG", n/1_000_000_000)
case n >= 10_000_000:
return fmt.Sprintf("%dM", n/1_000_000)
case n >= 10_000:
return fmt.Sprintf("%dk", n/1_000)
}
return fmt.Sprint(n)
}
func list(vals []string) string { return orDash(strings.Join(vals, ", ")) }
// patch collects the flags given on an update command.
type patch map[string]any
func deleteCmd(kind, what string) *cobra.Command {
var yes bool
cmd := &cobra.Command{
Use: "delete " + strings.ToUpper(what), Short: "Delete a " + what, Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if !confirm(fmt.Sprintf("Delete %s %s?", what, args[0]), yes) {
return fmt.Errorf("not deleted")
}
return change("DELETE", objPath(kind, args[0]), nil)
},
}
cmd.Flags().BoolVarP(&yes, "yes", "y", false, "don't ask")
return cmd
}
func showCmd[T any](kind, what string, print func(w io.Writer, o T)) *cobra.Command {
return &cobra.Command{
Use: "show " + strings.ToUpper(what), Short: "Show a " + what, Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
o, err := get[T](objPath(kind, args[0]), nil)
if err != nil {
return err
}
return output(o, func(w io.Writer) { print(w, o) })
},
}
}
// ---- zones
func zoneCmd() *cobra.Command {
cmd := &cobra.Command{Use: "zone", Aliases: []string{"zones"}, Short: "Zones: groups of interfaces rules refer to"}
cmd.AddCommand(&cobra.Command{
Use: "list", Short: "List the zones", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
zs, err := get[[]model.Zone]("/zones", nil)
if err != nil {
return err
}
return output(zs, func(w io.Writer) {
fmt.Fprintln(w, "NAME\tINTERFACES\tEXTERNAL\tCOMMENT")
for _, z := range zs {
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", z.Name, list(z.Interfaces), yesNo(z.External), z.Comment)
}
})
},
})
cmd.AddCommand(showCmd("zones", "zone", func(w io.Writer, z model.Zone) {
fmt.Fprintf(w, "Name:\t%s\nInterfaces:\t%s\nExternal:\t%s\n", z.Name, list(z.Interfaces), yesNo(z.External))
if z.Comment != "" {
fmt.Fprintf(w, "Comment:\t%s\n", z.Comment)
}
}))
var ifaces []string
var external bool
var comment string
add := &cobra.Command{
Use: "add NAME --interface IFACE...", Short: "Add a zone", Args: cobra.ExactArgs(1),
Long: `Adds a zone: interfaces rules can then name together. Wildcards match
many: --interface 'incusbr*'. --external marks a zone facing the internet:
antispoofing, MSS clamping and port forwards apply there.
hc zone add wan --interface wan --external
hc zone add lan --interface enp2s0`,
RunE: func(cmd *cobra.Command, args []string) error {
return change("POST", "/zones", model.Zone{Name: args[0], Interfaces: ifaces, External: external, Comment: comment})
},
}
add.Flags().StringArrayVarP(&ifaces, "interface", "i", nil, "an interface (repeat; a trailing * matches many)")
add.Flags().BoolVar(&external, "external", false, "the zone faces the internet")
add.Flags().StringVar(&comment, "comment", "", "a comment")
cmd.AddCommand(add)
var addIf, removeIf []string
var name string
update := &cobra.Command{
Use: "update ZONE", Short: "Change a zone", Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
p := patch{}
fl := cmd.Flags()
if fl.Changed("interface") || len(addIf) > 0 || len(removeIf) > 0 {
cur := ifaces
if !fl.Changed("interface") {
z, err := get[model.Zone](objPath("zones", args[0]), nil)
if err != nil {
return err
}
cur = z.Interfaces
}
p["interfaces"] = editList(cur, addIf, removeIf)
}
if fl.Changed("external") {
p["external"] = external
}
if fl.Changed("comment") {
p["comment"] = comment
}
if fl.Changed("name") {
p["name"] = name
}
return change("PATCH", objPath("zones", args[0]), p)
},
}
f := update.Flags()
f.StringArrayVarP(&ifaces, "interface", "i", nil, "replace the interfaces (repeat)")
f.StringArrayVar(&addIf, "add", nil, "add an interface")
f.StringArrayVar(&removeIf, "remove", nil, "remove an interface")
f.BoolVar(&external, "external", false, "the zone faces the internet (--external=false: it doesn't)")
f.StringVar(&comment, "comment", "", "a comment")
f.StringVar(&name, "name", "", "a new name")
cmd.AddCommand(update)
cmd.AddCommand(deleteCmd("zones", "zone"))
return cmd
}
func editList(cur, add, remove []string) []string {
out := []string{}
drop := map[string]bool{}
for _, r := range remove {
drop[r] = true
}
for _, x := range cur {
if !drop[x] {
out = append(out, x)
}
}
return append(out, add...)
}
func yesNo(b bool) string {
if b {
return "yes"
}
return "no"
}
// ---- sets
func setCmd() *cobra.Command {
cmd := &cobra.Command{Use: "set", Aliases: []string{"sets"}, Short: "Address sets: named lists of IPv4 and IPv6 addresses"}
cmd.AddCommand(&cobra.Command{
Use: "list", Short: "List the sets", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
ss, err := get[[]model.AddressSet]("/sets", nil)
if err != nil {
return err
}
return output(ss, func(w io.Writer) {
fmt.Fprintln(w, "NAME\tENTRIES\tCOMMENT")
for _, s := range ss {
entries := list(s.Entries)
if s.Dynamic {
entries = "dynamic"
if s.Timeout > 0 {
entries += ", timeout " + fmtSeconds(s.Timeout)
}
}
fmt.Fprintf(w, "%s\t%s\t%s\n", s.Name, entries, s.Comment)
}
})
},
})
cmd.AddCommand(showCmd("sets", "set", func(w io.Writer, s model.AddressSet) {
fmt.Fprintf(w, "Name:\t%s\n", s.Name)
if s.Dynamic {
fmt.Fprintf(w, "Dynamic:\tyes (entries: hc set entries %s)\n", s.Name)
if s.Timeout > 0 {
fmt.Fprintf(w, "Timeout:\t%s\n", fmtSeconds(s.Timeout))
}
}
for i, e := range s.Entries {
label := ""
if i == 0 {
label = "Entries:"
}
fmt.Fprintf(w, "%s\t%s\n", label, e)
}
if s.Comment != "" {
fmt.Fprintf(w, "Comment:\t%s\n", s.Comment)
}
}))
var dynamic bool
var timeout, comment string
add := &cobra.Command{
Use: "add NAME [ENTRY...]", Short: "Add a set", Args: cobra.MinimumNArgs(1),
Long: `Adds a set of addresses, prefixes and ranges, IPv4 and IPv6 mixed:
hc set add trusted 10.0.10.0/24 10.20.30.0/24 2a02:8013:7f3d:10::/64
hc set add guests --dynamic --timeout 24h
A dynamic set takes entries at run time, each with its own timeout ('hc
set entries', or the API with a token for that set alone: a captive
portal's signed-in clients).`,
RunE: func(cmd *cobra.Command, args []string) error {
s := model.AddressSet{Name: args[0], Entries: args[1:], Dynamic: dynamic, Comment: comment}
if timeout != "" {
n, err := parseSeconds(timeout)
if err != nil {
return err
}
s.Timeout = n
}
return change("POST", "/sets", s)
},
}
add.Flags().BoolVar(&dynamic, "dynamic", false, "entries are added at run time")
add.Flags().StringVar(&timeout, "timeout", "", "a dynamic set's default entry lifetime (1h, 7d)")
add.Flags().StringVar(&comment, "comment", "", "a comment")
cmd.AddCommand(add)
var addE, removeE, entries []string
var name string
update := &cobra.Command{
Use: "update SET", Short: "Change a set", Args: cobra.ExactArgs(1),
Long: `Changes a set: --add and --remove change entries, --entry replaces them all.
hc set update trusted --add 10.65.65.0/24 --remove 10.1.1.0/24`,
RunE: func(cmd *cobra.Command, args []string) error {
p := patch{}
fl := cmd.Flags()
if fl.Changed("entry") || len(addE) > 0 || len(removeE) > 0 {
cur := entries
if !fl.Changed("entry") {
s, err := get[model.AddressSet](objPath("sets", args[0]), nil)
if err != nil {
return err
}
cur = s.Entries
}
p["entries"] = editList(cur, addE, removeE)
}
if fl.Changed("timeout") {
n, err := parseSeconds(timeout)
if err != nil {
return err
}
p["timeout"] = n
}
if fl.Changed("comment") {
p["comment"] = comment
}
if fl.Changed("name") {
p["name"] = name
}
return change("PATCH", objPath("sets", args[0]), p)
},
}
f := update.Flags()
f.StringArrayVar(&entries, "entry", nil, "replace the entries (repeat)")
f.StringArrayVar(&addE, "add", nil, "add an entry")
f.StringArrayVar(&removeE, "remove", nil, "remove an entry")
f.StringVar(&timeout, "timeout", "", "a dynamic set's default entry lifetime")
f.StringVar(&comment, "comment", "", "a comment")
f.StringVar(&name, "name", "", "a new name")
cmd.AddCommand(update)
cmd.AddCommand(deleteCmd("sets", "set"))
cmd.AddCommand(entriesCmd())
return cmd
}
// entriesCmd manages a dynamic set's run-time entries.
func entriesCmd() *cobra.Command {
cmd := &cobra.Command{Use: "entries SET", Short: "A dynamic set's entries: list, add, remove", Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
es, err := get[[]api.Element](objPath("sets", args[0])+"/elements", nil)
if err != nil {
return err
}
return output(es, func(w io.Writer) {
fmt.Fprintln(w, "ADDRESS\tEXPIRES IN")
for _, e := range es {
exp := "-"
if e.Timeout > 0 {
exp = fmtSeconds(e.Timeout)
}
fmt.Fprintf(w, "%s\t%s\n", e.Address, exp)
}
})
}}
var timeout string
add := &cobra.Command{Use: "add SET ADDRESS", Short: "Add an entry to a dynamic set", Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
e := api.Element{Address: args[1]}
if timeout != "" {
n, err := parseSeconds(timeout)
if err != nil {
return err
}
e.Timeout = n
}
c, err := newClient()
if err != nil {
return err
}
return c.Do(ctx(), "POST", objPath("sets", args[0])+"/elements", nil, e, nil)
}}
add.Flags().StringVar(&timeout, "timeout", "", "how long it stays (1h; default: the set's)")
cmd.AddCommand(add)
cmd.AddCommand(&cobra.Command{Use: "remove SET ADDRESS", Short: "Remove an entry from a dynamic set", Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
return c.Do(ctx(), "DELETE", objPath("sets", args[0])+"/elements/"+url.PathEscape(args[1]), nil, nil, nil)
}})
return cmd
}
// ---- services
func serviceCmd() *cobra.Command {
cmd := &cobra.Command{Use: "service", Aliases: []string{"services"}, Short: "Services: named ports and ICMP types (many are built in)"}
var own bool
ls := &cobra.Command{
Use: "list", Short: "List the services, built-in ones too", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
ss, err := get[[]model.Service]("/services", q("own", map[bool]string{true: "1"}[own]))
if err != nil {
return err
}
return output(ss, func(w io.Writer) {
fmt.Fprintln(w, "NAME\tENTRIES\tSOURCE\tCOMMENT")
for _, s := range ss {
src := "yours"
if s.Builtin {
src = "built in"
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", s.Name, list(s.Entries), src, s.Comment)
}
})
},
}
ls.Flags().BoolVar(&own, "own", false, "only yours")
cmd.AddCommand(ls)
cmd.AddCommand(showCmd("services", "service", func(w io.Writer, s model.Service) {
fmt.Fprintf(w, "Name:\t%s\nEntries:\t%s\n", s.Name, list(s.Entries))
if s.Builtin {
fmt.Fprintln(w, "Source:\tbuilt in (add one of the same name to change it)")
}
}))
var comment string
add := &cobra.Command{
Use: "add NAME ENTRY...", Short: "Add a service", Args: cobra.MinimumNArgs(2),
Long: `Adds a service: ports of a protocol, and ICMP types.
hc service add jellyfin tcp/8096 tcp/8920
hc service add games udp/27015-27030 tcp/27015
hc service add pmtu icmp/destination-unreachable icmpv6/packet-too-big`,
RunE: func(cmd *cobra.Command, args []string) error {
return change("POST", "/services", model.Service{Name: args[0], Entries: args[1:], Comment: comment})
},
}
add.Flags().StringVar(&comment, "comment", "", "a comment")
cmd.AddCommand(add)
var addE, removeE []string
update := &cobra.Command{
Use: "update SERVICE", Short: "Change a service", Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
p := patch{}
if len(addE) > 0 || len(removeE) > 0 {
s, err := get[model.Service](objPath("services", args[0]), nil)
if err != nil {
return err
}
p["entries"] = editList(s.Entries, addE, removeE)
}
if cmd.Flags().Changed("comment") {
p["comment"] = comment
}
return change("PATCH", objPath("services", args[0]), p)
},
}
update.Flags().StringArrayVar(&addE, "add", nil, "add an entry")
update.Flags().StringArrayVar(&removeE, "remove", nil, "remove an entry")
update.Flags().StringVar(&comment, "comment", "", "a comment")
cmd.AddCommand(update)
cmd.AddCommand(deleteCmd("services", "service"))
return cmd
}
+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
}