PortRelay:把内网服务临时、可控地开放给同事
边缘网关 + Agent 的端口中继:Agent 主动连出到网关,把内网目标挂到网关端口上; 通过邀请链接按设备授权,可选审批,首次连接起计时,到期、闲置自动收回。 - portrelay-server:边缘网关(端口池、白名单与授权租约、邀请与授权网页) - portrelay-agent:Agent(yamux 隧道、本地 API 与网页面板、流量统计) - mac-native:SwiftUI 菜单栏 App
This commit is contained in:
commit
86e159b3f0
67 files changed
+13888
No files matched your search
@@ -0,0 +1,193 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package acl
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestWhitelist(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
rules []string
|
||||
ip string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "allow all wildcard",
|
||||
rules: []string{"*"},
|
||||
ip: "192.168.1.50:12345",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "single ip match",
|
||||
rules: []string{"192.168.1.50"},
|
||||
ip: "192.168.1.50:12345",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "single ip mismatch",
|
||||
rules: []string{"192.168.1.50"},
|
||||
ip: "192.168.1.51:12345",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "cidr match",
|
||||
rules: []string{"192.168.1.0/24"},
|
||||
ip: "192.168.1.77:45678",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "cidr mismatch",
|
||||
rules: []string{"192.168.1.0/24"},
|
||||
ip: "192.168.2.190:45678",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "multiple rules mixed",
|
||||
rules: []string{"10.0.0.1", "192.168.1.0/24"},
|
||||
ip: "10.0.0.1:80",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "empty rules reject all",
|
||||
rules: []string{},
|
||||
ip: "127.0.0.1:80",
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
wl := NewWhitelist(tt.rules)
|
||||
allowed, _ := wl.IsAllowed(tt.ip)
|
||||
if allowed != tt.expected {
|
||||
t.Errorf("expected %v for IP %s with rules %v, got %v", tt.expected, tt.ip, tt.rules, allowed)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRule(t *testing.T) {
|
||||
for _, ok := range []string{"*", "192.168.1.100", "192.168.1.0/24", "::1", " 10.0.0.1 "} {
|
||||
if err := ValidateRule(ok); err != nil {
|
||||
t.Errorf("ValidateRule(%q) = %v, want nil", ok, err)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{"", "192.168.1", "10.0.0.0/33", "abc", "192.168.1.*"} {
|
||||
if err := ValidateRule(bad); err == nil {
|
||||
t.Errorf("ValidateRule(%q) = nil, want error", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDynamicLeasesRoundTrip(t *testing.T) {
|
||||
w1 := NewWhitelist([]string{"10.0.0.1"})
|
||||
w1.AddDynamic("192.168.1.5", time.Now().Add(time.Hour))
|
||||
w1.AddDynamic("192.168.1.6", time.Now().Add(-time.Second))
|
||||
|
||||
w2 := NewWhitelist([]string{"10.0.0.2"})
|
||||
w2.RestoreDynamic(w1.DynamicLeases())
|
||||
if ok, _ := w2.IsAllowed("192.168.1.5:1234"); !ok {
|
||||
t.Error("restored lease not honoured")
|
||||
}
|
||||
if ok, _ := w2.IsAllowed("192.168.1.6"); ok {
|
||||
t.Error("expired lease should not be restored")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveDynamic(t *testing.T) {
|
||||
w := NewWhitelist(nil)
|
||||
w.AddDynamic("10.0.0.1", time.Now().Add(time.Hour))
|
||||
w.AddDynamic("10.0.0.2", time.Now().Add(time.Hour))
|
||||
if n := w.RemoveDynamic("10.0.0.1"); n != 1 {
|
||||
t.Fatalf("removed %d, want 1", n)
|
||||
}
|
||||
if ok, _ := w.IsAllowed("10.0.0.1"); ok {
|
||||
t.Error("revoked IP still allowed")
|
||||
}
|
||||
if n := w.RemoveDynamic(""); n != 1 {
|
||||
t.Fatalf("remove all removed %d, want 1", n)
|
||||
}
|
||||
if ok, _ := w.IsAllowed("10.0.0.2"); ok {
|
||||
t.Error("IP still allowed after revoke-all")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user