Added ldap and pam authentication
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// credCache memoises successful Basic-auth validations for a short TTL. Browsers
|
||||
// resend the Authorization header on every request, so without this each one
|
||||
// would trigger a full PAM round-trip (slow, and a brute-force/lockout risk).
|
||||
// Only positive results are cached, keyed by a salted SHA-256 of user+password
|
||||
// so the cache never holds a recoverable secret. A fresh random salt per process
|
||||
// keeps keys from being precomputable.
|
||||
type credCache struct {
|
||||
mu sync.Mutex
|
||||
ttl time.Duration
|
||||
salt []byte
|
||||
entries map[string]time.Time
|
||||
}
|
||||
|
||||
func newCredCache(ttl time.Duration) *credCache {
|
||||
salt := make([]byte, 16)
|
||||
_, _ = rand.Read(salt)
|
||||
return &credCache{ttl: ttl, salt: salt, entries: map[string]time.Time{}}
|
||||
}
|
||||
|
||||
func (c *credCache) key(user, pass string) string {
|
||||
h := sha256.New()
|
||||
h.Write(c.salt)
|
||||
h.Write([]byte(user))
|
||||
h.Write([]byte{0})
|
||||
h.Write([]byte(pass))
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
// valid reports whether user/pass was validated within the TTL.
|
||||
func (c *credCache) valid(user, pass string) bool {
|
||||
if c.ttl <= 0 {
|
||||
return false
|
||||
}
|
||||
k := c.key(user, pass)
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
exp, ok := c.entries[k]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if time.Now().After(exp) {
|
||||
delete(c.entries, k)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// store records a successful validation, opportunistically pruning expired keys.
|
||||
func (c *credCache) store(user, pass string) {
|
||||
if c.ttl <= 0 {
|
||||
return
|
||||
}
|
||||
k := c.key(user, pass)
|
||||
now := time.Now()
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.entries[k] = now.Add(c.ttl)
|
||||
if len(c.entries) > 1024 {
|
||||
for kk, exp := range c.entries {
|
||||
if now.After(exp) {
|
||||
delete(c.entries, kk)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// basicAuth wraps next with HTTP Basic authentication validated by authFn (PAM,
|
||||
// see internal/pamauth). It mirrors kerberosAuth: a successful login writes the
|
||||
// username into userHeader for the existing access pipeline (accessMiddleware /
|
||||
// wsHandler), and any inbound value of userHeader is always discarded first so a
|
||||
// client cannot spoof an identity.
|
||||
//
|
||||
// challenge controls the no/invalid-credentials case:
|
||||
// - challenge=true (REST/page API): reply 401 + WWW-Authenticate: Basic so the
|
||||
// browser prompts for credentials.
|
||||
// - challenge=false (WebSocket upgrade): fall through unauthenticated; the
|
||||
// session resolves to default_user. Browsers cannot show a login dialog for a
|
||||
// WebSocket, but once they have cached credentials from the page's API calls
|
||||
// they resend them on the upgrade, so the validated path is still taken.
|
||||
func basicAuth(authFn func(user, pass string) error, userHeader, realm string, challenge bool, cache *credCache, log *slog.Logger, next http.Handler) http.Handler {
|
||||
if realm == "" {
|
||||
realm = "uopi"
|
||||
}
|
||||
challengeValue := `Basic realm="` + realm + `", charset="UTF-8"`
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Never trust a client-supplied identity header; only a validated login sets it.
|
||||
r.Header.Del(userHeader)
|
||||
|
||||
if user, pass, ok := r.BasicAuth(); ok && user != "" {
|
||||
if cache.valid(user, pass) {
|
||||
r.Header.Set(userHeader, user)
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
if err := authFn(user, pass); err == nil {
|
||||
cache.store(user, pass)
|
||||
r.Header.Set(userHeader, user)
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
} else {
|
||||
log.Warn("basic auth failed", "user", user, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// No or invalid credentials.
|
||||
if challenge {
|
||||
w.Header().Set("WWW-Authenticate", challengeValue)
|
||||
http.Error(w, "authentication required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r) // best-effort (WebSocket): downstream → default_user
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const testUserHeader = "X-Uopi-User"
|
||||
|
||||
func quietLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
}
|
||||
|
||||
// echoUser is a terminal handler that reports the resolved identity header so a
|
||||
// test can assert what basicAuth stamped.
|
||||
func echoUser() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = io.WriteString(w, r.Header.Get(testUserHeader))
|
||||
})
|
||||
}
|
||||
|
||||
func basicReq(t *testing.T, user, pass string, setHeader string) *http.Request {
|
||||
t.Helper()
|
||||
r := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil)
|
||||
if user != "" || pass != "" {
|
||||
r.SetBasicAuth(user, pass)
|
||||
}
|
||||
if setHeader != "" {
|
||||
r.Header.Set(testUserHeader, setHeader) // a spoof attempt
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// authFn that accepts only alice/secret.
|
||||
func aliceAuth(calls *int32) func(string, string) error {
|
||||
return func(user, pass string) error {
|
||||
atomic.AddInt32(calls, 1)
|
||||
if user == "alice" && pass == "secret" {
|
||||
return nil
|
||||
}
|
||||
return pamErr{}
|
||||
}
|
||||
}
|
||||
|
||||
type pamErr struct{}
|
||||
|
||||
func (pamErr) Error() string { return "bad credentials" }
|
||||
|
||||
func TestBasicAuthChallengesWithoutCredentials(t *testing.T) {
|
||||
cache := newCredCache(time.Minute)
|
||||
h := basicAuth(aliceAuth(new(int32)), testUserHeader, "uopi", true, cache, quietLogger(), echoUser())
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, basicReq(t, "", "", ""))
|
||||
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("want 401, got %d", rec.Code)
|
||||
}
|
||||
if got := rec.Header().Get("WWW-Authenticate"); got == "" {
|
||||
t.Fatalf("missing WWW-Authenticate challenge header")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBasicAuthValidCredentialsStampUser(t *testing.T) {
|
||||
cache := newCredCache(time.Minute)
|
||||
h := basicAuth(aliceAuth(new(int32)), testUserHeader, "uopi", true, cache, quietLogger(), echoUser())
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
// Client also tries to spoof the identity header; it must be ignored.
|
||||
h.ServeHTTP(rec, basicReq(t, "alice", "secret", "root"))
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("want 200, got %d", rec.Code)
|
||||
}
|
||||
if got := rec.Body.String(); got != "alice" {
|
||||
t.Fatalf("want resolved user alice, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBasicAuthInvalidCredentialsRejected(t *testing.T) {
|
||||
cache := newCredCache(time.Minute)
|
||||
h := basicAuth(aliceAuth(new(int32)), testUserHeader, "uopi", true, cache, quietLogger(), echoUser())
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, basicReq(t, "alice", "wrong", ""))
|
||||
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("want 401, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBasicAuthBestEffortFallsThrough(t *testing.T) {
|
||||
cache := newCredCache(time.Minute)
|
||||
// challenge=false (WebSocket path): no creds → pass through unauthenticated.
|
||||
h := basicAuth(aliceAuth(new(int32)), testUserHeader, "uopi", false, cache, quietLogger(), echoUser())
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, basicReq(t, "", "", ""))
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("want 200 pass-through, got %d", rec.Code)
|
||||
}
|
||||
if got := rec.Body.String(); got != "" {
|
||||
t.Fatalf("want empty identity, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBasicAuthCachesSuccess(t *testing.T) {
|
||||
var calls int32
|
||||
cache := newCredCache(time.Minute)
|
||||
h := basicAuth(aliceAuth(&calls), testUserHeader, "uopi", true, cache, quietLogger(), echoUser())
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, basicReq(t, "alice", "secret", ""))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("req %d: want 200, got %d", i, rec.Code)
|
||||
}
|
||||
}
|
||||
if got := atomic.LoadInt32(&calls); got != 1 {
|
||||
t.Fatalf("want authFn called once (cached), got %d", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
stdlog "log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/jcmturner/goidentity/v6"
|
||||
"github.com/jcmturner/gokrb5/v8/keytab"
|
||||
"github.com/jcmturner/gokrb5/v8/service"
|
||||
"github.com/jcmturner/gokrb5/v8/spnego"
|
||||
)
|
||||
|
||||
// internalUserHeader is the request header the built-in authentication
|
||||
// middlewares (Kerberos, Basic) use to hand the validated username to the
|
||||
// downstream access pipeline when no external TrustedUserHeader is configured. It
|
||||
// is always stripped from inbound requests before validation, so a client cannot
|
||||
// spoof it.
|
||||
const internalUserHeader = "X-Uopi-User"
|
||||
|
||||
// kerberosAuth wraps next with SPNEGO/Kerberos ("Negotiate") authentication. On a
|
||||
// successful handshake the authenticated principal's short username (realm
|
||||
// stripped) is written into userHeader, so the existing access pipeline
|
||||
// (accessMiddleware / wsHandler, which both read userHeader) resolves identity
|
||||
// uniformly whether it originated from a trusted proxy header or a Kerberos
|
||||
// ticket.
|
||||
//
|
||||
// challenge controls behaviour when a request carries no valid Negotiate
|
||||
// credentials:
|
||||
// - challenge=true (REST/page requests): delegate to gokrb5, which replies
|
||||
// 401 + WWW-Authenticate: Negotiate so the browser performs SPNEGO.
|
||||
// - challenge=false (WebSocket upgrades): fall through unauthenticated.
|
||||
// Browsers cannot attach an Authorization header when opening a WebSocket;
|
||||
// they only send Negotiate proactively to trusted URIs. When the header is
|
||||
// present we still validate it, otherwise the session resolves to
|
||||
// default_user exactly as before.
|
||||
//
|
||||
// Any inbound value of userHeader is always discarded before validation: with
|
||||
// native Kerberos there is no trusted proxy stripping client-supplied headers, so
|
||||
// only the SPNEGO-validated identity may set it.
|
||||
func kerberosAuth(kt *keytab.Keytab, spn, userHeader string, challenge bool, log *slog.Logger, next http.Handler) http.Handler {
|
||||
opts := []func(*service.Settings){
|
||||
service.Logger(stdlog.New(slogWriter{log: log}, "", 0)),
|
||||
}
|
||||
if spn != "" {
|
||||
opts = append(opts, service.KeytabPrincipal(spn))
|
||||
}
|
||||
|
||||
// inner runs only after a successful SPNEGO handshake: it copies the resolved
|
||||
// identity into userHeader and continues down the chain.
|
||||
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
id := goidentity.FromHTTPRequestContext(r)
|
||||
if id == nil {
|
||||
http.Error(w, "kerberos: missing identity", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
user := id.UserName()
|
||||
if i := strings.IndexByte(user, '@'); i >= 0 {
|
||||
user = user[:i]
|
||||
}
|
||||
r.Header.Set(userHeader, user)
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
validate := spnego.SPNEGOKRB5Authenticate(inner, kt, opts...)
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Never trust a client-supplied identity header under native Kerberos.
|
||||
r.Header.Del(userHeader)
|
||||
|
||||
negotiate := strings.HasPrefix(r.Header.Get("Authorization"), spnego.HTTPHeaderAuthResponseValueKey)
|
||||
if !negotiate && !challenge {
|
||||
// Best-effort path (WebSocket without proactive credentials): continue
|
||||
// unauthenticated; downstream resolves the session to default_user.
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
// Credentials are present (validate them) or a challenge is required.
|
||||
validate.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// slogWriter adapts the std logger gokrb5 expects to slog at debug level so SPNEGO
|
||||
// validation diagnostics surface without polluting normal output.
|
||||
type slogWriter struct{ log *slog.Logger }
|
||||
|
||||
func (s slogWriter) Write(p []byte) (int, error) {
|
||||
if s.log != nil {
|
||||
s.log.Debug("kerberos", "msg", strings.TrimRight(string(p), "\n"))
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
+105
-10
@@ -4,10 +4,12 @@ import (
|
||||
"context"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jcmturner/gokrb5/v8/keytab"
|
||||
"github.com/uopi/uopi/internal/access"
|
||||
"github.com/uopi/uopi/internal/api"
|
||||
"github.com/uopi/uopi/internal/audit"
|
||||
@@ -23,18 +25,35 @@ import (
|
||||
const apiPrefix = "/api/v1"
|
||||
|
||||
type Server struct {
|
||||
httpServer *http.Server
|
||||
log *slog.Logger
|
||||
httpServer *http.Server
|
||||
tlsCert string
|
||||
tlsKey string
|
||||
tlsRedirect string // plain-HTTP addr that 301-redirects to HTTPS; empty = off
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// New creates the HTTP server, registers all routes, and returns a ready-to-start Server.
|
||||
// synth may be nil if the synthetic data source is not enabled.
|
||||
func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, cfgStore *confmgr.Store, policy *access.Policy, acl *panelacl.Store, ctrlLogic *controllogic.Store, ctrlEngine *controllogic.Engine, dialogs *DialogHub, debug *DebugHub, rec audit.Recorder, channelFinderURL, archiverURL, trustedUserHeader string, log *slog.Logger) *Server {
|
||||
//
|
||||
// basicAuthFn, when non-nil, enables built-in HTTP Basic authentication validated
|
||||
// by that function (typically PAM): REST requests are challenged (401 Basic) and
|
||||
// WebSocket upgrades validate proactively-resent credentials best-effort. It is
|
||||
// mutually exclusive with Kerberos (krbKeytab). When tlsCert and tlsKey are both
|
||||
// set the server serves HTTPS via ListenAndServeTLS.
|
||||
func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, cfgStore *confmgr.Store, policy *access.Policy, acl *panelacl.Store, ctrlLogic *controllogic.Store, ctrlEngine *controllogic.Engine, dialogs *DialogHub, debug *DebugHub, rec audit.Recorder, channelFinderURL, archiverURL, trustedUserHeader string, krbKeytab *keytab.Keytab, krbSPN string, basicAuthFn func(user, pass string) error, basicAuthRealm, tlsCert, tlsKey, tlsRedirect string, uiDefaultZoom float64, log *slog.Logger) *Server {
|
||||
if rec == nil {
|
||||
rec = audit.Nop()
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// When built-in authentication (Kerberos or Basic) is enabled but no external
|
||||
// proxy header is configured, use an internal header to carry the validated
|
||||
// username downstream.
|
||||
userHeader := trustedUserHeader
|
||||
if (krbKeytab != nil || basicAuthFn != nil) && userHeader == "" {
|
||||
userHeader = internalUserHeader
|
||||
}
|
||||
|
||||
// Health check
|
||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
@@ -42,7 +61,7 @@ func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Syntheti
|
||||
})
|
||||
|
||||
// WebSocket endpoint
|
||||
mux.Handle("/ws", &wsHandler{broker: brk, log: log, userHeader: trustedUserHeader, policy: policy, audit: rec, dialogs: dialogs, debug: debug})
|
||||
var wsHandlerH http.Handler = &wsHandler{broker: brk, log: log, userHeader: userHeader, policy: policy, audit: rec, dialogs: dialogs, debug: debug}
|
||||
|
||||
// Prometheus-format metrics
|
||||
mux.HandleFunc("/metrics", metrics.Handler(brk.ActiveSubscriptions))
|
||||
@@ -50,11 +69,37 @@ func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Syntheti
|
||||
// REST API — registered on a dedicated mux so it can be wrapped with the
|
||||
// access-control middleware (identity resolution + global level enforcement).
|
||||
apiMux := http.NewServeMux()
|
||||
api.New(brk, synth, store, cfgStore, policy, acl, ctrlLogic, ctrlEngine, rec, channelFinderURL, archiverURL, log).Register(apiMux, apiPrefix)
|
||||
mux.Handle(apiPrefix+"/", accessMiddleware(policy, trustedUserHeader, apiMux))
|
||||
api.New(brk, synth, store, cfgStore, policy, acl, ctrlLogic, ctrlEngine, rec, channelFinderURL, archiverURL, uiDefaultZoom, log).Register(apiMux, apiPrefix)
|
||||
var apiHandler http.Handler = accessMiddleware(policy, userHeader, apiMux)
|
||||
|
||||
// Native SPNEGO/Kerberos authentication (optional). REST/page requests are
|
||||
// challenged (401 Negotiate); WebSocket upgrades validate proactively-sent
|
||||
// credentials best-effort. Both stash the validated username in userHeader.
|
||||
var frontendHandler http.Handler = http.FileServerFS(webFS)
|
||||
|
||||
if krbKeytab != nil {
|
||||
apiHandler = kerberosAuth(krbKeytab, krbSPN, userHeader, true, log, apiHandler)
|
||||
wsHandlerH = kerberosAuth(krbKeytab, krbSPN, userHeader, false, log, wsHandlerH)
|
||||
} else if basicAuthFn != nil {
|
||||
// Built-in HTTP Basic authentication (validated by basicAuthFn, e.g. PAM).
|
||||
// A shared positive-result cache spares a validation round-trip on every
|
||||
// browser request. REST is challenged; WebSocket upgrades are best-effort.
|
||||
cache := newCredCache(5 * time.Minute)
|
||||
apiHandler = basicAuth(basicAuthFn, userHeader, basicAuthRealm, true, cache, log, apiHandler)
|
||||
wsHandlerH = basicAuth(basicAuthFn, userHeader, basicAuthRealm, false, cache, log, wsHandlerH)
|
||||
// Challenge the top-level page load too. Browsers only show the native
|
||||
// Basic login dialog in response to a 401 on a navigation, not on the
|
||||
// SPA's background fetch('/api/v1/me'); without this the user is never
|
||||
// prompted and silently resolves to default_user. Once credentials are
|
||||
// entered the browser caches them for the origin and resends them on
|
||||
// every asset, API call and the WebSocket upgrade.
|
||||
frontendHandler = basicAuth(basicAuthFn, userHeader, basicAuthRealm, true, cache, log, frontendHandler)
|
||||
}
|
||||
mux.Handle("/ws", wsHandlerH)
|
||||
mux.Handle(apiPrefix+"/", apiHandler)
|
||||
|
||||
// Embedded frontend — must be last (catch-all)
|
||||
mux.Handle("/", http.FileServerFS(webFS))
|
||||
mux.Handle("/", frontendHandler)
|
||||
|
||||
return &Server{
|
||||
httpServer: &http.Server{
|
||||
@@ -64,7 +109,10 @@ func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Syntheti
|
||||
WriteTimeout: 30 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
},
|
||||
log: log,
|
||||
tlsCert: tlsCert,
|
||||
tlsKey: tlsKey,
|
||||
tlsRedirect: tlsRedirect,
|
||||
log: log,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,15 +156,40 @@ func accessMiddleware(policy *access.Policy, userHeader string, next http.Handle
|
||||
|
||||
// Start listens and serves until ctx is cancelled.
|
||||
func (s *Server) Start(ctx context.Context) error {
|
||||
s.log.Info("listening", "addr", s.httpServer.Addr)
|
||||
tls := s.tlsCert != "" && s.tlsKey != ""
|
||||
s.log.Info("listening", "addr", s.httpServer.Addr, "tls", tls)
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
if err := s.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
var err error
|
||||
if tls {
|
||||
err = s.httpServer.ListenAndServeTLS(s.tlsCert, s.tlsKey)
|
||||
} else {
|
||||
err = s.httpServer.ListenAndServe()
|
||||
}
|
||||
if err != nil && err != http.ErrServerClosed {
|
||||
errCh <- err
|
||||
}
|
||||
}()
|
||||
|
||||
// Optional plain-HTTP redirector: upgrade http:// visitors to the HTTPS
|
||||
// service instead of letting them hit the TLS port with a cleartext request.
|
||||
var redirectSrv *http.Server
|
||||
if tls && s.tlsRedirect != "" {
|
||||
redirectSrv = &http.Server{
|
||||
Addr: s.tlsRedirect,
|
||||
Handler: httpsRedirectHandler(s.httpServer.Addr),
|
||||
ReadTimeout: 10 * time.Second,
|
||||
WriteTimeout: 10 * time.Second,
|
||||
}
|
||||
s.log.Info("http→https redirect listening", "addr", s.tlsRedirect)
|
||||
go func() {
|
||||
if err := redirectSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
errCh <- err
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
select {
|
||||
case err := <-errCh:
|
||||
return err
|
||||
@@ -124,6 +197,28 @@ func (s *Server) Start(ctx context.Context) error {
|
||||
shutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
s.log.Info("shutting down")
|
||||
if redirectSrv != nil {
|
||||
_ = redirectSrv.Shutdown(shutCtx)
|
||||
}
|
||||
return s.httpServer.Shutdown(shutCtx)
|
||||
}
|
||||
}
|
||||
|
||||
// httpsRedirectHandler 301-redirects any plain-HTTP request to the HTTPS service.
|
||||
// It preserves the requested hostname and path, swapping in the TLS listener's
|
||||
// port (omitted when 443).
|
||||
func httpsRedirectHandler(tlsAddr string) http.Handler {
|
||||
_, tlsPort, _ := net.SplitHostPort(tlsAddr)
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
host := r.Host
|
||||
if h, _, err := net.SplitHostPort(host); err == nil {
|
||||
host = h
|
||||
}
|
||||
target := "https://" + host
|
||||
if tlsPort != "" && tlsPort != "443" {
|
||||
target += ":" + tlsPort
|
||||
}
|
||||
target += r.URL.RequestURI()
|
||||
http.Redirect(w, r, target, http.StatusMovedPermanently)
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user