Merge pull request #15 from Gouryella/feat/http-auth

feat(cli): add proxy authentication support
This commit is contained in:
Gouryella
2026-01-13 11:05:21 +08:00
committed by GitHub
8 changed files with 318 additions and 90 deletions

View File

@@ -17,6 +17,7 @@ var (
localAddress string
allowIPs []string
denyIPs []string
authPass string
)
var httpCmd = &cobra.Command{
@@ -30,6 +31,7 @@ Example:
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
drip http 3000 --auth secret Enable proxy authentication with password
Configuration:
First time: Run 'drip config init' to save server and token
@@ -46,6 +48,7 @@ func init() {
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().StringVar(&authPass, "auth", "", "Password for proxy authentication")
httpCmd.Flags().BoolVar(&daemonMarker, "daemon-child", false, "Internal flag for daemon child process")
httpCmd.Flags().MarkHidden("daemon-child")
rootCmd.AddCommand(httpCmd)
@@ -76,6 +79,7 @@ func runHTTP(_ *cobra.Command, args []string) error {
Insecure: insecure,
AllowIPs: allowIPs,
DenyIPs: denyIPs,
AuthPass: authPass,
}
var daemon *DaemonInfo

View File

@@ -21,6 +21,7 @@ Example:
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
drip https 443 --auth secret Enable proxy authentication with password
Configuration:
First time: Run 'drip config init' to save server and token
@@ -37,6 +38,7 @@ func init() {
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().StringVar(&authPass, "auth", "", "Password for proxy authentication")
httpsCmd.Flags().BoolVar(&daemonMarker, "daemon-child", false, "Internal flag for daemon child process")
httpsCmd.Flags().MarkHidden("daemon-child")
rootCmd.AddCommand(httpsCmd)
@@ -67,6 +69,7 @@ func runHTTPS(_ *cobra.Command, args []string) error {
Insecure: insecure,
AllowIPs: allowIPs,
DenyIPs: denyIPs,
AuthPass: authPass,
}
var daemon *DaemonInfo

View File

@@ -27,6 +27,9 @@ type ConnectorConfig struct {
AllowIPs []string
DenyIPs []string
// Proxy authentication
AuthPass string
}
type TunnelClient interface {

View File

@@ -66,6 +66,8 @@ type PoolClient struct {
allowIPs []string
denyIPs []string
authPass string
}
// NewPoolClient creates a new pool client.
@@ -131,6 +133,7 @@ func NewPoolClient(cfg *ConnectorConfig, logger *zap.Logger) *PoolClient {
logger: logger,
allowIPs: cfg.AllowIPs,
denyIPs: cfg.DenyIPs,
authPass: cfg.AuthPass,
}
if tunnelType == protocol.TunnelTypeHTTP || tunnelType == protocol.TunnelTypeHTTPS {
@@ -168,6 +171,13 @@ func (c *PoolClient) Connect() error {
}
}
if c.authPass != "" {
req.ProxyAuth = &protocol.ProxyAuth{
Enabled: true,
Password: c.authPass,
}
}
payload, err := json.Marshal(req)
if err != nil {
_ = primaryConn.Close()

View File

@@ -3,7 +3,11 @@ package proxy
import (
"bufio"
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
"html"
"io"
"net"
"net/http"
@@ -32,6 +36,57 @@ var bufioReaderPool = sync.Pool{
}
const openStreamTimeout = 3 * time.Second
const authCookieName = "drip_auth"
const authSessionDuration = 24 * time.Hour
type authSession struct {
subdomain string
expiresAt time.Time
}
type authSessionStore struct {
mu sync.RWMutex
sessions map[string]*authSession
}
var sessionStore = &authSessionStore{
sessions: make(map[string]*authSession),
}
func (s *authSessionStore) create(subdomain string) string {
token := generateSessionToken()
s.mu.Lock()
s.sessions[token] = &authSession{
subdomain: subdomain,
expiresAt: time.Now().Add(authSessionDuration),
}
s.mu.Unlock()
return token
}
func (s *authSessionStore) validate(token, subdomain string) bool {
s.mu.RLock()
session, ok := s.sessions[token]
s.mu.RUnlock()
if !ok {
return false
}
if time.Now().After(session.expiresAt) {
s.mu.Lock()
delete(s.sessions, token)
s.mu.Unlock()
return false
}
return session.subdomain == subdomain
}
func generateSessionToken() string {
b := make([]byte, 32)
rand.Read(b)
hash := sha256.Sum256(b)
return hex.EncodeToString(hash[:])
}
type Handler struct {
manager *tunnel.Manager
@@ -45,13 +100,13 @@ var privateNetworks []*net.IPNet
func init() {
privateCIDRs := []string{
"127.0.0.0/8", // IPv4 loopback
"10.0.0.0/8", // RFC 1918 Class A
"172.16.0.0/12", // RFC 1918 Class B
"192.168.0.0/16", // RFC 1918 Class C
"::1/128", // IPv6 loopback
"fc00::/7", // IPv6 unique local
"fe80::/10", // IPv6 link-local
"127.0.0.0/8",
"10.0.0.0/8",
"172.16.0.0/12",
"192.168.0.0/16",
"::1/128",
"fc00::/7",
"fe80::/10",
}
for _, cidr := range privateCIDRs {
_, ipNet, _ := net.ParseCIDR(cidr)
@@ -107,6 +162,18 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
}
// Check proxy authentication
if tconn.HasProxyAuth() {
if r.URL.Path == "/_drip/login" {
h.handleProxyLogin(w, r, tconn, subdomain)
return
}
if !h.isProxyAuthenticated(r, subdomain) {
h.serveLoginPage(w, r, subdomain, "")
return
}
}
tType := tconn.GetTunnelType()
if tType != "" && tType != protocol.TunnelTypeHTTP && tType != protocol.TunnelTypeHTTPS {
http.Error(w, "Tunnel does not accept HTTP traffic", http.StatusBadGateway)
@@ -638,3 +705,147 @@ type bufferedReadWriteCloser struct {
func (b *bufferedReadWriteCloser) Read(p []byte) (int, error) {
return b.Reader.Read(p)
}
func (h *Handler) isProxyAuthenticated(r *http.Request, subdomain string) bool {
cookie, err := r.Cookie(authCookieName + "_" + subdomain)
if err != nil {
return false
}
return sessionStore.validate(cookie.Value, subdomain)
}
func (h *Handler) handleProxyLogin(w http.ResponseWriter, r *http.Request, tconn *tunnel.Connection, subdomain string) {
if r.Method != http.MethodPost {
h.serveLoginPage(w, r, subdomain, "")
return
}
if err := r.ParseForm(); err != nil {
h.serveLoginPage(w, r, subdomain, "Invalid form data")
return
}
password := r.FormValue("password")
if !tconn.ValidateProxyAuth(password) {
h.serveLoginPage(w, r, subdomain, "Invalid password")
return
}
token := sessionStore.create(subdomain)
http.SetCookie(w, &http.Cookie{
Name: authCookieName + "_" + subdomain,
Value: token,
Path: "/",
MaxAge: int(authSessionDuration.Seconds()),
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
redirectURL := r.FormValue("redirect")
if redirectURL == "" || redirectURL == "/_drip/login" {
redirectURL = "/"
}
http.Redirect(w, r, redirectURL, http.StatusSeeOther)
}
func (h *Handler) serveLoginPage(w http.ResponseWriter, r *http.Request, subdomain string, errorMsg string) {
redirectURL := r.URL.Path
if r.URL.RawQuery != "" {
redirectURL += "?" + r.URL.RawQuery
}
if redirectURL == "/_drip/login" {
redirectURL = "/"
}
errorHTML := ""
if errorMsg != "" {
errorHTML = fmt.Sprintf(`<p class="error">%s</p>`, html.EscapeString(errorMsg))
}
safeRedirectURL := html.EscapeString(redirectURL)
htmlContent := fmt.Sprintf(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>%s - Drip</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #fff;
color: #24292f;
line-height: 1.6;
}
.container { max-width: 720px; margin: 0 auto; padding: 48px 24px; }
header { margin-bottom: 48px; }
h1 { font-size: 28px; font-weight: 600; margin-bottom: 8px; }
h1 span { margin-right: 8px; }
.desc { color: #57606a; font-size: 16px; }
p { margin-bottom: 24px; }
.error { color: #cf222e; margin-bottom: 16px; }
.input-wrap {
position: relative;
background: #f6f8fa;
border: 1px solid #d0d7de;
border-radius: 6px;
margin-bottom: 12px;
display: flex;
}
.input-wrap input {
flex: 1;
margin: 0;
padding: 12px 16px;
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
font-size: 14px;
background: transparent;
border: none;
outline: none;
}
.input-wrap button {
background: #24292f;
color: #fff;
border: none;
padding: 8px 16px;
margin: 4px;
border-radius: 4px;
font-size: 14px;
cursor: pointer;
}
.input-wrap button:hover { background: #32383f; }
footer { margin-top: 48px; padding-top: 24px; border-top: 1px solid #d0d7de; }
footer a { color: #57606a; text-decoration: none; font-size: 14px; }
footer a:hover { color: #0969da; }
</style>
</head>
<body>
<div class="container">
<header>
<h1><span>🔒</span>%s</h1>
<p class="desc">This tunnel is password protected</p>
</header>
%s
<form method="POST" action="/_drip/login">
<input type="hidden" name="redirect" value="%s" />
<div class="input-wrap">
<input type="password" name="password" placeholder="Enter password" required autofocus />
<button type="submit">Continue</button>
</div>
</form>
<footer>
<a href="https://github.com/Gouryella/drip" target="_blank">GitHub</a>
</footer>
</div>
</body>
</html>`, subdomain, subdomain, errorHTML, safeRedirectURL)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate")
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte(htmlContent))
}

View File

@@ -190,6 +190,13 @@ func (c *Connection) Handle() error {
)
}
if req.ProxyAuth != nil && req.ProxyAuth.Enabled {
c.tunnelConn.SetProxyAuth(req.ProxyAuth)
c.logger.Info("Proxy authentication configured",
zap.String("subdomain", subdomain),
)
}
c.logger.Info("Tunnel registered",
zap.String("subdomain", subdomain),
zap.String("tunnel_type", string(req.TunnelType)),

View File

@@ -13,7 +13,6 @@ import (
"go.uber.org/zap"
)
// Connection represents a tunnel connection from a client
type Connection struct {
Subdomain string
Conn *websocket.Conn
@@ -22,19 +21,19 @@ type Connection struct {
LastActive time.Time
mu sync.RWMutex
logger *zap.Logger
closed atomic.Bool // Use atomic for lock-free hot path checks
closed atomic.Bool
tunnelType protocol.TunnelType
openStream func() (net.Conn, error)
remoteIP string // Client IP for rate limiting tracking
remoteIP string
bytesIn atomic.Int64
bytesOut atomic.Int64
activeConnections atomic.Int64
ipAccessChecker *netutil.IPAccessChecker
proxyAuth *protocol.ProxyAuth
}
// NewConnection creates a new tunnel connection
func NewConnection(subdomain string, conn *websocket.Conn, logger *zap.Logger) *Connection {
return &Connection{
Subdomain: subdomain,
@@ -46,9 +45,7 @@ func NewConnection(subdomain string, conn *websocket.Conn, logger *zap.Logger) *
}
}
// Send sends data through the WebSocket connection
func (c *Connection) Send(data []byte) error {
// Lock-free check using atomic - avoids RLock contention on hot path
if c.closed.Load() {
return ErrConnectionClosed
}
@@ -61,25 +58,21 @@ func (c *Connection) Send(data []byte) error {
}
}
// UpdateActivity updates the last activity timestamp
func (c *Connection) UpdateActivity() {
c.mu.Lock()
defer c.mu.Unlock()
c.LastActive = time.Now()
}
// IsAlive checks if the connection is still alive based on last activity
func (c *Connection) IsAlive(timeout time.Duration) bool {
c.mu.RLock()
defer c.mu.RUnlock()
return time.Since(c.LastActive) < timeout
}
// Close closes the connection and all associated channels
func (c *Connection) Close() {
// Use atomic swap to ensure only one goroutine closes
if c.closed.Swap(true) {
return // Already closed
return
}
c.mu.Lock()
@@ -89,46 +82,37 @@ func (c *Connection) Close() {
close(c.SendCh)
if c.Conn != nil {
// Send close message
c.Conn.WriteMessage(websocket.CloseMessage,
websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
c.Conn.Close()
}
c.logger.Info("Connection closed",
zap.String("subdomain", c.Subdomain),
)
c.logger.Info("Connection closed", zap.String("subdomain", c.Subdomain))
}
// IsClosed returns whether the connection is closed
func (c *Connection) IsClosed() bool {
return c.closed.Load() // Lock-free check
return c.closed.Load()
}
// SetTunnelType sets the tunnel type.
func (c *Connection) SetTunnelType(tType protocol.TunnelType) {
c.mu.Lock()
c.tunnelType = tType
c.mu.Unlock()
}
// GetTunnelType returns the tunnel type.
func (c *Connection) GetTunnelType() protocol.TunnelType {
c.mu.RLock()
defer c.mu.RUnlock()
return c.tunnelType
}
// SetOpenStream registers a stream opener for this tunnel.
func (c *Connection) SetOpenStream(open func() (net.Conn, error)) {
c.mu.Lock()
c.openStream = open
c.mu.Unlock()
}
// OpenStream opens a new mux stream to the tunnel client.
func (c *Connection) OpenStream() (net.Conn, error) {
// Lock-free closed check
if c.closed.Load() {
return nil, ErrConnectionClosed
}
@@ -161,13 +145,8 @@ func (c *Connection) AddBytesOut(n int64) {
metrics.TunnelBytesSent.WithLabelValues(c.Subdomain, c.Subdomain, c.GetTunnelType().String()).Add(float64(n))
}
func (c *Connection) GetBytesIn() int64 {
return c.bytesIn.Load()
}
func (c *Connection) GetBytesOut() int64 {
return c.bytesOut.Load()
}
func (c *Connection) GetBytesIn() int64 { return c.bytesIn.Load() }
func (c *Connection) GetBytesOut() int64 { return c.bytesOut.Load() }
func (c *Connection) IncActiveConnections() {
c.activeConnections.Add(1)
@@ -181,37 +160,60 @@ func (c *Connection) DecActiveConnections() {
metrics.TunnelActiveConnections.WithLabelValues(c.Subdomain, c.Subdomain, c.GetTunnelType().String()).Dec()
}
func (c *Connection) GetActiveConnections() int64 {
return c.activeConnections.Load()
}
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 true
}
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) SetProxyAuth(auth *protocol.ProxyAuth) {
c.mu.Lock()
defer c.mu.Unlock()
c.proxyAuth = auth
}
func (c *Connection) GetProxyAuth() *protocol.ProxyAuth {
c.mu.RLock()
defer c.mu.RUnlock()
return c.proxyAuth
}
func (c *Connection) HasProxyAuth() bool {
c.mu.RLock()
defer c.mu.RUnlock()
return c.proxyAuth != nil && c.proxyAuth.Enabled
}
func (c *Connection) ValidateProxyAuth(password string) bool {
c.mu.RLock()
auth := c.proxyAuth
c.mu.RUnlock()
if auth == nil || !auth.Enabled {
return true
}
return auth.Password == password
}
func (c *Connection) StartWritePump() {
if c.Conn == nil {
go func() {
@@ -241,15 +243,11 @@ func (c *Connection) StartWritePump() {
c.Conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if err := c.Conn.WriteMessage(websocket.TextMessage, message); err != nil {
c.logger.Error("Write error",
zap.String("subdomain", c.Subdomain),
zap.Error(err),
)
c.logger.Error("Write error", zap.String("subdomain", c.Subdomain), zap.Error(err))
return
}
case <-ticker.C:
// Send ping to keep connection alive
c.Conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if err := c.Conn.WriteMessage(websocket.PingMessage, nil); err != nil {
return

View File

@@ -2,68 +2,60 @@ package protocol
import json "github.com/goccy/go-json"
// PoolCapabilities advertises client connection pool capabilities
type PoolCapabilities struct {
MaxDataConns int `json:"max_data_conns"` // Maximum data connections client supports
Version int `json:"version"` // Protocol version for pool features
MaxDataConns int `json:"max_data_conns"`
Version int `json:"version"`
}
// 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)
AllowIPs []string `json:"allow_ips,omitempty"`
DenyIPs []string `json:"deny_ips,omitempty"`
}
type ProxyAuth struct {
Enabled bool `json:"enabled"`
Password string `json:"password,omitempty"`
}
// RegisterRequest is sent by client to register a tunnel
type RegisterRequest struct {
Token string `json:"token"` // Authentication token
CustomSubdomain string `json:"custom_subdomain"` // Optional custom subdomain
TunnelType TunnelType `json:"tunnel_type"` // http, tcp, udp
LocalPort int `json:"local_port"` // Local port to forward to
// Connection pool fields (optional, for multi-connection support)
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
Token string `json:"token"`
CustomSubdomain string `json:"custom_subdomain"`
TunnelType TunnelType `json:"tunnel_type"`
LocalPort int `json:"local_port"`
ConnectionType string `json:"connection_type,omitempty"`
TunnelID string `json:"tunnel_id,omitempty"`
PoolCapabilities *PoolCapabilities `json:"pool_capabilities,omitempty"`
IPAccess *IPAccessControl `json:"ip_access,omitempty"`
ProxyAuth *ProxyAuth `json:"proxy_auth,omitempty"`
}
// RegisterResponse is sent by server after successful registration
type RegisterResponse struct {
Subdomain string `json:"subdomain"` // Assigned subdomain
Port int `json:"port,omitempty"` // Assigned TCP port (for TCP tunnels)
URL string `json:"url"` // Full tunnel URL
Message string `json:"message"` // Success message
// Connection pool fields (optional, for multi-connection support)
TunnelID string `json:"tunnel_id,omitempty"` // Unique tunnel identifier
SupportsDataConn bool `json:"supports_data_conn,omitempty"` // Server supports multi-connection
RecommendedConns int `json:"recommended_conns,omitempty"` // Suggested data connection count
Subdomain string `json:"subdomain"`
Port int `json:"port,omitempty"`
URL string `json:"url"`
Message string `json:"message"`
TunnelID string `json:"tunnel_id,omitempty"`
SupportsDataConn bool `json:"supports_data_conn,omitempty"`
RecommendedConns int `json:"recommended_conns,omitempty"`
}
// DataConnectRequest is sent by data connections to join a tunnel
type DataConnectRequest struct {
TunnelID string `json:"tunnel_id"` // Tunnel to join
Token string `json:"token"` // Same auth token as primary
ConnectionID string `json:"connection_id"` // Unique connection identifier
TunnelID string `json:"tunnel_id"`
Token string `json:"token"`
ConnectionID string `json:"connection_id"`
}
// DataConnectResponse acknowledges data connection
type DataConnectResponse struct {
Accepted bool `json:"accepted"` // Whether connection was accepted
ConnectionID string `json:"connection_id"` // Echoed connection ID
Message string `json:"message,omitempty"` // Optional message
Accepted bool `json:"accepted"`
ConnectionID string `json:"connection_id"`
Message string `json:"message,omitempty"`
}
// ErrorMessage represents an error
type ErrorMessage struct {
Code string `json:"code"` // Error code
Message string `json:"message"` // Error message
Code string `json:"code"`
Message string `json:"message"`
}
// Marshal helpers for control plane messages (JSON encoding)
func MarshalJSON(v interface{}) ([]byte, error) {
return json.Marshal(v)
}