Jarl is now an ACME client (lego's core, no DNS providers). It answers DNS-01 challenges in a zone it serves, or through Porkbun's DNS API while Porkbun serves the domain; "auto" decides from the domain's public name servers at every renewal, and a CNAME on _acme-challenge is followed. Certificates are used for jarld's HTTPS (served without a restart; each port speaks HTTPS and plain HTTP, plain HTTP from loopback and trusted proxies still served) and/or written for named's DNS over TLS/HTTPS, with rndc reconfig on renewal. Renewal follows the CA's ARI window or two thirds of the lifetime, with back-off. Certificates you renew yourself can be used too. Certificates page and dashboard card, jarl cert, /api/v1/certificates, Prometheus gauges, docs/certificates.md, and an e2e section against Pebble. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
569 lines
17 KiB
Go
569 lines
17 KiB
Go
// Package server implements Jarl's HTTP API and serves the web UI.
|
|
package server
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"log/slog"
|
|
"net"
|
|
"net/http"
|
|
"net/netip"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
"time"
|
|
|
|
"jarl/internal/api"
|
|
"jarl/internal/auth"
|
|
"jarl/internal/certs"
|
|
"jarl/internal/engine"
|
|
"jarl/internal/nscheck"
|
|
"jarl/internal/porkbun"
|
|
"jarl/internal/stats"
|
|
"jarl/internal/store"
|
|
)
|
|
|
|
// Config configures the HTTP server.
|
|
type Config struct {
|
|
Listen []string
|
|
TLSCertFile string
|
|
TLSKeyFile string
|
|
TrustedProxies []netip.Prefix
|
|
SessionLifetime time.Duration
|
|
SessionIdle time.Duration
|
|
// CookieSecure is "auto" (secure when TLS is used or the request came
|
|
// through a trusted HTTPS proxy), "always" or "never".
|
|
CookieSecure string
|
|
MaxBodyBytes int64
|
|
Version string
|
|
// Stats is the query statistics collector (nil when disabled).
|
|
Stats *stats.Collector
|
|
// HTTPReq configures the endpoint for lego's httpreq DNS provider.
|
|
HTTPReq HTTPReq
|
|
// Socket is the local unix socket's path ("" for none); see socket.go.
|
|
Socket string
|
|
// BackupDir and BackupWarnAfter are the backup settings the dashboard
|
|
// reports on (backup.dir and backup.warn_after).
|
|
BackupDir string
|
|
BackupWarnAfter time.Duration
|
|
// Porkbun manages domains' delegation at the registrar Porkbun (nil
|
|
// without an API key); PorkbunAllows says which domains it may touch.
|
|
Porkbun *porkbun.Client
|
|
PorkbunAllows func(domain string) bool
|
|
// TOTPRequire is totp.require (none, admins or all) and TOTPIssuer the
|
|
// name authenticator apps show (h_totp.go).
|
|
TOTPRequire string
|
|
TOTPIssuer string
|
|
// Metrics configures /metrics (metrics.go).
|
|
Metrics Metrics
|
|
// Certs manages TLS certificates (nil in tests that don't need it),
|
|
// including the one jarld serves (h_certs.go, sniff.go).
|
|
Certs *certs.Manager
|
|
}
|
|
|
|
// Server is the HTTP server.
|
|
type Server struct {
|
|
cfg Config
|
|
eng *engine.Engine
|
|
store *store.Store
|
|
log *slog.Logger
|
|
mux *http.ServeMux
|
|
limiter *rateLimiter
|
|
web fs.FS
|
|
certs *certReloader
|
|
statsQ *stats.Querier
|
|
// oldSecretWarn limits warnings about rotated tokens' old secrets.
|
|
oldSecretWarn throttle
|
|
// expiryWarn limits warnings about expiring tokens to one a day each.
|
|
expiryWarn throttle
|
|
// pendingLogins are logins waiting for their TOTP code.
|
|
pendingLogins pendingLogins
|
|
// nsChecker, when set, replaces the default name server checker
|
|
// (tests point it at local DNS servers).
|
|
nsChecker *nscheck.Checker
|
|
// started is when the server was created; metricsCache holds the Jarl
|
|
// state part of /metrics.
|
|
started time.Time
|
|
metricsCache metricsCache
|
|
|
|
mu sync.Mutex
|
|
srvs []*http.Server
|
|
}
|
|
|
|
// New creates a server. web holds the static web UI (may be nil).
|
|
func New(cfg Config, eng *engine.Engine, log *slog.Logger, web fs.FS) *Server {
|
|
if cfg.SessionLifetime == 0 {
|
|
cfg.SessionLifetime = 12 * time.Hour
|
|
}
|
|
if cfg.SessionIdle == 0 {
|
|
cfg.SessionIdle = 2 * time.Hour
|
|
}
|
|
if cfg.MaxBodyBytes == 0 {
|
|
cfg.MaxBodyBytes = 16 << 20
|
|
}
|
|
if cfg.CookieSecure == "" {
|
|
cfg.CookieSecure = "auto"
|
|
}
|
|
if cfg.TOTPRequire == "" {
|
|
cfg.TOTPRequire = "none"
|
|
}
|
|
if cfg.TOTPIssuer == "" {
|
|
cfg.TOTPIssuer = "Jarl"
|
|
}
|
|
if log == nil {
|
|
log = slog.Default()
|
|
}
|
|
s := &Server{cfg: cfg, eng: eng, store: eng.Store(), log: log, mux: http.NewServeMux(), limiter: newRateLimiter(), web: web, started: time.Now()}
|
|
s.expiryWarn.every = 24 * time.Hour
|
|
if cfg.Stats != nil {
|
|
s.statsQ = stats.NewQuerier(cfg.Stats)
|
|
}
|
|
s.routes()
|
|
return s
|
|
}
|
|
|
|
// Handler returns the server's HTTP handler with all middleware.
|
|
// lowPort reports whether addr ("host:port") has a port below 1024.
|
|
func lowPort(addr string) bool {
|
|
_, port, err := net.SplitHostPort(addr)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
n, err := strconv.Atoi(port)
|
|
return err == nil && n > 0 && n < 1024
|
|
}
|
|
|
|
func (s *Server) Handler() http.Handler {
|
|
return s.recoverer(s.securityHeaders(s.logRequests(s.mux)))
|
|
}
|
|
|
|
// Serve listens on all configured addresses until ctx is canceled.
|
|
func (s *Server) Serve(ctx context.Context, ready func()) error {
|
|
if len(s.cfg.Listen) == 0 {
|
|
return errors.New("no listen addresses configured")
|
|
}
|
|
if s.cfg.TLSCertFile != "" {
|
|
cr, err := newCertReloader(s.cfg.TLSCertFile, s.cfg.TLSKeyFile, s.log)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
s.certs = cr
|
|
}
|
|
// With a certificate (tls.cert_file, or one Jarl manages, which may
|
|
// turn up later), the listeners serve HTTPS and plain HTTP side by
|
|
// side (sniff.go).
|
|
var tlsCfg *tls.Config
|
|
if s.certs != nil || s.cfg.Certs != nil {
|
|
tlsCfg = &tls.Config{MinVersion: tls.VersionTLS12, GetCertificate: s.webCertificate, NextProtos: []string{"h2", "http/1.1"}}
|
|
}
|
|
errc := make(chan error, len(s.cfg.Listen)+1)
|
|
var listeners []net.Listener
|
|
for _, addr := range s.cfg.Listen {
|
|
ln, err := net.Listen("tcp", addr)
|
|
if err != nil {
|
|
for _, l := range listeners {
|
|
l.Close()
|
|
}
|
|
if errors.Is(err, syscall.EACCES) && lowPort(addr) {
|
|
return fmt.Errorf("listening on %s: %w (ports below 1024 need the CAP_NET_BIND_SERVICE capability; Jarl's jarl.service grants it, so check that the installed unit is current and no drop-in removes it: systemctl cat jarl)", addr, err)
|
|
}
|
|
return fmt.Errorf("listening on %s: %w", addr, err)
|
|
}
|
|
listeners = append(listeners, ln)
|
|
}
|
|
for i, ln := range listeners {
|
|
handler := s.Handler()
|
|
if tlsCfg != nil {
|
|
handler = s.requireHTTPS(handler)
|
|
}
|
|
srv := &http.Server{
|
|
Handler: handler,
|
|
ReadHeaderTimeout: 10 * time.Second,
|
|
ReadTimeout: 2 * time.Minute,
|
|
WriteTimeout: 5 * time.Minute,
|
|
IdleTimeout: 2 * time.Minute,
|
|
TLSConfig: tlsCfg,
|
|
ErrorLog: slog.NewLogLogger(s.log.Handler(), slog.LevelWarn),
|
|
}
|
|
s.mu.Lock()
|
|
s.srvs = append(s.srvs, srv)
|
|
s.mu.Unlock()
|
|
scheme := "http"
|
|
if s.httpsAvailable() {
|
|
scheme = "https"
|
|
}
|
|
s.log.Info("listening", "addr", s.cfg.Listen[i], "url", scheme+"://"+ln.Addr().String())
|
|
if tlsCfg != nil {
|
|
ln = newSniffListener(ln, tlsCfg)
|
|
}
|
|
go func(srv *http.Server, ln net.Listener) {
|
|
if err := srv.Serve(ln); !errors.Is(err, http.ErrServerClosed) {
|
|
errc <- err
|
|
}
|
|
}(srv, ln)
|
|
}
|
|
if !s.httpsAvailable() && (s.cfg.Certs == nil || !s.cfg.Certs.HasWeb(ctx)) {
|
|
for _, a := range s.cfg.Listen {
|
|
if host, _, err := net.SplitHostPort(a); err == nil {
|
|
if ip, err := netip.ParseAddr(host); host == "" || err == nil && !ip.IsLoopback() {
|
|
s.log.Warn("serving plain HTTP on a non-loopback address: passwords and API tokens travel unencrypted; configure tls or a TLS reverse proxy", "addr", a)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if err := s.serveMetricsListeners(errc); err != nil {
|
|
s.shutdown()
|
|
return err
|
|
}
|
|
s.serveSocket(errc)
|
|
s.logHTTPReq(s.httpsAvailable())
|
|
if ready != nil {
|
|
ready()
|
|
}
|
|
go s.housekeeping(ctx)
|
|
select {
|
|
case <-ctx.Done():
|
|
case err := <-errc:
|
|
s.shutdown()
|
|
return err
|
|
}
|
|
s.shutdown()
|
|
return nil
|
|
}
|
|
|
|
// serveSocket serves the API on the local unix socket. A socket that
|
|
// can't be created (e.g. /run/jarl missing outside systemd) is logged, not
|
|
// fatal: the network listeners still work.
|
|
func (s *Server) serveSocket(errc chan error) {
|
|
if s.cfg.Socket == "" {
|
|
return
|
|
}
|
|
ln, err := listenSocket(s.cfg.Socket)
|
|
if err != nil {
|
|
s.log.Warn("not listening on the local socket", "path", s.cfg.Socket, "err", err)
|
|
return
|
|
}
|
|
srv := &http.Server{
|
|
Handler: s.Handler(),
|
|
ConnContext: socketConnContext,
|
|
ReadHeaderTimeout: 10 * time.Second,
|
|
WriteTimeout: 5 * time.Minute,
|
|
IdleTimeout: 2 * time.Minute,
|
|
ErrorLog: slog.NewLogLogger(s.log.Handler(), slog.LevelWarn),
|
|
}
|
|
s.mu.Lock()
|
|
s.srvs = append(s.srvs, srv)
|
|
s.mu.Unlock()
|
|
s.log.Info("listening", "socket", s.cfg.Socket)
|
|
go func() {
|
|
if err := srv.Serve(ln); !errors.Is(err, http.ErrServerClosed) {
|
|
errc <- err
|
|
}
|
|
}()
|
|
}
|
|
|
|
func (s *Server) shutdown() {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
for _, srv := range s.srvs {
|
|
srv.Shutdown(ctx)
|
|
}
|
|
if s.cfg.Socket != "" {
|
|
os.Remove(s.cfg.Socket)
|
|
}
|
|
}
|
|
|
|
// ReloadTLS rereads the TLS certificate (on SIGHUP).
|
|
func (s *Server) ReloadTLS() {
|
|
if s.certs != nil {
|
|
s.certs.reload()
|
|
}
|
|
}
|
|
|
|
func (s *Server) housekeeping(ctx context.Context) {
|
|
t := time.NewTicker(10 * time.Minute)
|
|
defer t.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-t.C:
|
|
if err := s.store.PurgeSessions(ctx, time.Now()); err != nil {
|
|
s.log.Warn("purging sessions failed", "err", err)
|
|
}
|
|
s.limiter.cleanup()
|
|
s.warnExpiringTokens(ctx, time.Now())
|
|
if s.certs != nil {
|
|
s.certs.reloadIfChanged()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// certReloader serves a certificate that is reread when the files change,
|
|
// so renewed certificates (e.g. from Let's Encrypt) are picked up.
|
|
type certReloader struct {
|
|
certFile, keyFile string
|
|
log *slog.Logger
|
|
mu sync.RWMutex
|
|
cert *tls.Certificate
|
|
modTime time.Time
|
|
}
|
|
|
|
func newCertReloader(certFile, keyFile string, log *slog.Logger) (*certReloader, error) {
|
|
cr := &certReloader{certFile: certFile, keyFile: keyFile, log: log}
|
|
if err := cr.load(); err != nil {
|
|
return nil, err
|
|
}
|
|
return cr, nil
|
|
}
|
|
|
|
func (cr *certReloader) load() error {
|
|
cert, err := tls.LoadX509KeyPair(cr.certFile, cr.keyFile)
|
|
if err != nil {
|
|
return fmt.Errorf("loading TLS certificate: %w", err)
|
|
}
|
|
fi, _ := os.Stat(cr.certFile)
|
|
cr.mu.Lock()
|
|
cr.cert = &cert
|
|
if fi != nil {
|
|
cr.modTime = fi.ModTime()
|
|
}
|
|
cr.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
func (cr *certReloader) reload() {
|
|
if err := cr.load(); err != nil {
|
|
cr.log.Error("reloading TLS certificate failed", "err", err)
|
|
return
|
|
}
|
|
cr.log.Info("TLS certificate reloaded", "file", cr.certFile)
|
|
}
|
|
|
|
func (cr *certReloader) reloadIfChanged() {
|
|
fi, err := os.Stat(cr.certFile)
|
|
if err != nil {
|
|
return
|
|
}
|
|
cr.mu.RLock()
|
|
changed := fi.ModTime().After(cr.modTime)
|
|
cr.mu.RUnlock()
|
|
if changed {
|
|
cr.reload()
|
|
}
|
|
}
|
|
|
|
func (cr *certReloader) get(*tls.ClientHelloInfo) (*tls.Certificate, error) {
|
|
cr.mu.RLock()
|
|
defer cr.mu.RUnlock()
|
|
return cr.cert, nil
|
|
}
|
|
|
|
// ---- middleware
|
|
|
|
type ctxKey int
|
|
|
|
const (
|
|
keyPrincipal ctxKey = iota
|
|
keySession
|
|
)
|
|
|
|
func (s *Server) recoverer(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
defer func() {
|
|
if v := recover(); v != nil {
|
|
if v == http.ErrAbortHandler {
|
|
panic(v)
|
|
}
|
|
s.log.Error("panic serving request", "path", r.URL.Path, "panic", v)
|
|
writeError(w, http.StatusInternalServerError, "internal", "internal server error")
|
|
}
|
|
}()
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
func (s *Server) securityHeaders(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
h := w.Header()
|
|
// The version is no secret: /healthz reports it too.
|
|
h.Set("Server", "Jarl/"+s.cfg.Version)
|
|
h.Set("X-Content-Type-Options", "nosniff")
|
|
h.Set("X-Frame-Options", "DENY")
|
|
h.Set("Referrer-Policy", "no-referrer")
|
|
h.Set("Content-Security-Policy", "default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'")
|
|
h.Set("Cross-Origin-Opener-Policy", "same-origin")
|
|
if s.isHTTPS(r) {
|
|
h.Set("Strict-Transport-Security", "max-age=31536000")
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
type statusWriter struct {
|
|
http.ResponseWriter
|
|
status int
|
|
bytes int
|
|
}
|
|
|
|
func (w *statusWriter) WriteHeader(code int) {
|
|
w.status = code
|
|
w.ResponseWriter.WriteHeader(code)
|
|
}
|
|
|
|
// Unwrap lets http.ResponseController reach the connection (to flush a
|
|
// streamed response).
|
|
func (w *statusWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter }
|
|
|
|
func (w *statusWriter) Write(b []byte) (int, error) {
|
|
if w.status == 0 {
|
|
w.status = http.StatusOK
|
|
}
|
|
n, err := w.ResponseWriter.Write(b)
|
|
w.bytes += n
|
|
return n, err
|
|
}
|
|
|
|
func (s *Server) logRequests(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
start := time.Now()
|
|
sw := &statusWriter{ResponseWriter: w}
|
|
next.ServeHTTP(sw, r)
|
|
if r.URL.Path == "/healthz" || strings.HasPrefix(r.URL.Path, "/assets/") {
|
|
return
|
|
}
|
|
user := ""
|
|
if p, ok := r.Context().Value(keyPrincipal).(*auth.Principal); ok {
|
|
user = p.Name()
|
|
}
|
|
s.log.Debug("http", "method", r.Method, "path", r.URL.Path, "status", sw.status, "bytes", sw.bytes, "duration", time.Since(start).Round(time.Millisecond), "ip", s.clientIP(r), "user", user)
|
|
})
|
|
}
|
|
|
|
// clientIP returns the client's address, honoring X-Forwarded-For from
|
|
// trusted proxies.
|
|
func (s *Server) clientIP(r *http.Request) string {
|
|
if peerOf(r.Context()) != nil {
|
|
return "local"
|
|
}
|
|
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
|
if err != nil {
|
|
host = r.RemoteAddr
|
|
}
|
|
peer, err := netip.ParseAddr(host)
|
|
if err != nil || !s.trusted(peer) {
|
|
return host
|
|
}
|
|
xff := r.Header.Get("X-Forwarded-For")
|
|
if xff == "" {
|
|
return host
|
|
}
|
|
parts := strings.Split(xff, ",")
|
|
// Walk from the right, skipping trusted proxies.
|
|
for i := len(parts) - 1; i >= 0; i-- {
|
|
a, err := netip.ParseAddr(strings.TrimSpace(parts[i]))
|
|
if err != nil {
|
|
return host
|
|
}
|
|
if !s.trusted(a) {
|
|
return a.String()
|
|
}
|
|
}
|
|
return strings.TrimSpace(parts[0])
|
|
}
|
|
|
|
func (s *Server) trusted(a netip.Addr) bool {
|
|
a = a.Unmap()
|
|
for _, p := range s.cfg.TrustedProxies {
|
|
if p.Contains(a) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// logHTTPReq says at startup whether the httpreq endpoint can work.
|
|
func (s *Server) logHTTPReq(tls bool) {
|
|
h := s.cfg.HTTPReq
|
|
switch {
|
|
case !h.Enabled:
|
|
case h.AllowPlainHTTP:
|
|
s.log.Warn("acme.httpreq accepts plain HTTP (require_https: false): API tokens may travel unencrypted", "path", api.Prefix+"/acme/httpreq")
|
|
case !tls && len(s.cfg.TrustedProxies) == 0 && !h.AllowLoopbackHTTP:
|
|
s.log.Warn("acme.httpreq is enabled but jarld has no tls and no trusted_proxies, so every request will be refused as not HTTPS", "path", api.Prefix+"/acme/httpreq")
|
|
default:
|
|
s.log.Info("acme.httpreq endpoint enabled", "path", api.Prefix+"/acme/httpreq", "tls", tls, "trusted_proxies", len(s.cfg.TrustedProxies), "allow_loopback_http", h.AllowLoopbackHTTP)
|
|
}
|
|
}
|
|
|
|
func (s *Server) isHTTPS(r *http.Request) bool {
|
|
https, _ := s.transport(r)
|
|
return https
|
|
}
|
|
|
|
// transport reports whether r reached Jarl over HTTPS, and explains how
|
|
// that was decided (for error messages and the httpreq status check).
|
|
//
|
|
// A request is HTTPS when jarld terminated TLS itself (tls in jarl.yaml),
|
|
// or when it came straight from a trusted proxy (trusted_proxies) that
|
|
// says the client used HTTPS: X-Forwarded-Proto, or, if that is absent,
|
|
// the proto parameter of Forwarded (RFC 7239). Proxies append to these
|
|
// headers, so the last value is the one the nearest proxy set; anything a
|
|
// client sent itself comes earlier and is ignored. From any other address
|
|
// the headers are ignored, since anyone could send them.
|
|
func (s *Server) transport(r *http.Request) (https bool, why string) {
|
|
if r.TLS != nil {
|
|
return true, "HTTPS to jarld (tls in jarl.yaml)"
|
|
}
|
|
if peerOf(r.Context()) != nil {
|
|
return true, "jarld's local unix socket (never crosses a network)"
|
|
}
|
|
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
|
if err != nil {
|
|
host = r.RemoteAddr
|
|
}
|
|
peer, err := netip.ParseAddr(host)
|
|
if err != nil || !s.trusted(peer) {
|
|
if r.Header.Get("X-Forwarded-Proto") != "" || r.Header.Get("Forwarded") != "" {
|
|
return false, fmt.Sprintf("plain HTTP from %s, which sent X-Forwarded-Proto or Forwarded but isn't in trusted_proxies, so the header was ignored", host)
|
|
}
|
|
return false, fmt.Sprintf("plain HTTP from %s (jarld has no tls configured, and %s isn't a trusted proxy)", host, host)
|
|
}
|
|
proto, header := forwardedProto(r)
|
|
switch {
|
|
case proto == "":
|
|
return false, fmt.Sprintf("trusted proxy %s, which sent neither X-Forwarded-Proto nor Forwarded: proto=, so the client's protocol is unknown", host)
|
|
case proto == "https":
|
|
return true, fmt.Sprintf("HTTPS to trusted proxy %s (%s: https)", host, header)
|
|
default:
|
|
return false, fmt.Sprintf("trusted proxy %s, which says the client used %s (%s: %s)", host, proto, header, proto)
|
|
}
|
|
}
|
|
|
|
// forwardedProto returns the client's protocol as the nearest proxy
|
|
// reported it, and the header it came from.
|
|
func forwardedProto(r *http.Request) (proto, header string) {
|
|
if vals := r.Header.Values("X-Forwarded-Proto"); len(vals) > 0 {
|
|
parts := strings.Split(strings.Join(vals, ","), ",")
|
|
return strings.ToLower(strings.TrimSpace(parts[len(parts)-1])), "X-Forwarded-Proto"
|
|
}
|
|
if vals := r.Header.Values("Forwarded"); len(vals) > 0 {
|
|
elems := strings.Split(strings.Join(vals, ","), ",")
|
|
for _, pair := range strings.Split(elems[len(elems)-1], ";") {
|
|
k, v, _ := strings.Cut(strings.TrimSpace(pair), "=")
|
|
if strings.EqualFold(k, "proto") {
|
|
return strings.ToLower(strings.Trim(strings.TrimSpace(v), `"`)), "Forwarded"
|
|
}
|
|
}
|
|
}
|
|
return "", ""
|
|
}
|