A set can take its addresses from Jarl: every A and AAAA record of its
names (NAME@VIEW), CNAMEs followed within Jarl's zones in the same view.
housecarld reads them every jarl.refresh and after each change, keeps
only the addresses within the zones and networks the set allows, and
keeps what it last read (saved in the database and the boot ruleset)
while Jarl can't be reached. A change of the addresses applies the
ruleset again without a history entry and is audited (set.jarl).
hc set add --jarl --within, hc set jarl; the kind "from Jarl" and
Addresses… on the Sets page; GET /sets/{name}/jarl and /jarl/refresh;
validation codes set_jarl_*, status warnings jarl_*; docs/jarl.md.
e2e phase with scripts/fakejarl.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
633 lines
19 KiB
Go
633 lines
19 KiB
Go
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
|
|
}
|
|
|
|
// lookupNames asks for inside addresses' names, for tables: none with
|
|
// --no-names, another output format, or when they can't be had.
|
|
func lookupNames(addrs ...string) map[string]string {
|
|
out := map[string]string{}
|
|
if flagNoNames || flagOutput != "table" {
|
|
return out
|
|
}
|
|
seen := map[string]bool{}
|
|
var list []string
|
|
for _, a := range addrs {
|
|
if a != "" && !seen[a] {
|
|
seen[a] = true
|
|
list = append(list, a)
|
|
}
|
|
}
|
|
if len(list) == 0 {
|
|
return out
|
|
}
|
|
c, err := newClient()
|
|
if err != nil {
|
|
return out
|
|
}
|
|
var ns api.Names
|
|
if c.Do(ctx(), "POST", "/names", nil, api.NamesInput{Addresses: list}, &ns) != nil {
|
|
return out
|
|
}
|
|
for a, n := range ns.Names {
|
|
out[a] = strings.SplitN(n, ".", 2)[0]
|
|
}
|
|
return out
|
|
}
|
|
|
|
// named adds an address's name to text (the address, or address:port).
|
|
func named(names map[string]string, addr, text string) string {
|
|
if n := names[addr]; n != "" {
|
|
return text + " (" + n + ")"
|
|
}
|
|
return text
|
|
}
|
|
|
|
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 eth1`,
|
|
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.Jarl != nil {
|
|
entries = "from Jarl: " + strings.Join(jarlNames(s.Jarl), ", ")
|
|
}
|
|
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))
|
|
}
|
|
}
|
|
if s.Jarl != nil {
|
|
fmt.Fprintf(w, "From Jarl:\t%s\n", strings.Join(jarlNames(s.Jarl), ", "))
|
|
fmt.Fprintf(w, "Within:\t%s\n", strings.Join(s.Jarl.Within, ", "))
|
|
fmt.Fprintf(w, "Addresses:\thc set jarl %s\n", s.Name)
|
|
}
|
|
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
|
|
var jarlN, within []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 192.168.1.0/24 192.168.2.0/24 2001:db8:1::/64
|
|
hc set add guests --dynamic --timeout 24h
|
|
hc set add nas --jarl nas.example.com@internal --within lan
|
|
|
|
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).
|
|
|
|
A Jarl set holds the addresses of names in Jarl (their A and AAAA
|
|
records, CNAMEs followed), read again every minute. --jarl takes
|
|
NAME@VIEW, or NAME when only one view has it; --within says where its
|
|
addresses may be (zones: their connected networks; or networks), and the
|
|
others are left out ('hc set jarl NAME' shows them). docs/jarl.md.`,
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
s := model.AddressSet{Name: args[0], Entries: args[1:], Dynamic: dynamic, Comment: comment}
|
|
if len(jarlN) > 0 || len(within) > 0 {
|
|
s.Jarl = jarlSource(jarlN, within)
|
|
}
|
|
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().StringArrayVar(&jarlN, "jarl", nil, "a name in Jarl whose addresses the set holds: NAME or NAME@VIEW (repeat)")
|
|
add.Flags().StringArrayVar(&within, "within", nil, "a Jarl set: a zone or network its addresses must lie in (repeat)")
|
|
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("jarl") || fl.Changed("within") {
|
|
s, err := get[model.AddressSet](objPath("sets", args[0]), nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
j := s.Jarl
|
|
if j == nil {
|
|
j = &model.JarlSource{}
|
|
}
|
|
if fl.Changed("jarl") {
|
|
j.Names = jarlSource(jarlN, nil).Names
|
|
}
|
|
if fl.Changed("within") {
|
|
j.Within = within
|
|
}
|
|
p["jarl"] = j
|
|
}
|
|
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.StringArrayVar(&jarlN, "jarl", nil, "replace a Jarl set's names: NAME or NAME@VIEW (repeat)")
|
|
f.StringArrayVar(&within, "within", nil, "replace where a Jarl set's addresses may be: zones, networks (repeat)")
|
|
f.StringVar(&comment, "comment", "", "a comment")
|
|
f.StringVar(&name, "name", "", "a new name")
|
|
cmd.AddCommand(update)
|
|
cmd.AddCommand(deleteCmd("sets", "set"))
|
|
cmd.AddCommand(entriesCmd())
|
|
cmd.AddCommand(jarlCmd())
|
|
return cmd
|
|
}
|
|
|
|
func jarlSource(names, within []string) *model.JarlSource {
|
|
j := &model.JarlSource{Names: []model.JarlName{}, Within: within}
|
|
for _, n := range names {
|
|
j.Names = append(j.Names, model.ParseJarlName(n))
|
|
}
|
|
return j
|
|
}
|
|
|
|
func jarlNames(j *model.JarlSource) []string {
|
|
var out []string
|
|
for _, n := range j.Names {
|
|
out = append(out, n.String())
|
|
}
|
|
return out
|
|
}
|
|
|
|
// jarlCmd shows what a Jarl set holds, name by name.
|
|
func jarlCmd() *cobra.Command {
|
|
var refresh bool
|
|
cmd := &cobra.Command{Use: "jarl SET", Short: "What a Jarl set holds: each name's addresses in Jarl, and which are used", Args: cobra.ExactArgs(1),
|
|
Long: `Shows a Jarl set's names with the addresses Jarl gives them (CNAMEs
|
|
followed), whether each is used or why it is left out (not within the
|
|
set's zones and networks), and when Jarl last answered. --refresh reads
|
|
Jarl first.`,
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
c, err := newClient()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var js api.JarlSet
|
|
if refresh {
|
|
err = c.Do(ctx(), "POST", objPath("sets", args[0])+"/jarl/refresh", nil, nil, &js)
|
|
} else {
|
|
err = c.Do(ctx(), "GET", objPath("sets", args[0])+"/jarl", nil, nil, &js)
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return output(js, func(w io.Writer) {
|
|
if !js.Configured {
|
|
fmt.Fprintln(w, "Jarl isn't configured (jarl.server in housecarl.yaml): the set keeps the addresses it last had.")
|
|
}
|
|
if js.Error != "" {
|
|
fmt.Fprintf(w, "Reading Jarl failed: %s (the set keeps the addresses it had)\n", js.Error)
|
|
}
|
|
fmt.Fprintf(w, "Set:\t%s\n", js.Set)
|
|
fmt.Fprintf(w, "Within:\t%s\n", strings.Join(js.Within, ", "))
|
|
fmt.Fprintf(w, "Holds:\t%s\n", list(js.Entries))
|
|
if js.Read != nil {
|
|
fmt.Fprintf(w, "Jarl read:\t%s\n", js.Read.Local().Format("2006-01-02 15:04:05"))
|
|
}
|
|
fmt.Fprintln(w)
|
|
fmt.Fprintln(w, "NAME\tVIEW\tADDRESS\tUSED")
|
|
for _, n := range js.Names {
|
|
label := n.Name
|
|
if len(n.Chain) > 0 {
|
|
label += " → " + strings.Join(n.Chain, " → ")
|
|
}
|
|
view := n.View
|
|
if view == "" {
|
|
view = n.Found
|
|
}
|
|
if view == "" {
|
|
view = "-"
|
|
}
|
|
switch {
|
|
case n.Problem != "":
|
|
fmt.Fprintf(w, "%s\t%s\t-\tno: %s\n", label, view, n.Problem)
|
|
case len(n.Addresses) == 0 && n.Error != "":
|
|
fmt.Fprintf(w, "%s\t%s\t-\tnot read: %s\n", label, view, n.Error)
|
|
case len(n.Addresses) == 0:
|
|
fmt.Fprintf(w, "%s\t%s\t-\tnot read yet\n", label, view)
|
|
}
|
|
for _, a := range n.Addresses {
|
|
used := "yes"
|
|
if !a.Used {
|
|
used = "no: " + a.Why
|
|
}
|
|
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", label, view, a.Address, used)
|
|
}
|
|
if n.Error != "" && len(n.Addresses) > 0 {
|
|
fmt.Fprintf(w, "%s\t%s\t\t(the last read failed: %s; these are from before)\n", label, view, n.Error)
|
|
}
|
|
}
|
|
})
|
|
}}
|
|
cmd.Flags().BoolVar(&refresh, "refresh", false, "read Jarl now")
|
|
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 web tcp/8080 tcp/8443
|
|
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
|
|
}
|