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>
643 lines
18 KiB
Go
643 lines
18 KiB
Go
// Package engine changes Housecarl's configuration. Every change, from the
|
|
// API, the CLI or an import, goes through Apply: the change is made on a
|
|
// copy of the state, the result is validated as a whole, the ruleset built
|
|
// from it is checked by nftables and put in force, and only then are the
|
|
// changed objects and a history entry saved together (if saving fails, the
|
|
// previous ruleset is put back). Revert undoes a history entry the same
|
|
// way.
|
|
package engine
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"housecarl/internal/auth"
|
|
"housecarl/internal/model"
|
|
"housecarl/internal/store"
|
|
"housecarl/internal/validate"
|
|
)
|
|
|
|
// Kinds of errors, for the API's status codes.
|
|
const (
|
|
KindBadRequest = "bad_request"
|
|
KindNotFound = "not_found"
|
|
KindConflict = "conflict"
|
|
KindValidation = "validation"
|
|
// KindFirewall: nftables refused or failed to apply the ruleset.
|
|
KindFirewall = "firewall"
|
|
)
|
|
|
|
// Firewall puts configurations in force (internal/nft, through
|
|
// internal/render).
|
|
type Firewall interface {
|
|
// Check builds the ruleset and has nftables check it, changing
|
|
// nothing.
|
|
Check(ctx context.Context, st *model.State) error
|
|
// Apply puts the configuration's ruleset in force, in one
|
|
// transaction.
|
|
Apply(ctx context.Context, st *model.State) error
|
|
}
|
|
|
|
// Error is a refused change.
|
|
type Error struct {
|
|
Kind string `json:"code"`
|
|
Message string `json:"message"`
|
|
Problems []validate.Problem `json:"problems,omitempty"`
|
|
}
|
|
|
|
func (e *Error) Error() string { return e.Message }
|
|
|
|
// Errorf makes an Error.
|
|
func Errorf(kind, format string, args ...any) *Error {
|
|
return &Error{Kind: kind, Message: fmt.Sprintf(format, args...)}
|
|
}
|
|
|
|
// AsError unwraps an Error.
|
|
func AsError(err error) (*Error, bool) {
|
|
var e *Error
|
|
ok := errors.As(err, &e)
|
|
return e, ok
|
|
}
|
|
|
|
// Result describes an applied (or previewed) change.
|
|
type Result struct {
|
|
Applied bool `json:"applied"`
|
|
DryRun bool `json:"dry_run,omitempty"`
|
|
HistoryID int64 `json:"history_id,omitempty"`
|
|
Changes []model.Change `json:"changes"`
|
|
Warnings []validate.Problem `json:"warnings,omitempty"`
|
|
// Before and After are the configurations the change goes from and
|
|
// to (After also for a dry run), for diffs and answers.
|
|
Before, After *model.State `json:"-"`
|
|
}
|
|
|
|
// Engine holds the current configuration.
|
|
type Engine struct {
|
|
store *store.Store
|
|
log *slog.Logger
|
|
// Env describes the host's interfaces for validation (nil: skip).
|
|
Env func() *validate.Env
|
|
// Firewall puts each new state in force (nil: none, in tests that
|
|
// only look at the configuration).
|
|
Firewall Firewall
|
|
// OnChange is called with each new state, after it was saved.
|
|
OnChange func(*model.State)
|
|
|
|
mu sync.Mutex
|
|
state *model.State
|
|
}
|
|
|
|
// New loads the configuration.
|
|
func New(ctx context.Context, st *store.Store, log *slog.Logger) (*Engine, error) {
|
|
if log == nil {
|
|
log = slog.Default()
|
|
}
|
|
s, err := st.LoadState(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
Normalize(s)
|
|
return &Engine{store: st, log: log, state: s}, nil
|
|
}
|
|
|
|
// Store is the engine's database.
|
|
func (e *Engine) Store() *store.Store { return e.store }
|
|
|
|
// State returns a copy of the current configuration.
|
|
func (e *Engine) State() *model.State {
|
|
e.mu.Lock()
|
|
defer e.mu.Unlock()
|
|
return e.state.Clone()
|
|
}
|
|
|
|
func (e *Engine) env() *validate.Env {
|
|
if e.Env == nil {
|
|
return nil
|
|
}
|
|
return e.Env()
|
|
}
|
|
|
|
// Problems validates the current configuration (for the status page).
|
|
func (e *Engine) Problems() []validate.Problem {
|
|
return validate.Check(e.State(), e.env())
|
|
}
|
|
|
|
// Edit changes a configuration in place. NewID gives IDs for new objects.
|
|
type Edit func(st *model.State, newID func() int64) error
|
|
|
|
// Options say how to apply a change.
|
|
type Options struct {
|
|
DryRun bool
|
|
Via string // web, cli, api, import
|
|
// RevertOf is the history entry the change undoes (a changeset with a
|
|
// staged undo), so History links the two.
|
|
RevertOf int64
|
|
}
|
|
|
|
// Apply makes a change: edit runs on a copy of the configuration, the
|
|
// result is validated, and the changed objects are saved with a history
|
|
// entry. A change that touches nothing is not recorded.
|
|
func (e *Engine) Apply(ctx context.Context, p *auth.Principal, description string, opts Options, edit Edit) (*Result, error) {
|
|
e.mu.Lock()
|
|
defer e.mu.Unlock()
|
|
next := e.state.Clone()
|
|
var idErr error
|
|
newID := func() int64 {
|
|
ids, err := e.store.AllocateIDs(ctx, 1)
|
|
if err != nil {
|
|
idErr = err
|
|
return 0
|
|
}
|
|
return ids[0]
|
|
}
|
|
if err := edit(next, newID); err != nil {
|
|
return nil, err
|
|
}
|
|
if idErr != nil {
|
|
return nil, idErr
|
|
}
|
|
return e.commit(ctx, p, description, opts, next, opts.RevertOf)
|
|
}
|
|
|
|
// commit validates next and saves it as the new state.
|
|
func (e *Engine) commit(ctx context.Context, p *auth.Principal, description string, opts Options, next *model.State, revertOf int64) (*Result, error) {
|
|
Normalize(next)
|
|
next.Sort()
|
|
changes := Diff(e.state, next)
|
|
res := &Result{DryRun: opts.DryRun, Changes: changes, Before: e.state.Clone(), After: next.Clone()}
|
|
if len(changes) == 0 {
|
|
return res, nil
|
|
}
|
|
problems := validate.Check(next, e.env())
|
|
if validate.HasErrors(problems) {
|
|
errs := validate.Errors(problems)
|
|
msg := errs[0].Message
|
|
if errs[0].Object != "" {
|
|
msg = errs[0].Object + ": " + msg
|
|
}
|
|
if len(errs) > 1 {
|
|
msg += fmt.Sprintf(" (and %d more problems)", len(errs)-1)
|
|
}
|
|
return res, &Error{Kind: KindValidation, Message: msg, Problems: errs}
|
|
}
|
|
// Only report warnings the change brings; old ones are on the status
|
|
// page.
|
|
res.Warnings = newProblems(validate.Check(e.state, e.env()), problems)
|
|
res.Warnings = append(res.Warnings, quotaRestarts(e.state, next)...)
|
|
if e.Firewall != nil {
|
|
if err := e.Firewall.Check(ctx, next); err != nil {
|
|
return res, &Error{Kind: KindFirewall, Message: "nftables refused the ruleset: " + err.Error()}
|
|
}
|
|
}
|
|
if opts.DryRun {
|
|
return res, nil
|
|
}
|
|
if e.Firewall != nil {
|
|
if err := e.Firewall.Apply(ctx, next); err != nil {
|
|
return res, &Error{Kind: KindFirewall, Message: "applying the ruleset failed, nothing was changed: " + err.Error()}
|
|
}
|
|
}
|
|
entry := &model.HistoryEntry{Time: time.Now(), Username: p.Name(), Via: opts.Via, Description: description, Changes: changes, RevertOf: revertOf}
|
|
if p != nil && p.User != nil {
|
|
entry.UserID = p.User.ID
|
|
}
|
|
if entry.Description == "" {
|
|
entry.Description = Describe(changes)
|
|
}
|
|
if err := e.store.SaveChanges(ctx, changes, entry); err != nil {
|
|
// The new ruleset is in force but not saved: put the old one back.
|
|
if e.Firewall != nil {
|
|
if rerr := e.Firewall.Apply(ctx, e.state); rerr != nil {
|
|
e.log.Error("saving a change failed, and putting the previous ruleset back failed too", "err", rerr)
|
|
}
|
|
}
|
|
return res, err
|
|
}
|
|
e.state = next
|
|
res.Applied, res.HistoryID = true, entry.ID
|
|
e.Audit(ctx, p, "config.change", fmt.Sprintf("history %d", entry.ID), "success", entry.Description)
|
|
e.log.Info("configuration changed", "history", entry.ID, "user", p.Name(), "via", opts.Via, "description", entry.Description)
|
|
if e.OnChange != nil {
|
|
e.OnChange(next.Clone())
|
|
}
|
|
return res, nil
|
|
}
|
|
|
|
// quotaRestarts warns of the rules whose quota starts again because they
|
|
// now count it in a way what they used can't be carried to.
|
|
func quotaRestarts(before, after *model.State) []validate.Problem {
|
|
was := map[int64]*model.Rule{}
|
|
for _, r := range before.Rules {
|
|
was[r.ID] = r
|
|
}
|
|
var out []validate.Problem
|
|
for _, r := range after.Rules {
|
|
if carry, why := model.QuotaCarry(was[r.ID], r); carry == model.QuotaRestarts {
|
|
out = append(out, validate.Problem{Severity: validate.Warning, Code: "quota_restarts", Object: r.Label(),
|
|
Message: "its quota starts again: " + why})
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func newProblems(before, after []validate.Problem) []validate.Problem {
|
|
seen := map[string]bool{}
|
|
for _, p := range before {
|
|
seen[p.Code+"\x00"+p.Object+"\x00"+p.Message] = true
|
|
}
|
|
var out []validate.Problem
|
|
for _, p := range after {
|
|
if !seen[p.Code+"\x00"+p.Object+"\x00"+p.Message] {
|
|
out = append(out, p)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ApplyCurrent puts the current configuration in force again: at start,
|
|
// and when a flowtable device appears. It holds the engine's lock, so a
|
|
// change can't be applied in between.
|
|
func (e *Engine) ApplyCurrent(ctx context.Context) error {
|
|
if e.Firewall == nil {
|
|
return nil
|
|
}
|
|
e.mu.Lock()
|
|
defer e.mu.Unlock()
|
|
return e.Firewall.Apply(ctx, e.state.Clone())
|
|
}
|
|
|
|
// Revert undoes a history entry, if the objects it changed are still as
|
|
// it left them.
|
|
func (e *Engine) Revert(ctx context.Context, p *auth.Principal, id int64, opts Options) (*Result, error) {
|
|
h, err := e.store.GetHistory(ctx, id)
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
return nil, Errorf(KindNotFound, "history entry %d not found", id)
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if h.RevertedBy != 0 {
|
|
return nil, Errorf(KindConflict, "history entry %d was already undone by entry %d", id, h.RevertedBy)
|
|
}
|
|
e.mu.Lock()
|
|
defer e.mu.Unlock()
|
|
next := e.state.Clone()
|
|
if err := Undo(next, h); err != nil {
|
|
return nil, err
|
|
}
|
|
return e.commit(ctx, p, UndoDescription(h), opts, next, h.ID)
|
|
}
|
|
|
|
// UndoDescription is an undo's description: "undo 12: create rule …".
|
|
func UndoDescription(h *model.HistoryEntry) string {
|
|
return fmt.Sprintf("undo %d: %s", h.ID, h.Description)
|
|
}
|
|
|
|
// Undo applies a history entry backwards to st, if every object it
|
|
// changed is still as it left it (a change made since would be lost).
|
|
func Undo(st *model.State, h *model.HistoryEntry) error {
|
|
cur := objects(st)
|
|
for i := len(h.Changes) - 1; i >= 0; i-- {
|
|
c := h.Changes[i]
|
|
now := cur[key{c.Kind, c.ID}]
|
|
if !sameJSON(now, c.After) {
|
|
what := c.Label
|
|
if what == "" {
|
|
what = fmt.Sprintf("%s %d", c.Kind, c.ID)
|
|
}
|
|
return Errorf(KindConflict, "%s %s has changed since entry %d; undo the later changes first, or change it by hand", c.Kind, what, h.ID)
|
|
}
|
|
if err := put(st, c.Kind, c.ID, c.Before); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Rewind applies a history entry backwards to st without Undo's check:
|
|
// stepping back through the entries newest first brings back the
|
|
// configuration as it was before each.
|
|
func Rewind(st *model.State, h *model.HistoryEntry) error {
|
|
for i := len(h.Changes) - 1; i >= 0; i-- {
|
|
c := h.Changes[i]
|
|
if c.Kind == model.KindSettings && c.Before == nil {
|
|
continue
|
|
}
|
|
if err := put(st, c.Kind, c.ID, c.Before); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
// put appends what it brings back; its position puts it in place.
|
|
st.Sort()
|
|
return nil
|
|
}
|
|
|
|
// Replace makes the configuration next as a whole (imports).
|
|
func (e *Engine) Replace(ctx context.Context, p *auth.Principal, description string, opts Options, next *model.State) (*Result, error) {
|
|
e.mu.Lock()
|
|
defer e.mu.Unlock()
|
|
return e.commit(ctx, p, description, opts, next.Clone(), 0)
|
|
}
|
|
|
|
// Audit records an action in the audit log.
|
|
func (e *Engine) Audit(ctx context.Context, p *auth.Principal, action, object, result, detail string) {
|
|
ev := &model.AuditEvent{Action: action, Object: object, Result: result, Detail: detail}
|
|
if p != nil {
|
|
ev.Username, ev.Auth, ev.IP = p.Name(), p.Method, p.IP
|
|
if p.User != nil {
|
|
ev.UserID = p.User.ID
|
|
}
|
|
if p.Token != nil {
|
|
ev.TokenID = p.Token.ID
|
|
}
|
|
}
|
|
if err := e.store.AddAudit(ctx, ev); err != nil {
|
|
e.log.Warn("writing the audit log failed", "err", err)
|
|
}
|
|
}
|
|
|
|
// ---- diffs
|
|
|
|
type key struct {
|
|
kind string
|
|
id int64
|
|
}
|
|
|
|
func objects(st *model.State) map[key]json.RawMessage {
|
|
m := map[key]json.RawMessage{{model.KindSettings, 0}: mustJSON(st.Settings)}
|
|
for _, k := range model.Kinds {
|
|
for _, o := range st.Objects(k) {
|
|
m[key{k, o.GetID()}] = mustJSON(o)
|
|
}
|
|
}
|
|
return m
|
|
}
|
|
|
|
func labels(st *model.State) map[key]string {
|
|
m := map[key]string{{model.KindSettings, 0}: "settings"}
|
|
for _, k := range model.Kinds {
|
|
for _, o := range st.Objects(k) {
|
|
m[key{k, o.GetID()}] = o.Label()
|
|
}
|
|
}
|
|
return m
|
|
}
|
|
|
|
// Diff lists what changes from a to b: settings, then each kind of
|
|
// object (zones, sets, services, rules, NAT), each by ID.
|
|
func Diff(a, b *model.State) []model.Change {
|
|
oa, ob := objects(a), objects(b)
|
|
la, lb := labels(a), labels(b)
|
|
var keys []key
|
|
for k := range oa {
|
|
keys = append(keys, k)
|
|
}
|
|
for k := range ob {
|
|
if _, ok := oa[k]; !ok {
|
|
keys = append(keys, k)
|
|
}
|
|
}
|
|
order := map[string]int{model.KindSettings: 0}
|
|
for i, k := range model.Kinds {
|
|
order[k] = i + 1
|
|
}
|
|
sort.Slice(keys, func(i, j int) bool {
|
|
if keys[i].kind != keys[j].kind {
|
|
return order[keys[i].kind] < order[keys[j].kind]
|
|
}
|
|
return keys[i].id < keys[j].id
|
|
})
|
|
var out []model.Change
|
|
for _, k := range keys {
|
|
before, after := oa[k], ob[k]
|
|
if sameJSON(before, after) {
|
|
continue
|
|
}
|
|
label := lb[k]
|
|
if label == "" {
|
|
label = la[k]
|
|
}
|
|
out = append(out, model.Change{Kind: k.kind, ID: k.id, Label: label, Before: before, After: after})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Describe sums up changes: "create zone lan (eth1)", or
|
|
// "3 changes" for many.
|
|
func Describe(cs []model.Change) string {
|
|
if len(cs) == 0 {
|
|
return "no changes"
|
|
}
|
|
parts := []string{}
|
|
for _, c := range cs {
|
|
if c.Kind == model.KindSettings {
|
|
parts = append(parts, "update settings")
|
|
continue
|
|
}
|
|
parts = append(parts, c.Action()+" "+c.Kind+" "+c.Label)
|
|
}
|
|
if len(parts) > 3 {
|
|
return fmt.Sprintf("%s and %d more changes", strings.Join(parts[:2], ", "), len(parts)-2)
|
|
}
|
|
return strings.Join(parts, ", ")
|
|
}
|
|
|
|
func mustJSON(v any) json.RawMessage {
|
|
b, err := json.Marshal(v)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return b
|
|
}
|
|
|
|
func sameJSON(a, b json.RawMessage) bool {
|
|
if a == nil || b == nil {
|
|
return a == nil && b == nil
|
|
}
|
|
return bytes.Equal(a, b)
|
|
}
|
|
|
|
// put sets (or with nil data deletes) one object in st.
|
|
func put(st *model.State, kind string, id int64, data json.RawMessage) error {
|
|
if kind == model.KindSettings {
|
|
if data == nil {
|
|
return fmt.Errorf("settings can't be deleted")
|
|
}
|
|
var set model.Settings
|
|
if err := json.Unmarshal(data, &set); err != nil {
|
|
return err
|
|
}
|
|
st.Settings = set
|
|
return nil
|
|
}
|
|
return st.Put(kind, id, data)
|
|
}
|
|
|
|
// Normalize writes names, addresses, ports and lists in their canonical
|
|
// form, so equal configurations compare equal, and spaces rule positions
|
|
// out again when insertions have crowded them.
|
|
func Normalize(st *model.State) {
|
|
st.Fill()
|
|
set := &st.Settings
|
|
set.AntispoofV4 = normAddrs(set.AntispoofV4)
|
|
set.AntispoofV6 = normAddrs(set.AntispoofV6)
|
|
set.OwnPrefixes = normAddrs(set.OwnPrefixes)
|
|
set.FlowtableDevices = normList(set.FlowtableDevices)
|
|
for _, z := range st.Zones {
|
|
z.Name = strings.TrimSpace(z.Name)
|
|
z.Interfaces = normList(z.Interfaces)
|
|
}
|
|
for _, x := range st.Sets {
|
|
x.Name = strings.TrimSpace(x.Name)
|
|
x.Entries = normAddrs(x.Entries)
|
|
if j := x.Jarl; j != nil {
|
|
for i, n := range j.Names {
|
|
j.Names[i] = model.ParseJarlName(n.Name)
|
|
if n.View != "" {
|
|
j.Names[i].View = strings.TrimSpace(n.View)
|
|
}
|
|
}
|
|
j.Within = normAddrs(j.Within) // zone names stay as they are
|
|
}
|
|
}
|
|
for _, x := range st.Services {
|
|
x.Name = strings.TrimSpace(x.Name)
|
|
x.Entries = normServices(x.Entries)
|
|
}
|
|
for _, r := range st.Rules {
|
|
normMatch(&r.Match)
|
|
r.CtState = normList(lower(r.CtState))
|
|
r.CtStatus = normList(lower(r.CtStatus))
|
|
r.Raw = strings.TrimSpace(r.Raw)
|
|
}
|
|
for _, f := range st.Forwards {
|
|
f.Protocols = normList(lower(f.Protocols))
|
|
f.PublicAddresses = normAddrs(f.PublicAddresses)
|
|
f.Sources = normAddrs(f.Sources)
|
|
f.HairpinSources = normAddrs(f.HairpinSources)
|
|
f.Ports = normPorts(f.Ports)
|
|
f.ToPort = normPorts(f.ToPort)
|
|
f.To = normAddr(f.To)
|
|
f.To6 = normAddr(f.To6)
|
|
}
|
|
for _, x := range st.SNATs {
|
|
normMatch(&x.Match)
|
|
x.To = normAddr(x.To)
|
|
}
|
|
for _, x := range st.Redirects {
|
|
normMatch(&x.Match)
|
|
x.ToPort = normPorts(x.ToPort)
|
|
}
|
|
for _, b := range st.BanLists {
|
|
b.Name = strings.TrimSpace(b.Name)
|
|
b.Scope = strings.ToLower(strings.TrimSpace(b.Scope))
|
|
b.Hosts = normAddrs(b.Hosts)
|
|
b.Never = normAddrs(b.Never)
|
|
}
|
|
// Positions are fractions after insertions between rules; renumber
|
|
// only when two have come too close to put another between.
|
|
st.Sort()
|
|
crowded := func(pos []float64) bool {
|
|
for i := 1; i < len(pos); i++ {
|
|
if pos[i]-pos[i-1] < 1e-6 {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
byChain := map[string][]float64{}
|
|
for _, r := range st.Rules {
|
|
byChain[r.Chain] = append(byChain[r.Chain], r.Position)
|
|
}
|
|
re := false
|
|
for _, pos := range byChain {
|
|
re = re || crowded(pos)
|
|
}
|
|
var fp, sp, rp []float64
|
|
for _, x := range st.Forwards {
|
|
fp = append(fp, x.Position)
|
|
}
|
|
for _, x := range st.SNATs {
|
|
sp = append(sp, x.Position)
|
|
}
|
|
for _, x := range st.Redirects {
|
|
rp = append(rp, x.Position)
|
|
}
|
|
if re || crowded(fp) || crowded(sp) || crowded(rp) {
|
|
st.Renumber()
|
|
}
|
|
}
|
|
|
|
func normMatch(m *model.Match) {
|
|
m.InZones = normList(m.InZones)
|
|
m.OutZones = normList(m.OutZones)
|
|
m.Sources = normAddrs(m.Sources)
|
|
m.Destinations = normAddrs(m.Destinations)
|
|
m.Services = normServices(m.Services)
|
|
m.Family = strings.ToLower(strings.TrimSpace(m.Family))
|
|
}
|
|
|
|
// normList trims, drops empty entries and duplicates, keeping the order.
|
|
func normList(in []string) []string {
|
|
var out []string
|
|
seen := map[string]bool{}
|
|
for _, s := range in {
|
|
s = strings.TrimSpace(s)
|
|
if s != "" && !seen[s] {
|
|
seen[s] = true
|
|
out = append(out, s)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func lower(in []string) []string {
|
|
out := make([]string, len(in))
|
|
for i, s := range in {
|
|
out[i] = strings.ToLower(s)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// normAddrs writes addresses canonically; set names stay as they are.
|
|
func normAddrs(in []string) []string {
|
|
out := normList(in)
|
|
for i, s := range out {
|
|
out[i] = normAddr(s)
|
|
}
|
|
return normList(out)
|
|
}
|
|
|
|
func normAddr(s string) string {
|
|
s = strings.TrimSpace(s)
|
|
if n, err := model.NormalizeAddress(s); err == nil {
|
|
return n
|
|
}
|
|
return s
|
|
}
|
|
|
|
func normServices(in []string) []string {
|
|
out := normList(in)
|
|
for i, s := range out {
|
|
if e, err := model.ParseServiceEntry(s); err == nil {
|
|
out[i] = e.String()
|
|
}
|
|
}
|
|
return normList(out)
|
|
}
|
|
|
|
func normPorts(s string) string {
|
|
if n, err := model.NormalizePorts(s); err == nil {
|
|
return n
|
|
}
|
|
return strings.TrimSpace(s)
|
|
}
|