v0.24.0 cli long and short options, http sink viewer and new logo
This commit is contained in:
1 parent
bf410e1225
commit
9e86d7999d
42 files changed
+786
-404
No files matched your search
@@ -532,6 +532,10 @@ func (p *Policy) NodeBinding() string {
|
||||
// Enabled reports whether a policy is in force
|
||||
func (p *Policy) Enabled() bool { return p != nil }
|
||||
|
||||
// NeedsLogin reports a scram listener: its clients log in at /auth before any
|
||||
// protected request, where mtls admits by the certificate alone
|
||||
func (p *Policy) NeedsLogin() bool { return p != nil && p.listener != nil }
|
||||
|
||||
// Unrestricted reports whether an mtls policy admits any identity the CA
|
||||
// vouches for. Under scram the credentials file is the allow list.
|
||||
func (p *Policy) Unrestricted() bool {
|
||||
|
||||
@@ -177,7 +177,7 @@ func TestBrowserSessionBehindProxy(t *testing.T) {
|
||||
}
|
||||
|
||||
// Behind a proxy logwisp never sees the certificate a client binds to, so
|
||||
// only unbound proofs (lw auth token -unbound) log in.
|
||||
// only unbound proofs (lw auth token --unbound) log in.
|
||||
func TestOnlyUnboundLoginsBehindProxy(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
l := f.listener(t, config.AuthOptions{TrustedProxies: []string{"127.0.0.1"}}, f.serverTLS, RoleListener, HTTP)
|
||||
|
||||
@@ -355,9 +355,9 @@ func (p *Policy) finish(ip, nonce string, step authStep, cs *tls.ConnectionState
|
||||
p.rejected.Add(1)
|
||||
switch {
|
||||
case step.Binding != "" && l.proxy != nil:
|
||||
err = fmt.Errorf("%w; the client bound its proof to the proxy's certificate: behind trusted_proxies, log in unbound (lw auth token -unbound)", err)
|
||||
err = fmt.Errorf("%w; the client bound its proof to the proxy's certificate: behind trusted_proxies, log in unbound (lw auth token --unbound)", err)
|
||||
case step.Binding == "" && l.cb != nil:
|
||||
err = fmt.Errorf("%w; the client sent an unbound proof (-unbound), but this listener binds logins to its certificate", err)
|
||||
err = fmt.Errorf("%w; the client sent an unbound proof (--unbound), but this listener binds logins to its certificate", err)
|
||||
case step.Binding != "" && step.Binding != base64.StdEncoding.EncodeToString(l.cb):
|
||||
l.bindingMismatch.Add(1)
|
||||
err = fmt.Errorf("%w; the client saw another server certificate: TLS interception or a terminating proxy", err)
|
||||
|
||||
@@ -59,13 +59,13 @@ var presets = []Preset{
|
||||
p.Flow.Format = &FormatConfig{Type: v["format"]}
|
||||
return nil
|
||||
}},
|
||||
{"serve", "Serve files or standard input as a live HTTP stream (SSE)",
|
||||
{"serve", "Serve files or standard input live: a browser viewer at /, SSE at /stream",
|
||||
append([]PresetParam{pathParam, fromParam, formatParam("json"),
|
||||
{Name: "listen", Default: "127.0.0.1:8080", Help: "HOST:PORT; [IPV6]:PORT"},
|
||||
{Name: "tls", Default: "off", Help: "off|self|issuer|files: a certificate made at startup (self-signed or from issuer_*), or files"},
|
||||
{Name: "users", Help: "credentials file (lw auth add-user): readers log in with SCRAM"},
|
||||
{Name: "proxy", List: true, Help: "addresses or CIDRs of the TLS-terminating proxies browsers come through; ',' between them"},
|
||||
{Name: "viewer", Default: "false", Help: "true: the built-in login and viewer pages, behind proxy"},
|
||||
{Name: "viewer", Default: "false", Help: "true: the login page and viewer for users, behind proxy; without users the viewer is always on"},
|
||||
}, tlsParams...),
|
||||
func(p *PipelineConfig, v map[string]string) error {
|
||||
p.PluginSources = pathOrStdin(v)
|
||||
@@ -85,6 +85,9 @@ var presets = []Preset{
|
||||
}
|
||||
switch v["viewer"] {
|
||||
case "true":
|
||||
if v["proxy"] == "" {
|
||||
return fmt.Errorf("viewer=true needs proxy, behind which browsers log in; without users the viewer is always on")
|
||||
}
|
||||
sink["login_page"], sink["viewer_page"] = true, true
|
||||
case "false":
|
||||
default:
|
||||
|
||||
@@ -49,6 +49,7 @@ func TestPresetKeysAreChecked(t *testing.T) {
|
||||
"serve,tls=files,cert=c": "tls=files needs key",
|
||||
"serve,viewer=yes": `viewer "yes"`,
|
||||
"serve,proxy=127.0.0.1": "proxy needs users",
|
||||
"serve,viewer=true": "viewer=true needs proxy",
|
||||
"edge,to=agg:9000": "it never sends unauthenticated",
|
||||
"edge,to=agg:9000,user=u": "user and password_file go together",
|
||||
"edge,to=agg:9000,user=u,password_file=p,transport=udp": `transport "udp"`,
|
||||
|
||||
@@ -116,7 +116,7 @@ func (p *Pipeline) initializeComponents() error {
|
||||
sessionProxy,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create source %s: %w", srcCfg.ID, err)
|
||||
return err // the registry names the plugin
|
||||
}
|
||||
|
||||
// Check and inject capabilities using core interfaces
|
||||
@@ -144,7 +144,7 @@ func (p *Pipeline) initializeComponents() error {
|
||||
sessionProxy,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create sink %s: %w", sinkCfg.ID, err)
|
||||
return err // the registry names the plugin
|
||||
}
|
||||
|
||||
// Check and inject capabilities using core interfaces
|
||||
|
||||
+45
-34
@@ -10,6 +10,7 @@ import (
|
||||
"maps"
|
||||
"net"
|
||||
"net/http"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -77,7 +78,7 @@ type HTTPSink struct {
|
||||
|
||||
// Authorization
|
||||
auth *authz.Policy
|
||||
web map[string]http.Handler // proxy mode: GET /auth/... browser files
|
||||
web map[string]http.Handler // GET paths of the browser files and the root
|
||||
|
||||
// Runtime. Stop closes flush; the broker then moves what remains of its
|
||||
// input to tail and closes flushed; each stream writes its queue, tail
|
||||
@@ -128,22 +129,21 @@ func NewHTTPSinkPlugin(
|
||||
}
|
||||
if opts.StreamPath == "" {
|
||||
opts.StreamPath = DefaultHTTPStreamPath
|
||||
} else if !strings.HasPrefix(opts.StreamPath, "/") {
|
||||
return nil, fmt.Errorf("stream_path: must start with '/'")
|
||||
}
|
||||
if opts.StatusPath == "" {
|
||||
opts.StatusPath = DefaultHTTPStatusPath
|
||||
} else if !strings.HasPrefix(opts.StatusPath, "/") {
|
||||
return nil, fmt.Errorf("status_path: must start with '/'")
|
||||
}
|
||||
for _, o := range []struct{ name, p string }{{"stream_path", opts.StreamPath}, {"status_path", opts.StatusPath}} {
|
||||
if !routable(o.p) {
|
||||
return nil, fmt.Errorf("%s %q: must start with '/', hold no '//', '.' or '..' segment, and none of '{', '}', '%%', '?', '#'", o.name, o.p)
|
||||
}
|
||||
if p := o.p; p == chain.AuthPath || strings.HasPrefix(p, chain.AuthPath+"/") {
|
||||
return nil, fmt.Errorf("%s and the paths under it are reserved for authentication", chain.AuthPath)
|
||||
}
|
||||
}
|
||||
if opts.StreamPath == opts.StatusPath {
|
||||
return nil, fmt.Errorf("stream_path and status_path must differ")
|
||||
}
|
||||
for _, p := range []string{opts.StreamPath, opts.StatusPath} {
|
||||
if p == chain.AuthPath || strings.HasPrefix(p, chain.AuthPath+"/") {
|
||||
return nil, fmt.Errorf("%s and the paths under it are reserved for authentication", chain.AuthPath)
|
||||
}
|
||||
}
|
||||
if opts.BufferSize <= 0 {
|
||||
opts.BufferSize = DefaultHTTPBufferSize
|
||||
}
|
||||
@@ -160,15 +160,13 @@ func NewHTTPSinkPlugin(
|
||||
}
|
||||
switch {
|
||||
case (opts.LoginPage || opts.ViewerPage) && !authPolicy.BehindProxy():
|
||||
return nil, errors.New("login_page and viewer_page need auth.trusted_proxies: browsers log in only behind a TLS-terminating proxy")
|
||||
return nil, errors.New("login_page and viewer_page apply to scram behind auth.trusted_proxies, where browsers log in; without auth or under mtls the viewer is always served")
|
||||
case opts.ViewerPage && !opts.LoginPage:
|
||||
return nil, errors.New("viewer_page needs login_page, where it sends a signed-out viewer")
|
||||
}
|
||||
var web map[string]http.Handler
|
||||
if authPolicy.BehindProxy() {
|
||||
if web, err = webHandlers(opts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
web, err := webHandlers(opts, authPolicy)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
h := &HTTPSink{
|
||||
@@ -202,7 +200,7 @@ func NewHTTPSinkPlugin(
|
||||
"mtls", tlsCfg != nil && tlsCfg.ClientAuth == tls.RequireAndVerifyClientCert,
|
||||
"auth", authPolicy.Describe(),
|
||||
"login_page", opts.LoginPage,
|
||||
"viewer_page", opts.ViewerPage)
|
||||
"viewer_page", web[chain.AuthPath+"/view"] != nil)
|
||||
tlsx.LogWarnings(logger, "http_sink", id, opts.TLS, true)
|
||||
authPolicy.LogStartup(logger, "http_sink", id, false)
|
||||
return h, nil
|
||||
@@ -248,28 +246,26 @@ func (h *HTTPSink) serve(ctx context.Context, ln net.Listener) error {
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
// Method-scoped patterns: mux answers 405 with Allow header on non-GET
|
||||
mux.HandleFunc(http.MethodGet+" "+h.config.StreamPath, h.handleStream)
|
||||
mux.HandleFunc(http.MethodGet+" "+h.config.StatusPath, h.handleStatus)
|
||||
mux.HandleFunc(http.MethodGet+" "+exact(h.config.StreamPath), h.handleStream)
|
||||
mux.HandleFunc(http.MethodGet+" "+exact(h.config.StatusPath), h.handleStatus)
|
||||
// A GET pattern also serves HEAD, and a HEAD stream is a registered client
|
||||
// whose body writes are discarded: it never reads, so nothing but the peer
|
||||
// closing the connection ends it. The status path answers one either way.
|
||||
mux.HandleFunc(http.MethodHead+" "+h.config.StreamPath, streamHeadNotAllowed)
|
||||
mux.HandleFunc(http.MethodHead+" "+exact(h.config.StreamPath), streamHeadNotAllowed)
|
||||
|
||||
// One wrapper covers stream and status, and keeps the handlers themselves
|
||||
// unaware of authorization. Login and its browser files sit outside it;
|
||||
// in proxy mode everything sits behind the proxy gate.
|
||||
var handler http.Handler = mux
|
||||
if h.auth.Enabled() {
|
||||
outer := http.NewServeMux()
|
||||
outer.HandleFunc(http.MethodPost+" "+chain.AuthPath, h.handleAuth)
|
||||
for p, file := range h.web {
|
||||
outer.Handle(http.MethodGet+" "+p, file)
|
||||
}
|
||||
outer.Handle("/", h.authMiddleware(mux))
|
||||
handler = outer
|
||||
if h.auth.BehindProxy() {
|
||||
handler = h.proxyGate(outer)
|
||||
}
|
||||
// unaware of authorization; a nil policy admits every request. Login, the
|
||||
// browser files and the root sit outside it; in proxy mode everything sits
|
||||
// behind the proxy gate.
|
||||
outer := http.NewServeMux()
|
||||
outer.HandleFunc(http.MethodPost+" "+chain.AuthPath, h.handleAuth)
|
||||
for p, file := range h.web {
|
||||
outer.Handle(http.MethodGet+" "+p, file)
|
||||
}
|
||||
outer.Handle("/", h.authMiddleware(mux))
|
||||
var handler http.Handler = outer
|
||||
if h.auth.BehindProxy() {
|
||||
handler = h.proxyGate(outer)
|
||||
}
|
||||
|
||||
h.server = &http.Server{
|
||||
@@ -671,6 +667,21 @@ func (h *HTTPSink) handleAuth(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// routable reports a path ServeMux and a URL take literally: clean, a trailing
|
||||
// '/' aside, without the braces of wildcards, an escape, a query or a fragment
|
||||
func routable(p string) bool {
|
||||
c := path.Clean(p)
|
||||
return strings.HasPrefix(p, "/") && !strings.ContainsAny(p, "{}%?#") && (c == p || c != "/" && c+"/" == p)
|
||||
}
|
||||
|
||||
// exact keeps a path ending in '/' from matching every path beneath it
|
||||
func exact(p string) string {
|
||||
if strings.HasSuffix(p, "/") {
|
||||
return p + "{$}"
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// streamHeadNotAllowed refuses a body-less read of a stream that is only a body
|
||||
func streamHeadNotAllowed(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Allow", http.MethodGet)
|
||||
|
||||
@@ -4,14 +4,12 @@ import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -138,17 +136,6 @@ func TestHeadOnStreamPathIsRefused(t *testing.T) {
|
||||
// open stream keeps the wildcard so browser dashboards still work.
|
||||
func TestWildcardCORSOnlyWithoutAuth(t *testing.T) {
|
||||
pki := testutil.NewPKI(t, "viewer-01")
|
||||
caPEM, err := os.ReadFile(pki.CA)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
roots := x509.NewCertPool()
|
||||
roots.AppendCertsFromPEM(caPEM)
|
||||
clientCert, err := tls.LoadX509KeyPair(pki.ClientCert, pki.ClientKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
open, _ := newTestHTTPSink(t, nil)
|
||||
gated, _ := newTestHTTPSink(t, map[string]any{
|
||||
"tls": map[string]any{"enabled": true, "cert_file": pki.ServerCert, "key_file": pki.ServerKey,
|
||||
@@ -162,8 +149,7 @@ func TestWildcardCORSOnlyWithoutAuth(t *testing.T) {
|
||||
}{{"open", open, "*"}, {"gated", gated, ""}} {
|
||||
client, baseURL := serveTestHTTPSink(t, tc.sink)
|
||||
if tc.sink.tlsConfig != nil {
|
||||
client.Transport = &http.Transport{TLSClientConfig: &tls.Config{RootCAs: roots, Certificates: []tls.Certificate{clientCert}}}
|
||||
baseURL = "https" + strings.TrimPrefix(baseURL, "http")
|
||||
baseURL = trustPKI(t, client, baseURL, pki, true)
|
||||
}
|
||||
resp, err := client.Get(baseURL + "/stream")
|
||||
if err != nil {
|
||||
@@ -189,14 +175,7 @@ func TestLoginEndpointBypassesTheGate(t *testing.T) {
|
||||
"auth": map[string]any{"type": "scram", "credentials_file": creds},
|
||||
})
|
||||
client, baseURL := serveTestHTTPSink(t, gated)
|
||||
caPEM, err := os.ReadFile(pki.CA)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
roots := x509.NewCertPool()
|
||||
roots.AppendCertsFromPEM(caPEM)
|
||||
client.Transport = &http.Transport{TLSClientConfig: &tls.Config{RootCAs: roots}}
|
||||
baseURL = "https" + strings.TrimPrefix(baseURL, "http")
|
||||
baseURL = trustPKI(t, client, baseURL, pki, false)
|
||||
|
||||
hello := `{"logwisp":1,"scram":{"username":"viewer-01","client_nonce":"abcdefgh"}}`
|
||||
resp, err := client.Post(baseURL+"/auth", "application/json", strings.NewReader(hello))
|
||||
@@ -268,6 +247,85 @@ func TestProxyModeServesBrowserFiles(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The root leads to the page a browser can use: the viewer, needing no login
|
||||
// without auth and under mtls; in proxy mode the viewer, or else the login
|
||||
// page; nothing under direct scram, which a browser cannot log in to; and an
|
||||
// endpoint at "/" keeps the root.
|
||||
func TestRootLeadsToThePageABrowserCanUse(t *testing.T) {
|
||||
pki := testutil.NewPKI(t, "viewer-01")
|
||||
tlsOn := map[string]any{"enabled": true, "cert_file": pki.ServerCert, "key_file": pki.ServerKey}
|
||||
mtlsOn := map[string]any{"enabled": true, "cert_file": pki.ServerCert, "key_file": pki.ServerKey,
|
||||
"client_auth": true, "client_ca_file": pki.CA}
|
||||
creds := scramCredentials(t)
|
||||
proxy := map[string]any{"type": "scram", "credentials_file": creds, "trusted_proxies": []any{"127.0.0.1"}}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
opts map[string]any
|
||||
code int
|
||||
target, mode string // the root's Location, and that page's logwisp-login
|
||||
}{
|
||||
{"no auth", nil, http.StatusSeeOther, "auth/view", "none"},
|
||||
{"mtls", map[string]any{"tls": mtlsOn, "auth": map[string]any{"type": "mtls"}}, http.StatusSeeOther, "auth/view", "none"},
|
||||
{"proxy, viewer", map[string]any{"auth": proxy, "login_page": true, "viewer_page": true}, http.StatusSeeOther, "auth/view", "scram"},
|
||||
{"proxy, login page", map[string]any{"auth": proxy, "login_page": true}, http.StatusSeeOther, "auth/login", ""},
|
||||
{"direct scram", map[string]any{"tls": tlsOn, "auth": map[string]any{"type": "scram", "credentials_file": creds}}, http.StatusUnauthorized, "", ""},
|
||||
{"status at the root", map[string]any{"status_path": "/"}, http.StatusOK, "", ""},
|
||||
} {
|
||||
h, _ := newTestHTTPSink(t, tc.opts)
|
||||
client, baseURL := serveTestHTTPSink(t, h)
|
||||
client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
|
||||
if h.tlsConfig != nil {
|
||||
baseURL = trustPKI(t, client, baseURL, pki, h.tlsConfig.ClientAuth == tls.RequireAndVerifyClientCert)
|
||||
}
|
||||
get := func(path string) (*http.Response, string) {
|
||||
req, _ := http.NewRequest(http.MethodGet, baseURL+path, nil)
|
||||
resp, err := client.Do(proxied(req))
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", tc.name, err)
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
return resp, string(body)
|
||||
}
|
||||
resp, _ := get("/")
|
||||
if resp.StatusCode != tc.code || resp.Header.Get("Location") != tc.target {
|
||||
t.Errorf("%s: GET / = %d to %q, want %d to %q", tc.name, resp.StatusCode, resp.Header.Get("Location"), tc.code, tc.target)
|
||||
}
|
||||
for _, file := range []string{tc.target + ".js", "auth/scram.js", "auth/style.css"} {
|
||||
if resp, _ := get("/" + file); tc.target != "" && resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("%s: /%s = %d, which its page loads", tc.name, file, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
if tc.mode == "" {
|
||||
continue
|
||||
}
|
||||
resp, page := get("/" + tc.target)
|
||||
if want := `name="logwisp-login" content="` + tc.mode + `"`; resp.StatusCode != http.StatusOK || !strings.Contains(page, want) {
|
||||
t.Errorf("%s: /%s = %d, without %s", tc.name, tc.target, resp.StatusCode, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A path ending in "/" matches itself only: a stream at the root does not
|
||||
// answer every other path, a browser's favicon request included
|
||||
func TestEndpointPathsMatchExactly(t *testing.T) {
|
||||
h, manager := newTestHTTPSink(t, map[string]any{"stream_path": "/"})
|
||||
client, baseURL := serveTestHTTPSink(t, h)
|
||||
for path, want := range map[string]int{"/favicon.ico": http.StatusNotFound, "/auth/view": http.StatusOK} {
|
||||
resp, err := client.Get(baseURL + path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != want {
|
||||
t.Errorf("GET %s = %d, want %d", path, resp.StatusCode, want)
|
||||
}
|
||||
}
|
||||
if n := manager.GetSessionCount(); n != 0 {
|
||||
t.Errorf("sessions = %d, want 0", n)
|
||||
}
|
||||
}
|
||||
|
||||
// Behind a proxy nothing answers a peer outside trusted_proxies, not even the
|
||||
// login page or the challenge.
|
||||
func TestProxyModeRefusesDirectPeers(t *testing.T) {
|
||||
@@ -289,9 +347,10 @@ func TestProxyModeRefusesDirectPeers(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Pages exist only behind a proxy, where browsers can log in; the viewer
|
||||
// needs the login page; /auth and the paths under it are reserved.
|
||||
func TestPagesNeedProxyMode(t *testing.T) {
|
||||
// The constructor refuses what it cannot serve: the login pages outside
|
||||
// scram's proxy mode, a viewer without the login page, a path under /auth, and
|
||||
// a path ServeMux or a URL would not take literally, which panicked at Start.
|
||||
func TestUnservableOptionsAreRefused(t *testing.T) {
|
||||
scram := map[string]any{"type": "scram", "credentials_file": scramCredentials(t)}
|
||||
pki := testutil.NewPKI(t, "viewer-01")
|
||||
tlsOn := map[string]any{"enabled": true, "cert_file": pki.ServerCert, "key_file": pki.ServerKey}
|
||||
@@ -300,6 +359,10 @@ func TestPagesNeedProxyMode(t *testing.T) {
|
||||
"login page without proxies": {"tls": tlsOn, "auth": scram, "login_page": true},
|
||||
"viewer without login": {"auth": proxy, "viewer_page": true},
|
||||
"stream under /auth": {"auth": proxy, "stream_path": "/auth/stream"},
|
||||
"wildcard brace in a path": {"stream_path": "/a{b"},
|
||||
"unclean path": {"status_path": "//status"},
|
||||
"escape aliasing a path": {"stream_path": "/x", "status_path": "/%78"},
|
||||
"query in a path": {"status_path": "/a?b"},
|
||||
} {
|
||||
maps.Copy(opts, map[string]any{"host": "127.0.0.1", "port": int64(8081)})
|
||||
if _, err := NewHTTPSinkPlugin("stream", opts, log.NewLogger(), nil); err == nil {
|
||||
@@ -326,7 +389,7 @@ func TestSSEFramesEveryLineAsData(t *testing.T) {
|
||||
// stopped reading does; Stop returns at the flush bound.
|
||||
func TestStopWritesQueuedEventsToReadingStreams(t *testing.T) {
|
||||
const n = 2000 // of 8 KiB: more than a silent peer's socket buffers hold
|
||||
httpSink, _ := newTestHTTPSink(t, map[string]any{"buffer_size": int64(n), "client_buffer_size": int64(64), "write_timeout_ms": int64(500)})
|
||||
httpSink, _ := newTestHTTPSink(t, map[string]any{"buffer_size": int64(n), "client_buffer_size": int64(64), "write_timeout_ms": int64(2000)})
|
||||
client, baseURL := serveTestHTTPSink(t, httpSink)
|
||||
var scanners []*bufio.Scanner
|
||||
for range 2 {
|
||||
@@ -383,8 +446,8 @@ func TestStopWritesQueuedEventsToReadingStreams(t *testing.T) {
|
||||
}()
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
httpSink.clientsMu.Unlock()
|
||||
if d := <-stopped; d > 1500*time.Millisecond {
|
||||
t.Errorf("Stop took %v past a 500 ms flush bound", d)
|
||||
if d := <-stopped; d > 3500*time.Millisecond {
|
||||
t.Errorf("Stop took %v past a 2 s flush bound", d)
|
||||
}
|
||||
if r := <-done; r.lines != n || !r.disconnect {
|
||||
t.Fatalf("reading stream received %d of %d lines, disconnect %v", r.lines, n, r.disconnect)
|
||||
|
||||
@@ -2,10 +2,14 @@ package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"maps"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -51,6 +55,27 @@ func serveTestHTTPSink(t *testing.T, h *HTTPSink) (*http.Client, string) {
|
||||
return client, "http://" + listener.Addr().String()
|
||||
}
|
||||
|
||||
// trustPKI points client at https, trusting pki's CA and, with cert,
|
||||
// presenting its client certificate
|
||||
func trustPKI(t *testing.T, client *http.Client, baseURL string, pki *testutil.PKI, cert bool) string {
|
||||
t.Helper()
|
||||
caPEM, err := os.ReadFile(pki.CA)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := &tls.Config{RootCAs: x509.NewCertPool()}
|
||||
cfg.RootCAs.AppendCertsFromPEM(caPEM)
|
||||
if cert {
|
||||
pair, err := tls.LoadX509KeyPair(pki.ClientCert, pki.ClientKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg.Certificates = []tls.Certificate{pair}
|
||||
}
|
||||
client.Transport = &http.Transport{TLSClientConfig: cfg}
|
||||
return "https" + strings.TrimPrefix(baseURL, "http")
|
||||
}
|
||||
|
||||
// scramCredentials writes a credentials file holding viewer-01 under a cheap
|
||||
// Argon2 profile
|
||||
func scramCredentials(t *testing.T) string {
|
||||
|
||||
+38
-18
@@ -28,29 +28,30 @@ var webTypes = map[string]string{
|
||||
".html": "text/html; charset=utf-8",
|
||||
}
|
||||
|
||||
func loginOn(o *config.HTTPSinkOptions) bool { return o.LoginPage }
|
||||
func viewerOn(o *config.HTTPSinkOptions) bool { return o.ViewerPage }
|
||||
|
||||
// webRoutes are served under /auth/ in proxy mode: the client library always,
|
||||
// so a site can load it, and the pages when enabled
|
||||
// webRoutes are the browser files under /auth/, each with the page that needs
|
||||
// it; "" marks a shared file, served with any page and always in proxy mode,
|
||||
// where a site may load the client library on its own
|
||||
var webRoutes = []struct {
|
||||
name, file string
|
||||
on func(*config.HTTPSinkOptions) bool
|
||||
name, file, page string
|
||||
}{
|
||||
{"scram.js", "scram.js", func(*config.HTTPSinkOptions) bool { return true }},
|
||||
{"style.css", "style.css", loginOn},
|
||||
{"login", "login.html", loginOn},
|
||||
{"login.js", "login.js", loginOn},
|
||||
{"view", "view.html", viewerOn},
|
||||
{"view.js", "view.js", viewerOn},
|
||||
{"scram.js", "scram.js", ""},
|
||||
{"style.css", "style.css", ""},
|
||||
{"login", "login.html", "login"},
|
||||
{"login.js", "login.js", "login"},
|
||||
{"view", "view.html", "view"},
|
||||
{"view.js", "view.js", "view"},
|
||||
}
|
||||
|
||||
// webHandlers maps each enabled GET path to its file. The viewer learns a
|
||||
// custom status path from a meta tag, as the CSP allows no inline script.
|
||||
func webHandlers(o *config.HTTPSinkOptions) (map[string]http.Handler, error) {
|
||||
// webHandlers maps each GET path a browser uses to its handler. Without a
|
||||
// login (no auth, or mtls: the certificate is the login) the viewer is always
|
||||
// served; under scram the pages need proxy mode. The viewer learns the status
|
||||
// path and whether it logs in from meta tags, as the CSP allows no inline script.
|
||||
func webHandlers(o *config.HTTPSinkOptions, p *authz.Policy) (map[string]http.Handler, error) {
|
||||
open := !p.NeedsLogin()
|
||||
on := map[string]bool{"": open || p.BehindProxy(), "login": o.LoginPage, "view": open || o.ViewerPage}
|
||||
handlers := make(map[string]http.Handler)
|
||||
for _, r := range webRoutes {
|
||||
if !r.on(o) {
|
||||
if !on[r.page] {
|
||||
continue
|
||||
}
|
||||
data, err := webFiles.ReadFile("web/" + r.file)
|
||||
@@ -59,13 +60,32 @@ func webHandlers(o *config.HTTPSinkOptions) (map[string]http.Handler, error) {
|
||||
}
|
||||
if r.file == "view.html" {
|
||||
status := html.EscapeString(strings.TrimPrefix(o.StatusPath, "/"))
|
||||
data = bytes.Replace(data, []byte(`content="status"`), []byte(`content="`+status+`"`), 1)
|
||||
data = bytes.Replace(data, []byte(`name="logwisp-status" content="status"`), []byte(`name="logwisp-status" content="`+status+`"`), 1)
|
||||
if open {
|
||||
data = bytes.Replace(data, []byte(`name="logwisp-login" content="scram"`), []byte(`name="logwisp-login" content="none"`), 1)
|
||||
}
|
||||
}
|
||||
handlers[chain.AuthPath+"/"+r.name] = serveWebFile(data, webTypes[path.Ext(r.file)])
|
||||
}
|
||||
switch {
|
||||
case o.StreamPath == "/" || o.StatusPath == "/": // the endpoint owns the root
|
||||
case on["view"]:
|
||||
handlers["/{$}"] = seeOther("auth/view")
|
||||
case on["login"]:
|
||||
handlers["/{$}"] = seeOther("auth/login")
|
||||
}
|
||||
return handlers, nil
|
||||
}
|
||||
|
||||
// seeOther redirects to a relative target, which a proxy prefix keeps intact;
|
||||
// http.Redirect would resolve it against the backend's path
|
||||
func seeOther(target string) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Location", target)
|
||||
w.WriteHeader(http.StatusSeeOther)
|
||||
})
|
||||
}
|
||||
|
||||
func serveWebFile(data []byte, contentType string) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
hd := w.Header()
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>LogWisp stream</title>
|
||||
<meta name="logwisp-status" content="status">
|
||||
<meta name="logwisp-login" content="scram">
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<script type="module" src="view.js"></script>
|
||||
</head>
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { cookiesUsable, login, loginUnavailable, logout, stream } from "./scram.js";
|
||||
|
||||
const base = new URL("..", location.href);
|
||||
// The server writes its status path here, relative to the mount
|
||||
const statusPath = document.querySelector('meta[name="logwisp-status"]').content;
|
||||
// The server writes its status path here, relative to the mount, and "none"
|
||||
// as the login when the browser needs none: no auth, or its mTLS certificate
|
||||
const meta = (name) => document.querySelector(`meta[name="logwisp-${name}"]`).content;
|
||||
const statusPath = meta("status");
|
||||
const open = meta("login") === "none";
|
||||
const MAX_LINES = 5000;
|
||||
const log = document.getElementById("log");
|
||||
const state = document.getElementById("state");
|
||||
@@ -11,10 +14,11 @@ const form = document.getElementById("login");
|
||||
const progress = document.getElementById("progress");
|
||||
const status = document.getElementById("status");
|
||||
|
||||
const unavailable = loginUnavailable();
|
||||
// A page without a login needs no WebCrypto, so plain http works too
|
||||
const unavailable = open ? "" : loginUnavailable();
|
||||
// Without cookies the session is a token in this variable only: never stored,
|
||||
// so it ends with the page, or earlier with its lifetime or sign-out
|
||||
let tokenMode = !unavailable && !cookiesUsable();
|
||||
let tokenMode = !open && !unavailable && !cookiesUsable();
|
||||
let token = null;
|
||||
// The login page comes back with this fragment: a 401 now means the browser
|
||||
// dropped the session cookie
|
||||
@@ -45,7 +49,8 @@ async function connect() {
|
||||
} catch {
|
||||
return retry("server unreachable");
|
||||
}
|
||||
if (res.status === 401) return sessionEnded();
|
||||
// An open page has no session to end: a 401 there is a proxy's, so it retries
|
||||
if (res.status === 401 && !open) return sessionEnded();
|
||||
fresh = false;
|
||||
const path = res.ok ? (await res.json().catch(() => ({})))?.endpoints?.stream : null;
|
||||
if (typeof path !== "string" || !path.startsWith("/")) {
|
||||
@@ -168,5 +173,6 @@ if (unavailable) {
|
||||
state.textContent = unavailable;
|
||||
signOut.hidden = true;
|
||||
} else {
|
||||
signOut.hidden = open;
|
||||
connect();
|
||||
}
|
||||
@@ -80,7 +80,7 @@ func TestStopWritesQueuedEventsToReadingClients(t *testing.T) {
|
||||
t.Cleanup(manager.Stop)
|
||||
created, err := NewTCPSinkPlugin("out", map[string]any{
|
||||
"host": "127.0.0.1", "port": p, "buffer_size": int64(n), "client_buffer_size": int64(64),
|
||||
"write_timeout_ms": int64(500),
|
||||
"write_timeout_ms": int64(2000),
|
||||
}, log.NewLogger(), session.NewProxy(manager, "out"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -132,8 +132,8 @@ func TestStopWritesQueuedEventsToReadingClients(t *testing.T) {
|
||||
}()
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
sink.clientsMu.Unlock()
|
||||
if d := <-stopped; d > 1500*time.Millisecond {
|
||||
t.Errorf("Stop took %v past a 500 ms flush bound", d)
|
||||
if d := <-stopped; d > 3500*time.Millisecond {
|
||||
t.Errorf("Stop took %v past a 2 s flush bound", d)
|
||||
}
|
||||
if got := <-lines; got != n {
|
||||
t.Fatalf("reading client received %d of %d lines", got, n)
|
||||
|
||||
Reference in new issue
Block a user