Files
cgpcli/iplist.go
2026-02-19 10:29:11 +03:00

445 lines
11 KiB
Go

// # IP List Administration
//
// The IP List section provides specialized types and methods for handling CommuniGate Pro
// Network Lists (Client IP addresses, Blacklists, etc.). It implements the unique
// CommuniGate Pro format that allows for mixed IP addresses, CIDR masks, ranges, and sequences
// within a single list, including support for negative (exclusion) rules and comments.
//
// Key capabilities include:
// - Flexible Management: adding, inserting, and deleting entries with [Cli.Add],
// [Cli.InsertBefore], and [Cli.InsertAfter] to maintain strict rule ordering.
// - Rule Logic: full support for exclusion rules (prefixed with "!") and complex
// entry types like [IPRange] and [IPSeq].
// - IP Matching: the [IPList.Contains] method allows for high-performance
// checks to determine if a specific net.IP is matched by any rule in the list.
// - Format Preservation: methods for parsing and writing lists while maintaining
// comment alignment and special character encoding required by the CommuniGate Pro CLI.
package cgpcli
import (
"bytes"
"fmt"
"net"
"strconv"
"strings"
)
// IPCIDR is a wrapper around net.IPNet that represents an IP address with its network mask.
// It is used to handle CommuniGate Pro's IP and CIDR formats, providing specialized
// string representation where /32 (IPv4) or /128 (IPv6) masks are omitted if they
// represent a single host.
type IPCIDR struct{ net.IPNet }
// IPEntry represents a single line in a CommuniGate Pro IP List, including the value,
// optional comment, and whether it is an exclusion rule.
type IPEntry struct {
Comment string // optional comment after the semicolon.
CommentPos int // original position of the semicolon for alignment.
IsNegative bool // true if the entry starts with "!" (exclusion rule).
Value any // the IP value: IPCIDR, IPRange, IPSeq, or IPUnknown.
}
type (
IPList []IPEntry
IPRange struct{ Start, End net.IP } // contiguous range of IP addresses from Start to End.
IPSeq []any // comma-separated sequence of multiple IP rules.
IPUnknown string
)
// Add appends a new entry to the IP list or updates the comment/exclusion status if the entry exists.
//
// Parameters:
// - input: the IP address, CIDR, range, or sequence to add. Supports "!" prefix for negative rules.
// - comment: the comment string to associate with this entry.
//
// Returns:
// - error: an error if the input format is invalid.
func (l *IPList) Add(input string, comment string) error {
isNegative := false
trimmedInput := strings.TrimSpace(input)
// Распознаем "!" в начале входной строки
if strings.HasPrefix(trimmedInput, "!") {
isNegative = true
trimmedInput = strings.TrimSpace(trimmedInput[1:])
}
val := parseDataPart(trimmedInput)
if _, ok := val.(IPUnknown); ok {
return fmt.Errorf("invalid IP format: %s", input)
}
inputNormalized := dataToString(val)
for i, entry := range *l {
if dataToString(entry.Value) == inputNormalized {
(*l)[i].Comment = comment
(*l)[i].IsNegative = isNegative
return nil
}
}
*l = append(*l, IPEntry{
Value: val,
Comment: comment,
IsNegative: isNegative,
})
return nil
}
// Contains checks if the specified net.IP matches any rule in the list.
// It respects the order of entries and correctly handles negative (exclusion) rules.
//
// Parameters:
// - ip: the net.IP address to check.
//
// Returns:
// - bool: true if the IP is allowed by the list rules, false otherwise.
func (l IPList) Contains(ip net.IP) bool {
if ip == nil {
return false
}
matched := false
for _, entry := range l {
if matchAny(entry.Value, ip) {
if entry.IsNegative {
matched = false
} else {
matched = true
}
}
}
return matched
}
// Delete removes an entry from the IP list matching the provided input string.
//
// Parameters:
// - input: the IP address, CIDR, or range to remove. The "!" prefix is ignored during matching.
//
// Returns:
// - error: an error if the entry is not found or the format is invalid.
func (l *IPList) Delete(input string) error {
cleanInput := strings.TrimPrefix(strings.TrimSpace(input), "!")
val := parseDataPart(cleanInput)
if _, ok := val.(IPUnknown); ok {
return fmt.Errorf("invalid IP format: %s", input)
}
inputNormalized := dataToString(val)
for i, entry := range *l {
if dataToString(entry.Value) == inputNormalized {
*l = append((*l)[:i], (*l)[i+1:]...)
return nil
}
}
return fmt.Errorf("entry not found: %s", input)
}
// GetEntry retrieves a pointer to an IPEntry matching the input string.
//
// Parameters:
// - input: the IP representation to search for.
//
// Returns:
// - *[IPEntry]: a pointer to the entry if found, nil otherwise.
func (l IPList) GetEntry(input string) *IPEntry {
idx := l.indexOf(input)
if idx == -1 {
return nil
}
return &l[idx]
}
// Insert places a new entry at a specific index in the list.
//
// Parameters:
// - index: the position at which to insert the new entry.
// - input: the IP rule string (supports "!" prefix).
// - comment: the comment for the entry.
// - alignPos: the visual position of the semicolon for comment alignment.
//
// Returns:
// - error: an error if the input format is invalid.
func (l *IPList) Insert(index int, input string, comment string, alignPos int) error {
isNegative := false
trimmedInput := strings.TrimSpace(input)
if strings.HasPrefix(trimmedInput, "!") {
isNegative = true
trimmedInput = strings.TrimSpace(trimmedInput[1:])
}
val := parseDataPart(trimmedInput)
if _, ok := val.(IPUnknown); ok {
return fmt.Errorf("invalid IP format: %s", input)
}
_ = l.Delete(trimmedInput)
newEntry := IPEntry{
Value: val,
Comment: comment,
CommentPos: alignPos,
IsNegative: isNegative,
}
if index < 0 {
index = 0
}
if index > len(*l) {
index = len(*l)
}
*l = append((*l)[:index], append([]IPEntry{newEntry}, (*l)[index:]...)...)
return nil
}
// InsertAfter places a new entry immediately following a specific marker entry.
//
// Parameters:
// - marker: the existing entry value to look for.
// - input: the new IP rule string to insert.
// - comment: the comment for the new entry.
// - alignPos: the visual position of the semicolon for comment alignment.
//
// Returns:
// - error: an error if the marker is not found or input is invalid.
func (l *IPList) InsertAfter(marker string, input string, comment string, alignPos int) error {
idx := l.indexOf(marker)
if idx == -1 {
return fmt.Errorf("marker not found: %s", marker)
}
return l.Insert(idx+1, input, comment, alignPos)
}
// InsertBefore places a new entry immediately preceding a specific marker entry.
//
// Parameters:
// - marker: the existing entry value to look for.
// - input: the new IP rule string to insert.
// - comment: the comment for the new entry.
// - alignPos: the visual position of the semicolon for comment alignment.
//
// Returns:
// - error: an error if the marker is not found or input is invalid.
func (l *IPList) InsertBefore(marker string, input string, comment string, alignPos int) error {
idx := l.indexOf(marker)
if idx == -1 {
return fmt.Errorf("marker not found: %s", marker)
}
return l.Insert(idx, input, comment, alignPos)
}
func dataToString(val any) string {
switch v := val.(type) {
case IPCIDR:
ones, bits := v.Mask.Size()
if ones == bits {
return v.IP.String()
}
return v.IP.String() + "/" + strconv.Itoa(ones)
case IPRange:
return v.Start.String() + "-" + v.End.String()
case IPSeq:
parts := make([]string, len(v))
for i, sub := range v {
parts[i] = dataToString(sub)
}
return strings.Join(parts, ",")
case IPUnknown:
return string(v)
default:
return ""
}
}
func (l IPList) indexOf(input string) int {
cleanInput := strings.TrimPrefix(strings.TrimSpace(input), "!")
val := parseDataPart(cleanInput)
if targetCIDR, ok := val.(IPCIDR); ok {
for i, entry := range l {
if matchAny(entry.Value, targetCIDR.IP) {
return i
}
}
}
clean := dataToString(val)
for i, entry := range l {
if dataToString(entry.Value) == clean {
return i
}
}
return -1
}
func matchAny(val any, ip net.IP) bool {
switch v := val.(type) {
case IPCIDR:
return v.Contains(ip)
case IPRange:
return bytes.Compare(ip, v.Start) >= 0 && bytes.Compare(ip, v.End) <= 0
case IPSeq:
for _, sub := range v {
if matchAny(sub, ip) {
return true
}
}
case IPUnknown:
if target := net.ParseIP(string(v)); target != nil {
return target.Equal(ip)
}
}
return false
}
func parseDataPart(s string) any {
s = strings.TrimSpace(s)
if strings.Contains(s, ",") {
parts := strings.Split(s, ",")
seq := make(IPSeq, 0, len(parts))
for _, p := range parts {
seq = append(seq, parseDataPart(p))
}
return seq
}
if idx := strings.Index(s, "-"); idx != -1 {
start := net.ParseIP(strings.TrimSpace(s[:idx]))
end := net.ParseIP(strings.TrimSpace(s[idx+1:]))
if start != nil && end != nil {
return IPRange{Start: start, End: end}
}
}
if !strings.Contains(s, "/") {
if ip := net.ParseIP(s); ip != nil {
if ip4 := ip.To4(); ip4 != nil {
ip = ip4
}
mask := net.CIDRMask(len(ip)*8, len(ip)*8)
return IPCIDR{net.IPNet{IP: ip, Mask: mask}}
}
} else if _, ipnet, err := net.ParseCIDR(s); err == nil {
return IPCIDR{*ipnet}
}
return IPUnknown(s)
}
func parseIPList(raw string) IPList {
lines := strings.Split(raw, "\n")
list := make(IPList, 0, len(lines))
for _, line := range lines {
if line == "" {
continue
}
entry := IPEntry{}
idx := strings.IndexByte(line, ';')
dataPart := line
if idx != -1 {
entry.CommentPos = idx
entry.Comment = line[idx+1:]
dataPart = line[:idx]
}
trimmedData := strings.TrimSpace(dataPart)
if strings.HasPrefix(trimmedData, "!") {
entry.IsNegative = true
trimmedData = strings.TrimSpace(trimmedData[1:])
}
if trimmedData != "" {
entry.Value = parseDataPart(trimmedData)
list = append(list, entry)
} else if entry.Comment != "" {
list = append(list, entry)
}
}
return list
}
func (l IPList) writeCGP(w encodeWriter) (err error) {
if err = w.WriteByte('"'); err != nil {
return
}
for i, entry := range l {
if i > 0 {
if _, err = w.WriteString(`\e`); err != nil {
return
}
}
if entry.IsNegative {
if err = w.WriteByte('!'); err != nil {
return
}
}
if err = writeDataToString(w, entry.Value); err != nil {
return
}
if entry.Comment != "" {
dataStrLen := len(dataToString(entry.Value))
if entry.IsNegative {
dataStrLen++
}
if entry.CommentPos > dataStrLen {
w.WriteString(strings.Repeat(" ", entry.CommentPos-dataStrLen))
} else {
w.WriteByte(' ')
}
if err = w.WriteByte(';'); err != nil {
return
}
if _, err = w.WriteString(replaceSpecChars(entry.Comment)); err != nil {
return
}
}
}
return w.WriteByte('"')
}
// writeDataToString пишет строковое представление IP-данных напрямую в writer
func writeDataToString(w encodeWriter, val any) error {
switch v := val.(type) {
case IPCIDR:
ones, bits := v.Mask.Size()
w.WriteString(v.IP.String())
if ones != bits {
w.WriteByte('/')
w.WriteString(strconv.Itoa(ones))
}
case IPRange:
w.WriteString(v.Start.String())
w.WriteByte('-')
w.WriteString(v.End.String())
case IPSeq:
for i, sub := range v {
if i > 0 {
w.WriteByte(',')
}
if err := writeDataToString(w, sub); err != nil {
return err
}
}
case IPUnknown:
w.WriteString(string(v))
}
return nil
}