feat: traefik-selectel-dns — A-записи Selectel по роутам Traefik + CI сборка образа
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
// Package hostparse извлекает имена хостов из правил роутеров Traefik
|
||||
// и сопоставляет их с разрешёнными DNS-зонами.
|
||||
package hostparse
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Result — результат разбора правила роутера.
|
||||
type Result struct {
|
||||
// Hosts — валидные нормализованные (lower-case, без точки в конце) имена из Host(...).
|
||||
Hosts []string
|
||||
// Wildcards — хосты вида *.example.com (не поддерживаются).
|
||||
Wildcards []string
|
||||
// Invalid — значения Host(...), не являющиеся корректным DNS-именем.
|
||||
Invalid []string
|
||||
// RegexpCount — число HostRegexp(...) в правиле (игнорируются).
|
||||
RegexpCount int
|
||||
}
|
||||
|
||||
// Parse разбирает rule и возвращает все «положительные» Host(...).
|
||||
// Host внутри отрицания (!Host(...), !(...)) пропускается.
|
||||
// Поддерживаются строки в обратных кавычках и двойных кавычках.
|
||||
func Parse(rule string) Result {
|
||||
var res Result
|
||||
seen := map[string]struct{}{}
|
||||
|
||||
s := []rune(rule)
|
||||
n := len(s)
|
||||
i := 0
|
||||
// стек скобок: true — группа под отрицанием
|
||||
var stack []bool
|
||||
negDepth := 0
|
||||
pendingNot := false
|
||||
|
||||
skipSpaces := func() {
|
||||
for i < n && isSpace(s[i]) {
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
for i < n {
|
||||
c := s[i]
|
||||
switch {
|
||||
case isSpace(c):
|
||||
i++
|
||||
case c == '!':
|
||||
pendingNot = true
|
||||
i++
|
||||
case c == '`' || c == '"':
|
||||
_, next := readString(s, i)
|
||||
i = next
|
||||
pendingNot = false
|
||||
case c == '(':
|
||||
neg := pendingNot
|
||||
stack = append(stack, neg)
|
||||
if neg {
|
||||
negDepth++
|
||||
}
|
||||
pendingNot = false
|
||||
i++
|
||||
case c == ')':
|
||||
if len(stack) > 0 {
|
||||
if stack[len(stack)-1] {
|
||||
negDepth--
|
||||
}
|
||||
stack = stack[:len(stack)-1]
|
||||
}
|
||||
pendingNot = false
|
||||
i++
|
||||
case isIdentStart(c):
|
||||
start := i
|
||||
for i < n && isIdentPart(s[i]) {
|
||||
i++
|
||||
}
|
||||
ident := string(s[start:i])
|
||||
skipSpaces()
|
||||
negated := pendingNot || negDepth > 0
|
||||
pendingNot = false
|
||||
if i >= n || s[i] != '(' {
|
||||
continue
|
||||
}
|
||||
switch ident {
|
||||
case "Host":
|
||||
i++ // '('
|
||||
args, next := readArgs(s, i)
|
||||
i = next
|
||||
if negated {
|
||||
continue
|
||||
}
|
||||
for _, a := range args {
|
||||
classify(a, &res, seen)
|
||||
}
|
||||
case "HostRegexp":
|
||||
i++
|
||||
_, next := readArgs(s, i)
|
||||
i = next
|
||||
if !negated {
|
||||
res.RegexpCount++
|
||||
}
|
||||
default:
|
||||
// прочие функции: обычный вход в скобку, строки внутри пропускаются основным циклом
|
||||
stack = append(stack, negated)
|
||||
if negated {
|
||||
negDepth++
|
||||
}
|
||||
i++
|
||||
}
|
||||
default:
|
||||
i++
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func classify(raw string, res *Result, seen map[string]struct{}) {
|
||||
h := Normalize(raw)
|
||||
if h == "" {
|
||||
res.Invalid = append(res.Invalid, raw)
|
||||
return
|
||||
}
|
||||
if strings.HasPrefix(h, "*.") || h == "*" {
|
||||
res.Wildcards = append(res.Wildcards, h)
|
||||
return
|
||||
}
|
||||
if !ValidHostname(h) {
|
||||
res.Invalid = append(res.Invalid, raw)
|
||||
return
|
||||
}
|
||||
if _, ok := seen[h]; ok {
|
||||
return
|
||||
}
|
||||
seen[h] = struct{}{}
|
||||
res.Hosts = append(res.Hosts, h)
|
||||
}
|
||||
|
||||
// Normalize приводит имя к нижнему регистру и убирает пробелы и точку в конце.
|
||||
func Normalize(name string) string {
|
||||
name = strings.TrimSpace(name)
|
||||
name = strings.TrimSuffix(name, ".")
|
||||
return strings.ToLower(name)
|
||||
}
|
||||
|
||||
// ValidHostname проверяет, что имя — корректный ASCII hostname (LDH, допускается '_').
|
||||
func ValidHostname(h string) bool {
|
||||
if h == "" || len(h) > 253 {
|
||||
return false
|
||||
}
|
||||
for _, label := range strings.Split(h, ".") {
|
||||
if label == "" || len(label) > 63 {
|
||||
return false
|
||||
}
|
||||
if label[0] == '-' || label[len(label)-1] == '-' {
|
||||
return false
|
||||
}
|
||||
for _, r := range label {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-', r == '_':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// readArgs читает список строк до закрывающей ')' (начиная сразу после '(').
|
||||
func readArgs(s []rune, i int) ([]string, int) {
|
||||
var args []string
|
||||
n := len(s)
|
||||
for i < n {
|
||||
c := s[i]
|
||||
switch {
|
||||
case c == ')':
|
||||
return args, i + 1
|
||||
case c == '`' || c == '"':
|
||||
v, next := readString(s, i)
|
||||
args = append(args, v)
|
||||
i = next
|
||||
default:
|
||||
i++
|
||||
}
|
||||
}
|
||||
return args, i
|
||||
}
|
||||
|
||||
// readString читает строку в кавычках начиная с s[i] (кавычка), возвращает значение и позицию после неё.
|
||||
func readString(s []rune, i int) (string, int) {
|
||||
q := s[i]
|
||||
i++
|
||||
var b strings.Builder
|
||||
for i < len(s) {
|
||||
c := s[i]
|
||||
if q == '"' && c == '\\' && i+1 < len(s) {
|
||||
b.WriteRune(s[i+1])
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
if c == q {
|
||||
return b.String(), i + 1
|
||||
}
|
||||
b.WriteRune(c)
|
||||
i++
|
||||
}
|
||||
return b.String(), i
|
||||
}
|
||||
|
||||
func isSpace(r rune) bool { return r == ' ' || r == '\t' || r == '\n' || r == '\r' }
|
||||
func isIdentStart(r rune) bool {
|
||||
return r == '_' || (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z')
|
||||
}
|
||||
func isIdentPart(r rune) bool { return isIdentStart(r) || (r >= '0' && r <= '9') }
|
||||
@@ -0,0 +1,78 @@
|
||||
package hostparse
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParse(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
rule string
|
||||
hosts []string
|
||||
wildcards []string
|
||||
invalid int
|
||||
regexp int
|
||||
}{
|
||||
{"single", "Host(`app.example.com`)", []string{"app.example.com"}, nil, 0, 0},
|
||||
{"multi args v3", "Host(`a.example.com`, `b.example.com`)", []string{"a.example.com", "b.example.com"}, nil, 0, 0},
|
||||
{"or", "Host(`a.example.com`) || Host(`b.example.com`)", []string{"a.example.com", "b.example.com"}, nil, 0, 0},
|
||||
{"with path and", "Host(`a.example.com`) && PathPrefix(`/api`)", []string{"a.example.com"}, nil, 0, 0},
|
||||
{"double quotes", `Host("a.example.com")`, []string{"a.example.com"}, nil, 0, 0},
|
||||
{"spaces", " Host ( `A.Example.COM.` ) ", []string{"a.example.com"}, nil, 0, 0},
|
||||
{"dedupe", "Host(`a.example.com`) || Host(`a.example.com`)", []string{"a.example.com"}, nil, 0, 0},
|
||||
{"regexp ignored", "HostRegexp(`^.+\\.example\\.com$`)", nil, nil, 0, 1},
|
||||
{"regexp and host", "Host(`a.example.com`) || HostRegexp(`{sub:.+}.example.com`)", []string{"a.example.com"}, nil, 0, 1},
|
||||
{"wildcard", "Host(`*.example.com`)", nil, []string{"*.example.com"}, 0, 0},
|
||||
{"negated host", "!Host(`a.example.com`) && Host(`b.example.com`)", []string{"b.example.com"}, nil, 0, 0},
|
||||
{"negated group", "!(Host(`a.example.com`) || Host(`c.example.com`)) && Host(`b.example.com`)", []string{"b.example.com"}, nil, 0, 0},
|
||||
{"group positive", "(Host(`a.example.com`) || Host(`b.example.com`)) && Path(`/x`)", []string{"a.example.com", "b.example.com"}, nil, 0, 0},
|
||||
{"host inside other string", "PathPrefix(`/Host(`)", nil, nil, 0, 0},
|
||||
{"hostsni not host", "HostSNI(`a.example.com`)", nil, nil, 0, 0},
|
||||
{"invalid", "Host(`bad host`, `-x.example.com`, `ünï.example.com`)", nil, nil, 3, 0},
|
||||
{"empty", "", nil, nil, 0, 0},
|
||||
{"api internal", "PathPrefix(`/api`) || PathPrefix(`/dashboard`)", nil, nil, 0, 0},
|
||||
{"unterminated", "Host(`a.example.com", []string{"a.example.com"}, nil, 0, 0},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := Parse(tc.rule)
|
||||
if !reflect.DeepEqual(got.Hosts, tc.hosts) {
|
||||
t.Errorf("hosts = %v, want %v", got.Hosts, tc.hosts)
|
||||
}
|
||||
if !reflect.DeepEqual(got.Wildcards, tc.wildcards) {
|
||||
t.Errorf("wildcards = %v, want %v", got.Wildcards, tc.wildcards)
|
||||
}
|
||||
if len(got.Invalid) != tc.invalid {
|
||||
t.Errorf("invalid = %v, want %d", got.Invalid, tc.invalid)
|
||||
}
|
||||
if got.RegexpCount != tc.regexp {
|
||||
t.Errorf("regexp = %d, want %d", got.RegexpCount, tc.regexp)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchZone(t *testing.T) {
|
||||
zones := []string{"example.com", "dev.example.com", "other.org"}
|
||||
tests := []struct {
|
||||
host, zone string
|
||||
ok bool
|
||||
}{
|
||||
{"example.com", "example.com", true},
|
||||
{"app.example.com", "example.com", true},
|
||||
{"a.b.example.com", "example.com", true},
|
||||
{"app.dev.example.com", "dev.example.com", true},
|
||||
{"dev.example.com", "dev.example.com", true},
|
||||
{"badexample.com", "", false},
|
||||
{"example.com.evil.net", "", false},
|
||||
{"x.other.org", "other.org", true},
|
||||
{"unknown.net", "", false},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
z, ok := MatchZone(tc.host, zones)
|
||||
if z != tc.zone || ok != tc.ok {
|
||||
t.Errorf("MatchZone(%q) = %q,%v want %q,%v", tc.host, z, ok, tc.zone, tc.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package hostparse
|
||||
|
||||
import "strings"
|
||||
|
||||
// MatchZone возвращает наиболее специфичную зону из zones, которой принадлежит host
|
||||
// (host равен зоне или является её поддоменом). Зоны и host ожидаются нормализованными.
|
||||
func MatchZone(host string, zones []string) (string, bool) {
|
||||
best := ""
|
||||
for _, z := range zones {
|
||||
if host == z || strings.HasSuffix(host, "."+z) {
|
||||
if len(z) > len(best) {
|
||||
best = z
|
||||
}
|
||||
}
|
||||
}
|
||||
return best, best != ""
|
||||
}
|
||||
Reference in New Issue
Block a user