v0.22.0 several enhancements and fixes for security, network, doc, test, packaging
This commit is contained in:
1 parent
9ab8c356d2
commit
71d1b82f83
102 files changed
+7102
-2178
No files matched your search
+22
-5
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
)
|
||||
)
|
||||
@@ -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()
|
||||
}
|
||||
@@ -24,4 +24,3 @@ type TransportEvent struct {
|
||||
// Structured entry for re-serializing sinks (chain links). Zero Time => absent
|
||||
Entry LogEntry
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -79,4 +79,4 @@ func (c *Chain) GetStats() map[string]any {
|
||||
"total_passed": c.totalPassed.Load(),
|
||||
"filters": filterStats,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -209,4 +209,4 @@ func (f *Filter) matches(text string) bool {
|
||||
"logic", f.config.Logic)
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -161,4 +161,3 @@ func (f *Flow) GetStats() map[string]any {
|
||||
|
||||
return stats
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -130,4 +130,4 @@ func policyString(p config.RateLimitPolicy) string {
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,4 +25,4 @@ func NewFormatter(cfg *config.FormatConfig) (Formatter, error) {
|
||||
}
|
||||
|
||||
return NewFormatterAdapter(cfg)
|
||||
}
|
||||
}
|
||||
@@ -219,4 +219,4 @@ func (r *Registry) RemoveSink(id string) {
|
||||
}
|
||||
|
||||
delete(r.sinkInstances, id)
|
||||
}
|
||||
}
|
||||
@@ -186,4 +186,4 @@ func ListSinks() []string {
|
||||
types = append(types, t)
|
||||
}
|
||||
return types
|
||||
}
|
||||
}
|
||||
@@ -67,4 +67,4 @@ func IsSafe(data string) bool {
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
+18
-14
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -115,3 +115,12 @@ progress {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.note {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -34,4 +34,4 @@ type SinkStats struct {
|
||||
StartTime time.Time
|
||||
LastProcessed time.Time
|
||||
Details map[string]any
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -59,4 +59,4 @@ func ExtractLogLevel(line string) string {
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -22,4 +22,4 @@ func String() string {
|
||||
// Short returns just the version tag
|
||||
func Short() string {
|
||||
return Version
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user