- Which never-used pool address a new client gets: sequential (the lowest, as before), reverse or random, in the settings, a subnet or a single pool (FIRST-LAST:ORDER). When no never-used address is left, the one free the longest, whatever the order; returning clients keep theirs. - A subnet's pools are used in the order listed, or one at random weighted by its never-used addresses. Pools now keep the order they are given in (no more sorting by address). - CLI --address-order and --pool-order; --add-pool of an existing range changes its order in place. Web UI: per-pool order and a move-up button, pool and address order on the subnet and settings pages. - Kea's allocator: random is imported. Schema migration 5. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
608 lines
17 KiB
Go
608 lines
17 KiB
Go
// Package kea reads a Kea DHCPv4 configuration (kea-dhcp4.conf) and its
|
|
// lease file (the memfile CSV), so a Kea setup can move to Steward without
|
|
// renumbering anything. What Steward doesn't do is listed, not dropped
|
|
// silently.
|
|
package kea
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/csv"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/netip"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"steward/internal/dhcp4"
|
|
"steward/internal/model"
|
|
)
|
|
|
|
// Result is what an import makes of a Kea configuration.
|
|
type Result struct {
|
|
// State has the settings, subnets and reservations (IDs not set).
|
|
State *model.State
|
|
// Notes say how things were mapped; Skipped lists what Steward
|
|
// doesn't support, so it isn't lost silently.
|
|
Notes []string
|
|
Skipped []string
|
|
}
|
|
|
|
// Options steer an import.
|
|
type Options struct {
|
|
// HostInterfaces are this machine's interfaces with their prefixes,
|
|
// to find a subnet's interface when Kea doesn't name it.
|
|
HostInterfaces map[string][]netip.Prefix
|
|
// Interfaces gives the interface of subnets by prefix
|
|
// ("10.0.20.0/24" -> "eth0.20"); it wins over the configuration.
|
|
Interfaces map[string]string
|
|
}
|
|
|
|
// StripComments removes Kea's comments (#, // and /* */) outside strings.
|
|
func StripComments(b []byte) []byte {
|
|
var out bytes.Buffer
|
|
inStr, esc := false, false
|
|
for i := 0; i < len(b); i++ {
|
|
c := b[i]
|
|
if inStr {
|
|
out.WriteByte(c)
|
|
switch {
|
|
case esc:
|
|
esc = false
|
|
case c == '\\':
|
|
esc = true
|
|
case c == '"':
|
|
inStr = false
|
|
}
|
|
continue
|
|
}
|
|
switch {
|
|
case c == '"':
|
|
inStr = true
|
|
out.WriteByte(c)
|
|
case c == '#' || c == '/' && i+1 < len(b) && b[i+1] == '/':
|
|
for i < len(b) && b[i] != '\n' {
|
|
i++
|
|
}
|
|
out.WriteByte('\n')
|
|
case c == '/' && i+1 < len(b) && b[i+1] == '*':
|
|
i += 2
|
|
for i+1 < len(b) && !(b[i] == '*' && b[i+1] == '/') {
|
|
if b[i] == '\n' {
|
|
out.WriteByte('\n')
|
|
}
|
|
i++
|
|
}
|
|
i++
|
|
default:
|
|
out.WriteByte(c)
|
|
}
|
|
}
|
|
return out.Bytes()
|
|
}
|
|
|
|
type obj = map[string]json.RawMessage
|
|
|
|
type importer struct {
|
|
opts Options
|
|
res *Result
|
|
globals []string // interfaces-config interfaces
|
|
// allocator is Dhcp4's allocator (iterative, random or flq).
|
|
allocator string
|
|
noted map[string]bool
|
|
}
|
|
|
|
// ParseConfig reads a kea-dhcp4.conf.
|
|
func ParseConfig(data []byte, opts Options) (*Result, error) {
|
|
clean := StripComments(data)
|
|
if bytes.Contains(clean, []byte("<?include")) {
|
|
return nil, fmt.Errorf("the configuration uses <?include ...?>; put the included files' contents in place first")
|
|
}
|
|
var top obj
|
|
if err := json.Unmarshal(clean, &top); err != nil {
|
|
return nil, fmt.Errorf("reading the Kea configuration: %w", jsonError(clean, err))
|
|
}
|
|
raw, ok := top["Dhcp4"]
|
|
if !ok {
|
|
return nil, fmt.Errorf("no Dhcp4 section: is this a kea-dhcp4.conf?")
|
|
}
|
|
var d4 obj
|
|
if err := json.Unmarshal(raw, &d4); err != nil {
|
|
return nil, fmt.Errorf("Dhcp4: %w", err)
|
|
}
|
|
im := &importer{opts: opts, res: &Result{State: &model.State{Settings: model.DefaultSettings(), Subnets: []*model.Subnet{}, Reservations: []*model.Reservation{}}}}
|
|
for k := range top {
|
|
if k != "Dhcp4" {
|
|
im.skip("the %s section (Steward imports Dhcp4 only)", k)
|
|
}
|
|
}
|
|
if err := im.dhcp4(d4); err != nil {
|
|
return nil, err
|
|
}
|
|
return im.res, nil
|
|
}
|
|
|
|
// jsonError adds the line number to a JSON syntax error.
|
|
func jsonError(b []byte, err error) error {
|
|
if se, ok := err.(*json.SyntaxError); ok {
|
|
line := 1 + bytes.Count(b[:min(int(se.Offset), len(b))], []byte("\n"))
|
|
return fmt.Errorf("line %d: %v", line, err)
|
|
}
|
|
return err
|
|
}
|
|
|
|
// noteOnce adds a note once per key.
|
|
func (im *importer) noteOnce(key, format string, args ...any) {
|
|
if im.noted == nil {
|
|
im.noted = map[string]bool{}
|
|
}
|
|
if !im.noted[key] {
|
|
im.noted[key] = true
|
|
im.note(format, args...)
|
|
}
|
|
}
|
|
|
|
func (im *importer) note(format string, args ...any) {
|
|
im.res.Notes = append(im.res.Notes, fmt.Sprintf(format, args...))
|
|
}
|
|
|
|
func (im *importer) skip(format string, args ...any) {
|
|
im.res.Skipped = append(im.res.Skipped, fmt.Sprintf(format, args...))
|
|
}
|
|
|
|
func get[T any](o obj, key string, into *T) (bool, error) {
|
|
raw, ok := o[key]
|
|
if !ok {
|
|
return false, nil
|
|
}
|
|
if err := json.Unmarshal(raw, into); err != nil {
|
|
return true, fmt.Errorf("%s: %v", key, err)
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
// Keys of Dhcp4 that are handled, ignored on purpose, or skipped with a
|
|
// note.
|
|
var (
|
|
handledKeys = map[string]bool{"interfaces-config": true, "valid-lifetime": true, "option-data": true, "subnet4": true, "shared-networks": true,
|
|
"reservations": true, "authoritative": true, "decline-probation-period": true, "allocator": true}
|
|
ignoredKeys = map[string]string{
|
|
"lease-database": "Steward keeps leases in its own database", "control-socket": "Steward has its own API",
|
|
"loggers": "Steward logs to the journal", "renew-timer": "Steward sends half the lease time",
|
|
"rebind-timer": "Steward sends 7/8 of the lease time", "expired-leases-processing": "Steward expires leases itself",
|
|
"sanity-checks": "", "multi-threading": "", "server-tag": "", "option-def": "", "calculate-tee-times": "", "t1-percent": "", "t2-percent": "",
|
|
"min-valid-lifetime": "", "max-valid-lifetime": "", "host-reservation-identifiers": "", "echo-client-id": "", "match-client-id": "",
|
|
"user-context": "", "comment": "", "cache-threshold": "", "dhcp4o6-port": "", "hostname-char-set": "", "hostname-char-replacement": "",
|
|
}
|
|
)
|
|
|
|
func (im *importer) dhcp4(d obj) error {
|
|
var keys []string
|
|
for k := range d {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
for _, k := range keys {
|
|
if handledKeys[k] {
|
|
continue
|
|
}
|
|
if why, ok := ignoredKeys[k]; ok {
|
|
if why != "" {
|
|
im.note("%s isn't needed: %s", k, why)
|
|
}
|
|
continue
|
|
}
|
|
im.skip("%s (not supported by Steward 0.1)", k)
|
|
}
|
|
var ic struct {
|
|
Interfaces []string `json:"interfaces"`
|
|
}
|
|
if _, err := get(d, "interfaces-config", &ic); err != nil {
|
|
return err
|
|
}
|
|
for _, i := range ic.Interfaces {
|
|
name, _, _ := strings.Cut(i, "/")
|
|
if name != "*" && name != "" {
|
|
im.globals = append(im.globals, name)
|
|
}
|
|
}
|
|
get(d, "allocator", &im.allocator)
|
|
set := &im.res.State.Settings
|
|
var vl int
|
|
if ok, err := get(d, "valid-lifetime", &vl); err != nil {
|
|
return err
|
|
} else if ok {
|
|
set.LeaseTime = vl
|
|
}
|
|
var dpp int
|
|
if ok, err := get(d, "decline-probation-period", &dpp); err != nil {
|
|
return err
|
|
} else if ok {
|
|
set.DeclineHold = dpp
|
|
}
|
|
var authoritative bool
|
|
if ok, err := get(d, "authoritative", &authoritative); err != nil {
|
|
return err
|
|
} else if !ok || !authoritative {
|
|
im.note("Kea wasn't authoritative; Steward is by default (it answers requests for addresses that don't belong on an interface with a NAK, so clients start over at once); turn it off in the settings if another DHCP server shares a network")
|
|
}
|
|
var err error
|
|
if set.Options, err = im.options(d, "global"); err != nil {
|
|
return err
|
|
}
|
|
var subnets []obj
|
|
if _, err := get(d, "subnet4", &subnets); err != nil {
|
|
return err
|
|
}
|
|
for _, s := range subnets {
|
|
if err := im.subnet(s, nil); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
var shared []obj
|
|
if _, err := get(d, "shared-networks", &shared); err != nil {
|
|
return err
|
|
}
|
|
for _, sh := range shared {
|
|
var name string
|
|
get(sh, "name", &name)
|
|
var subs []obj
|
|
if _, err := get(sh, "subnet4", &subs); err != nil {
|
|
return fmt.Errorf("shared network %s: %w", name, err)
|
|
}
|
|
if len(subs) > 1 {
|
|
im.note("shared network %s: its %d subnets are served together on one interface, as in Kea", name, len(subs))
|
|
}
|
|
for _, s := range subs {
|
|
if err := im.subnet(s, sh); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
var global []obj
|
|
if _, err := get(d, "reservations", &global); err != nil {
|
|
return err
|
|
}
|
|
for _, r := range global {
|
|
if err := im.reservation(r, nil); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// options reads option-data.
|
|
func (im *importer) options(o obj, where string) ([]model.OptionValue, error) {
|
|
var list []obj
|
|
if _, err := get(o, "option-data", &list); err != nil {
|
|
return nil, fmt.Errorf("%s: %w", where, err)
|
|
}
|
|
out := []model.OptionValue{}
|
|
for _, od := range list {
|
|
var name, data, space string
|
|
var code int
|
|
csvFormat := true
|
|
get(od, "name", &name)
|
|
get(od, "code", &code)
|
|
get(od, "data", &data)
|
|
get(od, "space", &space)
|
|
get(od, "csv-format", &csvFormat)
|
|
if space != "" && space != "dhcp4" {
|
|
im.skip("%s: option %s%d in space %s", where, name, code, space)
|
|
continue
|
|
}
|
|
var def dhcp4.Def
|
|
ok := false
|
|
if name != "" {
|
|
def, ok = dhcp4.Lookup(name)
|
|
}
|
|
if !ok && code > 0 && code < 255 {
|
|
def, ok = dhcp4.ByCode(byte(code)), true
|
|
}
|
|
if !ok {
|
|
im.skip("%s: option %s (Steward doesn't know it by name; set it by number)", where, name)
|
|
continue
|
|
}
|
|
if why, reserved := dhcp4.Reserved[def.Code]; reserved {
|
|
im.note("%s: option %s left out: %s", where, def.Name, why)
|
|
continue
|
|
}
|
|
v := model.OptionValue{Code: int(def.Code), Value: data}
|
|
if !dhcp4.Known(def.Code) {
|
|
v.Type = dhcp4.TypeHex
|
|
}
|
|
if !csvFormat {
|
|
b, err := hex.DecodeString(strings.NewReplacer(":", "", " ", "").Replace(strings.TrimPrefix(strings.ToLower(data), "0x")))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%s: option %s: data %q isn't hex (csv-format is false)", where, def.Name, data)
|
|
}
|
|
typ := def.Type
|
|
if v.Type != "" {
|
|
typ = v.Type
|
|
}
|
|
v.Value = dhcp4.Decode(typ, b)
|
|
}
|
|
typ := def.Type
|
|
if v.Type != "" {
|
|
typ = v.Type
|
|
}
|
|
if _, err := dhcp4.Encode(typ, v.Value); err != nil {
|
|
return nil, fmt.Errorf("%s: option %s: %v", where, def.Name, err)
|
|
}
|
|
out = append(out, v)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (im *importer) subnet(s obj, shared obj) error {
|
|
var prefix, iface, comment string
|
|
var lifetime int
|
|
get(s, "subnet", &prefix)
|
|
pfx, err := netip.ParsePrefix(prefix)
|
|
if err != nil || !pfx.Addr().Is4() {
|
|
return fmt.Errorf("subnet %q isn't an IPv4 prefix", prefix)
|
|
}
|
|
where := "subnet " + pfx.String()
|
|
sn := &model.Subnet{Prefix: pfx.Masked(), Pools: []model.Pool{}}
|
|
if shared != nil {
|
|
get(shared, "interface", &iface)
|
|
var name string
|
|
get(shared, "name", &name)
|
|
sn.Comment = "shared network " + name
|
|
if ok, _ := get(shared, "valid-lifetime", &lifetime); ok {
|
|
sn.LeaseTime = lifetime
|
|
}
|
|
so, err := im.options(shared, "shared network "+name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
sn.Options = so
|
|
}
|
|
alloc := im.allocator
|
|
if shared != nil {
|
|
get(shared, "allocator", &alloc)
|
|
}
|
|
get(s, "allocator", &alloc)
|
|
switch alloc {
|
|
case "random":
|
|
// Kea picks among all the subnet's free addresses.
|
|
sn.PoolOrder, sn.AddressOrder = model.PoolsRandom, model.OrderRandom
|
|
case "flq":
|
|
im.noteOnce("flq", "the allocator flq (free lease queue) became Steward's sequential order: a new client gets the lowest address never handed out, else the one free the longest")
|
|
}
|
|
if ok, _ := get(s, "interface", &iface); ok && iface == "" {
|
|
iface = ""
|
|
}
|
|
if ok, _ := get(s, "valid-lifetime", &lifetime); ok {
|
|
sn.LeaseTime = lifetime
|
|
}
|
|
if lt := im.res.State.Settings.LeaseTime; sn.LeaseTime == lt {
|
|
sn.LeaseTime = 0
|
|
}
|
|
var uc struct {
|
|
Comment string `json:"comment"`
|
|
}
|
|
if get(s, "user-context", &uc); uc.Comment != "" {
|
|
comment = uc.Comment
|
|
}
|
|
get(s, "comment", &comment)
|
|
if comment != "" {
|
|
sn.Comment = strings.TrimSpace(sn.Comment + " " + comment)
|
|
}
|
|
opts, err := im.options(s, where)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// The subnet's options over the shared network's.
|
|
sn.Options = mergeOptions(sn.Options, opts)
|
|
var pools []obj
|
|
if _, err := get(s, "pools", &pools); err != nil {
|
|
return fmt.Errorf("%s: %w", where, err)
|
|
}
|
|
for _, p := range pools {
|
|
var ps string
|
|
get(p, "pool", &ps)
|
|
pool, err := model.ParsePool(strings.ReplaceAll(ps, " ", ""))
|
|
if err != nil {
|
|
return fmt.Errorf("%s: %v", where, err)
|
|
}
|
|
sn.Pools = append(sn.Pools, pool)
|
|
if _, ok := p["option-data"]; ok {
|
|
im.skip("%s: options of pool %s (Steward sets options per subnet)", where, pool)
|
|
}
|
|
if _, ok := p["client-class"]; ok {
|
|
im.skip("%s: the client class of pool %s", where, pool)
|
|
}
|
|
}
|
|
for _, k := range []string{"relay", "client-class", "require-client-classes", "next-server", "boot-file-name", "4o6-interface", "4o6-subnet"} {
|
|
if _, ok := s[k]; ok {
|
|
im.skip("%s: %s", where, k)
|
|
}
|
|
}
|
|
if forced, ok := im.opts.Interfaces[sn.Prefix.String()]; ok {
|
|
iface = forced
|
|
}
|
|
if iface == "" {
|
|
iface = im.findInterface(sn.Prefix)
|
|
if iface != "" {
|
|
im.note("%s: served on %s (Kea didn't name its interface)", where, iface)
|
|
}
|
|
}
|
|
sn.Interface = iface
|
|
im.res.State.Subnets = append(im.res.State.Subnets, sn)
|
|
var res []obj
|
|
if _, err := get(s, "reservations", &res); err != nil {
|
|
return fmt.Errorf("%s: %w", where, err)
|
|
}
|
|
for _, r := range res {
|
|
if err := im.reservation(r, sn); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// findInterface guesses a subnet's interface: the host's interface with an
|
|
// address in it, else the only interface Kea listened on.
|
|
func (im *importer) findInterface(p netip.Prefix) string {
|
|
for name, addrs := range im.opts.HostInterfaces {
|
|
for _, a := range addrs {
|
|
if p.Contains(a.Addr()) {
|
|
return name
|
|
}
|
|
}
|
|
}
|
|
if len(im.globals) == 1 {
|
|
return im.globals[0]
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func mergeOptions(base, over []model.OptionValue) []model.OptionValue {
|
|
out := []model.OptionValue{}
|
|
seen := map[int]bool{}
|
|
for _, o := range over {
|
|
seen[o.Code] = true
|
|
}
|
|
for _, o := range base {
|
|
if !seen[o.Code] {
|
|
out = append(out, o)
|
|
}
|
|
}
|
|
return append(out, over...)
|
|
}
|
|
|
|
func (im *importer) reservation(r obj, sn *model.Subnet) error {
|
|
var hw, cid, ip, host string
|
|
get(r, "hw-address", &hw)
|
|
get(r, "client-id", &cid)
|
|
get(r, "ip-address", &ip)
|
|
get(r, "hostname", &host)
|
|
where := "reservation " + ip
|
|
if host != "" {
|
|
where = "reservation " + host
|
|
}
|
|
if ip == "" {
|
|
im.skip("%s: no ip-address (Steward reservations always fix the address)", where)
|
|
return nil
|
|
}
|
|
a, err := netip.ParseAddr(ip)
|
|
if err != nil {
|
|
return fmt.Errorf("%s: %q isn't an address", where, ip)
|
|
}
|
|
res := &model.Reservation{IP: a, Hostname: host, Options: []model.OptionValue{}}
|
|
if hw != "" {
|
|
if res.MAC, err = model.NormalizeMAC(hw); err != nil {
|
|
return fmt.Errorf("%s: %v", where, err)
|
|
}
|
|
}
|
|
if cid != "" {
|
|
if res.ClientID, err = model.NormalizeClientID(cid); err != nil {
|
|
return fmt.Errorf("%s: %v", where, err)
|
|
}
|
|
}
|
|
if res.MAC == "" && res.ClientID == "" {
|
|
im.skip("%s: identified by %s (Steward knows clients by MAC address or client id)", where, firstKey(r, "duid", "circuit-id", "flex-id"))
|
|
return nil
|
|
}
|
|
if res.Options, err = im.options(r, where); err != nil {
|
|
return err
|
|
}
|
|
for _, k := range []string{"client-classes", "next-server", "boot-file-name", "server-hostname"} {
|
|
if _, ok := r[k]; ok {
|
|
im.skip("%s: %s", where, k)
|
|
}
|
|
}
|
|
im.res.State.Reservations = append(im.res.State.Reservations, res)
|
|
return nil
|
|
}
|
|
|
|
func firstKey(o obj, keys ...string) string {
|
|
for _, k := range keys {
|
|
if _, ok := o[k]; ok {
|
|
return k
|
|
}
|
|
}
|
|
return "something else"
|
|
}
|
|
|
|
// ParseLeases reads a Kea memfile lease file (kea-leases4.csv). The file
|
|
// is a log: later lines for an address replace earlier ones. Only leases
|
|
// that haven't ended are returned (declined ones too).
|
|
func ParseLeases(r io.Reader, now time.Time) ([]*model.Lease, error) {
|
|
cr := csv.NewReader(r)
|
|
cr.FieldsPerRecord = -1
|
|
header, err := cr.Read()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("reading the lease file: %w", err)
|
|
}
|
|
col := map[string]int{}
|
|
for i, h := range header {
|
|
col[strings.TrimSpace(h)] = i
|
|
}
|
|
for _, need := range []string{"address", "hwaddr", "expire", "valid_lifetime"} {
|
|
if _, ok := col[need]; !ok {
|
|
return nil, fmt.Errorf("the lease file has no %s column: is it a Kea DHCPv4 memfile (kea-leases4.csv)?", need)
|
|
}
|
|
}
|
|
field := func(rec []string, name string) string {
|
|
if i, ok := col[name]; ok && i < len(rec) {
|
|
return strings.TrimSpace(rec[i])
|
|
}
|
|
return ""
|
|
}
|
|
byIP := map[netip.Addr]*model.Lease{}
|
|
line := 1
|
|
for {
|
|
rec, err := cr.Read()
|
|
line++
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("lease file line %d: %w", line, err)
|
|
}
|
|
a, err := netip.ParseAddr(field(rec, "address"))
|
|
if err != nil || !a.Is4() {
|
|
continue
|
|
}
|
|
exp, _ := strconv.ParseInt(field(rec, "expire"), 10, 64)
|
|
life, _ := strconv.ParseInt(field(rec, "valid_lifetime"), 10, 64)
|
|
end := time.Unix(exp, 0).UTC()
|
|
l := &model.Lease{IP: a, State: model.LeaseActive, Start: end.Add(-time.Duration(life) * time.Second), End: end, Updated: now,
|
|
Hostname: unescape(field(rec, "hostname"))}
|
|
if m, err := model.NormalizeMAC(field(rec, "hwaddr")); err == nil {
|
|
l.MAC = m
|
|
}
|
|
if c := field(rec, "client_id"); c != "" {
|
|
if id, err := model.NormalizeClientID(c); err == nil {
|
|
l.ClientID = id
|
|
}
|
|
}
|
|
switch field(rec, "state") {
|
|
case "1":
|
|
l.State = model.LeaseDeclined
|
|
case "2", "3":
|
|
l.State = model.LeaseReleased
|
|
}
|
|
byIP[a] = l
|
|
}
|
|
var out []*model.Lease
|
|
for _, l := range byIP {
|
|
if l.MAC == "" && l.State != model.LeaseDeclined {
|
|
continue
|
|
}
|
|
if l.End.After(now) || l.State == model.LeaseReleased {
|
|
out = append(out, l)
|
|
}
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].IP.Less(out[j].IP) })
|
|
return out, nil
|
|
}
|
|
|
|
// unescape undoes Kea's escaping of commas in the lease file.
|
|
func unescape(s string) string { return strings.ReplaceAll(s, ",", ",") }
|