mirror of
https://github.com/aziis98/statuspage.git
synced 2026-10-06 06:35:03 +00:00
server: expand {a,b}/{1..10} host patterns in config; fuzz + tests
Hosts accept brace groups expanded at load time: enumeration {1,2,3},
inclusive ranges {2..5,8..10} with '..' syntax, and labels {a3,a4};
several groups multiply in cartesian fashion. Leading zeros are
preserved, each expanded machine gets its host as unique ID/name.
Malformed patterns (unmatched/nested braces, non-integer or descending
ranges, extra dots) and patterns expanding past 10000 machines fail at
startup. Adds table tests plus a FuzzExpandHostPattern target; the
safety cap keeps fuzzing and real configs from blowing up memory.
This commit is contained in:
+176
-2
@@ -3,6 +3,8 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
@@ -144,15 +146,187 @@ func LoadConfig(path string) (*Config, error) {
|
||||
cfg.Metrics = raw.Metrics
|
||||
for _, g := range raw.Groups {
|
||||
for _, mc := range g.Machines {
|
||||
cfg.Machines = append(cfg.Machines, resolveMachine(mc, g.Name, cfg))
|
||||
ms, err := expandMachine(mc, g.Name, cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.Machines = append(cfg.Machines, ms...)
|
||||
}
|
||||
}
|
||||
for _, mc := range raw.Machines {
|
||||
cfg.Machines = append(cfg.Machines, resolveMachine(mc, "", cfg))
|
||||
ms, err := expandMachine(mc, "", cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.Machines = append(cfg.Machines, ms...)
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// expandMachine expands a MachineConfig's host pattern into one or more
|
||||
// concrete Machine entries. A host may contain brace groups that are expanded
|
||||
// cartesian-style, e.g. "server-{a3,a4}-{1..10}.example.org".
|
||||
func expandMachine(mc MachineConfig, group string, cfg *Config) ([]Machine, error) {
|
||||
hosts, err := expandHostPattern(mc.Host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
machines := make([]Machine, 0, len(hosts))
|
||||
for _, host := range hosts {
|
||||
m := resolveMachine(mc, group, cfg)
|
||||
m.Host = host
|
||||
m.ID = host
|
||||
if mc.Name == "" {
|
||||
m.Name = host
|
||||
}
|
||||
machines = append(machines, m)
|
||||
}
|
||||
return machines, nil
|
||||
}
|
||||
|
||||
// maximum number of machines a single host pattern may expand to
|
||||
const maxHostExpansion = 10000
|
||||
|
||||
// expandHostPattern expands brace groups in a host pattern. Supported syntax:
|
||||
//
|
||||
// {1,2,3} enumeration
|
||||
// {2..5,8..10} inclusive numeric ranges
|
||||
// {a3,a4} labels (any non-range item is taken literally)
|
||||
//
|
||||
// Multiple groups expand in cartesian fashion:
|
||||
//
|
||||
// server-{a3,a4}-{1..2}.example.org
|
||||
// -> server-a3-1.example.org, server-a3-2.example.org, server-a4-1.example.org, ...
|
||||
//
|
||||
// A pattern without braces is returned unchanged.
|
||||
func expandHostPattern(pattern string) ([]string, error) {
|
||||
type segment struct {
|
||||
prefix string
|
||||
values []string
|
||||
}
|
||||
var segs []segment
|
||||
rest := pattern
|
||||
for {
|
||||
open := strings.IndexByte(rest, '{')
|
||||
if open < 0 {
|
||||
if rest != "" {
|
||||
segs = append(segs, segment{prefix: rest})
|
||||
}
|
||||
break
|
||||
}
|
||||
close := strings.IndexByte(rest[open:], '}')
|
||||
if close < 0 {
|
||||
return nil, fmt.Errorf("host %q: unmatched '{'", pattern)
|
||||
}
|
||||
close += open
|
||||
if strings.IndexByte(rest[open+1:close], '{') >= 0 {
|
||||
return nil, fmt.Errorf("host %q: nested brace groups are not supported", pattern)
|
||||
}
|
||||
inner := rest[open+1 : close]
|
||||
values, err := parseBraceGroup(inner)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("host %q: %w", pattern, err)
|
||||
}
|
||||
segs = append(segs, segment{prefix: rest[:open], values: values})
|
||||
rest = rest[close+1:]
|
||||
}
|
||||
|
||||
result := []string{""}
|
||||
count := 1
|
||||
for _, s := range segs {
|
||||
if len(s.values) > 0 {
|
||||
if maxHostExpansion/count < len(s.values) {
|
||||
return nil, fmt.Errorf("host %q: expands to more than %d machines", pattern, maxHostExpansion)
|
||||
}
|
||||
count *= len(s.values)
|
||||
}
|
||||
}
|
||||
for _, s := range segs {
|
||||
if len(s.values) == 0 {
|
||||
for i := range result {
|
||||
result[i] += s.prefix
|
||||
}
|
||||
continue
|
||||
}
|
||||
next := make([]string, 0, len(result)*len(s.values))
|
||||
for _, r := range result {
|
||||
for _, v := range s.values {
|
||||
next = append(next, r+s.prefix+v)
|
||||
}
|
||||
}
|
||||
result = next
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func parseBraceGroup(inner string) ([]string, error) {
|
||||
var values []string
|
||||
for _, part := range strings.Split(inner, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
if lo, hi, ok := strings.Cut(part, ".."); ok {
|
||||
vals, err := expandNumericRange(lo, hi, part)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
values = append(values, vals...)
|
||||
} else {
|
||||
values = append(values, part)
|
||||
}
|
||||
}
|
||||
if len(values) == 0 {
|
||||
return nil, fmt.Errorf("empty brace group")
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func expandNumericRange(lo, hi, raw string) ([]string, error) {
|
||||
if lo == "" || hi == "" || !isNumeric(lo) || !isNumeric(hi) {
|
||||
return nil, fmt.Errorf("invalid range %q: endpoints must be numbers", raw)
|
||||
}
|
||||
a, err := strconv.Atoi(lo)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid range %q: %w", raw, err)
|
||||
}
|
||||
b, err := strconv.Atoi(hi)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid range %q: %w", raw, err)
|
||||
}
|
||||
if b < a {
|
||||
return nil, fmt.Errorf("invalid range %q: %d > %d", raw, a, b)
|
||||
}
|
||||
if b-a+1 > maxHostExpansion {
|
||||
return nil, fmt.Errorf("range %q: expands to more than %d values", raw, maxHostExpansion)
|
||||
}
|
||||
pad := 0
|
||||
if len(lo) > 1 && lo[0] == '0' {
|
||||
pad = len(lo)
|
||||
}
|
||||
out := make([]string, 0, b-a+1)
|
||||
for n := a; n <= b; n++ {
|
||||
if pad > 0 {
|
||||
out = append(out, fmt.Sprintf("%0*d", pad, n))
|
||||
} else {
|
||||
out = append(out, strconv.Itoa(n))
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func isNumeric(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range s {
|
||||
if r < '0' || r > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func resolveMachine(mc MachineConfig, group string, cfg *Config) Machine {
|
||||
m := Machine{
|
||||
ID: mc.Host,
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func FuzzExpandHostPattern(f *testing.F) {
|
||||
seeds := []string{
|
||||
"server{1..3}.example.org",
|
||||
"server{1,2,3}.example.org",
|
||||
"server-{a3,a4}-{1..2}.example.org",
|
||||
"host{01..03}",
|
||||
"router.example.net",
|
||||
"host-{5..2}.example.org",
|
||||
"host-{1,2.example.org",
|
||||
"{1..10}",
|
||||
"{2..5,8..10}.example.org",
|
||||
"",
|
||||
"host-{}",
|
||||
"{1..}",
|
||||
"{..5}",
|
||||
"x}{y}",
|
||||
"{a,b}{c,d}",
|
||||
"{0..10001}",
|
||||
}
|
||||
for _, s := range seeds {
|
||||
f.Add(s)
|
||||
}
|
||||
f.Fuzz(func(t *testing.T, pattern string) {
|
||||
hosts, err := expandHostPattern(pattern)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if !strings.Contains(pattern, "{") {
|
||||
if !reflect.DeepEqual(hosts, []string{pattern}) {
|
||||
t.Fatalf("pattern without braces %q expanded to %v", pattern, hosts)
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(hosts) == 0 {
|
||||
t.Fatalf("pattern %q produced no hosts", pattern)
|
||||
}
|
||||
for _, h := range hosts {
|
||||
if strings.Contains(h, "{") {
|
||||
t.Fatalf("expanded host %q still contains '{' (pattern %q)", h, pattern)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExpandHostPattern(t *testing.T) {
|
||||
tests := []struct {
|
||||
pattern string
|
||||
want []string
|
||||
err string
|
||||
}{
|
||||
{
|
||||
pattern: "server{1..3}.example.org",
|
||||
want: []string{"server1.example.org", "server2.example.org", "server3.example.org"},
|
||||
},
|
||||
{
|
||||
pattern: "server{1,2,3}.example.org",
|
||||
want: []string{"server1.example.org", "server2.example.org", "server3.example.org"},
|
||||
},
|
||||
{
|
||||
pattern: "server{2..5,8..10}.example.org",
|
||||
want: []string{
|
||||
"server2.example.org", "server3.example.org", "server4.example.org",
|
||||
"server5.example.org", "server8.example.org", "server9.example.org", "server10.example.org",
|
||||
},
|
||||
},
|
||||
{
|
||||
pattern: "server-{a3,a4}-{1..2}.example.org",
|
||||
want: []string{
|
||||
"server-a3-1.example.org",
|
||||
"server-a3-2.example.org",
|
||||
"server-a4-1.example.org",
|
||||
"server-a4-2.example.org",
|
||||
},
|
||||
},
|
||||
{
|
||||
pattern: "node{01..03}",
|
||||
want: []string{"node01", "node02", "node03"},
|
||||
},
|
||||
{
|
||||
pattern: "router.example.net",
|
||||
want: []string{"router.example.net"},
|
||||
},
|
||||
{
|
||||
pattern: "host-{1..2}-{a,b}",
|
||||
want: []string{"host-1-a", "host-1-b", "host-2-a", "host-2-b"},
|
||||
},
|
||||
{
|
||||
pattern: "{1..10..2}.example.org",
|
||||
err: "endpoints must be numbers",
|
||||
},
|
||||
{
|
||||
pattern: "host-{5..2}.example.org",
|
||||
err: "5 > 2",
|
||||
},
|
||||
{
|
||||
pattern: "host-{x..y}.example.org",
|
||||
err: "endpoints must be numbers",
|
||||
},
|
||||
{
|
||||
pattern: "host-{1,2.example.org",
|
||||
err: "unmatched '{'",
|
||||
},
|
||||
{
|
||||
pattern: "host-{1..2-{3..4}}.example.org",
|
||||
err: "nested",
|
||||
},
|
||||
{
|
||||
pattern: "{{",
|
||||
err: "unmatched '{'",
|
||||
},
|
||||
{
|
||||
pattern: "{{1,2}}",
|
||||
err: "nested",
|
||||
},
|
||||
{
|
||||
pattern: "{a}{{b}}",
|
||||
err: "nested",
|
||||
},
|
||||
{
|
||||
pattern: "{1..5..10}",
|
||||
err: "endpoints must be numbers",
|
||||
},
|
||||
{
|
||||
pattern: "{1...5}",
|
||||
err: "endpoints must be numbers",
|
||||
},
|
||||
{
|
||||
pattern: "{1..2..3}",
|
||||
err: "endpoints must be numbers",
|
||||
},
|
||||
{
|
||||
pattern: "{1..2.5}",
|
||||
err: "endpoints must be numbers",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got, err := expandHostPattern(tt.pattern)
|
||||
if tt.err != "" {
|
||||
if err == nil {
|
||||
t.Errorf("%q: expected error containing %q, got nil", tt.pattern, tt.err)
|
||||
} else if !strings.Contains(err.Error(), tt.err) {
|
||||
t.Errorf("%q: expected error containing %q, got %q", tt.pattern, tt.err, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("%q: unexpected error: %v", tt.pattern, err)
|
||||
continue
|
||||
}
|
||||
if !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("%q:\n got %v\nwant %v", tt.pattern, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user