v0.31.0 refinements
This commit is contained in:
1 parent
bfcc15613a
commit
6bd5e12a60
52 files changed
+1915
-650
No files matched your search
@@ -23,7 +23,7 @@ streams, or downstream LogWisp nodes.
|
|||||||
to stdout line for line and exits at the end of input; flags add filters,
|
to stdout line for line and exits at the end of input; flags add filters,
|
||||||
formats, sources and sinks
|
formats, sources and sinks
|
||||||
- **Presets**: `tail`, `serve`, `edge` and `aggregator` build the common
|
- **Presets**: `tail`, `serve`, `edge` and `aggregator` build the common
|
||||||
pipelines from a few keys (`lw --preset tail,path=/var/log/app`);
|
pipelines from a few keys (`lw --preset tail:path=/var/log/app`);
|
||||||
`lw --dump` turns any command line into a configuration file
|
`lw --dump` turns any command line into a configuration file
|
||||||
- **Independent pipelines**, each `sources → flow → sinks`, running concurrently
|
- **Independent pipelines**, each `sources → flow → sinks`, running concurrently
|
||||||
in one process
|
in one process
|
||||||
@@ -132,18 +132,18 @@ one without `--sink` writes stdout:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
./bin/lw < app.log > copy.log
|
./bin/lw < app.log > copy.log
|
||||||
tail -F app.log | ./bin/lw --filter include,patterns=ERROR,patterns=WARN
|
tail -F app.log | ./bin/lw --filter include:patterns=ERROR,patterns=WARN
|
||||||
|
|
||||||
# stdin as a live SSE stream, with a browser viewer at http://127.0.0.1:8080/;
|
# stdin as a live SSE stream, with a browser viewer at http://127.0.0.1:8080/;
|
||||||
# the http sink binds 0.0.0.0 unless told
|
# the http sink binds 0.0.0.0 unless told
|
||||||
journalctl -f | ./bin/lw --sink http,host=127.0.0.1,port=8080
|
journalctl -f | ./bin/lw --sink http:host=127.0.0.1,port=8080
|
||||||
|
|
||||||
# tail a directory instead of stdin
|
# tail a directory instead of stdin
|
||||||
./bin/lw --source 'file,directory=/var/log/myapp,pattern=*.log' \
|
./bin/lw --source 'file:directory=/var/log/myapp,pattern=*.log' \
|
||||||
--format json,sanitizer_policy=json > all.json
|
--format json:sanitizer_policy=json > all.json
|
||||||
|
|
||||||
# the same with a preset; on a terminal the level names are in color
|
# the same with a preset; on a terminal the level names are in color
|
||||||
./bin/lw --preset tail,path=/var/log/myapp
|
./bin/lw --preset tail:path=/var/log/myapp
|
||||||
```
|
```
|
||||||
|
|
||||||
As a service, a configuration file holds the pipelines:
|
As a service, a configuration file holds the pipelines:
|
||||||
|
|||||||
+7
-7
@@ -35,12 +35,12 @@ var authCommands = []subcommand{
|
|||||||
{"remove-user", "--credentials FILE -u NAME",
|
{"remove-user", "--credentials FILE -u NAME",
|
||||||
"Remove a user from a credentials file",
|
"Remove a user from a credentials file",
|
||||||
[]string{"credentials", "user"}, defineRemoveUser},
|
[]string{"credentials", "user"}, defineRemoveUser},
|
||||||
{"token", "--url https://HOST:PORT -u NAME --password-file FILE [--unbound] [TLS flags]",
|
{"token", "--url https://HOST:PORT -u NAME [--password-file FILE] [--unbound] [TLS flags]",
|
||||||
"Log in to an http sink or http_chain source and print a bearer token",
|
"Log in to an http sink or http_chain source and print a bearer token",
|
||||||
[]string{"url", "user", "password-file"}, defineToken},
|
[]string{"url", "user"}, defineToken},
|
||||||
{"stream", "--addr HOST:PORT -u NAME --password-file FILE [TLS flags]",
|
{"stream", "--addr HOST:PORT -u NAME [--password-file FILE] [TLS flags]",
|
||||||
"Log in to a tcp sink and copy its stream to stdout until interrupted",
|
"Log in to a tcp sink and copy its stream to stdout until interrupted",
|
||||||
[]string{"addr", "user", "password-file"}, defineStream},
|
[]string{"addr", "user"}, defineStream},
|
||||||
}
|
}
|
||||||
|
|
||||||
func defineAddUser(fs *flag.FlagSet) func(stdout, stderr io.Writer) error {
|
func defineAddUser(fs *flag.FlagSet) func(stdout, stderr io.Writer) error {
|
||||||
@@ -62,7 +62,7 @@ func defineAddUser(fs *flag.FlagSet) func(stdout, stderr io.Writer) error {
|
|||||||
|
|
||||||
var password string
|
var password string
|
||||||
if *passwordFile != "" && !*generate {
|
if *passwordFile != "" && !*generate {
|
||||||
password, err = authz.ReadPassword(*passwordFile)
|
password, err = authz.ReadPassword(*passwordFile, *user, false)
|
||||||
switch {
|
switch {
|
||||||
case err == nil && len(password) < 8:
|
case err == nil && len(password) < 8:
|
||||||
return fmt.Errorf("the password in %s is shorter than 8 bytes", *passwordFile)
|
return fmt.Errorf("the password in %s is shorter than 8 bytes", *passwordFile)
|
||||||
@@ -155,7 +155,7 @@ func defineRemoveUser(fs *flag.FlagSet) func(stdout, stderr io.Writer) error {
|
|||||||
func defineToken(fs *flag.FlagSet) func(stdout, stderr io.Writer) error {
|
func defineToken(fs *flag.FlagSet) func(stdout, stderr io.Writer) error {
|
||||||
rawURL := fs.String("url", "", "listener `URL`, https://HOST:PORT or https://[IPV6]:PORT (with --unbound, a proxy's mount URL may carry a path)")
|
rawURL := fs.String("url", "", "listener `URL`, https://HOST:PORT or https://[IPV6]:PORT (with --unbound, a proxy's mount URL may carry a path)")
|
||||||
user := fs.String("user", "", "user `name`")
|
user := fs.String("user", "", "user `name`")
|
||||||
passwordFile := fs.String("password-file", "", "`file` holding the password")
|
passwordFile := fs.String("password-file", "/dev/tty", "`file` holding the password; a terminal asks")
|
||||||
unbound := fs.Bool("unbound", false, "log in without channel binding, to an http sink behind a TLS-terminating proxy (auth.trusted_proxies)")
|
unbound := fs.Bool("unbound", false, "log in without channel binding, to an http sink behind a TLS-terminating proxy (auth.trusted_proxies)")
|
||||||
tlsOpts := tlsFlags(fs)
|
tlsOpts := tlsFlags(fs)
|
||||||
return func(stdout, _ io.Writer) error {
|
return func(stdout, _ io.Writer) error {
|
||||||
@@ -205,7 +205,7 @@ func defineToken(fs *flag.FlagSet) func(stdout, stderr io.Writer) error {
|
|||||||
func defineStream(fs *flag.FlagSet) func(stdout, stderr io.Writer) error {
|
func defineStream(fs *flag.FlagSet) func(stdout, stderr io.Writer) error {
|
||||||
addr := fs.String("addr", "", "tcp sink address, `HOST:PORT` or [IPV6]:PORT")
|
addr := fs.String("addr", "", "tcp sink address, `HOST:PORT` or [IPV6]:PORT")
|
||||||
user := fs.String("user", "", "user `name`")
|
user := fs.String("user", "", "user `name`")
|
||||||
passwordFile := fs.String("password-file", "", "`file` holding the password")
|
passwordFile := fs.String("password-file", "/dev/tty", "`file` holding the password; a terminal asks")
|
||||||
tlsOpts := tlsFlags(fs)
|
tlsOpts := tlsFlags(fs)
|
||||||
return func(stdout, _ io.Writer) error {
|
return func(stdout, _ io.Writer) error {
|
||||||
host, _, err := net.SplitHostPort(*addr)
|
host, _, err := net.SplitHostPort(*addr)
|
||||||
|
|||||||
+1
-1
@@ -63,7 +63,7 @@ func TestAddUserCreatesPrivateFilesWithAMatchingVerifier(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
password, err := authz.ReadPassword(pass)
|
password, err := authz.ReadPassword(pass, "edge-01", false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import (
|
|||||||
_ "github.com/lixenwraith/logwisp/internal/sink/tcp"
|
_ "github.com/lixenwraith/logwisp/internal/sink/tcp"
|
||||||
_ "github.com/lixenwraith/logwisp/internal/sink/tcpchain"
|
_ "github.com/lixenwraith/logwisp/internal/sink/tcpchain"
|
||||||
|
|
||||||
|
"github.com/lixenwraith/logwisp/internal/authz"
|
||||||
"github.com/lixenwraith/logwisp/internal/config"
|
"github.com/lixenwraith/logwisp/internal/config"
|
||||||
"github.com/lixenwraith/logwisp/internal/service"
|
"github.com/lixenwraith/logwisp/internal/service"
|
||||||
"github.com/lixenwraith/logwisp/internal/version"
|
"github.com/lixenwraith/logwisp/internal/version"
|
||||||
@@ -37,6 +38,7 @@ func bootstrapInitial(ctx context.Context, cfg *config.Config) (*service.Service
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, fmt.Errorf("failed to bootstrap service: %w", err)
|
return nil, nil, fmt.Errorf("failed to bootstrap service: %w", err)
|
||||||
}
|
}
|
||||||
|
authz.EndStartup() // a reload reads no descriptor, pipe or terminal
|
||||||
|
|
||||||
if err := svc.Start(); err != nil {
|
if err := svc.Start(); err != nil {
|
||||||
svc.Shutdown()
|
svc.Shutdown()
|
||||||
|
|||||||
+1
-1
@@ -40,7 +40,7 @@ var commands = []command{
|
|||||||
{"tls", "A CA and certificates for TLS and mTLS",
|
{"tls", "A CA and certificates for TLS and mTLS",
|
||||||
"Files are never overwritten; keys are written mode 0600.", tlsCommands},
|
"Files are never overwritten; keys are written mode 0600.", tlsCommands},
|
||||||
{"preset", "Show the pipeline a preset makes, as TOML",
|
{"preset", "Show the pipeline a preset makes, as TOML",
|
||||||
"lw --preset NAME,key=value runs one; flags here are its keys.", presetCommands()},
|
"lw --preset NAME:key=value runs one; flags here are its keys.", presetCommands()},
|
||||||
{"config", "Place the annotated default configuration",
|
{"config", "Place the annotated default configuration",
|
||||||
"Files are never overwritten; lw --dump prints the effective configuration.", configCommands},
|
"Files are never overwritten; lw --dump prints the effective configuration.", configCommands},
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-13
@@ -31,9 +31,9 @@ func TestCommandLineGrammar(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{[]string{"-c", "a.toml", "--config=b.toml", "--quiet"}, invocation{load: config.Args{File: "b.toml", Overrides: []string{"--quiet"}}}},
|
{[]string{"-c", "a.toml", "--config=b.toml", "--quiet"}, invocation{load: config.Args{File: "b.toml", Overrides: []string{"--quiet"}}}},
|
||||||
{[]string{"-c=a.toml", "--config", "b.toml"}, invocation{load: config.Args{File: "b.toml"}}},
|
{[]string{"-c=a.toml", "--config", "b.toml"}, invocation{load: config.Args{File: "b.toml"}}},
|
||||||
{[]string{"--source=null", "--sink", "http,port=1"},
|
{[]string{"--source=null", "--sink", "http:port=1"},
|
||||||
invocation{load: config.Args{Specs: []config.Spec{{Flag: "source", Value: "null"}, {Flag: "sink", Value: "http,port=1"}}}}},
|
invocation{load: config.Args{Specs: []config.Spec{{Flag: "source", Value: "null"}, {Flag: "sink", Value: "http:port=1"}}}}},
|
||||||
{[]string{"--preset", "tail,path=x", "--pipeline", "b"}, invocation{load: config.Args{Specs: []config.Spec{{Flag: "preset", Value: "tail,path=x"}, {Flag: "pipeline", Value: "b"}}}}},
|
{[]string{"--preset", "tail:path=x", "--pipeline", "b"}, invocation{load: config.Args{Specs: []config.Spec{{Flag: "preset", Value: "tail:path=x"}, {Flag: "pipeline", Value: "b"}}}}},
|
||||||
{[]string{"--color", "--color", "never", "--color=auto"}, invocation{load: config.Args{Overrides: []string{"--color=always", "--color=never", "--color=auto"}}}},
|
{[]string{"--color", "--color", "never", "--color=auto"}, invocation{load: config.Args{Overrides: []string{"--color=always", "--color=never", "--color=auto"}}}},
|
||||||
{[]string{"--quiet", "--"}, invocation{load: config.Args{Overrides: []string{"--quiet"}}}},
|
{[]string{"--quiet", "--"}, invocation{load: config.Args{Overrides: []string{"--quiet"}}}},
|
||||||
{[]string{"--quiet", "-h"}, invocation{help: true, load: config.Args{Overrides: []string{"--quiet"}}}},
|
{[]string{"--quiet", "-h"}, invocation{help: true, load: config.Args{Overrides: []string{"--quiet"}}}},
|
||||||
@@ -41,9 +41,9 @@ func TestCommandLineGrammar(t *testing.T) {
|
|||||||
{[]string{"help"}, invocation{help: true}},
|
{[]string{"help"}, invocation{help: true}},
|
||||||
{[]string{"-T", "-c", "a.toml"}, invocation{on: map[string]bool{"tui": true}, load: config.Args{File: "a.toml"}}},
|
{[]string{"-T", "-c", "a.toml"}, invocation{on: map[string]bool{"tui": true}, load: config.Args{File: "a.toml"}}},
|
||||||
{[]string{"tls", "ca", "-h"}, invocation{command: &commands[1], args: []string{"ca", "-h"}}},
|
{[]string{"tls", "ca", "-h"}, invocation{command: &commands[1], args: []string{"ca", "-h"}}},
|
||||||
{[]string{"-q", "-t", "-V", "-p", "tail,path=x", "-c=a.toml", "--logging.level", "debug", "--logging.file.retention_hours=-1", "--dump"},
|
{[]string{"-q", "-t", "-V", "-p", "tail:path=x", "-c=a.toml", "--logging.level", "debug", "--logging.file.retention_hours=-1", "--dump"},
|
||||||
invocation{on: map[string]bool{"check": true, "version": true, "dump": true},
|
invocation{on: map[string]bool{"check": true, "version": true, "dump": true},
|
||||||
load: config.Args{File: "a.toml", Specs: []config.Spec{{Flag: "preset", Value: "tail,path=x"}},
|
load: config.Args{File: "a.toml", Specs: []config.Spec{{Flag: "preset", Value: "tail:path=x"}},
|
||||||
Overrides: []string{"--quiet", "--logging.level=debug", "--logging.file.retention_hours=-1"}}}},
|
Overrides: []string{"--quiet", "--logging.level=debug", "--logging.file.retention_hours=-1"}}}},
|
||||||
} {
|
} {
|
||||||
got, err := parseCommandLine(c.argv)
|
got, err := parseCommandLine(c.argv)
|
||||||
@@ -173,8 +173,8 @@ func TestDumpReadsBack(t *testing.T) {
|
|||||||
if err := os.WriteFile(empty, nil, 0o600); err != nil {
|
if err := os.WriteFile(empty, nil, 0o600); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
args := []string{"--color", "--preset", "serve,listen=127.0.0.1:15841,tls=self,hosts=a.example,hosts=b.example",
|
args := []string{"--color", "--preset", "serve:listen=127.0.0.1:15841,tls=self,hosts=a.example,hosts=b.example",
|
||||||
"--filter", `exclude,patterns=password=\S{8\,64}`, "--sink", "console", "--logging.level=debug"}
|
"--filter", `exclude:patterns=password=\S{8\,64}`, "--sink", "console", "--logging.level=debug"}
|
||||||
cfg := loadTestConfig(t, empty, args...)
|
cfg := loadTestConfig(t, empty, args...)
|
||||||
var dump bytes.Buffer
|
var dump bytes.Buffer
|
||||||
if code := dumpConfig(loadTestConfig(t, empty, args...), &dump); code != 0 {
|
if code := dumpConfig(loadTestConfig(t, empty, args...), &dump); code != 0 {
|
||||||
@@ -225,7 +225,7 @@ func TestEveryFormLoadsTheSamePipelines(t *testing.T) {
|
|||||||
written := filepath.Join(dir, "written.toml")
|
written := filepath.Join(dir, "written.toml")
|
||||||
if err := os.WriteFile(written, []byte(`[[pipelines]]
|
if err := os.WriteFile(written, []byte(`[[pipelines]]
|
||||||
name = "written"
|
name = "written"
|
||||||
flow.rate_limit.rate = 5
|
flow.rate_limit.entries_per_second = 5
|
||||||
plugin_sources = [{id = "in", type = "null"}]
|
plugin_sources = [{id = "in", type = "null"}]
|
||||||
plugin_sinks = [{id = "out", type = "http", config = {port = 15844, acl = {requests_per_second_per_client = 2}}}]
|
plugin_sinks = [{id = "out", type = "http", config = {port = 15844, acl = {requests_per_second_per_client = 2}}}]
|
||||||
`), 0o600); err != nil {
|
`), 0o600); err != nil {
|
||||||
@@ -321,11 +321,11 @@ func TestEveryPresetBuilds(t *testing.T) {
|
|||||||
}
|
}
|
||||||
pin := "sha256//" + strings.Repeat("A", 43) + "="
|
pin := "sha256//" + strings.Repeat("A", 43) + "="
|
||||||
samples := map[string]string{
|
samples := map[string]string{
|
||||||
"pipe": "pipe,format=txt",
|
"pipe": "pipe:format=txt",
|
||||||
"tail": "tail,path=" + dir,
|
"tail": "tail:path=" + dir,
|
||||||
"serve": "serve,path=" + filepath.Join(dir, "*.log") + ",listen=127.0.0.1:15841,tls=self,users=" + users + ",allow=127.0.0.0/8,deny=127.0.0.2",
|
"serve": "serve:path=" + filepath.Join(dir, "*.log") + ",listen=127.0.0.1:15841,tls=self,users=" + users + ",allow=127.0.0.0/8,deny=127.0.0.2",
|
||||||
"edge": "edge,to=127.0.0.1:15842,transport=http,pin=" + pin + ",user=edge-01,password_file=" + pass,
|
"edge": "edge:to=127.0.0.1:15842,transport=http,pin=" + pin + ",user=edge-01,password_file=" + pass,
|
||||||
"aggregator": "aggregator,listen=127.0.0.1:15843,users=" + users + ",out=" + filepath.Join(dir, "out") + ",allow=10.0.0.0/8,allow=127.0.0.1",
|
"aggregator": "aggregator:listen=127.0.0.1:15843,user=edge-01,password_file=" + pass + ",out=" + filepath.Join(dir, "out") + ",allow=10.0.0.0/8,allow=127.0.0.1",
|
||||||
}
|
}
|
||||||
for _, p := range config.Presets() {
|
for _, p := range config.Presets() {
|
||||||
sample, ok := samples[p.Name]
|
sample, ok := samples[p.Name]
|
||||||
|
|||||||
+19
-14
@@ -81,7 +81,7 @@ key alone means true:
|
|||||||
chooses the console
|
chooses the console
|
||||||
|
|
||||||
Pipelines (they replace the configuration file's pipelines):
|
Pipelines (they replace the configuration file's pipelines):
|
||||||
-p, --preset NAME[,KEY=VALUE...]
|
-p, --preset NAME[:KEY=VALUE,...]
|
||||||
Start a pipeline with a preset: pipe, tail,
|
Start a pipeline with a preset: pipe, tail,
|
||||||
serve, edge, aggregator; lw preset NAME -h
|
serve, edge, aggregator; lw preset NAME -h
|
||||||
lists its keys, lw preset NAME prints it
|
lists its keys, lw preset NAME prints it
|
||||||
@@ -89,27 +89,32 @@ Pipelines (they replace the configuration file's pipelines):
|
|||||||
pipeline options after it configure; those
|
pipeline options after it configure; those
|
||||||
before any --pipeline make one named after
|
before any --pipeline make one named after
|
||||||
its --preset, or "cli" without one
|
its --preset, or "cli" without one
|
||||||
--source SPEC TYPE[,KEY=VALUE...], repeatable
|
--source SPEC TYPE[:KEY=VALUE,...], repeatable
|
||||||
--sink SPEC e.g. http,host=0.0.0.0,port=8080, repeatable
|
--sink SPEC e.g. http:host=0.0.0.0,port=8080, repeatable
|
||||||
--filter SPEC include|exclude,patterns=RE, repeatable
|
--filter SPEC include|exclude:patterns=RE, repeatable
|
||||||
--format SPEC json|txt|raw[,KEY=VALUE...]
|
--format SPEC json|txt|raw[:KEY=VALUE,...]
|
||||||
--rate-limit SPEC rate=N[,burst=N,policy=drop|pass]
|
--rate-limit SPEC entries_per_second=N[,burst_entries=N,
|
||||||
|
policy=drop|pass]
|
||||||
--heartbeat SPEC interval_ms=N[,include_stats=true,...]
|
--heartbeat SPEC interval_ms=N[,include_stats=true,...]
|
||||||
A pipeline without --source reads stdin, one without --sink writes stdout.
|
A pipeline without --source reads stdin, one without --sink writes stdout.
|
||||||
Keys nest with '.' (tls.cert_file=...), a repeated key makes a list, and
|
TYPE ends at the first ':'. Keys nest with '.' (tls.cert_file=...), a
|
||||||
'\' escapes ',' '=' '\' in values.
|
repeated key makes a list, and '\' escapes ',' '=' '\' in values.
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
lw < app.log > copy.log Copy, line for line
|
lw < app.log > copy.log Copy, line for line
|
||||||
tail -F app.log | lw --filter include,patterns=ERROR,patterns=WARN
|
tail -F app.log | lw --filter include:patterns=ERROR,patterns=WARN
|
||||||
journalctl -f | lw --sink http,host=127.0.0.1,port=8080
|
journalctl -f | lw --sink http:host=127.0.0.1,port=8080
|
||||||
Serve stdin live; browse http://127.0.0.1:8080/
|
Serve stdin live; browse http://127.0.0.1:8080/
|
||||||
lw --source file,directory=/var/log/app,pattern='*.log' --format txt
|
lw --source file:directory=/var/log/app,pattern='*.log' --format txt
|
||||||
lw -p tail,path=/var/log/app Follow a directory's files
|
lw -p tail:path=/var/log/app Follow a directory's files
|
||||||
lw --preset serve,path=/var/log/app,tls=self,users=users.toml
|
lw --preset serve:path=/var/log/app,tls=self,users=users.toml
|
||||||
HTTPS stream, SCRAM logins
|
HTTPS stream, SCRAM logins
|
||||||
lw --preset edge,path=/var/log/app,to=agg:9000,ca=ca.crt,user=edge-01,\
|
lw --preset edge:path=/var/log/app,to=agg:9000,ca=ca.crt,user=edge-01,\
|
||||||
password_file=edge-01.pass Forward to an aggregator
|
password_file=edge-01.pass Forward to an aggregator
|
||||||
|
lw -p aggregator:user=pipe,format=raw A temporary pipe's end: Enter
|
||||||
|
at the prompt draws a password
|
||||||
|
cmd | lw -p edge:to=HOST:9000,pin=PIN,user=pipe
|
||||||
|
Its source, asked the password
|
||||||
|
|
||||||
Environment:
|
Environment:
|
||||||
LOGWISP_<KEY> A configuration key, '.' as '_', uppercase:
|
LOGWISP_<KEY> A configuration key, '.' as '_', uppercase:
|
||||||
|
|||||||
+23
-3
@@ -11,6 +11,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/lixenwraith/logwisp/internal/authz"
|
||||||
"github.com/lixenwraith/logwisp/internal/plugin"
|
"github.com/lixenwraith/logwisp/internal/plugin"
|
||||||
"github.com/lixenwraith/logwisp/internal/session"
|
"github.com/lixenwraith/logwisp/internal/session"
|
||||||
"github.com/lixenwraith/logwisp/internal/testutil"
|
"github.com/lixenwraith/logwisp/internal/testutil"
|
||||||
@@ -60,6 +61,25 @@ func TestShippedConfigurationBuildsWithCheckedPluginDecoder(t *testing.T) {
|
|||||||
svc.Shutdown() // A later reload failure or process exit can close it again.
|
svc.Shutdown() // A later reload failure or process exit can close it again.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Once the first service is built a reload reads no descriptor, pipe or
|
||||||
|
// terminal, which a console source may be reading
|
||||||
|
func TestStartupEndsWithTheFirstService(t *testing.T) {
|
||||||
|
testLogger(t)
|
||||||
|
path := filepath.Join(t.TempDir(), "empty.toml")
|
||||||
|
testutil.WriteFile(t, path, "")
|
||||||
|
svc, cancel, err := bootstrapInitial(context.Background(), loadTestConfig(t, path, "--source", "null", "--sink", "null"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer svc.Shutdown()
|
||||||
|
if cancel != nil {
|
||||||
|
defer cancel()
|
||||||
|
}
|
||||||
|
if _, err := authz.ReadPassword(os.DevNull, "late", false); err == nil || !strings.Contains(err.Error(), "only at startup") {
|
||||||
|
t.Fatalf("a device after the first service: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestInvalidReloadLeavesStatusReporterRunning(t *testing.T) {
|
func TestInvalidReloadLeavesStatusReporterRunning(t *testing.T) {
|
||||||
testLogger(t)
|
testLogger(t)
|
||||||
path := filepath.Join(t.TempDir(), "config.toml")
|
path := filepath.Join(t.TempDir(), "config.toml")
|
||||||
@@ -124,8 +144,8 @@ func TestCheckBuildsWithoutStarting(t *testing.T) {
|
|||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
path, out := filepath.Join(dir, "empty.toml"), filepath.Join(dir, "out")
|
path, out := filepath.Join(dir, "empty.toml"), filepath.Join(dir, "out")
|
||||||
testutil.WriteFile(t, path, "")
|
testutil.WriteFile(t, path, "")
|
||||||
if code := checkConfig(loadTestConfig(t, path, "--source", "null", "--sink", "http,host=127.0.0.1,port=15862",
|
if code := checkConfig(loadTestConfig(t, path, "--source", "null", "--sink", "http:host=127.0.0.1,port=15862",
|
||||||
"--sink", "file,name=check,directory="+out)); code != 0 {
|
"--sink", "file:name=check,directory="+out)); code != 0 {
|
||||||
t.Fatalf("valid configuration: exit %d", code)
|
t.Fatalf("valid configuration: exit %d", code)
|
||||||
}
|
}
|
||||||
ln, err := net.Listen("tcp4", "127.0.0.1:15862")
|
ln, err := net.Listen("tcp4", "127.0.0.1:15862")
|
||||||
@@ -138,7 +158,7 @@ func TestCheckBuildsWithoutStarting(t *testing.T) {
|
|||||||
}
|
}
|
||||||
missing := filepath.Join(dir, "missing")
|
missing := filepath.Join(dir, "missing")
|
||||||
if code := checkConfig(loadTestConfig(t, path, "--source", "null", "--sink",
|
if code := checkConfig(loadTestConfig(t, path, "--source", "null", "--sink",
|
||||||
"http,port=15862,tls.enabled=true,tls.cert_file="+missing+",tls.key_file="+missing)); code != 1 {
|
"http:port=15862,tls.enabled=true,tls.cert_file="+missing+",tls.key_file="+missing)); code != 1 {
|
||||||
t.Fatalf("listener TLS without its certificate file: exit %d, want 1", code)
|
t.Fatalf("listener TLS without its certificate file: exit %d, want 1", code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+9
-6
@@ -64,10 +64,10 @@ name = "default"
|
|||||||
###============================================================================
|
###============================================================================
|
||||||
|
|
||||||
## policy="pass" short-circuits the size check too: enforcing a size cap needs
|
## policy="pass" short-circuits the size check too: enforcing a size cap needs
|
||||||
## rate > 0 AND policy = "drop".
|
## entries_per_second > 0 AND policy = "drop".
|
||||||
# [pipelines.flow.rate_limit]
|
# [pipelines.flow.rate_limit]
|
||||||
# rate = 0.0 # Entries/second (0 = limiter disabled)
|
# entries_per_second = 0.0 # 0 = limiter disabled
|
||||||
# burst = 0.0 # Burst capacity (defaults to rate)
|
# burst_entries = 0.0 # Bucket capacity (0 = entries_per_second)
|
||||||
# policy = "pass" # pass|drop
|
# policy = "pass" # pass|drop
|
||||||
# max_entry_size_bytes = 0 # 0 = unlimited
|
# max_entry_size_bytes = 0 # 0 = unlimited
|
||||||
|
|
||||||
@@ -131,7 +131,8 @@ name = "default"
|
|||||||
### On the http sink the block gates stream_path AND status_path.
|
### On the http sink the block gates stream_path AND status_path.
|
||||||
### Listeners (tcp/http sinks, tcp_chain/http_chain sources):
|
### Listeners (tcp/http sinks, tcp_chain/http_chain sources):
|
||||||
### mtls authorizes the client certificate; REQUIRES tls.client_auth.
|
### mtls authorizes the client certificate; REQUIRES tls.client_auth.
|
||||||
### scram admits the users in credentials_file (`lw auth add-user`).
|
### scram admits the users in credentials_file (`lw auth add-user`), or
|
||||||
|
### the one username whose password is in password_file.
|
||||||
### Dialers (tcp_chain/http_chain sinks):
|
### Dialers (tcp_chain/http_chain sinks):
|
||||||
### mtls pins the server identity.
|
### mtls pins the server identity.
|
||||||
### scram logs in as username with the password in password_file.
|
### scram logs in as username with the password in password_file.
|
||||||
@@ -153,8 +154,10 @@ name = "default"
|
|||||||
### Overrides trust_node. See doc/security.md.
|
### Overrides trust_node. See doc/security.md.
|
||||||
### credentials_file = "" scram listeners: verifiers; read at (re)load, not watched
|
### credentials_file = "" scram listeners: verifiers; read at (re)load, not watched
|
||||||
### token_lifetime_ms = 0 scram, http sink and http_chain source: 10 s to 24 h, 0 = 15 minutes
|
### token_lifetime_ms = 0 scram, http sink and http_chain source: 10 s to 24 h, 0 = 15 minutes
|
||||||
### username = "" scram dialers
|
### username = "" scram dialers, or a listener's one user
|
||||||
### password_file = "" scram dialers: one trailing line break is trimmed
|
### password_file = "" scram, with username: one trailing line break is
|
||||||
|
### trimmed; /dev/fd/N or a pipe is read once and kept
|
||||||
|
### for reloads, /dev/tty asks once
|
||||||
### trusted_proxies = [] scram http sink: TLS-terminating reverse proxies
|
### trusted_proxies = [] scram http sink: TLS-terminating reverse proxies
|
||||||
### (addresses or CIDRs); browsers log in through them
|
### (addresses or CIDRs); browsers log in through them
|
||||||
###============================================================================
|
###============================================================================
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ auth:*)
|
|||||||
'--ca-file=[CA file that verifies the server (default: system roots)]:value:_default' \
|
'--ca-file=[CA file that verifies the server (default: system roots)]:value:_default' \
|
||||||
'--cert-file=[client certificate file, for listeners with tls.client_auth]:value:_default' \
|
'--cert-file=[client certificate file, for listeners with tls.client_auth]:value:_default' \
|
||||||
'--key-file=[client key file]:value:_default' \
|
'--key-file=[client key file]:value:_default' \
|
||||||
'--password-file=[file holding the password]:value:_default' \
|
'--password-file=[file holding the password; a terminal asks]:value:_default' \
|
||||||
'--pin-sha256=[sha256//BASE64 pin of the server key, in place of --ca-file (a self_signed listener logs it)]:value:_default' \
|
'--pin-sha256=[sha256//BASE64 pin of the server key, in place of --ca-file (a self_signed listener logs it)]:value:_default' \
|
||||||
'--server-name=[name the server certificate must carry (default: the host)]:value:_default' \
|
'--server-name=[name the server certificate must carry (default: the host)]:value:_default' \
|
||||||
'--unbound[log in without channel binding, to an http sink behind a TLS-terminating proxy (auth.trusted_proxies)]' \
|
'--unbound[log in without channel binding, to an http sink behind a TLS-terminating proxy (auth.trusted_proxies)]' \
|
||||||
@@ -43,7 +43,7 @@ auth:*)
|
|||||||
'--ca-file=[CA file that verifies the server (default: system roots)]:value:_default' \
|
'--ca-file=[CA file that verifies the server (default: system roots)]:value:_default' \
|
||||||
'--cert-file=[client certificate file, for listeners with tls.client_auth]:value:_default' \
|
'--cert-file=[client certificate file, for listeners with tls.client_auth]:value:_default' \
|
||||||
'--key-file=[client key file]:value:_default' \
|
'--key-file=[client key file]:value:_default' \
|
||||||
'--password-file=[file holding the password]:value:_default' \
|
'--password-file=[file holding the password; a terminal asks]:value:_default' \
|
||||||
'--pin-sha256=[sha256//BASE64 pin of the server key, in place of --ca-file (a self_signed listener logs it)]:value:_default' \
|
'--pin-sha256=[sha256//BASE64 pin of the server key, in place of --ca-file (a self_signed listener logs it)]:value:_default' \
|
||||||
'--server-name=[name the server certificate must carry (default: the host)]:value:_default' \
|
'--server-name=[name the server certificate must carry (default: the host)]:value:_default' \
|
||||||
'(-u --user)'{-u=,--user=}'[user name]:value:_default' \
|
'(-u --user)'{-u=,--user=}'[user name]:value:_default' \
|
||||||
@@ -112,11 +112,13 @@ preset:*)
|
|||||||
'--issuer_key=[CA key file, for tls=issuer]:value:_default' \
|
'--issuer_key=[CA key file, for tls=issuer]:value:_default' \
|
||||||
'--key=[key file, for tls=files]:value:_default' \
|
'--key=[key file, for tls=files]:value:_default' \
|
||||||
'--listen=[HOST:PORT; [IPV6\]:PORT]:value:_default' \
|
'--listen=[HOST:PORT; [IPV6\]:PORT]:value:_default' \
|
||||||
|
'--password_file=[file holding user'\''s password; /dev/fd/N or a pipe is read once; default: /dev/tty, which asks]:value:_default' \
|
||||||
'--path=[file, directory (its files) or glob to follow; default: standard input]:value:_default' \
|
'--path=[file, directory (its files) or glob to follow; default: standard input]:value:_default' \
|
||||||
'--proxy=[addresses or CIDRs of the TLS-terminating proxies browsers come through; '\'','\'' between them]:value:_default' \
|
'--proxy=[addresses or CIDRs of the TLS-terminating proxies browsers come through; '\'','\'' between them]:value:_default' \
|
||||||
'--tls=[off|self|issuer|files: a certificate made at startup (self-signed or from issuer_*), or files]:value:_default' \
|
'--tls=[off|self|issuer|files: a certificate made at startup (self-signed or from issuer_*), or files]:value:_default' \
|
||||||
|
'(-u --user)'{-u=,--user=}'[SCRAM user: an edge'\''s login, or a listener'\''s only user in place of users]:value:_default' \
|
||||||
'--users=[credentials file (lw auth add-user): readers log in with SCRAM]:value:_default' \
|
'--users=[credentials file (lw auth add-user): readers log in with SCRAM]:value:_default' \
|
||||||
'--viewer=[true: the login page and viewer for users, behind proxy; without users the viewer is always on]:value:_default' \
|
'--viewer=[true: the login page and viewer, behind proxy; without user or users the viewer is always on]:value:_default' \
|
||||||
'(-h --help)'{-h,--help}'[print the flags]' ;;
|
'(-h --help)'{-h,--help}'[print the flags]' ;;
|
||||||
edge) _arguments -S \
|
edge) _arguments -S \
|
||||||
'--ca=[CA file that verifies the aggregator; default: system roots]:value:_default' \
|
'--ca=[CA file that verifies the aggregator; default: system roots]:value:_default' \
|
||||||
@@ -124,13 +126,13 @@ preset:*)
|
|||||||
'--from=[start|end: where reading a file starts]:value:_default' \
|
'--from=[start|end: where reading a file starts]:value:_default' \
|
||||||
'--key=[client key file]:value:_default' \
|
'--key=[client key file]:value:_default' \
|
||||||
'--node=[origin label; default: this host'\''s name]:value:_default' \
|
'--node=[origin label; default: this host'\''s name]:value:_default' \
|
||||||
'--password_file=[file holding the SCRAM password]:value:_default' \
|
'--password_file=[file holding user'\''s password; /dev/fd/N or a pipe is read once; default: /dev/tty, which asks]:value:_default' \
|
||||||
'--path=[file, directory (its files) or glob to follow; default: standard input]:value:_default' \
|
'--path=[file, directory (its files) or glob to follow; default: standard input]:value:_default' \
|
||||||
'--pin=[sha256//BASE64 of the aggregator'\''s key, which it logs with tls=self]:value:_default' \
|
'--pin=[sha256//BASE64 of the aggregator'\''s key, which it logs with tls=self]:value:_default' \
|
||||||
'--server_name=[name the aggregator'\''s certificate carries; default: to'\''s host]:value:_default' \
|
'--server_name=[name the aggregator'\''s certificate carries; default: to'\''s host]:value:_default' \
|
||||||
'--to=[the aggregator'\''s HOST:PORT; [IPV6\]:PORT]:value:_default' \
|
'--to=[the aggregator'\''s HOST:PORT; [IPV6\]:PORT]:value:_default' \
|
||||||
'--transport=[tcp|http: tcp_chain or http_chain]:value:_default' \
|
'--transport=[tcp|http: tcp_chain or http_chain]:value:_default' \
|
||||||
'(-u --user)'{-u=,--user=}'[SCRAM user (lw auth add-user on the aggregator)]:value:_default' \
|
'(-u --user)'{-u=,--user=}'[SCRAM user: an edge'\''s login, or a listener'\''s only user in place of users]:value:_default' \
|
||||||
'(-h --help)'{-h,--help}'[print the flags]' ;;
|
'(-h --help)'{-h,--help}'[print the flags]' ;;
|
||||||
aggregator) _arguments -S \
|
aggregator) _arguments -S \
|
||||||
'--allow=[addresses or CIDRs that may connect, of listen'\''s family (either behind proxy=); default: all; '\'','\'' between them]:value:_default' \
|
'--allow=[addresses or CIDRs that may connect, of listen'\''s family (either behind proxy=); default: all; '\'','\'' between them]:value:_default' \
|
||||||
@@ -144,8 +146,10 @@ preset:*)
|
|||||||
'--key=[key file, for tls=files]:value:_default' \
|
'--key=[key file, for tls=files]:value:_default' \
|
||||||
'--listen=[HOST:PORT; [IPV6\]:PORT; [::\] for IPv6 only]:value:_default' \
|
'--listen=[HOST:PORT; [IPV6\]:PORT; [::\] for IPv6 only]:value:_default' \
|
||||||
'--out=[directory for the received entries; default: standard output]:value:_default' \
|
'--out=[directory for the received entries; default: standard output]:value:_default' \
|
||||||
|
'--password_file=[file holding user'\''s password; /dev/fd/N or a pipe is read once; default: /dev/tty, which asks]:value:_default' \
|
||||||
'--tls=[self|issuer|files: a certificate made at startup (self-signed or from issuer_*), or files]:value:_default' \
|
'--tls=[self|issuer|files: a certificate made at startup (self-signed or from issuer_*), or files]:value:_default' \
|
||||||
'--transport=[tcp|http: tcp_chain or http_chain]:value:_default' \
|
'--transport=[tcp|http: tcp_chain or http_chain]:value:_default' \
|
||||||
|
'(-u --user)'{-u=,--user=}'[SCRAM user: an edge'\''s login, or a listener'\''s only user in place of users]:value:_default' \
|
||||||
'--users=[credentials file (lw auth add-user): edges log in with SCRAM]:value:_default' \
|
'--users=[credentials file (lw auth add-user): edges log in with SCRAM]:value:_default' \
|
||||||
'(-h --help)'{-h,--help}'[print the flags]' ;;
|
'(-h --help)'{-h,--help}'[print the flags]' ;;
|
||||||
esac
|
esac
|
||||||
|
|||||||
@@ -66,9 +66,9 @@ _lw() {
|
|||||||
words='--format --from --path --help -h' ;;
|
words='--format --from --path --help -h' ;;
|
||||||
serve)
|
serve)
|
||||||
case $prev in
|
case $prev in
|
||||||
--allow|-allow|--cert|-cert|--deny|-deny|--format|-format|--from|-from|--hosts|-hosts|--issuer_cert|-issuer_cert|--issuer_key|-issuer_key|--key|-key|--listen|-listen|--path|-path|--proxy|-proxy|--tls|-tls|--users|-users|--viewer|-viewer) COMPREPLY=(); return ;;
|
--allow|-allow|--cert|-cert|--deny|-deny|--format|-format|--from|-from|--hosts|-hosts|--issuer_cert|-issuer_cert|--issuer_key|-issuer_key|--key|-key|--listen|-listen|--password_file|-password_file|--path|-path|--proxy|-proxy|--tls|-tls|--user|-user|-u|--users|-users|--viewer|-viewer) COMPREPLY=(); return ;;
|
||||||
esac
|
esac
|
||||||
words='--allow --cert --deny --format --from --hosts --issuer_cert --issuer_key --key --listen --path --proxy --tls --users --viewer --help -h' ;;
|
words='--allow --cert --deny --format --from --hosts --issuer_cert --issuer_key --key --listen --password_file --path --proxy --tls --user -u --users --viewer --help -h' ;;
|
||||||
edge)
|
edge)
|
||||||
case $prev in
|
case $prev in
|
||||||
--ca|-ca|--cert|-cert|--from|-from|--key|-key|--node|-node|--password_file|-password_file|--path|-path|--pin|-pin|--server_name|-server_name|--to|-to|--transport|-transport|--user|-user|-u) COMPREPLY=(); return ;;
|
--ca|-ca|--cert|-cert|--from|-from|--key|-key|--node|-node|--password_file|-password_file|--path|-path|--pin|-pin|--server_name|-server_name|--to|-to|--transport|-transport|--user|-user|-u) COMPREPLY=(); return ;;
|
||||||
@@ -76,9 +76,9 @@ _lw() {
|
|||||||
words='--ca --cert --from --key --node --password_file --path --pin --server_name --to --transport --user -u --help -h' ;;
|
words='--ca --cert --from --key --node --password_file --path --pin --server_name --to --transport --user -u --help -h' ;;
|
||||||
aggregator)
|
aggregator)
|
||||||
case $prev in
|
case $prev in
|
||||||
--allow|-allow|--cert|-cert|--client_ca|-client_ca|--deny|-deny|--format|-format|--hosts|-hosts|--issuer_cert|-issuer_cert|--issuer_key|-issuer_key|--key|-key|--listen|-listen|--out|-out|--tls|-tls|--transport|-transport|--users|-users) COMPREPLY=(); return ;;
|
--allow|-allow|--cert|-cert|--client_ca|-client_ca|--deny|-deny|--format|-format|--hosts|-hosts|--issuer_cert|-issuer_cert|--issuer_key|-issuer_key|--key|-key|--listen|-listen|--out|-out|--password_file|-password_file|--tls|-tls|--transport|-transport|--user|-user|-u|--users|-users) COMPREPLY=(); return ;;
|
||||||
esac
|
esac
|
||||||
words='--allow --cert --client_ca --deny --format --hosts --issuer_cert --issuer_key --key --listen --out --tls --transport --users --help -h' ;;
|
words='--allow --cert --client_ca --deny --format --hosts --issuer_cert --issuer_key --key --listen --out --password_file --tls --transport --user -u --users --help -h' ;;
|
||||||
esac ;;
|
esac ;;
|
||||||
config:2) words='init' ;;
|
config:2) words='init' ;;
|
||||||
config:*)
|
config:*)
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ complete -c lw -n '__lw_in auth remove-user' -l help -o h -d 'print the flags'
|
|||||||
complete -c lw -n '__lw_in auth token' -l ca-file -r -F -d 'CA file that verifies the server (default: system roots)'
|
complete -c lw -n '__lw_in auth token' -l ca-file -r -F -d 'CA file that verifies the server (default: system roots)'
|
||||||
complete -c lw -n '__lw_in auth token' -l cert-file -r -F -d 'client certificate file, for listeners with tls.client_auth'
|
complete -c lw -n '__lw_in auth token' -l cert-file -r -F -d 'client certificate file, for listeners with tls.client_auth'
|
||||||
complete -c lw -n '__lw_in auth token' -l key-file -r -F -d 'client key file'
|
complete -c lw -n '__lw_in auth token' -l key-file -r -F -d 'client key file'
|
||||||
complete -c lw -n '__lw_in auth token' -l password-file -r -F -d 'file holding the password'
|
complete -c lw -n '__lw_in auth token' -l password-file -r -F -d 'file holding the password; a terminal asks'
|
||||||
complete -c lw -n '__lw_in auth token' -l pin-sha256 -r -F -d 'sha256//BASE64 pin of the server key, in place of --ca-file (a self_signed listener logs it)'
|
complete -c lw -n '__lw_in auth token' -l pin-sha256 -r -F -d 'sha256//BASE64 pin of the server key, in place of --ca-file (a self_signed listener logs it)'
|
||||||
complete -c lw -n '__lw_in auth token' -l server-name -r -F -d 'name the server certificate must carry (default: the host)'
|
complete -c lw -n '__lw_in auth token' -l server-name -r -F -d 'name the server certificate must carry (default: the host)'
|
||||||
complete -c lw -n '__lw_in auth token' -l unbound -d 'log in without channel binding, to an http sink behind a TLS-terminating proxy (auth.trusted_proxies)'
|
complete -c lw -n '__lw_in auth token' -l unbound -d 'log in without channel binding, to an http sink behind a TLS-terminating proxy (auth.trusted_proxies)'
|
||||||
@@ -70,7 +70,7 @@ complete -c lw -n '__lw_in auth stream' -l addr -r -F -d 'tcp sink address, HOST
|
|||||||
complete -c lw -n '__lw_in auth stream' -l ca-file -r -F -d 'CA file that verifies the server (default: system roots)'
|
complete -c lw -n '__lw_in auth stream' -l ca-file -r -F -d 'CA file that verifies the server (default: system roots)'
|
||||||
complete -c lw -n '__lw_in auth stream' -l cert-file -r -F -d 'client certificate file, for listeners with tls.client_auth'
|
complete -c lw -n '__lw_in auth stream' -l cert-file -r -F -d 'client certificate file, for listeners with tls.client_auth'
|
||||||
complete -c lw -n '__lw_in auth stream' -l key-file -r -F -d 'client key file'
|
complete -c lw -n '__lw_in auth stream' -l key-file -r -F -d 'client key file'
|
||||||
complete -c lw -n '__lw_in auth stream' -l password-file -r -F -d 'file holding the password'
|
complete -c lw -n '__lw_in auth stream' -l password-file -r -F -d 'file holding the password; a terminal asks'
|
||||||
complete -c lw -n '__lw_in auth stream' -l pin-sha256 -r -F -d 'sha256//BASE64 pin of the server key, in place of --ca-file (a self_signed listener logs it)'
|
complete -c lw -n '__lw_in auth stream' -l pin-sha256 -r -F -d 'sha256//BASE64 pin of the server key, in place of --ca-file (a self_signed listener logs it)'
|
||||||
complete -c lw -n '__lw_in auth stream' -l server-name -r -F -d 'name the server certificate must carry (default: the host)'
|
complete -c lw -n '__lw_in auth stream' -l server-name -r -F -d 'name the server certificate must carry (default: the host)'
|
||||||
complete -c lw -n '__lw_in auth stream' -l user -o u -r -F -d 'user name'
|
complete -c lw -n '__lw_in auth stream' -l user -o u -r -F -d 'user name'
|
||||||
@@ -112,24 +112,26 @@ complete -c lw -n '__lw_in preset serve' -l issuer_cert -r -F -d 'CA certificate
|
|||||||
complete -c lw -n '__lw_in preset serve' -l issuer_key -r -F -d 'CA key file, for tls=issuer'
|
complete -c lw -n '__lw_in preset serve' -l issuer_key -r -F -d 'CA key file, for tls=issuer'
|
||||||
complete -c lw -n '__lw_in preset serve' -l key -r -F -d 'key file, for tls=files'
|
complete -c lw -n '__lw_in preset serve' -l key -r -F -d 'key file, for tls=files'
|
||||||
complete -c lw -n '__lw_in preset serve' -l listen -r -F -d 'HOST:PORT; [IPV6]:PORT'
|
complete -c lw -n '__lw_in preset serve' -l listen -r -F -d 'HOST:PORT; [IPV6]:PORT'
|
||||||
|
complete -c lw -n '__lw_in preset serve' -l password_file -r -F -d 'file holding user\'s password; /dev/fd/N or a pipe is read once; default: /dev/tty, which asks'
|
||||||
complete -c lw -n '__lw_in preset serve' -l path -r -F -d 'file, directory (its files) or glob to follow; default: standard input'
|
complete -c lw -n '__lw_in preset serve' -l path -r -F -d 'file, directory (its files) or glob to follow; default: standard input'
|
||||||
complete -c lw -n '__lw_in preset serve' -l proxy -r -F -d 'addresses or CIDRs of the TLS-terminating proxies browsers come through; \',\' between them'
|
complete -c lw -n '__lw_in preset serve' -l proxy -r -F -d 'addresses or CIDRs of the TLS-terminating proxies browsers come through; \',\' between them'
|
||||||
complete -c lw -n '__lw_in preset serve' -l tls -r -F -d 'off|self|issuer|files: a certificate made at startup (self-signed or from issuer_*), or files'
|
complete -c lw -n '__lw_in preset serve' -l tls -r -F -d 'off|self|issuer|files: a certificate made at startup (self-signed or from issuer_*), or files'
|
||||||
|
complete -c lw -n '__lw_in preset serve' -l user -o u -r -F -d 'SCRAM user: an edge\'s login, or a listener\'s only user in place of users'
|
||||||
complete -c lw -n '__lw_in preset serve' -l users -r -F -d 'credentials file (lw auth add-user): readers log in with SCRAM'
|
complete -c lw -n '__lw_in preset serve' -l users -r -F -d 'credentials file (lw auth add-user): readers log in with SCRAM'
|
||||||
complete -c lw -n '__lw_in preset serve' -l viewer -r -F -d 'true: the login page and viewer for users, behind proxy; without users the viewer is always on'
|
complete -c lw -n '__lw_in preset serve' -l viewer -r -F -d 'true: the login page and viewer, behind proxy; without user or users the viewer is always on'
|
||||||
complete -c lw -n '__lw_in preset serve' -l help -o h -d 'print the flags'
|
complete -c lw -n '__lw_in preset serve' -l help -o h -d 'print the flags'
|
||||||
complete -c lw -n '__lw_in preset edge' -l ca -r -F -d 'CA file that verifies the aggregator; default: system roots'
|
complete -c lw -n '__lw_in preset edge' -l ca -r -F -d 'CA file that verifies the aggregator; default: system roots'
|
||||||
complete -c lw -n '__lw_in preset edge' -l cert -r -F -d 'client certificate file, for an aggregator with client_ca'
|
complete -c lw -n '__lw_in preset edge' -l cert -r -F -d 'client certificate file, for an aggregator with client_ca'
|
||||||
complete -c lw -n '__lw_in preset edge' -l from -r -F -d 'start|end: where reading a file starts'
|
complete -c lw -n '__lw_in preset edge' -l from -r -F -d 'start|end: where reading a file starts'
|
||||||
complete -c lw -n '__lw_in preset edge' -l key -r -F -d 'client key file'
|
complete -c lw -n '__lw_in preset edge' -l key -r -F -d 'client key file'
|
||||||
complete -c lw -n '__lw_in preset edge' -l node -r -F -d 'origin label; default: this host\'s name'
|
complete -c lw -n '__lw_in preset edge' -l node -r -F -d 'origin label; default: this host\'s name'
|
||||||
complete -c lw -n '__lw_in preset edge' -l password_file -r -F -d 'file holding the SCRAM password'
|
complete -c lw -n '__lw_in preset edge' -l password_file -r -F -d 'file holding user\'s password; /dev/fd/N or a pipe is read once; default: /dev/tty, which asks'
|
||||||
complete -c lw -n '__lw_in preset edge' -l path -r -F -d 'file, directory (its files) or glob to follow; default: standard input'
|
complete -c lw -n '__lw_in preset edge' -l path -r -F -d 'file, directory (its files) or glob to follow; default: standard input'
|
||||||
complete -c lw -n '__lw_in preset edge' -l pin -r -F -d 'sha256//BASE64 of the aggregator\'s key, which it logs with tls=self'
|
complete -c lw -n '__lw_in preset edge' -l pin -r -F -d 'sha256//BASE64 of the aggregator\'s key, which it logs with tls=self'
|
||||||
complete -c lw -n '__lw_in preset edge' -l server_name -r -F -d 'name the aggregator\'s certificate carries; default: to\'s host'
|
complete -c lw -n '__lw_in preset edge' -l server_name -r -F -d 'name the aggregator\'s certificate carries; default: to\'s host'
|
||||||
complete -c lw -n '__lw_in preset edge' -l to -r -F -d 'the aggregator\'s HOST:PORT; [IPV6]:PORT'
|
complete -c lw -n '__lw_in preset edge' -l to -r -F -d 'the aggregator\'s HOST:PORT; [IPV6]:PORT'
|
||||||
complete -c lw -n '__lw_in preset edge' -l transport -r -F -d 'tcp|http: tcp_chain or http_chain'
|
complete -c lw -n '__lw_in preset edge' -l transport -r -F -d 'tcp|http: tcp_chain or http_chain'
|
||||||
complete -c lw -n '__lw_in preset edge' -l user -o u -r -F -d 'SCRAM user (lw auth add-user on the aggregator)'
|
complete -c lw -n '__lw_in preset edge' -l user -o u -r -F -d 'SCRAM user: an edge\'s login, or a listener\'s only user in place of users'
|
||||||
complete -c lw -n '__lw_in preset edge' -l help -o h -d 'print the flags'
|
complete -c lw -n '__lw_in preset edge' -l help -o h -d 'print the flags'
|
||||||
complete -c lw -n '__lw_in preset aggregator' -l allow -r -F -d 'addresses or CIDRs that may connect, of listen\'s family (either behind proxy=); default: all; \',\' between them'
|
complete -c lw -n '__lw_in preset aggregator' -l allow -r -F -d 'addresses or CIDRs that may connect, of listen\'s family (either behind proxy=); default: all; \',\' between them'
|
||||||
complete -c lw -n '__lw_in preset aggregator' -l cert -r -F -d 'certificate file, for tls=files'
|
complete -c lw -n '__lw_in preset aggregator' -l cert -r -F -d 'certificate file, for tls=files'
|
||||||
@@ -142,8 +144,10 @@ complete -c lw -n '__lw_in preset aggregator' -l issuer_key -r -F -d 'CA key fil
|
|||||||
complete -c lw -n '__lw_in preset aggregator' -l key -r -F -d 'key file, for tls=files'
|
complete -c lw -n '__lw_in preset aggregator' -l key -r -F -d 'key file, for tls=files'
|
||||||
complete -c lw -n '__lw_in preset aggregator' -l listen -r -F -d 'HOST:PORT; [IPV6]:PORT; [::] for IPv6 only'
|
complete -c lw -n '__lw_in preset aggregator' -l listen -r -F -d 'HOST:PORT; [IPV6]:PORT; [::] for IPv6 only'
|
||||||
complete -c lw -n '__lw_in preset aggregator' -l out -r -F -d 'directory for the received entries; default: standard output'
|
complete -c lw -n '__lw_in preset aggregator' -l out -r -F -d 'directory for the received entries; default: standard output'
|
||||||
|
complete -c lw -n '__lw_in preset aggregator' -l password_file -r -F -d 'file holding user\'s password; /dev/fd/N or a pipe is read once; default: /dev/tty, which asks'
|
||||||
complete -c lw -n '__lw_in preset aggregator' -l tls -r -F -d 'self|issuer|files: a certificate made at startup (self-signed or from issuer_*), or files'
|
complete -c lw -n '__lw_in preset aggregator' -l tls -r -F -d 'self|issuer|files: a certificate made at startup (self-signed or from issuer_*), or files'
|
||||||
complete -c lw -n '__lw_in preset aggregator' -l transport -r -F -d 'tcp|http: tcp_chain or http_chain'
|
complete -c lw -n '__lw_in preset aggregator' -l transport -r -F -d 'tcp|http: tcp_chain or http_chain'
|
||||||
|
complete -c lw -n '__lw_in preset aggregator' -l user -o u -r -F -d 'SCRAM user: an edge\'s login, or a listener\'s only user in place of users'
|
||||||
complete -c lw -n '__lw_in preset aggregator' -l users -r -F -d 'credentials file (lw auth add-user): edges log in with SCRAM'
|
complete -c lw -n '__lw_in preset aggregator' -l users -r -F -d 'credentials file (lw auth add-user): edges log in with SCRAM'
|
||||||
complete -c lw -n '__lw_in preset aggregator' -l help -o h -d 'print the flags'
|
complete -c lw -n '__lw_in preset aggregator' -l help -o h -d 'print the flags'
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -90,7 +90,7 @@ stdout:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
lw < app.log > copy.log
|
lw < app.log > copy.log
|
||||||
tail -F app.log | lw --filter include,patterns=ERROR,patterns=WARN
|
tail -F app.log | lw --filter include:patterns=ERROR,patterns=WARN
|
||||||
```
|
```
|
||||||
|
|
||||||
As a service, a file holds the pipelines:
|
As a service, a file holds the pipelines:
|
||||||
|
|||||||
+3
-2
@@ -255,8 +255,9 @@ is what their certificates say, regardless of the `node` each edge configured.
|
|||||||
`test/mtls-chain-test.sh` builds exactly this shape against a throwaway PKI.
|
`test/mtls-chain-test.sh` builds exactly this shape against a throwaway PKI.
|
||||||
|
|
||||||
To use passwords instead of client certificates, replace both `auth` blocks:
|
To use passwords instead of client certificates, replace both `auth` blocks:
|
||||||
`type = "scram"` with `username` and `password_file` on the edge, and with a
|
`type = "scram"` with `username` and `password_file` on the edge, and on the
|
||||||
`credentials_file` on the relay. `client_auth` and the edge's certificate become
|
relay a `credentials_file`, or one `username` with its `password_file`.
|
||||||
|
`client_auth` and the edge's certificate become
|
||||||
optional, and entries are labelled with the username. See
|
optional, and entries are labelled with the username. See
|
||||||
[Password Authentication](security.md#password-authentication-scram).
|
[Password Authentication](security.md#password-authentication-scram).
|
||||||
|
|
||||||
|
|||||||
+91
-66
@@ -3,7 +3,7 @@
|
|||||||
```
|
```
|
||||||
lw [options] [< input]
|
lw [options] [< input]
|
||||||
lw -c|--config FILE [options]
|
lw -c|--config FILE [options]
|
||||||
lw -p|--preset NAME[,KEY=VALUE...] [options]
|
lw -p|--preset NAME[:KEY=VALUE,...] [options]
|
||||||
lw -t|--check [options]
|
lw -t|--check [options]
|
||||||
lw --dump [options]
|
lw --dump [options]
|
||||||
lw -T|--tui [options]
|
lw -T|--tui [options]
|
||||||
@@ -45,7 +45,7 @@ first argument.
|
|||||||
tail`, `lw -t FILE`) are usage errors, named on stderr.
|
tail`, `lw -t FILE`) are usage errors, named on stderr.
|
||||||
- lw's multiword options join words with `-` (`--rate-limit`,
|
- lw's multiword options join words with `-` (`--rate-limit`,
|
||||||
`--password-file`); configuration keys, spec keys and preset keys keep
|
`--password-file`); configuration keys, spec keys and preset keys keep
|
||||||
TOML's `_` and `.` (`--status_reporter`, `--sink http,tls.cert_file=F`,
|
TOML's `_` and `.` (`--status_reporter`, `--sink http:tls.cert_file=F`,
|
||||||
`lw preset edge --password_file F`).
|
`lw preset edge --password_file F`).
|
||||||
- The commands also take a long option after one dash (`-user NAME`).
|
- The commands also take a long option after one dash (`-user NAME`).
|
||||||
|
|
||||||
@@ -121,14 +121,15 @@ pipelines and the built-in default; every other key keeps its precedence.
|
|||||||
- stdin has one reader, so the whole configuration holds at most one console
|
- stdin has one reader, so the whole configuration holds at most one console
|
||||||
source: `pipeline "b": a console source already reads stdin in pipeline "a"`
|
source: `pipeline "b": a console source already reads stdin in pipeline "a"`
|
||||||
|
|
||||||
A SPEC is a comma-separated list:
|
A SPEC is `TYPE:key=value,...`, or `key=value,...` without a TYPE:
|
||||||
|
|
||||||
- source, sink, filter and format specs start with a TYPE
|
- source, sink, filter and format specs start with a TYPE, which ends at the
|
||||||
|
first `:`; a TYPE alone (`--sink null`, `--format txt`) takes the defaults
|
||||||
- the plugin type: [Sources](sources.md), [Sinks](sinks.md)
|
- the plugin type: [Sources](sources.md), [Sinks](sinks.md)
|
||||||
- `include` or `exclude`: [Filters](filters.md)
|
- `include` or `exclude`: [Filters](filters.md)
|
||||||
- `json`, `txt` or `raw`: [Formatters](formatters.md)
|
- `json`, `txt` or `raw`: [Formatters](formatters.md)
|
||||||
- rate-limit and heartbeat specs have no TYPE
|
- rate-limit and heartbeat specs have no TYPE
|
||||||
- the rest are `key=value` pairs with the TOML keys of the plugin's `config`
|
- the pairs are comma-separated, with the TOML keys of the plugin's `config`
|
||||||
table or of the [flow stage](configuration.md#flow-stages)
|
table or of the [flow stage](configuration.md#flow-stages)
|
||||||
- a dotted key reaches a nested table: `tls.cert_file=...`, `auth.type=scram`
|
- a dotted key reaches a nested table: `tls.cert_file=...`, `auth.type=scram`
|
||||||
- a repeated key makes a list: `patterns=ERROR,patterns=WARN`
|
- a repeated key makes a list: `patterns=ERROR,patterns=WARN`
|
||||||
@@ -136,7 +137,8 @@ A SPEC is a comma-separated list:
|
|||||||
is one regex, `auth.allow=a,auth.allow=b` two entries
|
is one regex, `auth.allow=a,auth.allow=b` two entries
|
||||||
- values convert to the option's type: `port=8080`, `raw=true`
|
- values convert to the option's type: `port=8080`, `raw=true`
|
||||||
- `\` escapes `,`, `=` and `\` in a value; any other backslash stays, so regex
|
- `\` escapes `,`, `=` and `\` in a value; any other backslash stays, so regex
|
||||||
escapes such as `\d` pass unchanged
|
escapes such as `\d` pass unchanged; a `:` in a value needs no escape
|
||||||
|
(`host=::1`, `listen=127.0.0.1:8080`)
|
||||||
- `id=NAME` names a source or sink; the default is its TYPE, then `TYPE_2`,
|
- `id=NAME` names a source or sink; the default is its TYPE, then `TYPE_2`,
|
||||||
`TYPE_3`, ...
|
`TYPE_3`, ...
|
||||||
- naming a stage turns it on: `--rate-limit` defaults `policy` to `drop`,
|
- naming a stage turns it on: `--rate-limit` defaults `policy` to `drop`,
|
||||||
@@ -145,8 +147,9 @@ A SPEC is a comma-separated list:
|
|||||||
Errors name the flag and the offending part, and nothing starts:
|
Errors name the flag and the offending part, and nothing starts:
|
||||||
|
|
||||||
```
|
```
|
||||||
--sink http,port: missing "="
|
--sink http:port: missing "="
|
||||||
--rate-limit rate=100,polcy=drop: unknown key "polcy"
|
--rate-limit entries_per_second=100,polcy=drop: unknown key "polcy"
|
||||||
|
--source file,directory=/x: TYPE ends at ':', as in file:directory=/x
|
||||||
```
|
```
|
||||||
|
|
||||||
A misspelled plugin key fails when the plugin is built, before any listener
|
A misspelled plugin key fails when the plugin is built, before any listener
|
||||||
@@ -155,35 +158,39 @@ opens: `failed to create sink http: ... unknown key "tls.enabeld"`.
|
|||||||
```bash
|
```bash
|
||||||
# tail a directory, serve it over SSE on every interface; a browser at
|
# tail a directory, serve it over SSE on every interface; a browser at
|
||||||
# http://HOST:8080/ shows it
|
# http://HOST:8080/ shows it
|
||||||
lw --source 'file,directory=/var/log/app,pattern=*.log' \
|
lw --source 'file:directory=/var/log/app,pattern=*.log' \
|
||||||
--sink http,host=0.0.0.0,port=8080
|
--sink http:host=0.0.0.0,port=8080
|
||||||
|
|
||||||
# errors and warnings as JSON, over TLS with SCRAM logins
|
# errors and warnings as JSON, over TLS with SCRAM logins
|
||||||
lw --source file,directory=/var/log/app \
|
lw --source file:directory=/var/log/app \
|
||||||
--filter include,patterns=ERROR,patterns=WARN --format json \
|
--filter include:patterns=ERROR,patterns=WARN --format json \
|
||||||
--sink "http,port=8443,auth.type=scram,auth.credentials_file=/etc/logwisp/users.toml,\
|
--sink "http:port=8443,auth.type=scram,auth.credentials_file=/etc/logwisp/users.toml,\
|
||||||
tls.enabled=true,tls.cert_file=/etc/logwisp/server.crt,tls.key_file=/etc/logwisp/server.key"
|
tls.enabled=true,tls.cert_file=/etc/logwisp/server.crt,tls.key_file=/etc/logwisp/server.key"
|
||||||
|
|
||||||
# two pipelines: app writes stdout, relay a file
|
# two pipelines: app writes stdout, relay a file
|
||||||
lw --pipeline app --source file,directory=/var/log/app \
|
lw --pipeline app --source file:directory=/var/log/app \
|
||||||
--pipeline relay --source tcp_chain,port=9000 \
|
--pipeline relay --source tcp_chain:port=9000 \
|
||||||
--sink file,directory=/var/log/relay,name=relay
|
--sink file:directory=/var/log/relay,name=relay
|
||||||
```
|
```
|
||||||
|
|
||||||
Pipelines over stdin and stdout are under [Usage Patterns](#usage-patterns).
|
Pipelines over stdin and stdout are under [Usage Patterns](#usage-patterns).
|
||||||
|
|
||||||
## Presets
|
## Presets
|
||||||
|
|
||||||
`--preset NAME,key=value,...` takes the [SPEC](#pipelines) syntax. A list
|
`--preset NAME:key=value,...` takes the [SPEC](#pipelines) syntax. A list
|
||||||
key (`hosts`, `proxy`, `allow`, `deny`) takes several values, repeated
|
key (`hosts`, `proxy`, `allow`, `deny`) takes several values: repeated
|
||||||
(`hosts=a,hosts=b`) or `,`-separated; any other key takes one.
|
(`hosts=a,hosts=b`) or `\,`-separated in a SPEC, `,`-separated in `lw preset`
|
||||||
|
flags and the TUI; any other key takes one.
|
||||||
`lw preset NAME [--key value ...]` prints the pipeline a preset expands to,
|
`lw preset NAME [--key value ...]` prints the pipeline a preset expands to,
|
||||||
ready for a configuration file; its flags are the keys, spelled as in the SPEC
|
ready for a configuration file; its flags are the keys, spelled as in the SPEC
|
||||||
(`--password_file`), and `-u` is `--user`;
|
(`--password_file`), and `-u` is `--user`;
|
||||||
`lw preset NAME -h` lists its keys and defaults, `lw preset` the presets. An
|
`lw preset NAME -h` lists its keys and defaults, `lw preset` the presets. An
|
||||||
unknown key fails and lists the valid ones. A `path` is a file, a directory
|
unknown key fails and lists the valid ones. A `path` is a file, a directory
|
||||||
(its files) or a glob; a preset that takes one reads stdin without it, and
|
(its files) or a glob; a preset that takes one reads stdin without it, and
|
||||||
`from` (`end`, or `start`) is where reading a file starts.
|
`from` (`end`, or `start`) is where reading a file starts. `user` names one
|
||||||
|
SCRAM user, an edge's login or a listener's only user in place of `users`;
|
||||||
|
`password_file` holds its password and defaults to `/dev/tty`, which asks
|
||||||
|
([password sources](security.md#one-user-without-a-credentials-file)).
|
||||||
|
|
||||||
- `pipe`: stdin to stdout, the built-in default
|
- `pipe`: stdin to stdout, the built-in default
|
||||||
- `format` (`raw`)
|
- `format` (`raw`)
|
||||||
@@ -196,41 +203,49 @@ unknown key fails and lists the valid ones. A `path` is a file, a directory
|
|||||||
- `tls`: `off` (default), `self` (a self-signed certificate made at
|
- `tls`: `off` (default), `self` (a self-signed certificate made at
|
||||||
startup), `issuer` (one signed by `issuer_cert` and `issuer_key`), or
|
startup), `issuer` (one signed by `issuer_cert` and `issuer_key`), or
|
||||||
`files` (`cert` and `key`); `hosts` adds names to a made certificate
|
`files` (`cert` and `key`); `hosts` adds names to a made certificate
|
||||||
- without `users` the viewer needs no login, over plain http too
|
- without `user` or `users` the viewer needs no login, over plain http too
|
||||||
- `allow` and `deny`: addresses or CIDRs, the sink's
|
- `allow` and `deny`: addresses or CIDRs, the sink's
|
||||||
[acl](security.md#the-acl-block)
|
[acl](security.md#the-acl-block)
|
||||||
- `users`: a credentials file; readers then log in with SCRAM, and browsers
|
- `users` (a credentials file) or `user`: readers then log in with SCRAM,
|
||||||
need `proxy` and `viewer=true`: the login page and viewer behind the
|
which needs `tls` or `proxy`; browsers need `proxy` and `viewer=true`: the
|
||||||
TLS-terminating proxies `proxy` names, since a browser cannot bind its
|
login page and viewer behind the TLS-terminating proxies `proxy` names,
|
||||||
login to the TLS channel
|
since a browser cannot bind its login to the TLS channel
|
||||||
- `edge`: forward to an aggregator over TLS (`tcp_chain`, or `http_chain`
|
- `edge`: forward to an aggregator over TLS (`tcp_chain`, or `http_chain`
|
||||||
with `transport=http`)
|
with `transport=http`)
|
||||||
- `to` (required), `path`, `from`, `transport` (`tcp`), `node`
|
- `to` (required), `path`, `from`, `transport` (`tcp`), `node`
|
||||||
- verify the aggregator by `ca` (a CA file; default: system roots) or `pin`
|
- verify the aggregator by `ca` (a CA file; default: system roots) or `pin`
|
||||||
(its `tls.pin_sha256`), and `server_name`
|
(its `tls.pin_sha256`), and `server_name`
|
||||||
- authenticate by `user` and `password_file` (SCRAM), or a client `cert` and
|
- authenticate by `user` (SCRAM), or a client `cert` and `key` (mTLS):
|
||||||
`key` (mTLS): without either the preset refuses to run
|
without either the preset refuses to run
|
||||||
- `aggregator`: receive from edges over TLS, to stdout or files
|
- `aggregator`: receive from edges over TLS, to stdout or files
|
||||||
- `listen` (`0.0.0.0:9000`), `transport` (`tcp`), `format` (`json`)
|
- `listen` (`0.0.0.0:9000`), `transport` (`tcp`), `format` (`json`)
|
||||||
- `tls`: `self` (default), `issuer` or `files`, as for `serve`; never `off`
|
- `tls`: `self` (default), `issuer` or `files`, as for `serve`; never `off`
|
||||||
- authenticate by `users` (SCRAM) or `client_ca` (mTLS): one is required
|
- authenticate by `users` or `user` (SCRAM), or `client_ca` (mTLS): one is
|
||||||
|
required
|
||||||
- `allow` and `deny`, as for `serve`
|
- `allow` and `deny`, as for `serve`
|
||||||
- `out`: a directory for `aggregate*.log` files; default stdout
|
- `out`: a directory for `aggregate*.log` files; default stdout
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# follow a directory, level names in color on a terminal
|
# follow a directory, level names in color on a terminal
|
||||||
lw --preset tail,path=/var/log/app
|
lw --preset tail:path=/var/log/app
|
||||||
|
|
||||||
# serve it to a browser at http://127.0.0.1:8080/
|
# serve it to a browser at http://127.0.0.1:8080/
|
||||||
lw --preset serve,path=/var/log/app
|
lw --preset serve:path=/var/log/app
|
||||||
|
|
||||||
# serve it over HTTPS, self-signed, to SCRAM users; keep the result as a file
|
# serve it over HTTPS, self-signed, to SCRAM users; keep the result as a file
|
||||||
lw --preset serve,path=/var/log/app,tls=self,users=/etc/logwisp/users.toml --dump > serve.toml
|
lw --preset serve:path=/var/log/app,tls=self,users=/etc/logwisp/users.toml --dump > serve.toml
|
||||||
|
|
||||||
# an aggregator with a certificate made at startup, and an edge pinning it
|
# an aggregator with a certificate made at startup, and an edge pinning it
|
||||||
lw --preset aggregator,users=/etc/logwisp/users.toml,out=/var/log/edges
|
lw --preset aggregator:users=/etc/logwisp/users.toml,out=/var/log/edges
|
||||||
lw --preset edge,path=/var/log/app,to=agg.example.org:9000,pin=sha256//BASE64,\
|
lw --preset edge:path=/var/log/app,to=agg.example.org:9000,pin=sha256//BASE64,\
|
||||||
user=edge-01,password_file=/etc/logwisp/edge-01.pass
|
user=edge-01,password_file=/etc/logwisp/edge-01.pass
|
||||||
|
|
||||||
|
# a temporary pipe, nothing on disk: Enter at the receiver's prompt draws the
|
||||||
|
# password, typed at the sender's; or both read it from a descriptor
|
||||||
|
lw -p aggregator:listen=0.0.0.0:9000,user=pipe,format=raw,allow=SENDER/32
|
||||||
|
cmd | lw -p edge:to=RECEIVER:9000,pin=sha256//BASE64,user=pipe
|
||||||
|
cmd | lw -p edge:to=RECEIVER:9000,pin=sha256//BASE64,user=pipe,\
|
||||||
|
password_file=/dev/fd/3 3< <(pass show lw/pipe)
|
||||||
```
|
```
|
||||||
|
|
||||||
A self-signed aggregator logs its pin at startup (`pin_sha256`, a warning, so
|
A self-signed aggregator logs its pin at startup (`pin_sha256`, a warning, so
|
||||||
@@ -278,9 +293,9 @@ A container needs no configuration file:
|
|||||||
```bash
|
```bash
|
||||||
docker run --rm -p 8080:8080 -v /var/log/app:/logs:ro \
|
docker run --rm -p 8080:8080 -v /var/log/app:/logs:ro \
|
||||||
-e LOGWISP_PIPELINE=app \
|
-e LOGWISP_PIPELINE=app \
|
||||||
-e LOGWISP_SOURCE='file,directory=/logs,pattern=*.log' \
|
-e LOGWISP_SOURCE='file:directory=/logs,pattern=*.log' \
|
||||||
-e LOGWISP_FILTER='exclude,patterns=DEBUG' \
|
-e LOGWISP_FILTER='exclude:patterns=DEBUG' \
|
||||||
-e LOGWISP_SINK='http,host=0.0.0.0,port=8080' \
|
-e LOGWISP_SINK='http:host=0.0.0.0,port=8080' \
|
||||||
-e LOGWISP_SINK_1=console \
|
-e LOGWISP_SINK_1=console \
|
||||||
-e LOGWISP_LOGGING_LEVEL=info \
|
-e LOGWISP_LOGGING_LEVEL=info \
|
||||||
logwisp
|
logwisp
|
||||||
@@ -382,13 +397,13 @@ sink gives its clients or its downstream its `write_timeout_ms`
|
|||||||
lw < app.log > copy.log
|
lw < app.log > copy.log
|
||||||
|
|
||||||
# errors and warnings only
|
# errors and warnings only
|
||||||
tail -F app.log | lw --filter include,patterns=ERROR,patterns=WARN
|
tail -F app.log | lw --filter include:patterns=ERROR,patterns=WARN
|
||||||
|
|
||||||
# stdin as a live SSE stream; the http and tcp sinks bind 0.0.0.0 by default
|
# stdin as a live SSE stream; the http and tcp sinks bind 0.0.0.0 by default
|
||||||
journalctl -f | lw --sink http,host=127.0.0.1,port=8080
|
journalctl -f | lw --sink http:host=127.0.0.1,port=8080
|
||||||
|
|
||||||
# a directory's files to one file, through stdout
|
# a directory's files to one file, through stdout
|
||||||
lw --source file,directory=/var/log/app,pattern='*.log' > all.log
|
lw --source file:directory=/var/log/app,pattern='*.log' > all.log
|
||||||
```
|
```
|
||||||
|
|
||||||
A console sink never drops: when its reader is slow, the pipeline waits, and
|
A console sink never drops: when its reader is slow, the pipeline waits, and
|
||||||
@@ -411,7 +426,7 @@ prints `configuration ok: N pipeline(s)` and exits 0, or the error and exits 1
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
lw --check -c /etc/logwisp/logwisp.toml
|
lw --check -c /etc/logwisp/logwisp.toml
|
||||||
lw --check --source file,directory=/var/log/app --sink http,port=8080
|
lw --check --source file:directory=/var/log/app --sink http:port=8080
|
||||||
```
|
```
|
||||||
|
|
||||||
**Production**
|
**Production**
|
||||||
@@ -443,21 +458,30 @@ WebAssembly.
|
|||||||
pipeline flags, `LOGWISP_SOURCE`, a discovered file); with none but the
|
pipeline flags, `LOGWISP_SOURCE`, a discovered file); with none but the
|
||||||
built-in pipe, the preset menu (or `empty`). A pasted `lw` command line
|
built-in pipe, the preset menu (or `empty`). A pasted `lw` command line
|
||||||
replaces the pipelines, in the preset menu too.
|
replaces the pipelines, in the preset menu too.
|
||||||
- Screen: SOURCES, FLOW and SINKS side by side from 64 columns, stacked
|
- Screen: the pipeline bar on top; SOURCES, FLOW and SINKS side by side
|
||||||
below; each source and sink a tinted bar with its type, whether it listens
|
from 64 columns, stacked below; each source and sink a tinted bar with its
|
||||||
or dials, and a summary; the four flow stages in their order in a dashed
|
type, whether it listens or dials, and a summary; the four flow stages in
|
||||||
box, the ones off dimmed. The inspector, the chosen part's options, sits
|
their order in a dashed box, the ones off dimmed. The inspector, the
|
||||||
below the canvas, and beside it from 110 columns.
|
chosen part's options with radio buttons for few choices, sits below the
|
||||||
- Keys: arrows or `hjkl` move; Enter inspects, then edits a value (empty
|
canvas, and beside it from 110 columns. Narrow panes stack labels, wrap
|
||||||
returns it to its default); Tab sets it and goes to the next; Esc back; `a` adds a source,
|
text and scroll dialogs; the status line wraps to three rows.
|
||||||
sink or filter; `d` deletes, or returns a value to its default; Space turns
|
- Keys: arrows or `hjkl` move; Enter inspects a part, opening on its first
|
||||||
a flow stage or a switch on or off; `J`/`K` move a filter; `[` `]` switch
|
required key, then edits a value (empty returns it to its default), opens
|
||||||
pipelines; `p` presets; `c` shows the first problem; `o` output; `?` keys;
|
a list's choices or adds on an add row; in a value, Enter sets it, and Tab
|
||||||
`q` quits.
|
or Up/Down set it and move. Left/Right step a choice or set a switch;
|
||||||
- Output, `o`: run the pipelines with the other options given (so `--check`
|
Space flips it; Esc goes back. `a` adds a source, sink or filter; `c`
|
||||||
and `--dump` apply to them, and reloads keep them), or print one of the
|
changes a source's or sink's type in its place; `d` deletes, or returns a
|
||||||
command line, the environment variables (one pipeline) and the file's
|
value to its default; Space turns a flow stage on or off; `J`/`K` move a
|
||||||
pipelines on standard output once the screen closes.
|
filter; `[` `]` switch pipelines; `p` presets; `!` shows the first
|
||||||
|
problem; `r` runs; `o` output; `?` keys; `q` quits.
|
||||||
|
- Pipelines: Up from a column's top row reaches the bar, where `[` `]` or
|
||||||
|
Left/Right switch, `a` adds one (empty or from a preset), Enter renames and
|
||||||
|
`d` deletes, asking first when it has parts.
|
||||||
|
- Output, `o`, each picked by its letter: `r` runs the pipelines with the
|
||||||
|
other options given (so `--check` and `--dump` apply to them, and reloads
|
||||||
|
keep them), or `c`, `e` and `f` print the command line, the environment
|
||||||
|
variables (one pipeline) or the file's pipelines on standard output once
|
||||||
|
the screen closes.
|
||||||
- The screen draws on the controlling terminal: `lw -T > pipelines.toml` and
|
- The screen draws on the controlling terminal: `lw -T > pipelines.toml` and
|
||||||
`cmd | lw -T` keep standard input and output as data. lw never writes the
|
`cmd | lw -T` keep standard input and output as data. lw never writes the
|
||||||
file it starts from, but the shell empties a redirect's target before lw
|
file it starts from, but the shell empties a redirect's target before lw
|
||||||
@@ -476,9 +500,9 @@ them as a viewer. See
|
|||||||
```
|
```
|
||||||
lw auth add-user --credentials FILE -u NAME [--password-file FILE] [--generate]
|
lw auth add-user --credentials FILE -u NAME [--password-file FILE] [--generate]
|
||||||
lw auth remove-user --credentials FILE -u NAME
|
lw auth remove-user --credentials FILE -u NAME
|
||||||
lw auth token --url https://HOST:PORT[/PATH] -u NAME --password-file FILE
|
lw auth token --url https://HOST:PORT[/PATH] -u NAME [--password-file FILE]
|
||||||
[--unbound] [TLS flags]
|
[--unbound] [TLS flags]
|
||||||
lw auth stream --addr HOST:PORT -u NAME --password-file FILE [TLS flags]
|
lw auth stream --addr HOST:PORT -u NAME [--password-file FILE] [TLS flags]
|
||||||
```
|
```
|
||||||
|
|
||||||
`-u` is short for `--user`.
|
`-u` is short for `--user`.
|
||||||
@@ -496,7 +520,7 @@ success, `1` on failure and `2` on a usage error.
|
|||||||
**`add-user`** creates the credentials file, with a fresh `decoy_key`, when it
|
**`add-user`** creates the credentials file, with a fresh `decoy_key`, when it
|
||||||
does not exist or is empty (create it empty first to choose its owner). The
|
does not exist or is empty (create it empty first to choose its owner). The
|
||||||
password comes from `--password-file` when that file exists (at least 8 bytes;
|
password comes from `--password-file` when that file exists (at least 8 bytes;
|
||||||
one trailing line break is trimmed). Otherwise a random
|
one trailing line break is trimmed; `/dev/tty` asks). Otherwise a random
|
||||||
26-character password (130 bits) is generated and written to `--password-file`,
|
26-character password (130 bits) is generated and written to `--password-file`,
|
||||||
or printed once to stdout when there is none. Replacing an existing user's
|
or printed once to stdout when there is none. Replacing an existing user's
|
||||||
password needs an existing `--password-file` or `--generate`, so a mistyped path
|
password needs an existing `--password-file` or `--generate`, so a mistyped path
|
||||||
@@ -512,11 +536,13 @@ Neither touches a running LogWisp: send `SIGHUP`, since `auto_reload` does not
|
|||||||
watch the credentials file.
|
watch the credentials file.
|
||||||
|
|
||||||
**`token`** and **`stream`** build the same TLS and SCRAM client as a chain
|
**`token`** and **`stream`** build the same TLS and SCRAM client as a chain
|
||||||
sink. TLS flags: `--ca-file` (default: system roots) or `--pin-sha256` (the
|
sink. `--password-file` defaults to `/dev/tty`: the password is asked without
|
||||||
`tls.pin_sha256` a self-signed listener logs), `--server-name` (default: the
|
echo; a file, `/dev/fd/N` or a pipe is read as by a
|
||||||
host), and `--cert-file` / `--key-file` for a listener with `tls.client_auth`.
|
[plugin](security.md#one-user-without-a-credentials-file). TLS flags:
|
||||||
There is no flag to skip verification: an unverified server could relay the
|
`--ca-file` (default: system roots) or `--pin-sha256` (the `tls.pin_sha256` a
|
||||||
login. Redirects are not followed. `--unbound` logs in to an `http` sink behind
|
self-signed listener logs), `--server-name` (default: the host), and
|
||||||
|
`--cert-file` / `--key-file` for a listener with `tls.client_auth`. There is no
|
||||||
|
flag to skip verification: an unverified server could relay the login. Redirects are not followed. `--unbound` logs in to an `http` sink behind
|
||||||
a TLS-terminating proxy (`auth.trusted_proxies`), still pinning the proxy's
|
a TLS-terminating proxy (`auth.trusted_proxies`), still pinning the proxy's
|
||||||
certificate across the two requests; only then may `--url` carry the path the
|
certificate across the two requests; only then may `--url` carry the path the
|
||||||
proxy mounts LogWisp at. `stream` exits `0` on `SIGINT` or `SIGTERM`, and `1`
|
proxy mounts LogWisp at. `stream` exits `0` on `SIGINT` or `SIGTERM`, and `1`
|
||||||
@@ -549,9 +575,8 @@ curl --cacert ca.crt -H @<(printf 'Authorization: Bearer %s\n' \
|
|||||||
"$(lw auth token --url https://HOST:PORT --user viewer \
|
"$(lw auth token --url https://HOST:PORT --user viewer \
|
||||||
--password-file viewer.pass --ca-file ca.crt)") https://HOST:PORT/status
|
--password-file viewer.pass --ca-file ca.crt)") https://HOST:PORT/status
|
||||||
|
|
||||||
# follow a tcp sink
|
# follow a tcp sink, asked for the password
|
||||||
lw auth stream --addr HOST:PORT --user viewer --password-file viewer.pass \
|
lw auth stream --addr HOST:PORT --user viewer --pin-sha256 sha256//BASE64
|
||||||
--ca-file ca.crt
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## `lw tls`
|
## `lw tls`
|
||||||
|
|||||||
@@ -111,8 +111,8 @@ name = "app" # required, unique across pipelines
|
|||||||
|
|
||||||
# --- flow: everything between sources and sinks ---
|
# --- flow: everything between sources and sinks ---
|
||||||
[pipelines.flow.rate_limit]
|
[pipelines.flow.rate_limit]
|
||||||
rate = 1000.0
|
entries_per_second = 1000.0
|
||||||
burst = 2000.0
|
burst_entries = 2000.0
|
||||||
policy = "drop"
|
policy = "drop"
|
||||||
max_entry_size_bytes = 65536
|
max_entry_size_bytes = 65536
|
||||||
|
|
||||||
@@ -168,19 +168,19 @@ Every stage is optional:
|
|||||||
|
|
||||||
Options, each as type and default:
|
Options, each as type and default:
|
||||||
|
|
||||||
- `rate` (float, `0`): entries per second; `<= 0` disables the limiter
|
- `entries_per_second` (float, `0`): `<= 0` disables the limiter entirely.
|
||||||
entirely.
|
- `burst_entries` (float, `entries_per_second`): token bucket capacity.
|
||||||
- `burst` (float, `rate`): token bucket capacity.
|
|
||||||
- `policy` (string, `pass`): `pass` allows everything through, `drop` discards
|
- `policy` (string, `pass`): `pass` allows everything through, `drop` discards
|
||||||
over-limit entries.
|
over-limit entries.
|
||||||
- `max_entry_size_bytes` (int, `0`): per-entry byte cap; `0` = unlimited.
|
- `max_entry_size_bytes` (int, `0`): per-entry byte cap; `0` = unlimited.
|
||||||
|
|
||||||
Two behaviours are easy to trip over:
|
Two behaviours are easy to trip over:
|
||||||
|
|
||||||
- The limiter is constructed only when `rate > 0`. With `rate = 0`,
|
- The limiter is constructed only when `entries_per_second > 0`. With `0`,
|
||||||
`max_entry_size_bytes` is never enforced.
|
`max_entry_size_bytes` is never enforced.
|
||||||
- `policy = "pass"` short-circuits the whole check, including the size cap.
|
- `policy = "pass"` short-circuits the whole check, including the size cap.
|
||||||
To enforce a size cap you need `rate > 0` **and** `policy = "drop"`.
|
To enforce a size cap you need `entries_per_second > 0` **and**
|
||||||
|
`policy = "drop"`.
|
||||||
|
|
||||||
#### Heartbeat
|
#### Heartbeat
|
||||||
|
|
||||||
@@ -258,7 +258,7 @@ Startup validation is intentionally split.
|
|||||||
"pipelines[0].plugin_sinks[0].confg"`. A misspelled table path would otherwise
|
"pipelines[0].plugin_sinks[0].confg"`. A misspelled table path would otherwise
|
||||||
drop the whole table it heads. A top-level `config_file` is ignored
|
drop the whole table it heads. A top-level `config_file` is ignored
|
||||||
- pipeline [specs](cli.md#pipelines): syntax, and unknown flow-stage keys such
|
- pipeline [specs](cli.md#pipelines): syntax, and unknown flow-stage keys such
|
||||||
as `--rate-limit rate=1,polcy=drop`
|
as `--rate-limit entries_per_second=1,polcy=drop`
|
||||||
- at least one pipeline
|
- at least one pipeline
|
||||||
- unique, non-empty pipeline names
|
- unique, non-empty pipeline names
|
||||||
- at least one source and one sink per pipeline
|
- at least one source and one sink per pipeline
|
||||||
|
|||||||
+1
-1
@@ -317,7 +317,7 @@ docker run -d --name logwisp \
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
-e LOGWISP_PIPELINE=relay \
|
-e LOGWISP_PIPELINE=relay \
|
||||||
-e LOGWISP_SOURCE=tcp_chain,port=9000,tls.enabled=true,tls.cert_file=/run/secrets/tls.crt,tls.key_file=/run/secrets/tls.key,auth.type=scram,auth.credentials_file=/run/secrets/users.toml \
|
-e LOGWISP_SOURCE=tcp_chain:port=9000,tls.enabled=true,tls.cert_file=/run/secrets/tls.crt,tls.key_file=/run/secrets/tls.key,auth.type=scram,auth.credentials_file=/run/secrets/users.toml \
|
||||||
-e LOGWISP_SINK=console
|
-e LOGWISP_SINK=console
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -342,17 +342,20 @@ Set the pipeline's formatter, rate limit and heartbeat.
|
|||||||
.Pp
|
.Pp
|
||||||
A source, sink, filter or format
|
A source, sink, filter or format
|
||||||
.Ar spec
|
.Ar spec
|
||||||
is a type followed by comma-separated
|
is a type, then a colon and comma-separated
|
||||||
.Ar key Ns = Ns Ar value
|
.Ar key Ns = Ns Ar value
|
||||||
pairs; a rate limit or heartbeat
|
pairs; the first
|
||||||
|
.Ql \&:
|
||||||
|
ends the type, and a type alone takes the defaults.
|
||||||
|
A rate limit or heartbeat
|
||||||
.Ar spec
|
.Ar spec
|
||||||
has the pairs only:
|
has the pairs only:
|
||||||
.Bd -literal -offset indent
|
.Bd -literal -offset indent
|
||||||
\-\-source file,directory=/var/log/app,pattern=*.log
|
\-\-source file:directory=/var/log/app,pattern=*.log
|
||||||
\-\-sink http,host=127.0.0.1,port=8080,id=web
|
\-\-sink http:host=127.0.0.1,port=8080,id=web
|
||||||
\-\-filter include,patterns=ERROR,patterns=WARN
|
\-\-filter include:patterns=ERROR,patterns=WARN
|
||||||
\-\-format json
|
\-\-format json
|
||||||
\-\-rate\-limit rate=100,burst=200,policy=drop
|
\-\-rate\-limit entries_per_second=100,burst_entries=200,policy=drop
|
||||||
.Ed
|
.Ed
|
||||||
.Pp
|
.Pp
|
||||||
Keys are dotted paths into the plugin's options, such as
|
Keys are dotted paths into the plugin's options, such as
|
||||||
@@ -366,6 +369,8 @@ and
|
|||||||
inside a value; quote such a
|
inside a value; quote such a
|
||||||
.Ar spec
|
.Ar spec
|
||||||
so the shell keeps the backslash.
|
so the shell keeps the backslash.
|
||||||
|
A colon in a value needs no escape, as in
|
||||||
|
.Cm host=::1 .
|
||||||
.Cm id= Ns Ar name
|
.Cm id= Ns Ar name
|
||||||
names a source or sink; the default is its type, then
|
names a source or sink; the default is its type, then
|
||||||
.Ar type Ns _2 ,
|
.Ar type Ns _2 ,
|
||||||
@@ -377,7 +382,7 @@ ignore the pipeline variables in
|
|||||||
.Sx ENVIRONMENT .
|
.Sx ENVIRONMENT .
|
||||||
.Ss Presets
|
.Ss Presets
|
||||||
A preset builds a common pipeline from a few keys:
|
A preset builds a common pipeline from a few keys:
|
||||||
.Fl \-preset Ar name Ns Op , Ns Ar key Ns = Ns Ar value ... ,
|
.Fl \-preset Ar name Ns Op : Ns Ar key Ns = Ns Ar value ... ,
|
||||||
in the syntax of the other specifications;
|
in the syntax of the other specifications;
|
||||||
.Nm
|
.Nm
|
||||||
.Cm preset Ar name Op Fl \- Ns Ar key Ar value ...
|
.Cm preset Ar name Op Fl \- Ns Ar key Ar value ...
|
||||||
@@ -392,6 +397,16 @@ A
|
|||||||
.Ar path
|
.Ar path
|
||||||
is a file, a directory, whose files are followed, or a glob; without one, a
|
is a file, a directory, whose files are followed, or a glob; without one, a
|
||||||
preset that takes it reads standard input.
|
preset that takes it reads standard input.
|
||||||
|
.Cm user
|
||||||
|
names one SCRAM user, an edge's login or a listener's only user in place of
|
||||||
|
.Cm users ;
|
||||||
|
.Cm password_file
|
||||||
|
holds its password, read once at startup when it is
|
||||||
|
.Pa /dev/fd/ Ns Ar N
|
||||||
|
or a pipe, and defaults to
|
||||||
|
.Pa /dev/tty ,
|
||||||
|
which asks at startup; on a listener, Enter alone generates a password and
|
||||||
|
shows it there.
|
||||||
.Bl -tag -width Ds
|
.Bl -tag -width Ds
|
||||||
.It Cm pipe Op Cm format
|
.It Cm pipe Op Cm format
|
||||||
Standard input to standard output, the pipeline
|
Standard input to standard output, the pipeline
|
||||||
@@ -400,7 +415,7 @@ runs without a configuration.
|
|||||||
.It Cm tail path Op Cm from , format
|
.It Cm tail path Op Cm from , format
|
||||||
Follow files to standard output, like
|
Follow files to standard output, like
|
||||||
.Ql tail \-F .
|
.Ql tail \-F .
|
||||||
.It Cm serve Oo Cm path , listen , format , tls , users , proxy , viewer , allow , deny Oc
|
.It Cm serve Oo Cm path , listen , format , tls , users , user , password_file , proxy , viewer , allow , deny Oc
|
||||||
Serve a live HTTP stream on
|
Serve a live HTTP stream on
|
||||||
.Cm listen
|
.Cm listen
|
||||||
.Pq default Li 127.0.0.1:8080 ,
|
.Pq default Li 127.0.0.1:8080 ,
|
||||||
@@ -416,9 +431,13 @@ or
|
|||||||
.Cm files
|
.Cm files
|
||||||
.Pq Cm cert No and Cm key ;
|
.Pq Cm cert No and Cm key ;
|
||||||
.Cm users
|
.Cm users
|
||||||
names a credentials file, so readers log in with SCRAM.
|
names a credentials file, or
|
||||||
A browser at the root gets a live viewer; with
|
.Cm user
|
||||||
.Cm users ,
|
one user, so readers log in with SCRAM, which needs
|
||||||
|
.Cm tls
|
||||||
|
or
|
||||||
|
.Cm proxy .
|
||||||
|
A browser at the root gets a live viewer; with a user,
|
||||||
only behind a TLS-terminating proxy named by
|
only behind a TLS-terminating proxy named by
|
||||||
.Cm proxy ,
|
.Cm proxy ,
|
||||||
with
|
with
|
||||||
@@ -437,14 +456,12 @@ or, with
|
|||||||
.Cm transport=http ,
|
.Cm transport=http ,
|
||||||
.Cm http_chain .
|
.Cm http_chain .
|
||||||
It refuses to run without credentials:
|
It refuses to run without credentials:
|
||||||
.Cm user
|
.Cm user ,
|
||||||
and
|
|
||||||
.Cm password_file ,
|
|
||||||
or a client
|
or a client
|
||||||
.Cm cert
|
.Cm cert
|
||||||
and
|
and
|
||||||
.Cm key .
|
.Cm key .
|
||||||
.It Cm aggregator Oo Cm listen , transport , tls , users , client_ca , out , format , allow , deny Oc
|
.It Cm aggregator Oo Cm listen , transport , tls , users , user , password_file , client_ca , out , format , allow , deny Oc
|
||||||
Receive from edges on
|
Receive from edges on
|
||||||
.Cm listen
|
.Cm listen
|
||||||
.Pq default Li 0.0.0.0:9000
|
.Pq default Li 0.0.0.0:9000
|
||||||
@@ -452,7 +469,9 @@ over TLS, never off
|
|||||||
.Pq default Cm tls=self ,
|
.Pq default Cm tls=self ,
|
||||||
authenticated by the
|
authenticated by the
|
||||||
.Cm users
|
.Cm users
|
||||||
credentials file or the client certificates
|
credentials file, the one
|
||||||
|
.Cm user ,
|
||||||
|
or the client certificates
|
||||||
.Cm client_ca
|
.Cm client_ca
|
||||||
vouches for, and write to standard output or to files in
|
vouches for, and write to standard output or to files in
|
||||||
.Cm out ;
|
.Cm out ;
|
||||||
@@ -534,7 +553,7 @@ one keeps its owner and mode.
|
|||||||
.It Cm remove-user Fl \-credentials Ar file Fl u Ar name
|
.It Cm remove-user Fl \-credentials Ar file Fl u Ar name
|
||||||
Remove a user.
|
Remove a user.
|
||||||
The last user cannot be removed.
|
The last user cannot be removed.
|
||||||
.It Cm token Fl \-url Ar https://host:port Fl u Ar name Fl \-password\-file Ar file Oo Ar tls-flags Oc Op Fl \-unbound
|
.It Cm token Fl \-url Ar https://host:port Fl u Ar name Oo Fl \-password\-file Ar file Oc Oo Ar tls-flags Oc Op Fl \-unbound
|
||||||
Log in to an
|
Log in to an
|
||||||
.Cm http
|
.Cm http
|
||||||
sink or
|
sink or
|
||||||
@@ -544,12 +563,21 @@ source and print a bearer token.
|
|||||||
logs in without channel binding, to an
|
logs in without channel binding, to an
|
||||||
.Cm http
|
.Cm http
|
||||||
sink behind a TLS-terminating proxy; the URL may then carry the proxy's path.
|
sink behind a TLS-terminating proxy; the URL may then carry the proxy's path.
|
||||||
.It Cm stream Fl \-addr Ar host:port Fl u Ar name Fl \-password\-file Ar file Op Ar tls-flags
|
.It Cm stream Fl \-addr Ar host:port Fl u Ar name Oo Fl \-password\-file Ar file Oc Op Ar tls-flags
|
||||||
Log in to a
|
Log in to a
|
||||||
.Cm tcp
|
.Cm tcp
|
||||||
sink and copy its stream to standard output until interrupted.
|
sink and copy its stream to standard output until interrupted.
|
||||||
.El
|
.El
|
||||||
.Pp
|
.Pp
|
||||||
|
For
|
||||||
|
.Cm token
|
||||||
|
and
|
||||||
|
.Cm stream ,
|
||||||
|
.Fl \-password\-file
|
||||||
|
defaults to
|
||||||
|
.Pa /dev/tty ,
|
||||||
|
where the password is asked without echo.
|
||||||
|
.Pp
|
||||||
The
|
The
|
||||||
.Ar tls-flags
|
.Ar tls-flags
|
||||||
are
|
are
|
||||||
@@ -627,7 +655,8 @@ is looked for.
|
|||||||
.Pp
|
.Pp
|
||||||
Secrets never go in the environment: TLS keys, credentials files and
|
Secrets never go in the environment: TLS keys, credentials files and
|
||||||
password files are named by path
|
password files are named by path
|
||||||
.Pq Cm tls.key_file , auth.credentials_file , auth.password_file .
|
.Pq Cm tls.key_file , auth.credentials_file , auth.password_file ,
|
||||||
|
or asked on the terminal.
|
||||||
.Sh FILES
|
.Sh FILES
|
||||||
Without
|
Without
|
||||||
.Fl c
|
.Fl c
|
||||||
@@ -728,7 +757,7 @@ $ lw < app.log > copy.log
|
|||||||
.Pp
|
.Pp
|
||||||
Follow a log and keep only errors and warnings:
|
Follow a log and keep only errors and warnings:
|
||||||
.Bd -literal -offset indent
|
.Bd -literal -offset indent
|
||||||
$ tail \-F app.log | lw \-\-filter include,patterns=ERROR,patterns=WARN
|
$ tail \-F app.log | lw \-\-filter include:patterns=ERROR,patterns=WARN
|
||||||
.Ed
|
.Ed
|
||||||
.Pp
|
.Pp
|
||||||
Prefix each line with the time, the level found in it and the source:
|
Prefix each line with the time, the level found in it and the source:
|
||||||
@@ -743,7 +772,7 @@ sink listens on every address by default, and read it from another terminal
|
|||||||
as server-sent events; a client gets the lines that arrive while it is
|
as server-sent events; a client gets the lines that arrive while it is
|
||||||
connected:
|
connected:
|
||||||
.Bd -literal -offset indent
|
.Bd -literal -offset indent
|
||||||
$ journalctl \-f | lw \-\-sink http,host=127.0.0.1,port=8080
|
$ journalctl \-f | lw \-\-sink http:host=127.0.0.1,port=8080
|
||||||
$ curl \-N http://127.0.0.1:8080/stream
|
$ curl \-N http://127.0.0.1:8080/stream
|
||||||
.Ed
|
.Ed
|
||||||
.Pp
|
.Pp
|
||||||
@@ -753,19 +782,19 @@ file in a directory, as
|
|||||||
.Ql tail \-F
|
.Ql tail \-F
|
||||||
does, and write the new lines unparsed to one file:
|
does, and write the new lines unparsed to one file:
|
||||||
.Bd -literal -offset indent
|
.Bd -literal -offset indent
|
||||||
$ lw \-\-source 'file,directory=/var/log/app,pattern=*.log,raw=true' \e
|
$ lw \-\-source 'file:directory=/var/log/app,pattern=*.log,raw=true' \e
|
||||||
> all.log
|
> all.log
|
||||||
.Ed
|
.Ed
|
||||||
.Pp
|
.Pp
|
||||||
Follow every file in a directory, with the level names in color on a
|
Follow every file in a directory, with the level names in color on a
|
||||||
terminal:
|
terminal:
|
||||||
.Bd -literal -offset indent
|
.Bd -literal -offset indent
|
||||||
$ lw \-\-preset tail,path=/var/log/app
|
$ lw \-\-preset tail:path=/var/log/app
|
||||||
.Ed
|
.Ed
|
||||||
.Pp
|
.Pp
|
||||||
Print the configuration a set of options makes, to keep as a file:
|
Print the configuration a set of options makes, to keep as a file:
|
||||||
.Bd -literal -offset indent
|
.Bd -literal -offset indent
|
||||||
$ lw \-\-preset serve,path=/var/log/app,tls=self \-\-dump > serve.toml
|
$ lw \-\-preset serve:path=/var/log/app,tls=self \-\-dump > serve.toml
|
||||||
.Ed
|
.Ed
|
||||||
.Pp
|
.Pp
|
||||||
Ship a directory from an edge to an aggregator: on the aggregator, create
|
Ship a directory from an edge to an aggregator: on the aggregator, create
|
||||||
@@ -777,13 +806,22 @@ and the password file to the edge; then run both:
|
|||||||
# lw tls cert \-\-ca-dir /etc/logwisp/pki \-\-name agg.example.org \-\-server
|
# lw tls cert \-\-ca-dir /etc/logwisp/pki \-\-name agg.example.org \-\-server
|
||||||
# lw auth add-user \-\-credentials /etc/logwisp/users.toml \e
|
# lw auth add-user \-\-credentials /etc/logwisp/users.toml \e
|
||||||
\-\-user edge-01 \-\-password-file /root/edge-01.pass
|
\-\-user edge-01 \-\-password-file /root/edge-01.pass
|
||||||
agg# lw \-\-preset aggregator,tls=files,users=/etc/logwisp/users.toml,\e
|
agg# lw \-\-preset aggregator:tls=files,users=/etc/logwisp/users.toml,\e
|
||||||
cert=/etc/logwisp/pki/agg.example.org.crt,\e
|
cert=/etc/logwisp/pki/agg.example.org.crt,\e
|
||||||
key=/etc/logwisp/pki/agg.example.org.key,out=/var/log/edges
|
key=/etc/logwisp/pki/agg.example.org.key,out=/var/log/edges
|
||||||
edge# lw \-\-preset edge,path=/var/log/app,to=agg.example.org:9000,\e
|
edge# lw \-\-preset edge:path=/var/log/app,to=agg.example.org:9000,\e
|
||||||
ca=/etc/logwisp/pki/ca.crt,user=edge-01,password_file=/root/edge-01.pass
|
ca=/etc/logwisp/pki/ca.crt,user=edge-01,password_file=/root/edge-01.pass
|
||||||
.Ed
|
.Ed
|
||||||
.Pp
|
.Pp
|
||||||
|
A temporary pipe with nothing on disk: the receiver makes a self-signed
|
||||||
|
certificate, logs its pin, and generates the password when Enter alone
|
||||||
|
answers its prompt; the sender pins the certificate and is asked for the
|
||||||
|
password:
|
||||||
|
.Bd -literal -offset indent
|
||||||
|
recv$ lw \-p aggregator:listen=0.0.0.0:9000,user=pipe,format=raw
|
||||||
|
send$ cmd | lw \-p edge:to=RECEIVER:9000,pin=sha256//BASE64,user=pipe
|
||||||
|
.Ed
|
||||||
|
.Pp
|
||||||
Write the annotated configuration to the file
|
Write the annotated configuration to the file
|
||||||
.Nm
|
.Nm
|
||||||
reads without
|
reads without
|
||||||
|
|||||||
+6
-6
@@ -7,7 +7,7 @@ Running, monitoring, and maintaining LogWisp.
|
|||||||
```bash
|
```bash
|
||||||
# a filter: stdin to stdout, exits 0 at the end of input
|
# a filter: stdin to stdout, exits 0 at the end of input
|
||||||
lw < app.log > copy.log
|
lw < app.log > copy.log
|
||||||
tail -F app.log | lw --filter include,patterns=ERROR
|
tail -F app.log | lw --filter include:patterns=ERROR
|
||||||
|
|
||||||
# a service: the file's pipelines, until SIGINT or SIGTERM
|
# a service: the file's pipelines, until SIGINT or SIGTERM
|
||||||
lw -c /etc/logwisp/logwisp.toml
|
lw -c /etc/logwisp/logwisp.toml
|
||||||
@@ -249,16 +249,16 @@ client_buffer_size = 1024
|
|||||||
|
|
||||||
```toml
|
```toml
|
||||||
[pipelines.flow.rate_limit]
|
[pipelines.flow.rate_limit]
|
||||||
rate = 1000.0
|
entries_per_second = 1000.0
|
||||||
burst = 2000.0
|
burst_entries = 2000.0
|
||||||
policy = "drop"
|
policy = "drop"
|
||||||
max_entry_size_bytes = 65536
|
max_entry_size_bytes = 65536
|
||||||
```
|
```
|
||||||
|
|
||||||
Two behaviours to keep in mind: the limiter does not exist at all when
|
Two behaviours to keep in mind: the limiter does not exist at all when
|
||||||
`rate <= 0`, and `policy = "pass"` short-circuits the size cap as well as the
|
`entries_per_second <= 0`, and `policy = "pass"` short-circuits the size cap as
|
||||||
rate check. Enforcing `max_entry_size_bytes` therefore requires `rate > 0` and
|
well as the rate check. Enforcing `max_entry_size_bytes` therefore requires
|
||||||
`policy = "drop"`.
|
`entries_per_second > 0` and `policy = "drop"`.
|
||||||
|
|
||||||
### Formatting
|
### Formatting
|
||||||
|
|
||||||
|
|||||||
+33
-14
@@ -15,8 +15,10 @@ next to the existing certificate method, without weakening what mTLS gives.
|
|||||||
|
|
||||||
- Password authentication for chain links (`tcp_chain`, `http_chain`: sources
|
- Password authentication for chain links (`tcp_chain`, `http_chain`: sources
|
||||||
verify, sinks present) and for viewers of the `tcp` and `http` sinks.
|
verify, sinks present) and for viewers of the `tcp` and `http` sinks.
|
||||||
- No password or password-equivalent on the wire, no KDF work on listeners, and
|
- No password or password-equivalent on the wire, no KDF work per login on
|
||||||
no credential relay through a TLS-terminating MITM.
|
listeners, and no credential relay through a TLS-terminating MITM.
|
||||||
|
- A temporary pipe needs no file: one listener user from a descriptor, a pipe
|
||||||
|
or the terminal, with a certificate made at startup.
|
||||||
- Default stays `auth.type = "none"`; existing configurations are unaffected.
|
- Default stays `auth.type = "none"`; existing configurations are unaffected.
|
||||||
- Config in TOML, credential management and viewer clients in a `lw auth`
|
- Config in TOML, credential management and viewer clients in a `lw auth`
|
||||||
CLI.
|
CLI.
|
||||||
@@ -66,7 +68,7 @@ curl by the CLI is unpinned.
|
|||||||
# listener (tcp_chain/http_chain source, tcp/http sink)
|
# listener (tcp_chain/http_chain source, tcp/http sink)
|
||||||
[pipelines.plugin_sources.config.auth]
|
[pipelines.plugin_sources.config.auth]
|
||||||
type = "scram"
|
type = "scram"
|
||||||
credentials_file = "/etc/logwisp/users.toml"
|
credentials_file = "/etc/logwisp/users.toml" # or username + password_file
|
||||||
token_lifetime_ms = 900000 # http sink and http_chain source only
|
token_lifetime_ms = 900000 # http sink and http_chain source only
|
||||||
node_binding = "force" # chain sources; binds to the username
|
node_binding = "force" # chain sources; binds to the username
|
||||||
|
|
||||||
@@ -81,12 +83,20 @@ Validation at construction:
|
|||||||
|
|
||||||
- `scram` requires TLS, except on an `http` sink in proxy mode; a dialer also
|
- `scram` requires TLS, except on an `http` sink in proxy mode; a dialer also
|
||||||
forbids `insecure_skip_verify`.
|
forbids `insecure_skip_verify`.
|
||||||
- Listeners require `credentials_file` and reject `allow`/`allow_patterns` (the
|
- Listeners require `credentials_file`, or `username` and `password_file`
|
||||||
credentials file is the allow list), `username` and `password_file`. Use one
|
(one user, its verifier derived in memory at construction), not both, and
|
||||||
file per listener when listeners admit different users.
|
reject `allow`/`allow_patterns`: the users are the allow list. Use one file
|
||||||
- Dialers require `username` + `password_file` (1–1024 bytes after trimming one
|
per listener when listeners admit different users.
|
||||||
line break), and reject `credentials_file`, `token_lifetime_ms`, `allow*` and
|
- Dialers require `username` + `password_file`, and reject `credentials_file`,
|
||||||
`identity` (server pinning stays an mTLS feature).
|
`token_lifetime_ms`, `allow*` and `identity` (server pinning stays an mTLS
|
||||||
|
feature).
|
||||||
|
- A password is 1–1024 bytes after trimming one line break, 8 or more on a
|
||||||
|
listener. A regular file is read at every construction; `/dev/fd/N` (the
|
||||||
|
inherited descriptor, then closed), a pipe, a FIFO or another device once, at
|
||||||
|
startup, and kept for reloads; a terminal is asked once, at startup, without
|
||||||
|
echo, and on a listener Enter alone generates a password shown only there.
|
||||||
|
The open file decides, not stat: FreeBSD stats `/dev/stdin` as a device
|
||||||
|
whatever it holds. No terminal fails closed.
|
||||||
- `identity` on a listener requires `tls.client_auth` and **binds the certificate
|
- `identity` on a listener requires `tls.client_auth` and **binds the certificate
|
||||||
to the user**: the certificate's identity field must equal the SCRAM username,
|
to the user**: the certificate's identity field must equal the SCRAM username,
|
||||||
so a peer needs its own certificate and its own password (PostgreSQL's
|
so a peer needs its own certificate and its own password (PostgreSQL's
|
||||||
@@ -207,8 +217,8 @@ closed.
|
|||||||
```
|
```
|
||||||
lw auth add-user --credentials F --user U [--password-file P] [--generate]
|
lw auth add-user --credentials F --user U [--password-file P] [--generate]
|
||||||
lw auth remove-user --credentials F --user U
|
lw auth remove-user --credentials F --user U
|
||||||
lw auth token --url https://host:port[/path] --user U --password-file P [--unbound] [TLS flags]
|
lw auth token --url https://host:port[/path] --user U [--password-file P] [--unbound] [TLS flags]
|
||||||
lw auth stream --addr host:port --user U --password-file P [TLS flags]
|
lw auth stream --addr host:port --user U [--password-file P] [TLS flags]
|
||||||
```
|
```
|
||||||
|
|
||||||
- `add-user` takes the password from `--password-file` when it exists (at least
|
- `add-user` takes the password from `--password-file` when it exists (at least
|
||||||
@@ -224,7 +234,7 @@ lw auth stream --addr host:port --user U --password-file P [TLS flags]
|
|||||||
- `token` prints a bearer token for curl (`-H @<(...)` keeps it out of argv);
|
- `token` prints a bearer token for curl (`-H @<(...)` keeps it out of argv);
|
||||||
`stream` authenticates to a `tcp` sink and copies the stream to stdout until
|
`stream` authenticates to a `tcp` sink and copies the stream to stdout until
|
||||||
interrupted. `--url` takes a path only with `--unbound`, and redirects are not
|
interrupted. `--url` takes a path only with `--unbound`, and redirects are not
|
||||||
followed.
|
followed. `--password-file` defaults to `/dev/tty`, which asks.
|
||||||
- TLS flags: `--ca-file` or `--pin-sha256`, `--server-name`, `--cert-file`,
|
- TLS flags: `--ca-file` or `--pin-sha256`, `--server-name`, `--cert-file`,
|
||||||
`--key-file`. There is no insecure flag.
|
`--key-file`. There is no insecure flag.
|
||||||
- Exit status: 0 success, 1 failure, 2 usage error.
|
- Exit status: 0 success, 1 failure, 2 usage error.
|
||||||
@@ -340,7 +350,11 @@ a challenge below its Argon2 floor; the HTTP exchange on a TLS test server
|
|||||||
(token accepted, garbage and foreign tokens `401`, forged final yields no token,
|
(token accepted, garbage and foreign tokens `401`, forged final yields no token,
|
||||||
a different certificate after the challenge refused before the proof is sent,
|
a different certificate after the challenge refused before the proof is sent,
|
||||||
renewal ahead of expiry); `Authorize` failing closed under SCRAM; password
|
renewal ahead of expiry); `Authorize` failing closed under SCRAM; password
|
||||||
trimming; refused hellos freeing their slot; unbound logins pinned too. Proxy
|
trimming; a one-user listener admitting its password, refusing another and an
|
||||||
|
unknown user alike; a descriptor's password surviving a reload, closed and
|
||||||
|
refused to a second user; a descriptor lw opened itself never read; refused
|
||||||
|
hellos freeing their slot; unbound logins pinned too. In `tlsx`, terminals,
|
||||||
|
pipes and descriptors never warned as world-readable. Proxy
|
||||||
mode: trusted peers, https and the client hop; throttling on the forwarded
|
mode: trusted peers, https and the client hop; throttling on the forwarded
|
||||||
client and IPv6 /64; an unbound browser login with the cookie's attributes, the
|
client and IPv6 /64; an unbound browser login with the cookie's attributes, the
|
||||||
cookie opening endpoints (beside Basic auth) until logout revokes it, cookie
|
cookie opening endpoints (beside Basic auth) until logout revokes it, cookie
|
||||||
@@ -371,7 +385,12 @@ viewer's own form, not a loop, its token kept out of storage and the URL; a
|
|||||||
profile blocking every cookie signing in on the viewer, its proofs asking for
|
profile blocking every cookie signing in on the viewer, its proofs asking for
|
||||||
no cookie, streaming with a bearer, sign-out revoking the token, and a reload's
|
no cookie, streaming with a bearer, sign-out revoking the token, and a reload's
|
||||||
`401` asking again; `token --unbound` and curl through the proxy; direct peers
|
`401` asking again; `token --unbound` and curl through the proxy; direct peers
|
||||||
and plaintext-forwarded requests `403`. The existing scripts keep passing.
|
and plaintext-forwarded requests `403`. `test/preset-test.sh --auto` (ports
|
||||||
|
15897-15898): an aggregator and an edge given one password on `/dev/fd/3`
|
||||||
|
deliver, another password is refused, both survive `SIGHUP` and deliver again,
|
||||||
|
and the password is in no file, argv or environment; under `script(1)` a
|
||||||
|
listener's generated password shows on its terminal and not in its log, and
|
||||||
|
an edge and `lw auth token` log in with it. The existing scripts keep passing.
|
||||||
|
|
||||||
## Not Implemented
|
## Not Implemented
|
||||||
|
|
||||||
|
|||||||
+68
-9
@@ -159,8 +159,8 @@ allow_patterns = [] # mtls
|
|||||||
node_binding = "force" # chain sources only
|
node_binding = "force" # chain sources only
|
||||||
credentials_file = "" # scram listeners
|
credentials_file = "" # scram listeners
|
||||||
token_lifetime_ms = 0 # scram, HTTP listeners; 10 s to 24 h, 0 = 15 minutes
|
token_lifetime_ms = 0 # scram, HTTP listeners; 10 s to 24 h, 0 = 15 minutes
|
||||||
username = "" # scram dialers
|
username = "" # scram dialers, or a listener's one user
|
||||||
password_file = "" # scram dialers
|
password_file = "" # scram, with username
|
||||||
trusted_proxies = [] # scram http sink behind a TLS-terminating proxy
|
trusted_proxies = [] # scram http sink behind a TLS-terminating proxy
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -181,7 +181,9 @@ Options by where they apply, each with its default:
|
|||||||
them yourself.
|
them yourself.
|
||||||
- `scram` listeners
|
- `scram` listeners
|
||||||
- `credentials_file`: verifiers written by
|
- `credentials_file`: verifiers written by
|
||||||
[`lw auth add-user`](cli.md#lw-auth).
|
[`lw auth add-user`](cli.md#lw-auth); or, in its place, `username` and
|
||||||
|
`password_file`, see
|
||||||
|
[One user without a credentials file](#one-user-without-a-credentials-file).
|
||||||
- `token_lifetime_ms` (15 minutes): bearer token lifetime, 10 s to 24 h; the
|
- `token_lifetime_ms` (15 minutes): bearer token lifetime, 10 s to 24 h; the
|
||||||
`http` sink and `http_chain` source only.
|
`http` sink and `http_chain` source only.
|
||||||
- `trusted_proxies` (`[]`): addresses or CIDRs of the reverse proxies that
|
- `trusted_proxies` (`[]`): addresses or CIDRs of the reverse proxies that
|
||||||
@@ -190,7 +192,7 @@ Options by where they apply, each with its default:
|
|||||||
- `scram` dialers
|
- `scram` dialers
|
||||||
- `username`: user to log in as.
|
- `username`: user to log in as.
|
||||||
- `password_file`: file holding the password; one trailing line break is
|
- `password_file`: file holding the password; one trailing line break is
|
||||||
trimmed.
|
trimmed. `/dev/fd/N`, a pipe or a terminal are read once, as on listeners.
|
||||||
|
|
||||||
**Roles by plugin**, and what the block decides:
|
**Roles by plugin**, and what the block decides:
|
||||||
|
|
||||||
@@ -306,9 +308,10 @@ Misconfiguration fails at plugin construction, before the pipeline starts:
|
|||||||
type forgotten
|
type forgotten
|
||||||
- a key of the other method: `allow` or `allow_patterns` under `scram`, a
|
- a key of the other method: `allow` or `allow_patterns` under `scram`, a
|
||||||
`scram` key under `mtls`
|
`scram` key under `mtls`
|
||||||
- a `scram` listener without a loadable `credentials_file` (see
|
- a `scram` listener with neither a loadable `credentials_file` (see
|
||||||
[Credentials file](#credentials-file)), with `username` or `password_file`,
|
[Credentials file](#credentials-file)) nor `username` and `password_file`,
|
||||||
with a `token_lifetime_ms` outside 10 s to 24 h or on a TCP listener, or with
|
or with both, with only one of `username` and `password_file`, with a
|
||||||
|
`token_lifetime_ms` outside 10 s to 24 h or on a TCP listener, or with
|
||||||
`identity` but no `tls.client_auth`
|
`identity` but no `tls.client_auth`
|
||||||
- a `scram` dialer without `username` and `password_file`, with
|
- a `scram` dialer without `username` and `password_file`, with
|
||||||
`credentials_file`, `token_lifetime_ms`, `trusted_proxies` or `identity`
|
`credentials_file`, `token_lifetime_ms`, `trusted_proxies` or `identity`
|
||||||
@@ -435,8 +438,8 @@ when it is constructed, so startup and every reload repeat them:
|
|||||||
- an `allow_patterns` entry not anchored at both ends of every alternative
|
- an `allow_patterns` entry not anchored at both ends of every alternative
|
||||||
(`^a|b$` admits `a…` and `…b`)
|
(`^a|b$` admits `a…` and `…b`)
|
||||||
- a `key_file`, `issuer_key_file`, `credentials_file` or `password_file` every
|
- a `key_file`, `issuer_key_file`, `credentials_file` or `password_file` every
|
||||||
local user can read
|
local user can read (once per path per process); terminals, pipes and
|
||||||
(once per path per process)
|
descriptors hold nothing at rest and are not reported
|
||||||
- an `acl` entry with host bits set (`10.0.0.1/8`), naming the network it
|
- an `acl` entry with host bits set (`10.0.0.1/8`), naming the network it
|
||||||
matches; an `acl.allow` entry of a whole family (`0.0.0.0/0`, `::/0`); a
|
matches; an `acl.allow` entry of a whole family (`0.0.0.0/0`, `::/0`); a
|
||||||
wildcard listener whose `acl` has `deny` but no `allow`; an `acl.proxy_from`
|
wildcard listener whose `acl` has `deny` but no `allow`; an `acl.proxy_from`
|
||||||
@@ -477,6 +480,18 @@ lw tls cert --ca-dir /etc/logwisp/pki --name agg.example.org --server
|
|||||||
lw tls cert --ca-dir /etc/logwisp/pki --name edge-01 --client
|
lw tls cert --ca-dir /etc/logwisp/pki --name edge-01 --client
|
||||||
```
|
```
|
||||||
|
|
||||||
|
A temporary pipe needs no file at all: a self-signed certificate and
|
||||||
|
[one user](#one-user-without-a-credentials-file) whose password is typed, or
|
||||||
|
generated, at startup. Both last until lw exits.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# receiver: Enter at the prompt generates the password; copy it, and the
|
||||||
|
# pin_sha256 of the WARN line (-q would hide it)
|
||||||
|
lw -p aggregator:listen=0.0.0.0:9000,tls=self,user=pipe,format=raw,allow=SENDER/32
|
||||||
|
# sender: asked for the password
|
||||||
|
cmd | lw -p edge:to=RECEIVER:9000,pin=sha256//PIN,user=pipe
|
||||||
|
```
|
||||||
|
|
||||||
## Enabling mTLS
|
## Enabling mTLS
|
||||||
|
|
||||||
### 1. Generate a CA and certificates
|
### 1. Generate a CA and certificates
|
||||||
@@ -702,6 +717,50 @@ only when the plugin is built and `auto_reload` does not watch it: send
|
|||||||
`SIGHUP` after every change. Use one file per listener when listeners admit
|
`SIGHUP` after every change. Use one file per listener when listeners admit
|
||||||
different users.
|
different users.
|
||||||
|
|
||||||
|
### One user without a credentials file
|
||||||
|
|
||||||
|
`username` and `password_file` on a listener replace `credentials_file`: one
|
||||||
|
user whose verifier is derived in memory when the plugin is built (one
|
||||||
|
Argon2id at startup and each reload, never per login), with a decoy key drawn
|
||||||
|
once per process. Nothing is written. It suits a temporary pipe or a single
|
||||||
|
edge. `password_file` is read by what it names, on either side:
|
||||||
|
|
||||||
|
- A regular file: at every build, so `SIGHUP` rotates it. A systemd
|
||||||
|
`LoadCredential=` file sits on tmpfs.
|
||||||
|
- `/dev/fd/N`, a pipe, a FIFO or another device: once, at startup, then kept
|
||||||
|
in memory for reloads; restart lw to change it. A reload naming a new one
|
||||||
|
fails and the running pipelines stay: a console source may be reading the
|
||||||
|
same input, and the read would hold the reload and its signals.
|
||||||
|
- `/dev/fd/N` is the descriptor lw was given (`3< <(pass show lw/pipe)`),
|
||||||
|
read and closed; it needs no fdescfs on FreeBSD. A descriptor lw opened
|
||||||
|
itself is refused.
|
||||||
|
- One such source holds one user's password. `/dev/stdin` would take the
|
||||||
|
console source's input.
|
||||||
|
- A terminal: `/dev/tty`, the presets' default with `user`, asked once, at
|
||||||
|
startup, without echo. lw asks whatever file is a terminal once opened, so
|
||||||
|
`/dev/stdin` holding a pipe is read on FreeBSD too, where stat calls it a
|
||||||
|
device.
|
||||||
|
- On a listener, Enter alone generates a 130-bit password and shows it once
|
||||||
|
on that terminal, never in the log or on stderr: logs persist, are
|
||||||
|
forwarded, and stderr is often a file.
|
||||||
|
- With no terminal (systemd, cron) the plugin fails. `lw ... &` stops on
|
||||||
|
`SIGTTIN` at the prompt.
|
||||||
|
|
||||||
|
Trade-offs:
|
||||||
|
|
||||||
|
- The password lives as long as the process, as dialers' passwords do:
|
||||||
|
`tcp_chain` logs in again on every reconnect and `http_chain` at every token
|
||||||
|
renewal, so it cannot be single-use.
|
||||||
|
- A generated password stays in terminal scrollback, tmux or screen logs, and
|
||||||
|
the clipboard that carries it. A typed one may be as short as 8 bytes; on an
|
||||||
|
exposed listener prefer the generated one and `acl.allow`.
|
||||||
|
- It never goes in argv or the environment: `/proc/PID/cmdline` is
|
||||||
|
world-readable, and `--dump` keeps only the path.
|
||||||
|
- Each build draws a new salt, which, like a rotation, shows a prober that the
|
||||||
|
name exists.
|
||||||
|
- Older bash and zsh back a here-string (`<<<`) with a temporary file; prefer
|
||||||
|
`3< <(...)`.
|
||||||
|
|
||||||
### Rollout, rotation and revocation
|
### Rollout, rotation and revocation
|
||||||
|
|
||||||
- **Rollout.** Turning `scram` on cuts off every dialer of that listener that
|
- **Rollout.** Turning `scram` on cuts off every dialer of that listener that
|
||||||
|
|||||||
+6
-5
@@ -173,11 +173,12 @@ Options, each as type and default:
|
|||||||
- `replay_lines` (int, `0`; `1000` from the `serve` preset): the last entries
|
- `replay_lines` (int, `0`; `1000` from the `serve` preset): the last entries
|
||||||
a new stream gets after its `connected` event, before the live ones, so a
|
a new stream gets after its `connected` event, before the live ones, so a
|
||||||
browser opened late sees a backlog; `0` = none.
|
browser opened late sees a backlog; `0` = none.
|
||||||
- `login_page` (bool, `false`): `scram` in proxy mode only (an error
|
- `login_page` (bool, `false`): only for `scram` behind
|
||||||
elsewhere): the browser login page at `/auth/login`.
|
`auth.trusted_proxies`, where browsers log in (an error elsewhere): the
|
||||||
- `viewer_page` (bool, `false`): `scram` in proxy mode only (an error
|
login page at `/auth/login`.
|
||||||
elsewhere): the live viewer at `/auth/view`; needs `login_page`. Without
|
- `viewer_page` (bool, `false`): the same, the live viewer at `/auth/view`
|
||||||
auth, or under `mtls`, the viewer is always served.
|
after a login; needs `login_page`. Without auth, or under `mtls`, the
|
||||||
|
viewer is always at `/`, with neither key.
|
||||||
- `tls` (table): listener TLS; see [Security](security.md).
|
- `tls` (table): listener TLS; see [Security](security.md).
|
||||||
- `auth` (table): client authentication (`mtls` or `scram`); see
|
- `auth` (table): client authentication (`mtls` or `scram`); see
|
||||||
[Security](security.md#the-auth-block).
|
[Security](security.md#the-auth-block).
|
||||||
|
|||||||
+2
-1
@@ -227,7 +227,8 @@ Options, each as type and default:
|
|||||||
[Security](security.md#the-acl-block).
|
[Security](security.md#the-acl-block).
|
||||||
|
|
||||||
For passwords instead of certificates, the `auth` block takes `type = "scram"`
|
For passwords instead of certificates, the `auth` block takes `type = "scram"`
|
||||||
and a `credentials_file`, and `client_auth` becomes optional; see
|
and a `credentials_file`, or one `username` with its `password_file`, and
|
||||||
|
`client_auth` becomes optional; see
|
||||||
[Password Authentication](security.md#password-authentication-scram).
|
[Password Authentication](security.md#password-authentication-scram).
|
||||||
|
|
||||||
**Behaviour**
|
**Behaviour**
|
||||||
|
|||||||
+6
-2
@@ -102,7 +102,9 @@ What exists:
|
|||||||
auth and acl tables, sources, sinks and presets, as one JSON document.
|
auth and acl tables, sources, sinks and presets, as one JSON document.
|
||||||
- `github.com/lixenwraith/terminal` (pinned) and its `tui` package: regions,
|
- `github.com/lixenwraith/terminal` (pinned) and its `tui` package: regions,
|
||||||
layout, boxes, List, Tree, TabBar, StatusBar, TextField, Form, Modal,
|
layout, boxes, List, Tree, TabBar, StatusBar, TextField, Form, Modal,
|
||||||
ConfirmDialog, scrolling, mouse. `github.com/lixenwraith/color`: RGB,
|
ConfirmDialog, scrolling, mouse; themed Radio, Field, TextInput,
|
||||||
|
OptionList and menus, Frame, Rule, Wires and Window, which `lw --tui`
|
||||||
|
draws with. `github.com/lixenwraith/color`: RGB,
|
||||||
blending, RGBTo256, RGBTo16.
|
blending, RGBTo256, RGBTo16.
|
||||||
|
|
||||||
Steps:
|
Steps:
|
||||||
@@ -112,7 +114,9 @@ Steps:
|
|||||||
4. `lixenwraith/terminal` v0.2.0: done: 16 and no color, the terminal's
|
4. `lixenwraith/terminal` v0.2.0: done: 16 and no color, the terminal's
|
||||||
default colors, `/dev/tty`, bracketed paste, themed form controls. Still
|
default colors, `/dev/tty`, bracketed paste, themed form controls. Still
|
||||||
optional, in `color`: export the 16 xterm reference shades (RGBTo16 maps
|
optional, in `color`: export the 16 xterm reference shades (RGBTo16 maps
|
||||||
the site's hues badly, so the 16-color tier is chosen by hand).
|
the site's hues badly, so the 16-color tier is chosen by hand). v0.3.0:
|
||||||
|
radio buttons, narrow form fields and option lists, menus, frames, rules,
|
||||||
|
wires and windows, moved from `lw --tui`.
|
||||||
5. `lw --tui`: done ([doc/cli.md](cli.md#lw---tui)).
|
5. `lw --tui`: done ([doc/cli.md](cli.md#lw---tui)).
|
||||||
6. WebAssembly: done. `make wasm` builds `bin/lwconf.wasm`, version stamped,
|
6. WebAssembly: done. `make wasm` builds `bin/lwconf.wasm`, version stamped,
|
||||||
beside the toolchain's `wasm_exec.js`, and prints both checksums: about
|
beside the toolchain's `wasm_exec.js`, and prints both checksums: about
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
module github.com/lixenwraith/logwisp
|
module github.com/lixenwraith/logwisp
|
||||||
|
|
||||||
go 1.27.1
|
go 1.27.2
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/lixenwraith/auth v0.5.1
|
github.com/lixenwraith/auth v0.5.1
|
||||||
github.com/lixenwraith/color v0.1.3
|
github.com/lixenwraith/color v0.1.3
|
||||||
github.com/lixenwraith/config v0.2.2
|
github.com/lixenwraith/config v0.2.2
|
||||||
github.com/lixenwraith/log v0.1.11
|
github.com/lixenwraith/log v0.1.11
|
||||||
github.com/lixenwraith/terminal v0.2.0
|
github.com/lixenwraith/terminal v0.2.1
|
||||||
github.com/lixenwraith/toml v0.1.3
|
github.com/lixenwraith/toml v0.1.3
|
||||||
golang.org/x/term v0.47.0
|
golang.org/x/term v0.47.0
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ github.com/lixenwraith/config v0.2.2 h1:X+l3Tf8Rbk49cipYBJU7/pfdw5wDwD4hRxtFH7rZ
|
|||||||
github.com/lixenwraith/config v0.2.2/go.mod h1:yfJQwKS+/6Dg70GIoHeYc0eYztOPETpqe6nUIbakcyg=
|
github.com/lixenwraith/config v0.2.2/go.mod h1:yfJQwKS+/6Dg70GIoHeYc0eYztOPETpqe6nUIbakcyg=
|
||||||
github.com/lixenwraith/log v0.1.11 h1:dKWLnaR8dhXxj4l/aU5XZiFIsr8geaJQbOf8m7SiKhU=
|
github.com/lixenwraith/log v0.1.11 h1:dKWLnaR8dhXxj4l/aU5XZiFIsr8geaJQbOf8m7SiKhU=
|
||||||
github.com/lixenwraith/log v0.1.11/go.mod h1:EE8i5nSAW2yZBTytKuhdgehmgzrby6kW1k5DyH1SvBg=
|
github.com/lixenwraith/log v0.1.11/go.mod h1:EE8i5nSAW2yZBTytKuhdgehmgzrby6kW1k5DyH1SvBg=
|
||||||
github.com/lixenwraith/terminal v0.2.0 h1:Xwsg8LoAeV7E//JLIFmEBTTV9e03jpze3EUAOdqS6P8=
|
github.com/lixenwraith/terminal v0.2.1-0.20261010134811-bbb83d6c20df h1:HPfMYYSfNva8cMlgyTtZntu3oUwNKXW48rCwpmTEPoU=
|
||||||
github.com/lixenwraith/terminal v0.2.0/go.mod h1:f9Ntk+xKBS2LWQEe6gk/1TOpG22HH0scTe4wlPhEy44=
|
github.com/lixenwraith/terminal v0.2.1-0.20261010134811-bbb83d6c20df/go.mod h1:f9Ntk+xKBS2LWQEe6gk/1TOpG22HH0scTe4wlPhEy44=
|
||||||
|
github.com/lixenwraith/terminal v0.2.1 h1:KsMfmxrRicBFWMdXJIEJ3cpLWVw6IoNF8GwgsxbD3kQ=
|
||||||
|
github.com/lixenwraith/terminal v0.2.1/go.mod h1:V0P/FDpY+UeSvx1VIVuTG7llo0RbK3XrSJLDA7GqHqY=
|
||||||
github.com/lixenwraith/toml v0.1.3 h1:mhnNVLNogdaqjGd3eC4rV9ZqY3j1Sv+DK1erZ+SPFeI=
|
github.com/lixenwraith/toml v0.1.3 h1:mhnNVLNogdaqjGd3eC4rV9ZqY3j1Sv+DK1erZ+SPFeI=
|
||||||
github.com/lixenwraith/toml v0.1.3/go.mod h1:itcT/Vt5mwmX9KVGCMsWZr6vbU/JgZ24wV/I0J5cMt0=
|
github.com/lixenwraith/toml v0.1.3/go.mod h1:itcT/Vt5mwmX9KVGCMsWZr6vbU/JgZ24wV/I0J5cMt0=
|
||||||
golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
|
golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
|
||||||
|
|||||||
@@ -448,7 +448,7 @@ func (p *Policy) Enabled() bool { return p != nil }
|
|||||||
func (p *Policy) NeedsLogin() bool { return p != nil && p.listener != nil }
|
func (p *Policy) NeedsLogin() bool { return p != nil && p.listener != nil }
|
||||||
|
|
||||||
// Unrestricted reports whether an mtls policy admits any identity the CA
|
// Unrestricted reports whether an mtls policy admits any identity the CA
|
||||||
// vouches for. Under scram the credentials file is the allow list.
|
// vouches for. Under scram the users are the allow list.
|
||||||
func (p *Policy) Unrestricted() bool {
|
func (p *Policy) Unrestricted() bool {
|
||||||
return p != nil && p.method == MethodMTLS && len(p.allow) == 0 && len(p.patterns) == 0
|
return p != nil && p.method == MethodMTLS && len(p.allow) == 0 && len(p.patterns) == 0
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -139,9 +139,10 @@ func TestNewValidation(t *testing.T) {
|
|||||||
{"dialer skips verify", &config.AuthOptions{Type: MethodMTLS}, &tls.Config{InsecureSkipVerify: true}, config.Dialer, TCP, "insecure_skip_verify"},
|
{"dialer skips verify", &config.AuthOptions{Type: MethodMTLS}, &tls.Config{InsecureSkipVerify: true}, config.Dialer, TCP, "insecure_skip_verify"},
|
||||||
{"mtls with a password", &config.AuthOptions{Type: MethodMTLS, PasswordFile: pw}, &tls.Config{}, config.Dialer, TCP, "password_file applies only"},
|
{"mtls with a password", &config.AuthOptions{Type: MethodMTLS, PasswordFile: pw}, &tls.Config{}, config.Dialer, TCP, "password_file applies only"},
|
||||||
{"scram without tls", listener(config.AuthOptions{}), nil, config.Listener, TCP, "requires tls.enabled"},
|
{"scram without tls", listener(config.AuthOptions{}), nil, config.Listener, TCP, "requires tls.enabled"},
|
||||||
{"scram without credentials", scram(config.AuthOptions{}), f.serverTLS, config.Listener, TCP, "requires credentials_file"},
|
{"scram without users", scram(config.AuthOptions{}), f.serverTLS, config.Listener, TCP, "requires credentials_file, or username and password_file"},
|
||||||
{"scram with an allow list", listener(config.AuthOptions{Allow: []string{"edge-01"}}), f.serverTLS, config.Listener, TCP, "credentials file is the allow list"},
|
{"scram with an allow list", listener(config.AuthOptions{Allow: []string{"edge-01"}}), f.serverTLS, config.Listener, TCP, "the users are the allow list"},
|
||||||
{"scram listener with a password", listener(config.AuthOptions{Username: "edge-01"}), f.serverTLS, config.Listener, TCP, "only to dialers"},
|
{"credentials and a user", listener(config.AuthOptions{Username: "edge-01"}), f.serverTLS, config.Listener, TCP, "not both"},
|
||||||
|
{"user without password_file", scram(config.AuthOptions{Username: "edge-01"}), f.serverTLS, config.Listener, TCP, "go together"},
|
||||||
{"scram certificate binding without client_auth", listener(config.AuthOptions{Identity: "cn"}), f.serverTLS, config.Listener, TCP, "requires tls.client_auth"},
|
{"scram certificate binding without client_auth", listener(config.AuthOptions{Identity: "cn"}), f.serverTLS, config.Listener, TCP, "requires tls.client_auth"},
|
||||||
{"token lifetime on tcp", listener(config.AuthOptions{TokenLifetimeMS: 60000}), f.serverTLS, config.Listener, TCP, "only to HTTP listeners"},
|
{"token lifetime on tcp", listener(config.AuthOptions{TokenLifetimeMS: 60000}), f.serverTLS, config.Listener, TCP, "only to HTTP listeners"},
|
||||||
{"token lifetime too short", listener(config.AuthOptions{TokenLifetimeMS: 900}), f.serverTLS, config.Listener, HTTP, "must be from 10000 to 86400000"},
|
{"token lifetime too short", listener(config.AuthOptions{TokenLifetimeMS: 900}), f.serverTLS, config.Listener, HTTP, "must be from 10000 to 86400000"},
|
||||||
@@ -176,9 +177,11 @@ func TestNewValidation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if _, err := New(listener(config.AuthOptions{TokenLifetimeMS: 60000}), f.serverTLS, nil, config.Listener, HTTP); err != nil {
|
for _, o := range []*config.AuthOptions{listener(config.AuthOptions{TokenLifetimeMS: 60000}), scram(config.AuthOptions{Username: "edge-01", PasswordFile: pw})} {
|
||||||
|
if _, err := New(o, f.serverTLS, nil, config.Listener, HTTP); err != nil {
|
||||||
t.Fatalf("scram listener rejected: %v", err)
|
t.Fatalf("scram listener rejected: %v", err)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
// Behind proxies TLS ends at the proxy, so the hop may be plaintext
|
// Behind proxies TLS ends at the proxy, so the hop may be plaintext
|
||||||
if _, err := New(listener(config.AuthOptions{TrustedProxies: proxies}), nil, nil, config.Listener, HTTP); err != nil {
|
if _, err := New(listener(config.AuthOptions{TrustedProxies: proxies}), nil, nil, config.Listener, HTTP); err != nil {
|
||||||
t.Fatalf("proxy-mode listener rejected: %v", err)
|
t.Fatalf("proxy-mode listener rejected: %v", err)
|
||||||
|
|||||||
+158
-6
@@ -16,8 +16,11 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/lixenwraith/logwisp/internal/chain"
|
"github.com/lixenwraith/logwisp/internal/chain"
|
||||||
@@ -27,6 +30,7 @@ import (
|
|||||||
|
|
||||||
"github.com/lixenwraith/auth"
|
"github.com/lixenwraith/auth"
|
||||||
"github.com/lixenwraith/toml"
|
"github.com/lixenwraith/toml"
|
||||||
|
"golang.org/x/term"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -162,10 +166,87 @@ func (c *Credentials) Marshal() ([]byte, error) {
|
|||||||
return append([]byte(header), body...), nil
|
return append([]byte(header), body...), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReadPassword reads a password file, trimming one trailing line break so a
|
// processDecoy is the decoy key of listeners without a credentials file:
|
||||||
// file from an editor and one from `lw auth add-user` agree.
|
// one per process, so reloads keep unknown-user challenges stable
|
||||||
func ReadPassword(path string) (string, error) {
|
var processDecoy = sync.OnceValue(func() []byte { key := make([]byte, 32); rand.Read(key); return key })
|
||||||
|
|
||||||
|
// oneUser is a listener's only user, derived in memory at the dialers' floor
|
||||||
|
// cost, the default outside tests
|
||||||
|
func oneUser(user, path string) (*Credentials, error) {
|
||||||
|
pw, err := ReadPassword(path, user, true)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cred, err := auth.NewCredential(user, pw, auth.WithTime(minArgonTime), auth.WithMemory(minArgonMemory))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("auth: user %q: %w", user, err)
|
||||||
|
}
|
||||||
|
return &Credentials{DecoyKey: processDecoy(), Users: []*auth.Credential{cred}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readOnce keeps what a reload cannot read again, by path and user, and
|
||||||
|
// whether a terminal was asked: one may be asked for several users
|
||||||
|
var readOnce = struct {
|
||||||
|
sync.Mutex
|
||||||
|
m map[[2]string]asked
|
||||||
|
}{m: map[[2]string]asked{}}
|
||||||
|
|
||||||
|
type asked struct {
|
||||||
|
pw string
|
||||||
|
tty bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// startupOver is set once lw's first service is built
|
||||||
|
var startupOver atomic.Bool
|
||||||
|
|
||||||
|
// EndStartup stops reading descriptors, pipes, FIFOs and terminals: at a
|
||||||
|
// reload a console source may be reading the same one, and a read would hold
|
||||||
|
// the reload and its signals. What was read before stays for reloads.
|
||||||
|
func EndStartup() { startupOver.Store(true) }
|
||||||
|
|
||||||
|
// ReadPassword reads user's password from path, trimming one trailing line
|
||||||
|
// break so a file from an editor and one from `lw auth add-user` agree. A
|
||||||
|
// regular file is read each time, for rotation by reload; /dev/fd/N, a pipe,
|
||||||
|
// a FIFO or another device once, before EndStartup; a terminal is asked once,
|
||||||
|
// where generate lets Enter alone draw the password.
|
||||||
|
func ReadPassword(path, user string, generate bool) (string, error) {
|
||||||
|
fd, inherited := descriptor(path)
|
||||||
|
if fi, err := os.Stat(path); !inherited && (err != nil || fi.Mode().IsRegular()) {
|
||||||
data, err := os.ReadFile(path)
|
data, err := os.ReadFile(path)
|
||||||
|
return checkPassword(path, data, err)
|
||||||
|
}
|
||||||
|
readOnce.Lock()
|
||||||
|
defer readOnce.Unlock()
|
||||||
|
key := [2]string{path, user}
|
||||||
|
if a, ok := readOnce.m[key]; ok {
|
||||||
|
return a.pw, nil
|
||||||
|
}
|
||||||
|
if startupOver.Load() {
|
||||||
|
return "", fmt.Errorf("auth: password_file %s is read only at startup; name a regular file or restart lw", path)
|
||||||
|
}
|
||||||
|
for k, a := range readOnce.m {
|
||||||
|
if k[0] == path && !a.tty {
|
||||||
|
return "", fmt.Errorf("auth: password_file %s was read for user %q and holds one password", path, k[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var (
|
||||||
|
data []byte
|
||||||
|
tty bool
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
if inherited {
|
||||||
|
data, err = readDescriptor(fd, path)
|
||||||
|
} else {
|
||||||
|
data, tty, err = readDevice(path, user, generate)
|
||||||
|
}
|
||||||
|
pw, err := checkPassword(path, data, err)
|
||||||
|
if err == nil {
|
||||||
|
readOnce.m[key] = asked{pw, tty}
|
||||||
|
}
|
||||||
|
return pw, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkPassword(path string, data []byte, err error) (string, error) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("auth: password_file: %w", err)
|
return "", fmt.Errorf("auth: password_file: %w", err)
|
||||||
}
|
}
|
||||||
@@ -176,6 +257,70 @@ func ReadPassword(path string) (string, error) {
|
|||||||
return pw, nil
|
return pw, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// descriptor is N of /dev/fd/N, which ReadPassword closes: the path then
|
||||||
|
// names another file. 0-2 stay open, or stdio would go to the next file.
|
||||||
|
func descriptor(path string) (int, bool) {
|
||||||
|
n, ok := strings.CutPrefix(path, "/dev/fd/")
|
||||||
|
fd, err := strconv.Atoi(n)
|
||||||
|
return fd, ok && err == nil && fd > 2
|
||||||
|
}
|
||||||
|
|
||||||
|
// readDescriptor reads an inherited descriptor to its end and closes it,
|
||||||
|
// which needs no fdescfs on FreeBSD. Go opens its own close-on-exec, so a
|
||||||
|
// descriptor with the flag is lw's: reading or closing it would break lw.
|
||||||
|
func readDescriptor(fd int, path string) ([]byte, error) {
|
||||||
|
flags, _, errno := syscall.Syscall(syscall.SYS_FCNTL, uintptr(fd), syscall.F_GETFD, 0)
|
||||||
|
if errno != 0 || flags&syscall.FD_CLOEXEC != 0 {
|
||||||
|
return nil, fmt.Errorf("%s was not passed to lw: redirect it, as in %d<FILE", path, fd)
|
||||||
|
}
|
||||||
|
f := os.NewFile(uintptr(fd), path)
|
||||||
|
defer f.Close()
|
||||||
|
return io.ReadAll(io.LimitReader(f, auth.MaxPasswordLen+3))
|
||||||
|
}
|
||||||
|
|
||||||
|
// readDevice reads a pipe, FIFO or device to its end, or asks when it is a
|
||||||
|
// terminal. The open file decides: on FreeBSD stat calls /dev/stdin a device
|
||||||
|
// whatever it holds.
|
||||||
|
func readDevice(path, user string, generate bool) ([]byte, bool, error) {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if errors.Is(err, syscall.ENXIO) {
|
||||||
|
err = fmt.Errorf("%s: no terminal to ask on; name a password file", path)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
if term.IsTerminal(int(f.Fd())) {
|
||||||
|
data, err := ask(path, user, generate)
|
||||||
|
return data, true, err
|
||||||
|
}
|
||||||
|
data, err := io.ReadAll(io.LimitReader(f, auth.MaxPasswordLen+3))
|
||||||
|
return data, false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ask prompts on the terminal at path without echo. A generated password is
|
||||||
|
// shown there only: logs persist, and stderr is often a file.
|
||||||
|
func ask(path, user string, generate bool) ([]byte, error) {
|
||||||
|
tty, err := os.OpenFile(path, os.O_RDWR, 0)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer tty.Close()
|
||||||
|
hint := ""
|
||||||
|
if generate {
|
||||||
|
hint = " (Enter generates one)"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(tty, "password for %s%s: ", user, hint)
|
||||||
|
data, err := term.ReadPassword(int(tty.Fd()))
|
||||||
|
fmt.Fprintln(tty)
|
||||||
|
if err == nil && len(data) == 0 && generate {
|
||||||
|
pw := rand.Text()
|
||||||
|
fmt.Fprintf(tty, "generated password for %s, shown once: %s\n", user, pw)
|
||||||
|
return []byte(pw), nil
|
||||||
|
}
|
||||||
|
return data, err
|
||||||
|
}
|
||||||
|
|
||||||
// --- Listener ---
|
// --- Listener ---
|
||||||
|
|
||||||
type scramListener struct {
|
type scramListener struct {
|
||||||
@@ -215,7 +360,14 @@ func (p *Policy) compileSCRAM(o *config.AuthOptions, tlsCfg *tls.Config) error {
|
|||||||
cb := sha256.Sum256(tlsCfg.Certificates[0].Certificate[0])
|
cb := sha256.Sum256(tlsCfg.Certificates[0].Certificate[0])
|
||||||
l.cb = cb[:]
|
l.cb = cb[:]
|
||||||
}
|
}
|
||||||
if l.creds, err = LoadCredentials(o.CredentialsFile); err != nil {
|
secret := secretFile{"auth.credentials_file", o.CredentialsFile}
|
||||||
|
if o.CredentialsFile != "" {
|
||||||
|
l.creds, err = LoadCredentials(o.CredentialsFile)
|
||||||
|
} else {
|
||||||
|
secret = secretFile{"auth.password_file", o.PasswordFile}
|
||||||
|
l.creds, err = oneUser(o.Username, o.PasswordFile)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if p.transport == HTTP {
|
if p.transport == HTTP {
|
||||||
@@ -231,7 +383,7 @@ func (p *Policy) compileSCRAM(o *config.AuthOptions, tlsCfg *tls.Config) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
p.listener = l
|
p.listener = l
|
||||||
p.secrets = append(p.secrets, secretFile{"auth.credentials_file", o.CredentialsFile})
|
p.secrets = append(p.secrets, secret)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -543,7 +695,7 @@ type scramDialer struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *Policy) compileSCRAMDialer(o *config.AuthOptions) error {
|
func (p *Policy) compileSCRAMDialer(o *config.AuthOptions) error {
|
||||||
password, err := ReadPassword(o.PasswordFile)
|
password, err := ReadPassword(o.PasswordFile, o.Username, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
"syscall"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -101,7 +102,9 @@ func (f *fixture) pool(t *testing.T) *x509.CertPool {
|
|||||||
|
|
||||||
func (f *fixture) listener(t *testing.T, o config.AuthOptions, tlsCfg *tls.Config, role config.Side, transport Transport) *Policy {
|
func (f *fixture) listener(t *testing.T, o config.AuthOptions, tlsCfg *tls.Config, role config.Side, transport Transport) *Policy {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
o.Type, o.CredentialsFile = MethodSCRAM, f.creds
|
if o.Type = MethodSCRAM; o.Username == "" {
|
||||||
|
o.CredentialsFile = f.creds
|
||||||
|
}
|
||||||
p, err := New(&o, tlsCfg, nil, role, transport)
|
p, err := New(&o, tlsCfg, nil, role, transport)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -348,13 +351,15 @@ func TestCertificateBindsToUser(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// An unknown user's challenge salt comes from the file's decoy key, so it is
|
// An unknown user's challenge salt comes from the decoy key, the file's or,
|
||||||
// the same after a restart and a prober learns nothing from restarts.
|
// for one user without a file, the process's, so it is the same after a
|
||||||
|
// rebuild and a prober learns nothing from restarts or reloads.
|
||||||
func TestUnknownUserSaltSurvivesRestart(t *testing.T) {
|
func TestUnknownUserSaltSurvivesRestart(t *testing.T) {
|
||||||
f := newFixture(t)
|
f := newFixture(t)
|
||||||
|
for _, o := range []config.AuthOptions{{}, {Username: "pipe", PasswordFile: f.write(t, "pipe.pass", "pipe-secret\n")}} {
|
||||||
var salts []string
|
var salts []string
|
||||||
for range 2 {
|
for range 2 {
|
||||||
l := f.listener(t, config.AuthOptions{}, f.serverTLS, config.Listener, TCP)
|
l := f.listener(t, o, f.serverTLS, config.Listener, TCP)
|
||||||
c, err := l.listener.server.Load().ProcessClientFirstMessage("nobody", strings.Repeat("n", 32))
|
c, err := l.listener.server.Load().ProcessClientFirstMessage("nobody", strings.Repeat("n", 32))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -362,7 +367,8 @@ func TestUnknownUserSaltSurvivesRestart(t *testing.T) {
|
|||||||
salts = append(salts, c.Salt)
|
salts = append(salts, c.Salt)
|
||||||
}
|
}
|
||||||
if salts[0] != salts[1] {
|
if salts[0] != salts[1] {
|
||||||
t.Fatalf("unknown user salts differ across restarts: %q, %q", salts[0], salts[1])
|
t.Fatalf("%+v: unknown user salts differ across rebuilds: %q, %q", o, salts[0], salts[1])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -615,15 +621,121 @@ func TestRefusedHellosFreeTheirSlot(t *testing.T) {
|
|||||||
func TestReadPasswordTrimsOneLineBreak(t *testing.T) {
|
func TestReadPasswordTrimsOneLineBreak(t *testing.T) {
|
||||||
f := newFixture(t)
|
f := newFixture(t)
|
||||||
for _, contents := range []string{"secret", "secret\n", "secret\r\n"} {
|
for _, contents := range []string{"secret", "secret\n", "secret\r\n"} {
|
||||||
if pw, err := ReadPassword(f.write(t, "pw", contents)); err != nil || pw != "secret" {
|
if pw, err := ReadPassword(f.write(t, "pw", contents), "edge-01", false); err != nil || pw != "secret" {
|
||||||
t.Errorf("ReadPassword(%q) = %q, %v", contents, pw, err)
|
t.Errorf("ReadPassword(%q) = %q, %v", contents, pw, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if _, err := ReadPassword(f.write(t, "pw", "\n")); err == nil {
|
if _, err := ReadPassword(f.write(t, "pw", "\n"), "edge-01", false); err == nil {
|
||||||
t.Error("an empty password was accepted")
|
t.Error("an empty password was accepted")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A descriptor's password is read once and the descriptor closed: a reload
|
||||||
|
// gets it from memory, and another user naming it is refused.
|
||||||
|
func TestPasswordFromADescriptorSurvivesReload(t *testing.T) {
|
||||||
|
r, w, err := os.Pipe()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
fd, err := syscall.Dup(int(r.Fd())) // not close-on-exec, as a shell passes it
|
||||||
|
r.Close()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { readOnce.Lock(); clear(readOnce.m); readOnce.Unlock() })
|
||||||
|
var before, after syscall.Stat_t
|
||||||
|
syscall.Fstat(fd, &before)
|
||||||
|
w.WriteString("pipe-secret\n")
|
||||||
|
w.Close()
|
||||||
|
path := fmt.Sprintf("/dev/fd/%d", fd)
|
||||||
|
for range 2 {
|
||||||
|
if pw, err := ReadPassword(path, "pipe", true); err != nil || pw != "pipe-secret" {
|
||||||
|
t.Fatalf("ReadPassword(%s) = %q, %v", path, pw, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if syscall.Fstat(fd, &after) == nil && after.Ino == before.Ino {
|
||||||
|
t.Error("the descriptor was left open")
|
||||||
|
}
|
||||||
|
if _, err := ReadPassword(path, "other", false); err == nil || !strings.Contains(err.Error(), `read for user "pipe"`) {
|
||||||
|
t.Errorf("another user of the descriptor: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// After startup a descriptor, pipe or terminal is not read: a console source
|
||||||
|
// may read the same one. One read at startup stays for reloads.
|
||||||
|
func TestOnceReadSourcesAreReadAtStartupOnly(t *testing.T) {
|
||||||
|
r, w, err := os.Pipe()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
fd, err := syscall.Dup(int(r.Fd()))
|
||||||
|
r.Close()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { startupOver.Store(false); readOnce.Lock(); clear(readOnce.m); readOnce.Unlock() })
|
||||||
|
w.WriteString("pipe-secret\n")
|
||||||
|
w.Close()
|
||||||
|
path := fmt.Sprintf("/dev/fd/%d", fd)
|
||||||
|
if _, err := ReadPassword(path, "early", false); err != nil {
|
||||||
|
t.Fatalf("at startup: %v", err)
|
||||||
|
}
|
||||||
|
EndStartup()
|
||||||
|
if _, err := ReadPassword(os.DevNull, "late", false); err == nil || !strings.Contains(err.Error(), "only at startup") {
|
||||||
|
t.Fatalf("a device after startup: %v", err)
|
||||||
|
}
|
||||||
|
if pw, err := ReadPassword(path, "early", false); err != nil || pw != "pipe-secret" {
|
||||||
|
t.Fatalf("a reload of the early user: %q, %v", pw, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A device is classed by the file it opens, not by stat: FreeBSD stats
|
||||||
|
// /dev/stdin as a device whatever it holds, so one that is no terminal is read.
|
||||||
|
func TestADeviceThatIsNoTerminalIsRead(t *testing.T) {
|
||||||
|
if _, err := ReadPassword(os.DevNull, "dev", false); err == nil || !strings.Contains(err.Error(), "password must be") {
|
||||||
|
t.Fatalf("%s: %v; want it read, and empty", os.DevNull, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A /dev/fd/N lw opened itself, close-on-exec as Go opens every file, was
|
||||||
|
// never passed to it: it is refused and left open.
|
||||||
|
func TestOwnDescriptorIsNeverRead(t *testing.T) {
|
||||||
|
f, err := os.Open(os.DevNull)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
if _, err := ReadPassword(fmt.Sprintf("/dev/fd/%d", f.Fd()), "pipe", false); err == nil || !strings.Contains(err.Error(), "not passed to lw") {
|
||||||
|
t.Fatalf("lw's own descriptor: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := f.Stat(); err != nil {
|
||||||
|
t.Fatalf("lw's own descriptor was closed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A listener's one user, from username and password_file, logs in with its
|
||||||
|
// password; another password and an unknown user fail alike.
|
||||||
|
func TestOneUserListenerAdmitsItsPassword(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
o := config.AuthOptions{Username: "pipe", PasswordFile: f.write(t, "pipe.pass", "pipe-secret\n")}
|
||||||
|
l := f.listener(t, o, f.serverTLS, config.ChainListener, TCP)
|
||||||
|
res, _, err := f.connect(t, l, f.serverTLS, f.dialer(t, "pipe", "pipe-secret"), nil)
|
||||||
|
if err != nil || res.err != nil || res.adm.Identity.Name != "pipe" {
|
||||||
|
t.Fatalf("exchange: dialer %v, listener %v", err, res.err)
|
||||||
|
}
|
||||||
|
var seen []string
|
||||||
|
for _, d := range []*Policy{f.dialer(t, "pipe", "edge-01-secret"), f.dialer(t, "edge-01", "pipe-secret")} {
|
||||||
|
res, _, err := f.connect(t, l, f.serverTLS, d, nil)
|
||||||
|
if !errors.Is(err, ErrRefused) || !errors.Is(res.err, ErrRefused) {
|
||||||
|
t.Fatalf("dialer %v, listener %v; want both refused", err, res.err)
|
||||||
|
}
|
||||||
|
seen = append(seen, err.Error())
|
||||||
|
}
|
||||||
|
if seen[0] != seen[1] {
|
||||||
|
t.Fatalf("wrong password and unknown user differ: %q vs %q", seen[0], seen[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// A credentials file is validated whole, and Marshal round-trips. Each case
|
// A credentials file is validated whole, and Marshal round-trips. Each case
|
||||||
// breaks one rule of an otherwise valid file.
|
// breaks one rule of an otherwise valid file.
|
||||||
func TestCredentialsFileValidation(t *testing.T) {
|
func TestCredentialsFileValidation(t *testing.T) {
|
||||||
|
|||||||
@@ -181,13 +181,34 @@ func (c *Composition) pipeline(i int) (*config.PipelineConfig, error) {
|
|||||||
|
|
||||||
// AddPipeline appends an empty pipeline under a name no other has
|
// AddPipeline appends an empty pipeline under a name no other has
|
||||||
func (c *Composition) AddPipeline(name string) error {
|
func (c *Composition) AddPipeline(name string) error {
|
||||||
if name == "" || slices.ContainsFunc(c.Pipelines, func(p config.PipelineConfig) bool { return p.Name == name }) {
|
if err := c.free(name, -1); err != nil {
|
||||||
return fmt.Errorf("pipeline %q: a name no other pipeline has", name)
|
return err
|
||||||
}
|
}
|
||||||
c.Pipelines = append(c.Pipelines, config.PipelineConfig{Name: name, Flow: &config.FlowConfig{}})
|
c.Pipelines = append(c.Pipelines, config.PipelineConfig{Name: name, Flow: &config.FlowConfig{}})
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RenamePipeline gives pipeline i a name no other has
|
||||||
|
func (c *Composition) RenamePipeline(i int, name string) error {
|
||||||
|
p, err := c.pipeline(i)
|
||||||
|
if err == nil {
|
||||||
|
err = c.free(name, i)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
p.Name = name
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// free refuses a name that is empty or held by a pipeline other than i
|
||||||
|
func (c *Composition) free(name string, i int) error {
|
||||||
|
if j := slices.IndexFunc(c.Pipelines, func(p config.PipelineConfig) bool { return p.Name == name }); name == "" || j >= 0 && j != i {
|
||||||
|
return fmt.Errorf("pipeline %q: a name no other pipeline has", name)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Composition) RemovePipeline(i int) error {
|
func (c *Composition) RemovePipeline(i int) error {
|
||||||
if _, err := c.pipeline(i); err != nil {
|
if _, err := c.pipeline(i); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -233,6 +254,52 @@ func (c *Composition) Add(pi int, role, typ string) (Node, error) {
|
|||||||
return n, nil
|
return n, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Retype gives a source or sink another type of its role, in its place: a
|
||||||
|
// chosen id stays and an automatic one follows the type. Options of the same
|
||||||
|
// name and kind carry over to a type on the same network side, where they
|
||||||
|
// mean the same; across sides host, port, tls and auth do not.
|
||||||
|
func (c *Composition) Retype(pi int, n Node, typ string) (Node, error) {
|
||||||
|
p, err := c.pipeline(pi)
|
||||||
|
if err != nil {
|
||||||
|
return Node{}, err
|
||||||
|
}
|
||||||
|
to, ok := config.LookupPlugin(n.Role, typ)
|
||||||
|
if !ok {
|
||||||
|
return Node{}, fmt.Errorf("unknown %s type %q", n.Role, typ)
|
||||||
|
}
|
||||||
|
var id, old *string
|
||||||
|
var opts *map[string]any
|
||||||
|
var ids []string
|
||||||
|
switch i := index(p, n); {
|
||||||
|
case i >= 0 && n.Role == "source":
|
||||||
|
id, old, opts = &p.PluginSources[i].ID, &p.PluginSources[i].Type, &p.PluginSources[i].Config
|
||||||
|
for _, s := range p.PluginSources {
|
||||||
|
ids = append(ids, s.ID)
|
||||||
|
}
|
||||||
|
case i >= 0 && n.Role == "sink":
|
||||||
|
id, old, opts = &p.PluginSinks[i].ID, &p.PluginSinks[i].Type, &p.PluginSinks[i].Config
|
||||||
|
for _, s := range p.PluginSinks {
|
||||||
|
ids = append(ids, s.ID)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return Node{}, n.missing()
|
||||||
|
}
|
||||||
|
from, _ := config.LookupPlugin(n.Role, *old)
|
||||||
|
kept := map[string]any{}
|
||||||
|
for _, k := range from.Keys() {
|
||||||
|
same := func(t config.Key) bool { return t.Name == k.Name && t.Kind == k.Kind }
|
||||||
|
if v, set := (*opts)[k.Name]; set && from.Side == to.Side && slices.ContainsFunc(to.Keys(), same) {
|
||||||
|
kept[k.Name] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
suffix, auto := strings.CutPrefix(*id, *old)
|
||||||
|
if n, err := strconv.Atoi(strings.TrimPrefix(suffix, "_")); auto && (suffix == "" || suffix[0] == '_' && err == nil && n > 1) {
|
||||||
|
*id = config.FreeID(typ, slices.DeleteFunc(ids, func(s string) bool { return s == *id }))
|
||||||
|
}
|
||||||
|
*old, *opts = typ, kept
|
||||||
|
return Node{Role: n.Role, ID: *id}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Remove deletes a source, sink or filter, or turns a flow stage off
|
// Remove deletes a source, sink or filter, or turns a flow stage off
|
||||||
func (c *Composition) Remove(pi int, n Node) error {
|
func (c *Composition) Remove(pi int, n Node) error {
|
||||||
p, err := c.pipeline(pi)
|
p, err := c.pipeline(pi)
|
||||||
@@ -353,22 +420,22 @@ func (c *Composition) edit(pi int, n Node, change func(map[string]any) error) er
|
|||||||
return plugin(p.PluginSinks[i].Type, &p.PluginSinks[i].Config)
|
return plugin(p.PluginSinks[i].Type, &p.PluginSinks[i].Config)
|
||||||
case n.Role == "filters":
|
case n.Role == "filters":
|
||||||
filter := &p.Flow.Filters[i]
|
filter := &p.Flow.Filters[i]
|
||||||
if err := editStage(reflect.ValueOf(&filter).Elem(), change); err != nil {
|
if err := editStage(reflect.ValueOf(&filter).Elem(), "", change); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
p.Flow.Filters[i] = *filter
|
p.Flow.Filters[i] = *filter
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if stage, ok := stages(p.Flow)[n.Role]; ok {
|
if stage, ok := stages(p.Flow)[n.Role]; ok {
|
||||||
return editStage(stage, change)
|
return editStage(stage, n.Role, change)
|
||||||
}
|
}
|
||||||
return n.missing()
|
return n.missing()
|
||||||
}
|
}
|
||||||
|
|
||||||
// editStage edits a stage's keys as a spec sets them; the table they scan into
|
// editStage edits a stage's keys as a spec sets them; the table they scan into
|
||||||
// replaces the stage, which turns on
|
// replaces the stage, which turns on as a spec turns it on
|
||||||
func editStage(stage reflect.Value, change func(map[string]any) error) error {
|
func editStage(stage reflect.Value, key string, change func(map[string]any) error) error {
|
||||||
m := map[string]any{}
|
m := config.StageOn(key, nil)
|
||||||
if !stage.IsNil() {
|
if !stage.IsNil() {
|
||||||
m = config.Values(stage.Interface())
|
m = config.Values(stage.Interface())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package compose
|
package compose
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"reflect"
|
"reflect"
|
||||||
@@ -64,7 +65,7 @@ func TestWrittenFormsStartTheSameComposition(t *testing.T) {
|
|||||||
_, err = c.Add(0, "filters", "include")
|
_, err = c.Add(0, "filters", "include")
|
||||||
must(err)
|
must(err)
|
||||||
must(c.MoveFilter(0, 1, 0))
|
must(c.MoveFilter(0, 1, 0))
|
||||||
must(c.Set(0, Node{Role: "rate_limit"}, "rate", "100"))
|
must(c.Set(0, Node{Role: "rate_limit"}, "entries_per_second", "100"))
|
||||||
must(c.Set(0, Node{Role: "heartbeat"}, "interval_ms", "5000"))
|
must(c.Set(0, Node{Role: "heartbeat"}, "interval_ms", "5000"))
|
||||||
must(c.Set(0, Node{Role: "heartbeat"}, "enabled", "true"))
|
must(c.Set(0, Node{Role: "heartbeat"}, "enabled", "true"))
|
||||||
must(c.AddPipeline("-b"))
|
must(c.AddPipeline("-b"))
|
||||||
@@ -190,3 +191,71 @@ func TestUnnamedPartsTakeFreeIDs(t *testing.T) {
|
|||||||
t.Fatalf("%v\n%s", err, line)
|
t.Fatalf("%v\n%s", err, line)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Retype keeps the part's place and a chosen id, renames an automatic one,
|
||||||
|
// and carries the options of the same name and kind only within a side
|
||||||
|
func TestRetypeKeepsThePlace(t *testing.T) {
|
||||||
|
c := &Composition{}
|
||||||
|
if err := c.AddPipeline("p"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, typ := range []string{"tcp", "http", "tcp"} {
|
||||||
|
if _, err := c.Add(0, "sink", typ); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tcp2, chosen := Node{Role: "sink", ID: "tcp_2"}, Node{Role: "sink", ID: "web"}
|
||||||
|
c.Pipelines[0].PluginSinks[1].ID = chosen.ID
|
||||||
|
for _, err := range []error{c.Set(0, tcp2, "port", "9000"), c.Set(0, tcp2, "keep_alive", "false"), c.Set(0, chosen, "port", "80")} {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
got, err := c.Retype(0, tcp2, "http")
|
||||||
|
if err != nil || got.ID != "http" {
|
||||||
|
t.Fatalf("%+v %v", got, err)
|
||||||
|
}
|
||||||
|
if _, err := c.Retype(0, chosen, "tcp_chain"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var parts []string
|
||||||
|
for _, s := range c.Pipelines[0].PluginSinks {
|
||||||
|
parts = append(parts, fmt.Sprintf("%s=%s %v", s.ID, s.Type, s.Config))
|
||||||
|
}
|
||||||
|
if want := []string{"tcp=tcp map[]", "web=tcp_chain map[]", "http=http map[port:9000]"}; !slices.Equal(parts, want) {
|
||||||
|
t.Fatalf("%q, want %q", parts, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A pipeline takes a name no other has, renamed as when added
|
||||||
|
func TestPipelineNamesAreUnique(t *testing.T) {
|
||||||
|
c := &Composition{}
|
||||||
|
for _, name := range []string{"a", "b"} {
|
||||||
|
if err := c.AddPipeline(name); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c.AddPipeline("a") == nil || c.RenamePipeline(1, "a") == nil || c.RenamePipeline(1, "") == nil {
|
||||||
|
t.Fatal("took a name another pipeline has")
|
||||||
|
}
|
||||||
|
if err := c.RenamePipeline(1, "b"); err != nil || c.RenamePipeline(1, "c") != nil || c.Pipelines[1].Name != "c" {
|
||||||
|
t.Fatalf("%v, %+v", err, c.Pipelines)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A stage an edit turns on does what its spec would: a rate limit drops what
|
||||||
|
// passes its rate, a heartbeat beats
|
||||||
|
func TestAStageTurnsOnAsItsSpecDoes(t *testing.T) {
|
||||||
|
c := &Composition{}
|
||||||
|
if err := c.AddPipeline("p"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, err := range []error{c.Set(0, Node{Role: "rate_limit"}, "entries_per_second", "5"), c.Set(0, Node{Role: "heartbeat"}, "interval_ms", "500")} {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if f := c.Pipelines[0].Flow; f.RateLimit.Policy != "drop" || !f.Heartbeat.Enabled {
|
||||||
|
t.Fatalf("%+v %+v", f.RateLimit, f.Heartbeat)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -103,8 +103,8 @@ const (
|
|||||||
|
|
||||||
// RateLimitConfig defines the configuration for pipeline-level rate limiting
|
// RateLimitConfig defines the configuration for pipeline-level rate limiting
|
||||||
type RateLimitConfig struct {
|
type RateLimitConfig struct {
|
||||||
Rate float64 `toml:"rate" lw:"min=0,zero=off" help:"entries a second"`
|
EntriesPerSecond float64 `toml:"entries_per_second" lw:"min=0,zero=off" help:"entries a second the limit lets through"`
|
||||||
Burst float64 `toml:"burst" lw:"min=0,zero=the rate" help:"entries at once"`
|
BurstEntries float64 `toml:"burst_entries" lw:"min=0,zero=one second's worth" help:"entries let through at once before the rate applies"`
|
||||||
Policy string `toml:"policy" default:"pass" lw:"enum=pass|drop" help:"drop entries over the rate, or pass them"`
|
Policy string `toml:"policy" default:"pass" lw:"enum=pass|drop" help:"drop entries over the rate, or pass them"`
|
||||||
MaxEntrySizeBytes int64 `toml:"max_entry_size_bytes" lw:"min=0,zero=unlimited" help:"drop larger entries"`
|
MaxEntrySizeBytes int64 `toml:"max_entry_size_bytes" lw:"min=0,zero=unlimited" help:"drop larger entries"`
|
||||||
}
|
}
|
||||||
@@ -262,8 +262,8 @@ type HTTPSinkOptions struct {
|
|||||||
WriteTimeoutMS int64 `toml:"write_timeout_ms" lw:"min=0,zero=none" help:"milliseconds an event write may take"`
|
WriteTimeoutMS int64 `toml:"write_timeout_ms" lw:"min=0,zero=none" help:"milliseconds an event write may take"`
|
||||||
MaxConnections int64 `toml:"max_connections" lw:"min=0,zero=unlimited" help:"clients at once"`
|
MaxConnections int64 `toml:"max_connections" lw:"min=0,zero=unlimited" help:"clients at once"`
|
||||||
ReplayLines int64 `toml:"replay_lines" lw:"min=0,zero=none" help:"recent entries a new stream gets first"`
|
ReplayLines int64 `toml:"replay_lines" lw:"min=0,zero=none" help:"recent entries a new stream gets first"`
|
||||||
LoginPage bool `toml:"login_page" help:"serve the login page; scram behind auth.trusted_proxies"`
|
LoginPage bool `toml:"login_page" help:"scram behind auth.trusted_proxies only: a login page at /auth/login"`
|
||||||
ViewerPage bool `toml:"viewer_page" help:"serve the viewer to users logged in; needs login_page"`
|
ViewerPage bool `toml:"viewer_page" help:"scram behind auth.trusted_proxies only: the viewer after a login; without scram it is always at /"`
|
||||||
Auth *AuthOptions `toml:"auth"`
|
Auth *AuthOptions `toml:"auth"`
|
||||||
ACL *ACLOptions `toml:"acl"`
|
ACL *ACLOptions `toml:"acl"`
|
||||||
}
|
}
|
||||||
@@ -307,9 +307,9 @@ type HTTPChainSinkOptions struct {
|
|||||||
|
|
||||||
// AuthOptions selects how a network plugin authenticates its peer. It sits
|
// AuthOptions selects how a network plugin authenticates its peer. It sits
|
||||||
// beside `tls`: TLS answers "is this channel private", auth answers "may this
|
// beside `tls`: TLS answers "is this channel private", auth answers "may this
|
||||||
// peer do this". Listeners verify (mtls: certificate identity; scram: password
|
// peer do this". Listeners verify (mtls: certificate identity; scram: the
|
||||||
// via credentials_file); dialers prove themselves (scram) or pin the server
|
// credentials_file's users, or one username and password_file); dialers prove
|
||||||
// (mtls). AuthOptions.Check holds the rules of each side.
|
// themselves (scram) or pin the server (mtls). Check holds each side's rules.
|
||||||
type AuthOptions struct {
|
type AuthOptions struct {
|
||||||
Type string `toml:"type" default:"none" lw:"enum=none|mtls|scram" help:"how peers authenticate"`
|
Type string `toml:"type" default:"none" lw:"enum=none|mtls|scram" help:"how peers authenticate"`
|
||||||
Identity string `toml:"identity" lw:"enum=cn|san_dns|san_uri|san_email,zero=cn under mtls; no binding under scram" help:"certificate field naming the peer; under scram it must equal the user"`
|
Identity string `toml:"identity" lw:"enum=cn|san_dns|san_uri|san_email,zero=cn under mtls; no binding under scram" help:"certificate field naming the peer; under scram it must equal the user"`
|
||||||
@@ -321,8 +321,8 @@ type AuthOptions struct {
|
|||||||
// leaves room for renewal ahead of expiry
|
// leaves room for renewal ahead of expiry
|
||||||
TokenLifetimeMS int64 `toml:"token_lifetime_ms" lw:"listener,min=10000,max=86400000,zero=15 minutes" help:"scram on HTTP: bearer token lifetime"`
|
TokenLifetimeMS int64 `toml:"token_lifetime_ms" lw:"listener,min=10000,max=86400000,zero=15 minutes" help:"scram on HTTP: bearer token lifetime"`
|
||||||
TrustedProxies []string `toml:"trusted_proxies" lw:"listener,hint=cidr" help:"scram on the http sink: proxies ending the browsers' TLS; logins are then unbound"`
|
TrustedProxies []string `toml:"trusted_proxies" lw:"listener,hint=cidr" help:"scram on the http sink: proxies ending the browsers' TLS; logins are then unbound"`
|
||||||
Username string `toml:"username" lw:"dialer" help:"scram: the user this dialer logs in as"`
|
Username string `toml:"username" help:"scram: the user a dialer logs in as, or a listener's one user"`
|
||||||
PasswordFile string `toml:"password_file" lw:"dialer,hint=file" help:"scram: file holding the user's password"`
|
PasswordFile string `toml:"password_file" lw:"hint=file" help:"scram: file holding the password; /dev/fd/N or a pipe is read once, /dev/tty asks"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ACLOptions is a listener's address rules, entries being addresses or CIDRs:
|
// ACLOptions is a listener's address rules, entries being addresses or CIDRs:
|
||||||
|
|||||||
@@ -340,7 +340,7 @@ func TestFileStringIsOneListEntry(t *testing.T) {
|
|||||||
// its own; other sinks do not take the key.
|
// its own; other sinks do not take the key.
|
||||||
func TestTopLevelColorIsEachConsoleSinksDefault(t *testing.T) {
|
func TestTopLevelColorIsEachConsoleSinksDefault(t *testing.T) {
|
||||||
isolateConfig(t)
|
isolateConfig(t)
|
||||||
m, err := Load(Args{Specs: specs("sink", "console", "sink", "console,color=never", "sink", "null"), Overrides: []string{"--color=always"}})
|
m, err := Load(Args{Specs: specs("sink", "console", "sink", "console:color=never", "sink", "null"), Overrides: []string{"--color=always"}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
+48
-21
@@ -13,7 +13,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Preset is a pipeline built from a few keys: --preset NAME,key=value starts
|
// Preset is a pipeline built from a few keys: --preset NAME:key=value starts
|
||||||
// a pipeline with it. Later flags add sources, sinks and filters, or replace
|
// a pipeline with it. Later flags add sources, sinks and filters, or replace
|
||||||
// its format, rate limit and heartbeat.
|
// its format, rate limit and heartbeat.
|
||||||
type Preset struct {
|
type Preset struct {
|
||||||
@@ -64,6 +64,10 @@ var (
|
|||||||
{Name: "deny", List: true, Help: "addresses or CIDRs refused, allow or not; ',' between them"},
|
{Name: "deny", List: true, Help: "addresses or CIDRs refused, allow or not; ',' between them"},
|
||||||
}
|
}
|
||||||
transportParam = PresetParam{Name: "transport", Default: "tcp", Help: "tcp|http: tcp_chain or http_chain"}
|
transportParam = PresetParam{Name: "transport", Default: "tcp", Help: "tcp|http: tcp_chain or http_chain"}
|
||||||
|
userParams = []PresetParam{
|
||||||
|
{Name: "user", Help: "SCRAM user: an edge's login, or a listener's only user in place of users"},
|
||||||
|
{Name: "password_file", Help: "file holding user's password; /dev/fd/N or a pipe is read once; default: /dev/tty, which asks"},
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
var presets = []Preset{
|
var presets = []Preset{
|
||||||
@@ -88,8 +92,9 @@ var presets = []Preset{
|
|||||||
{Name: "listen", Default: "127.0.0.1:8080", Help: "HOST:PORT; [IPV6]:PORT"},
|
{Name: "listen", Default: "127.0.0.1:8080", Help: "HOST:PORT; [IPV6]:PORT"},
|
||||||
{Name: "tls", Default: "off", Help: "off|self|issuer|files: a certificate made at startup (self-signed or from issuer_*), or files"},
|
{Name: "tls", Default: "off", Help: "off|self|issuer|files: a certificate made at startup (self-signed or from issuer_*), or files"},
|
||||||
{Name: "users", Help: "credentials file (lw auth add-user): readers log in with SCRAM"},
|
{Name: "users", Help: "credentials file (lw auth add-user): readers log in with SCRAM"},
|
||||||
|
}, userParams, []PresetParam{
|
||||||
{Name: "proxy", List: true, Help: "addresses or CIDRs of the TLS-terminating proxies browsers come through; ',' between them"},
|
{Name: "proxy", List: true, Help: "addresses or CIDRs of the TLS-terminating proxies browsers come through; ',' between them"},
|
||||||
{Name: "viewer", Default: "false", Help: "true: the login page and viewer for users, behind proxy; without users the viewer is always on"},
|
{Name: "viewer", Default: "false", Help: "true: the login page and viewer, behind proxy; without user or users the viewer is always on"},
|
||||||
}, aclParams, tlsParams),
|
}, aclParams, tlsParams),
|
||||||
func(p *PipelineConfig, v map[string]string, isDir IsDir) error {
|
func(p *PipelineConfig, v map[string]string, isDir IsDir) error {
|
||||||
sources, err := pathOrStdin(v, isDir)
|
sources, err := pathOrStdin(v, isDir)
|
||||||
@@ -101,14 +106,18 @@ var presets = []Preset{
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if v["users"] != "" {
|
auth, err := scramAuth(v)
|
||||||
sink["auth"] = map[string]any{"type": "scram", "credentials_file": v["users"]}
|
switch {
|
||||||
|
case err != nil:
|
||||||
|
return err
|
||||||
|
case auth != nil && v["tls"] == "off" && v["proxy"] == "":
|
||||||
|
return fmt.Errorf("user and users need tls (self, issuer or files), or proxy")
|
||||||
|
case auth != nil:
|
||||||
|
sink["auth"] = auth
|
||||||
|
case v["proxy"] != "":
|
||||||
|
return fmt.Errorf("proxy needs user or users: browsers log in with SCRAM")
|
||||||
}
|
}
|
||||||
if v["proxy"] != "" {
|
if v["proxy"] != "" {
|
||||||
auth, ok := sink["auth"].(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("proxy needs users: browsers log in with SCRAM")
|
|
||||||
}
|
|
||||||
auth["trusted_proxies"] = list(v["proxy"])
|
auth["trusted_proxies"] = list(v["proxy"])
|
||||||
}
|
}
|
||||||
switch v["viewer"] {
|
switch v["viewer"] {
|
||||||
@@ -127,18 +136,17 @@ var presets = []Preset{
|
|||||||
return nil
|
return nil
|
||||||
}},
|
}},
|
||||||
{"edge", "Forward files or standard input to an aggregator over TLS, authenticated",
|
{"edge", "Forward files or standard input to an aggregator over TLS, authenticated",
|
||||||
[]PresetParam{pathParam, fromParam,
|
slices.Concat([]PresetParam{pathParam, fromParam,
|
||||||
{Name: "to", Required: true, Help: "the aggregator's HOST:PORT; [IPV6]:PORT"},
|
{Name: "to", Required: true, Help: "the aggregator's HOST:PORT; [IPV6]:PORT"},
|
||||||
transportParam,
|
transportParam,
|
||||||
{Name: "ca", Help: "CA file that verifies the aggregator; default: system roots"},
|
{Name: "ca", Help: "CA file that verifies the aggregator; default: system roots"},
|
||||||
{Name: "pin", Help: "sha256//BASE64 of the aggregator's key, which it logs with tls=self"},
|
{Name: "pin", Help: "sha256//BASE64 of the aggregator's key, which it logs with tls=self"},
|
||||||
{Name: "server_name", Help: "name the aggregator's certificate carries; default: to's host"},
|
{Name: "server_name", Help: "name the aggregator's certificate carries; default: to's host"},
|
||||||
{Name: "user", Help: "SCRAM user (lw auth add-user on the aggregator)"},
|
}, userParams, []PresetParam{
|
||||||
{Name: "password_file", Help: "file holding the SCRAM password"},
|
|
||||||
{Name: "cert", Help: "client certificate file, for an aggregator with client_ca"},
|
{Name: "cert", Help: "client certificate file, for an aggregator with client_ca"},
|
||||||
{Name: "key", Help: "client key file"},
|
{Name: "key", Help: "client key file"},
|
||||||
{Name: "node", Help: "origin label; default: this host's name"},
|
{Name: "node", Help: "origin label; default: this host's name"},
|
||||||
},
|
}),
|
||||||
func(p *PipelineConfig, v map[string]string, isDir IsDir) error {
|
func(p *PipelineConfig, v map[string]string, isDir IsDir) error {
|
||||||
sources, err := pathOrStdin(v, isDir)
|
sources, err := pathOrStdin(v, isDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -159,16 +167,15 @@ var presets = []Preset{
|
|||||||
tls[option] = v[key]
|
tls[option] = v[key]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var auth map[string]any
|
auth, err := scramAuth(v)
|
||||||
switch {
|
switch {
|
||||||
case v["user"] != "" && v["password_file"] != "":
|
case err != nil:
|
||||||
auth = map[string]any{"type": "scram", "username": v["user"], "password_file": v["password_file"]}
|
return err
|
||||||
case v["user"] != "" || v["password_file"] != "":
|
case auth != nil:
|
||||||
return fmt.Errorf("user and password_file go together")
|
|
||||||
case v["cert"] != "" && v["key"] != "":
|
case v["cert"] != "" && v["key"] != "":
|
||||||
auth = map[string]any{"type": "mtls"}
|
auth = map[string]any{"type": "mtls"}
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("edge needs user and password_file, or cert and key: it never sends unauthenticated")
|
return fmt.Errorf("edge needs user, or cert and key: it never sends unauthenticated")
|
||||||
}
|
}
|
||||||
sink := map[string]any{"host": host, "port": port, "tls": tls, "auth": auth}
|
sink := map[string]any{"host": host, "port": port, "tls": tls, "auth": auth}
|
||||||
if v["node"] != "" {
|
if v["node"] != "" {
|
||||||
@@ -183,6 +190,7 @@ var presets = []Preset{
|
|||||||
transportParam,
|
transportParam,
|
||||||
{Name: "tls", Default: "self", Help: "self|issuer|files: a certificate made at startup (self-signed or from issuer_*), or files"},
|
{Name: "tls", Default: "self", Help: "self|issuer|files: a certificate made at startup (self-signed or from issuer_*), or files"},
|
||||||
{Name: "users", Help: "credentials file (lw auth add-user): edges log in with SCRAM"},
|
{Name: "users", Help: "credentials file (lw auth add-user): edges log in with SCRAM"},
|
||||||
|
}, userParams, []PresetParam{
|
||||||
{Name: "client_ca", Help: "CA file that verifies edge certificates (mTLS)"},
|
{Name: "client_ca", Help: "CA file that verifies edge certificates (mTLS)"},
|
||||||
{Name: "out", Help: "directory for the received entries; default: standard output"},
|
{Name: "out", Help: "directory for the received entries; default: standard output"},
|
||||||
formatParam("json"),
|
formatParam("json"),
|
||||||
@@ -203,13 +211,16 @@ var presets = []Preset{
|
|||||||
tls := src["tls"].(map[string]any)
|
tls := src["tls"].(map[string]any)
|
||||||
tls["client_auth"], tls["client_ca_file"] = true, v["client_ca"]
|
tls["client_auth"], tls["client_ca_file"] = true, v["client_ca"]
|
||||||
}
|
}
|
||||||
|
auth, err := scramAuth(v)
|
||||||
switch {
|
switch {
|
||||||
case v["users"] != "":
|
case err != nil:
|
||||||
src["auth"] = map[string]any{"type": "scram", "credentials_file": v["users"]}
|
return err
|
||||||
|
case auth != nil:
|
||||||
|
src["auth"] = auth
|
||||||
case v["client_ca"] != "":
|
case v["client_ca"] != "":
|
||||||
src["auth"] = map[string]any{"type": "mtls"}
|
src["auth"] = map[string]any{"type": "mtls"}
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("aggregator needs users or client_ca: it never receives unauthenticated")
|
return fmt.Errorf("aggregator needs user, users or client_ca: it never receives unauthenticated")
|
||||||
}
|
}
|
||||||
p.PluginSources = append(p.PluginSources, PluginSourceConfig{ID: "edges", Type: typ, Config: src})
|
p.PluginSources = append(p.PluginSources, PluginSourceConfig{ID: "edges", Type: typ, Config: src})
|
||||||
p.PluginSinks = pipeDefault("").PluginSinks
|
p.PluginSinks = pipeDefault("").PluginSinks
|
||||||
@@ -356,6 +367,22 @@ func listener(v map[string]string, def string) (map[string]any, error) {
|
|||||||
return opts, nil
|
return opts, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// scramAuth is the scram table of user, whose password the terminal asks for
|
||||||
|
// unless password_file names it, or of a listener's users file; nil without
|
||||||
|
func scramAuth(v map[string]string) (map[string]any, error) {
|
||||||
|
switch {
|
||||||
|
case v["user"] != "" && v["users"] != "":
|
||||||
|
return nil, fmt.Errorf("user or users, not both")
|
||||||
|
case v["user"] != "":
|
||||||
|
return map[string]any{"type": "scram", "username": v["user"], "password_file": cmp.Or(v["password_file"], "/dev/tty")}, nil
|
||||||
|
case v["password_file"] != "":
|
||||||
|
return nil, fmt.Errorf("password_file needs user")
|
||||||
|
case v["users"] != "":
|
||||||
|
return map[string]any{"type": "scram", "credentials_file": v["users"]}, nil
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
func splitAddr(key, addr string) (string, int64, error) {
|
func splitAddr(key, addr string) (string, int64, error) {
|
||||||
host, p, err := net.SplitHostPort(addr)
|
host, p, err := net.SplitHostPort(addr)
|
||||||
port, perr := strconv.ParseUint(p, 10, 16)
|
port, perr := strconv.ParseUint(p, 10, 16)
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
// sinks add to its own, a later format replaces its own.
|
// sinks add to its own, a later format replaces its own.
|
||||||
func TestPresetStartsAndNamesItsPipeline(t *testing.T) {
|
func TestPresetStartsAndNamesItsPipeline(t *testing.T) {
|
||||||
isolateConfig(t)
|
isolateConfig(t)
|
||||||
got := loadPipelines(t, "preset", "tail,path=/var/log/app.log", "sink", "null", "format", "json",
|
got := loadPipelines(t, "preset", "tail:path=/var/log/app.log", "sink", "null", "format", "json",
|
||||||
"pipeline", "mine", "preset", "pipe")
|
"pipeline", "mine", "preset", "pipe")
|
||||||
want := []PipelineConfig{{
|
want := []PipelineConfig{{
|
||||||
Name: "tail",
|
Name: "tail",
|
||||||
@@ -41,20 +41,23 @@ func TestPresetKeysAreChecked(t *testing.T) {
|
|||||||
isolateConfig(t)
|
isolateConfig(t)
|
||||||
for spec, want := range map[string]string{
|
for spec, want := range map[string]string{
|
||||||
"nope": `no preset "nope" (valid: pipe, tail, serve, edge, aggregator)`,
|
"nope": `no preset "nope" (valid: pipe, tail, serve, edge, aggregator)`,
|
||||||
"tail,path=x,colour=1": `preset tail has no key "colour" (valid: path, from, format)`,
|
"tail:path=x,colour=1": `preset tail has no key "colour" (valid: path, from, format)`,
|
||||||
"tail": "preset tail needs path",
|
"tail": "preset tail needs path",
|
||||||
"tail,path=x,format.type=json": "preset keys do not nest",
|
"tail:path=x,format.type=json": "preset keys do not nest",
|
||||||
"tail,path=x,path=y": `key "path" takes one value`,
|
"tail:path=x,path=y": `key "path" takes one value`,
|
||||||
"serve,listen=8080": `listen "8080": want HOST:PORT`,
|
"serve:listen=8080": `listen "8080": want HOST:PORT`,
|
||||||
"serve,tls=self,cert=c": "cert does not apply to tls=self",
|
"serve:tls=self,cert=c": "cert does not apply to tls=self",
|
||||||
"serve,tls=files,cert=c": "tls=files needs key",
|
"serve:tls=files,cert=c": "tls=files needs key",
|
||||||
"serve,viewer=yes": `viewer "yes"`,
|
"serve:viewer=yes": `viewer "yes"`,
|
||||||
"serve,proxy=127.0.0.1": "proxy needs users",
|
"serve:proxy=127.0.0.1": "proxy needs user or users",
|
||||||
"serve,viewer=true": "viewer=true needs proxy",
|
"serve:viewer=true": "viewer=true needs proxy",
|
||||||
"edge,to=agg:9000": "it never sends unauthenticated",
|
"serve:user=u": "user and users need tls (self, issuer or files), or proxy",
|
||||||
"edge,to=agg:9000,user=u": "user and password_file go together",
|
"serve:tls=self,user=u,users=f": "user or users, not both",
|
||||||
"edge,to=agg:9000,user=u,password_file=p,transport=udp": `transport "udp"`,
|
"edge:to=agg:9000": "it never sends unauthenticated",
|
||||||
"aggregator,users=u,tls=off": "the aggregator always serves TLS",
|
"edge:to=agg:9000,password_file=p": "password_file needs user",
|
||||||
|
"edge:to=agg:9000,user=u,password_file=p,transport=udp": `transport "udp"`,
|
||||||
|
"aggregator:users=u,tls=off": "the aggregator always serves TLS",
|
||||||
|
"aggregator:user=u,users=f": "user or users, not both",
|
||||||
"aggregator": "it never receives unauthenticated",
|
"aggregator": "it never receives unauthenticated",
|
||||||
} {
|
} {
|
||||||
_, err := Load(Args{Specs: specs("preset", spec)})
|
_, err := Load(Args{Specs: specs("preset", spec)})
|
||||||
@@ -64,6 +67,17 @@ func TestPresetKeysAreChecked(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A preset user without password_file is asked for on the terminal
|
||||||
|
func TestPresetUserWithoutPasswordFileAsks(t *testing.T) {
|
||||||
|
p, err := ExpandPreset("edge", map[string]string{"to": "agg:9000", "user": "u"}, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if auth := p.PluginSinks[0].Config["auth"].(map[string]any); auth["password_file"] != "/dev/tty" {
|
||||||
|
t.Fatalf("auth = %v, want password_file /dev/tty", auth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// A preset path is what isDir answers, a directory named with brackets too;
|
// A preset path is what isDir answers, a directory named with brackets too;
|
||||||
// without isDir, off the target host, it is a directory when it ends in '/',
|
// without isDir, off the target host, it is a directory when it ends in '/',
|
||||||
// a pattern when it holds a glob, and otherwise ErrPathKind asks
|
// a pattern when it holds a glob, and otherwise ErrPathKind asks
|
||||||
|
|||||||
@@ -117,8 +117,8 @@ func TestValidateConfigNamesThePath(t *testing.T) {
|
|||||||
c.Pipelines[0].PluginSinks[0] = PluginSinkConfig{ID: "up", Type: "tcp_chain", Config: map[string]any{"host": "h", "port": int64(1),
|
c.Pipelines[0].PluginSinks[0] = PluginSinkConfig{ID: "up", Type: "tcp_chain", Config: map[string]any{"host": "h", "port": int64(1),
|
||||||
"tls": map[string]any{"enabled": true, "insecure_skip_verify": true}, "auth": map[string]any{"type": "mtls"}}}
|
"tls": map[string]any{"enabled": true, "insecure_skip_verify": true}, "auth": map[string]any{"type": "mtls"}}}
|
||||||
},
|
},
|
||||||
"pipelines[0].flow.rate_limit.rate: must be a finite number": func(c *Config) {
|
"pipelines[0].flow.rate_limit.entries_per_second: must be a finite number": func(c *Config) {
|
||||||
c.Pipelines[0].Flow.RateLimit = &RateLimitConfig{Rate: math.Inf(1)}
|
c.Pipelines[0].Flow.RateLimit = &RateLimitConfig{EntriesPerSecond: math.Inf(1)}
|
||||||
},
|
},
|
||||||
"color: must be one of auto, always, never, got \"\"": func(c *Config) {
|
"color: must be one of auto, always, never, got \"\"": func(c *Config) {
|
||||||
c.Color = ""
|
c.Color = ""
|
||||||
|
|||||||
+33
-17
@@ -93,10 +93,9 @@ var specKinds = []specKind{
|
|||||||
Fill(&format)
|
Fill(&format)
|
||||||
return []string{typedSpec(&format)}
|
return []string{typedSpec(&format)}
|
||||||
}},
|
}},
|
||||||
// Naming a stage turns it on: the file defaults (pass, disabled) would not.
|
|
||||||
{"rate-limit", false, false, false, func(p *PipelineConfig, _ string, opts map[string]any, _ IsDir) error {
|
{"rate-limit", false, false, false, func(p *PipelineConfig, _ string, opts map[string]any, _ IsDir) error {
|
||||||
p.Flow.RateLimit = &RateLimitConfig{Policy: "drop"}
|
p.Flow.RateLimit = &RateLimitConfig{}
|
||||||
return Scan(opts, p.Flow.RateLimit)
|
return Scan(StageOn("rate_limit", opts), p.Flow.RateLimit)
|
||||||
}, func(p *PipelineConfig) []string {
|
}, func(p *PipelineConfig) []string {
|
||||||
if p.Flow.RateLimit == nil {
|
if p.Flow.RateLimit == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -106,8 +105,8 @@ var specKinds = []specKind{
|
|||||||
return []string{formatSpec("", Values(&rate))}
|
return []string{formatSpec("", Values(&rate))}
|
||||||
}},
|
}},
|
||||||
{"heartbeat", false, false, false, func(p *PipelineConfig, _ string, opts map[string]any, _ IsDir) error {
|
{"heartbeat", false, false, false, func(p *PipelineConfig, _ string, opts map[string]any, _ IsDir) error {
|
||||||
p.Flow.Heartbeat = &HeartbeatConfig{Enabled: true}
|
p.Flow.Heartbeat = &HeartbeatConfig{}
|
||||||
return Scan(opts, p.Flow.Heartbeat)
|
return Scan(StageOn("heartbeat", opts), p.Flow.Heartbeat)
|
||||||
}, func(p *PipelineConfig) []string {
|
}, func(p *PipelineConfig) []string {
|
||||||
if p.Flow.Heartbeat == nil {
|
if p.Flow.Heartbeat == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -127,6 +126,18 @@ type pipelineSpec struct {
|
|||||||
value string
|
value string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// stageOn are the values naming a flow stage sets, in a spec or an engine
|
||||||
|
// edit: the file's defaults (pass, disabled) would leave it doing nothing
|
||||||
|
var stageOn = map[string]map[string]any{"rate_limit": {"policy": "drop"}, "heartbeat": {"enabled": true}}
|
||||||
|
|
||||||
|
// StageOn returns opts over the values that turn a flow stage on
|
||||||
|
func StageOn(stage string, opts map[string]any) map[string]any {
|
||||||
|
on := map[string]any{}
|
||||||
|
maps.Copy(on, stageOn[stage])
|
||||||
|
maps.Copy(on, opts)
|
||||||
|
return on
|
||||||
|
}
|
||||||
|
|
||||||
// Spec is one pipeline flag: its name without dashes, and its value.
|
// Spec is one pipeline flag: its name without dashes, and its value.
|
||||||
type Spec struct{ Flag, Value string }
|
type Spec struct{ Flag, Value string }
|
||||||
|
|
||||||
@@ -282,13 +293,10 @@ func typedSpec(stage any) string {
|
|||||||
return formatSpec(typ, m)
|
return formatSpec(typ, m)
|
||||||
}
|
}
|
||||||
|
|
||||||
// formatSpec writes [TYPE,]key=value,...: tables as dotted keys, lists as a
|
// formatSpec writes TYPE[:key=value,...] or key=value,...: tables as dotted
|
||||||
// repeated key
|
// keys, lists as a repeated key
|
||||||
func formatSpec(typ string, tables ...map[string]any) string {
|
func formatSpec(typ string, tables ...map[string]any) string {
|
||||||
var parts []string
|
var parts []string
|
||||||
if typ != "" {
|
|
||||||
parts = append(parts, escape(typ))
|
|
||||||
}
|
|
||||||
var add func(prefix string, m map[string]any)
|
var add func(prefix string, m map[string]any)
|
||||||
add = func(prefix string, m map[string]any) {
|
add = func(prefix string, m map[string]any) {
|
||||||
for _, k := range slices.Sorted(maps.Keys(m)) {
|
for _, k := range slices.Sorted(maps.Keys(m)) {
|
||||||
@@ -307,7 +315,11 @@ func formatSpec(typ string, tables ...map[string]any) string {
|
|||||||
for _, m := range tables {
|
for _, m := range tables {
|
||||||
add("", m)
|
add("", m)
|
||||||
}
|
}
|
||||||
return strings.Join(parts, ",")
|
opts := strings.Join(parts, ",")
|
||||||
|
if typ == "" || opts == "" {
|
||||||
|
return typ + opts
|
||||||
|
}
|
||||||
|
return typ + ":" + opts
|
||||||
}
|
}
|
||||||
|
|
||||||
func text(v any) string {
|
func text(v any) string {
|
||||||
@@ -365,16 +377,20 @@ func useStdio(p *PipelineConfig) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseSpec reads [TYPE,]key=value,...: dotted keys nest, a repeated key makes
|
// parseSpec reads TYPE[:key=value,...] or key=value,...: TYPE ends at the
|
||||||
// a list, and a backslash escapes ',', '=' and '\'. Errors name the part.
|
// first ':', 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) {
|
func parseSpec(spec string, typed bool) (typ string, opts map[string]any, err error) {
|
||||||
rest, more := spec, true
|
rest, more := spec, true
|
||||||
if typed {
|
if typed {
|
||||||
typ, rest, more = cutUnescaped(spec, ',')
|
typ, rest, _ = strings.Cut(spec, ":")
|
||||||
if _, _, eq := cutUnescaped(typ, '='); typ == "" || eq {
|
more = rest != ""
|
||||||
|
if head, _, comma := cutUnescaped(typ, ','); comma && !strings.Contains(head, "=") {
|
||||||
|
return "", nil, fmt.Errorf("%s: TYPE ends at ':', as in %s:%s", spec, head, spec[len(head)+1:])
|
||||||
|
}
|
||||||
|
if typ == "" || strings.ContainsAny(typ, "=,\\") {
|
||||||
return "", nil, fmt.Errorf("%s: missing TYPE", spec)
|
return "", nil, fmt.Errorf("%s: missing TYPE", spec)
|
||||||
}
|
}
|
||||||
typ = unescape(typ)
|
|
||||||
}
|
}
|
||||||
opts = map[string]any{}
|
opts = map[string]any{}
|
||||||
for more {
|
for more {
|
||||||
@@ -390,7 +406,7 @@ func parseSpec(spec string, typed bool) (typ string, opts map[string]any, err er
|
|||||||
err = SetOption(opts, key, unescape(value))
|
err = SetOption(opts, key, unescape(value))
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", nil, fmt.Errorf("%s: %w", strings.TrimPrefix(typ+","+part, ","), err)
|
return "", nil, fmt.Errorf("%s: %w", strings.TrimPrefix(typ+":"+part, ":"), err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return typ, opts, nil
|
return typ, opts, nil
|
||||||
|
|||||||
@@ -36,13 +36,13 @@ func loadPipelines(t *testing.T, pairs ...string) []PipelineConfig {
|
|||||||
func TestPipelineSpecGrammar(t *testing.T) {
|
func TestPipelineSpecGrammar(t *testing.T) {
|
||||||
isolateConfig(t)
|
isolateConfig(t)
|
||||||
got := loadPipelines(t,
|
got := loadPipelines(t,
|
||||||
"source", `file,directory=/var/log/a\,b,pattern=*.log`,
|
"source", `file:directory=/var/log/a\,b,pattern=*.log`,
|
||||||
"source", `file,id=app,directory=x\=y`,
|
"source", `file:id=app,directory=x\=y`,
|
||||||
"source", `file,directory=c:\\logs\\`,
|
"source", `file:directory=c:\\logs\\`,
|
||||||
"sink", "http,port=8080,auth.type=scram,auth.credentials_file=users.toml,tls.enabled=true,tls.cert_file=c,tls.key_file=k",
|
"sink", "http:port=8080,auth.type=scram,auth.credentials_file=users.toml,tls.enabled=true,tls.cert_file=c,tls.key_file=k",
|
||||||
"filter", "include,patterns=ERROR,patterns=WARN",
|
"filter", "include:patterns=ERROR,patterns=WARN",
|
||||||
"filter", `exclude,patterns=\d{1\,3}`,
|
"filter", `exclude:patterns=\d{1\,3}`,
|
||||||
"format", `txt,timestamp_format=Jan 2\, 2006`,
|
"format", `txt:timestamp_format=Jan 2\, 2006`,
|
||||||
"pipeline", "b", "source", "null", "sink", "null", "sink", "null", "heartbeat", "interval_ms=1000")
|
"pipeline", "b", "source", "null", "sink", "null", "sink", "null", "heartbeat", "interval_ms=1000")
|
||||||
want := []PipelineConfig{{
|
want := []PipelineConfig{{
|
||||||
Name: "cli",
|
Name: "cli",
|
||||||
@@ -76,13 +76,14 @@ func TestPipelineSpecGrammar(t *testing.T) {
|
|||||||
t.Fatalf("pipelines:\n got %+v\nwant %+v", got, want)
|
t.Fatalf("pipelines:\n got %+v\nwant %+v", got, want)
|
||||||
}
|
}
|
||||||
for want, pairs := range map[string][]string{
|
for want, pairs := range map[string][]string{
|
||||||
`--sink http,port: missing "="`: {"sink", "http,port"},
|
`--sink http:port: missing "="`: {"sink", "http:port"},
|
||||||
|
`--source file,directory=/x: TYPE ends at ':', as in file:directory=/x`: {"source", "file,directory=/x"},
|
||||||
`--source directory=/x: missing TYPE`: {"source", "directory=/x"},
|
`--source directory=/x: missing TYPE`: {"source", "directory=/x"},
|
||||||
`--source file,type=x: TYPE already sets "type"`: {"source", "file,type=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 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", ""},
|
`--sink requires a value`: {"sink", ""},
|
||||||
`--format txt: pipeline "cli" already has one`: {"format", "json", "format", "txt"},
|
`--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"},
|
`--rate-limit entries_per_second=1,polcy=drop: unknown key "polcy"`: {"rate-limit", "entries_per_second=1,polcy=drop"},
|
||||||
} {
|
} {
|
||||||
if _, err := Load(Args{Specs: specs(pairs...)}); err == nil || err.Error() != want {
|
if _, err := Load(Args{Specs: specs(pairs...)}); err == nil || err.Error() != want {
|
||||||
t.Errorf("%q: err = %v, want %s", pairs, err, want)
|
t.Errorf("%q: err = %v, want %s", pairs, err, want)
|
||||||
@@ -95,7 +96,7 @@ func TestPipelineSpecGrammar(t *testing.T) {
|
|||||||
func TestSpecValueIsOneListEntry(t *testing.T) {
|
func TestSpecValueIsOneListEntry(t *testing.T) {
|
||||||
isolateConfig(t)
|
isolateConfig(t)
|
||||||
sink := loadPipelines(t, "source", "null", "sink",
|
sink := loadPipelines(t, "source", "null", "sink",
|
||||||
`tcp,port=9000,tls.enabled=true,tls.self_signed=true,tls.client_auth=true,tls.client_ca_file=ca,`+
|
`tcp:port=9000,tls.enabled=true,tls.self_signed=true,tls.client_auth=true,tls.client_ca_file=ca,`+
|
||||||
`auth.type=mtls,auth.allow_patterns=^edge-\d{1\,3}$,auth.allow=CN=a\,O=b,auth.allow=CN=c`)[0].PluginSinks[0]
|
`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
|
var opts TCPSinkOptions
|
||||||
if err := Scan(sink.Config, &opts); err != nil {
|
if err := Scan(sink.Config, &opts); err != nil {
|
||||||
@@ -114,12 +115,12 @@ func TestEnvironmentPipeline(t *testing.T) {
|
|||||||
for name, value := range map[string]string{
|
for name, value := range map[string]string{
|
||||||
"LOGWISP_PIPELINE": "edge",
|
"LOGWISP_PIPELINE": "edge",
|
||||||
"LOGWISP_SOURCE": "random",
|
"LOGWISP_SOURCE": "random",
|
||||||
"LOGWISP_SOURCE_10": "null,id=ten",
|
"LOGWISP_SOURCE_10": "null:id=ten",
|
||||||
"LOGWISP_SOURCE_2": "null,id=two",
|
"LOGWISP_SOURCE_2": "null:id=two",
|
||||||
"LOGWISP_SINK_1": "console",
|
"LOGWISP_SINK_1": "console",
|
||||||
"LOGWISP_FILTER": "",
|
"LOGWISP_FILTER": "",
|
||||||
"LOGWISP_FORMAT_1": "raw",
|
"LOGWISP_FORMAT_1": "raw",
|
||||||
"LOGWISP_RATE_LIMIT": "rate=10",
|
"LOGWISP_RATE_LIMIT": "entries_per_second=10",
|
||||||
"LOGWISP_LOGGING_LEVEL": "debug",
|
"LOGWISP_LOGGING_LEVEL": "debug",
|
||||||
} {
|
} {
|
||||||
t.Setenv(name, value)
|
t.Setenv(name, value)
|
||||||
@@ -136,7 +137,7 @@ func TestEnvironmentPipeline(t *testing.T) {
|
|||||||
p := cfg.Pipelines
|
p := cfg.Pipelines
|
||||||
if len(p) != 1 || p[0].Name != "edge" || len(p[0].PluginSources) != 3 || p[0].PluginSources[1].ID != "two" ||
|
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].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" {
|
*p[0].Flow.RateLimit != (RateLimitConfig{EntriesPerSecond: 10, Policy: "drop"}) || cfg.Logging.Level != "debug" {
|
||||||
t.Fatalf("environment pipeline: %+v %+v", p, cfg.Logging)
|
t.Fatalf("environment pipeline: %+v %+v", p, cfg.Logging)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -174,7 +175,7 @@ func TestSpecPipelinesReplaceFilePipelines(t *testing.T) {
|
|||||||
// stdout, as a filter does; two pipelines cannot both read stdin.
|
// stdout, as a filter does; two pipelines cannot both read stdin.
|
||||||
func TestSpecPipelinesDefaultToStdio(t *testing.T) {
|
func TestSpecPipelinesDefaultToStdio(t *testing.T) {
|
||||||
isolateConfig(t)
|
isolateConfig(t)
|
||||||
got := loadPipelines(t, "filter", "include,patterns=ERROR")
|
got := loadPipelines(t, "filter", "include:patterns=ERROR")
|
||||||
if p := got[0]; len(p.PluginSources) != 1 || p.PluginSources[0].Type != "console" || p.PluginSources[0].ID != "stdin" ||
|
if p := got[0]; len(p.PluginSources) != 1 || p.PluginSources[0].Type != "console" || p.PluginSources[0].ID != "stdin" ||
|
||||||
len(p.PluginSinks) != 1 || p.PluginSinks[0].Type != "console" || p.PluginSinks[0].ID != "stdout" {
|
len(p.PluginSinks) != 1 || p.PluginSinks[0].Type != "console" || p.PluginSinks[0].ID != "stdout" {
|
||||||
t.Fatalf("pipeline: %+v", p)
|
t.Fatalf("pipeline: %+v", p)
|
||||||
@@ -190,7 +191,7 @@ func TestSpecPipelinesDefaultToStdio(t *testing.T) {
|
|||||||
// removal fails the reload instead of keeping the removed file's values.
|
// removal fails the reload instead of keeping the removed file's values.
|
||||||
func TestReloadKeepsSpecPipelines(t *testing.T) {
|
func TestReloadKeepsSpecPipelines(t *testing.T) {
|
||||||
isolateConfig(t)
|
isolateConfig(t)
|
||||||
m, err := Load(Args{Specs: specs("source", "random,special=true", "sink", "null")})
|
m, err := Load(Args{Specs: specs("source", "random:special=true", "sink", "null")})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -217,11 +218,11 @@ func TestReloadKeepsSpecPipelines(t *testing.T) {
|
|||||||
// Written specs build the pipelines a file would: each flow stage with its
|
// Written specs build the pipelines a file would: each flow stage with its
|
||||||
// defaults, so the rate limit keeps its file policy and the heartbeat its off
|
// defaults, so the rate limit keeps its file policy and the heartbeat its off
|
||||||
func TestWrittenSpecsBuildWhatAFileWould(t *testing.T) {
|
func TestWrittenSpecsBuildWhatAFileWould(t *testing.T) {
|
||||||
p := PipelineConfig{Name: "p", Flow: &FlowConfig{Format: &FormatConfig{}, RateLimit: &RateLimitConfig{Rate: 5}, Heartbeat: &HeartbeatConfig{}},
|
p := PipelineConfig{Name: "p", Flow: &FlowConfig{Format: &FormatConfig{}, RateLimit: &RateLimitConfig{EntriesPerSecond: 5}, Heartbeat: &HeartbeatConfig{}},
|
||||||
PluginSources: []PluginSourceConfig{{ID: "in", Type: "null", Config: map[string]any{}}},
|
PluginSources: []PluginSourceConfig{{ID: "in", Type: "null", Config: map[string]any{}}},
|
||||||
PluginSinks: []PluginSinkConfig{{ID: "out", Type: "null", Config: map[string]any{}}}}
|
PluginSinks: []PluginSinkConfig{{ID: "out", Type: "null", Config: map[string]any{}}}}
|
||||||
got, err := SpecPipelines(PipelineSpecs([]PipelineConfig{p}), nil)
|
got, err := SpecPipelines(PipelineSpecs([]PipelineConfig{p}), nil)
|
||||||
p.Flow = &FlowConfig{Format: &FormatConfig{Type: "raw"}, RateLimit: &RateLimitConfig{Rate: 5, Policy: "pass"},
|
p.Flow = &FlowConfig{Format: &FormatConfig{Type: "raw"}, RateLimit: &RateLimitConfig{EntriesPerSecond: 5, Policy: "pass"},
|
||||||
Heartbeat: &HeartbeatConfig{IntervalMS: 1000, Format: "txt"}}
|
Heartbeat: &HeartbeatConfig{IntervalMS: 1000, Format: "txt"}}
|
||||||
if err != nil || !reflect.DeepEqual(got, []PipelineConfig{p}) {
|
if err != nil || !reflect.DeepEqual(got, []PipelineConfig{p}) {
|
||||||
t.Fatalf("got %+v %v\nwant %+v", got, err, p)
|
t.Fatalf("got %+v %v\nwant %+v", got, err, p)
|
||||||
|
|||||||
@@ -296,7 +296,7 @@ func (o *AuthOptions) Check(side Side, http bool, t TLSState) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
case len(o.Allow) > 0 || len(o.AllowPatterns) > 0:
|
case len(o.Allow) > 0 || len(o.AllowPatterns) > 0:
|
||||||
return fmt.Errorf("auth: allow and allow_patterns apply only to type %q; the credentials file is the allow list", "mtls")
|
return fmt.Errorf("auth: allow and allow_patterns apply only to type %q; the users are the allow list", "mtls")
|
||||||
case side == Dialer && (o.CredentialsFile != "" || o.TokenLifetimeMS != 0 || len(o.TrustedProxies) > 0):
|
case side == Dialer && (o.CredentialsFile != "" || o.TokenLifetimeMS != 0 || len(o.TrustedProxies) > 0):
|
||||||
return errors.New("auth: credentials_file, token_lifetime_ms and trusted_proxies apply only to listeners")
|
return errors.New("auth: credentials_file, token_lifetime_ms and trusted_proxies apply only to listeners")
|
||||||
case side == Dialer && o.Identity != "":
|
case side == Dialer && o.Identity != "":
|
||||||
@@ -305,10 +305,12 @@ func (o *AuthOptions) Check(side Side, http bool, t TLSState) error {
|
|||||||
return fmt.Errorf("auth: type %q on a dialer requires username and password_file", "scram")
|
return fmt.Errorf("auth: type %q on a dialer requires username and password_file", "scram")
|
||||||
case side == Dialer:
|
case side == Dialer:
|
||||||
return nil
|
return nil
|
||||||
case o.Username != "" || o.PasswordFile != "":
|
case o.CredentialsFile != "" && (o.Username != "" || o.PasswordFile != ""):
|
||||||
return errors.New("auth: username and password_file apply only to dialers")
|
return errors.New("auth: credentials_file, or username and password_file: not both")
|
||||||
case o.CredentialsFile == "":
|
case (o.Username == "") != (o.PasswordFile == ""):
|
||||||
return fmt.Errorf("auth: type %q requires credentials_file", "scram")
|
return errors.New("auth: username and password_file go together")
|
||||||
|
case o.CredentialsFile == "" && o.Username == "":
|
||||||
|
return fmt.Errorf("auth: type %q requires credentials_file, or username and password_file", "scram")
|
||||||
case o.TokenLifetimeMS > 0 && !http:
|
case o.TokenLifetimeMS > 0 && !http:
|
||||||
return errors.New("auth: token_lifetime_ms applies only to HTTP listeners")
|
return errors.New("auth: token_lifetime_ms applies only to HTTP listeners")
|
||||||
case len(o.TrustedProxies) > 0 && (side != Listener || !http):
|
case len(o.TrustedProxies) > 0 && (side != Listener || !http):
|
||||||
@@ -425,6 +427,10 @@ func and(keys []string) string {
|
|||||||
return strings.Join(keys[:len(keys)-1], ", ") + " and " + keys[len(keys)-1]
|
return strings.Join(keys[:len(keys)-1], ", ") + " and " + keys[len(keys)-1]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pagesBehind is why a login or viewer page needs a proxy: only there do
|
||||||
|
// browsers log in, and elsewhere the viewer needs no page
|
||||||
|
const pagesBehind = "only for scram behind auth.trusted_proxies, where browsers log in; without auth or under mtls, the viewer is always at /"
|
||||||
|
|
||||||
// Check keeps a stream and a status path ServeMux and a URL take literally,
|
// Check keeps a stream and a status path ServeMux and a URL take literally,
|
||||||
// apart, and off the authentication paths; a login page needs browsers that
|
// apart, and off the authentication paths; a login page needs browsers that
|
||||||
// log in, behind a proxy
|
// log in, behind a proxy
|
||||||
@@ -442,10 +448,12 @@ func (o *HTTPSinkOptions) Check() error {
|
|||||||
switch {
|
switch {
|
||||||
case o.StreamPath == o.StatusPath:
|
case o.StreamPath == o.StatusPath:
|
||||||
return fmt.Errorf("stream_path and status_path must differ")
|
return fmt.Errorf("stream_path and status_path must differ")
|
||||||
case (o.LoginPage || o.ViewerPage) && !behind:
|
case o.LoginPage && !behind:
|
||||||
return errors.New("login_page and viewer_page apply to scram behind auth.trusted_proxies, where browsers log in; without auth or under mtls the viewer is always served")
|
return &KeyError{"login_page", errors.New(pagesBehind)}
|
||||||
|
case o.ViewerPage && !behind:
|
||||||
|
return &KeyError{"viewer_page", errors.New(pagesBehind)}
|
||||||
case o.ViewerPage && !o.LoginPage:
|
case o.ViewerPage && !o.LoginPage:
|
||||||
return errors.New("viewer_page needs login_page, where it sends a signed-out viewer")
|
return &KeyError{"viewer_page", errors.New("needs login_page, where it sends a signed-out viewer")}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,12 +27,12 @@ func NewRateLimiter(cfg config.RateLimitConfig, logger *log.Logger) (*RateLimite
|
|||||||
if err := config.Settle(&cfg); err != nil {
|
if err := config.Settle(&cfg); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if cfg.Rate == 0 {
|
if cfg.EntriesPerSecond == 0 {
|
||||||
return nil, nil // off
|
return nil, nil // off
|
||||||
}
|
}
|
||||||
burst := cfg.Burst
|
burst := cfg.BurstEntries
|
||||||
if burst == 0 {
|
if burst == 0 {
|
||||||
burst = cfg.Rate
|
burst = cfg.EntriesPerSecond
|
||||||
}
|
}
|
||||||
policy := config.PolicyPass
|
policy := config.PolicyPass
|
||||||
if cfg.Policy == "drop" {
|
if cfg.Policy == "drop" {
|
||||||
@@ -40,7 +40,7 @@ func NewRateLimiter(cfg config.RateLimitConfig, logger *log.Logger) (*RateLimite
|
|||||||
}
|
}
|
||||||
|
|
||||||
l := &RateLimiter{
|
l := &RateLimiter{
|
||||||
bucket: tokenbucket.New(burst, cfg.Rate),
|
bucket: tokenbucket.New(burst, cfg.EntriesPerSecond),
|
||||||
policy: policy,
|
policy: policy,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
maxEntrySizeBytes: cfg.MaxEntrySizeBytes,
|
maxEntrySizeBytes: cfg.MaxEntrySizeBytes,
|
||||||
@@ -85,8 +85,8 @@ func (l *RateLimiter) GetStats() map[string]any {
|
|||||||
|
|
||||||
stats := map[string]any{
|
stats := map[string]any{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"rate": l.bucket.Rate(),
|
"entries_per_second": l.bucket.Rate(),
|
||||||
"burst": l.bucket.Capacity(),
|
"burst_entries": l.bucket.Capacity(),
|
||||||
"dropped_total": l.droppedCount.Load(),
|
"dropped_total": l.droppedCount.Load(),
|
||||||
"dropped_by_size_total": l.droppedBySizeCount.Load(),
|
"dropped_by_size_total": l.droppedBySizeCount.Load(),
|
||||||
"policy": policyString(l.policy),
|
"policy": policyString(l.policy),
|
||||||
|
|||||||
@@ -240,13 +240,14 @@ var warnedSecrets sync.Map
|
|||||||
|
|
||||||
// SecretFileWarning describes a secret file every local user can read, once
|
// SecretFileWarning describes a secret file every local user can read, once
|
||||||
// per path per process. Group access is left alone: container secret mounts
|
// per path per process. Group access is left alone: container secret mounts
|
||||||
// commonly grant it on purpose.
|
// commonly grant it on purpose. Terminals, pipes and descriptors hold no
|
||||||
|
// secret at rest; lw closes /dev/fd/N after reading, and N then names another.
|
||||||
func SecretFileWarning(key, path string) string {
|
func SecretFileWarning(key, path string) string {
|
||||||
if path == "" {
|
if path == "" || strings.HasPrefix(path, "/dev/fd/") {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
fi, err := os.Stat(path)
|
fi, err := os.Stat(path)
|
||||||
if err != nil || fi.Mode().Perm()&0o004 == 0 {
|
if err != nil || !fi.Mode().IsRegular() || fi.Mode().Perm()&0o004 == 0 {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
if _, seen := warnedSecrets.LoadOrStore(path, struct{}{}); seen {
|
if _, seen := warnedSecrets.LoadOrStore(path, struct{}{}); seen {
|
||||||
|
|||||||
@@ -4,10 +4,13 @@ import (
|
|||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
"crypto/x509/pkix"
|
"crypto/x509/pkix"
|
||||||
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
"syscall"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -200,3 +203,33 @@ func TestTLSOptionConflicts(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Terminals, pipes and descriptors hold no secret at rest: /dev/tty, mode
|
||||||
|
// 0666 like /dev/null, is not warned about, nor /dev/fd/N whatever it names,
|
||||||
|
// while a world-readable file is.
|
||||||
|
func TestSecretFileWarningSkipsTerminalsAndPipes(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
fifo, file := filepath.Join(dir, "fifo"), filepath.Join(dir, "pw")
|
||||||
|
if err := syscall.Mkfifo(fifo, 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
testutil.WriteFile(t, file, "secret\n")
|
||||||
|
for _, path := range []string{fifo, file} {
|
||||||
|
if err := os.Chmod(path, 0o644); err != nil { // past the umask
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
held, err := os.Open(file)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer held.Close()
|
||||||
|
for _, path := range []string{os.DevNull, fifo, fmt.Sprintf("/dev/fd/%d", held.Fd())} {
|
||||||
|
if w := SecretFileWarning("auth.password_file", path); w != "" {
|
||||||
|
t.Errorf("%s: %s", path, w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if SecretFileWarning("auth.password_file", file) == "" {
|
||||||
|
t.Error("a world-readable file was not warned about")
|
||||||
|
}
|
||||||
|
}
|
||||||
+45
-66
@@ -10,7 +10,6 @@ import (
|
|||||||
"github.com/lixenwraith/logwisp/internal/compose"
|
"github.com/lixenwraith/logwisp/internal/compose"
|
||||||
"github.com/lixenwraith/logwisp/internal/config"
|
"github.com/lixenwraith/logwisp/internal/config"
|
||||||
|
|
||||||
"github.com/lixenwraith/terminal"
|
|
||||||
ui "github.com/lixenwraith/terminal/tui"
|
ui "github.com/lixenwraith/terminal/tui"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -92,22 +91,9 @@ func (a *app) layout(w int) layout {
|
|||||||
func (a *app) drawCanvas(r ui.Region) {
|
func (a *app) drawCanvas(r ui.Region) {
|
||||||
l := a.layout(r.W)
|
l := a.layout(r.W)
|
||||||
chosen := l.nodes[a.col][min(a.row[a.col], len(l.nodes[a.col])-1)]
|
chosen := l.nodes[a.col][min(a.row[a.col], len(l.nodes[a.col])-1)]
|
||||||
if chosen.y < a.scroll {
|
a.scroll.SetDimensions(l.h, r.H)
|
||||||
a.scroll = chosen.y
|
a.scroll.EnsureRange(chosen.y, 2-a.col%2)
|
||||||
}
|
r.Window(l.h, &a.scroll, a.th.Text, func(full ui.Region) { a.drawParts(full, l) })
|
||||||
if tall := 2 - a.col%2; chosen.y+tall > a.scroll+r.H {
|
|
||||||
a.scroll = chosen.y + tall - r.H
|
|
||||||
}
|
|
||||||
a.scroll = max(0, min(a.scroll, l.h-r.H))
|
|
||||||
|
|
||||||
// Drawn whole off screen, then the rows in view copied
|
|
||||||
cells := make([]terminal.Cell, r.W*l.h)
|
|
||||||
full := ui.NewRegion(cells, r.W, 0, 0, r.W, l.h)
|
|
||||||
full.FillStyle(a.th.Text)
|
|
||||||
a.drawParts(full, l)
|
|
||||||
for y := range min(r.H, l.h-a.scroll) {
|
|
||||||
copy(r.Cells[(r.Y+y)*r.TotalW+r.X:][:r.W], cells[(a.scroll+y)*r.W:][:r.W])
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *app) drawParts(r ui.Region, l layout) {
|
func (a *app) drawParts(r ui.Region, l layout) {
|
||||||
@@ -118,7 +104,7 @@ func (a *app) drawParts(r ui.Region, l layout) {
|
|||||||
r.TextStyled(t.x, t.y, string(f.dot)+" "+c.title, tints[col].On(th.Text))
|
r.TextStyled(t.x, t.y, string(f.dot)+" "+c.title, tints[col].On(th.Text))
|
||||||
}
|
}
|
||||||
a.drawBox(r, l)
|
a.drawBox(r, l)
|
||||||
g := wires{w: r.W, arms: make([]uint8, r.W*l.h)}
|
g := ui.NewWires(r.W, l.h)
|
||||||
for _, col := range []int{sourcesCol, sinksCol} {
|
for _, col := range []int{sourcesCol, sinksCol} {
|
||||||
nodes := a.nodes(col)
|
nodes := a.nodes(col)
|
||||||
for i, s := range l.nodes[col] {
|
for i, s := range l.nodes[col] {
|
||||||
@@ -130,7 +116,7 @@ func (a *app) drawParts(r ui.Region, l layout) {
|
|||||||
a.drawNode(r, s, nodes[i], tints[col], chosen)
|
a.drawNode(r, s, nodes[i], tints[col], chosen)
|
||||||
}
|
}
|
||||||
if l.wide && len(nodes) > 0 {
|
if l.wide && len(nodes) > 0 {
|
||||||
g.join(l, col)
|
join(g, l, col)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !l.wide {
|
if !l.wide {
|
||||||
@@ -139,11 +125,7 @@ func (a *app) drawParts(r ui.Region, l layout) {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for i, arms := range g.arms {
|
r.DrawWires(g, f.Line, th.Border)
|
||||||
if arms != 0 {
|
|
||||||
r.TextStyled(i%r.W, i/r.W, string(f.wire[arms]), th.Border)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(a.nodes(sourcesCol)) > 0 {
|
if len(a.nodes(sourcesCol)) > 0 {
|
||||||
r.TextStyled(l.box.x-1, l.entry, string(f.arrow), th.Border)
|
r.TextStyled(l.box.x-1, l.entry, string(f.arrow), th.Border)
|
||||||
}
|
}
|
||||||
@@ -152,30 +134,9 @@ func (a *app) drawParts(r ui.Region, l layout) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// wires are the canvas's cells by the arms they have: up 1, right 2, down 4,
|
|
||||||
// left 8, so every junction draws from what meets there
|
|
||||||
type wires struct {
|
|
||||||
w int
|
|
||||||
arms []uint8
|
|
||||||
}
|
|
||||||
|
|
||||||
func (g wires) h(y, x0, x1 int) {
|
|
||||||
for x := x0; x < x1; x++ {
|
|
||||||
g.arms[y*g.w+x] |= 2
|
|
||||||
g.arms[y*g.w+x+1] |= 8
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (g wires) v(x, y0, y1 int) {
|
|
||||||
for y := y0; y < y1; y++ {
|
|
||||||
g.arms[y*g.w+x] |= 4
|
|
||||||
g.arms[(y+1)*g.w+x] |= 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// join wires a column's nodes to a trunk beside the flow box, and the trunk
|
// join wires a column's nodes to a trunk beside the flow box, and the trunk
|
||||||
// to the box's side
|
// to the box's side
|
||||||
func (g wires) join(l layout, col int) {
|
func join(g ui.Wires, l layout, col int) {
|
||||||
ys := []int{l.entry}
|
ys := []int{l.entry}
|
||||||
if col == sinksCol {
|
if col == sinksCol {
|
||||||
ys[0] = l.exit
|
ys[0] = l.exit
|
||||||
@@ -186,18 +147,18 @@ func (g wires) join(l layout, col int) {
|
|||||||
if col == sourcesCol {
|
if col == sourcesCol {
|
||||||
trunk := l.box.x - gutter + 1
|
trunk := l.box.x - gutter + 1
|
||||||
for _, s := range l.nodes[col] {
|
for _, s := range l.nodes[col] {
|
||||||
g.h(s.y, trunk-1, trunk)
|
g.H(s.y, trunk-1, trunk)
|
||||||
}
|
}
|
||||||
g.v(trunk, slices.Min(ys), slices.Max(ys))
|
g.V(trunk, slices.Min(ys), slices.Max(ys))
|
||||||
g.h(l.entry, trunk, l.box.x-1)
|
g.H(l.entry, trunk, l.box.x-1)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
right := l.box.x + l.box.w
|
right := l.box.x + l.box.w
|
||||||
trunk := right + 2
|
trunk := right + 2
|
||||||
g.h(l.exit, right, trunk)
|
g.H(l.exit, right, trunk)
|
||||||
g.v(trunk, slices.Min(ys), slices.Max(ys))
|
g.V(trunk, slices.Min(ys), slices.Max(ys))
|
||||||
for _, s := range l.nodes[col] {
|
for _, s := range l.nodes[col] {
|
||||||
g.h(s.y, trunk, s.x-1)
|
g.H(s.y, trunk, s.x-1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,14 +206,7 @@ func (a *app) drawEmpty(r ui.Region, s spot, role string, chosen bool) {
|
|||||||
func (a *app) drawBox(r ui.Region, l layout) {
|
func (a *app) drawBox(r ui.Region, l layout) {
|
||||||
th, f := a.th, a.font
|
th, f := a.th, a.font
|
||||||
b := l.box
|
b := l.box
|
||||||
edge := []rune(f.box)
|
r.Sub(b.x, b.y, b.w, l.boxH).BoxStyle(f.box, th.Border)
|
||||||
across := strings.Repeat(string(edge[4]), max(0, b.w-2))
|
|
||||||
r.TextStyled(b.x, b.y, string(edge[0])+across+string(edge[1]), th.Border)
|
|
||||||
r.TextStyled(b.x, b.y+l.boxH-1, string(edge[2])+across+string(edge[3]), th.Border)
|
|
||||||
for y := b.y + 1; y < b.y+l.boxH-1; y++ {
|
|
||||||
r.TextStyled(b.x, y, string(edge[5]), th.Border)
|
|
||||||
r.TextStyled(b.x+b.w-1, y, string(edge[5]), th.Border)
|
|
||||||
}
|
|
||||||
for i, n := range a.nodes(flowCol) {
|
for i, n := range a.nodes(flowCol) {
|
||||||
s := l.nodes[flowCol][i]
|
s := l.nodes[flowCol][i]
|
||||||
chosen := a.col == flowCol && a.row[flowCol] == i
|
chosen := a.col == flowCol && a.row[flowCol] == i
|
||||||
@@ -345,11 +299,14 @@ func show(v any) string {
|
|||||||
// key, as config's key paths write them
|
// key, as config's key paths write them
|
||||||
var problemPath = regexp.MustCompile(`^pipelines?\[(\d+)\](?:\.(plugin_sources|plugin_sinks)\[([^\]]*)\](?:\.config\.?([\w.]*))?|\.flow\.(\w+)(?:\[(\d+)\])?\.?([\w.]*))?`)
|
var problemPath = regexp.MustCompile(`^pipelines?\[(\d+)\](?:\.(plugin_sources|plugin_sinks)\[([^\]]*)\](?:\.config\.?([\w.]*))?|\.flow\.(\w+)(?:\[(\d+)\])?\.?([\w.]*))?`)
|
||||||
|
|
||||||
func locate(err error) (pi int, n compose.Node, key string, ok bool) {
|
// locate reads a problem's part, and its text past the path
|
||||||
m := problemPath.FindStringSubmatch(err.Error())
|
func locate(err error) (pi int, n compose.Node, key, text string, ok bool) {
|
||||||
if m == nil {
|
at := problemPath.FindStringSubmatchIndex(err.Error())
|
||||||
return 0, n, "", false
|
if at == nil {
|
||||||
|
return 0, n, "", err.Error(), false
|
||||||
}
|
}
|
||||||
|
m := problemPath.FindStringSubmatch(err.Error())
|
||||||
|
text = strings.TrimPrefix(err.Error()[at[1]:], ": ")
|
||||||
pi, _ = strconv.Atoi(m[1])
|
pi, _ = strconv.Atoi(m[1])
|
||||||
switch {
|
switch {
|
||||||
case m[2] != "":
|
case m[2] != "":
|
||||||
@@ -362,7 +319,7 @@ func locate(err error) (pi int, n compose.Node, key string, ok bool) {
|
|||||||
}
|
}
|
||||||
key = m[7]
|
key = m[7]
|
||||||
}
|
}
|
||||||
return pi, n, key, true
|
return pi, n, key, text, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// faulty reports whether the first problem names this node, or a filter
|
// faulty reports whether the first problem names this node, or a filter
|
||||||
@@ -371,6 +328,28 @@ func (a *app) faulty(n compose.Node) bool {
|
|||||||
if a.problem == nil {
|
if a.problem == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
pi, m, _, ok := locate(a.problem)
|
pi, m, _, _, ok := locate(a.problem)
|
||||||
return ok && pi == a.pi && m.Role == n.Role && m.ID == n.ID
|
return ok && pi == a.pi && m.Role == n.Role && m.ID == n.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// where names a problem's part as the screen shows it: its pipeline when
|
||||||
|
// another is shown, the part, and its key
|
||||||
|
func (a *app) where(pi int, n compose.Node, key string) string {
|
||||||
|
var out []string
|
||||||
|
if pi != a.pi && pi < len(a.comp.Pipelines) {
|
||||||
|
out = append(out, "pipeline "+a.comp.Pipelines[pi].Name)
|
||||||
|
}
|
||||||
|
switch n.Role {
|
||||||
|
case "":
|
||||||
|
case "source", "sink":
|
||||||
|
out = append(out, n.Role+" "+n.ID)
|
||||||
|
case "filters":
|
||||||
|
out = append(out, fmt.Sprintf("filter %d", n.Index+1))
|
||||||
|
default:
|
||||||
|
out = append(out, n.Role)
|
||||||
|
}
|
||||||
|
if key != "" {
|
||||||
|
out = append(out, key)
|
||||||
|
}
|
||||||
|
return strings.Join(out, " · ")
|
||||||
|
}
|
||||||
+245
-118
@@ -1,6 +1,7 @@
|
|||||||
package tui
|
package tui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/lixenwraith/logwisp/internal/compose"
|
"github.com/lixenwraith/logwisp/internal/compose"
|
||||||
@@ -16,23 +17,19 @@ type dialog interface {
|
|||||||
handle(a *app, ev terminal.Event)
|
handle(a *app, ev terminal.Event)
|
||||||
}
|
}
|
||||||
|
|
||||||
// frame draws a dialog's box, w by h at most, centered in r with its title,
|
// frame draws a dialog's box around an inside w by h, centered in r and
|
||||||
// and returns the inside
|
// clipped to it, with its title, and returns the inside; sized to what it
|
||||||
|
// holds, it ends on its last line
|
||||||
func (a *app) frame(r ui.Region, w, h int, title string) ui.Region {
|
func (a *app) frame(r ui.Region, w, h int, title string) ui.Region {
|
||||||
b := ui.Center(r, min(w, r.W), min(h, r.H))
|
return ui.Center(r, min(w+4, r.W), min(h+2, r.H)).Frame(title, a.th)
|
||||||
b.FillStyle(a.th.Text)
|
|
||||||
wire := a.font.wire
|
|
||||||
across := strings.Repeat(string(wire[10]), max(0, b.W-2))
|
|
||||||
b.TextStyled(0, 0, string(wire[6])+across+string(wire[12]), a.th.Border)
|
|
||||||
b.TextStyled(0, b.H-1, string(wire[3])+across+string(wire[9]), a.th.Border)
|
|
||||||
for y := 1; y < b.H-1; y++ {
|
|
||||||
b.TextStyled(0, y, string(wire[5]), a.th.Border)
|
|
||||||
b.TextStyled(b.W-1, y, string(wire[5]), a.th.Border)
|
|
||||||
}
|
|
||||||
b.TextStyled(2, 0, " "+ui.Truncate(title, b.W-6)+" ", a.th.Accent)
|
|
||||||
return b.Sub(2, 1, b.W-4, b.H-2)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// inside is a dialog's widest inside on a screen w wide; one narrower than
|
||||||
|
// stacked puts labels and keys above what they name
|
||||||
|
func inside(w int) int { return max(1, min(72, w-4)) }
|
||||||
|
|
||||||
|
const stacked = 32
|
||||||
|
|
||||||
// chooser picks one option from a filtered list
|
// chooser picks one option from a filtered list
|
||||||
type chooser struct {
|
type chooser struct {
|
||||||
title string
|
title string
|
||||||
@@ -43,8 +40,8 @@ type chooser struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *chooser) draw(a *app, r ui.Region) {
|
func (c *chooser) draw(a *app, r ui.Region) {
|
||||||
in := a.frame(r, 76, len(c.list.Options)+4, c.title)
|
w := inside(r.W)
|
||||||
in.OptionList(c.list, a.th)
|
a.frame(r, w, c.list.Rows(w), c.title).OptionList(c.list, a.th)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *chooser) handle(a *app, ev terminal.Event) {
|
func (c *chooser) handle(a *app, ev terminal.Event) {
|
||||||
@@ -64,6 +61,11 @@ func (c *chooser) handle(a *app, ev terminal.Event) {
|
|||||||
a.dialog = nil
|
a.dialog = nil
|
||||||
c.pick(a, i)
|
c.pick(a, i)
|
||||||
}
|
}
|
||||||
|
case ev.Key == terminal.KeyRune && c.list.Menu:
|
||||||
|
if i, ok := c.list.Pick(ev.Rune); ok {
|
||||||
|
a.dialog = nil
|
||||||
|
c.pick(a, i)
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
c.list.HandleKey(ev.Key, ev.Rune, ev.Modifiers)
|
c.list.HandleKey(ev.Key, ev.Rune, ev.Modifiers)
|
||||||
}
|
}
|
||||||
@@ -95,56 +97,104 @@ func (a *app) addMenu() dialog {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
a.choose(n)
|
a.choose(n)
|
||||||
a.inspect, a.insp.cursor = true, 0
|
a.inspectFirst(n)
|
||||||
for j, l := range a.lines() {
|
}}
|
||||||
if l.group != "" && l.node == n {
|
|
||||||
a.insp.cursor = j
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// typeMenu changes a source's or sink's type, in its place
|
||||||
|
func (a *app) typeMenu(n compose.Node) dialog {
|
||||||
|
p, _ := a.plugin(n)
|
||||||
|
var options []ui.Option
|
||||||
|
for _, q := range config.Plugins() {
|
||||||
|
if q.Role == n.Role && q.Type != p.Type {
|
||||||
|
options = append(options, ui.Option{Name: q.Type, Hint: q.Summary})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &chooser{title: "Change " + p.Type, list: ui.NewOptionListState(options), pick: func(a *app, i int) {
|
||||||
|
m, err := a.comp.Retype(a.pi, n, options[i].Name)
|
||||||
|
if a.edit(err) {
|
||||||
|
a.choose(m)
|
||||||
|
a.inspectFirst(m)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
|
|
||||||
// presetMenu starts the pipeline over from a preset, or the pipelines from a
|
// enumMenu sets an enum key to the option chosen, the cursor on its value
|
||||||
// pasted command line; on a bare start, Esc starts empty
|
func (a *app) enumMenu(l line, cur any) dialog {
|
||||||
func (a *app) presetMenu() dialog {
|
|
||||||
presets := config.Presets()
|
|
||||||
var options []ui.Option
|
var options []ui.Option
|
||||||
for _, p := range presets {
|
for _, e := range l.key.Enum {
|
||||||
options = append(options, ui.Option{Name: p.Name, Hint: p.Summary})
|
hint := ""
|
||||||
|
if e == l.key.Default {
|
||||||
|
hint = "the default"
|
||||||
}
|
}
|
||||||
options = append(options, ui.Option{Name: "empty", Hint: "no sources or sinks: add your own"})
|
options = append(options, ui.Option{Name: e, Hint: hint})
|
||||||
|
}
|
||||||
|
list := ui.NewOptionListState(options)
|
||||||
|
list.Cursor = max(0, choice(l.key, cur))
|
||||||
|
return &chooser{title: label(l), list: list, pick: func(a *app, i int) {
|
||||||
|
a.edit(a.comp.Set(a.pi, l.node, l.key.Name, options[i].Name))
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// presetMenu replaces the pipeline shown from a preset, or the pipelines
|
||||||
|
// from a pasted command line; on a bare start, Esc starts empty
|
||||||
|
func (a *app) presetMenu() dialog {
|
||||||
title := "Start from a preset, or paste an lw command line"
|
title := "Start from a preset, or paste an lw command line"
|
||||||
if len(a.comp.Pipelines) > 0 {
|
if len(a.comp.Pipelines) > 0 {
|
||||||
title = "Replace pipeline " + a.pipeline().Name + " with a preset"
|
title = "Replace pipeline " + a.pipeline().Name + " with a preset"
|
||||||
}
|
}
|
||||||
empty := func(a *app) {
|
menu := a.pipelineMenu(title, (*app).use)
|
||||||
a.use(config.PipelineConfig{Name: "default", Flow: &config.FlowConfig{}})
|
|
||||||
}
|
|
||||||
menu := &chooser{title: title, list: ui.NewOptionListState(options)}
|
|
||||||
menu.paste = func(a *app, text string) {
|
menu.paste = func(a *app, text string) {
|
||||||
a.dialog = nil
|
a.dialog = nil
|
||||||
if a.paste(text); len(a.comp.Pipelines) == 0 {
|
if a.paste(text); len(a.comp.Pipelines) == 0 {
|
||||||
a.dialog = menu // refused: the status says why
|
a.dialog = menu // refused: the status says why
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
menu.pick = func(a *app, i int) {
|
|
||||||
switch {
|
|
||||||
case i == len(presets):
|
|
||||||
empty(a)
|
|
||||||
case len(presets[i].Params) == 0:
|
|
||||||
(&presetForm{preset: presets[i]}).apply(a)
|
|
||||||
default:
|
|
||||||
a.dialog = newPresetForm(presets[i])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
menu.back = func(a *app) {
|
menu.back = func(a *app) {
|
||||||
if len(a.comp.Pipelines) == 0 {
|
if len(a.comp.Pipelines) == 0 {
|
||||||
empty(a)
|
a.use(empty())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return menu
|
return menu
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pipelineMenu offers the presets and an empty pipeline, which place puts
|
||||||
|
// on screen
|
||||||
|
func (a *app) pipelineMenu(title string, place func(a *app, p config.PipelineConfig)) *chooser {
|
||||||
|
presets := config.Presets()
|
||||||
|
var options []ui.Option
|
||||||
|
for _, p := range presets {
|
||||||
|
options = append(options, ui.Option{Name: p.Name, Hint: p.Summary})
|
||||||
|
}
|
||||||
|
options = append(options, ui.Option{Name: "empty", Hint: "no sources or sinks: add your own"})
|
||||||
|
menu := &chooser{title: title, list: ui.NewOptionListState(options)}
|
||||||
|
menu.pick = func(a *app, i int) {
|
||||||
|
if i == len(presets) {
|
||||||
|
place(a, empty())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p := presets[i]
|
||||||
|
f := &form{title: "preset " + p.Name, summary: p.Summary, params: p.Params, back: func(a *app) { a.dialog = menu },
|
||||||
|
apply: func(a *app, values map[string]string) error {
|
||||||
|
c, err := compose.FromPreset(p.Name, values, config.HostIsDir)
|
||||||
|
if err == nil {
|
||||||
|
place(a, c.Pipelines[0])
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}}
|
||||||
|
if f.fill(nil); len(p.Params) == 0 {
|
||||||
|
f.submit(a)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.dialog = f
|
||||||
|
}
|
||||||
|
return menu
|
||||||
|
}
|
||||||
|
|
||||||
|
func empty() config.PipelineConfig {
|
||||||
|
return config.PipelineConfig{Name: "default", Flow: &config.FlowConfig{}}
|
||||||
|
}
|
||||||
|
|
||||||
// use puts a pipeline in place of the one shown, under its name, asking
|
// use puts a pipeline in place of the one shown, under its name, asking
|
||||||
// first when that one has sources or sinks; the first pipeline keeps its own,
|
// first when that one has sources or sinks; the first pipeline keeps its own,
|
||||||
// and its parts are work quit asks about
|
// and its parts are work quit asks about
|
||||||
@@ -167,51 +217,102 @@ func (a *app) use(p config.PipelineConfig) {
|
|||||||
a.dialog = &confirm{question: "Replace pipeline " + old.Name + "?", yes: replace}
|
a.dialog = &confirm{question: "Replace pipeline " + old.Name + "?", yes: replace}
|
||||||
}
|
}
|
||||||
|
|
||||||
// presetForm takes a preset's keys, its defaults as placeholders
|
// append adds a pipeline after the others, under its name or the first free
|
||||||
type presetForm struct {
|
// one after it, and shows it
|
||||||
preset config.Preset
|
func (a *app) append(p config.PipelineConfig) {
|
||||||
|
var names []string
|
||||||
|
for _, q := range a.comp.Pipelines {
|
||||||
|
names = append(names, q.Name)
|
||||||
|
}
|
||||||
|
p.Name = config.FreeID(p.Name, names)
|
||||||
|
a.comp.Pipelines = append(a.comp.Pipelines, p)
|
||||||
|
a.pi, a.row, a.bar, a.changed = len(a.comp.Pipelines)-1, [3]int{}, false, true
|
||||||
|
a.check()
|
||||||
|
}
|
||||||
|
|
||||||
|
// form takes a few keys, their defaults as placeholders, then applies them;
|
||||||
|
// a refusal stays in the form
|
||||||
|
type form struct {
|
||||||
|
title, summary string
|
||||||
|
params []config.PresetParam
|
||||||
fields []*ui.TextFieldState
|
fields []*ui.TextFieldState
|
||||||
focus ui.Focus
|
focus ui.Focus
|
||||||
scroll int // the first field shown
|
scroll ui.ViewportScroll // the fields' rows
|
||||||
err string
|
err string
|
||||||
|
apply func(a *app, values map[string]string) error
|
||||||
|
back func(a *app) // on Esc, after the form closes
|
||||||
}
|
}
|
||||||
|
|
||||||
func newPresetForm(p config.Preset) *presetForm {
|
// fill gives the fields their first values
|
||||||
f := &presetForm{preset: p, focus: ui.Focus{Len: len(p.Params)}}
|
func (f *form) fill(values map[string]string) {
|
||||||
for range p.Params {
|
f.focus, f.fields = ui.Focus{Len: len(f.params)}, nil
|
||||||
f.fields = append(f.fields, ui.NewTextFieldState(""))
|
for _, p := range f.params {
|
||||||
|
f.fields = append(f.fields, ui.NewTextFieldState(values[p.Name]))
|
||||||
}
|
}
|
||||||
return f
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *presetForm) draw(a *app, r ui.Region) {
|
// draw lays the form out at the screen's width: the summary, the fields,
|
||||||
th := a.th
|
// their labels beside them or, when stacked, above, then the focused
|
||||||
in := a.frame(r, 76, len(f.fields)+6, "preset "+f.preset.Name)
|
// field's help or the refusal, in rows kept for the longest of them
|
||||||
in.TextStyled(0, 0, ui.Truncate(f.preset.Summary, in.W), th.Muted)
|
func (f *form) draw(a *app, r ui.Region) {
|
||||||
rows := max(1, in.H-3) // between the summary and the note
|
th, w := a.th, inside(r.W)
|
||||||
f.scroll = max(0, min(f.scroll, f.focus.Index, len(f.fields)-rows), f.focus.Index-rows+1)
|
labelW, tall := 0, 2
|
||||||
for i := f.scroll; i < len(f.fields) && i-f.scroll < rows; i++ {
|
if w >= stacked {
|
||||||
p := f.preset.Params[i]
|
for _, p := range f.params {
|
||||||
v, _ := in.Field(2+i-f.scroll, 14, ui.Field{Label: p.Name, Required: p.Required}, i == f.focus.Index, th)
|
labelW = max(labelW, ui.RuneLen(p.Name)+2)
|
||||||
|
}
|
||||||
|
tall = 1
|
||||||
|
}
|
||||||
|
summary, noteRows := wrap(f.summary, w, 2), len(wrap(f.err, w, 3))
|
||||||
|
for _, p := range f.params {
|
||||||
|
noteRows = max(noteRows, len(wrap(p.Help, w, 3)))
|
||||||
|
}
|
||||||
|
top, bottom := len(summary)+min(1, len(summary)), noteRows+min(1, noteRows)
|
||||||
|
in := a.frame(r, w, top+len(f.fields)*tall+bottom, f.title)
|
||||||
|
for i, l := range summary {
|
||||||
|
in.TextStyled(0, i, l, th.Muted)
|
||||||
|
}
|
||||||
|
view := in.Sub(0, top, in.W, max(1, in.H-top-bottom))
|
||||||
|
f.scroll.SetDimensions(len(f.fields)*tall, view.H)
|
||||||
|
f.scroll.EnsureRange(f.focus.Index*tall, tall)
|
||||||
|
view.Window(len(f.fields)*tall, &f.scroll, th.Text, func(all ui.Region) {
|
||||||
|
for i, p := range f.params {
|
||||||
|
v, _ := all.Field(i*tall, labelW, ui.Field{Label: p.Name, Required: p.Required}, i == f.focus.Index, th)
|
||||||
v.TextInput(f.fields[i], p.Default, i == f.focus.Index, th)
|
v.TextInput(f.fields[i], p.Default, i == f.focus.Index, th)
|
||||||
}
|
}
|
||||||
note, style := f.preset.Params[f.focus.Index].Help, th.Muted
|
})
|
||||||
|
note, style := f.params[f.focus.Index].Help, th.Muted
|
||||||
if f.err != "" {
|
if f.err != "" {
|
||||||
note, style = f.err, th.Error
|
note, style = f.err, th.Error
|
||||||
}
|
}
|
||||||
in.TextStyled(0, in.H-1, ui.Truncate(note, in.W), style)
|
lines := wrap(note, in.W, noteRows) // on the last rows, the box ending on text
|
||||||
|
for i, l := range lines {
|
||||||
|
in.TextStyled(0, in.H-len(lines)+i, l, style)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *presetForm) handle(a *app, ev terminal.Event) {
|
// wrap is s wrapped to w, at most n lines, the last cut with '…'; none when
|
||||||
|
// s is empty
|
||||||
|
func wrap(s string, w, n int) []string {
|
||||||
|
if s == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return clip(ui.WrapText(s, w), n)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *form) handle(a *app, ev terminal.Event) {
|
||||||
field := f.fields[f.focus.Index]
|
field := f.fields[f.focus.Index]
|
||||||
switch {
|
switch {
|
||||||
case ev.Type == terminal.EventPaste:
|
case ev.Type == terminal.EventPaste:
|
||||||
field.Paste(ev.Text)
|
field.Paste(ev.Text)
|
||||||
case ev.Type != terminal.EventKey:
|
case ev.Type != terminal.EventKey:
|
||||||
case is(ev, terminal.KeyEscape, ""):
|
case is(ev, terminal.KeyEscape, ""):
|
||||||
a.dialog = a.presetMenu()
|
a.dialog = nil
|
||||||
|
if f.back != nil {
|
||||||
|
f.back(a)
|
||||||
|
}
|
||||||
case is(ev, terminal.KeyEnter, ""):
|
case is(ev, terminal.KeyEnter, ""):
|
||||||
f.apply(a)
|
f.submit(a)
|
||||||
case is(ev, terminal.KeyUp, ""):
|
case is(ev, terminal.KeyUp, ""):
|
||||||
f.focus.Index = max(0, f.focus.Index-1)
|
f.focus.Index = max(0, f.focus.Index-1)
|
||||||
case is(ev, terminal.KeyDown, ""):
|
case is(ev, terminal.KeyDown, ""):
|
||||||
@@ -222,39 +323,33 @@ func (f *presetForm) handle(a *app, ev terminal.Event) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// apply builds the preset's pipeline, on this host: a path is a directory
|
// submit applies the values typed, closing the form unless they are refused
|
||||||
// when one is there
|
func (f *form) submit(a *app) {
|
||||||
func (f *presetForm) apply(a *app) {
|
|
||||||
values := map[string]string{}
|
values := map[string]string{}
|
||||||
for i, p := range f.preset.Params {
|
for i, p := range f.params {
|
||||||
if v := f.fields[i].Value(); v != "" {
|
if v := f.fields[i].Value(); v != "" {
|
||||||
values[p.Name] = v
|
values[p.Name] = v
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
c, err := compose.FromPreset(f.preset.Name, values, config.HostIsDir)
|
|
||||||
if err != nil {
|
|
||||||
f.err = err.Error()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
a.dialog = nil
|
a.dialog = nil
|
||||||
a.use(c.Pipelines[0])
|
if err := f.apply(a, values); err != nil {
|
||||||
|
f.err, a.dialog = err.Error(), f
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// outputMenu is what o offers: run, or print one of the forms
|
// outputMenu is what o offers: run, or print one of the forms
|
||||||
func (a *app) outputMenu() dialog {
|
func (a *app) outputMenu() dialog {
|
||||||
forms := compose.Forms()
|
forms := compose.Forms()
|
||||||
options := []ui.Option{{Name: "run", Hint: "start the pipelines now, with lw's other settings"}}
|
options := []ui.Option{{Name: "run", Key: 'r', Hint: "start the pipelines now, with lw's other settings"}}
|
||||||
for _, f := range forms {
|
for _, f := range forms {
|
||||||
options = append(options, ui.Option{Name: f.Name, Hint: "print " + f.Hint})
|
options = append(options, ui.Option{Name: f.Name, Key: rune(f.Name[0]), Hint: "print " + f.Hint})
|
||||||
}
|
}
|
||||||
return &chooser{title: "Output, once the screen closes", list: ui.NewOptionListState(options),
|
list := ui.NewOptionListState(options)
|
||||||
|
list.Menu = true
|
||||||
|
return &chooser{title: "Output, once the screen closes", list: list,
|
||||||
pick: func(a *app, i int) {
|
pick: func(a *app, i int) {
|
||||||
if i == 0 {
|
if i == 0 {
|
||||||
if a.problem != nil {
|
a.run()
|
||||||
a.status = "not valid yet: c shows the problem"
|
|
||||||
return
|
|
||||||
}
|
|
||||||
a.result, a.done = Result{Exit: Start, Pipelines: a.comp.Pipelines}, true
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
text, err := forms[i-1].Write(a.comp)
|
text, err := forms[i-1].Write(a.comp)
|
||||||
@@ -266,88 +361,120 @@ func (a *app) outputMenu() dialog {
|
|||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
|
|
||||||
// notice shows lines until any key
|
// notice shows text, wrapped to the screen, until a key other than one
|
||||||
|
// that scrolls it
|
||||||
type notice struct {
|
type notice struct {
|
||||||
title string
|
title string
|
||||||
lines []string
|
body func(w int) []string // the lines at a width
|
||||||
|
view ui.ViewportScroll
|
||||||
|
}
|
||||||
|
|
||||||
|
// textNotice is a notice of plain text, its paragraphs split at '\n'
|
||||||
|
func textNotice(title, body string) *notice {
|
||||||
|
return ¬ice{title: title, body: func(w int) []string { return ui.WrapText(body, w) }}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (n *notice) draw(a *app, r ui.Region) {
|
func (n *notice) draw(a *app, r ui.Region) {
|
||||||
w, lines := ui.RuneLen(n.title)+8, fit(n.lines, r.W)
|
lines, w := n.body(inside(r.W)), ui.RuneLen(n.title)+2
|
||||||
for _, l := range lines {
|
for _, l := range lines {
|
||||||
w = max(w, ui.RuneLen(l)+6)
|
w = max(w, ui.RuneLen(l))
|
||||||
}
|
}
|
||||||
in := a.frame(r, w, len(lines)+4, n.title)
|
in := a.frame(r, min(w, inside(r.W)), len(lines), n.title)
|
||||||
for i, l := range lines {
|
rows := in.H
|
||||||
in.TextStyled(0, 1+i, l, a.th.Text)
|
if len(lines) > in.H { // the last row says where the view is
|
||||||
|
rows = max(1, in.H-1)
|
||||||
}
|
}
|
||||||
|
n.view.SetDimensions(len(lines), rows)
|
||||||
|
for y := range min(rows, len(lines)-n.view.Offset) {
|
||||||
|
in.TextStyled(0, y, lines[n.view.Offset+y], a.th.Text)
|
||||||
}
|
}
|
||||||
|
if rows < in.H {
|
||||||
// fit wraps lines to a dialog's inside on a screen w wide
|
where := fmt.Sprintf("j k: %d-%d of %d", n.view.Offset+1, n.view.Offset+rows, len(lines))
|
||||||
func fit(lines []string, w int) []string {
|
in.TextStyled(0, rows, ui.Truncate(where, in.W), a.th.Muted)
|
||||||
var out []string
|
|
||||||
for _, l := range lines {
|
|
||||||
out = append(out, ui.WrapText(l, max(1, w-6))...)
|
|
||||||
}
|
}
|
||||||
return out
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (n *notice) handle(a *app, ev terminal.Event) {
|
func (n *notice) handle(a *app, ev terminal.Event) {
|
||||||
if ev.Type == terminal.EventKey {
|
switch {
|
||||||
|
case ev.Type != terminal.EventKey:
|
||||||
|
case is(ev, terminal.KeyUp, "k"):
|
||||||
|
n.view.ScrollBy(-1)
|
||||||
|
case is(ev, terminal.KeyDown, "j"):
|
||||||
|
n.view.ScrollBy(1)
|
||||||
|
case is(ev, terminal.KeyPageUp, ""):
|
||||||
|
n.view.PageUp()
|
||||||
|
case is(ev, terminal.KeyPageDown, ""):
|
||||||
|
n.view.PageDown()
|
||||||
|
default:
|
||||||
a.dialog = nil
|
a.dialog = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// confirm asks a yes or no question
|
// confirm asks a yes or no question: y or n answers, as does Enter on the
|
||||||
|
// answer the arrows chose; Esc is no
|
||||||
type confirm struct {
|
type confirm struct {
|
||||||
question string
|
question string
|
||||||
yes func(a *app)
|
yes func(a *app)
|
||||||
|
state ui.ConfirmState
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *confirm) draw(a *app, r ui.Region) {
|
func (c *confirm) draw(a *app, r ui.Region) {
|
||||||
lines := fit([]string{c.question}, r.W)
|
lines := ui.WrapText(c.question, inside(r.W))
|
||||||
w := 0
|
w := 12 // the answers
|
||||||
for _, l := range lines {
|
for _, l := range lines {
|
||||||
w = max(w, ui.RuneLen(l)+6)
|
w = max(w, ui.RuneLen(l))
|
||||||
}
|
}
|
||||||
in := a.frame(r, w, len(lines)+5, "Confirm")
|
in := a.frame(r, w, len(lines)+1, "Confirm")
|
||||||
for i, l := range lines {
|
for i, l := range lines {
|
||||||
in.TextStyled(0, 1+i, l, a.th.Text)
|
in.TextStyled(0, i, l, a.th.Text)
|
||||||
}
|
}
|
||||||
in.TextStyled(0, 1+len(lines), "y yes n no", a.th.Muted)
|
in.Radio(0, in.H-1, []string{"yes", "no"}, map[bool]int{true: 0, false: 1}[c.state.FocusYes], a.th)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *confirm) handle(a *app, ev terminal.Event) {
|
func (c *confirm) handle(a *app, ev terminal.Event) {
|
||||||
switch {
|
if ev.Type != terminal.EventKey || !c.state.HandleKey(ev.Key, ev.Rune) {
|
||||||
case is(ev, terminal.KeyNone, "yY"):
|
return
|
||||||
a.dialog = nil
|
}
|
||||||
|
if a.dialog = nil; c.state.Result == ui.ConfirmYes {
|
||||||
c.yes(a)
|
c.yes(a)
|
||||||
case is(ev, terminal.KeyEscape, "nNq"):
|
|
||||||
a.dialog = nil
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// help lists the keys
|
// help lists the keys, each beside its description or, narrow, above it
|
||||||
func help(f font) dialog {
|
func help(f font) dialog {
|
||||||
keys := [][2]string{
|
keys := [][2]string{
|
||||||
{"arrows, hjkl", "move"},
|
{"arrows, hjkl", "move; up from the top row to the pipeline bar"},
|
||||||
{f.enter, "inspect the chosen part; edit a value"},
|
{f.enter, "inspect a part; edit a value; open a list; add"},
|
||||||
{"tab", "next value"},
|
{"tab", "next value"},
|
||||||
{"esc", "back"},
|
{"esc", "back"},
|
||||||
{"a", "add a source, sink or filter"},
|
{"a", "add a source, sink or filter; on the bar, a pipeline"},
|
||||||
|
{"c", "change a source's or sink's type"},
|
||||||
{"d", "delete it; a value back to its default"},
|
{"d", "delete it; a value back to its default"},
|
||||||
{f.space, "a flow stage or a switch on or off"},
|
{f.space, "a flow stage or a switch on or off"},
|
||||||
{"J K", "move a filter down or up"},
|
{"J K", "move a filter down or up"},
|
||||||
{"[ ]", "previous or next pipeline"},
|
{"[ ]", "previous or next pipeline"},
|
||||||
{"p", "start over from a preset"},
|
{"p", "start over from a preset"},
|
||||||
|
{"r", "run the pipelines"},
|
||||||
{"o", "run, or print a command line, environment or file"},
|
{"o", "run, or print a command line, environment or file"},
|
||||||
{"c", "the first problem"},
|
{"!", "the first problem"},
|
||||||
{"paste", "an lw command line replaces the pipelines"},
|
{"paste", "an lw command line replaces the pipelines"},
|
||||||
{"q", "quit"},
|
{"q", "quit"},
|
||||||
}
|
}
|
||||||
n := ¬ice{title: "Keys"}
|
return ¬ice{title: "Keys", body: func(w int) []string {
|
||||||
|
var lines []string
|
||||||
for _, k := range keys {
|
for _, k := range keys {
|
||||||
n.lines = append(n.lines, ui.PadRight(k[0], 14)+k[1])
|
if w < stacked {
|
||||||
|
lines = append(lines, k[0])
|
||||||
|
for _, l := range ui.WrapText(k[1], w-2) {
|
||||||
|
lines = append(lines, " "+l)
|
||||||
}
|
}
|
||||||
return n
|
continue
|
||||||
|
}
|
||||||
|
key := k[0]
|
||||||
|
for _, l := range ui.WrapText(k[1], w-14) {
|
||||||
|
lines, key = append(lines, ui.PadRight(key, 14)+l), ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lines
|
||||||
|
}}
|
||||||
}
|
}
|
||||||
+58
-37
@@ -21,15 +21,21 @@ type inspector struct {
|
|||||||
field *ui.TextFieldState // the value being typed, nil while none
|
field *ui.TextFieldState // the value being typed, nil while none
|
||||||
}
|
}
|
||||||
|
|
||||||
// line is one inspector row: a key, a group that folds, or a list's element
|
// line is one inspector row: a key, a group that folds, a list's element, or
|
||||||
|
// the row that adds an element or a filter
|
||||||
type line struct {
|
type line struct {
|
||||||
node compose.Node
|
node compose.Node
|
||||||
key config.Key // its Name is the dotted path in the node's options
|
key config.Key // its Name is the dotted path in the node's options, "" on a filter's rows
|
||||||
depth int
|
depth int
|
||||||
group string // a group's title, "" for a value
|
group string // a group's title, "" for a value
|
||||||
item int // a list element's index, the list's length on the row that adds one; -1 elsewhere
|
item int // a list element's index, the list's length on the row that adds one; -1 elsewhere
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// adds reports whether a row adds: a list's last row, or the filters'
|
||||||
|
func (a *app) adds(l line) bool {
|
||||||
|
return l.item >= 0 && (l.key.Name == "" || l.item == len(a.items(l.node, l.key.Name)))
|
||||||
|
}
|
||||||
|
|
||||||
func (a *app) foldKey(n compose.Node, path string) string {
|
func (a *app) foldKey(n compose.Node, path string) string {
|
||||||
return fmt.Sprintf("%d/%s/%s/%d/%s", a.pi, n.Role, n.ID, n.Index, path)
|
return fmt.Sprintf("%d/%s/%s/%d/%s", a.pi, n.Role, n.ID, n.Index, path)
|
||||||
}
|
}
|
||||||
@@ -44,14 +50,15 @@ func (a *app) lines() []line {
|
|||||||
return a.keyLines(nil, n, a.keys(n), "", 0)
|
return a.keyLines(nil, n, a.keys(n), "", 0)
|
||||||
}
|
}
|
||||||
var out []line
|
var out []line
|
||||||
for i, f := range a.pipeline().Flow.Filters {
|
filters := a.pipeline().Flow.Filters
|
||||||
|
for i, f := range filters {
|
||||||
fn := compose.Node{Role: "filters", Index: i}
|
fn := compose.Node{Role: "filters", Index: i}
|
||||||
out = append(out, line{node: fn, group: fmt.Sprintf("%d %s", i+1, f.Type), item: -1})
|
out = append(out, line{node: fn, group: fmt.Sprintf("%d %s", i+1, f.Type), item: -1})
|
||||||
if a.insp.open[a.foldKey(n, strconv.Itoa(i))] {
|
if a.insp.open[a.foldKey(n, strconv.Itoa(i))] {
|
||||||
out = a.keyLines(out, fn, a.keys(n), "", 1)
|
out = a.keyLines(out, fn, a.keys(n), "", 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out
|
return append(out, line{node: compose.Node{Role: "filters", Index: len(filters)}, item: len(filters)})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *app) keyLines(out []line, n compose.Node, keys []config.Key, prefix string, depth int) []line {
|
func (a *app) keyLines(out []line, n compose.Node, keys []config.Key, prefix string, depth int) []line {
|
||||||
@@ -120,31 +127,31 @@ func (a *app) drawInspector(r ui.Region) {
|
|||||||
if a.inspect {
|
if a.inspect {
|
||||||
hint = f.enter + " edit esc back"
|
hint = f.enter + " edit esc back"
|
||||||
}
|
}
|
||||||
r.TextStyled(0, 0, strings.Repeat(string(f.rule), r.W), th.Border)
|
r.Rule(0, title, hint, th)
|
||||||
r.TextStyled(2, 0, " "+ui.Truncate(title, r.W-ui.RuneLen(hint)-10)+" ", th.Text)
|
|
||||||
r.TextStyled(r.W-ui.RuneLen(hint)-3, 0, " "+hint+" ", th.Muted)
|
|
||||||
lines := a.lines()
|
lines := a.lines()
|
||||||
switch {
|
switch {
|
||||||
case !ok:
|
case !ok:
|
||||||
r.TextStyled(2, 2, "a adds a "+columns[a.col].role, th.Muted)
|
r.TextStyled(2, 2, "a adds a "+columns[a.col].role, th.Muted)
|
||||||
return
|
return
|
||||||
case len(lines) == 0 && n.Role == "filters":
|
|
||||||
r.TextStyled(2, 2, "no filters: a adds one", th.Muted)
|
|
||||||
return
|
|
||||||
case len(lines) == 0:
|
case len(lines) == 0:
|
||||||
r.TextStyled(2, 2, "no options", th.Muted)
|
r.TextStyled(2, 2, "no options", th.Muted)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
a.insp.cursor = max(0, min(a.insp.cursor, len(lines)-1))
|
a.insp.cursor = max(0, min(a.insp.cursor, len(lines)-1))
|
||||||
rows := r.H - 2
|
var help []string
|
||||||
|
style := th.Muted
|
||||||
|
if a.inspect {
|
||||||
|
var text string
|
||||||
|
text, style = a.help(lines[a.insp.cursor])
|
||||||
|
help = clip(ui.WrapText(text, r.W-4), 2)
|
||||||
|
}
|
||||||
|
rows := r.H - 1 - len(help)
|
||||||
cols, perCol := 1, rows
|
cols, perCol := 1, rows
|
||||||
if half := (len(lines) + 1) / 2; r.W >= 80 && half <= rows {
|
if half := (len(lines) + 1) / 2; r.W >= 80 && half <= rows {
|
||||||
cols, perCol = 2, half
|
cols, perCol = 2, half
|
||||||
}
|
}
|
||||||
if cols == 1 {
|
a.insp.scroll = ui.AdjustScroll(a.insp.cursor, ui.ClampScroll(a.insp.scroll, rows, len(lines)), rows, len(lines))
|
||||||
a.insp.scroll = max(0, min(a.insp.scroll, a.insp.cursor, len(lines)-rows))
|
if cols == 2 {
|
||||||
a.insp.scroll = max(a.insp.scroll, a.insp.cursor-rows+1)
|
|
||||||
} else {
|
|
||||||
a.insp.scroll = 0
|
a.insp.scroll = 0
|
||||||
}
|
}
|
||||||
colW, labelW := r.W/cols, 0
|
colW, labelW := r.W/cols, 0
|
||||||
@@ -157,9 +164,8 @@ func (a *app) drawInspector(r ui.Region) {
|
|||||||
row := r.Sub(at/perCol*colW, 1+at%perCol, colW-1, 1)
|
row := r.Sub(at/perCol*colW, 1+at%perCol, colW-1, 1)
|
||||||
a.drawLine(row, lines[i], labelW, a.inspect && i == a.insp.cursor)
|
a.drawLine(row, lines[i], labelW, a.inspect && i == a.insp.cursor)
|
||||||
}
|
}
|
||||||
if a.inspect && rows > 0 {
|
for i, h := range help {
|
||||||
help, style := a.help(lines[a.insp.cursor])
|
r.TextStyled(2, r.H-len(help)+i, h, style)
|
||||||
r.TextStyled(2, r.H-1, ui.Truncate(help, r.W-4), style)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,12 +183,15 @@ func (a *app) nodeTitle(n compose.Node) string {
|
|||||||
// help is the focused row's problem, or its key's help and default
|
// help is the focused row's problem, or its key's help and default
|
||||||
func (a *app) help(l line) (string, ui.Style) {
|
func (a *app) help(l line) (string, ui.Style) {
|
||||||
if a.problem != nil {
|
if a.problem != nil {
|
||||||
if pi, n, key, ok := locate(a.problem); ok && pi == a.pi && n == l.node && key == l.key.Name {
|
if pi, n, key, text, ok := locate(a.problem); ok && pi == a.pi && n == l.node && key == l.key.Name {
|
||||||
return a.problem.Error(), a.th.Error
|
return text, a.th.Error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if l.group != "" && l.key.Name == "" {
|
switch {
|
||||||
return "J and K move this filter; d deletes it", a.th.Muted
|
case l.group != "" && l.key.Name == "":
|
||||||
|
return "J and K move this filter; c changes its type; d deletes it", a.th.Muted
|
||||||
|
case a.adds(l) && l.key.Name == "":
|
||||||
|
return "adds a filter: include passes only the entries that match, exclude drops them", a.th.Muted
|
||||||
}
|
}
|
||||||
help := l.key.Help
|
help := l.key.Help
|
||||||
if d := placeholder(l.key); d != "" && l.group == "" {
|
if d := placeholder(l.key); d != "" && l.group == "" {
|
||||||
@@ -217,12 +226,12 @@ func (a *app) drawLine(r ui.Region, l line, labelW int, focused bool) {
|
|||||||
switch {
|
switch {
|
||||||
case focused && a.insp.field != nil:
|
case focused && a.insp.field != nil:
|
||||||
v.TextInput(a.insp.field, placeholder(k), true, th)
|
v.TextInput(a.insp.field, placeholder(k), true, th)
|
||||||
case l.item == len(items):
|
case a.adds(l):
|
||||||
v.TextStyled(0, 0, "+ add", th.Muted)
|
v.TextStyled(0, 0, "+ add", th.Muted)
|
||||||
case l.item >= 0:
|
case l.item >= 0:
|
||||||
v.TextStyled(0, 0, ui.Truncate(items[l.item], v.W), th.Text)
|
v.TextStyled(0, 0, ui.Truncate(items[l.item], v.W), th.Text)
|
||||||
case len(k.Enum) > 0:
|
case len(k.Enum) > 0:
|
||||||
v.Choice(0, 0, k.Enum, choice(k, cur), th)
|
v.Radio(0, 0, k.Enum, choice(k, cur), th)
|
||||||
case k.Kind == "bool":
|
case k.Kind == "bool":
|
||||||
v.Toggle(0, 0, cur == true || cur == nil && k.Default == "true", th)
|
v.Toggle(0, 0, cur == true || cur == nil && k.Default == "true", th)
|
||||||
case cur == nil:
|
case cur == nil:
|
||||||
@@ -289,7 +298,8 @@ func (a *app) inspectKey(ev terminal.Event) bool {
|
|||||||
k := l.key
|
k := l.key
|
||||||
cur := a.value(l.node, k.Name)
|
cur := a.value(l.node, k.Name)
|
||||||
focus := ui.Focus{Index: a.insp.cursor, Len: len(lines)}
|
focus := ui.Focus{Index: a.insp.cursor, Len: len(lines)}
|
||||||
toggle := is(ev, terminal.KeyEnter, " ") || is(ev, terminal.KeySpace, "")
|
enter, space := is(ev, terminal.KeyEnter, ""), is(ev, terminal.KeySpace, " ")
|
||||||
|
left, right := is(ev, terminal.KeyLeft, "h"), is(ev, terminal.KeyRight, "l")
|
||||||
switch {
|
switch {
|
||||||
case focus.HandleKey(ev.Key, ev.Modifiers):
|
case focus.HandleKey(ev.Key, ev.Modifiers):
|
||||||
a.insp.cursor = focus.Index
|
a.insp.cursor = focus.Index
|
||||||
@@ -297,31 +307,35 @@ func (a *app) inspectKey(ev terminal.Event) bool {
|
|||||||
a.insp.cursor = max(0, a.insp.cursor-1)
|
a.insp.cursor = max(0, a.insp.cursor-1)
|
||||||
case is(ev, terminal.KeyDown, "j"):
|
case is(ev, terminal.KeyDown, "j"):
|
||||||
a.insp.cursor = min(len(lines)-1, a.insp.cursor+1)
|
a.insp.cursor = min(len(lines)-1, a.insp.cursor+1)
|
||||||
case l.group != "" && (toggle || is(ev, terminal.KeyRight, "l") || is(ev, terminal.KeyLeft, "h")):
|
case a.adds(l) && k.Name == "" && enter:
|
||||||
|
a.dialog = a.addMenu()
|
||||||
|
case l.group != "" && (enter || space || left || right):
|
||||||
fold := a.foldKey(l.node, k.Name)
|
fold := a.foldKey(l.node, k.Name)
|
||||||
if k.Name == "" {
|
if k.Name == "" {
|
||||||
fold = a.foldKey(compose.Node{Role: "filters"}, strconv.Itoa(l.node.Index))
|
fold = a.foldKey(compose.Node{Role: "filters"}, strconv.Itoa(l.node.Index))
|
||||||
}
|
}
|
||||||
a.insp.open[fold] = toggle && !a.insp.open[fold] || is(ev, terminal.KeyRight, "l")
|
a.insp.open[fold] = (enter || space) && !a.insp.open[fold] || right
|
||||||
case l.group != "" && l.key.Name == "" && is(ev, terminal.KeyNone, "JK"):
|
case l.group != "" && k.Name == "" && is(ev, terminal.KeyNone, "JK"):
|
||||||
a.moveFilter(l.node.Index, map[rune]int{'J': 1, 'K': -1}[ev.Rune])
|
a.moveFilter(l.node.Index, map[rune]int{'J': 1, 'K': -1}[ev.Rune])
|
||||||
case is(ev, terminal.KeyNone, "d"):
|
case is(ev, terminal.KeyNone, "d"):
|
||||||
a.clear(l)
|
a.clear(l)
|
||||||
case len(k.Enum) > 0 && l.item < 0 && (toggle || is(ev, terminal.KeyLeft, "h") || is(ev, terminal.KeyRight, "l")):
|
case len(k.Enum) > 0 && l.item < 0 && enter:
|
||||||
|
a.dialog = a.enumMenu(l, cur)
|
||||||
|
case len(k.Enum) > 0 && l.item < 0 && (space || left || right):
|
||||||
i := choice(k, cur)
|
i := choice(k, cur)
|
||||||
switch next, moved := ui.StepChoice(ev.Key, ev.Rune, i, len(k.Enum)); {
|
switch next, moved := ui.StepChoice(ev.Key, ev.Rune, i, len(k.Enum)); {
|
||||||
case moved:
|
case moved:
|
||||||
i = next
|
i = next
|
||||||
case !toggle:
|
case !space:
|
||||||
return true // an arrow at either end
|
return true // an arrow at either end
|
||||||
default:
|
default:
|
||||||
i = (i + 1) % len(k.Enum)
|
i = (i + 1) % len(k.Enum)
|
||||||
}
|
}
|
||||||
a.edit(a.comp.Set(a.pi, l.node, k.Name, k.Enum[i]))
|
a.edit(a.comp.Set(a.pi, l.node, k.Name, k.Enum[i]))
|
||||||
case k.Kind == "bool" && l.item < 0 && toggle:
|
case k.Kind == "bool" && l.item < 0 && (enter || space || left || right):
|
||||||
on := cur == true || cur == nil && k.Default == "true"
|
on := cur == true || cur == nil && k.Default == "true"
|
||||||
a.edit(a.comp.Set(a.pi, l.node, k.Name, strconv.FormatBool(!on)))
|
a.edit(a.comp.Set(a.pi, l.node, k.Name, strconv.FormatBool(right || !left && !on)))
|
||||||
case is(ev, terminal.KeyEnter, ""):
|
case enter:
|
||||||
text := show(cmp.Or(cur, any("")))
|
text := show(cmp.Or(cur, any("")))
|
||||||
if items := a.items(l.node, k.Name); l.item >= 0 {
|
if items := a.items(l.node, k.Name); l.item >= 0 {
|
||||||
text = ""
|
text = ""
|
||||||
@@ -337,13 +351,14 @@ func (a *app) inspectKey(ev terminal.Event) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// typeKey edits the value being typed: Enter sets it, Tab sets it and moves
|
// typeKey edits the value being typed: Enter sets it, Tab and the arrows up
|
||||||
// on, Esc drops it
|
// and down set it and move on, Esc drops it
|
||||||
func (a *app) typeKey(ev terminal.Event, l line) {
|
func (a *app) typeKey(ev terminal.Event, l line) {
|
||||||
|
up, down := is(ev, terminal.KeyUp, ""), is(ev, terminal.KeyDown, "")
|
||||||
switch {
|
switch {
|
||||||
case is(ev, terminal.KeyEscape, ""):
|
case is(ev, terminal.KeyEscape, ""):
|
||||||
a.insp.field = nil
|
a.insp.field = nil
|
||||||
case is(ev, terminal.KeyEnter, "") || is(ev, terminal.KeyTab, "") || is(ev, terminal.KeyBacktab, ""):
|
case is(ev, terminal.KeyEnter, "") || is(ev, terminal.KeyTab, "") || is(ev, terminal.KeyBacktab, "") || up || down:
|
||||||
value := a.insp.field.Value()
|
value := a.insp.field.Value()
|
||||||
if a.commit(l, value) {
|
if a.commit(l, value) {
|
||||||
a.insp.field = nil
|
a.insp.field = nil
|
||||||
@@ -351,7 +366,12 @@ func (a *app) typeKey(ev terminal.Event, l line) {
|
|||||||
a.insp.cursor++ // stays on the row that adds one
|
a.insp.cursor++ // stays on the row that adds one
|
||||||
}
|
}
|
||||||
focus := ui.Focus{Index: a.insp.cursor, Len: len(a.lines())}
|
focus := ui.Focus{Index: a.insp.cursor, Len: len(a.lines())}
|
||||||
if focus.HandleKey(ev.Key, ev.Modifiers) {
|
switch {
|
||||||
|
case up:
|
||||||
|
a.insp.cursor = max(0, a.insp.cursor-1)
|
||||||
|
case down:
|
||||||
|
a.insp.cursor = min(focus.Len-1, a.insp.cursor+1)
|
||||||
|
case focus.HandleKey(ev.Key, ev.Modifiers):
|
||||||
a.insp.cursor = focus.Index
|
a.insp.cursor = focus.Index
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -390,6 +410,7 @@ func (a *app) commit(l line, value string) bool {
|
|||||||
// value, which returns to its default
|
// value, which returns to its default
|
||||||
func (a *app) clear(l line) {
|
func (a *app) clear(l line) {
|
||||||
switch {
|
switch {
|
||||||
|
case a.adds(l):
|
||||||
case l.group != "" && l.key.Name == "":
|
case l.group != "" && l.key.Name == "":
|
||||||
a.edit(a.comp.Remove(a.pi, l.node))
|
a.edit(a.comp.Remove(a.pi, l.node))
|
||||||
case l.item >= 0:
|
case l.item >= 0:
|
||||||
|
|||||||
+10
-10
@@ -55,14 +55,14 @@ var looks = map[terminal.ColorMode]look{
|
|||||||
Source: ui.MonoTheme.Text, Flow: ui.MonoTheme.Text, Sink: ui.MonoTheme.Text},
|
Source: ui.MonoTheme.Text, Flow: ui.MonoTheme.Text, Sink: ui.MonoTheme.Text},
|
||||||
}
|
}
|
||||||
|
|
||||||
// font is the marks one character set draws: the widgets' and the canvas's
|
// font is the marks one character set draws: the widgets' and the canvas's;
|
||||||
|
// Glyphs.Line draws frames, rules and wires
|
||||||
type font struct {
|
type font struct {
|
||||||
ui.Glyphs
|
ui.Glyphs
|
||||||
wire []rune // a wire cell by its arms: up 1, right 2, down 4, left 8
|
box ui.LineType // the flow box
|
||||||
box []rune // the flow box: top left, top right, bottom left, bottom right, across, down
|
|
||||||
arrow, down rune // into the flow box and a sink; between stacked parts
|
arrow, down rune // into the flow box and a sink; between stacked parts
|
||||||
bar, barOn rune // a node, and the chosen node
|
bar, barOn rune // a node, and the chosen node
|
||||||
dot, rule rune // before a column's title; the inspector's rule
|
dot rune // before a column's title
|
||||||
valid string
|
valid string
|
||||||
invalid string
|
invalid string
|
||||||
enter string
|
enter string
|
||||||
@@ -85,13 +85,13 @@ func (f font) fold(r rune) rune {
|
|||||||
|
|
||||||
// fonts: Unicode, a text console's CP437, and ASCII outside UTF-8
|
// fonts: Unicode, a text console's CP437, and ASCII outside UTF-8
|
||||||
var (
|
var (
|
||||||
unicodeFont = font{Glyphs: ui.GlyphsUnicode, wire: []rune(" │─└││┌├─┘─┴┐┤┬┼"), box: []rune("╭╮╰╯╌╎"),
|
unicodeFont = font{Glyphs: ui.GlyphsUnicode, box: ui.LineDashed,
|
||||||
arrow: '►', down: '▼', bar: '▌', barOn: '█', dot: '●', rule: '─', valid: "✓", invalid: "✗", enter: "⏎", space: "␣"}
|
arrow: '►', down: '▼', bar: '▌', barOn: '█', dot: '●', valid: "✓", invalid: "✗", enter: "⏎", space: "␣"}
|
||||||
cp437Font = font{Glyphs: ui.GlyphsCP437, wire: []rune(" │─└││┌├─┘─┴┐┤┬┼"), box: []rune("┌┐└┘─│"),
|
cp437Font = font{Glyphs: ui.GlyphsCP437, box: ui.LineSingle,
|
||||||
arrow: '►', down: '▼', bar: '▌', barOn: '█', dot: '•', rule: '─', valid: "√", invalid: "x", enter: "enter", space: "space",
|
arrow: '►', down: '▼', bar: '▌', barOn: '█', dot: '•', valid: "√", invalid: "x", enter: "enter", space: "space",
|
||||||
folds: map[rune]rune{'…': '.'}}
|
folds: map[rune]rune{'…': '.'}}
|
||||||
asciiFont = font{Glyphs: ui.GlyphsASCII, wire: []rune(" |-+||++-+-+++++"), box: []rune("++++-|"),
|
asciiFont = font{Glyphs: ui.GlyphsASCII, box: ui.LineASCII,
|
||||||
arrow: '>', down: 'v', bar: '|', barOn: '#', dot: '*', rule: '-', valid: "ok", invalid: "x", enter: "enter", space: "space",
|
arrow: '>', down: 'v', bar: '|', barOn: '#', dot: '*', valid: "ok", invalid: "x", enter: "enter", space: "space",
|
||||||
folds: map[rune]rune{'…': '.', '·': '-'}, ascii: true}
|
folds: map[rune]rune{'…': '.', '·': '-'}, ascii: true}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+184
-29
@@ -7,6 +7,7 @@ package tui
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/lixenwraith/logwisp/internal/compose"
|
"github.com/lixenwraith/logwisp/internal/compose"
|
||||||
@@ -88,7 +89,8 @@ type app struct {
|
|||||||
pi int // the pipeline shown
|
pi int // the pipeline shown
|
||||||
col int // the chosen column
|
col int // the chosen column
|
||||||
row [3]int // the chosen node of each column
|
row [3]int // the chosen node of each column
|
||||||
scroll int // the canvas's first row
|
scroll ui.ViewportScroll // the canvas's rows
|
||||||
|
bar bool // keys go to the pipeline bar
|
||||||
inspect bool // keys go to the inspector
|
inspect bool // keys go to the inspector
|
||||||
insp inspector
|
insp inspector
|
||||||
dialog dialog // takes every key while open
|
dialog dialog // takes every key while open
|
||||||
@@ -217,6 +219,8 @@ func (a *app) handle(ev terminal.Event) {
|
|||||||
case ev.Type == terminal.EventPaste:
|
case ev.Type == terminal.EventPaste:
|
||||||
a.paste(ev.Text)
|
a.paste(ev.Text)
|
||||||
case ev.Type != terminal.EventKey:
|
case ev.Type != terminal.EventKey:
|
||||||
|
case a.bar:
|
||||||
|
a.barKey(ev)
|
||||||
case !a.inspect:
|
case !a.inspect:
|
||||||
a.canvasKey(ev)
|
a.canvasKey(ev)
|
||||||
case !a.inspectKey(ev):
|
case !a.inspectKey(ev):
|
||||||
@@ -238,13 +242,17 @@ func (a *app) canvasKey(ev terminal.Event) {
|
|||||||
a.col = min(2, a.col+1)
|
a.col = min(2, a.col+1)
|
||||||
case is(ev, terminal.KeyTab, ""):
|
case is(ev, terminal.KeyTab, ""):
|
||||||
a.col = (a.col + 1) % 3
|
a.col = (a.col + 1) % 3
|
||||||
|
case is(ev, terminal.KeyUp, "k") && a.row[a.col] == 0:
|
||||||
|
a.bar = true
|
||||||
case is(ev, terminal.KeyUp, "k"):
|
case is(ev, terminal.KeyUp, "k"):
|
||||||
a.row[a.col] = max(0, a.row[a.col]-1)
|
a.row[a.col]--
|
||||||
case is(ev, terminal.KeyDown, "j"):
|
case is(ev, terminal.KeyDown, "j"):
|
||||||
a.row[a.col]++
|
a.row[a.col]++
|
||||||
a.node()
|
a.node()
|
||||||
case is(ev, terminal.KeyEnter, "") && ok:
|
case is(ev, terminal.KeyEnter, "") && (!ok || n.Role == "filters" && len(a.pipeline().Flow.Filters) == 0):
|
||||||
a.inspect, a.insp.cursor = true, 0
|
a.dialog = a.addMenu() // an empty column, or no filters, as a
|
||||||
|
case is(ev, terminal.KeyEnter, ""):
|
||||||
|
a.inspectFirst(n)
|
||||||
case is(ev, terminal.KeyNone, "a"):
|
case is(ev, terminal.KeyNone, "a"):
|
||||||
a.dialog = a.addMenu()
|
a.dialog = a.addMenu()
|
||||||
case is(ev, terminal.KeyNone, "d") && ok && n.Role == "filters":
|
case is(ev, terminal.KeyNone, "d") && ok && n.Role == "filters":
|
||||||
@@ -269,8 +277,12 @@ func (a *app) globalKey(ev terminal.Event) {
|
|||||||
a.dialog = a.outputMenu()
|
a.dialog = a.outputMenu()
|
||||||
case is(ev, terminal.KeyNone, "?"):
|
case is(ev, terminal.KeyNone, "?"):
|
||||||
a.dialog = help(a.font)
|
a.dialog = help(a.font)
|
||||||
case is(ev, terminal.KeyNone, "c"):
|
case is(ev, terminal.KeyNone, "!"):
|
||||||
a.showProblem()
|
a.showProblem()
|
||||||
|
case is(ev, terminal.KeyNone, "c"):
|
||||||
|
a.changeType()
|
||||||
|
case is(ev, terminal.KeyNone, "r"):
|
||||||
|
a.run()
|
||||||
case is(ev, terminal.KeyNone, "]") && n > 0:
|
case is(ev, terminal.KeyNone, "]") && n > 0:
|
||||||
a.pi, a.row, a.inspect = (a.pi+1)%n, [3]int{}, false
|
a.pi, a.row, a.inspect = (a.pi+1)%n, [3]int{}, false
|
||||||
case is(ev, terminal.KeyNone, "[") && n > 0:
|
case is(ev, terminal.KeyNone, "[") && n > 0:
|
||||||
@@ -280,6 +292,76 @@ func (a *app) globalKey(ev terminal.Event) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// barKey takes the keys on the pipeline bar: switch, add, rename, delete
|
||||||
|
func (a *app) barKey(ev terminal.Event) {
|
||||||
|
n := len(a.comp.Pipelines)
|
||||||
|
switch p := a.pipeline(); {
|
||||||
|
case is(ev, terminal.KeyLeft, "h["):
|
||||||
|
a.pi, a.row = (a.pi+n-1)%n, [3]int{}
|
||||||
|
case is(ev, terminal.KeyRight, "l]"):
|
||||||
|
a.pi, a.row = (a.pi+1)%n, [3]int{}
|
||||||
|
case is(ev, terminal.KeyDown, "j") || is(ev, terminal.KeyEscape, ""):
|
||||||
|
a.bar = false
|
||||||
|
case is(ev, terminal.KeyNone, "a"):
|
||||||
|
a.dialog = a.pipelineMenu("Add a pipeline", (*app).append)
|
||||||
|
case is(ev, terminal.KeyEnter, ""):
|
||||||
|
f := &form{title: "Rename pipeline " + p.Name, params: []config.PresetParam{{Name: "name", Required: true, Help: "a name no other pipeline has"}},
|
||||||
|
apply: func(a *app, v map[string]string) error {
|
||||||
|
err := a.comp.RenamePipeline(a.pi, v["name"])
|
||||||
|
if err == nil {
|
||||||
|
a.edit(nil)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}}
|
||||||
|
f.fill(map[string]string{"name": p.Name})
|
||||||
|
a.dialog = f
|
||||||
|
case is(ev, terminal.KeyNone, "d"):
|
||||||
|
remove := func(a *app) {
|
||||||
|
a.edit(a.comp.RemovePipeline(a.pi))
|
||||||
|
a.pi, a.row, a.insp.open = max(0, a.pi-1), [3]int{}, map[string]bool{} // folds are keyed by index
|
||||||
|
if len(a.comp.Pipelines) == 0 {
|
||||||
|
a.bar, a.dialog = false, a.presetMenu()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(p.PluginSources)+len(p.PluginSinks) == 0 {
|
||||||
|
remove(a)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.dialog = &confirm{question: "Delete pipeline " + p.Name + "?", yes: remove}
|
||||||
|
default:
|
||||||
|
a.globalKey(ev)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// changeType offers a source or sink the other types of its role; a filter
|
||||||
|
// or the format changes type on its type row
|
||||||
|
func (a *app) changeType() {
|
||||||
|
n, ok := a.node()
|
||||||
|
if l := a.lines(); a.inspect && ok && len(l) > 0 {
|
||||||
|
n = l[min(a.insp.cursor, len(l)-1)].node
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case !ok || n.Role == "filters" && !a.inspect:
|
||||||
|
a.note = "c changes the type of a source, sink, filter or format"
|
||||||
|
case n.Role == "source" || n.Role == "sink":
|
||||||
|
a.dialog = a.typeMenu(n)
|
||||||
|
case n.Role == "filters" && n.Index < len(a.pipeline().Flow.Filters), n.Role == "format":
|
||||||
|
a.choose(n)
|
||||||
|
a.inspectAt(func(l line) bool { return l.node == n && l.key.Name == "type" })
|
||||||
|
default:
|
||||||
|
a.note = "c changes the type of a source, sink, filter or format"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// run leaves to start the pipelines, once they are valid
|
||||||
|
func (a *app) run() {
|
||||||
|
if a.problem != nil {
|
||||||
|
a.status = "not valid yet: ! shows the problem"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.result, a.done = Result{Exit: Start, Pipelines: a.comp.Pipelines}, true
|
||||||
|
}
|
||||||
|
|
||||||
// switchStage turns a flow stage on with its defaults, or off; a stage with
|
// switchStage turns a flow stage on with its defaults, or off; a stage with
|
||||||
// an enabled key switches that
|
// an enabled key switches that
|
||||||
func (a *app) switchStage(n compose.Node) {
|
func (a *app) switchStage(n compose.Node) {
|
||||||
@@ -308,7 +390,8 @@ func slicesHasKey(keys []config.Key, name string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// paste starts over from a pasted command line
|
// paste starts over from a pasted command line, asking first when there are
|
||||||
|
// edited pipelines to lose
|
||||||
func (a *app) paste(text string) {
|
func (a *app) paste(text string) {
|
||||||
c, err := compose.FromCommandLine(text, config.HostIsDir)
|
c, err := compose.FromCommandLine(text, config.HostIsDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -320,7 +403,7 @@ func (a *app) paste(text string) {
|
|||||||
a.note = fmt.Sprintf("pasted %d pipeline(s)", len(c.Pipelines))
|
a.note = fmt.Sprintf("pasted %d pipeline(s)", len(c.Pipelines))
|
||||||
a.check()
|
a.check()
|
||||||
}
|
}
|
||||||
if !a.changed {
|
if !a.changed || len(a.comp.Pipelines) == 0 {
|
||||||
replace(a)
|
replace(a)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -341,18 +424,25 @@ func (a *app) showProblem() {
|
|||||||
a.note = "the pipelines are valid"
|
a.note = "the pipelines are valid"
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if pi, n, key, ok := locate(a.problem); ok && pi < len(a.comp.Pipelines) {
|
if pi, n, key, _, ok := locate(a.problem); ok && pi < len(a.comp.Pipelines) {
|
||||||
a.pi = pi
|
a.pi = pi
|
||||||
a.choose(n)
|
a.choose(n)
|
||||||
a.inspect = true
|
a.inspectAt(func(l line) bool { return l.node == n && l.key.Name == key })
|
||||||
a.insp.cursor = 0
|
|
||||||
for i, l := range a.lines() {
|
|
||||||
if l.node == n && l.key.Name == key {
|
|
||||||
a.insp.cursor = i
|
|
||||||
}
|
}
|
||||||
|
a.dialog = textNotice("Problem", a.problem.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// inspectFirst opens the inspector on a node's first required key, or on a
|
||||||
|
// filter's group
|
||||||
|
func (a *app) inspectFirst(n compose.Node) {
|
||||||
|
a.inspectAt(func(l line) bool { return l.node == n && (l.key.Required || l.key.Name == "") })
|
||||||
}
|
}
|
||||||
a.dialog = ¬ice{title: "Problem", lines: ui.WrapText(a.problem.Error(), 60)}
|
|
||||||
|
// inspectAt opens the inspector, which takes the keys from the bar, on the
|
||||||
|
// first line at holds for, or on its top
|
||||||
|
func (a *app) inspectAt(at func(line) bool) {
|
||||||
|
a.inspect, a.bar = true, false
|
||||||
|
a.insp.cursor = max(0, slices.IndexFunc(a.lines(), at))
|
||||||
}
|
}
|
||||||
|
|
||||||
// choose puts the canvas cursor on a node, a filter on the filters stage
|
// choose puts the canvas cursor on a node, a filter on the filters stage
|
||||||
@@ -374,13 +464,12 @@ func (a *app) choose(n compose.Node) {
|
|||||||
// it from 110 columns or below it, a status row, and any dialog over them
|
// it from 110 columns or below it, a status row, and any dialog over them
|
||||||
func (a *app) draw(r ui.Region) {
|
func (a *app) draw(r ui.Region) {
|
||||||
r.FillStyle(a.th.Text)
|
r.FillStyle(a.th.Text)
|
||||||
|
status, style := a.bottom(r.W)
|
||||||
if len(a.comp.Pipelines) > 0 {
|
if len(a.comp.Pipelines) > 0 {
|
||||||
a.pi = min(a.pi, len(a.comp.Pipelines)-1)
|
a.pi = min(a.pi, len(a.comp.Pipelines)-1)
|
||||||
a.node() // a delete can leave the row past its column's end
|
a.node() // a delete can leave the row past its column's end
|
||||||
p := a.pipeline()
|
a.drawBar(r.Sub(0, 0, r.W, 1))
|
||||||
title := fmt.Sprintf("LogWisp pipeline %s %d of %d", p.Name, a.pi+1, len(a.comp.Pipelines))
|
body := r.Sub(0, 1, r.W, r.H-1-len(status))
|
||||||
r.TextStyled(1, 0, ui.Truncate(title, r.W-2), a.th.Accent)
|
|
||||||
body := r.Sub(0, 1, r.W, r.H-2)
|
|
||||||
if r.W >= 110 {
|
if r.W >= 110 {
|
||||||
iw := min(60, r.W*2/5)
|
iw := min(60, r.W*2/5)
|
||||||
a.drawCanvas(body.Sub(0, 0, r.W-iw-1, body.H))
|
a.drawCanvas(body.Sub(0, 0, r.W-iw-1, body.H))
|
||||||
@@ -391,12 +480,56 @@ func (a *app) draw(r ui.Region) {
|
|||||||
a.drawInspector(body.Sub(0, ch+1, r.W, body.H-ch-1))
|
a.drawInspector(body.Sub(0, ch+1, r.W, body.H-ch-1))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
a.drawStatus(r.Sub(0, r.H-1, r.W, 1))
|
for i, l := range status {
|
||||||
|
r.TextStyled(1, r.H-len(status)+i, l, style)
|
||||||
|
}
|
||||||
|
if a.status == "" && a.note == "" && a.problem == nil {
|
||||||
|
keys := a.hints(r.W)
|
||||||
|
r.TextStyled(r.W-ui.RuneLen(keys)-1, r.H-1, keys, a.th.Muted)
|
||||||
|
}
|
||||||
if a.dialog != nil {
|
if a.dialog != nil {
|
||||||
a.dialog.draw(a, r)
|
a.dialog.draw(a, r)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// drawBar draws the title row: the pipelines by name, the one shown marked,
|
||||||
|
// or it and its place when they do not fit; with the keys, the bar's verbs
|
||||||
|
func (a *app) drawBar(r ui.Region) {
|
||||||
|
th, f := a.th, a.font
|
||||||
|
if a.bar {
|
||||||
|
r.TextStyled(0, 0, string(f.Focus), th.Accent)
|
||||||
|
hint := "a add " + f.enter + " rename d delete"
|
||||||
|
if r.W >= 60 {
|
||||||
|
r.TextStyled(r.W-ui.RuneLen(hint)-1, 0, hint, th.Muted)
|
||||||
|
r = r.Sub(0, 0, r.W-ui.RuneLen(hint)-2, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r.TextStyled(2, 0, "LogWisp", th.Accent)
|
||||||
|
names, w := a.comp.Pipelines, 11
|
||||||
|
for _, p := range names {
|
||||||
|
w += ui.RuneLen(p.Name) + 4
|
||||||
|
}
|
||||||
|
if w > r.W {
|
||||||
|
names = names[a.pi : a.pi+1]
|
||||||
|
}
|
||||||
|
x := 11
|
||||||
|
for _, p := range names {
|
||||||
|
mark, style := f.Off, th.Muted
|
||||||
|
if p.Name == a.pipeline().Name {
|
||||||
|
mark, style = f.On, th.Text
|
||||||
|
if a.bar {
|
||||||
|
style = th.Text.On(th.Selected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
label := ui.Truncate(string(mark)+" "+p.Name, max(0, r.W-x))
|
||||||
|
r.TextStyled(x, 0, label, style)
|
||||||
|
x += ui.RuneLen(label) + 2
|
||||||
|
}
|
||||||
|
if len(names) < len(a.comp.Pipelines) {
|
||||||
|
r.TextStyled(x, 0, fmt.Sprintf("%d/%d", a.pi+1, len(a.comp.Pipelines)), th.Muted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// paint draws the screen into cells, each rune one the font's terminal shows
|
// paint draws the screen into cells, each rune one the font's terminal shows
|
||||||
func (a *app) paint(cells []terminal.Cell, w, h int) {
|
func (a *app) paint(cells []terminal.Cell, w, h int) {
|
||||||
a.draw(ui.NewRegion(cells, w, 0, 0, w, h))
|
a.draw(ui.NewRegion(cells, w, 0, 0, w, h))
|
||||||
@@ -405,23 +538,45 @@ func (a *app) paint(cells []terminal.Cell, w, h int) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// drawStatus shows an edit's refusal, a note, or whether the pipelines are
|
// bottom is the status rows, wrapped to three at most: an edit's refusal, a
|
||||||
// valid, and the keys
|
// note, the first problem by its part, or the keys while all is valid
|
||||||
func (a *app) drawStatus(r ui.Region) {
|
func (a *app) bottom(w int) ([]string, ui.Style) {
|
||||||
th, f := a.th, a.font
|
th, f := a.th, a.font
|
||||||
text, style := f.valid+" valid", th.Accent
|
text, style := "", th.Error
|
||||||
switch {
|
switch {
|
||||||
case a.status != "":
|
case a.status != "":
|
||||||
text, style = a.status, th.Error
|
text = a.status
|
||||||
case a.note != "":
|
case a.note != "":
|
||||||
text, style = a.note, th.Muted
|
text, style = a.note, th.Muted
|
||||||
case a.problem != nil:
|
case a.problem != nil:
|
||||||
text, style = f.invalid+" "+a.problem.Error(), th.Error
|
pi, n, key, msg, _ := locate(a.problem)
|
||||||
|
if where := a.where(pi, n, key); where != "" {
|
||||||
|
msg = where + ": " + msg
|
||||||
}
|
}
|
||||||
keys := "a add d delete " + f.space + " on/off J/K move p preset o output ? help"
|
text = f.invalid + " " + msg
|
||||||
if ui.RuneLen(keys)+24 > r.W {
|
default:
|
||||||
|
return []string{f.valid + " valid"}, th.Accent
|
||||||
|
}
|
||||||
|
return clip(ui.WrapText(text, w-2), 3), style
|
||||||
|
}
|
||||||
|
|
||||||
|
// hints are the status row's keys, shown while nothing else needs the row
|
||||||
|
func (a *app) hints(w int) string {
|
||||||
|
keys := "a add d delete " + a.font.space + " on/off J/K move p preset o output ? help"
|
||||||
|
if ui.RuneLen(keys)+12 > w {
|
||||||
keys = "? help"
|
keys = "? help"
|
||||||
}
|
}
|
||||||
r.TextStyled(1, 0, ui.Truncate(text, r.W-ui.RuneLen(keys)-4), style)
|
return keys
|
||||||
r.TextStyled(r.W-ui.RuneLen(keys)-1, 0, keys, th.Muted)
|
}
|
||||||
|
|
||||||
|
// clip keeps n lines, the last cut short when more follow
|
||||||
|
func clip(lines []string, n int) []string {
|
||||||
|
if len(lines) > n {
|
||||||
|
if n < 1 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
lines = lines[:n]
|
||||||
|
lines[n-1] = ui.Truncate(lines[n-1]+"…", ui.RuneLen(lines[n-1]))
|
||||||
|
}
|
||||||
|
return lines
|
||||||
}
|
}
|
||||||
+248
-12
@@ -197,8 +197,9 @@ func TestSelectionChangesAGlyph(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The inspector types a value and sets it, typed by the key's kind, and Tab
|
// The inspector types a value and sets it, typed by the key's kind, and Tab
|
||||||
// moves on; empty or d returns it to its default; arrows step a choice,
|
// or Up moves on; empty or d returns it to its default; arrows step a choice,
|
||||||
// setting nothing past either end, and Space a switch
|
// setting nothing past either end, and set a switch; Enter opens a choice's
|
||||||
|
// options, at its value, and Space flips a switch
|
||||||
func TestInspectorSetsKeys(t *testing.T) {
|
func TestInspectorSetsKeys(t *testing.T) {
|
||||||
a := mono(pipeline(t, []string{"file"}, []string{"null"}))
|
a := mono(pipeline(t, []string{"file"}, []string{"null"}))
|
||||||
press(a, terminal.KeyEnter)
|
press(a, terminal.KeyEnter)
|
||||||
@@ -221,8 +222,24 @@ func TestInspectorSetsKeys(t *testing.T) {
|
|||||||
focusKey(t, a, "from")
|
focusKey(t, a, "from")
|
||||||
press(a, terminal.KeyLeft)
|
press(a, terminal.KeyLeft)
|
||||||
focusKey(t, a, "raw")
|
focusKey(t, a, "raw")
|
||||||
press(a, " ")
|
if press(a, " ", terminal.KeyLeft, terminal.KeyLeft); opts()["raw"] != false {
|
||||||
if o := opts(); o["directory"] != "/srv/a b" || o["check_interval_ms"] != int64(15) || o["from"] != "start" || o["raw"] != true {
|
t.Fatalf("Left left raw %v", opts()["raw"])
|
||||||
|
}
|
||||||
|
press(a, terminal.KeyRight, terminal.KeyRight)
|
||||||
|
focusKey(t, a, "pattern")
|
||||||
|
at = a.insp.cursor
|
||||||
|
if press(a, terminal.KeyEnter, "x", terminal.KeyUp); a.insp.cursor != at-1 || opts()["pattern"] != "x" {
|
||||||
|
t.Fatalf("Up left the cursor on row %d, pattern %v", a.insp.cursor, opts()["pattern"])
|
||||||
|
}
|
||||||
|
focusKey(t, a, "from")
|
||||||
|
if press(a, terminal.KeyEnter); a.dialog.(*chooser).list.Cursor != 0 {
|
||||||
|
t.Fatalf("the options open off the value: %+v", a.dialog)
|
||||||
|
}
|
||||||
|
if press(a, terminal.KeyDown, terminal.KeyEscape); opts()["from"] != "start" {
|
||||||
|
t.Fatalf("Esc set %v", opts()["from"])
|
||||||
|
}
|
||||||
|
press(a, terminal.KeyEnter, terminal.KeyDown, terminal.KeyEnter)
|
||||||
|
if o := opts(); o["directory"] != "/srv/a b" || o["check_interval_ms"] != int64(15) || o["from"] != "end" || o["raw"] != true {
|
||||||
t.Fatalf("%v", o)
|
t.Fatalf("%v", o)
|
||||||
}
|
}
|
||||||
focusKey(t, a, "directory")
|
focusKey(t, a, "directory")
|
||||||
@@ -294,8 +311,9 @@ func TestFiltersMoveWithTheirFold(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// o leaves with the chosen form, printed as the engine writes it, or with
|
// o leaves with the form its letter picks, printed as the engine writes it,
|
||||||
// the pipelines to run; a composition that does not validate stays
|
// or with the pipelines to run, as r does; a composition that does not
|
||||||
|
// validate stays
|
||||||
func TestOutputLeavesWithTheForm(t *testing.T) {
|
func TestOutputLeavesWithTheForm(t *testing.T) {
|
||||||
c := pipeline(t, []string{"null"}, []string{"null"})
|
c := pipeline(t, []string{"null"}, []string{"null"})
|
||||||
line, err := c.CommandLine()
|
line, err := c.CommandLine()
|
||||||
@@ -303,7 +321,7 @@ func TestOutputLeavesWithTheForm(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
a := mono(c)
|
a := mono(c)
|
||||||
press(a, "o", "comm", terminal.KeyEnter)
|
press(a, "o", "c")
|
||||||
if !a.done || a.result.Exit != Print || a.result.Output != line {
|
if !a.done || a.result.Exit != Print || a.result.Output != line {
|
||||||
t.Fatalf("%+v", a.result)
|
t.Fatalf("%+v", a.result)
|
||||||
}
|
}
|
||||||
@@ -312,8 +330,13 @@ func TestOutputLeavesWithTheForm(t *testing.T) {
|
|||||||
if !a.done || a.result.Exit != Start || len(a.result.Pipelines) != 1 {
|
if !a.done || a.result.Exit != Start || len(a.result.Pipelines) != 1 {
|
||||||
t.Fatalf("%+v", a.result)
|
t.Fatalf("%+v", a.result)
|
||||||
}
|
}
|
||||||
|
a = mono(pipeline(t, []string{"null"}, []string{"null"}))
|
||||||
|
press(a, "r")
|
||||||
|
if !a.done || a.result.Exit != Start || len(a.result.Pipelines) != 1 {
|
||||||
|
t.Fatalf("r: %+v", a.result)
|
||||||
|
}
|
||||||
a = mono(pipeline(t, []string{"file"}, []string{"null"}))
|
a = mono(pipeline(t, []string{"file"}, []string{"null"}))
|
||||||
press(a, "o", terminal.KeyEnter)
|
press(a, "o", terminal.KeyEnter, "r")
|
||||||
if a.done || a.status == "" {
|
if a.done || a.status == "" {
|
||||||
t.Fatalf("ran an invalid composition: %+v", a.result)
|
t.Fatalf("ran an invalid composition: %+v", a.result)
|
||||||
}
|
}
|
||||||
@@ -351,7 +374,8 @@ func TestPresetsStartThePipeline(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// A pasted command line replaces the pipelines, from the preset menu too,
|
// A pasted command line replaces the pipelines, from the preset menu too,
|
||||||
// asking first once they were edited; one that names none is refused
|
// asking first once they were edited and while some remain; one that names
|
||||||
|
// none is refused
|
||||||
func TestPasteReplacesThePipelines(t *testing.T) {
|
func TestPasteReplacesThePipelines(t *testing.T) {
|
||||||
a := mono(&compose.Composition{})
|
a := mono(&compose.Composition{})
|
||||||
if press(a, []byte("lw\n")); a.status == "" {
|
if press(a, []byte("lw\n")); a.status == "" {
|
||||||
@@ -368,22 +392,42 @@ func TestPasteReplacesThePipelines(t *testing.T) {
|
|||||||
if a.pipeline().Name != "x" {
|
if a.pipeline().Name != "x" {
|
||||||
t.Fatalf("replaced edited pipelines without asking: %+v", a.comp.Pipelines)
|
t.Fatalf("replaced edited pipelines without asking: %+v", a.comp.Pipelines)
|
||||||
}
|
}
|
||||||
|
a = mono(pipeline(t, []string{"null"}, []string{"null"}))
|
||||||
|
press(a, "k", "d", "y", []byte("lw --pipeline z --source null --sink null"))
|
||||||
|
if a.dialog != nil || len(a.comp.Pipelines) != 1 || a.pipeline().Name != "z" {
|
||||||
|
t.Fatalf("a paste after the last pipeline was deleted: %T %+v", a.dialog, a.comp.Pipelines)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The first problem marks the part it names, and c moves there, to the key
|
// The first problem marks the part it names, and ! moves there, to the key
|
||||||
func TestProblemNamesItsPart(t *testing.T) {
|
func TestProblemNamesItsPart(t *testing.T) {
|
||||||
a := mono(pipeline(t, []string{"null"}, []string{"null", "tcp"}))
|
a := mono(pipeline(t, []string{"null"}, []string{"null", "tcp"}))
|
||||||
sink := compose.Node{Role: "sink", ID: "tcp"}
|
sink := compose.Node{Role: "sink", ID: "tcp"}
|
||||||
if !a.faulty(sink) || a.faulty(compose.Node{Role: "source", ID: "null"}) || a.faulty(compose.Node{Role: "sink", ID: "null"}) {
|
if !a.faulty(sink) || a.faulty(compose.Node{Role: "source", ID: "null"}) || a.faulty(compose.Node{Role: "sink", ID: "null"}) {
|
||||||
t.Fatalf("problem %v", a.problem)
|
t.Fatalf("problem %v", a.problem)
|
||||||
}
|
}
|
||||||
press(a, "c")
|
press(a, "!")
|
||||||
n, _ := a.node()
|
n, _ := a.node()
|
||||||
if l := a.lines()[a.insp.cursor]; n != sink || !a.inspect || l.key.Name != "port" {
|
if l := a.lines()[a.insp.cursor]; n != sink || !a.inspect || l.key.Name != "port" {
|
||||||
t.Fatalf("on %+v, row %+v", n, l)
|
t.Fatalf("on %+v, row %+v", n, l)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ! and c on the pipeline bar open the inspector, which then takes the keys:
|
||||||
|
// Enter edits the key it shows, not the pipeline's name
|
||||||
|
func TestTheInspectorTakesTheKeysFromTheBar(t *testing.T) {
|
||||||
|
a := mono(pipeline(t, []string{"null"}, []string{"tcp"}))
|
||||||
|
press(a, "k", "!", terminal.KeyEscape, terminal.KeyEnter)
|
||||||
|
if a.bar || a.insp.field == nil {
|
||||||
|
t.Fatalf("Enter after ! on the bar: bar %v, %T", a.bar, a.dialog)
|
||||||
|
}
|
||||||
|
a = mono(pipeline(t, []string{"null"}, []string{"null"}))
|
||||||
|
press(a, "k", "c", terminal.KeyEnter, terminal.KeyEnter)
|
||||||
|
if a.bar || !a.inspect || a.dialog != nil {
|
||||||
|
t.Fatalf("Enter after c on the bar: bar %v, %T", a.bar, a.dialog)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 16 colors and none keep the terminal's own background; more paint the site's
|
// 16 colors and none keep the terminal's own background; more paint the site's
|
||||||
func TestOnlyFullColorPaintsTheBackground(t *testing.T) {
|
func TestOnlyFullColorPaintsTheBackground(t *testing.T) {
|
||||||
for mode, l := range looks {
|
for mode, l := range looks {
|
||||||
@@ -451,7 +495,9 @@ func TestPresetFormShowsTheFocusedField(t *testing.T) {
|
|||||||
presets := config.Presets()
|
presets := config.Presets()
|
||||||
p := slices.MaxFunc(presets, func(x, y config.Preset) int { return len(x.Params) - len(y.Params) })
|
p := slices.MaxFunc(presets, func(x, y config.Preset) int { return len(x.Params) - len(y.Params) })
|
||||||
a := mono(pipeline(t, []string{"null"}, []string{"null"}))
|
a := mono(pipeline(t, []string{"null"}, []string{"null"}))
|
||||||
a.dialog = newPresetForm(p)
|
f := &form{title: p.Name, params: p.Params}
|
||||||
|
f.fill(nil)
|
||||||
|
a.dialog = f
|
||||||
press(a, terminal.KeyBacktab)
|
press(a, terminal.KeyBacktab)
|
||||||
last := p.Params[len(p.Params)-1].Name
|
last := p.Params[len(p.Params)-1].Name
|
||||||
if screen := strings.Join(text(render(a, 80, 12), 80), "\n"); !strings.Contains(screen, last) {
|
if screen := strings.Join(text(render(a, 80, 12), 80), "\n"); !strings.Contains(screen, last) {
|
||||||
@@ -459,6 +505,96 @@ func TestPresetFormShowsTheFocusedField(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// presetForm is a preset's form, open on a
|
||||||
|
func presetForm(a *app, name string) *form {
|
||||||
|
presets := config.Presets()
|
||||||
|
p := presets[slices.IndexFunc(presets, func(p config.Preset) bool { return p.Name == name })]
|
||||||
|
f := &form{title: "preset " + p.Name, summary: p.Summary, params: p.Params}
|
||||||
|
f.fill(nil)
|
||||||
|
a.dialog = f
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
// A preset form is usable at 25 columns: labels stack above their values,
|
||||||
|
// which take the row, so typed text shows whole
|
||||||
|
func TestPresetFormFitsANarrowScreen(t *testing.T) {
|
||||||
|
a := mono(pipeline(t, nil, nil))
|
||||||
|
presetForm(a, "serve")
|
||||||
|
press(a, terminal.KeyTab, terminal.KeyTab, terminal.KeyTab, "127.0.0.1:9000")
|
||||||
|
if screen := strings.Join(text(render(a, 25, 20), 25), "\n"); !strings.Contains(screen, "listen") || !strings.Contains(screen, "127.0.0.1:9000") {
|
||||||
|
t.Fatalf("listen and its value not whole at 25 columns:\n%s", screen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A dialog is sized to what it holds: its first and last rows inside the
|
||||||
|
// box have text, a form without a summary included
|
||||||
|
func TestDialogsHaveNoBlankEdgeRows(t *testing.T) {
|
||||||
|
var a *app
|
||||||
|
for name, open := range map[string]func(){
|
||||||
|
"add": func() { press(a, "a") },
|
||||||
|
"presets": func() { press(a, "p") },
|
||||||
|
"output": func() { press(a, "o") },
|
||||||
|
"confirm": func() { press(a, "q") },
|
||||||
|
"help": func() { press(a, "?") },
|
||||||
|
"problem": func() { press(a, "!") },
|
||||||
|
"rename": func() { press(a, terminal.KeyUp, terminal.KeyEnter) },
|
||||||
|
"form": func() { presetForm(a, "serve") },
|
||||||
|
} {
|
||||||
|
a = mono(pipeline(t, nil, nil))
|
||||||
|
a.changed, a.dialog = true, nil
|
||||||
|
open()
|
||||||
|
rows := text(render(a, 80, 40), 80)
|
||||||
|
bottom := slices.IndexFunc(rows, func(r string) bool { return strings.Contains(r, "└") })
|
||||||
|
top := slices.IndexFunc(rows, func(r string) bool { return strings.Contains(r, "┌") })
|
||||||
|
if a.dialog == nil || top < 0 || bottom < top+2 {
|
||||||
|
t.Errorf("%s: no dialog:\n%s", name, strings.Join(rows, "\n"))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
edge := []rune(rows[bottom])
|
||||||
|
left, right := slices.Index(edge, '└'), slices.Index(edge, '┘')
|
||||||
|
for _, y := range []int{top + 1, bottom - 1} {
|
||||||
|
if inside := strings.Trim(string([]rune(rows[y])[left:right]), " │"); inside == "" {
|
||||||
|
t.Errorf("%s: row %d blank:\n%s", name, y, strings.Join(rows[top:bottom+1], "\n"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A question is answered by y or n, or by Enter on the answer the arrows
|
||||||
|
// chose; no is chosen first
|
||||||
|
func TestConfirmAnswersOnEnter(t *testing.T) {
|
||||||
|
a := mono(pipeline(t, []string{"null"}, []string{"null"}))
|
||||||
|
a.changed = true
|
||||||
|
if press(a, "q", terminal.KeyEnter); a.done || a.dialog != nil {
|
||||||
|
t.Fatalf("Enter on no: done %v, dialog %T", a.done, a.dialog)
|
||||||
|
}
|
||||||
|
if press(a, "q", terminal.KeyLeft, terminal.KeyEnter); !a.done {
|
||||||
|
t.Fatal("Enter on yes did not quit")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The canvas scrolls to keep the chosen part in view, its bar solid
|
||||||
|
func TestCanvasKeepsTheChosenInView(t *testing.T) {
|
||||||
|
a := mono(pipeline(t, []string{"null"}, slices.Repeat([]string{"null"}, 8)))
|
||||||
|
a.col, a.row[sinksCol] = sinksCol, 7
|
||||||
|
if screen := strings.Join(text(render(a, 80, 24), 80), "\n"); !strings.ContainsRune(screen, a.font.barOn) {
|
||||||
|
t.Fatalf("the eighth sink is out of view:\n%s", screen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A notice taller than the screen scrolls with j and k and says where it is
|
||||||
|
func TestNoticeScrolls(t *testing.T) {
|
||||||
|
a := mono(pipeline(t, nil, nil))
|
||||||
|
press(a, "?")
|
||||||
|
screen := func() string { return strings.Join(text(render(a, 25, 20), 25), "\n") }
|
||||||
|
if s := screen(); strings.Contains(s, "quit") || !strings.Contains(s, "j k: 1-") {
|
||||||
|
t.Fatalf("help at 25 columns:\n%s", s)
|
||||||
|
}
|
||||||
|
if press(a, strings.Repeat("j", 60)); a.dialog == nil || !strings.Contains(screen(), "quit") {
|
||||||
|
t.Fatalf("scrolled to the end:\n%s", screen())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// A dialog's text wraps to a narrow screen
|
// A dialog's text wraps to a narrow screen
|
||||||
func TestDialogsWrapToTheScreen(t *testing.T) {
|
func TestDialogsWrapToTheScreen(t *testing.T) {
|
||||||
a := mono(pipeline(t, []string{"null"}, []string{"null"}))
|
a := mono(pipeline(t, []string{"null"}, []string{"null"}))
|
||||||
@@ -472,3 +608,103 @@ func TestDialogsWrapToTheScreen(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The inspector opens on a part's first required key, after a adds it or
|
||||||
|
// Enter chooses it
|
||||||
|
func TestInspectorOpensOnTheFirstRequiredKey(t *testing.T) {
|
||||||
|
a := mono(pipeline(t, []string{"null"}, []string{"tcp_chain"}))
|
||||||
|
a.col = sinksCol
|
||||||
|
press(a, terminal.KeyEnter)
|
||||||
|
at := func() string { return a.lines()[a.insp.cursor].key.Name }
|
||||||
|
if got := at(); got != "host" {
|
||||||
|
t.Fatalf("Enter on tcp_chain: %s", got)
|
||||||
|
}
|
||||||
|
press(a, terminal.KeyEscape, "a", "http", terminal.KeyEnter)
|
||||||
|
if got := at(); got != "port" {
|
||||||
|
t.Fatalf("a adding http: %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The status shows the first problem whole, by its part and key, not by its
|
||||||
|
// path, on as many rows as it takes
|
||||||
|
func TestTheProblemShowsWhole(t *testing.T) {
|
||||||
|
a := mono(pipeline(t, []string{"null"}, []string{"http"}))
|
||||||
|
sink := compose.Node{Role: "sink", ID: "http"}
|
||||||
|
for key, value := range map[string]string{"port": "8080", "viewer_page": "true"} {
|
||||||
|
if err := a.comp.Set(0, sink, key, value); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.check()
|
||||||
|
for _, w := range []int{60, 87, 146} {
|
||||||
|
screen := strings.Join(text(render(a, w, 30), w), "\n")
|
||||||
|
if !strings.Contains(screen, "sink http · viewer_page: only") || !strings.Contains(screen, "at /") || strings.Contains(screen, "pipelines[0]") {
|
||||||
|
t.Errorf("%d columns:\n%s", w, screen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enter on a row that adds acts as a does: an empty column's slot, the
|
||||||
|
// filters stage with none, the inspector's last filter row
|
||||||
|
func TestEnterOnAnAddRowAdds(t *testing.T) {
|
||||||
|
a := mono(pipeline(t, nil, []string{"null"}))
|
||||||
|
if press(a, terminal.KeyEnter); a.dialog == nil {
|
||||||
|
t.Fatal("Enter on the empty sources column")
|
||||||
|
}
|
||||||
|
press(a, "null", terminal.KeyEnter, terminal.KeyEscape, terminal.KeyRight, terminal.KeyDown, terminal.KeyEnter, "exc", terminal.KeyEnter)
|
||||||
|
if f := a.pipeline().Flow.Filters; len(f) != 1 || f[0].Type != "exclude" {
|
||||||
|
t.Fatalf("Enter on filters: %+v", f)
|
||||||
|
}
|
||||||
|
lines := a.lines()
|
||||||
|
a.insp.cursor = len(lines) - 1
|
||||||
|
press(a, terminal.KeyEnter, terminal.KeyEnter)
|
||||||
|
if f := a.pipeline().Flow.Filters; len(f) != 2 {
|
||||||
|
t.Fatalf("Enter on the add row: %+v", f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// c changes a source's or sink's type in its place and opens it; on a filter
|
||||||
|
// or the format it goes to the type row
|
||||||
|
func TestChangeTypeKeepsThePlace(t *testing.T) {
|
||||||
|
a := mono(pipeline(t, []string{"null"}, []string{"null", "file"}))
|
||||||
|
a.col = sinksCol
|
||||||
|
press(a, "c", "tcp", terminal.KeyEnter)
|
||||||
|
sinks := a.pipeline().PluginSinks
|
||||||
|
if sinks[0].Type != "tcp" || sinks[0].ID != "tcp" || sinks[1].Type != "file" || !a.inspect || a.lines()[a.insp.cursor].key.Name != "port" {
|
||||||
|
t.Fatalf("%+v, cursor on %+v", sinks, a.lines()[a.insp.cursor])
|
||||||
|
}
|
||||||
|
press(a, terminal.KeyEscape)
|
||||||
|
a.choose(compose.Node{Role: "format"})
|
||||||
|
if press(a, "c"); !a.inspect || a.lines()[a.insp.cursor].key.Name != "type" {
|
||||||
|
t.Fatalf("c on format: %+v", a.lines()[a.insp.cursor])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The pipeline bar, above the parts, switches pipelines, adds one under a free
|
||||||
|
// name, renames one under a name no other has, and deletes one, asking first
|
||||||
|
// when it has parts
|
||||||
|
func TestThePipelineBarAddsRenamesDeletes(t *testing.T) {
|
||||||
|
a := mono(pipeline(t, []string{"null"}, []string{"null"}))
|
||||||
|
press(a, "k", "a", "empty", terminal.KeyEnter, "k", "a", "empty", terminal.KeyEnter)
|
||||||
|
names := func() (out []string) {
|
||||||
|
for _, p := range a.comp.Pipelines {
|
||||||
|
out = append(out, p.Name)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
if got := names(); !slices.Equal(got, []string{"p", "default", "default_2"}) || a.pi != 2 {
|
||||||
|
t.Fatalf("added %q, showing %d", got, a.pi)
|
||||||
|
}
|
||||||
|
press(a, "k", terminal.KeyEnter, terminal.KeyCtrlU, "p", terminal.KeyEnter)
|
||||||
|
if _, open := a.dialog.(*form); !open {
|
||||||
|
t.Fatalf("renamed to a name another has: %q", names())
|
||||||
|
}
|
||||||
|
press(a, terminal.KeyCtrlU, "x", terminal.KeyEnter, "h", "h", "d")
|
||||||
|
if got := names(); !slices.Equal(got, []string{"p", "default", "x"}) || a.dialog == nil {
|
||||||
|
t.Fatalf("deleted without asking: %q", got)
|
||||||
|
}
|
||||||
|
press(a, "y", "l", "d")
|
||||||
|
if got := names(); !slices.Equal(got, []string{"default"}) || a.dialog != nil {
|
||||||
|
t.Fatalf("%q %T", got, a.dialog)
|
||||||
|
}
|
||||||
|
}
|
||||||
+20
-20
@@ -96,22 +96,22 @@ section "Rules"
|
|||||||
rejects() { ! lw --check --source null --sink "$1" >/dev/null 2>"$LOG/check.err" && grep -q -- "$2" "$LOG/check.err" && echo 1 || echo 0; }
|
rejects() { ! lw --check --source null --sink "$1" >/dev/null 2>"$LOG/check.err" && grep -q -- "$2" "$LOG/check.err" && echo 1 || echo 0; }
|
||||||
# warns SINK PATTERN: lw --check builds SINK and WARNs PATTERN
|
# warns SINK PATTERN: lw --check builds SINK and WARNs PATTERN
|
||||||
warns() { lw --check --source null --sink "$1" 2>&1 >/dev/null | grep ' WARN ' | grep -q -- "$2" && echo 1 || echo 0; }
|
warns() { lw --check --source null --sink "$1" 2>&1 >/dev/null | grep ' WARN ' | grep -q -- "$2" && echo 1 || echo 0; }
|
||||||
check "an IPv4 entry on an IPv6 listener is refused" "$(rejects 'tcp,host=::1,port=15881,acl.allow=10.0.0.0/8' "not of the listener's family")"
|
check "an IPv4 entry on an IPv6 listener is refused" "$(rejects 'tcp:host=::1,port=15881,acl.allow=10.0.0.0/8' "not of the listener's family")"
|
||||||
check "an acl on a dialer is refused" "$(rejects 'tcp_chain,host=127.0.0.1,port=15881,acl.deny=10.0.0.1' 'unknown key "acl"')"
|
check "an acl on a dialer is refused" "$(rejects 'tcp_chain:host=127.0.0.1,port=15881,acl.deny=10.0.0.1' 'unknown key "acl"')"
|
||||||
check "a CIDR with host bits warns, naming the network it matches" "$(warns 'tcp,host=127.0.0.1,port=15881,acl.allow=10.0.0.1/8' 'network 10.0.0.0/8')"
|
check "a CIDR with host bits warns, naming the network it matches" "$(warns 'tcp:host=127.0.0.1,port=15881,acl.allow=10.0.0.1/8' 'network 10.0.0.0/8')"
|
||||||
check "a deny list alone on a wildcard listener warns" "$(warns 'tcp,port=15881,acl.deny=10.0.0.1' 'admits all that acl.deny does not list')"
|
check "a deny list alone on a wildcard listener warns" "$(warns 'tcp:port=15881,acl.deny=10.0.0.1' 'admits all that acl.deny does not list')"
|
||||||
check "an allow entry of a whole family warns" "$(warns 'tcp,port=15881,acl.allow=0.0.0.0/0' 'admits every address of its family')"
|
check "an allow entry of a whole family warns" "$(warns 'tcp:port=15881,acl.allow=0.0.0.0/0' 'admits every address of its family')"
|
||||||
check "a proxy_from entry reaching public addresses warns" \
|
check "a proxy_from entry reaching public addresses warns" \
|
||||||
"$(warns 'tcp,host=127.0.0.1,port=15881,acl.proxy_protocol=optional,acl.proxy_from=203.0.113.0/24' 'proxy_from reaches public addresses')"
|
"$(warns 'tcp:host=127.0.0.1,port=15881,acl.proxy_protocol=optional,acl.proxy_from=203.0.113.0/24' 'proxy_from reaches public addresses')"
|
||||||
check "a request rate on a tcp listener is refused" \
|
check "a request rate on a tcp listener is refused" \
|
||||||
"$(rejects 'tcp,host=127.0.0.1,port=15881,acl.requests_per_second_per_client=5' 'applies only to HTTP listeners')"
|
"$(rejects 'tcp:host=127.0.0.1,port=15881,acl.requests_per_second_per_client=5' 'applies only to HTTP listeners')"
|
||||||
check "a connection cap behind trusted_proxies, which would count the proxy, is refused" \
|
check "a connection cap behind trusted_proxies, which would count the proxy, is refused" \
|
||||||
"$(rejects "http,host=127.0.0.1,port=15881,auth.type=scram,auth.credentials_file=$RUN/users.toml,auth.trusted_proxies=127.0.0.1,acl.max_connections_per_client=2" 'behind trusted_proxies')"
|
"$(rejects "http:host=127.0.0.1,port=15881,auth.type=scram,auth.credentials_file=$RUN/users.toml,auth.trusted_proxies=127.0.0.1,acl.max_connections_per_client=2" 'behind trusted_proxies')"
|
||||||
check "optional proxy_protocol warns that a headerless client passes as the proxy" \
|
check "optional proxy_protocol warns that a headerless client passes as the proxy" \
|
||||||
"$(warns 'tcp,host=127.0.0.1,port=15881,acl.proxy_protocol=optional,acl.proxy_from=127.0.0.1' 'passes as the proxy')"
|
"$(warns 'tcp:host=127.0.0.1,port=15881,acl.proxy_protocol=optional,acl.proxy_from=127.0.0.1' 'passes as the proxy')"
|
||||||
|
|
||||||
section "HTTP sink"
|
section "HTTP sink"
|
||||||
spawn serve "$BIN" --preset "serve,path=$RUN/edge.log,listen=127.0.0.1:15881,allow=127.0.0.0/8,deny=127.0.0.2"
|
spawn serve "$BIN" --preset "serve:path=$RUN/edge.log,listen=127.0.0.1:15881,allow=127.0.0.0/8,deny=127.0.0.2"
|
||||||
wait_port 15881 || abort "the serve preset did not listen" serve
|
wait_port 15881 || abort "the serve preset did not listen" serve
|
||||||
check "a client the allow list names reads the status" "$(get http://127.0.0.1:15881/status | grep -q '"acl":"allow=1 deny=1"' && echo 1 || echo 0)"
|
check "a client the allow list names reads the status" "$(get http://127.0.0.1:15881/status | grep -q '"acl":"allow=1 deny=1"' && echo 1 || echo 0)"
|
||||||
get --interface 127.0.0.2 -o /dev/null http://127.0.0.1:15881/status
|
get --interface 127.0.0.2 -o /dev/null http://127.0.0.1:15881/status
|
||||||
@@ -127,7 +127,7 @@ section "TCP sink"
|
|||||||
# the lines of a finite input, once the clients had time to connect
|
# the lines of a finite input, once the clients had time to connect
|
||||||
seq 1 20000 | sed 's/^/flush /' >"$RUN/flush.in"
|
seq 1 20000 | sed 's/^/flush /' >"$RUN/flush.in"
|
||||||
{ wait_port 15882; sleep 1; cat "$RUN/flush.in"; } |
|
{ wait_port 15882; sleep 1; cat "$RUN/flush.in"; } |
|
||||||
lw --sink "tcp,host=127.0.0.1,port=15882,acl.allow=127.0.0.1,buffer_size=20000,client_buffer_size=20000" 2>"$LOG/tcp.err" &
|
lw --sink "tcp:host=127.0.0.1,port=15882,acl.allow=127.0.0.1,buffer_size=20000,client_buffer_size=20000" 2>"$LOG/tcp.err" &
|
||||||
fpid=$!
|
fpid=$!
|
||||||
wait_port 15882
|
wait_port 15882
|
||||||
tcp_read 15882 15 >"$RUN/flush.out" &
|
tcp_read 15882 15 >"$RUN/flush.out" &
|
||||||
@@ -141,7 +141,7 @@ check "one it denies gets nothing ($denied bytes) and is logged" \
|
|||||||
|
|
||||||
section "Behind an L7 proxy"
|
section "Behind an L7 proxy"
|
||||||
# proxy mode over plaintext loopback: curl is the proxy, its headers name the client
|
# proxy mode over plaintext loopback: curl is the proxy, its headers name the client
|
||||||
spawn web-proxied "$BIN" --source null --sink "http,host=127.0.0.1,port=15882,auth.type=scram,auth.credentials_file=$RUN/users.toml,auth.trusted_proxies=127.0.0.1,acl.deny=198.51.100.5,acl.deny=2001:db8::/32,acl.requests_per_second_per_client=1"
|
spawn web-proxied "$BIN" --source null --sink "http:host=127.0.0.1,port=15882,auth.type=scram,auth.credentials_file=$RUN/users.toml,auth.trusted_proxies=127.0.0.1,acl.deny=198.51.100.5,acl.deny=2001:db8::/32,acl.requests_per_second_per_client=1"
|
||||||
wait_port 15882 || abort "the proxy-mode http sink did not listen" web-proxied
|
wait_port 15882 || abort "the proxy-mode http sink did not listen" web-proxied
|
||||||
# as CLIENT [PATH [CURL ARGS]]: the status of a request forwarded for CLIENT,
|
# as CLIENT [PATH [CURL ARGS]]: the status of a request forwarded for CLIENT,
|
||||||
# GET /status by default; there 401 is past the acl, short of a login
|
# GET /status by default; there 401 is past the acl, short of a login
|
||||||
@@ -162,9 +162,9 @@ section "Chain sources"
|
|||||||
# pin DAEMON: the pin_sha256 a self-signed listener logged
|
# pin DAEMON: the pin_sha256 a self-signed listener logged
|
||||||
pin() { wait_until 5 logged "$1" pin_sha256 && grep -o 'pin_sha256 "sha256//[^"]*"' "$LOG/$1.out" | head -1 | cut -d'"' -f2; }
|
pin() { wait_until 5 logged "$1" pin_sha256 && grep -o 'pin_sha256 "sha256//[^"]*"' "$LOG/$1.out" | head -1 | cut -d'"' -f2; }
|
||||||
received() { cat "$RUN/out/$1"/aggregate*.log 2>/dev/null | grep -c 'edge line'; }
|
received() { cat "$RUN/out/$1"/aggregate*.log 2>/dev/null | grep -c 'edge line'; }
|
||||||
edge() { spawn "$1" "$BIN" --preset "edge,path=$RUN/edge.log,from=start,to=$2,transport=$3,pin=$4,user=edge-01,password_file=$RUN/edge-01.pass"; }
|
edge() { spawn "$1" "$BIN" --preset "edge:path=$RUN/edge.log,from=start,to=$2,transport=$3,pin=$4,user=edge-01,password_file=$RUN/edge-01.pass"; }
|
||||||
spawn agg-allow "$BIN" --preset "aggregator,listen=127.0.0.1:15884,allow=127.0.0.1,users=$RUN/users.toml,out=$RUN/out/allow"
|
spawn agg-allow "$BIN" --preset "aggregator:listen=127.0.0.1:15884,allow=127.0.0.1,users=$RUN/users.toml,out=$RUN/out/allow"
|
||||||
spawn agg-deny "$BIN" --preset "aggregator,listen=127.0.0.1:15885,transport=http,deny=127.0.0.0/8,users=$RUN/users.toml,out=$RUN/out/deny"
|
spawn agg-deny "$BIN" --preset "aggregator:listen=127.0.0.1:15885,transport=http,deny=127.0.0.0/8,users=$RUN/users.toml,out=$RUN/out/deny"
|
||||||
wait_port 15884 && wait_port 15885 || abort "an aggregator did not listen" agg-allow
|
wait_port 15884 && wait_port 15885 || abort "an aggregator did not listen" agg-allow
|
||||||
edge edge-allow 127.0.0.1:15884 tcp "$(pin agg-allow)"
|
edge edge-allow 127.0.0.1:15884 tcp "$(pin agg-allow)"
|
||||||
edge edge-deny 127.0.0.1:15885 http "$(pin agg-deny)"
|
edge edge-deny 127.0.0.1:15885 http "$(pin agg-deny)"
|
||||||
@@ -181,16 +181,16 @@ section "Behind a PROXY header"
|
|||||||
# connect from; each is up before a proxy shares its port on another address
|
# connect from; each is up before a proxy shares its port on another address
|
||||||
behind=acl.proxy_protocol=required,acl.proxy_from=127.0.0.1
|
behind=acl.proxy_protocol=required,acl.proxy_from=127.0.0.1
|
||||||
scram="tls.enabled=true,tls.self_signed=true,auth.type=scram,auth.credentials_file=$RUN/users.toml"
|
scram="tls.enabled=true,tls.self_signed=true,auth.type=scram,auth.credentials_file=$RUN/users.toml"
|
||||||
spawn sink-behind "$BIN" --logging.level=info --source null --sink "http,host=127.0.0.1,port=15886,$scram,$behind,acl.deny=198.51.100.2"
|
spawn sink-behind "$BIN" --logging.level=info --source null --sink "http:host=127.0.0.1,port=15886,$scram,$behind,acl.deny=198.51.100.2"
|
||||||
spawn tcp-capped "$BIN" --source null --sink "tcp,host=127.0.0.1,port=15889,$behind,acl.max_connections_per_client=2"
|
spawn tcp-capped "$BIN" --source null --sink "tcp:host=127.0.0.1,port=15889,$behind,acl.max_connections_per_client=2"
|
||||||
for t in tcp http; do
|
for t in tcp http; do
|
||||||
spawn "agg-$t-behind" "$BIN" --logging.level=info \
|
spawn "agg-$t-behind" "$BIN" --logging.level=info \
|
||||||
--source "${t}_chain,host=127.0.0.1,port=$([[ $t == tcp ]] && echo 15888 || echo 15883),$scram,$behind" \
|
--source "${t}_chain:host=127.0.0.1,port=$([[ $t == tcp ]] && echo 15888 || echo 15883),$scram,$behind" \
|
||||||
--sink "file,directory=$RUN/out/$t-behind,name=aggregate"
|
--sink "file:directory=$RUN/out/$t-behind,name=aggregate"
|
||||||
done
|
done
|
||||||
# the tcp sink sends its input once the clients connected
|
# the tcp sink sends its input once the clients connected
|
||||||
{ wait_until 30 test -e "$RUN/feed"; seq 1 100 | sed 's/^/behind /'; } |
|
{ wait_until 30 test -e "$RUN/feed"; seq 1 100 | sed 's/^/behind /'; } |
|
||||||
lw --sink "tcp,host=127.0.0.1,port=15887,$behind,acl.deny=198.51.100.2,acl.deny=127.0.0.6" 2>"$LOG/tcp-behind.out" &
|
lw --sink "tcp:host=127.0.0.1,port=15887,$behind,acl.deny=198.51.100.2,acl.deny=127.0.0.6" 2>"$LOG/tcp-behind.out" &
|
||||||
fpid=$!
|
fpid=$!
|
||||||
for p in 15883 15886 15887 15888 15889; do wait_port $p || abort "a listener behind the proxy did not listen on $p" sink-behind; done
|
for p in 15883 15886 15887 15888 15889; do wait_port $p || abort "a listener behind the proxy did not listen on $p" sink-behind; done
|
||||||
spawn proxy "$RUN/proxy/proxy" 127.0.0.11:15886,127.0.0.1:15886,1,198.51.100.1 \
|
spawn proxy "$RUN/proxy/proxy" 127.0.0.11:15886,127.0.0.1:15886,1,198.51.100.1 \
|
||||||
|
|||||||
+3
-3
@@ -55,14 +55,14 @@ rc=${PIPESTATUS[1]}
|
|||||||
check "reader gone: lw exits on SIGPIPE ($rc)" "$((rc == 141))"
|
check "reader gone: lw exits on SIGPIPE ($rc)" "$((rc == 141))"
|
||||||
|
|
||||||
# 7. filters and formats compose with stdin and stdout
|
# 7. filters and formats compose with stdin and stdout
|
||||||
check "--filter keeps matching lines" "$(is "$(printf 'x INFO\ny ERROR\n' | lw bare --filter include,patterns=ERROR)" "y ERROR")"
|
check "--filter keeps matching lines" "$(is "$(printf 'x INFO\ny ERROR\n' | lw bare --filter include:patterns=ERROR)" "y ERROR")"
|
||||||
|
|
||||||
# 8. control characters pass to a pipe unchanged and are escaped on request
|
# 8. control characters pass to a pipe unchanged and are escaped on request
|
||||||
check "control bytes reach a pipe unchanged" "$(is "$(printf 'a\033[31mb\n' | lw bare | od -An -tx1 | tr -d ' ')" 611b5b33316d620a)"
|
check "control bytes reach a pipe unchanged" "$(is "$(printf 'a\033[31mb\n' | lw bare | od -An -tx1 | tr -d ' ')" 611b5b33316d620a)"
|
||||||
check "escape=always writes them as <hex>" "$(is "$(printf 'a\033[31mb\n' | lw bare --sink console,escape=always)" 'a<1b>[31mb')"
|
check "escape=always writes them as <hex>" "$(is "$(printf 'a\033[31mb\n' | lw bare --sink console:escape=always)" 'a<1b>[31mb')"
|
||||||
|
|
||||||
# 9. at the end of input the file sink writes its queue before lw exits
|
# 9. at the end of input the file sink writes its queue before lw exits
|
||||||
seq 1 5000 | lw bare --sink "file,directory=$RUN/fsink,name=out,buffer_size=10000"
|
seq 1 5000 | lw bare --sink "file:directory=$RUN/fsink,name=out,buffer_size=10000"
|
||||||
n=$(cat "$RUN"/fsink/out*.log 2>/dev/null | wc -l)
|
n=$(cat "$RUN"/fsink/out*.log 2>/dev/null | wc -l)
|
||||||
check "file sink holds all 5000 lines at exit ($n)" "$((n == 5000))"
|
check "file sink holds all 5000 lines at exit ($n)" "$((n == 5000))"
|
||||||
|
|
||||||
|
|||||||
+74
-18
@@ -1,10 +1,12 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# logwisp preset test: colors, presets, --dump, lw tls, listeners that make
|
# logwisp preset test: colors, presets, --dump, lw tls, listeners that make
|
||||||
# their certificate (self-signed and pinned, or issued), the serve preset's
|
# their certificate (self-signed and pinned, or issued), the serve preset's
|
||||||
# browser viewer, and the network sinks delivering a finite input whole.
|
# browser viewer, the network sinks delivering a finite input whole, and a
|
||||||
|
# temporary pipe: one SCRAM user whose password is on no disk.
|
||||||
# One-shot: both modes run the checks.
|
# One-shot: both modes run the checks.
|
||||||
# Ports: 15890-15896 on 127.0.0.1.
|
# Ports: 15890-15898 on 127.0.0.1.
|
||||||
# Requires: bash 5+, coreutils, curl, openssl. Linux dev host only.
|
# Requires: bash 5+, coreutils, curl, openssl; script(1) for the terminal
|
||||||
|
# checks. Linux dev host only.
|
||||||
|
|
||||||
set -u
|
set -u
|
||||||
. "$(dirname -- "${BASH_SOURCE[0]}")/lib.sh"
|
. "$(dirname -- "${BASH_SOURCE[0]}")/lib.sh"
|
||||||
@@ -14,7 +16,7 @@ e2e_init "$@"
|
|||||||
|
|
||||||
section "Setup"
|
section "Setup"
|
||||||
need curl openssl
|
need curl openssl
|
||||||
ports_free 15890 15891 15892 15893 15894 15895 15896
|
ports_free 15890 15891 15892 15893 15894 15895 15896 15897 15898
|
||||||
rm -rf "$RUN"
|
rm -rf "$RUN"
|
||||||
mkdir -p "$RUN/out" "$LOG"
|
mkdir -p "$RUN/out" "$LOG"
|
||||||
cd "$RUN" || abort "no run directory"
|
cd "$RUN" || abort "no run directory"
|
||||||
@@ -31,15 +33,15 @@ check "--color paints the level name" "$(is "$(printf 'a ERROR b\n' | lw --color
|
|||||||
check "--color never, and auto on a pipe, leave it plain" \
|
check "--color never, and auto on a pipe, leave it plain" \
|
||||||
"$(is "$(printf 'a ERROR b\n' | lw --color never)$(printf 'a ERROR b\n' | lw)" "a ERROR ba ERROR b")"
|
"$(is "$(printf 'a ERROR b\n' | lw --color never)$(printf 'a ERROR b\n' | lw)" "a ERROR ba ERROR b")"
|
||||||
check "LOGWISP_COLOR=always, a sink's own color=never wins" \
|
check "LOGWISP_COLOR=always, a sink's own color=never wins" \
|
||||||
"$(is "$(printf 'x WARN\n' | LOGWISP_COLOR=always lw --sink console,color=never)" "x WARN")"
|
"$(is "$(printf 'x WARN\n' | LOGWISP_COLOR=always lw --sink console:color=never)" "x WARN")"
|
||||||
|
|
||||||
section "Presets"
|
section "Presets"
|
||||||
seq 1 5000 >"$RUN/seq.txt"
|
seq 1 5000 >"$RUN/seq.txt"
|
||||||
lw --preset pipe <"$RUN/seq.txt" >"$RUN/pipe.out"
|
lw --preset pipe <"$RUN/seq.txt" >"$RUN/pipe.out"
|
||||||
check "--preset pipe copies stdin line for line" "$(cmp -s "$RUN/seq.txt" "$RUN/pipe.out" && echo 1 || echo 0)"
|
check "--preset pipe copies stdin line for line" "$(cmp -s "$RUN/seq.txt" "$RUN/pipe.out" && echo 1 || echo 0)"
|
||||||
out=$(timeout 3 "$BIN" --preset "tail,path=$RUN/edge.log,from=start" </dev/null 2>/dev/null | head -2 | tr '\n' '|')
|
out=$(timeout 3 "$BIN" --preset "tail:path=$RUN/edge.log,from=start" </dev/null 2>/dev/null | head -2 | tr '\n' '|')
|
||||||
check "--preset tail follows a file" "$(is "$out" "edge line 1|edge line 2|")"
|
check "--preset tail follows a file" "$(is "$out" "edge line 1|edge line 2|")"
|
||||||
lw --preset "serve,path=$RUN/edge.log,listen=127.0.0.1:15890,tls=self,hosts=logs.example" --color --dump >"$RUN/dump.toml"
|
lw --preset "serve:path=$RUN/edge.log,listen=127.0.0.1:15890,tls=self,hosts=logs.example" --color --dump >"$RUN/dump.toml"
|
||||||
lw -c "$RUN/dump.toml" --dump >"$RUN/dump2.toml"
|
lw -c "$RUN/dump.toml" --dump >"$RUN/dump2.toml"
|
||||||
check "--dump prints a file that reads back to the same dump" "$(cmp -s "$RUN/dump.toml" "$RUN/dump2.toml" && echo 1 || echo 0)"
|
check "--dump prints a file that reads back to the same dump" "$(cmp -s "$RUN/dump.toml" "$RUN/dump2.toml" && echo 1 || echo 0)"
|
||||||
check "lw --check builds the dumped preset" "$(lw --check -c "$RUN/dump.toml" 2>"$LOG/check.err" | grep -q 'configuration ok' && echo 1 || echo 0)"
|
check "lw --check builds the dumped preset" "$(lw --check -c "$RUN/dump.toml" 2>"$LOG/check.err" | grep -q 'configuration ok' && echo 1 || echo 0)"
|
||||||
@@ -65,24 +67,24 @@ add_users "$RUN/users.toml" edge-01
|
|||||||
pin() { wait_until 5 logged "$1" pin_sha256 && grep -o 'pin_sha256 "sha256//[^"]*"' "$LOG/$1.out" | head -1 | cut -d'"' -f2; }
|
pin() { wait_until 5 logged "$1" pin_sha256 && grep -o 'pin_sha256 "sha256//[^"]*"' "$LOG/$1.out" | head -1 | cut -d'"' -f2; }
|
||||||
received() { cat "$RUN/out/$1"/aggregate*.log 2>/dev/null | grep -c 'edge line'; }
|
received() { cat "$RUN/out/$1"/aggregate*.log 2>/dev/null | grep -c 'edge line'; }
|
||||||
|
|
||||||
spawn agg-self "$BIN" --preset "aggregator,listen=127.0.0.1:15893,users=$RUN/users.toml,out=$RUN/out/self"
|
spawn agg-self "$BIN" --preset "aggregator:listen=127.0.0.1:15893,users=$RUN/users.toml,out=$RUN/out/self"
|
||||||
wait_port 15893 || abort "the self-signed aggregator did not listen" agg-self
|
wait_port 15893 || abort "the self-signed aggregator did not listen" agg-self
|
||||||
PIN=$(pin agg-self)
|
PIN=$(pin agg-self)
|
||||||
check "a self-signed aggregator logs its pin" "$(is_set "$PIN")"
|
check "a self-signed aggregator logs its pin" "$(is_set "$PIN")"
|
||||||
spawn edge-pin "$BIN" --preset "edge,path=$RUN/edge.log,from=start,to=127.0.0.1:15893,pin=$PIN,user=edge-01,password_file=$RUN/edge-01.pass"
|
spawn edge-pin "$BIN" --preset "edge:path=$RUN/edge.log,from=start,to=127.0.0.1:15893,pin=$PIN,user=edge-01,password_file=$RUN/edge-01.pass"
|
||||||
spawn edge-wrong "$BIN" --preset "edge,path=$RUN/edge.log,from=start,to=127.0.0.1:15893,pin=sha256//AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=,user=edge-01,password_file=$RUN/edge-01.pass"
|
spawn edge-wrong "$BIN" --preset "edge:path=$RUN/edge.log,from=start,to=127.0.0.1:15893,pin=sha256//AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=,user=edge-01,password_file=$RUN/edge-01.pass"
|
||||||
wait_until 10 eval '(( $(received self) >= 300 ))'
|
wait_until 10 eval '(( $(received self) >= 300 ))'
|
||||||
check "an edge pinning it delivers every line over SCRAM ($(received self))" "$(( $(received self) == 300 ))"
|
check "an edge pinning it delivers every line over SCRAM ($(received self))" "$(( $(received self) == 300 ))"
|
||||||
wait_until 10 logged edge-wrong 'matches no tls.pin_sha256'
|
wait_until 10 logged edge-wrong 'matches no tls.pin_sha256'
|
||||||
check "an edge with another pin is refused before it logs in" "$(logged edge-wrong 'matches no tls.pin_sha256' && echo 1 || echo 0)"
|
check "an edge with another pin is refused before it logs in" "$(logged edge-wrong 'matches no tls.pin_sha256' && echo 1 || echo 0)"
|
||||||
|
|
||||||
spawn agg-issuer "$BIN" --preset "aggregator,listen=127.0.0.1:15894,transport=http,tls=issuer,issuer_cert=$RUN/pki/ca.crt,issuer_key=$RUN/pki/ca.key,users=$RUN/users.toml,out=$RUN/out/issuer"
|
spawn agg-issuer "$BIN" --preset "aggregator:listen=127.0.0.1:15894,transport=http,tls=issuer,issuer_cert=$RUN/pki/ca.crt,issuer_key=$RUN/pki/ca.key,users=$RUN/users.toml,out=$RUN/out/issuer"
|
||||||
wait_port 15894 || abort "the issuing aggregator did not listen" agg-issuer
|
wait_port 15894 || abort "the issuing aggregator did not listen" agg-issuer
|
||||||
spawn edge-ca "$BIN" --preset "edge,path=$RUN/edge.log,from=start,to=127.0.0.1:15894,transport=http,ca=$RUN/pki/ca.crt,user=edge-01,password_file=$RUN/edge-01.pass"
|
spawn edge-ca "$BIN" --preset "edge:path=$RUN/edge.log,from=start,to=127.0.0.1:15894,transport=http,ca=$RUN/pki/ca.crt,user=edge-01,password_file=$RUN/edge-01.pass"
|
||||||
wait_until 10 eval '(( $(received issuer) >= 300 ))'
|
wait_until 10 eval '(( $(received issuer) >= 300 ))'
|
||||||
check "an aggregator signing its own certificate is verified by the CA ($(received issuer))" "$(( $(received issuer) == 300 ))"
|
check "an aggregator signing its own certificate is verified by the CA ($(received issuer))" "$(( $(received issuer) == 300 ))"
|
||||||
|
|
||||||
spawn serve "$BIN" --preset "serve,path=$RUN/edge.log,listen=127.0.0.1:15895,tls=self"
|
spawn serve "$BIN" --preset "serve:path=$RUN/edge.log,listen=127.0.0.1:15895,tls=self"
|
||||||
wait_port 15895 || abort "the serve preset did not listen" serve
|
wait_port 15895 || abort "the serve preset did not listen" serve
|
||||||
SERVE_PIN=$(pin serve)
|
SERVE_PIN=$(pin serve)
|
||||||
check "curl --pinnedpubkey reads a self-signed stream's status" \
|
check "curl --pinnedpubkey reads a self-signed stream's status" \
|
||||||
@@ -93,7 +95,7 @@ check "a browser at the self-signed root lands on the viewer" \
|
|||||||
"$(curl -sLk --pinnedpubkey "$SERVE_PIN" https://127.0.0.1:15895/ | grep -q 'name="logwisp-login" content="none"' && echo 1 || echo 0)"
|
"$(curl -sLk --pinnedpubkey "$SERVE_PIN" https://127.0.0.1:15895/ | grep -q 'name="logwisp-login" content="none"' && echo 1 || echo 0)"
|
||||||
|
|
||||||
section "Viewer"
|
section "Viewer"
|
||||||
spawn serve-plain "$BIN" --preset "serve,path=$RUN/edge.log,listen=127.0.0.1:15896"
|
spawn serve-plain "$BIN" --preset "serve:path=$RUN/edge.log,listen=127.0.0.1:15896"
|
||||||
wait_port 15896 || abort "the plain serve preset did not listen" serve-plain
|
wait_port 15896 || abort "the plain serve preset did not listen" serve-plain
|
||||||
root=$(curl -s -o /dev/null -w '%{http_code} %{redirect_url}' http://127.0.0.1:15896/)
|
root=$(curl -s -o /dev/null -w '%{http_code} %{redirect_url}' http://127.0.0.1:15896/)
|
||||||
check "GET / sends a browser to the viewer, which needs no login over plain http ($root)" \
|
check "GET / sends a browser to the viewer, which needs no login over plain http ($root)" \
|
||||||
@@ -103,13 +105,13 @@ section "Flush at exit"
|
|||||||
# feed SECONDS PORT: the lines, once a client had time to connect
|
# feed SECONDS PORT: the lines, once a client had time to connect
|
||||||
feed() { wait_port "$2"; sleep "$1"; seq 1 20000 | sed 's/^/flush /'; }
|
feed() { wait_port "$2"; sleep "$1"; seq 1 20000 | sed 's/^/flush /'; }
|
||||||
queues='buffer_size=20000,client_buffer_size=20000'
|
queues='buffer_size=20000,client_buffer_size=20000'
|
||||||
feed 1 15891 | lw --sink "tcp,host=127.0.0.1,port=15891,$queues" 2>"$LOG/flush-tcp.err" &
|
feed 1 15891 | lw --sink "tcp:host=127.0.0.1,port=15891,$queues" 2>"$LOG/flush-tcp.err" &
|
||||||
fpid=$!
|
fpid=$!
|
||||||
wait_port 15891
|
wait_port 15891
|
||||||
n=$(tcp_read 15891 15 | grep -c '^flush ')
|
n=$(tcp_read 15891 15 | grep -c '^flush ')
|
||||||
wait "$fpid"
|
wait "$fpid"
|
||||||
check "a tcp client gets all 20000 lines of a finite input ($n)" "$((n == 20000))"
|
check "a tcp client gets all 20000 lines of a finite input ($n)" "$((n == 20000))"
|
||||||
feed 1 15892 | lw --sink "http,host=127.0.0.1,port=15892,$queues" 2>"$LOG/flush-http.err" &
|
feed 1 15892 | lw --sink "http:host=127.0.0.1,port=15892,$queues" 2>"$LOG/flush-http.err" &
|
||||||
fpid=$!
|
fpid=$!
|
||||||
wait_port 15892
|
wait_port 15892
|
||||||
curl -sN --max-time 15 http://127.0.0.1:15892/stream >"$RUN/flush-http.out"
|
curl -sN --max-time 15 http://127.0.0.1:15892/stream >"$RUN/flush-http.out"
|
||||||
@@ -119,11 +121,65 @@ check "an http stream gets all 20000 lines, then the disconnect event ($n)" \
|
|||||||
"$( ((n == 20000)) && tail -3 "$RUN/flush-http.out" | grep -q '^event: disconnect' && echo 1 || echo 0)"
|
"$( ((n == 20000)) && tail -3 "$RUN/flush-http.out" | grep -q '^event: disconnect' && echo 1 || echo 0)"
|
||||||
# 1000 lines: the chain sink's queue, which holds them until the link is up
|
# 1000 lines: the chain sink's queue, which holds them until the link is up
|
||||||
flushed() { cat "$RUN/out/$1"/aggregate*.log 2>/dev/null | grep -c '"flush '; }
|
flushed() { cat "$RUN/out/$1"/aggregate*.log 2>/dev/null | grep -c '"flush '; }
|
||||||
seq 1 1000 | sed 's/^/flush /' | lw --preset "edge,to=127.0.0.1:15893,pin=$PIN,user=edge-01,password_file=$RUN/edge-01.pass" 2>"$LOG/flush-tcp-chain.err"
|
seq 1 1000 | sed 's/^/flush /' | lw --preset "edge:to=127.0.0.1:15893,pin=$PIN,user=edge-01,password_file=$RUN/edge-01.pass" 2>"$LOG/flush-tcp-chain.err"
|
||||||
wait_until 5 eval '(( $(flushed self) >= 1000 ))'
|
wait_until 5 eval '(( $(flushed self) >= 1000 ))'
|
||||||
check "a tcp_chain edge delivers all 1000 lines before it exits ($(flushed self))" "$(( $(flushed self) == 1000 ))"
|
check "a tcp_chain edge delivers all 1000 lines before it exits ($(flushed self))" "$(( $(flushed self) == 1000 ))"
|
||||||
seq 1 1000 | sed 's/^/flush /' | lw --preset "edge,to=127.0.0.1:15894,transport=http,ca=$RUN/pki/ca.crt,user=edge-01,password_file=$RUN/edge-01.pass" 2>"$LOG/flush-http-chain.err"
|
seq 1 1000 | sed 's/^/flush /' | lw --preset "edge:to=127.0.0.1:15894,transport=http,ca=$RUN/pki/ca.crt,user=edge-01,password_file=$RUN/edge-01.pass" 2>"$LOG/flush-http-chain.err"
|
||||||
wait_until 5 eval '(( $(flushed issuer) >= 1000 ))'
|
wait_until 5 eval '(( $(flushed issuer) >= 1000 ))'
|
||||||
check "an http_chain edge delivers all 1000 lines before it exits ($(flushed issuer))" "$(( $(flushed issuer) == 1000 ))"
|
check "an http_chain edge delivers all 1000 lines before it exits ($(flushed issuer))" "$(( $(flushed issuer) == 1000 ))"
|
||||||
|
|
||||||
|
section "Temporary pipe"
|
||||||
|
# One user on both ends, its password through a descriptor; printf is a
|
||||||
|
# builtin, so the password is in no argv either
|
||||||
|
PW=$(head -c 18 /dev/urandom | base64 | tr '+/' '-_')
|
||||||
|
pw() { printf '%s\n' "$1"; }
|
||||||
|
piped() { cat "$RUN/out/pipe"/aggregate*.log 2>/dev/null | grep -c '^pipe line'; }
|
||||||
|
piped_line() { cat "$RUN/out/pipe"/aggregate*.log 2>/dev/null | grep -qx "pipe line $1"; }
|
||||||
|
seq 1 100 | sed 's/^/pipe line /' >"$RUN/pipe.log"
|
||||||
|
spawn agg-pipe "$BIN" --logging.level=info --preset "aggregator:listen=127.0.0.1:15897,user=pipe,password_file=/dev/fd/3,format=raw,out=$RUN/out/pipe" 3< <(pw "$PW")
|
||||||
|
AGG_PIPE=${PIDS[-1]}
|
||||||
|
wait_port 15897 || abort "the pipe aggregator did not listen" agg-pipe
|
||||||
|
PIPE_PIN=$(pin agg-pipe)
|
||||||
|
spawn edge-pipe "$BIN" --logging.level=info --preset "edge:path=$RUN/pipe.log,from=start,to=127.0.0.1:15897,pin=$PIPE_PIN,user=pipe,password_file=/dev/fd/3" 3< <(pw "$PW")
|
||||||
|
EDGE_PIPE=${PIDS[-1]}
|
||||||
|
spawn edge-pipe-wrong "$BIN" --preset "edge:path=$RUN/pipe.log,from=start,to=127.0.0.1:15897,pin=$PIPE_PIN,user=pipe,password_file=/dev/fd/3" 3< <(pw another-password)
|
||||||
|
wait_until 10 eval '(( $(piped) >= 100 ))'
|
||||||
|
check "an aggregator and an edge given one password on /dev/fd/3 deliver every line ($(piped))" "$(( $(piped) == 100 ))"
|
||||||
|
wait_until 10 logged edge-pipe-wrong 'authentication failed'
|
||||||
|
check "an edge with another password is refused" "$(logged edge-pipe-wrong 'authentication failed' && echo 1 || echo 0)"
|
||||||
|
check "the descriptor, read and closed, draws no permission warning" "$(logged agg-pipe 'readable by all users' && echo 0 || echo 1)"
|
||||||
|
reloaded() { logged agg-pipe 'hot reload completed' && logged edge-pipe 'hot reload completed'; }
|
||||||
|
kill -HUP "$AGG_PIPE" "$EDGE_PIPE"
|
||||||
|
wait_until 10 reloaded
|
||||||
|
check "SIGHUP reloads both, the password kept from the first read" "$(reloaded && echo 1 || echo 0)"
|
||||||
|
seq 101 150 | sed 's/^/pipe line /' >>"$RUN/pipe.log"
|
||||||
|
wait_until 10 piped_line 150
|
||||||
|
check "a second batch is delivered after the reload" "$(piped_line 150 && echo 1 || echo 0)"
|
||||||
|
leaks=$(grep -rlF -- "$PW" "$RUN"; for p in "$AGG_PIPE" "$EDGE_PIPE"; do tr '\0' '\n' <"/proc/$p/cmdline"; tr '\0' '\n' <"/proc/$p/environ"; done | grep -F -- "$PW")
|
||||||
|
check "the password is in no file of the run directory, no argv and no environment" "$(is "$leaks" "")"
|
||||||
|
|
||||||
|
if command -v script >/dev/null && command -v setsid >/dev/null; then
|
||||||
|
setsid -w "$BIN" --check --preset aggregator:listen=127.0.0.1:15898,user=gen </dev/null >"$LOG/no-tty.out" 2>&1
|
||||||
|
check "with no terminal to ask on, a listener's user fails closed" "$(grep -q 'no terminal to ask on' "$LOG/no-tty.out" && echo 1 || echo 0)"
|
||||||
|
# script(1) is lw's terminal, what lw shows there lands in agg-gen-tty.out;
|
||||||
|
# not spawn, whose background command would read /dev/null, not the Enter
|
||||||
|
script -qefc "$(printf '%q ' "$BIN" --preset "aggregator:listen=127.0.0.1:15898,transport=http,user=gen,format=raw,out=$RUN/out/gen")2>$(printf %q "$LOG/agg-gen.out")" /dev/null \
|
||||||
|
< <(printf '\n') >"$LOG/agg-gen-tty.out" 2>&1 &
|
||||||
|
PIDS+=($!) DAEMONS+=(agg-gen-tty)
|
||||||
|
wait_until 10 logged agg-gen-tty 'generated password'
|
||||||
|
GEN=$(grep -o 'shown once: [A-Z2-7]*' "$LOG/agg-gen-tty.out" | cut -d' ' -f3)
|
||||||
|
check "Enter alone at a listener's prompt shows a generated password on its terminal" "$(is_set "$GEN")"
|
||||||
|
wait_port 15898 || abort "the generating aggregator did not listen" agg-gen-tty
|
||||||
|
GEN_PIN=$(pin agg-gen)
|
||||||
|
check "and not in lw's log or stderr" "$([[ -n $GEN ]] && ! grep -qF -- "$GEN" "$LOG/agg-gen.out" && echo 1 || echo 0)"
|
||||||
|
generated() { cat "$RUN/out/gen"/aggregate*.log 2>/dev/null | grep -c '^edge line'; }
|
||||||
|
spawn edge-gen "$BIN" --preset "edge:path=$RUN/edge.log,from=start,to=127.0.0.1:15898,transport=http,pin=$GEN_PIN,user=gen,password_file=/dev/fd/3" 3< <(pw "$GEN")
|
||||||
|
wait_until 10 eval '(( $(generated) >= 300 ))'
|
||||||
|
check "an edge given the generated password delivers every line ($(generated))" "$(( $(generated) == 300 ))"
|
||||||
|
token=$(pw "$GEN" | script -qefc "$(printf '%q ' "$BIN" auth token --url https://127.0.0.1:15898 -u gen --pin-sha256 "$GEN_PIN")" /dev/null | tr -d '\r' | tail -1)
|
||||||
|
check "lw auth token asks on the terminal without --password-file" "$([[ $token =~ ^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$ ]] && echo 1 || echo 0)"
|
||||||
|
else
|
||||||
|
skip "the terminal checks: no script(1) or setsid(1)"
|
||||||
|
fi
|
||||||
|
|
||||||
summary
|
summary
|
||||||
Reference in new issue
Block a user