- 批量创建:主机支持 IP、主机名、末段范围(10.0.1.11-13)和 主机:端口, 与端口列表展开为 主机 × 端口,自动分配连续的访问端口,创建前可预览 - 授权以组为单位:一条邀请链接、一次审批即可访问组内全部地址; 计时从首次连上任一地址开始,撤销一次全组断开;后加入组的通道自动获得组授权 - 网关授权状态按作用范围(通道 / 通道组)存储,通道的动态白名单统一由状态库重新计算, 通道授权与组授权互不干扰;组内最后一个通道关闭或组被解散时,组的邀请与授权失效 - 闲置联动:组内任一地址在用时整组都不算闲置;支持整组开关 - 已有通道可并入组或移出组;组可改名、追加目标、解散(保留或删除成员) - App 与网页面板:组卡片、新建 / 编辑通道组、组邀请与“连接与授权”,授权页列出全部地址 - 测试:网关 9 个、Agent 5 个通道组用例
206 lines
4.4 KiB
Go
206 lines
4.4 KiB
Go
package acl
|
|
|
|
import (
|
|
"fmt"
|
|
"net"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Whitelist checks if an IP is allowed based on rules and dynamic leases
|
|
type Whitelist struct {
|
|
rules []string
|
|
allowAll bool
|
|
ips map[string]bool
|
|
subnets []*net.IPNet
|
|
dynamic map[string]time.Time // dynamic IP -> expiration
|
|
mu sync.RWMutex
|
|
}
|
|
|
|
// NewWhitelist parses a list of rules
|
|
func NewWhitelist(rules []string) *Whitelist {
|
|
w := &Whitelist{
|
|
rules: rules,
|
|
ips: make(map[string]bool),
|
|
subnets: make([]*net.IPNet, 0),
|
|
dynamic: make(map[string]time.Time),
|
|
}
|
|
|
|
for _, r := range rules {
|
|
r = strings.TrimSpace(r)
|
|
if r == "" {
|
|
continue
|
|
}
|
|
if r == "*" || r == "0.0.0.0/0" || r == "::/0" {
|
|
w.allowAll = true
|
|
continue
|
|
}
|
|
if strings.Contains(r, "/") {
|
|
_, ipNet, err := net.ParseCIDR(r)
|
|
if err == nil {
|
|
w.subnets = append(w.subnets, ipNet)
|
|
}
|
|
} else {
|
|
ip := net.ParseIP(r)
|
|
if ip != nil {
|
|
w.ips[ip.String()] = true
|
|
}
|
|
}
|
|
}
|
|
return w
|
|
}
|
|
|
|
// ValidateRule reports whether a whitelist rule is "*", a valid IP or a valid CIDR
|
|
func ValidateRule(r string) error {
|
|
r = strings.TrimSpace(r)
|
|
switch {
|
|
case r == "":
|
|
return fmt.Errorf("白名单规则不能为空")
|
|
case r == "*":
|
|
return nil
|
|
case strings.Contains(r, "/"):
|
|
if _, _, err := net.ParseCIDR(r); err != nil {
|
|
return fmt.Errorf("无效的网段 %q", r)
|
|
}
|
|
default:
|
|
if net.ParseIP(r) == nil {
|
|
return fmt.Errorf("无效的 IP 地址 %q", r)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DynamicLeases returns a copy of unexpired dynamic leases
|
|
func (w *Whitelist) DynamicLeases() map[string]time.Time {
|
|
w.mu.RLock()
|
|
defer w.mu.RUnlock()
|
|
now := time.Now()
|
|
out := make(map[string]time.Time, len(w.dynamic))
|
|
for ip, exp := range w.dynamic {
|
|
if now.Before(exp) {
|
|
out[ip] = exp
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// RestoreDynamic merges previously granted leases (e.g. after a config update)
|
|
func (w *Whitelist) RestoreDynamic(leases map[string]time.Time) {
|
|
w.mu.Lock()
|
|
defer w.mu.Unlock()
|
|
for ip, exp := range leases {
|
|
if cur, ok := w.dynamic[ip]; !ok || exp.After(cur) {
|
|
w.dynamic[ip] = exp
|
|
}
|
|
}
|
|
}
|
|
|
|
// ReplaceDynamic sets the dynamic leases to exactly the given set (the access state store is the source of truth)
|
|
func (w *Whitelist) ReplaceDynamic(leases map[string]time.Time) {
|
|
w.mu.Lock()
|
|
defer w.mu.Unlock()
|
|
w.dynamic = make(map[string]time.Time, len(leases))
|
|
for ip, exp := range leases {
|
|
if parsed := net.ParseIP(strings.TrimSpace(ip)); parsed != nil {
|
|
w.dynamic[parsed.String()] = exp
|
|
}
|
|
}
|
|
}
|
|
|
|
// RemoveDynamic revokes the lease for ip (all leases when ip is empty) and returns how many were removed
|
|
func (w *Whitelist) RemoveDynamic(ip string) int {
|
|
w.mu.Lock()
|
|
defer w.mu.Unlock()
|
|
if ip == "" {
|
|
n := len(w.dynamic)
|
|
w.dynamic = make(map[string]time.Time)
|
|
return n
|
|
}
|
|
if parsed := net.ParseIP(strings.TrimSpace(ip)); parsed != nil {
|
|
if _, ok := w.dynamic[parsed.String()]; ok {
|
|
delete(w.dynamic, parsed.String())
|
|
return 1
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// AddDynamic adds an IP with an expiration timestamp
|
|
func (w *Whitelist) AddDynamic(ipStr string, expireAt time.Time) {
|
|
w.mu.Lock()
|
|
defer w.mu.Unlock()
|
|
host := ipStr
|
|
if h, _, err := net.SplitHostPort(ipStr); err == nil {
|
|
host = h
|
|
}
|
|
parsed := net.ParseIP(host)
|
|
if parsed != nil {
|
|
w.dynamic[parsed.String()] = expireAt
|
|
}
|
|
}
|
|
|
|
// GetDynamic returns active unexpired dynamic IPs
|
|
func (w *Whitelist) GetDynamic() []string {
|
|
w.mu.Lock()
|
|
defer w.mu.Unlock()
|
|
now := time.Now()
|
|
var list []string
|
|
for ip, exp := range w.dynamic {
|
|
if now.Before(exp) {
|
|
list = append(list, ip)
|
|
} else {
|
|
delete(w.dynamic, ip)
|
|
}
|
|
}
|
|
return list
|
|
}
|
|
|
|
// IsAllowed checks if remoteAddr (either "IP" or "IP:Port") is permitted
|
|
func (w *Whitelist) IsAllowed(remoteAddr string) (bool, string) {
|
|
if w.allowAll {
|
|
return true, ""
|
|
}
|
|
|
|
host := remoteAddr
|
|
if h, _, err := net.SplitHostPort(remoteAddr); err == nil {
|
|
host = h
|
|
}
|
|
|
|
parsedIP := net.ParseIP(host)
|
|
if parsedIP == nil {
|
|
return false, "invalid client IP"
|
|
}
|
|
|
|
ipStr := parsedIP.String()
|
|
|
|
// Canonical IP string check
|
|
if w.ips[ipStr] {
|
|
return true, ""
|
|
}
|
|
|
|
// Dynamic leases check
|
|
w.mu.RLock()
|
|
if exp, ok := w.dynamic[ipStr]; ok {
|
|
if time.Now().Before(exp) {
|
|
w.mu.RUnlock()
|
|
return true, ""
|
|
}
|
|
}
|
|
w.mu.RUnlock()
|
|
|
|
// CIDR subnets check
|
|
for _, subnet := range w.subnets {
|
|
if subnet.Contains(parsedIP) {
|
|
return true, ""
|
|
}
|
|
}
|
|
|
|
return false, "IP not in whitelist"
|
|
}
|
|
|
|
// Rules returns the raw rules
|
|
func (w *Whitelist) Rules() []string {
|
|
return w.rules
|
|
}
|