feat: Add IP access control functionality

- Implement IP whitelist/blacklist access control mechanism
- Add --allow-ip and --deny-ip command-line arguments to configure IP access rules
- Support CIDR format for IP range configuration
- Enable IP access control in HTTP, HTTPS, and TCP tunnels
- Add IP access check logic to server-side proxy handling
- Update documentation to explain how to use IP access control
This commit is contained in:
Gouryella
2026-01-11 14:22:41 +08:00
parent 4b0e15dfb5
commit 85a0f44e44
14 changed files with 297 additions and 0 deletions

View File

@@ -192,6 +192,7 @@ sudo journalctl -u drip-server -f
**Security**
- TLS 1.3 encryption for all connections
- Token-based authentication
- IP whitelist/blacklist access control
- No legacy protocol support
**Flexibility**
@@ -248,6 +249,21 @@ drip http 8080 -a 172.17.0.3
drip tcp 5432 -a db-container
```
**IP Access Control**
```bash
# Only allow access from specific networks (CIDR)
drip http 3000 --allow-ip 192.168.0.0/16,10.0.0.0/8
# Only allow specific IP addresses
drip http 3000 --allow-ip 192.168.1.100,192.168.1.101
# Block specific IP addresses
drip http 3000 --deny-ip 1.2.3.4,5.6.7.8
# Combine whitelist and blacklist
drip tcp 5432 --allow-ip 192.168.1.0/24 --deny-ip 192.168.1.100
```
## Command Reference
```bash
@@ -258,6 +274,8 @@ drip http <port> [flags]
-d, --daemon Run in background
-s, --server Server address
-t, --token Auth token
--allow-ip Allow only these IPs or CIDR ranges
--deny-ip Deny these IPs or CIDR ranges
# HTTPS tunnel (same flags as http)
drip https <port> [flags]

View File

