Initial vibeproxy implementation
Self-contained HTTP/HTTPS/TCP/UDP reverse proxy manager written in Go, with SQLite storage, a Bootstrap 5 admin UI (Go templates, no JS framework), and a REST API under /api/v1. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QTXFrNP1imCW7jtfL5SZrw
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/owen/vibeproxy/internal/runtime"
|
||||
)
|
||||
|
||||
// ClientIP returns the real client IP for r. If the immediate peer is a
|
||||
// trusted proxy (per snap.TrustedProxies), the right-most untrusted entry
|
||||
// in X-Forwarded-For is used; otherwise X-Forwarded-For is ignored
|
||||
// entirely and the TCP peer address is authoritative. This is the only
|
||||
// place XFF is trusted, and only for configured networks.
|
||||
func ClientIP(r *http.Request, snap *runtime.Snapshot) string {
|
||||
peerIP, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
peerIP = r.RemoteAddr
|
||||
}
|
||||
ip := net.ParseIP(peerIP)
|
||||
if ip == nil || snap == nil || !snap.IsTrustedProxy(ip) {
|
||||
return peerIP
|
||||
}
|
||||
|
||||
xff := r.Header.Get("X-Forwarded-For")
|
||||
if xff == "" {
|
||||
return peerIP
|
||||
}
|
||||
parts := strings.Split(xff, ",")
|
||||
for i := len(parts) - 1; i >= 0; i-- {
|
||||
candidate := strings.TrimSpace(parts[i])
|
||||
cip := net.ParseIP(candidate)
|
||||
if cip == nil {
|
||||
continue
|
||||
}
|
||||
if snap.IsTrustedProxy(cip) {
|
||||
continue // still a known proxy hop, keep walking left
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
return peerIP
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/owen/vibeproxy/internal/access"
|
||||
"github.com/owen/vibeproxy/internal/auth"
|
||||
"github.com/owen/vibeproxy/internal/certificates"
|
||||
"github.com/owen/vibeproxy/internal/logging"
|
||||
"github.com/owen/vibeproxy/internal/metrics"
|
||||
"github.com/owen/vibeproxy/internal/models"
|
||||
"github.com/owen/vibeproxy/internal/runtime"
|
||||
)
|
||||
|
||||
// maxRequestBodyBytes bounds request bodies to protect the proxy itself;
|
||||
// it is generous enough for large uploads while still being a hard limit.
|
||||
const maxRequestBodyBytes = 5 << 30 // 5 GiB
|
||||
|
||||
// Handler is the single entry point for both the HTTP and HTTPS listeners.
|
||||
// It resolves the target proxy host, enforces access control and
|
||||
// force-HTTPS/redirect rules, picks a healthy backend, and proxies the
|
||||
// request — or serves an ACME HTTP-01 challenge.
|
||||
type Handler struct {
|
||||
store *runtime.Store
|
||||
certMgr *certificates.Manager
|
||||
reqLog *logging.RequestLogger
|
||||
db *sql.DB
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
func NewHandler(store *runtime.Store, certMgr *certificates.Manager, reqLog *logging.RequestLogger, db *sql.DB, log *slog.Logger) *Handler {
|
||||
return &Handler{store: store, certMgr: certMgr, reqLog: reqLog, db: db, log: log}
|
||||
}
|
||||
|
||||
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if h.certMgr.ServeChallenge(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodyBytes)
|
||||
|
||||
snap := h.store.Current()
|
||||
rt := snap.LookupHost(r.Host)
|
||||
if rt == nil {
|
||||
http.Error(w, "404 - no proxy host configured for this domain", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if !rt.Model.Enabled {
|
||||
http.Error(w, "404 - not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
clientIP := ClientIP(r, snap)
|
||||
|
||||
if rt.AccessList != nil {
|
||||
if !h.checkAccess(w, r, rt.AccessList, clientIP) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if rt.Model.ForceHTTPS && r.TLS == nil {
|
||||
target := "https://" + normalizeHostHeader(r.Host) + r.URL.RequestURI()
|
||||
http.Redirect(w, r, target, http.StatusMovedPermanently)
|
||||
return
|
||||
}
|
||||
|
||||
if rt.Model.Type == models.ProxyHostTypeRedirect {
|
||||
h.serveRedirect(w, r, rt)
|
||||
return
|
||||
}
|
||||
|
||||
h.serveProxy(w, r, rt, snap, clientIP)
|
||||
}
|
||||
|
||||
func (h *Handler) checkAccess(w http.ResponseWriter, r *http.Request, list *access.List, clientIP string) bool {
|
||||
ip := parseIPLoose(clientIP)
|
||||
if ip == nil || !list.Allowed(ip) {
|
||||
http.Error(w, "403 - forbidden", http.StatusForbidden)
|
||||
return false
|
||||
}
|
||||
if list.RequiresAuth() {
|
||||
username, password, ok := r.BasicAuth()
|
||||
if !ok {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="restricted"`)
|
||||
http.Error(w, "401 - authentication required", http.StatusUnauthorized)
|
||||
return false
|
||||
}
|
||||
hash, exists := list.Users[username]
|
||||
if !exists {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="restricted"`)
|
||||
http.Error(w, "401 - authentication required", http.StatusUnauthorized)
|
||||
return false
|
||||
}
|
||||
valid, err := auth.VerifyPassword(password, hash)
|
||||
if err != nil || !valid {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="restricted"`)
|
||||
http.Error(w, "401 - authentication required", http.StatusUnauthorized)
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (h *Handler) serveRedirect(w http.ResponseWriter, r *http.Request, rt *runtime.ProxyHostRuntime) {
|
||||
target := rt.Model.RedirectTargetURL
|
||||
if target == "" {
|
||||
http.Error(w, "502 - redirect host has no target configured", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
target = strings.TrimRight(target, "/")
|
||||
if rt.Model.RedirectPreservePath {
|
||||
target += r.URL.Path
|
||||
}
|
||||
if rt.Model.RedirectPreserveQS && r.URL.RawQuery != "" {
|
||||
target += "?" + r.URL.RawQuery
|
||||
}
|
||||
code := rt.Model.RedirectCode
|
||||
switch code {
|
||||
case 301, 302, 307, 308:
|
||||
default:
|
||||
code = http.StatusMovedPermanently
|
||||
}
|
||||
http.Redirect(w, r, target, code)
|
||||
}
|
||||
|
||||
func (h *Handler) serveProxy(w http.ResponseWriter, r *http.Request, rt *runtime.ProxyHostRuntime, snap *runtime.Snapshot, clientIP string) {
|
||||
if rt.Pool == nil {
|
||||
http.Error(w, "502 - proxy host has no backends", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
backend, err := rt.Pool.Pick()
|
||||
if err != nil {
|
||||
h.log.Warn("no healthy backend", "proxy_host", rt.Model.Name, "err", err)
|
||||
metrics.HTTPRequestsTotal.WithLabelValues(rt.Model.Name, r.Method, "503").Inc()
|
||||
http.Error(w, "503 - no healthy backend available", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
backend.Acquire()
|
||||
defer backend.Release()
|
||||
|
||||
metrics.ActiveConnections.WithLabelValues("http").Inc()
|
||||
defer metrics.ActiveConnections.WithLabelValues("http").Dec()
|
||||
|
||||
rp := buildReverseProxy(rt, backend, snap, h.log)
|
||||
|
||||
readTimeout := time.Duration(rt.Model.ReadTimeoutMS) * time.Millisecond
|
||||
if readTimeout <= 0 {
|
||||
readTimeout = 60 * time.Second
|
||||
}
|
||||
ctx, cancel := withConnectTimeout(r.Context(), readTimeout)
|
||||
defer cancel()
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
rec := &statusRecorder{ResponseWriter: w}
|
||||
start := time.Now()
|
||||
rp.ServeHTTP(rec, r)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
backendAddr := backend.Addr() + ":" + strconv.Itoa(backend.Model.Port)
|
||||
metrics.HTTPRequestsTotal.WithLabelValues(rt.Model.Name, r.Method, strconv.Itoa(rec.status)).Inc()
|
||||
metrics.HTTPRequestDuration.WithLabelValues(rt.Model.Name).Observe(elapsed.Seconds())
|
||||
metrics.BackendRequestDuration.WithLabelValues(rt.Model.Name, backendAddr).Observe(elapsed.Seconds())
|
||||
metrics.BytesTransferred.WithLabelValues("out", "http").Add(float64(rec.bytes))
|
||||
|
||||
hostID := rt.Model.ID
|
||||
h.reqLog.Log(models.RequestLog{
|
||||
Timestamp: start,
|
||||
ProxyHostID: &hostID,
|
||||
ProxyHostName: rt.Model.Name,
|
||||
ClientIP: clientIP,
|
||||
Method: r.Method,
|
||||
Host: r.Host,
|
||||
Path: r.URL.Path,
|
||||
Status: rec.status,
|
||||
ResponseSize: rec.bytes,
|
||||
Backend: backendAddr,
|
||||
ResponseTimeMS: elapsed.Milliseconds(),
|
||||
UserAgent: r.UserAgent(),
|
||||
})
|
||||
}
|
||||
|
||||
func parseIPLoose(s string) net.IP {
|
||||
if h, _, err := net.SplitHostPort(s); err == nil {
|
||||
s = h
|
||||
}
|
||||
return net.ParseIP(s)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// statusRecorder wraps http.ResponseWriter to capture status code and
|
||||
// bytes written for logging/metrics, while still supporting hijacking
|
||||
// (required for WebSocket upgrades) and flushing (required for streaming
|
||||
// responses / SSE).
|
||||
type statusRecorder struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
bytes int64
|
||||
wroteHeader bool
|
||||
}
|
||||
|
||||
func (w *statusRecorder) WriteHeader(code int) {
|
||||
if w.wroteHeader {
|
||||
return
|
||||
}
|
||||
w.status = code
|
||||
w.wroteHeader = true
|
||||
w.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func (w *statusRecorder) Write(b []byte) (int, error) {
|
||||
if !w.wroteHeader {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
n, err := w.ResponseWriter.Write(b)
|
||||
w.bytes += int64(n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (w *statusRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
||||
hj, ok := w.ResponseWriter.(http.Hijacker)
|
||||
if !ok {
|
||||
return nil, nil, fmt.Errorf("underlying ResponseWriter does not support hijacking")
|
||||
}
|
||||
// A hijacked WebSocket connection never goes through WriteHeader, so
|
||||
// treat it as a successful 101 for logging purposes.
|
||||
w.status = http.StatusSwitchingProtocols
|
||||
w.wroteHeader = true
|
||||
return hj.Hijack()
|
||||
}
|
||||
|
||||
func (w *statusRecorder) Flush() {
|
||||
if f, ok := w.ResponseWriter.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/owen/vibeproxy/internal/loadbalancer"
|
||||
"github.com/owen/vibeproxy/internal/metrics"
|
||||
"github.com/owen/vibeproxy/internal/models"
|
||||
"github.com/owen/vibeproxy/internal/runtime"
|
||||
)
|
||||
|
||||
// sharedTransport is reused across every proxied request so backend
|
||||
// connections are pooled (keep-alive) instead of dialed fresh each time.
|
||||
// It supports HTTP/2 to upstreams that offer it via ALPN.
|
||||
var sharedTransport = &http.Transport{
|
||||
Proxy: nil,
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 10 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
}).DialContext,
|
||||
ForceAttemptHTTP2: true,
|
||||
MaxIdleConns: 200,
|
||||
MaxIdleConnsPerHost: 50,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12},
|
||||
}
|
||||
|
||||
// buildReverseProxy constructs a one-shot ReverseProxy for a single
|
||||
// request/backend pair. Constructing it per request is cheap (no
|
||||
// goroutines or connections are created here); the expensive parts —
|
||||
// backend TCP/TLS connections — are pooled by sharedTransport across
|
||||
// calls.
|
||||
func buildReverseProxy(rt *runtime.ProxyHostRuntime, backend *loadbalancer.Backend, snap *runtime.Snapshot, log *slog.Logger) *httputil.ReverseProxy {
|
||||
targetScheme := rt.Model.ForwardScheme
|
||||
if targetScheme == "" {
|
||||
targetScheme = "http"
|
||||
}
|
||||
targetAddr := net.JoinHostPort(backend.Model.Host, fmt.Sprintf("%d", backend.Model.Port))
|
||||
|
||||
director := func(req *http.Request) {
|
||||
clientIP := ClientIP(req, snap)
|
||||
priorFor := req.Header.Get("X-Forwarded-For")
|
||||
|
||||
req.URL.Scheme = targetScheme
|
||||
req.URL.Host = targetAddr
|
||||
if !rt.Model.PreserveHostHeader {
|
||||
req.Host = targetAddr
|
||||
}
|
||||
// else: leave req.Host as the original inbound Host header
|
||||
|
||||
if priorFor == "" {
|
||||
req.Header.Set("X-Forwarded-For", clientIP)
|
||||
} else {
|
||||
req.Header.Set("X-Forwarded-For", priorFor+", "+clientIP)
|
||||
}
|
||||
proto := "http"
|
||||
if req.TLS != nil {
|
||||
proto = "https"
|
||||
}
|
||||
req.Header.Set("X-Forwarded-Proto", proto)
|
||||
if fh := req.Header.Get("X-Forwarded-Host"); fh == "" {
|
||||
req.Header.Set("X-Forwarded-Host", req.Host)
|
||||
}
|
||||
req.Header.Set("X-Real-IP", clientIP)
|
||||
|
||||
applyCustomHeaders(req.Header, rt.Model.CustomHeaders, models.HeaderDirectionRequest)
|
||||
|
||||
// WebSocket upgrade headers must pass through untouched; Go's
|
||||
// httputil.ReverseProxy handles the hijack/tunnel automatically
|
||||
// once Connection: Upgrade is present, which we never strip.
|
||||
if !rt.Model.WebsocketSupport {
|
||||
req.Header.Del("Upgrade")
|
||||
req.Header.Set("Connection", "close")
|
||||
}
|
||||
}
|
||||
|
||||
modifyResponse := func(resp *http.Response) error {
|
||||
applyCustomHeaders(resp.Header, rt.Model.CustomHeaders, models.HeaderDirectionResponse)
|
||||
if rt.Model.HSTSEnabled && resp.Request.TLS != nil {
|
||||
resp.Header.Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
errorHandler := func(w http.ResponseWriter, r *http.Request, err error) {
|
||||
log.Warn("backend request failed", "proxy_host", rt.Model.Name, "backend", targetAddr, "err", err)
|
||||
metrics.HTTPRequestsTotal.WithLabelValues(rt.Model.Name, r.Method, "502").Inc()
|
||||
http.Error(w, "Bad Gateway", http.StatusBadGateway)
|
||||
}
|
||||
|
||||
return &httputil.ReverseProxy{
|
||||
Director: director,
|
||||
ModifyResponse: modifyResponse,
|
||||
ErrorHandler: errorHandler,
|
||||
Transport: sharedTransport,
|
||||
FlushInterval: 200 * time.Millisecond, // needed for SSE/streaming responses
|
||||
}
|
||||
}
|
||||
|
||||
func applyCustomHeaders(h http.Header, headers []models.CustomHeader, direction models.CustomHeaderDirection) {
|
||||
for _, c := range headers {
|
||||
if c.Direction != direction {
|
||||
continue
|
||||
}
|
||||
switch c.Action {
|
||||
case models.HeaderActionSet:
|
||||
h.Set(c.Name, c.Value)
|
||||
case models.HeaderActionRemove:
|
||||
h.Del(c.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// withConnectTimeout returns a context that bounds only the dial phase of
|
||||
// an outbound request via a deadline; read/write timeouts are handled by
|
||||
// the surrounding http.Server / http.ResponseController.
|
||||
func withConnectTimeout(ctx context.Context, d time.Duration) (context.Context, context.CancelFunc) {
|
||||
if d <= 0 {
|
||||
d = 5 * time.Second
|
||||
}
|
||||
deadline := time.Now().Add(d)
|
||||
return context.WithDeadline(ctx, deadline)
|
||||
}
|
||||
|
||||
func normalizeHostHeader(host string) string {
|
||||
if h, _, err := net.SplitHostPort(host); err == nil {
|
||||
return strings.ToLower(h)
|
||||
}
|
||||
return strings.ToLower(host)
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/owen/vibeproxy/internal/certificates"
|
||||
"github.com/owen/vibeproxy/internal/logging"
|
||||
"github.com/owen/vibeproxy/internal/metrics"
|
||||
"github.com/owen/vibeproxy/internal/runtime"
|
||||
)
|
||||
|
||||
// Server owns the plain-HTTP and TLS listeners that serve all proxy hosts.
|
||||
type Server struct {
|
||||
httpSrv *http.Server
|
||||
httpsSrv *http.Server
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
type Options struct {
|
||||
HTTPAddr string // e.g. ":80"
|
||||
HTTPSAddr string // e.g. ":443"
|
||||
}
|
||||
|
||||
func NewServer(opts Options, store *runtime.Store, certMgr *certificates.Manager, db *sql.DB, log *slog.Logger) *Server {
|
||||
reqLog := logging.NewRequestLogger(db, log)
|
||||
go reqLog.Run(context.Background())
|
||||
|
||||
handler := NewHandler(store, certMgr, reqLog, db, log)
|
||||
|
||||
tlsConfig := &tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
NextProtos: []string{"h2", "http/1.1"},
|
||||
GetCertificate: func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||
cert, err := certMgr.GetCertificateForDomain(hello.ServerName)
|
||||
if err != nil {
|
||||
metrics.TLSHandshakesTotal.WithLabelValues("error").Inc()
|
||||
return nil, err
|
||||
}
|
||||
metrics.TLSHandshakesTotal.WithLabelValues("ok").Inc()
|
||||
return cert, nil
|
||||
},
|
||||
}
|
||||
|
||||
httpAddr := opts.HTTPAddr
|
||||
if httpAddr == "" {
|
||||
httpAddr = ":80"
|
||||
}
|
||||
httpsAddr := opts.HTTPSAddr
|
||||
if httpsAddr == "" {
|
||||
httpsAddr = ":443"
|
||||
}
|
||||
|
||||
return &Server{
|
||||
log: log,
|
||||
httpSrv: &http.Server{
|
||||
Addr: httpAddr,
|
||||
Handler: handler,
|
||||
ReadTimeout: 0, // per-request timeouts are enforced inside the handler
|
||||
WriteTimeout: 0,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
ErrorLog: slog.NewLogLogger(log.Handler(), slog.LevelWarn),
|
||||
},
|
||||
httpsSrv: &http.Server{
|
||||
Addr: httpsAddr,
|
||||
Handler: handler,
|
||||
TLSConfig: tlsConfig,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
ErrorLog: slog.NewLogLogger(log.Handler(), slog.LevelWarn),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Start runs both listeners; it blocks until either fails or ctx is done.
|
||||
func (s *Server) Start(ctx context.Context) error {
|
||||
errCh := make(chan error, 2)
|
||||
|
||||
go func() {
|
||||
s.log.Info("http listener starting", "addr", s.httpSrv.Addr)
|
||||
if err := s.httpSrv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
errCh <- fmt.Errorf("http listener: %w", err)
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
s.log.Info("https listener starting", "addr", s.httpsSrv.Addr)
|
||||
// Certificates are served dynamically via GetCertificate, so we
|
||||
// don't pass cert/key file paths here.
|
||||
if err := s.httpsSrv.ListenAndServeTLS("", ""); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
errCh <- fmt.Errorf("https listener: %w", err)
|
||||
}
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return s.Shutdown(context.Background())
|
||||
case err := <-errCh:
|
||||
s.Shutdown(context.Background())
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Shutdown gracefully drains in-flight requests/connections before
|
||||
// closing the listeners.
|
||||
func (s *Server) Shutdown(ctx context.Context) error {
|
||||
shutdownCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
err1 := s.httpSrv.Shutdown(shutdownCtx)
|
||||
err2 := s.httpsSrv.Shutdown(shutdownCtx)
|
||||
if err1 != nil {
|
||||
return err1
|
||||
}
|
||||
return err2
|
||||
}
|
||||
Reference in New Issue
Block a user