Files
traefik-selectel/internal/hostparse/hostparse.go
T
vasyanskandClaude Sonnet 5 273cd54415
Build / Tests (push) Successful in 13s
Build / Build image (push) Failing after 15s
Build / Notify on failure (push) Failing after 0s
feat: traefik-selectel-dns — A-записи Selectel по роутам Traefik + CI сборка образа
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 21:50:07 +07:00

213 lines
5.1 KiB
Go

// 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') }