@@ -163,6 +163,7 @@ server {
location / {
proxy_pass https://127.0.0.1:8443;
proxy_ssl_protocols TLSv1.3;
proxy_ssl_verify off;
proxy_http_version 1.1;
proxy_set_header Host $host;
@@ -191,6 +192,7 @@ sudo journalctl -u drip-server -f
**安全性**
- 所有连接使用 TLS 1.3 加密
- 基于 Token 的身份验证
- IP 白名单/黑名单访问控制
- 不支持任何遗留协议
**灵活性**
@@ -247,6 +249,21 @@ drip http 8080 -a 172.17.0.3
drip tcp 5432 -a db-container
```
**IP 访问控制**
```bash
# 只允许特定网段访问CIDR
drip http 3000 --allow-ip 192.168.0.0/16,10.0.0.0/8
# 只允许特定 IP 访问
drip http 3000 --allow-ip 192.168.1.100,192.168.1.101
# 拒绝特定 IP
drip http 3000 --deny-ip 1.2.3.4,5.6.7.8
# 组合白名单和黑名单
drip tcp 5432 --allow-ip 192.168.1.0/24 --deny-ip 192.168.1.100
```
## 命令参考
```bash
@@ -257,6 +274,8 @@ drip http <端口> [参数]
-d, --daemon 后台运行
-s, --server 服务器地址
-t, --token 认证 token
--allow-ip 只允许这些 IP 或 CIDR 访问
--deny-ip 拒绝这些 IP 或 CIDR 访问
# HTTPS 隧道(参数同 http
drip https <端口> [参数]

View File

@@ -15,6 +15,8 @@ var (
daemonMode bool
daemonMarker bool
localAddress string
allowIPs []string
denyIPs []string
)
var httpCmd = &cobra.Command{
@@ -25,6 +27,9 @@ var httpCmd = &cobra.Command{
Example:
drip http 3000 Tunnel localhost:3000
drip http 8080 --subdomain myapp Use custom subdomain
drip http 3000 --allow-ip 192.168.0.0/16 Only allow IPs from 192.168.x.x
drip http 3000 --allow-ip 10.0.0.1 Allow single IP
drip http 3000 --deny-ip 1.2.3.4 Block specific IP
Configuration:
First time: Run 'drip config init' to save server and token
@@ -39,6 +44,8 @@ func init() {
httpCmd.Flags().StringVarP(&subdomain, "subdomain", "n", "", "Custom subdomain (optional)")
httpCmd.Flags().BoolVarP(&daemonMode, "daemon", "d", false, "Run in background (daemon mode)")
httpCmd.Flags().StringVarP(&localAddress, "address", "a", "127.0.0.1", "Local address to forward to (default: 127.0.0.1)")
httpCmd.Flags().StringSliceVar(&allowIPs, "allow-ip", nil, "Allow only these IPs or CIDR ranges (e.g., 192.168.1.1,10.0.0.0/8)")
httpCmd.Flags().StringSliceVar(&denyIPs, "deny-ip", nil, "Deny these IPs or CIDR ranges (e.g., 1.2.3.4,192.168.1.0/24)")
httpCmd.Flags().BoolVar(&daemonMarker, "daemon-child", false, "Internal flag for daemon child process")
httpCmd.Flags().MarkHidden("daemon-child")
rootCmd.AddCommand(httpCmd)
@@ -67,6 +74,8 @@ func runHTTP(_ *cobra.Command, args []string) error {
LocalPort: port,
Subdomain: subdomain,
Insecure: insecure,
AllowIPs: allowIPs,
DenyIPs: denyIPs,
}
var daemon *DaemonInfo

View File

@@ -18,6 +18,9 @@ var httpsCmd = &cobra.Command{
Example:
drip https 443 Tunnel localhost:443
drip https 8443 --subdomain myapp Use custom subdomain
drip https 443 --allow-ip 192.168.0.0/16 Only allow IPs from 192.168.x.x
drip https 443 --allow-ip 10.0.0.1 Allow single IP
drip https 443 --deny-ip 1.2.3.4 Block specific IP
Configuration:
First time: Run 'drip config init' to save server and token
@@ -32,6 +35,8 @@ func init() {
httpsCmd.Flags().StringVarP(&subdomain, "subdomain", "n", "", "Custom subdomain (optional)")
httpsCmd.Flags().BoolVarP(&daemonMode, "daemon", "d", false, "Run in background (daemon mode)")
httpsCmd.Flags().StringVarP(&localAddress, "address", "a", "127.0.0.1", "Local address to forward to (default: 127.0.0.1)")
httpsCmd.Flags().StringSliceVar(&allowIPs, "allow-ip", nil, "Allow only these IPs or CIDR ranges (e.g., 192.168.1.1,10.0.0.0/8)")
httpsCmd.Flags().StringSliceVar(&denyIPs, "deny-ip", nil, "Deny these IPs or CIDR ranges (e.g., 1.2.3.4,192.168.1.0/24)")
httpsCmd.Flags().BoolVar(&daemonMarker, "daemon-child", false, "Internal flag for daemon child process")
httpsCmd.Flags().MarkHidden("daemon-child")
rootCmd.AddCommand(httpsCmd)
@@ -60,6 +65,8 @@ func runHTTPS(_ *cobra.Command, args []string) error {
LocalPort: port,
Subdomain: subdomain,
Insecure: insecure,
AllowIPs: allowIPs,
DenyIPs: denyIPs,
}
var daemon *DaemonInfo

View File

@@ -20,6 +20,9 @@ Example:
drip tcp 3306 Tunnel MySQL
drip tcp 22 Tunnel SSH
drip tcp 6379 --subdomain myredis Tunnel Redis with custom subdomain
drip tcp 5432 --allow-ip 192.168.0.0/16 Only allow IPs from 192.168.x.x
drip tcp 22 --allow-ip 10.0.0.1 Allow single IP
drip tcp 22 --deny-ip 1.2.3.4 Block specific IP
Supported Services:
- Databases: PostgreSQL (5432), MySQL (3306), Redis (6379), MongoDB (27017)
@@ -39,6 +42,8 @@ func init() {
tcpCmd.Flags().StringVarP(&subdomain, "subdomain", "n", "", "Custom subdomain (optional)")
tcpCmd.Flags().BoolVarP(&daemonMode, "daemon", "d", false, "Run in background (daemon mode)")
tcpCmd.Flags().StringVarP(&localAddress, "address", "a", "127.0.0.1", "Local address to forward to (default: 127.0.0.1)")
tcpCmd.Flags().StringSliceVar(&allowIPs, "allow-ip", nil, "Allow only these IPs or CIDR ranges (e.g., 192.168.1.1,10.0.0.0/8)")
tcpCmd.Flags().StringSliceVar(&denyIPs, "deny-ip", nil, "Deny these IPs or CIDR ranges (e.g., 1.2.3.4,192.168.1.0/24)")
tcpCmd.Flags().BoolVar(&daemonMarker, "daemon-child", false, "Internal flag for daemon child process")
tcpCmd.Flags().MarkHidden("daemon-child")
rootCmd.AddCommand(tcpCmd)
@@ -67,6 +72,8 @@ func runTCP(_ *cobra.Command, args []string) error {
LocalPort: port,
Subdomain: subdomain,
Insecure: insecure,
AllowIPs: allowIPs,
DenyIPs: denyIPs,
}
var daemon *DaemonInfo

View File

@@ -24,6 +24,9 @@ type ConnectorConfig struct {
PoolSize int
PoolMin int
PoolMax int
AllowIPs []string
DenyIPs []string
}
type TunnelClient interface {

View File

@@ -63,6 +63,9 @@ type PoolClient struct {
lastScale time.Time
logger *zap.Logger
allowIPs []string
denyIPs []string
}
// NewPoolClient creates a new pool client.
@@ -126,6 +129,8 @@ func NewPoolClient(cfg *ConnectorConfig, logger *zap.Logger) *PoolClient {
doneCh: make(chan struct{}),
dataSessions: make(map[string]*sessionHandle),
logger: logger,
allowIPs: cfg.AllowIPs,
denyIPs: cfg.DenyIPs,
}
if tunnelType == protocol.TunnelTypeHTTP || tunnelType == protocol.TunnelTypeHTTPS {
@@ -156,6 +161,13 @@ func (c *PoolClient) Connect() error {
},
}
if len(c.allowIPs) > 0 || len(c.denyIPs) > 0 {
req.IPAccess = &protocol.IPAccessControl{
AllowIPs: c.allowIPs,
DenyIPs: c.denyIPs,
}
}
payload, err := json.Marshal(req)
if err != nil {
_ = primaryConn.Close()

View File

@@ -81,6 +81,14 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
if tconn.HasIPAccessControl() {
clientIP := h.extractClientIP(r)
if !tconn.IsIPAllowed(clientIP) {
http.Error(w, "Access denied: your IP is not allowed", http.StatusForbidden)
return
}
}
tType := tconn.GetTunnelType()
if tType != "" && tType != protocol.TunnelTypeHTTP && tType != protocol.TunnelTypeHTTPS {
http.Error(w, "Tunnel does not accept HTTP traffic", http.StatusBadGateway)
@@ -328,6 +336,32 @@ func (h *Handler) extractSubdomain(host string) string {
return ""
}
// extractClientIP extracts the client IP from the request.
// It checks X-Forwarded-For and X-Real-IP headers first (for reverse proxy setups),
// then falls back to the remote address.
func (h *Handler) extractClientIP(r *http.Request) string {
// Check X-Forwarded-For header (may contain multiple IPs)
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
// Take the first IP (original client)
if idx := strings.Index(xff, ","); idx != -1 {
return strings.TrimSpace(xff[:idx])
}
return strings.TrimSpace(xff)
}
// Check X-Real-IP header
if xri := r.Header.Get("X-Real-IP"); xri != "" {
return strings.TrimSpace(xri)
}
// Fall back to remote address
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
func (h *Handler) serveHomePage(w http.ResponseWriter, r *http.Request) {
html := `<!DOCTYPE html>
<html lang="en">

View File

@@ -181,6 +181,15 @@ func (c *Connection) Handle() error {
c.tunnelConn.SetTunnelType(req.TunnelType)
c.tunnelType = req.TunnelType
if req.IPAccess != nil && (len(req.IPAccess.AllowIPs) > 0 || len(req.IPAccess.DenyIPs) > 0) {
c.tunnelConn.SetIPAccessControl(req.IPAccess.AllowIPs, req.IPAccess.DenyIPs)
c.logger.Info("IP access control configured",
zap.String("subdomain", subdomain),
zap.Strings("allow_ips", req.IPAccess.AllowIPs),
zap.Strings("deny_ips", req.IPAccess.DenyIPs),
)
}
c.logger.Info("Tunnel registered",
zap.String("subdomain", subdomain),
zap.String("tunnel_type", string(req.TunnelType)),

View File

@@ -32,6 +32,8 @@ type Proxy struct {
ctx context.Context
cancel context.CancelFunc
checkIPAccess func(ip string) bool
}
type trafficStats interface {
@@ -66,6 +68,11 @@ func NewProxy(ctx context.Context, port int, subdomain string, openStream func()
}
}
// SetIPAccessCheck sets the IP access control check function.
func (p *Proxy) SetIPAccessCheck(check func(ip string) bool) {
p.checkIPAccess = check
}
func (p *Proxy) Start() error {
addr := fmt.Sprintf("0.0.0.0:%d", p.port)
@@ -156,6 +163,17 @@ func (p *Proxy) handleConn(conn net.Conn) {
defer p.wg.Done()
defer conn.Close()
if p.checkIPAccess != nil {
clientIP := netutil.ExtractIP(conn.RemoteAddr().String())
if !p.checkIPAccess(clientIP) {
p.logger.Debug("IP access denied",
zap.String("ip", clientIP),
zap.Int("port", p.port),
)
return
}
}
if p.sem != nil {
select {
case p.sem <- struct{}{}:

View File

@@ -44,6 +44,10 @@ func (c *Connection) handleTCPTunnel(reader *bufio.Reader) error {
}
c.proxy = NewProxy(c.ctx, c.port, c.subdomain, openStream, c.tunnelConn, c.logger)
if c.tunnelConn != nil && c.tunnelConn.HasIPAccessControl() {
c.proxy.SetIPAccessCheck(c.tunnelConn.IsIPAllowed)
}
if err := c.proxy.Start(); err != nil {
return fmt.Errorf("failed to start tcp proxy: %w", err)
}

View File

@@ -7,6 +7,7 @@ import (
"time"
"drip/internal/server/metrics"
"drip/internal/shared/netutil"
"drip/internal/shared/protocol"
"github.com/gorilla/websocket"
"go.uber.org/zap"
@@ -29,6 +30,8 @@ type Connection struct {
bytesIn atomic.Int64
bytesOut atomic.Int64
activeConnections atomic.Int64
ipAccessChecker *netutil.IPAccessChecker
}
// NewConnection creates a new tunnel connection
@@ -182,6 +185,32 @@ func (c *Connection) GetActiveConnections() int64 {
return c.activeConnections.Load()
}
// SetIPAccessControl sets the IP access control rules for this tunnel.
func (c *Connection) SetIPAccessControl(allowCIDRs, denyIPs []string) {
c.mu.Lock()
defer c.mu.Unlock()
c.ipAccessChecker = netutil.NewIPAccessChecker(allowCIDRs, denyIPs)
}
// IsIPAllowed checks if the given IP address is allowed to access this tunnel.
func (c *Connection) IsIPAllowed(ip string) bool {
c.mu.RLock()
checker := c.ipAccessChecker
c.mu.RUnlock()
if checker == nil {
return true // No access control configured
}
return checker.IsAllowed(ip)
}
// HasIPAccessControl returns true if IP access control is configured.
func (c *Connection) HasIPAccessControl() bool {
c.mu.RLock()
defer c.mu.RUnlock()
return c.ipAccessChecker != nil && c.ipAccessChecker.HasRules()
}
// StartWritePump starts the write pump for sending messages
func (c *Connection) StartWritePump() {
if c.Conn == nil {

View File

@@ -0,0 +1,119 @@
package netutil
import (
"net"
"strings"
)
// IPAccessChecker checks if an IP address is allowed based on whitelist/blacklist rules.
type IPAccessChecker struct {
allowNets []*net.IPNet // Allowed CIDR ranges (whitelist)
denyIPs []net.IP // Denied IP addresses (blacklist)
hasAllow bool // Whether whitelist is configured
hasDeny bool // Whether blacklist is configured
}
// NewIPAccessChecker creates a new IP access checker from CIDR and IP lists.
// allowCIDRs: list of CIDR ranges to allow (e.g., "192.168.1.0/24", "10.0.0.0/8")
// denyIPs: list of IP addresses to deny (e.g., "1.2.3.4", "5.6.7.8")
func NewIPAccessChecker(allowCIDRs, denyIPs []string) *IPAccessChecker {
checker := &IPAccessChecker{}
// Parse allowed CIDRs
for _, cidr := range allowCIDRs {
cidr = strings.TrimSpace(cidr)
if cidr == "" {
continue
}
// If no "/" in the string, treat it as a single IP (/32 for IPv4, /128 for IPv6)
if !strings.Contains(cidr, "/") {
ip := net.ParseIP(cidr)
if ip != nil {
if ip.To4() != nil {
cidr = cidr + "/32"
} else {
cidr = cidr + "/128"
}
}
}
_, ipNet, err := net.ParseCIDR(cidr)
if err != nil {
continue
}
checker.allowNets = append(checker.allowNets, ipNet)
}
checker.hasAllow = len(checker.allowNets) > 0
// Parse denied IPs
for _, ipStr := range denyIPs {
ipStr = strings.TrimSpace(ipStr)
if ipStr == "" {
continue
}
ip := net.ParseIP(ipStr)
if ip != nil {
checker.denyIPs = append(checker.denyIPs, ip)
}
}
checker.hasDeny = len(checker.denyIPs) > 0
return checker
}
// IsAllowed checks if the given IP address is allowed.
// Rules:
// 1. If IP is in deny list, reject
// 2. If whitelist is configured and IP is not in whitelist, reject
// 3. Otherwise, allow
func (c *IPAccessChecker) IsAllowed(ipStr string) bool {
if c == nil || (!c.hasAllow && !c.hasDeny) {
return true // No rules configured, allow all
}
ip := net.ParseIP(ipStr)
if ip == nil {
return false // Invalid IP, reject
}
// Check deny list first (blacklist takes priority)
if c.hasDeny {
for _, denyIP := range c.denyIPs {
if ip.Equal(denyIP) {
return false
}
}
}
// Check allow list (whitelist)
if c.hasAllow {
for _, allowNet := range c.allowNets {
if allowNet.Contains(ip) {
return true
}
}
return false // Whitelist configured but IP not in it
}
return true // No whitelist, and not in blacklist
}
// HasRules returns true if any access control rules are configured.
func (c *IPAccessChecker) HasRules() bool {
return c != nil && (c.hasAllow || c.hasDeny)
}
// ExtractIP extracts the IP address from a remote address string (e.g., "192.168.1.1:12345").
func ExtractIP(remoteAddr string) string {
host, _, err := net.SplitHostPort(remoteAddr)
if err != nil {
// Maybe it's just an IP without port
if ip := net.ParseIP(remoteAddr); ip != nil {
return remoteAddr
}
return ""
}
return host
}

View File

@@ -8,6 +8,12 @@ type PoolCapabilities struct {
Version int `json:"version"` // Protocol version for pool features
}
// IPAccessControl defines IP-based access control rules for a tunnel
type IPAccessControl struct {
AllowIPs []string `json:"allow_ips,omitempty"` // Allowed IPs or CIDR ranges (whitelist)
DenyIPs []string `json:"deny_ips,omitempty"` // Denied IPs or CIDR ranges (blacklist)
}
// RegisterRequest is sent by client to register a tunnel
type RegisterRequest struct {
Token string `json:"token"` // Authentication token
@@ -19,6 +25,9 @@ type RegisterRequest struct {
ConnectionType string `json:"connection_type,omitempty"` // "primary" or empty for legacy
TunnelID string `json:"tunnel_id,omitempty"` // For data connections to join
PoolCapabilities *PoolCapabilities `json:"pool_capabilities,omitempty"` // Client pool capabilities
// Access control (optional)
IPAccess *IPAccessControl `json:"ip_access,omitempty"` // IP-based access control rules
}
// RegisterResponse is sent by server after successful registration