feat: Add Bearer Token authentication support and optimize code structure

- Add Bearer Token authentication, supporting tunnel access control via the --auth-bearer parameter
- Refactor large modules into smaller, more focused components to improve code maintainability
- Update dependency versions, including golang.org/x/crypto, golang.org/x/net, etc.
- Add SilenceUsage and SilenceErrors configuration for all CLI commands
- Modify connector configuration structure to support the new authentication method
- Update recent change log in README with new feature descriptions

BREAKING CHANGE: Authentication via Bearer Token is now supported, requiring the new --auth-bearer parameter
This commit is contained in:
zhiqing
2026-01-29 14:40:53 +08:00
parent 3256a3486f
commit 307cf8e6cc
50 changed files with 3338 additions and 1611 deletions

View File

@@ -2,12 +2,8 @@ package proxy
import (
"bufio"
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"crypto/subtle"
"fmt"
"html"
"io"
"net"
"net/http"
@@ -16,18 +12,14 @@ import (
"sync"
"time"
json "github.com/goccy/go-json"
"github.com/gorilla/websocket"
"go.uber.org/zap"
"drip/internal/server/tunnel"
"drip/internal/shared/httputil"
"drip/internal/shared/netutil"
"drip/internal/shared/pool"
"drip/internal/shared/protocol"
"drip/internal/shared/wsutil"
"github.com/prometheus/client_golang/prometheus/promhttp"
"go.uber.org/zap"
)
// bufio.Reader pool to reduce allocations on hot path
@@ -38,56 +30,14 @@ 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 HandlerConfig struct {
Manager *tunnel.Manager
Logger *zap.Logger
ServerDomain string
TunnelDomain string
AuthToken string
MetricsToken string
}
type Handler struct {
@@ -113,32 +63,14 @@ type WSConnectionHandler interface {
HandleWSConnection(conn net.Conn, remoteAddr string)
}
var privateNetworks []*net.IPNet
func init() {
privateCIDRs := []string{
"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)
privateNetworks = append(privateNetworks, ipNet)
}
}
func NewHandler(manager *tunnel.Manager, logger *zap.Logger, serverDomain, tunnelDomain string, authToken string, metricsToken string) *Handler {
func NewHandler(cfg HandlerConfig) *Handler {
return &Handler{
manager: manager,
logger: logger,
serverDomain: serverDomain,
tunnelDomain: tunnelDomain,
authToken: authToken,
metricsToken: metricsToken,
manager: cfg.Manager,
logger: cfg.Logger,
serverDomain: cfg.ServerDomain,
tunnelDomain: cfg.TunnelDomain,
authToken: cfg.AuthToken,
metricsToken: cfg.MetricsToken,
wsUpgrader: websocket.Upgrader{
ReadBufferSize: 256 * 1024,
WriteBufferSize: 256 * 1024,
@@ -253,22 +185,38 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
if tconn.HasIPAccessControl() {
clientIP := h.extractClientIP(r)
clientIP := netutil.ExtractClientIP(r)
if !tconn.IsIPAllowed(clientIP) {
http.Error(w, "Access denied: your IP is not allowed", http.StatusForbidden)
return
}
}
// Check proxy authentication
if tconn.HasProxyAuth() {
if r.URL.Path == "/_drip/login" {
h.handleProxyLogin(w, r, tconn, subdomain)
if auth := tconn.GetProxyAuth(); auth != nil && auth.Enabled {
clientIP := netutil.ExtractClientIP(r)
if authLimiter.isRateLimited(clientIP) {
w.Header().Set("Retry-After", "60")
http.Error(w, "Too many failed authentication attempts. Please try again later.", http.StatusTooManyRequests)
return
}
if !h.isProxyAuthenticated(r, subdomain) {
h.serveLoginPage(w, r, subdomain, "")
return
if isBearerProxyAuth(auth) {
if !h.isBearerAuthenticated(r, auth) {
authLimiter.recordFailure(clientIP)
h.serveBearerAuthRequired(w, "drip")
return
}
authLimiter.resetFailures(clientIP)
} else {
if r.URL.Path == "/_drip/login" {
h.handleProxyLoginWithRateLimit(w, r, tconn, subdomain, clientIP)
return
}
if !h.isProxyAuthenticated(r, subdomain) {
h.serveLoginPage(w, r, subdomain, "")
return
}
}
}
@@ -283,14 +231,14 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
if httputil.IsWebSocketUpgrade(r) {
if h.isWebSocketUpgrade(r) {
h.handleWebSocket(w, r, tconn)
return
}
stream, err := h.openStreamWithTimeout(tconn)
if err != nil {
w.Header().Set("Connection", "close")
httputil.SetCloseConnection(w)
http.Error(w, "Tunnel unavailable", http.StatusBadGateway)
return
}
@@ -305,7 +253,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
)
if err := r.Write(countingStream); err != nil {
w.Header().Set("Connection", "close")
httputil.SetCloseConnection(w)
_ = r.Body.Close()
http.Error(w, "Forward failed", http.StatusBadGateway)
return
@@ -316,7 +264,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
resp, err := http.ReadResponse(reader, r)
if err != nil {
bufioReaderPool.Put(reader)
w.Header().Set("Connection", "close")
httputil.SetCloseConnection(w)
http.Error(w, "Read response failed", http.StatusBadGateway)
return
}
@@ -334,7 +282,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodHead || statusCode == http.StatusNoContent || statusCode == http.StatusNotModified {
if resp.ContentLength >= 0 {
w.Header().Set("Content-Length", fmt.Sprintf("%d", resp.ContentLength))
httputil.SetContentLength(w, resp.ContentLength)
} else {
w.Header().Del("Content-Length")
}
@@ -343,7 +291,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
if resp.ContentLength >= 0 {
w.Header().Set("Content-Length", fmt.Sprintf("%d", resp.ContentLength))
httputil.SetContentLength(w, resp.ContentLength)
} else {
w.Header().Del("Content-Length")
}
@@ -396,58 +344,6 @@ func (h *Handler) openStreamWithTimeout(tconn *tunnel.Connection) (net.Conn, err
}
}
func (h *Handler) handleWebSocket(w http.ResponseWriter, r *http.Request, tconn *tunnel.Connection) {
stream, err := h.openStreamWithTimeout(tconn)
if err != nil {
http.Error(w, "Tunnel unavailable", http.StatusBadGateway)
return
}
tconn.IncActiveConnections()
hj, ok := w.(http.Hijacker)
if !ok {
stream.Close()
tconn.DecActiveConnections()
http.Error(w, "WebSocket not supported", http.StatusInternalServerError)
return
}
clientConn, clientBuf, err := hj.Hijack()
if err != nil {
stream.Close()
tconn.DecActiveConnections()
http.Error(w, "Failed to hijack connection", http.StatusInternalServerError)
return
}
if err := r.Write(stream); err != nil {
stream.Close()
clientConn.Close()
tconn.DecActiveConnections()
return
}
go func() {
defer stream.Close()
defer clientConn.Close()
defer tconn.DecActiveConnections()
var clientRW io.ReadWriteCloser = clientConn
if clientBuf != nil && clientBuf.Reader.Buffered() > 0 {
clientRW = &bufferedReadWriteCloser{
Reader: clientBuf.Reader,
Conn: clientConn,
}
}
_ = netutil.PipeWithCallbacks(context.Background(), stream, clientRW,
func(n int64) { tconn.AddBytesOut(n) },
func(n int64) { tconn.AddBytesIn(n) },
)
}()
}
func (h *Handler) copyResponseHeaders(dst http.Header, src http.Header, proxyHost string) {
for key, values := range src {
canonicalKey := http.CanonicalHeaderKey(key)
@@ -530,571 +426,18 @@ func (h *Handler) extractSubdomain(host string) (string, subdomainResult) {
return "", subdomainNotFound
}
// extractClientIP extracts the client IP from the request.
// It only trusts X-Forwarded-For and X-Real-IP headers when the request
// comes from a private/loopback network (typical reverse proxy setup).
func (h *Handler) extractClientIP(r *http.Request) string {
// First, get the direct remote address
remoteIP := h.extractRemoteIP(r.RemoteAddr)
// Only trust proxy headers if the request comes from a private network
if isPrivateIP(remoteIP) {
// 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)
}
func (h *Handler) validateMetricsAuth(w http.ResponseWriter, r *http.Request, realm string) bool {
if h.metricsToken == "" {
return true
}
// Fall back to remote address
return remoteIP
}
token := extractBearerToken(r.Header.Get("Authorization"))
// extractRemoteIP extracts the IP address from a remote address string (host:port format).
func (h *Handler) extractRemoteIP(remoteAddr string) string {
host, _, err := net.SplitHostPort(remoteAddr)
if err != nil {
return remoteAddr
}
return host
}
// isPrivateIP checks if the given IP is a private/loopback address.
func isPrivateIP(ip string) bool {
parsedIP := net.ParseIP(ip)
if parsedIP == nil {
if subtle.ConstantTimeCompare([]byte(token), []byte(h.metricsToken)) != 1 {
w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Bearer realm="%s"`, realm))
http.Error(w, "Unauthorized: provide metrics token via 'Authorization: Bearer <token>' header", http.StatusUnauthorized)
return false
}
for _, network := range privateNetworks {
if network.Contains(parsedIP) {
return true
}
}
return false
}
func (h *Handler) serveHomePage(w http.ResponseWriter, r *http.Request) {
html := `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Drip - Your Tunnel, Your Domain, Anywhere</title>
` + faviconLink + `
<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; }
h2 { font-size: 18px; font-weight: 600; margin: 32px 0 12px; }
.code-wrap {
position: relative;
background: #f6f8fa;
border: 1px solid #d0d7de;
border-radius: 6px;
margin-bottom: 12px;
}
.code-wrap pre {
margin: 0;
padding: 12px 16px;
padding-right: 60px;
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
font-size: 14px;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-all;
}
.copy-btn {
position: absolute;
top: 8px;
right: 8px;
background: #fff;
border: 1px solid #d0d7de;
border-radius: 6px;
padding: 4px 6px;
cursor: pointer;
color: #57606a;
display: flex;
align-items: center;
justify-content: center;
}
.copy-btn:hover { background: #f3f4f6; }
.copy-btn svg { width: 16px; height: 16px; }
.copy-btn .check { display: none; color: #1a7f37; }
.copy-btn.copied .copy { display: none; }
.copy-btn.copied .check { display: block; }
.links { margin-top: 32px; display: flex; gap: 24px; flex-wrap: wrap; }
.links a { color: #0969da; text-decoration: none; font-size: 14px; }
.links a:hover { text-decoration: underline; }
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>Drip</h1>
<p class="desc">Your Tunnel, Your Domain, Anywhere</p>
</header>
<p>A self-hosted tunneling solution to securely expose your services to the internet.</p>
<h2>Install</h2>
<div class="code-wrap">
<pre>bash &lt;(curl -fsSL https://driptunnel.app/install.sh)</pre>
<button class="copy-btn" onclick="copy(this)">
<svg class="copy" viewBox="0 0 16 16" fill="currentColor"><path d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 9.25 16h-7.5A1.75 1.75 0 0 1 0 14.25Z"></path><path d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0 1 14.25 11h-7.5A1.75 1.75 0 0 1 5 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z"></path></svg>
<svg class="check" viewBox="0 0 16 16" fill="currentColor"><path d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z"></path></svg>
</button>
</div>
<h2>Usage</h2>
<div class="code-wrap">
<pre>drip http 3000</pre>
<button class="copy-btn" onclick="copy(this)">
<svg class="copy" viewBox="0 0 16 16" fill="currentColor"><path d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 9.25 16h-7.5A1.75 1.75 0 0 1 0 14.25Z"></path><path d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0 1 14.25 11h-7.5A1.75 1.75 0 0 1 5 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z"></path></svg>
<svg class="check" viewBox="0 0 16 16" fill="currentColor"><path d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z"></path></svg>
</button>
</div>
<div class="code-wrap">
<pre>drip https 443</pre>
<button class="copy-btn" onclick="copy(this)">
<svg class="copy" viewBox="0 0 16 16" fill="currentColor"><path d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 9.25 16h-7.5A1.75 1.75 0 0 1 0 14.25Z"></path><path d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0 1 14.25 11h-7.5A1.75 1.75 0 0 1 5 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z"></path></svg>
<svg class="check" viewBox="0 0 16 16" fill="currentColor"><path d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z"></path></svg>
</button>
</div>
<div class="code-wrap">
<pre>drip tcp 5432</pre>
<button class="copy-btn" onclick="copy(this)">
<svg class="copy" viewBox="0 0 16 16" fill="currentColor"><path d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 9.25 16h-7.5A1.75 1.75 0 0 1 0 14.25Z"></path><path d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0 1 14.25 11h-7.5A1.75 1.75 0 0 1 5 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z"></path></svg>
<svg class="check" viewBox="0 0 16 16" fill="currentColor"><path d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z"></path></svg>
</button>
</div>
<div class="links">
<a href="/health">Health Check</a>
<a href="/stats">Statistics</a>
<a href="/metrics">Prometheus Metrics</a>
</div>
<footer>
<a href="https://github.com/Gouryella/drip" target="_blank">GitHub</a>
</footer>
</div>
<script>
function copy(btn) {
const text = btn.previousElementSibling.textContent;
navigator.clipboard.writeText(text).then(() => {
btn.classList.add('copied');
setTimeout(() => { btn.classList.remove('copied'); }, 2000);
});
}
</script>
</body>
</html>`
data := []byte(html)
w.Header().Set("Content-Type", "text/html")
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(data)))
w.Write(data)
}
func (h *Handler) serveTunnelNotFound(w http.ResponseWriter, r *http.Request) {
html := `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>404 - Tunnel Not Found</title>
` + faviconLink + `
<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: 16px; }
.info-box {
background: #f6f8fa;
border: 1px solid #d0d7de;
border-radius: 6px;
padding: 16px;
margin: 24px 0;
}
.info-box ul {
margin: 12px 0 0 20px;
color: #57606a;
}
.info-box li { margin-bottom: 8px; }
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>Tunnel Not Found</h1>
<p class="desc">The requested tunnel does not exist or has been closed.</p>
</header>
<div class="info-box">
<p>This could happen because:</p>
<ul>
<li>The tunnel was never created</li>
<li>The tunnel has been closed by the owner</li>
<li>The tunnel URL is incorrect</li>
</ul>
</div>
<p>If you are the tunnel owner, please restart your tunnel client.</p>
<footer>
<a href="https://github.com/Gouryella/drip" target="_blank">GitHub</a>
</footer>
</div>
</body>
</html>`
data := []byte(html)
w.Header().Set("Content-Type", "text/html")
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(data)))
w.WriteHeader(http.StatusNotFound)
w.Write(data)
}
func (h *Handler) serveHealth(w http.ResponseWriter, r *http.Request) {
health := map[string]interface{}{
"status": "ok",
"active_tunnels": h.manager.Count(),
"timestamp": time.Now().Unix(),
}
data, err := json.Marshal(health)
if err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(data)))
w.Write(data)
}
func (h *Handler) serveStats(w http.ResponseWriter, r *http.Request) {
if h.metricsToken != "" {
// Only accept token via Authorization header (Bearer token)
// URL query parameters are insecure (logged, cached, visible in browser history)
var token string
authHeader := r.Header.Get("Authorization")
if strings.HasPrefix(authHeader, "Bearer ") {
token = strings.TrimPrefix(authHeader, "Bearer ")
}
if token != h.metricsToken {
w.Header().Set("WWW-Authenticate", `Bearer realm="stats"`)
http.Error(w, "Unauthorized: provide metrics token via 'Authorization: Bearer <token>' header", http.StatusUnauthorized)
return
}
}
connections := h.manager.List()
// Pre-allocate slice to avoid O(n²) reallocations
tunnelStats := make([]map[string]interface{}, 0, len(connections))
for _, conn := range connections {
if conn == nil {
continue
}
tunnelStats = append(tunnelStats, map[string]interface{}{
"subdomain": conn.Subdomain,
"tunnel_type": string(conn.GetTunnelType()),
"last_active": conn.LastActive.Unix(),
"bytes_in": conn.GetBytesIn(),
"bytes_out": conn.GetBytesOut(),
"active_connections": conn.GetActiveConnections(),
"total_bytes": conn.GetBytesIn() + conn.GetBytesOut(),
})
}
stats := map[string]interface{}{
"total_tunnels": len(tunnelStats),
"tunnels": tunnelStats,
}
data, err := json.Marshal(stats)
if err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(data)))
w.Write(data)
}
func (h *Handler) serveMetrics(w http.ResponseWriter, r *http.Request) {
if h.metricsToken != "" {
// Only accept token via Authorization header (Bearer token)
var token string
authHeader := r.Header.Get("Authorization")
if strings.HasPrefix(authHeader, "Bearer ") {
token = strings.TrimPrefix(authHeader, "Bearer ")
}
if token != h.metricsToken {
w.Header().Set("WWW-Authenticate", `Bearer realm="metrics"`)
http.Error(w, "Unauthorized: provide metrics token via 'Authorization: Bearer <token>' header", http.StatusUnauthorized)
return
}
}
// Serve Prometheus metrics
promhttp.Handler().ServeHTTP(w, r)
}
type bufferedReadWriteCloser struct {
*bufio.Reader
net.Conn
}
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>
`+faviconLink+`
<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))
}
// handleTunnelWebSocket handles WebSocket connections for tunnel clients
func (h *Handler) handleTunnelWebSocket(w http.ResponseWriter, r *http.Request) {
// Check if WSS transport is allowed
if !h.IsTransportAllowed("wss") {
http.Error(w, "WebSocket transport not allowed on this server", http.StatusForbidden)
return
}
if h.wsConnHandler == nil {
http.Error(w, "WebSocket tunnel not configured", http.StatusServiceUnavailable)
return
}
ws, err := h.wsUpgrader.Upgrade(w, r, nil)
if err != nil {
h.logger.Error("WebSocket upgrade failed", zap.Error(err))
return
}
// Configure WebSocket for tunnel use
ws.SetReadLimit(protocol.MaxFrameSize + protocol.FrameHeaderSize + 1024)
// Extract real client IP (support CDN headers)
remoteAddr := h.extractClientIP(r)
h.logger.Info("WebSocket tunnel connection established",
zap.String("remote_addr", remoteAddr),
)
// Wrap WebSocket as net.Conn with ping loop for CDN keep-alive
conn := wsutil.NewConnWithPing(ws, 30*time.Second)
// Handle the connection using the registered handler
h.wsConnHandler.HandleWSConnection(conn, remoteAddr)
}
// serveDiscovery returns server capabilities for client auto-detection
func (h *Handler) serveDiscovery(w http.ResponseWriter, r *http.Request) {
transports := h.allowedTransports
if len(transports) == 0 {
transports = []string{"tcp", "wss"}
}
tunnelTypes := h.allowedTunnelTypes
if len(tunnelTypes) == 0 {
tunnelTypes = []string{"http", "https", "tcp"}
}
response := map[string]interface{}{
"transports": transports,
"tunnel_types": tunnelTypes,
"preferred": h.GetPreferredTransport(),
"version": "1",
}
data, err := json.Marshal(response)
if err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-cache")
w.Write(data)
return true
}