The ruleset's diff names the chain each hunk is in on its @@ line, as
git names the function. A history entry shows what it did to the
ruleset (web page, hc history diff, GET /history/{id}/diff), rebuilt
from the current configuration by stepping back through the later
entries. A changed setting is listed by its own fields
(builtins.drop_invalid) instead of the whole object.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
632 lines
17 KiB
Go
632 lines
17 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
|
|
}
|
|
}
|
|
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)
|
|
}
|
|
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)
|
|
}
|