v0.22.0 several enhancements and fixes for security, network, doc, test, packaging
Test / test (push) Canceled after 0s
Test / e2e (push) Canceled after 0s

This commit is contained in:
lixen committed 2026-10-03 14:52:16 -04:00
1 parent 9ab8c356d2
commit 71d1b82f83
102 files changed
+7102 -2178

No files matched your search

+22 -5
View File
@@ -414,13 +414,26 @@ func (p *Policy) Prepare(ctx context.Context, client *http.Client, baseURL strin
if p == nil || p.dialer == nil {
return nil
}
token := p.dialer.token.Load()
if token == nil || time.Now().UnixNano() >= p.dialer.renewAt.Load() {
d := p.dialer
token := d.token.Load()
if token == nil || time.Now().UnixNano() >= d.renewAt.Load() {
pin, expires := d.pin.Load(), d.expires.Load()
t, err := p.Token(ctx, client, baseURL)
if err != nil {
// A renewal put off while the old token holds (a throttled login behind
// a shared address) keeps sending on it and retries in a few seconds.
// Idle connections close first: Token dialed them with no pin, and the
// token may ride only connections the old pin verifies
var later putOff
if left := time.Duration(expires - time.Now().UnixNano()); errors.As(err, &later) && token != nil && left > 0 {
client.CloseIdleConnections()
d.pin.Store(pin)
d.token.Store(token)
d.renewAt.Store(time.Now().Add(min(5*time.Second, left/2)).UnixNano())
} else if err != nil {
return err
} else {
token = &t
}
token = &t
}
req.Header.Set("Authorization", "Bearer "+*token)
return nil
@@ -561,7 +574,11 @@ func (p *Policy) LogStartup(l *log.Logger, component, id string, trustNode bool)
"hint", "set auth.allow or auth.allow_patterns to authorize named peers")
}
if p.BindsNode() {
l.Info("msg", "Node labels bound to peer identity; trust_node is ignored",
msg := "Connection node label bound to peer identity; trust_node still governs per-entry labels"
if p.binding == BindingForce {
msg = "Node labels bound to peer identity; trust_node is ignored"
}
l.Info("msg", msg,
"component", component,
"instance_id", id,
"node_binding", p.binding,
+1 -1
View File
@@ -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 (logwisp 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)
+28 -7
View File
@@ -167,12 +167,12 @@ func (c *Credentials) Marshal() ([]byte, error) {
if err != nil {
return nil, err
}
header := "# logwisp SCRAM verifiers, written by `logwisp auth add-user`. Keep it private.\n"
header := "# logwisp SCRAM verifiers, written by `lw auth add-user`. Keep it private.\n"
return append([]byte(header), body...), nil
}
// ReadPassword reads a password file, trimming one trailing line break so a
// file from an editor and one from `logwisp auth add-user` agree.
// file from an editor and one from `lw auth add-user` agree.
func ReadPassword(path string) (string, error) {
data, err := os.ReadFile(path)
if err != nil {
@@ -355,7 +355,7 @@ 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 (logwisp 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)
case step.Binding != "" && step.Binding != base64.StdEncoding.EncodeToString(l.cb):
@@ -566,6 +566,7 @@ type scramDialer struct {
unbound bool // HTTP logins to a listener behind a TLS-terminating proxy
token atomic.Pointer[string]
renewAt atomic.Int64 // unix nanoseconds; Prepare logs in again from then
expires atomic.Int64 // unix nanoseconds; 0 = lifetime unknown
pin atomic.Pointer[[]byte] // HTTP: certificate the token's login was bound to
failures atomic.Uint64
lastErr atomic.Pointer[string]
@@ -722,7 +723,12 @@ func (p *Policy) Token(ctx context.Context, client *http.Client, baseURL string)
if step.Token == "" {
return "", errors.New("auth: server issued no token")
}
d.renewAt.Store(renewAt(time.Now(), time.Duration(step.ExpiresIn)*time.Second).UnixNano())
now, lifetime := time.Now(), time.Duration(step.ExpiresIn)*time.Second
d.renewAt.Store(renewAt(now, lifetime).UnixNano())
d.expires.Store(0)
if lifetime > 0 {
d.expires.Store(now.Add(lifetime).UnixNano())
}
d.token.Store(&step.Token)
return step.Token, nil
}
@@ -770,6 +776,12 @@ func (d *scramDialer) stats(m map[string]any) {
}
}
// putOff marks a login the server deferred (throttled, busy) rather than
// refused: a token still held stays usable meanwhile.
type putOff struct{ error }
func (e putOff) Unwrap() error { return e.error }
// postStep sends one /auth request; any answer but 200 is a refusal
func postStep(ctx context.Context, client *http.Client, url string, body []byte) (authStep, *http.Response, error) {
var step authStep
@@ -796,7 +808,11 @@ func postStep(ctx context.Context, client *http.Client, url string, body []byte)
if msg == "" {
msg = resp.Status
}
return step, resp, fmt.Errorf("%w: server: %s", ErrRefused, msg)
err := fmt.Errorf("%w: server: %s", ErrRefused, msg)
if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode == http.StatusServiceUnavailable {
err = putOff{err}
}
return step, resp, err
case decodeErr != nil:
return step, resp, fmt.Errorf("auth: malformed answer: %w", decodeErr)
}
@@ -888,13 +904,18 @@ func remoteIP(addr string) string {
// --- Throttling ---
// throttleKey is what the limiter counts: an address, or for IPv6 its /64,
// which one host usually holds whole
// which one host usually holds whole. Link-local too, per zone: a peer picks
// any fe80::/64 address, so per address it would escape its budget.
func throttleKey(ip string) string {
addr, err := netip.ParseAddr(ip)
if err != nil || addr.Is4() {
return ip
}
return netip.PrefixFrom(addr, 64).Masked().String()
key := netip.PrefixFrom(addr, 64).Masked().String()
if zone := addr.Zone(); zone != "" {
key += "%" + zone
}
return key
}
// limiter bounds SCRAM attempts per remote address: failed or abandoned
+86
View File
@@ -417,6 +417,15 @@ func TestDialerRefusesACheapChallenge(t *testing.T) {
}
}
// Link-local peers share one budget per link, not one across every link: the
// zone names the link and stays in the key.
func TestLinkLocalBudgetIsPerLink(t *testing.T) {
a, b, other := throttleKey("fe80::1%eth0"), throttleKey("fe80::2%eth0"), throttleKey("fe80::1%eth1")
if a != b || a == other {
t.Fatalf("keys %q, %q on one link, %q on another", a, b, other)
}
}
// Only failed attempts drain an address's budget; beyond it the listener
// answers "too many attempts" without running the exchange.
func TestOnlyFailedAttemptsAreThrottled(t *testing.T) {
@@ -734,3 +743,80 @@ func TestTokenIsRenewedBeforeExpiry(t *testing.T) {
t.Fatalf("logins after the renewal point = %d, want 2", n)
}
}
// A renewal put off (throttled, or the server busy) keeps the link sending on
// the token it still holds, and retries soon, but only to the pinned server;
// any other refusal, or the token's expiry, ends it.
func TestPutOffRenewalKeepsTheValidToken(t *testing.T) {
f := newFixture(t)
l := f.listener(t, config.AuthOptions{TokenLifetimeMS: MinTokenLifetime.Milliseconds()}, f.serverTLS, RoleListener, HTTP)
srv, _ := f.httpListener(t, l, f.serverTLS)
// An impostor with another certificate of the same CA
var answer, stolen atomic.Int64
mux := http.NewServeMux()
mux.HandleFunc("POST "+chain.AuthPath, func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, int(answer.Load()), authStep{Error: "no"})
})
mux.HandleFunc("GET /protected", func(w http.ResponseWriter, r *http.Request) { stolen.Add(1) })
imp := httptest.NewUnstartedServer(mux)
imp.TLS = f.serverTLS.Clone()
imp.TLS.Certificates = []tls.Certificate{f.serverLeaf(t, "impostor")}
imp.StartTLS()
t.Cleanup(imp.Close)
d := f.dialer(t, "edge-01", "edge-01-secret")
client := f.httpClient(d)
var reroute atomic.Pointer[string]
client.Transport.(*http.Transport).DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
if to := reroute.Load(); to != nil {
addr = *to
}
return (&net.Dialer{}).DialContext(ctx, network, addr)
}
prepare := func(renew bool) (*http.Request, error) {
if renew {
d.dialer.renewAt.Store(time.Now().UnixNano())
}
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/protected", nil)
return req, d.Prepare(t.Context(), client, srv.URL, req)
}
held := func() string {
req, err := prepare(false)
if err != nil {
t.Fatal(err)
}
return req.Header.Get("Authorization")
}
first := held()
client.CloseIdleConnections()
to := imp.Listener.Addr().String()
reroute.Store(&to)
answer.Store(http.StatusTooManyRequests)
req, err := prepare(true)
if err != nil || req.Header.Get("Authorization") != first {
t.Fatalf("put-off renewal: header %q, %v; want the held token", req.Header.Get("Authorization"), err)
}
if ahead := time.Until(time.Unix(0, d.dialer.renewAt.Load())); ahead <= 0 || ahead > 5*time.Second {
t.Fatalf("next renewal %v ahead, want within 5 s", ahead)
}
if _, err := client.Do(req); !errors.Is(err, errPinMismatch) || stolen.Load() != 0 {
t.Fatalf("the held token went to a server the pin never checked: %v, %d requests", err, stolen.Load())
}
answer.Store(http.StatusUnauthorized)
if _, err := prepare(true); err == nil || d.dialer.token.Load() != nil {
t.Fatalf("a refused renewal kept the token: %v", err)
}
client.CloseIdleConnections()
reroute.Store(nil)
first = held()
l.Close() // every further login answers busy
if req, err := prepare(true); err != nil || req.Header.Get("Authorization") != first {
t.Fatalf("busy renewal: %v; want the held token", err)
}
d.dialer.expires.Store(time.Now().UnixNano())
if _, err := prepare(true); err == nil {
t.Fatal("an expired token was kept after a put-off renewal")
}
}
+2 -1
View File
@@ -6,6 +6,7 @@ package config
type Config struct {
// Top-level flags for application control
ShowVersion bool `toml:"version"`
Check bool `toml:"check"` // build every plugin, report, exit: lw --check
Quiet bool `toml:"quiet"`
// Runtime behavior flags
@@ -347,7 +348,7 @@ type AuthOptions struct {
// Overrides trust_node.
NodeBinding string `toml:"node_binding"`
// scram listeners: verifier file written by `logwisp auth add-user`, and
// scram listeners: verifier file written by `lw auth add-user`, and
// the bearer token lifetime on HTTP listeners (default 15 minutes)
CredentialsFile string `toml:"credentials_file"`
TokenLifetimeMS int64 `toml:"token_lifetime_ms"`
+45 -5
View File
@@ -18,8 +18,11 @@ var ErrConfigNotFound = lconfig.ErrConfigNotFound
// Manager owns the configuration sources and watcher for one application instance.
// Its snapshots are detached from each other and from the running service.
type Manager struct {
config *lconfig.Config
path string
config *lconfig.Config
path string
explicit bool
read bool // the file was loaded once
specs []pipelineSpec // command-line or environment pipelines, kept across reloads
}
// Load reads the startup sources and validates the initial configuration.
@@ -29,13 +32,30 @@ func Load(args []string) (*Manager, error) {
if err != nil {
return nil, err
}
specs, configArgs, err := takePipelineSpecs(configArgs)
if err != nil {
return nil, err
}
if len(specs) == 0 {
specs = envPipelineSpecs()
}
if _, err := buildPipelines(specs); err != nil {
return nil, err
}
m := &Manager{path: configPath, explicit: isExplicit, specs: specs}
initial := defaults()
cfg, err := lconfig.NewBuilder().
WithTarget(initial).
WithEnvPrefix("LOGWISP_").
WithArgs(configArgs).
WithFile(configPath).
WithTypedValidator(ValidateConfig).
WithTypedValidator(func(cfg *Config) error {
effective := *cfg // the builder keeps cfg; only the copy is replaced
if err := m.usePipelines(&effective); err != nil {
return err
}
return ValidateConfig(&effective)
}).
WithSecurityOptions(lconfig.SecurityOptions{
PreventPathTraversal: true,
MaxFileSize: 10 * 1024 * 1024,
@@ -50,10 +70,12 @@ func Load(args []string) (*Manager, error) {
}
// A missing discovered default still permits valid CLI/env/default values.
}
m.read = err == nil
if unknown := cfg.UnknownCLIKeys(); len(unknown) > 0 && !initial.Quiet {
fmt.Fprintf(os.Stderr, "Warning: unrecognized flags ignored: %v\n", unknown)
}
return &Manager{config: cfg, path: configPath}, nil
m.config = cfg
return m, nil
}
// Snapshot validates a detached candidate before it is used to build a service.
@@ -65,6 +87,9 @@ func (m *Manager) Snapshot() (*Config, error) {
}
cfg := value.(*Config)
cfg.ConfigFile = m.path
if err := m.usePipelines(cfg); err != nil {
return nil, err
}
if err := checkFileKeys(m.path); err != nil {
return nil, fmt.Errorf("config file %q: %w", m.path, err)
}
@@ -74,12 +99,27 @@ func (m *Manager) Snapshot() (*Config, error) {
return cfg, nil
}
// usePipelines replaces the file's or default pipelines with the spec ones.
func (m *Manager) usePipelines(cfg *Config) error {
if len(m.specs) == 0 {
return nil
}
pipelines, err := buildPipelines(m.specs)
cfg.Pipelines = pipelines
return err
}
// Reload rereads the selected file, retaining the startup CLI/environment sources.
// Signals must call this even when automatic watching is disabled.
func (m *Manager) Reload() (*Config, error) {
if err := m.config.LoadFile(m.path); err != nil {
// As at startup, a discovered default that does not exist is no error, so a
// file-less instance still rebuilds and rotates its certificates. Once read,
// its removal is one: the loader would keep the removed file's values.
err := m.config.LoadFile(m.path)
if err != nil && (m.explicit || m.read || !errors.Is(err, ErrConfigNotFound)) {
return nil, fmt.Errorf("reload %q: %w", m.path, err)
}
m.read = m.read || err == nil
return m.Snapshot()
}
+8
View File
@@ -2,6 +2,7 @@ package config
import (
"errors"
"os"
"path/filepath"
"reflect"
"strings"
@@ -124,6 +125,13 @@ func TestReloadReadsDiskValidatesAndPreservesOldSnapshots(t *testing.T) {
if err != nil || !recovered.StatusReporter {
t.Fatalf("override removal/recovery: %+v %v", recovered, err)
}
// Only a missing discovered default is tolerated; a named file must exist.
if err := os.Remove("reload.toml"); err != nil {
t.Fatal(err)
}
if _, err := m.Reload(); !errors.Is(err, ErrConfigNotFound) {
t.Fatalf("reload without the named file: %v", err)
}
}
func TestWatchStartsOnSubscriptionAndCloses(t *testing.T) {
+272
View File
@@ -0,0 +1,272 @@
package config
import (
"cmp"
"errors"
"fmt"
"os"
"slices"
"strconv"
"strings"
)
// specKind is one pipeline spec flag. Its environment form is LOGWISP_ plus
// the flag uppercased, '-' as '_'.
type specKind struct {
flag string
typed bool // SPEC starts with TYPE
many bool // repeatable within a pipeline; the environment adds _1.._N
apply func(p *PipelineConfig, typ string, opts map[string]any) error
}
var specKinds = []specKind{
{"source", true, true, func(p *PipelineConfig, typ string, opts map[string]any) error {
ids := make([]string, len(p.PluginSources))
for i, s := range p.PluginSources {
ids[i] = s.ID
}
id, err := instanceID(typ, opts, ids)
p.PluginSources = append(p.PluginSources, PluginSourceConfig{ID: id, Type: typ, Config: opts})
return err
}},
{"sink", true, true, func(p *PipelineConfig, typ string, opts map[string]any) error {
ids := make([]string, len(p.PluginSinks))
for i, s := range p.PluginSinks {
ids[i] = s.ID
}
id, err := instanceID(typ, opts, ids)
p.PluginSinks = append(p.PluginSinks, PluginSinkConfig{ID: id, Type: typ, Config: opts})
return err
}},
{"filter", true, true, func(p *PipelineConfig, typ string, opts map[string]any) error {
opts["type"] = typ
f := FilterConfig{}
err := Scan(opts, &f)
p.Flow.Filters = append(p.Flow.Filters, f)
return err
}},
{"format", true, false, func(p *PipelineConfig, typ string, opts map[string]any) error {
opts["type"] = typ
p.Flow.Format = &FormatConfig{}
return Scan(opts, p.Flow.Format)
}},
// Naming a stage turns it on: the file defaults (pass, disabled) would not.
{"rate-limit", false, false, func(p *PipelineConfig, _ string, opts map[string]any) error {
p.Flow.RateLimit = &RateLimitConfig{Policy: "drop"}
return Scan(opts, p.Flow.RateLimit)
}},
{"heartbeat", false, false, func(p *PipelineConfig, _ string, opts map[string]any) error {
p.Flow.Heartbeat = &HeartbeatConfig{Enabled: true}
return Scan(opts, p.Flow.Heartbeat)
}},
}
// pipelineSpec is one pipeline flag or variable; a nil kind starts a pipeline.
type pipelineSpec struct {
name string // "--sink" or "LOGWISP_SINK_2", for errors
kind *specKind
value string
}
// takePipelineSpecs consumes the pipeline flags, which lixenwraith/config
// would report as unrecognized.
func takePipelineSpecs(args []string) (specs []pipelineSpec, rest []string, err error) {
for i := 0; i < len(args); i++ {
if args[i] == "--" {
return specs, append(rest, args[i:]...), nil
}
name, value, inline := strings.Cut(args[i], "=")
var kind *specKind
if name != "--pipeline" {
k := slices.IndexFunc(specKinds, func(k specKind) bool { return "--"+k.flag == name })
if k < 0 {
rest = append(rest, args[i])
continue
}
kind = &specKinds[k]
}
if !inline && i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") {
i++
value = args[i]
}
if value == "" {
return nil, nil, fmt.Errorf("%s requires a value", name)
}
specs = append(specs, pipelineSpec{name, kind, value})
}
return specs, rest, nil
}
// envPipelineSpecs reads the one-pipeline environment form. An empty variable
// counts as unset, so container templates may leave optional ones blank.
func envPipelineSpecs() []pipelineSpec {
var specs []pipelineSpec
if name := os.Getenv("LOGWISP_PIPELINE"); name != "" {
specs = append(specs, pipelineSpec{"LOGWISP_PIPELINE", nil, name})
}
for i := range specKinds {
kind := &specKinds[i]
base := "LOGWISP_" + strings.ToUpper(strings.ReplaceAll(kind.flag, "-", "_"))
type numbered struct {
n uint64
name, spec string
}
var found []numbered
for _, kv := range os.Environ() {
name, spec, _ := strings.Cut(kv, "=")
suffix, ok := strings.CutPrefix(name, base+"_")
n, err := strconv.ParseUint(suffix, 10, 64)
if name == base || ok && kind.many && err == nil {
found = append(found, numbered{n, name, spec})
}
}
slices.SortFunc(found, func(a, b numbered) int { return cmp.Or(cmp.Compare(a.n, b.n), cmp.Compare(a.name, b.name)) })
for _, f := range found {
if f.spec != "" {
specs = append(specs, pipelineSpec{f.name, kind, f.spec})
}
}
}
return specs
}
// buildPipelines turns specs, in order, into pipelines; specs before the first
// --pipeline go to one named "cli". Every call returns fresh maps.
func buildPipelines(specs []pipelineSpec) ([]PipelineConfig, error) {
var pipelines []PipelineConfig
var seen map[*specKind]bool
for _, s := range specs {
if s.kind == nil || pipelines == nil {
name := "cli"
if s.kind == nil {
name = s.value
}
pipelines = append(pipelines, PipelineConfig{Name: name, Flow: &FlowConfig{}})
seen = map[*specKind]bool{}
if s.kind == nil {
continue
}
}
p := &pipelines[len(pipelines)-1]
if seen[s.kind] && !s.kind.many {
return nil, fmt.Errorf("%s %s: pipeline %q already has one", s.name, s.value, p.Name)
}
seen[s.kind] = true
typ, opts, err := parseSpec(s.value, s.kind.typed)
if err != nil {
return nil, fmt.Errorf("%s %w", s.name, err)
}
if err := s.kind.apply(p, typ, opts); err != nil {
return nil, fmt.Errorf("%s %s: %w", s.name, s.value, err)
}
}
return pipelines, nil
}
// parseSpec reads [TYPE,]key=value,...: dotted keys nest, a repeated key makes
// a list, and a backslash escapes ',', '=' and '\'. Errors name the part.
func parseSpec(spec string, typed bool) (typ string, opts map[string]any, err error) {
rest, more := spec, true
if typed {
typ, rest, more = cutUnescaped(spec, ',')
if _, _, eq := cutUnescaped(typ, '='); typ == "" || eq {
return "", nil, fmt.Errorf("%s: missing TYPE", spec)
}
typ = unescape(typ)
}
opts = map[string]any{}
for more {
var part string
part, rest, more = cutUnescaped(rest, ',')
key, value, eq := cutUnescaped(part, '=')
switch key = unescape(key); {
case !eq:
err = errors.New(`missing "="`)
case typed && key == "type":
err = errors.New(`TYPE already sets "type"`)
default:
err = setOption(opts, key, unescape(value))
}
if err != nil {
return "", nil, fmt.Errorf("%s: %w", strings.TrimPrefix(typ+","+part, ","), err)
}
}
return typ, opts, nil
}
// cutUnescaped cuts s at its first unescaped sep, leaving escapes in place.
func cutUnescaped(s string, sep byte) (before, after string, found bool) {
for i := 0; i < len(s); i++ {
if escapes(s, i) {
i++
} else if s[i] == sep {
return s[:i], s[i+1:], true
}
}
return s, "", false
}
func unescape(s string) string {
var b strings.Builder
for i := 0; i < len(s); i++ {
if escapes(s, i) {
i++
}
b.WriteByte(s[i])
}
return b.String()
}
// escapes keeps any other backslash literal, so regex escapes such as \d pass.
func escapes(s string, i int) bool {
return s[i] == '\\' && i+1 < len(s) && strings.IndexByte(`,=\`, s[i+1]) >= 0
}
// setOption stores value at a dotted key; a second value turns it into a list.
func setOption(opts map[string]any, key, value string) error {
segments := strings.Split(key, ".")
if slices.Contains(segments, "") {
return fmt.Errorf("empty key in %q", key)
}
table, last := opts, len(segments)-1
for i, segment := range segments[:last] {
switch existing := table[segment].(type) {
case nil:
next := map[string]any{}
table[segment], table = next, next
case map[string]any:
table = existing
default:
return fmt.Errorf("%q is both a value and a table", strings.Join(segments[:i+1], "."))
}
}
switch existing := table[segments[last]].(type) {
case nil:
table[segments[last]] = value
case string:
table[segments[last]] = []any{existing, value}
case []any:
table[segments[last]] = append(existing, value)
default:
return fmt.Errorf("%q is both a table and a value", key)
}
return nil
}
// instanceID takes the id option; an unnamed instance gets the first of TYPE,
// TYPE_2, TYPE_3... that no earlier instance of its role holds.
func instanceID(typ string, opts map[string]any, taken []string) (string, error) {
id, _ := opts["id"].(string)
if _, set := opts["id"]; set && id == "" {
return "", errors.New(`"id" takes one non-empty value`)
}
delete(opts, "id")
if id != "" {
return id, nil
}
id = typ
for n := 2; slices.Contains(taken, id); n++ {
id = typ + "_" + strconv.Itoa(n)
}
return id, nil
}
+190
View File
@@ -0,0 +1,190 @@
package config
import (
"errors"
"os"
"reflect"
"testing"
"logwisp/internal/testutil"
)
func loadPipelines(t *testing.T, args ...string) []PipelineConfig {
t.Helper()
m, err := Load(args)
if err != nil {
t.Fatal(err)
}
defer m.Close()
cfg, err := m.Snapshot()
if err != nil {
t.Fatal(err)
}
return cfg.Pipelines
}
func TestPipelineSpecGrammar(t *testing.T) {
isolateConfig(t)
got := loadPipelines(t,
"--source", `file,directory=/var/log/a\,b,pattern=*.log`,
`--source=file,id=app,directory=x\=y`,
"--source", `file,directory=c:\\logs\\`,
"--sink", "http,port=8080,auth.type=scram,auth.credentials_file=users.toml,tls.cert_file=c",
"--filter", "include,patterns=ERROR,patterns=WARN",
"--filter", `exclude,patterns=\d{1\,3}`,
"--format", `txt,timestamp_format=Jan 2\, 2006`,
"--pipeline", "b", "--source", "null", "--sink", "null", "--sink", "null", "--heartbeat", "interval_ms=1000",
"--", "--source", "random")
want := []PipelineConfig{{
Name: "cli",
Flow: &FlowConfig{
Filters: []FilterConfig{
{Type: "include", Patterns: []string{"ERROR", "WARN"}},
{Type: "exclude", Patterns: []string{`\d{1,3}`}},
},
Format: &FormatConfig{Type: "txt", TimestampFormat: "Jan 2, 2006"},
},
PluginSources: []PluginSourceConfig{
{ID: "file", Type: "file", Config: map[string]any{"directory": "/var/log/a,b", "pattern": "*.log"}},
{ID: "app", Type: "file", Config: map[string]any{"directory": "x=y"}},
{ID: "file_2", Type: "file", Config: map[string]any{"directory": `c:\logs\`}},
},
PluginSinks: []PluginSinkConfig{{ID: "http", Type: "http", Config: map[string]any{
"port": "8080",
"auth": map[string]any{"type": "scram", "credentials_file": "users.toml"},
"tls": map[string]any{"cert_file": "c"},
}}},
}, {
Name: "b",
Flow: &FlowConfig{Heartbeat: &HeartbeatConfig{Enabled: true, IntervalMS: 1000}},
PluginSources: []PluginSourceConfig{{ID: "null", Type: "null", Config: map[string]any{}}},
PluginSinks: []PluginSinkConfig{
{ID: "null", Type: "null", Config: map[string]any{}},
{ID: "null_2", Type: "null", Config: map[string]any{}},
},
}}
if !reflect.DeepEqual(got, want) {
t.Fatalf("pipelines:\n got %+v\nwant %+v", got, want)
}
for want, args := range map[string][]string{
`--sink http,port: missing "="`: {"--sink", "http,port"},
`--source directory=/x: missing TYPE`: {"--source", "directory=/x"},
`--source file,type=x: TYPE already sets "type"`: {"--source", "file,type=x"},
`--sink http,tls.cert_file=c: "tls" is both a value and a table`: {"--sink", "http,tls=1,tls.cert_file=c"},
`--sink requires a value`: {"--sink="},
`--format txt: pipeline "cli" already has one`: {"--format", "json", "--format", "txt"},
`--rate-limit rate=1,polcy=drop: unknown key "polcy"`: {"--rate-limit", "rate=1,polcy=drop"},
} {
if _, err := Load(args); err == nil || err.Error() != want {
t.Errorf("%q: err = %v, want %s", args, err, want)
}
}
}
// A spec value is one list entry, commas included: a regex quantifier or a DN
// must not split into several, wider entries.
func TestSpecValueIsOneListEntry(t *testing.T) {
isolateConfig(t)
sink := loadPipelines(t, "--source", "null", "--sink",
`tcp,auth.type=mtls,auth.allow_patterns=^edge-\d{1\,3}$,auth.allow=CN=a\,O=b,auth.allow=CN=c`)[0].PluginSinks[0]
var opts TCPSinkOptions
if err := Scan(sink.Config, &opts); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(opts.Auth.AllowPatterns, []string{`^edge-\d{1,3}$`}) ||
!reflect.DeepEqual(opts.Auth.Allow, []string{"CN=a,O=b", "CN=c"}) {
t.Fatalf("allow_patterns %q, allow %q", opts.Auth.AllowPatterns, opts.Auth.Allow)
}
}
// One pipeline from LOGWISP_*: numbered variables follow the bare one in
// numeric order, only repeatable kinds are numbered, and empty means unset.
func TestEnvironmentPipeline(t *testing.T) {
isolateConfig(t)
for name, value := range map[string]string{
"LOGWISP_PIPELINE": "edge",
"LOGWISP_SOURCE": "random",
"LOGWISP_SOURCE_10": "null,id=ten",
"LOGWISP_SOURCE_2": "null,id=two",
"LOGWISP_SINK_1": "console",
"LOGWISP_FILTER": "",
"LOGWISP_FORMAT_1": "raw",
"LOGWISP_RATE_LIMIT": "rate=10",
"LOGWISP_LOGGING_LEVEL": "debug",
} {
t.Setenv(name, value)
}
m, err := Load(nil)
if err != nil {
t.Fatal(err)
}
defer m.Close()
cfg, err := m.Snapshot()
if err != nil {
t.Fatal(err)
}
p := cfg.Pipelines
if len(p) != 1 || p[0].Name != "edge" || len(p[0].PluginSources) != 3 || p[0].PluginSources[1].ID != "two" ||
p[0].PluginSources[2].ID != "ten" || len(p[0].PluginSinks) != 1 || p[0].Flow.Filters != nil || p[0].Flow.Format != nil ||
*p[0].Flow.RateLimit != (RateLimitConfig{Rate: 10, Policy: "drop"}) || cfg.Logging.Level != "debug" {
t.Fatalf("environment pipeline: %+v %+v", p, cfg.Logging)
}
}
func TestCommandLinePipelinesIgnoreEnvironment(t *testing.T) {
isolateConfig(t)
t.Setenv("LOGWISP_SOURCE", "random")
t.Setenv("LOGWISP_SINK", "console")
got := loadPipelines(t, "--source", "null", "--sink", "null")
if len(got) != 1 || len(got[0].PluginSources) != 1 || got[0].PluginSources[0].Type != "null" ||
len(got[0].PluginSinks) != 1 || got[0].PluginSinks[0].Type != "null" {
t.Fatalf("environment specs mixed into command-line pipelines: %+v", got)
}
}
// The file's pipelines are dropped unvalidated; its other keys still apply.
func TestSpecPipelinesReplaceFilePipelines(t *testing.T) {
isolateConfig(t)
testutil.WriteFile(t, "file.toml", "[logging]\nlevel = \"warn\"\n[[pipelines]]\nname = \"file\"\n")
m, err := Load([]string{"-c", "file.toml", "--source", "null", "--sink", "null", "--status_reporter=false"})
if err != nil {
t.Fatal(err)
}
defer m.Close()
if unknown := m.config.UnknownCLIKeys(); len(unknown) != 0 {
t.Fatalf("spec flags reached the schema parser: %v", unknown)
}
cfg, err := m.Snapshot()
if err != nil || len(cfg.Pipelines) != 1 || cfg.Pipelines[0].Name != "cli" || cfg.Logging.Level != "warn" || cfg.StatusReporter {
t.Fatalf("snapshot: %+v %v", cfg, err)
}
}
// Without a file a reload still rebuilds; once the discovered default
// appears it is read, and its pipelines still yield to the specs. Its later
// removal fails the reload instead of keeping the removed file's values.
func TestReloadKeepsSpecPipelines(t *testing.T) {
isolateConfig(t)
m, err := Load([]string{"--source", "random,special=true", "--sink", "null"})
if err != nil {
t.Fatal(err)
}
defer m.Close()
first, err := m.Reload()
if err != nil || !first.StatusReporter {
t.Fatalf("reload without a file: %+v %v", first, err)
}
first.Pipelines[0].PluginSources[0].Config["special"] = "mutated"
testutil.WriteFile(t, "logwisp.toml", "status_reporter = false\n[[pipelines]]\nname = \"file\"\n")
next, err := m.Reload()
if err != nil || next.StatusReporter || len(next.Pipelines) != 1 || next.Pipelines[0].Name != "cli" ||
next.Pipelines[0].PluginSources[0].Config["special"] != "true" {
t.Fatalf("reload: %+v %v", next, err)
}
if err := os.Remove("logwisp.toml"); err != nil {
t.Fatal(err)
}
if _, err := m.Reload(); !errors.Is(err, ErrConfigNotFound) {
t.Fatalf("reload after the file was removed: %v", err)
}
}
+13 -7
View File
@@ -94,15 +94,17 @@ func validateLogConfig(cfg *LogConfig) error {
// not declare: a misspelled tls or auth key must fail, not silently disable
// the protection it was meant to configure.
func Scan(configMap map[string]any, target any) error {
if err := unknownKeys(configMap, reflect.TypeOf(target), ""); err != nil {
if err := checkKeys(configMap, reflect.TypeOf(target), ""); err != nil {
return err
}
return lconfig.ScanMap(configMap, target)
}
// unknownKeys walks nested tables against the toml tags of t. Map-typed fields
// hold free-form keys and are not descended into.
func unknownKeys(m map[string]any, t reflect.Type, prefix string) error {
// checkKeys walks nested tables against the toml tags of t, refusing unknown
// keys and making a lone string for a list field a one-entry list: the weakly
// typed decoder would split it at commas, which a pattern or a DN may hold.
// Map-typed fields hold free-form keys and are not descended into.
func checkKeys(m map[string]any, t reflect.Type, prefix string) error {
for t.Kind() == reflect.Pointer || t.Kind() == reflect.Slice {
t = t.Elem()
}
@@ -122,8 +124,12 @@ func unknownKeys(m map[string]any, t reflect.Type, prefix string) error {
}
var tables []map[string]any
switch v := m[key].(type) {
case string:
if ft.Kind() == reflect.Slice && ft.Elem().Kind() == reflect.String {
m[key] = []string{v}
}
case map[string]any:
if err := unknownKeys(v, ft, prefix+key+"."); err != nil {
if err := checkKeys(v, ft, prefix+key+"."); err != nil {
return err
}
case []map[string]any:
@@ -136,7 +142,7 @@ func unknownKeys(m map[string]any, t reflect.Type, prefix string) error {
}
}
for i, table := range tables {
if err := unknownKeys(table, ft, fmt.Sprintf("%s%s[%d].", prefix, key, i)); err != nil {
if err := checkKeys(table, ft, fmt.Sprintf("%s%s[%d].", prefix, key, i)); err != nil {
return err
}
}
@@ -160,5 +166,5 @@ func checkFileKeys(path string) error {
return err
}
delete(root, "config_file") // runtime metadata, documented as ignored in the file
return unknownKeys(root, reflect.TypeOf(Config{}), "")
return checkKeys(root, reflect.TypeOf(Config{}), "")
}
+23 -1
View File
@@ -1,9 +1,31 @@
package core
import (
"fmt"
"net/netip"
"strings"
"time"
)
// Network is what a listener on, or a dialer to, host uses: tcp4 for an IPv4
// literal or "" (the IPv4 wildcard, as the default 0.0.0.0), tcp6 for an IPv6
// literal, which Go binds IPv6-only, and tcp for a hostname, which resolves.
func Network(host string) (string, error) {
addr, err := netip.ParseAddr(host)
switch {
case host == "" || err == nil && addr.Is4():
return "tcp4", nil
case err == nil && addr.Is4In6():
// tcp6 refuses it at bind or dial, long after the config loaded
return "", fmt.Errorf("%q is an IPv4-mapped address; write it as IPv4", host)
case err == nil:
return "tcp6", nil
case strings.ContainsAny(host, "[]:"):
return "", fmt.Errorf("%q is neither an address nor a hostname (write an IPv6 address without brackets or port)", host)
}
return "tcp", nil
}
const (
MaxLogEntryBytes = 1024 * 1024
@@ -30,4 +52,4 @@ const (
ReloadWatchDebounce = 500 * time.Millisecond
ReloadWatchTimeout = 30 * time.Second
)
)
+53
View File
@@ -0,0 +1,53 @@
package core
import (
"net"
"testing"
"logwisp/internal/testutil"
)
// The host literal picks the family, strictly: an IPv4 literal or the empty
// host is IPv4 only, an IPv6 literal (the wildcard :: too) IPv6 only, and a
// hostname resolves. Brackets and ports belong to addresses, not hosts, and
// an IPv4-mapped address is IPv4 written as IPv6.
func TestNetworkFollowsTheHostLiteral(t *testing.T) {
for _, tc := range []struct{ host, want string }{
{"127.0.0.1", "tcp4"}, {"0.0.0.0", "tcp4"}, {"", "tcp4"},
{"::1", "tcp6"}, {"::", "tcp6"}, {"fe80::1%eth0", "tcp6"},
{"relay.example", "tcp"}, {"localhost", "tcp"},
{"[::1]", ""}, {"::1]", ""}, {"10.0.0.1:80", ""}, {"::ffff:10.0.0.1", ""},
} {
got, err := Network(tc.host)
if got != tc.want || (err != nil) != (tc.want == "") {
t.Errorf("Network(%q) = %q, %v; want %q", tc.host, got, err, tc.want)
}
}
testutil.RequireIPv6(t)
network, _ := Network("::")
ln, err := net.Listen(network, "[::]:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { ln.Close() })
go func() {
for {
c, err := ln.Accept()
if err != nil {
return
}
c.Close()
}
}()
_, port, _ := net.SplitHostPort(ln.Addr().String())
if c, err := net.Dial("tcp4", net.JoinHostPort("127.0.0.1", port)); err == nil {
c.Close()
t.Error(":: accepted an IPv4 connection")
}
c, err := net.Dial("tcp6", net.JoinHostPort("::1", port))
if err != nil {
t.Fatalf(":: refused [::1]: %v", err)
}
c.Close()
}
-1
View File
@@ -24,4 +24,3 @@ type TransportEvent struct {
// Structured entry for re-serializing sinks (chain links). Zero Time => absent
Entry LogEntry
}
+49
View File
@@ -0,0 +1,49 @@
package core
import (
"context"
"fmt"
"net"
)
// lookupNetIP resolves listener hostnames; tests replace it
var lookupNetIP = net.DefaultResolver.LookupNetIP
// Listen is lc.Listen, but a hostname first resolves to one address, IPv4 when
// it has one (as Go picks), listened on in that address's family alone: Go
// would bind a name for a wildcard address in both families.
func Listen(ctx context.Context, lc *net.ListenConfig, network, addr string) (net.Listener, error) {
network, addr, err := pinFamily(ctx, network, addr)
if err != nil {
return nil, err
}
return lc.Listen(ctx, network, addr)
}
func pinFamily(ctx context.Context, network, addr string) (string, string, error) {
if network != "tcp" {
return network, addr, nil
}
host, port, err := net.SplitHostPort(addr)
if err != nil {
return "", "", err
}
ips, err := lookupNetIP(ctx, "ip", host)
if err != nil {
return "", "", err
}
if len(ips) == 0 {
return "", "", fmt.Errorf("%s: no address", host)
}
ip := ips[0].Unmap()
for _, a := range ips {
if a.Unmap().Is4() {
ip = a.Unmap()
break
}
}
if network, err = Network(ip.String()); err != nil {
return "", "", err
}
return network, net.JoinHostPort(ip.String(), port), nil
}
+34
View File
@@ -0,0 +1,34 @@
package core
import (
"context"
"net/netip"
"testing"
)
// A hostname listener binds one of its addresses, IPv4 when it has one, in
// that address's family alone, a name for a wildcard address too.
func TestHostnameListenerKeepsToOneFamily(t *testing.T) {
names := map[string][]netip.Addr{
"wild4": {netip.MustParseAddr("0.0.0.0")},
"wild6": {netip.MustParseAddr("::")},
"both": {netip.MustParseAddr("::1"), netip.MustParseAddr("::ffff:127.0.0.1")},
}
saved := lookupNetIP
t.Cleanup(func() { lookupNetIP = saved })
lookupNetIP = func(_ context.Context, _, host string) ([]netip.Addr, error) { return names[host], nil }
for _, tc := range []struct{ network, addr, wantNetwork, wantAddr string }{
{"tcp", "wild4:80", "tcp4", "0.0.0.0:80"},
{"tcp", "wild6:80", "tcp6", "[::]:80"},
{"tcp", "both:80", "tcp4", "127.0.0.1:80"},
{"tcp6", "[::1]:80", "tcp6", "[::1]:80"},
} {
network, addr, err := pinFamily(t.Context(), tc.network, tc.addr)
if err != nil || network != tc.wantNetwork || addr != tc.wantAddr {
t.Errorf("%s %s: %s %s, %v; want %s %s", tc.network, tc.addr, network, addr, err, tc.wantNetwork, tc.wantAddr)
}
}
if _, _, err := pinFamily(t.Context(), "tcp", "none:80"); err == nil {
t.Error("a name without addresses was accepted")
}
}
+1 -1
View File
@@ -79,4 +79,4 @@ func (c *Chain) GetStats() map[string]any {
"total_passed": c.totalPassed.Load(),
"filters": filterStats,
}
}
}
+1 -1
View File
@@ -209,4 +209,4 @@ func (f *Filter) matches(text string) bool {
"logic", f.config.Logic)
return false
}
}
}
-1
View File
@@ -161,4 +161,3 @@ func (f *Flow) GetStats() map[string]any {
return stats
}
+3 -5
View File
@@ -137,12 +137,10 @@ func (hg *HeartbeatGenerator) generateHeartbeat(t time.Time) core.TransportEvent
// Use flow's formatter for consistent formatting
if adapter, ok := hg.formatter.(*format.FormatterAdapter); ok {
// Customize flags for heartbeat if needed
customFlags := int64(0)
// The formatter shows a timestamp unless told otherwise
customFlags := formatter.FlagDefault
if !hg.config.IncludeTimestamp {
// Remove timestamp flag if not wanted
customFlags = formatter.FlagShowLevel
} else {
customFlags = formatter.FlagDefault
customFlags = formatter.FlagShowLevel | formatter.FlagNoTimestamp
}
payload, err = adapter.FormatWithFlags(entry, customFlags)
} else {
+29
View File
@@ -0,0 +1,29 @@
package flow
import (
"bytes"
"testing"
"time"
"logwisp/internal/config"
"logwisp/internal/format"
)
// include_timestamp = false drops the timestamp the formatter shows by default
func TestHeartbeatTimestampFollowsIncludeTimestamp(t *testing.T) {
at := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
for _, include := range []bool{false, true} {
f, err := format.NewFormatter(&config.FormatConfig{Type: "txt"})
if err != nil {
t.Fatal(err)
}
hg, err := NewHeartbeatGenerator(&config.HeartbeatConfig{Enabled: true, IncludeTimestamp: include}, f, nil)
if err != nil {
t.Fatal(err)
}
payload := hg.generateHeartbeat(at).Payload
if got := bytes.Contains(payload, []byte("2026-01-02")); got != include {
t.Errorf("include_timestamp=%v: payload %q", include, payload)
}
}
}
+1 -1
View File
@@ -130,4 +130,4 @@ func policyString(p config.RateLimitPolicy) string {
default:
return "unknown"
}
}
}
+1 -1
View File
@@ -25,4 +25,4 @@ func NewFormatter(cfg *config.FormatConfig) (Formatter, error) {
}
return NewFormatterAdapter(cfg)
}
}
+1 -1
View File
@@ -219,4 +219,4 @@ func (r *Registry) RemoveSink(id string) {
}
delete(r.sinkInstances, id)
}
}
+1 -1
View File
@@ -186,4 +186,4 @@ func ListSinks() []string {
types = append(types, t)
}
return types
}
}
+1 -1
View File
@@ -67,4 +67,4 @@ func IsSafe(data string) bool {
}
}
return true
}
}
+18 -14
View File
@@ -34,9 +34,10 @@ type FileSink struct {
config *config.FileSinkOptions
// Application
input chan core.TransportEvent
writer *log.Logger // internal logger for file writing
logger *log.Logger // application logger
input chan core.TransportEvent
writer *log.Logger // internal logger for file writing
writerConfig *log.Config
logger *log.Logger // application logger
// Runtime
done chan struct{}
@@ -117,20 +118,21 @@ func NewFileSinkPlugin(
writerConfig.ShowLevel = false
writerConfig.Format = "raw"
// Create internal logger for file writing
writer := log.NewLogger()
if err := writer.ApplyConfig(writerConfig); err != nil {
// Validated here, applied in Start: applying creates the directory and
// file, which lw --check and a rejected reload must not do
if err := writerConfig.Validate(); err != nil {
return nil, fmt.Errorf("failed to initialize file writer: %w", err)
}
fs := &FileSink{
id: id,
proxy: proxy,
config: opts,
input: make(chan core.TransportEvent, opts.BufferSize),
writer: writer,
done: make(chan struct{}),
logger: logger,
id: id,
proxy: proxy,
config: opts,
input: make(chan core.TransportEvent, opts.BufferSize),
writer: log.NewLogger(),
writerConfig: writerConfig,
done: make(chan struct{}),
logger: logger,
}
fs.lastProcessed.Store(time.Time{})
@@ -168,7 +170,9 @@ func (fs *FileSink) Input() chan<- core.TransportEvent {
// Start begins the processing loop for the sink
func (fs *FileSink) Start(ctx context.Context) error {
// Start the internal file writer
if err := fs.writer.ApplyConfig(fs.writerConfig); err != nil {
return fmt.Errorf("failed to initialize file writer: %w", err)
}
if err := fs.writer.Start(); err != nil {
return fmt.Errorf("failed to start file writer: %w", err)
}
+9 -4
View File
@@ -55,8 +55,9 @@ type HTTPSink struct {
proxy *session.Proxy
// Configuration
config *config.HTTPSinkOptions
addr string
config *config.HTTPSinkOptions
addr string
network string
// Network
server *http.Server
@@ -116,6 +117,10 @@ func NewHTTPSinkPlugin(
if err := lconfig.Port(opts.Port); err != nil {
return nil, fmt.Errorf("port: %w", err)
}
network, err := core.Network(opts.Host)
if err != nil {
return nil, fmt.Errorf("host: %w", err)
}
if opts.StreamPath == "" {
opts.StreamPath = DefaultHTTPStreamPath
} else if !strings.HasPrefix(opts.StreamPath, "/") {
@@ -166,6 +171,7 @@ func NewHTTPSinkPlugin(
proxy: proxy,
config: opts,
addr: net.JoinHostPort(opts.Host, strconv.FormatInt(opts.Port, 10)),
network: network,
input: make(chan core.TransportEvent, opts.BufferSize),
done: make(chan struct{}),
logger: logger,
@@ -217,10 +223,9 @@ func (h *HTTPSink) Input() chan<- core.TransportEvent {
// Start binds the listener and serves stream/status endpoints
func (h *HTTPSink) Start(ctx context.Context) error {
// IPv4-only, parity with existing network sinks.
// TLS is applied via server.TLSConfig + ServeTLS below, not by wrapping
// ln; net/http then owns handshake, ALPN (h2), and per-conn errors.
ln, err := net.Listen("tcp4", h.addr)
ln, err := core.Listen(ctx, &net.ListenConfig{}, h.network, h.addr)
if err != nil {
return fmt.Errorf("http sink bind %s: %w", h.addr, err)
}
+17 -2
View File
@@ -1,4 +1,4 @@
import { login } from "./scram.js";
import { cookiesUsable, login, loginUnavailable } from "./scram.js";
const base = new URL("..", location.href);
const form = document.getElementById("login");
@@ -44,5 +44,20 @@ form.addEventListener("submit", async (event) => {
}
});
// A sign-in here only sets a cookie; the viewer, when the sink serves it,
// signs in by itself and keeps a token in memory instead
async function cookiesUnavailable() {
status.textContent = "This browser keeps no cookie for this site, so a sign-in here would not last.";
const viewer = await fetch("view", { method: "HEAD", cache: "no-store", redirect: "error" }).catch(() => null);
if (!viewer?.ok) return;
const link = document.createElement("a");
link.href = "view";
link.textContent = "Sign in on the viewer instead.";
status.append(" ", link);
}
// Enabled only once this script runs, so a failed load cannot submit the form natively
button.disabled = false;
const unavailable = loginUnavailable();
if (unavailable) status.textContent = unavailable;
else if (cookiesUsable()) button.disabled = false;
else cookiesUnavailable();
+122 -14
View File
@@ -16,11 +16,14 @@ const encoder = new TextEncoder();
/**
* Logs in to the logwisp http sink mounted at base (a URL ending in "/"), per
* the wire protocol in logwisp's doc/scram-auth-plan.md. The session arrives as
* an HttpOnly cookie; the promise resolves only once the server has proved it
* holds this user's verifier. onProgress(fraction) follows the Argon2 pass.
* logwisp's doc/scram-auth-plan.md; it resolves only once the server has proved
* it holds this user's verifier. The session is an HttpOnly cookie, or with
* session "token" the returned token. onProgress(fraction) follows Argon2.
*/
export async function login(base, username, password, { onProgress } = {}) {
export async function login(base, username, password, { onProgress, session = "cookie" } = {}) {
if (session !== "cookie" && session !== "token") {
throw new Error('session must be "cookie" or "token"');
}
const pw = checkCredentials(username, password);
subtle(); // an insecure page fails before anything is sent
const url = new URL("auth", base);
@@ -29,7 +32,8 @@ export async function login(base, username, password, { onProgress } = {}) {
const first = await post(url, { logwisp: 1, scram: { username, client_nonce: clientNonce } });
const { proof, serverSignature } = await prove(username, pw, clientNonce, first.challenge,
MIN_TIME, MIN_MEMORY, onProgress);
const step = await post(url, { proof, session: "cookie" });
// Without "session" the server answers with the token in the body
const step = await post(url, session === "cookie" ? { proof, session } : { proof });
const final = step.final;
if (!final || typeof final !== "object") {
throw new Error("server sent no final message");
@@ -40,15 +44,19 @@ export async function login(base, username, password, { onProgress } = {}) {
throw new Error("server could not prove it holds this user's verifier");
}
const expiresIn = Number.isSafeInteger(step.expires_in) && step.expires_in > 0 ? step.expires_in : 0;
return { username, expiresIn };
if (session === "cookie") return { username, expiresIn };
if (typeof step.token !== "string" || step.token === "") {
throw new Error("server issued no token");
}
return { username, expiresIn, token: step.token };
}
// Logout posts to auth itself: a clearing cookie from any other path would
// get another default path and miss the session cookie
export async function logout(base) {
// get another default path and miss the session cookie. A token is revoked.
export async function logout(base, { token } = {}) {
const res = await send(new URL("auth", base), {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: { "Content-Type": "application/json", ...bearer(token) },
body: JSON.stringify({ logout: true }),
});
if (res.status !== 200 && res.status !== 204) {
@@ -56,6 +64,101 @@ export async function logout(base) {
}
}
// Why no login can run on this page, or "": an insecure page refuses the probe's
// Secure cookie too, so a page asks this before cookiesUsable().
export function loginUnavailable() {
return globalThis.crypto?.subtle ? "" : "WebCrypto unavailable: the page must be served over HTTPS";
}
// Whether the session cookie would stick. Chromium blocking every cookie still
// reports navigator.cookieEnabled, so a cookie with the session's attributes
// must also come back.
export function cookiesUsable() {
if (!globalThis.navigator?.cookieEnabled) return false;
document.cookie = "logwisp_probe=1; Secure; SameSite=Strict";
const usable = document.cookie.split("; ").includes("logwisp_probe=1");
document.cookie = "logwisp_probe=; Max-Age=0; Secure; SameSite=Strict";
return usable;
}
/**
* Reads an event stream through fetch, which can send the bearer EventSource
* cannot. onEvent({type, data, lastEventId}) gets each event EventSource would
* dispatch. Resolves to {lastEventId, retry} when the server ends the stream;
* rejects with err.status on an answer other than 200, or on signal's abort.
*/
export async function stream(url, { token, signal, onEvent } = {}) {
const res = await send(new URL(url, globalThis.location?.href), {
headers: { Accept: "text/event-stream", ...bearer(token) },
signal,
});
const type = res.headers.get("Content-Type")?.split(";")[0].trim().toLowerCase();
if (res.status !== 200 || type !== "text/event-stream") {
res.body?.cancel().catch(() => {});
const err = new Error(res.status !== 200 ? `stream refused: ${res.status}` : "not an event stream");
err.status = res.status;
throw err;
}
const parser = eventParser(onEvent);
const reader = res.body.getReader();
const decoder = new TextDecoder(); // UTF-8, dropping a leading BOM as the spec asks
try {
for (let r = await reader.read(); !r.done; r = await reader.read()) {
parser.feed(decoder.decode(r.value, { stream: true }));
}
} finally {
reader.cancel().catch(() => {});
}
return parser.result();
}
// The event stream interpretation of the HTML standard; a CR that ends one
// chunk may be the first half of a CRLF the next chunk completes.
function eventParser(onEvent) {
let line = "", afterCR = false, data = "", type = "", idBuffer = "", lastEventId = "", retry = null;
const field = (name, value) => {
switch (name) {
case "event": type = value; break;
case "data": data += value + "\n"; break;
case "id": if (!value.includes("\0")) idBuffer = value; break;
case "retry": if (/^[0-9]+$/.test(value)) retry = Number(value); break;
}
};
const dispatch = () => {
lastEventId = idBuffer;
if (data !== "") onEvent?.({ type: type || "message", data: data.slice(0, -1), lastEventId });
data = type = "";
};
const interpret = (l) => {
if (l === "") return dispatch();
if (l.startsWith(":")) return;
const colon = l.indexOf(":");
if (colon < 0) return field(l, "");
const value = l.slice(colon + 1);
field(l.slice(0, colon), value.startsWith(" ") ? value.slice(1) : value);
};
const breaks = /[\r\n]/g;
return {
feed(text) {
let i = 0;
if (afterCR && text.startsWith("\n")) i = 1;
if (text !== "") afterCR = false;
for (breaks.lastIndex = i; breaks.exec(text); i = breaks.lastIndex) {
const end = breaks.lastIndex - 1;
interpret(line + text.slice(i, end));
line = "";
if (text[end] === "\r") {
if (end + 1 === text.length) afterCR = true;
else if (text[end + 1] === "\n") breaks.lastIndex++;
}
}
line += text.slice(i);
},
// An event the stream ended before its blank line is discarded
result: () => ({ lastEventId, retry }),
};
}
// Test hook: the proof for a fixed client nonce, under an explicit Argon2 floor
export async function _proofForTest(username, password, clientNonce, challenge, minTime, minMemory) {
const { proof, serverSignature } = await prove(username, checkCredentials(username, password),
@@ -135,19 +238,24 @@ async function post(url, body) {
return step;
}
// A redirect would replay the proof to wherever it points
// A redirect would replay the proof or the token to wherever it points
async function send(url, init) {
try {
return await fetch(url, { ...init, credentials: "same-origin", cache: "no-store", redirect: "error" });
} catch {
} catch (err) {
if (err?.name === "AbortError") throw err;
throw new Error(`cannot reach ${url.pathname}`);
}
}
function bearer(token) {
return token ? { Authorization: `Bearer ${token}` } : {};
}
function subtle() {
const s = globalThis.crypto?.subtle;
if (!s) throw new Error("WebCrypto unavailable: the page must be served over HTTPS");
return s;
const reason = loginUnavailable();
if (reason) throw new Error(reason);
return crypto.subtle;
}
async function hmac(key, message) {
+99 -1
View File
@@ -1,6 +1,6 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { argon2id, _proofForTest } from "./scram.js";
import { argon2id, _proofForTest, login, logout, stream } from "./scram.js";
const hex = (b) => Buffer.from(b).toString("hex");
const unhex = (s) => new Uint8Array(Buffer.from(s, "hex"));
@@ -42,3 +42,101 @@ test("proof and server signature match lixenwraith/auth", async () => {
server_signature: "0HKxosIZvdoAzstS4dEkNgoYvcDpFnOgwBjyhiSsK00=",
});
});
const STREAM = "https://site.test/logs/stream";
// A fetch answering with an event stream sent in the given chunks
function sseFetch(t, chunks, { status = 200, type = "text/event-stream" } = {}) {
return t.mock.method(globalThis, "fetch", async () => new Response(new ReadableStream({
start(c) {
for (const chunk of chunks) c.enqueue(new TextEncoder().encode(chunk));
c.close();
},
}), { status, headers: { "Content-Type": type } }));
}
async function events(t, chunks) {
sseFetch(t, chunks);
const got = [];
const end = await stream(STREAM, { onEvent: (e) => got.push(e) });
return { got, end };
}
// A split CRLF is one break, so "d" and "e" stay one event; a kept BOM would
// rename the first field and drop "a".
test("stream splits lines at CRLF, LF and a lone CR, across chunks", async (t) => {
const { got } = await events(t, ["\uFEFFdata: a\r\ndata: b\rdata: c\n\r", "\n", "data: d\r", "\ndata: e\r", "\r\n"]);
assert.deepEqual(got, [
{ type: "message", data: "a\nb\nc", lastEventId: "" },
{ type: "message", data: "d\ne", lastEventId: "" },
]);
});
test("stream interprets fields as the HTML standard does", async (t) => {
const { got, end } = await events(t, [
": comment\nevent: connected\ndata:{\"a\":1}\nid: 7\n\n",
"data\n\n",
"data: one space kept\nunknown: x\nid: 8\0\nretry: 3000\nretry: 15x\n\n",
"id: 8\nevent: no-data\n\n",
"id\ndata: never ended\n",
]);
assert.deepEqual(got, [
{ type: "connected", data: "{\"a\":1}", lastEventId: "7" },
{ type: "message", data: "", lastEventId: "7" },
{ type: "message", data: " one space kept", lastEventId: "7" },
]);
assert.deepEqual(end, { lastEventId: "8", retry: 3000 });
});
test("stream presents the bearer and refuses anything but a 200 event stream", async (t) => {
const fetch = sseFetch(t, []);
await stream(STREAM, { token: "tok" });
const init = fetch.mock.calls[0].arguments[1];
assert.equal(init.headers.Authorization, "Bearer tok");
assert.equal(init.redirect, "error");
fetch.mock.restore();
sseFetch(t, [], { status: 401 });
await assert.rejects(stream(STREAM, { token: "tok" }), { status: 401 });
sseFetch(t, [], { type: "text/html" });
await assert.rejects(stream(STREAM), { message: "not an event stream" });
});
test("stream rejects with the AbortError once its signal aborts", async (t) => {
t.mock.method(globalThis, "fetch", async (url, { signal }) => signal.throwIfAborted() ?? new Response(new ReadableStream({
start(c) {
c.enqueue(new TextEncoder().encode("data: first\n\n"));
signal.addEventListener("abort", () => c.error(signal.reason));
},
}), { headers: { "Content-Type": "text/event-stream" } }));
const ac = new AbortController();
await assert.rejects(stream(STREAM, { signal: ac.signal, onEvent: () => ac.abort() }), { name: "AbortError" });
await assert.rejects(stream(STREAM, { signal: AbortSignal.abort() }), { name: "AbortError" });
});
// The mock server signs with the client's own derivation at the Argon2 floor
test("token mode returns the token, and logout presents it", async (t) => {
const challenge = { salt: Buffer.alloc(16, 9).toString("base64"), argon_time: 3, argon_memory: 65536, argon_threads: 1 };
const sent = [];
t.mock.method(globalThis, "fetch", async (url, init) => {
const body = JSON.parse(init.body);
sent.push({ body, headers: init.headers });
if (body.scram) {
challenge.client_nonce = body.scram.client_nonce;
challenge.full_nonce = body.scram.client_nonce + "server-nonce";
return Response.json({ challenge });
}
if (body.proof) {
const want = await _proofForTest("alice", "password123", challenge.client_nonce, challenge, 3, 65536);
challenge.proof_ok = body.proof.client_proof === want.client_proof;
return Response.json({ final: { username: "alice", server_signature: want.server_signature }, token: "tok", expires_in: 900 });
}
return new Response(null, { status: 204 });
});
const base = "https://site.test/logs/";
assert.deepEqual(await login(base, "alice", "password123", { session: "token" }),
{ username: "alice", expiresIn: 900, token: "tok" });
assert.ok(challenge.proof_ok);
assert.equal("session" in sent[1].body, false);
await logout(base, { token: "tok" });
assert.equal(sent[2].headers.Authorization, "Bearer tok");
});
+9
View File
@@ -115,3 +115,12 @@ progress {
white-space: pre-wrap;
overflow-wrap: anywhere;
}
[hidden] {
display: none !important;
}
.note {
margin: 0;
color: var(--muted);
}
+8
View File
@@ -14,6 +14,14 @@
<span id="state" role="status">connecting…</span>
<button id="logout" type="button">Sign out</button>
</header>
<form id="login" class="card" method="post" hidden>
<p class="note">This browser keeps no cookie for this site, so the session lives in this page and ends when it closes.</p>
<label>Username <input name="username" autocomplete="username" required></label>
<label>Password <input name="password" type="password" autocomplete="current-password" required></label>
<button type="submit">Sign in</button>
<progress id="progress" max="1" hidden></progress>
<p id="status" role="status"></p>
</form>
<main id="log"></main>
</body>
</html>
+127 -22
View File
@@ -1,4 +1,4 @@
import { logout } from "./scram.js";
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
@@ -6,7 +6,22 @@ const statusPath = document.querySelector('meta[name="logwisp-status"]').content
const MAX_LINES = 5000;
const log = document.getElementById("log");
const state = document.getElementById("state");
let source = null;
const signOut = document.getElementById("logout");
const form = document.getElementById("login");
const progress = document.getElementById("progress");
const status = document.getElementById("status");
const unavailable = 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 token = null;
// The login page comes back with this fragment: a 401 now means the browser
// dropped the session cookie
let fresh = location.hash === "#signed-in";
if (fresh) history.replaceState(null, "", location.pathname + location.search);
let stopStream = null;
let timer = null;
function append(line) {
const atBottom = log.scrollTop + log.clientHeight >= log.scrollHeight - 4;
@@ -18,50 +33,140 @@ function append(line) {
}
// The status answer names the stream path; its 401 is the only way to tell an
// expired session apart, since EventSource hides the status of a failure.
// ended session apart, since EventSource hides the status of a failure.
async function connect() {
if (tokenMode && !token) return showLogin("");
let res;
try {
res = await fetch(new URL("./" + statusPath, base), { credentials: "same-origin", cache: "no-store", redirect: "error" });
res = await fetch(new URL("./" + statusPath, base), {
headers: token ? { Authorization: `Bearer ${token}` } : {},
credentials: "same-origin", cache: "no-store", redirect: "error",
});
} catch {
return retry("server unreachable");
}
if (res.status === 401) {
location.replace(new URL("login?next=view", location.href).href);
return;
}
if (res.status === 401) return sessionEnded();
fresh = false;
const path = res.ok ? (await res.json().catch(() => ({})))?.endpoints?.stream : null;
if (typeof path !== "string" || !path.startsWith("/")) {
return retry(`status unavailable (${res.status})`);
}
// Stream paths are absolute in logwisp; the proxy may mount it under a prefix
source = new EventSource(new URL("./" + path.replace(/^\/+/, ""), base));
source.addEventListener("connected", () => { state.textContent = "live"; });
source.addEventListener("disconnect", () => {
source.close();
const url = new URL("./" + path.replace(/^\/+/, ""), base);
if (tokenMode) streamWithToken(url);
else streamWithCookie(url);
}
function onEvent(type, data) {
if (type === "connected") state.textContent = "live";
if (type === "disconnect") {
stopStream();
retry("server shut down");
});
source.onmessage = (event) => event.data.split("\n").forEach(append);
}
if (type === "message") data.split("\n").forEach(append);
}
function streamWithCookie(url) {
const source = new EventSource(url);
stopStream = () => source.close();
for (const type of ["connected", "disconnect", "message"]) {
source.addEventListener(type, (event) => onEvent(type, event.data));
}
source.onerror = () => {
if (source.readyState === EventSource.CLOSED) retry("disconnected");
else state.textContent = "reconnecting…";
};
}
function retry(reason) {
state.textContent = `${reason}, retrying…`;
setTimeout(connect, 5000);
async function streamWithToken(url) {
const abort = new AbortController();
stopStream = () => abort.abort();
let refused = 0;
try {
await stream(url, { token, signal: abort.signal, onEvent: (event) => onEvent(event.type, event.data) });
} catch (err) {
refused = err.status ?? 0;
}
if (abort.signal.aborted) return;
if (refused === 401) sessionEnded();
else retry(refused ? `stream refused (${refused})` : "disconnected");
}
document.getElementById("logout").addEventListener("click", async () => {
source?.close();
function sessionEnded() {
if (!tokenMode && !fresh) {
location.replace(new URL("login?next=" + encodeURIComponent("view#signed-in"), location.href).href);
return;
}
const expired = token !== null;
tokenMode = true;
token = null;
showLogin(expired ? "The session ended; sign in again." : "");
}
function showLogin(message) {
stopStream?.();
clearTimeout(timer);
state.textContent = "signed out";
signOut.hidden = true;
status.textContent = message;
form.hidden = false;
form.elements.username.focus();
}
function retry(reason) {
state.textContent = `${reason}, retrying…`;
clearTimeout(timer);
timer = setTimeout(connect, 5000);
}
form.addEventListener("submit", async (event) => {
event.preventDefault();
const fields = new FormData(form);
const button = form.querySelector("button");
button.disabled = true;
progress.removeAttribute("value");
progress.hidden = false;
status.textContent = "Deriving the key, this takes a few seconds…";
try {
await logout(base);
({ token } = await login(base, fields.get("username"), fields.get("password"), {
session: "token",
onProgress: (fraction) => { progress.value = fraction; },
}));
} catch (err) {
status.textContent = err.message;
return;
} finally {
progress.hidden = true;
button.disabled = false;
}
form.elements.password.value = "";
form.hidden = true;
signOut.hidden = false;
state.textContent = "connecting…";
connect();
});
signOut.addEventListener("click", async () => {
stopStream?.();
clearTimeout(timer);
try {
await logout(base, { token });
} catch (err) {
state.textContent = err.message;
return;
}
location.replace(new URL("login", location.href).href);
if (!tokenMode) {
location.replace(new URL("login", location.href).href);
return;
}
token = null;
showLogin("Signed out.");
});
connect();
// No session of either kind can start on this page, so it offers no form
if (unavailable) {
state.textContent = unavailable;
signOut.hidden = true;
} else {
connect();
}
+11 -7
View File
@@ -9,6 +9,7 @@ import (
"maps"
"net"
"net/http"
"net/url"
"os"
"strconv"
"strings"
@@ -103,6 +104,10 @@ func NewHTTPChainSinkPlugin(
if err := lconfig.Port(opts.Port); err != nil {
return nil, fmt.Errorf("port: %w", err)
}
network, err := core.Network(opts.Host)
if err != nil {
return nil, fmt.Errorf("host: %w", err)
}
if opts.IngestPath == "" {
opts.IngestPath = DefaultHTTPChainSinkIngestPath
} else if !strings.HasPrefix(opts.IngestPath, "/") {
@@ -158,10 +163,9 @@ func NewHTTPChainSinkPlugin(
addr := net.JoinHostPort(opts.Host, strconv.FormatInt(opts.Port, 10))
transport := &http.Transport{
DialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
// IPv4-only, aligns with tcp/http sinks
DialContext: func(ctx context.Context, _, address string) (net.Conn, error) {
d := net.Dialer{}
return d.DialContext(ctx, "tcp4", address)
return d.DialContext(ctx, network, address)
},
MaxIdleConnsPerHost: 2,
IdleConnTimeout: 90 * time.Second,
@@ -172,9 +176,9 @@ func NewHTTPChainSinkPlugin(
// NDJSON POSTs gain nothing from it
}
scheme := "http"
base := url.URL{Scheme: "http", Host: addr} // escapes an IPv6 zone
if tlsCfg != nil {
scheme = "https"
base.Scheme = "https"
}
t := &HTTPChainSink{
@@ -185,8 +189,8 @@ func NewHTTPChainSinkPlugin(
tlsEnabled: tlsCfg != nil,
mtls: tlsCfg != nil && len(tlsCfg.Certificates) > 0,
auth: authPolicy,
baseURL: scheme + "://" + addr,
url: scheme + "://" + addr + opts.IngestPath,
baseURL: base.String(),
url: base.String() + opts.IngestPath,
// A redirect would resend the batch, even from https to plaintext http
client: &http.Client{
Transport: transport,
+74
View File
@@ -1,6 +1,7 @@
package httpchain
import (
"context"
"crypto/tls"
"net"
"net/http"
@@ -16,6 +17,7 @@ import (
"logwisp/internal/config"
"logwisp/internal/core"
"logwisp/internal/session"
ingest "logwisp/internal/source/httpchain"
"logwisp/internal/testutil"
"github.com/lixenwraith/auth"
@@ -126,3 +128,75 @@ func TestRefusedTokenLogsInAgainAndDeliversOnce(t *testing.T) {
t.Fatalf("logins = %v, want 2", logins)
}
}
// The target URL brackets an IPv6 host and escapes its zone: a URL built
// from the raw host could not even form a request.
func TestTargetURLBracketsAnIPv6Host(t *testing.T) {
manager := session.NewManager(time.Hour)
t.Cleanup(manager.Stop)
for host, want := range map[string]string{
"::1": "http://[::1]:9000/ingest",
"fe80::1%eth0": "http://[fe80::1%25eth0]:9000/ingest",
} {
created, err := NewHTTPChainSinkPlugin("fwd", map[string]any{"host": host, "port": int64(9000)},
log.NewLogger(), session.NewProxy(manager, "fwd"))
if err != nil {
t.Fatal(err)
}
if got := created.(*HTTPChainSink).url; got != want {
t.Errorf("host %q: target %q, want %q", host, got, want)
}
}
}
// A link runs over the IPv6 loopback with TLS: the source binds ::1, and the
// sink dials it and verifies the certificate's ::1 address.
func TestLinkOverIPv6LoopbackWithTLS(t *testing.T) {
testutil.RequireIPv6(t)
pki := testutil.NewPKI(t, "edge-01")
probe, err := net.Listen("tcp6", "[::1]:0")
if err != nil {
t.Fatal(err)
}
_, port, _ := net.SplitHostPort(probe.Addr().String())
probe.Close()
p, _ := strconv.ParseInt(port, 10, 64)
manager := session.NewManager(time.Hour)
t.Cleanup(manager.Stop)
src, err := ingest.NewHTTPChainSourcePlugin("in", map[string]any{
"host": "::1", "port": p,
"tls": map[string]any{"enabled": true, "cert_file": pki.ServerCert, "key_file": pki.ServerKey},
}, log.NewLogger(), session.NewProxy(manager, "in"))
if err != nil {
t.Fatal(err)
}
entries := src.Subscribe()
if err := src.Start(); err != nil {
t.Fatal(err)
}
t.Cleanup(src.Stop)
created, err := NewHTTPChainSinkPlugin("fwd", map[string]any{
"host": "::1", "port": p,
"tls": map[string]any{"enabled": true, "ca_file": pki.CA},
}, log.NewLogger(), session.NewProxy(manager, "fwd"))
if err != nil {
t.Fatal(err)
}
sink := created.(*HTTPChainSink)
sink.append(core.TransportEvent{Time: time.Now(), Payload: []byte("over IPv6")})
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
defer cancel()
if !sink.flush(ctx) || sink.droppedBatches.Load() != 0 {
t.Fatalf("batch not delivered: %d request errors", sink.requestErrors.Load())
}
select {
case e := <-entries:
if e.Message != "over IPv6" {
t.Fatalf("message %q", e.Message)
}
case <-time.After(2 * time.Second):
t.Fatal("the source published nothing")
}
}
+1 -1
View File
@@ -34,4 +34,4 @@ type SinkStats struct {
StartTime time.Time
LastProcessed time.Time
Details map[string]any
}
}
+9 -4
View File
@@ -48,8 +48,9 @@ type TCPSink struct {
proxy *session.Proxy
// Configuration
config *config.TCPSinkOptions
addr string
config *config.TCPSinkOptions
addr string
network string
// Network
listener net.Listener
@@ -114,6 +115,10 @@ func NewTCPSinkPlugin(
if err := lconfig.Port(opts.Port); err != nil {
return nil, fmt.Errorf("port: %w", err)
}
network, err := core.Network(opts.Host)
if err != nil {
return nil, fmt.Errorf("host: %w", err)
}
if opts.BufferSize <= 0 {
opts.BufferSize = DefaultTCPBufferSize
}
@@ -140,6 +145,7 @@ func NewTCPSinkPlugin(
proxy: proxy,
config: opts,
addr: net.JoinHostPort(opts.Host, strconv.FormatInt(opts.Port, 10)),
network: network,
input: make(chan core.TransportEvent, opts.BufferSize),
done: make(chan struct{}),
logger: logger,
@@ -192,8 +198,7 @@ func (t *TCPSink) listen() (net.Listener, error) {
Idle: time.Duration(t.config.KeepAlivePeriodMS) * time.Millisecond,
}
}
// IPv4-only, parity with existing network sinks
ln, err := lc.Listen(context.Background(), "tcp4", t.addr)
ln, err := core.Listen(context.Background(), &lc, t.network, t.addr)
if err != nil {
return nil, err
}
+8 -2
View File
@@ -51,6 +51,7 @@ type TCPChainSink struct {
node string
addr string
network string
tlsConfig *tls.Config
// Authorization: pins the downstream server's identity
@@ -100,6 +101,10 @@ func NewTCPChainSinkPlugin(
if err := lconfig.Port(opts.Port); err != nil {
return nil, fmt.Errorf("port: %w", err)
}
network, err := core.Network(opts.Host)
if err != nil {
return nil, fmt.Errorf("host: %w", err)
}
if opts.BufferSize <= 0 {
opts.BufferSize = DefaultChainSinkBufferSize
@@ -149,6 +154,7 @@ func NewTCPChainSinkPlugin(
config: opts,
node: node,
addr: net.JoinHostPort(opts.Host, strconv.FormatInt(opts.Port, 10)),
network: network,
tlsConfig: tlsCfg,
auth: authPolicy,
input: make(chan core.TransportEvent, opts.BufferSize),
@@ -377,10 +383,10 @@ func (t *TCPChainSink) connect(ctx context.Context) error {
// handshake under ctx, so bound dial + handshake together here
dctx, cancel := context.WithTimeout(ctx, t.dialTimeout+tlsx.HandshakeTimeout)
td := tls.Dialer{NetDialer: &nd, Config: t.tlsConfig}
conn, err = td.DialContext(dctx, "tcp4", t.addr) // IPv4-only
conn, err = td.DialContext(dctx, t.network, t.addr)
cancel()
} else {
conn, err = nd.DialContext(ctx, "tcp4", t.addr) // IPv4-only
conn, err = nd.DialContext(ctx, t.network, t.addr)
}
if err != nil {
return err
+10 -5
View File
@@ -45,9 +45,10 @@ const (
// HTTPChainSource accepts NDJSON batches from upstream http_chain sinks
type HTTPChainSource struct {
id string
proxy *session.Proxy
config *config.HTTPChainSourceOptions
id string
proxy *session.Proxy
config *config.HTTPChainSourceOptions
network string
subscribers []chan core.LogEntry
server *http.Server
@@ -91,6 +92,10 @@ func NewHTTPChainSourcePlugin(
if err := lconfig.Port(opts.Port); err != nil {
return nil, fmt.Errorf("port: %w", err)
}
network, err := core.Network(opts.Host)
if err != nil {
return nil, fmt.Errorf("host: %w", err)
}
if opts.IngestPath == "" {
opts.IngestPath = DefaultHTTPChainSourceIngestPath
} else if !strings.HasPrefix(opts.IngestPath, "/") {
@@ -120,6 +125,7 @@ func NewHTTPChainSourcePlugin(
id: id,
proxy: proxy,
config: opts,
network: network,
subscribers: make([]chan core.LogEntry, 0),
sessions: make(map[string]string),
logger: logger,
@@ -169,8 +175,7 @@ func (s *HTTPChainSource) Start() error {
return err
}
addr := net.JoinHostPort(s.config.Host, strconv.FormatInt(s.config.Port, 10))
// IPv4-only, aligns with tcp/http sinks
ln, err := net.Listen("tcp4", addr)
ln, err := core.Listen(context.Background(), &net.ListenConfig{}, s.network, addr)
if err != nil {
s.auth.Close()
return fmt.Errorf("listen %s: %w", addr, err)
+1 -1
View File
@@ -59,4 +59,4 @@ func ExtractLogLevel(line string) string {
}
return ""
}
}
+12 -6
View File
@@ -39,9 +39,10 @@ const (
// TCPChainSource accepts connections from upstream tcp_chain sinks and ingests NDJSON entries
type TCPChainSource struct {
id string
proxy *session.Proxy
config *config.TCPChainSourceOptions
id string
proxy *session.Proxy
config *config.TCPChainSourceOptions
network string
subscribers []chan core.LogEntry
listener net.Listener
@@ -86,6 +87,10 @@ func NewTCPChainSourcePlugin(
if err := lconfig.Port(opts.Port); err != nil {
return nil, fmt.Errorf("port: %w", err)
}
network, err := core.Network(opts.Host)
if err != nil {
return nil, fmt.Errorf("host: %w", err)
}
if opts.BufferSize <= 0 {
opts.BufferSize = DefaultChainSourceBufferSize
}
@@ -105,6 +110,7 @@ func NewTCPChainSourcePlugin(
id: id,
proxy: proxy,
config: opts,
network: network,
subscribers: make([]chan core.LogEntry, 0),
conns: make(map[net.Conn]struct{}),
logger: logger,
@@ -153,9 +159,9 @@ func (s *TCPChainSource) Start() error {
return err
}
addr := net.JoinHostPort(s.config.Host, strconv.FormatInt(s.config.Port, 10))
// IPv4-only. TLS-wrapped when configured; handshake runs explicitly in
// handleConn under tlsx.HandshakeTimeout, pre-hello.
ln, err := net.Listen("tcp4", addr)
// TLS-wrapped when configured; handshake runs explicitly in handleConn
// under tlsx.HandshakeTimeout, pre-hello.
ln, err := core.Listen(context.Background(), &net.ListenConfig{}, s.network, addr)
if err != nil {
s.auth.Close()
return fmt.Errorf("listen %s: %w", addr, err)
+15 -4
View File
@@ -40,8 +40,19 @@ func WriteFile(t testing.TB, path, contents string) {
}
}
// PKI is a throwaway CA with a server certificate for 127.0.0.1 and one client
// certificate, written as PEM files for plugins that load TLS from disk.
// RequireIPv6 skips a test on a host without an IPv6 loopback
func RequireIPv6(t testing.TB) {
t.Helper()
ln, err := net.Listen("tcp6", "[::1]:0")
if err != nil {
t.Skipf("no IPv6 loopback: %v", err)
}
ln.Close()
}
// PKI is a throwaway CA with a server certificate for 127.0.0.1 and ::1 and
// one client certificate, written as PEM files for plugins that load TLS from
// disk.
type PKI struct {
CA, ServerCert, ServerKey, ClientCert, ClientKey string
@@ -80,7 +91,7 @@ func NewPKI(t testing.TB, clientCN string) *PKI {
}
// Leaf issues another certificate from the same CA: a client certificate, or
// a server certificate valid for 127.0.0.1.
// a server certificate valid for 127.0.0.1 and ::1.
func (p *PKI) Leaf(t testing.TB, name, cn string, client bool) (certFile, keyFile string) {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
@@ -93,7 +104,7 @@ func (p *PKI) Leaf(t testing.TB, name, cn string, client bool) (certFile, keyFil
SerialNumber: big.NewInt(p.serial), Subject: pkix.Name{CommonName: cn},
NotBefore: now.Add(-time.Hour), NotAfter: now.Add(24 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1)},
IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1), net.IPv6loopback},
}
if client {
tmpl.ExtKeyUsage, tmpl.IPAddresses = []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, nil
+3 -2
View File
@@ -63,7 +63,8 @@ func Server(o *config.TLSOptions) (*tls.Config, error) {
// Client builds the *tls.Config for dialer plugins (tcp_chain/http_chain
// sinks). host seeds ServerName when no override is set; Go verifies IP SANs
// when host is an address. Returns (nil, nil) when disabled.
// when host is an address, without its zone, which names a local interface
// and no certificate carries. Returns (nil, nil) when disabled.
func Client(o *config.TLSOptions, host string) (*tls.Config, error) {
if o == nil || !o.Enabled {
return nil, nil
@@ -78,7 +79,7 @@ func Client(o *config.TLSOptions, host string) (*tls.Config, error) {
InsecureSkipVerify: o.InsecureSkipVerify,
}
if cfg.ServerName == "" {
cfg.ServerName = host
cfg.ServerName, _, _ = strings.Cut(host, "%")
}
if o.CAFile != "" {
pool, err := loadPool(o.CAFile)
+16
View File
@@ -6,8 +6,24 @@ import (
"strings"
"testing"
"time"
"logwisp/internal/config"
)
// A dialer verifies an IPv6 target by its address: the zone names a local
// interface, which no certificate carries.
func TestClientServerNameDropsTheZone(t *testing.T) {
for host, want := range map[string]string{"fe80::1%eth0": "fe80::1", "::1": "::1", "relay.example": "relay.example"} {
cfg, err := Client(&config.TLSOptions{Enabled: true}, host)
if err != nil {
t.Fatal(err)
}
if cfg.ServerName != want {
t.Errorf("Client(%q).ServerName = %q, want %q", host, cfg.ServerName, want)
}
}
}
// Startup names a certificate outside its validity window or within 30 days
// of leaving it; a healthy one stays silent.
func TestExpiryWarning(t *testing.T) {
+1 -1
View File
@@ -22,4 +22,4 @@ func String() string {
// Short returns just the version tag
func Short() string {
return Version
}
}